Add occupancy analytics, housekeeping maintenance notes, and booking widget form flow

- DynamicPricingPage: add 'Нагрузка' tab with monthly occupancy bar chart (2025/2026 data), year selector, summary stats, and editable occupancy-based pricing rules (threshold ranges → price modifiers)
- HousekeepingPage: add maintenance note button (wrench icon) on task cards — housekeeper can report broken items with a description that gets sent to technical service; notes displayed in orange badge
- BookingWidgetPage: fix 'Забронировать' button with full form flow (browse → form → success), add configurable form fields (required/optional/custom), add additional services toggle in settings and at checkout, add extra beds + children count in guest selector with auto-pricing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 15:51:31 +03:00
parent 87455c2710
commit f62ccdd3f7
4 changed files with 817 additions and 71 deletions

View File

@@ -2,13 +2,32 @@ import { useState } from 'react'
import {
Code2, CreditCard, Globe, Palette, CalendarCheck2, Copy, CheckCheck,
ChevronLeft, ChevronRight, Star, Users, Dumbbell, Waves,
Eye, Settings2, ArrowRight,
Eye, Settings2, ArrowRight, Plus, Trash2, Check, X as XIcon,
BedDouble, Baby, ToggleLeft, ToggleRight, ChevronDown,
} from 'lucide-react'
import { cn } from '../lib/utils'
import { useModules } from '../contexts/ModulesContext'
// ── Widget settings type ───────────────────────────────────────────────────────
interface FormField {
id: string
label: string
type: 'text' | 'email' | 'phone' | 'textarea' | 'select'
required: boolean
enabled: boolean
isCustom?: boolean
}
interface AdditionalService {
id: string
name: string
icon: string
price: number
unit: string
enabled: boolean
}
interface WidgetSettings {
hotelName: string
primaryColor: string
@@ -18,6 +37,10 @@ interface WidgetSettings {
minNights: number
paymentProvider: 'yukassa' | 'tinkoff' | 'cloudpayments' | 'none'
showPromo: boolean
allowExtraBeds: boolean
allowChildren: boolean
formFields: FormField[]
additionalServices: AdditionalService[]
}
const COLORS = [
@@ -45,10 +68,21 @@ const MOCK_ROOMS = [
{ id: 'r4', name: 'Пентхаус', beds: 2, guests: 4, price: 15000, img: '🌇' },
]
const MOCK_RENTAL = [
{ id: 'o1', name: 'Теннисный корт', icon: '🎾', price: 1200, unit: 'ч' },
{ id: 'o2', name: 'Сауна', icon: '🧖', price: 2500, unit: 'ч' },
{ id: 'o3', name: 'Банкетный зал', icon: '🎪', price: 15000, unit: 'день' },
const EXTRA_BED_PRICE = 1500
const DEFAULT_FORM_FIELDS: FormField[] = [
{ id: 'name', label: 'Имя и фамилия', type: 'text', required: true, enabled: true },
{ id: 'email', label: 'Email', type: 'email', required: true, enabled: true },
{ id: 'phone', label: 'Телефон', type: 'phone', required: true, enabled: true },
{ id: 'comment', label: 'Пожелания', type: 'textarea',required: false, enabled: true },
{ id: 'arrival', label: 'Время заезда', type: 'select', required: false, enabled: false },
]
const DEFAULT_SERVICES: AdditionalService[] = [
{ id: 's1', name: 'Теннисный корт', icon: '🎾', price: 1200, unit: 'ч', enabled: true },
{ id: 's2', name: 'Сауна', icon: '🧖', price: 2500, unit: 'ч', enabled: true },
{ id: 's3', name: 'Банкетный зал', icon: '🎪', price: 15000, unit: 'день',enabled: false },
{ id: 's4', name: 'Трансфер', icon: '🚗', price: 3000, unit: 'раз', enabled: true },
]
// ── Widget Preview Component ───────────────────────────────────────────────────
@@ -58,12 +92,195 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
const [checkIn, setCheckIn] = useState('2026-03-20')
const [checkOut, setCheckOut] = useState('2026-03-22')
const [guests, setGuests] = useState(2)
const [extraBeds, setExtraBeds] = useState(0)
const [children, setChildren] = useState(0)
const [selected, setSelected] = useState<string | null>(null)
const [step, setStep] = useState<'browse' | 'form' | 'success'>('browse')
// Form state
const [formValues, setFormValues] = useState<Record<string, string>>({})
const [selectedServices, setSelectedServices] = useState<string[]>([])
const nights = checkIn && checkOut
? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
: 0
const selectedRoom = MOCK_ROOMS.find(r => r.id === selected)
const roomTotal = selectedRoom ? selectedRoom.price * Math.max(1, nights) : 0
const extraTotal = extraBeds * EXTRA_BED_PRICE * Math.max(1, nights)
const servicesTotal = selectedServices.reduce((sum, sid) => {
const s = settings.additionalServices.find(s => s.id === sid)
return sum + (s ? s.price : 0)
}, 0)
const grandTotal = roomTotal + extraTotal + servicesTotal
const activeFields = settings.formFields.filter(f => f.enabled)
const handleBook = () => {
if (!selected || nights === 0) return
setStep('form')
}
const handleSubmit = () => {
// Check required fields
const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim())
if (missing.length > 0) return
setStep('success')
}
const handleBack = () => {
setStep('browse')
setFormValues({})
setSelectedServices([])
}
if (step === 'success') {
return (
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
<div className="px-6 py-4 text-white" style={{ background: settings.primaryColor }}>
<p className="font-bold text-lg">{settings.hotelName || 'Название отеля'}</p>
</div>
<div className="p-8 text-center space-y-3">
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto" style={{ background: settings.primaryColor + '20' }}>
<Check size={32} style={{ color: settings.primaryColor }} />
</div>
<p className="text-lg font-bold text-slate-900">Бронирование принято!</p>
<p className="text-sm text-slate-500">Подтверждение придёт на email в течение нескольких минут</p>
<div className="bg-slate-50 rounded-xl p-4 text-left space-y-1">
<p className="text-xs text-slate-500">Номер: <span className="font-medium text-slate-700">{selectedRoom?.name}</span></p>
<p className="text-xs text-slate-500">Заезд: <span className="font-medium text-slate-700">{checkIn}</span></p>
<p className="text-xs text-slate-500">Выезд: <span className="font-medium text-slate-700">{checkOut}</span></p>
<p className="text-xs text-slate-500">Итого: <span className="font-bold text-slate-900">{grandTotal.toLocaleString('ru-RU')} </span></p>
</div>
<button
onClick={() => { setStep('browse'); setSelected(null); setFormValues({}) }}
className="text-sm font-medium hover:underline"
style={{ color: settings.primaryColor }}
>
Новое бронирование
</button>
</div>
</div>
)
}
if (step === 'form') {
return (
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
<div className="px-6 py-4 text-white flex items-center gap-3" style={{ background: settings.primaryColor }}>
<button onClick={handleBack} className="opacity-80 hover:opacity-100">
<ChevronLeft size={18} />
</button>
<div>
<p className="font-bold">{settings.language === 'ru' ? 'Данные гостя' : 'Guest details'}</p>
<p className="text-xs opacity-80">{selectedRoom?.name} · {nights} ноч.</p>
</div>
</div>
<div className="p-5 space-y-3 max-h-[500px] overflow-y-auto">
{/* Summary */}
<div className="bg-slate-50 rounded-xl p-3 space-y-1">
<div className="flex justify-between text-xs">
<span className="text-slate-500">{selectedRoom?.name} × {nights} ноч.</span>
<span className="font-medium">{roomTotal.toLocaleString('ru-RU')} </span>
</div>
{extraBeds > 0 && (
<div className="flex justify-between text-xs">
<span className="text-slate-500">Доп. места × {extraBeds}</span>
<span className="font-medium">{extraTotal.toLocaleString('ru-RU')} </span>
</div>
)}
{selectedServices.map(sid => {
const s = settings.additionalServices.find(s => s.id === sid)
return s ? (
<div key={sid} className="flex justify-between text-xs">
<span className="text-slate-500">{s.icon} {s.name}</span>
<span className="font-medium">{s.price.toLocaleString('ru-RU')} </span>
</div>
) : null
})}
<div className="flex justify-between text-sm font-bold pt-1 border-t border-slate-200">
<span>Итого</span>
<span>{grandTotal.toLocaleString('ru-RU')} </span>
</div>
</div>
{/* Additional services */}
{settings.additionalServices.filter(s => s.enabled).length > 0 && (
<div>
<p className="text-xs font-medium text-slate-700 mb-1.5">
{settings.language === 'ru' ? 'Дополнительные услуги' : 'Additional services'}
</p>
<div className="space-y-1.5">
{settings.additionalServices.filter(s => s.enabled).map(s => (
<label key={s.id} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedServices.includes(s.id)}
onChange={e => setSelectedServices(prev =>
e.target.checked ? [...prev, s.id] : prev.filter(x => x !== s.id)
)}
className="rounded text-sm"
/>
<span className="text-sm text-slate-700 flex-1">{s.icon} {s.name}</span>
<span className="text-xs text-slate-500">{s.price.toLocaleString('ru-RU')} /{s.unit}</span>
</label>
))}
</div>
</div>
)}
{/* Form fields */}
{activeFields.map(field => (
<div key={field.id}>
<label className="block text-xs text-slate-500 mb-1">
{field.label}{field.required && <span className="text-red-500 ml-0.5">*</span>}
</label>
{field.type === 'textarea' ? (
<textarea
rows={2}
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none resize-none"
style={{ '--focus-color': settings.primaryColor } as any}
value={formValues[field.id] ?? ''}
onChange={e => setFormValues(prev => ({ ...prev, [field.id]: e.target.value }))}
/>
) : field.type === 'select' ? (
<select
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none bg-white"
value={formValues[field.id] ?? ''}
onChange={e => setFormValues(prev => ({ ...prev, [field.id]: e.target.value }))}
>
<option value="">Выбрать...</option>
<option>До 12:00</option>
<option>12:0014:00</option>
<option>14:0016:00</option>
<option>После 16:00</option>
</select>
) : (
<input
type={field.type}
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none"
value={formValues[field.id] ?? ''}
onChange={e => setFormValues(prev => ({ ...prev, [field.id]: e.target.value }))}
/>
)}
</div>
))}
</div>
<div className="px-5 pb-5 pt-2">
<button
onClick={handleSubmit}
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90"
style={{ background: settings.primaryColor }}
>
{settings.language === 'ru' ? `Подтвердить бронирование · ${grandTotal.toLocaleString('ru-RU')}` : `Confirm booking · ${grandTotal.toLocaleString('ru-RU')}`}
</button>
</div>
</div>
)
}
return (
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
{/* Widget header */}
@@ -101,7 +318,7 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
{/* Search bar */}
{previewTab === 'rooms' && (
<div className="px-5 py-4 bg-slate-50 border-b border-slate-200">
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<label className="block text-xs text-slate-500 mb-1">
{settings.language === 'ru' ? 'Заезд' : 'Check-in'}
@@ -111,7 +328,6 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
value={checkIn}
onChange={e => setCheckIn(e.target.value)}
className="w-full text-sm border border-slate-200 rounded-lg px-2.5 py-1.5 bg-white focus:outline-none"
style={{ '--focus-color': settings.primaryColor } as any}
/>
</div>
<div>
@@ -125,42 +341,62 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
className="w-full text-sm border border-slate-200 rounded-lg px-2.5 py-1.5 bg-white focus:outline-none"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<label className="block text-xs text-slate-500 mb-1">
{settings.language === 'ru' ? 'Гостей' : 'Guests'}
{settings.language === 'ru' ? 'Взрослых' : 'Adults'}
</label>
<div className="flex items-center gap-1">
<button
onClick={() => setGuests(g => Math.max(1, g - 1))}
className="w-7 h-7 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700"
>
<ChevronLeft size={14} />
</button>
<button onClick={() => setGuests(g => Math.max(1, g - 1))} className="w-6 h-6 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700 text-xs"></button>
<span className="flex-1 text-center text-sm font-medium">{guests}</span>
<button
onClick={() => setGuests(g => Math.min(8, g + 1))}
className="w-7 h-7 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700"
>
<ChevronRight size={14} />
</button>
<button onClick={() => setGuests(g => Math.min(8, g + 1))} className="w-6 h-6 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700 text-xs">+</button>
</div>
</div>
{settings.allowChildren && (
<div>
<label className="block text-xs text-slate-500 mb-1">
{settings.language === 'ru' ? 'Детей' : 'Children'}
</label>
<div className="flex items-center gap-1">
<button onClick={() => setChildren(c => Math.max(0, c - 1))} className="w-6 h-6 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700 text-xs"></button>
<span className="flex-1 text-center text-sm font-medium">{children}</span>
<button onClick={() => setChildren(c => Math.min(4, c + 1))} className="w-6 h-6 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700 text-xs">+</button>
</div>
</div>
)}
{settings.allowExtraBeds && (
<div>
<label className="block text-xs text-slate-500 mb-1">
{settings.language === 'ru' ? 'Доп. мест' : 'Extra beds'}
</label>
<div className="flex items-center gap-1">
<button onClick={() => setExtraBeds(b => Math.max(0, b - 1))} className="w-6 h-6 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700 text-xs"></button>
<span className="flex-1 text-center text-sm font-medium">{extraBeds}</span>
<button onClick={() => setExtraBeds(b => Math.min(3, b + 1))} className="w-6 h-6 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700 text-xs">+</button>
</div>
{extraBeds > 0 && (
<p className="text-[10px] text-slate-400 text-center">{(extraBeds * EXTRA_BED_PRICE).toLocaleString('ru-RU')} /ноч</p>
)}
</div>
)}
</div>
</div>
)}
{/* Rooms list */}
{previewTab === 'rooms' && (
<div className="p-4 space-y-3 max-h-80 overflow-y-auto">
<div className="p-4 space-y-3 max-h-72 overflow-y-auto">
{nights > 0 && (
<p className="text-xs text-slate-500">
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} · {guests} {settings.language === 'ru' ? 'гостя' : 'guests'}
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} · {guests} {settings.language === 'ru' ? 'гостей' : 'guests'}
{children > 0 && ` · ${children} дет.`}
</p>
)}
{MOCK_ROOMS.map(room => (
<div
key={room.id}
onClick={() => setSelected(room.id)}
onClick={() => setSelected(room.id === selected ? null : room.id)}
className={cn(
'flex items-center gap-4 p-3 rounded-xl border-2 cursor-pointer transition-all',
selected === room.id ? 'border-2' : 'border-slate-200 hover:border-slate-300',
@@ -201,10 +437,10 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
{previewTab === 'rental' && (
<div className="p-4 space-y-3">
<p className="text-xs text-slate-500">Выберите объект и удобное время</p>
{MOCK_RENTAL.map(obj => (
{settings.additionalServices.filter(s => s.enabled).map(obj => (
<div
key={obj.id}
onClick={() => setSelected(obj.id)}
onClick={() => setSelected(obj.id === selected ? null : obj.id)}
className={cn(
'flex items-center gap-4 p-3 rounded-xl border-2 cursor-pointer transition-all',
selected === obj.id ? '' : 'border-slate-200 hover:border-slate-300',
@@ -229,15 +465,23 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
{/* CTA */}
<div className="px-5 pb-5">
<button
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90"
onClick={handleBook}
disabled={!selected || (previewTab === 'rooms' && nights === 0)}
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: settings.primaryColor }}
>
{settings.language === 'ru' ? 'Забронировать' : 'Book now'}
{selected && nights > 0 && previewTab === 'rooms' && (() => {
const r = MOCK_ROOMS.find(r => r.id === selected)
return r ? ` · ${(r.price * nights).toLocaleString('ru-RU')}` : ''
const total = r ? r.price * nights + extraBeds * EXTRA_BED_PRICE * nights : 0
return total ? ` · ${total.toLocaleString('ru-RU')}` : ''
})()}
</button>
{(!selected || (previewTab === 'rooms' && nights === 0)) && (
<p className="text-center text-xs text-slate-400 mt-1.5">
{nights === 0 ? 'Выберите даты заезда и выезда' : 'Выберите номер для бронирования'}
</p>
)}
{settings.showPromo && (
<p className="text-center text-xs text-slate-400 mt-2">
{settings.language === 'ru' ? 'Лучшая цена гарантирована' : 'Best rate guaranteed'}
@@ -263,13 +507,58 @@ export function BookingWidgetPage() {
minNights: 1,
paymentProvider: 'yukassa',
showPromo: true,
allowExtraBeds: true,
allowChildren: true,
formFields: DEFAULT_FORM_FIELDS,
additionalServices: DEFAULT_SERVICES,
})
const [copied, setCopied] = useState(false)
const [pageTab, setPageTab] = useState<'constructor' | 'code' | 'stats'>('constructor')
const [newFieldLabel, setNewFieldLabel] = useState('')
const [showAddField, setShowAddField] = useState(false)
const set = <K extends keyof WidgetSettings>(k: K, v: WidgetSettings[K]) =>
setSettings(prev => ({ ...prev, [k]: v }))
const toggleService = (id: string) =>
setSettings(prev => ({
...prev,
additionalServices: prev.additionalServices.map(s =>
s.id === id ? { ...s, enabled: !s.enabled } : s
),
}))
const toggleField = (id: string, prop: 'enabled' | 'required') =>
setSettings(prev => ({
...prev,
formFields: prev.formFields.map(f =>
f.id === id ? { ...f, [prop]: !f[prop] } : f
),
}))
const addCustomField = () => {
if (!newFieldLabel.trim()) return
setSettings(prev => ({
...prev,
formFields: [...prev.formFields, {
id: `custom-${Date.now()}`,
label: newFieldLabel.trim(),
type: 'text',
required: false,
enabled: true,
isCustom: true,
}],
}))
setNewFieldLabel('')
setShowAddField(false)
}
const removeCustomField = (id: string) =>
setSettings(prev => ({
...prev,
formFields: prev.formFields.filter(f => f.id !== id),
}))
const snippet = `<script src="https://widget.hotelsync.ru/v1/embed.js"
data-hotel="grand-palace"
data-color="${settings.primaryColor}"
@@ -325,9 +614,10 @@ export function BookingWidgetPage() {
<div className="grid lg:grid-cols-2 gap-6">
{/* Settings panel */}
<div className="space-y-4">
{/* Appearance */}
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Внешний вид</h3>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Название отеля в виджете
@@ -340,7 +630,6 @@ export function BookingWidgetPage() {
placeholder="Grand Palace Hotel"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Основной цвет
@@ -369,7 +658,6 @@ export function BookingWidgetPage() {
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Язык</label>
<div className="flex gap-2">
@@ -391,67 +679,147 @@ export function BookingWidgetPage() {
</div>
</div>
{/* What to show */}
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Что показывать</h3>
<div className="space-y-2.5">
<label className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<CalendarCheck2 size={15} className="text-indigo-500" />
Бронирование номеров
</div>
<input
type="checkbox"
checked={settings.showRooms}
onChange={e => set('showRooms', e.target.checked)}
className="rounded"
/>
<input type="checkbox" checked={settings.showRooms} onChange={e => set('showRooms', e.target.checked)} className="rounded" />
</label>
{rentalActive && (
<label className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<Dumbbell size={15} className="text-emerald-500" />
Аренда объектов (корт, сауна и т.д.)
Аренда объектов
</div>
<input
type="checkbox"
checked={settings.showRental}
onChange={e => set('showRental', e.target.checked)}
className="rounded"
/>
<input type="checkbox" checked={settings.showRental} onChange={e => set('showRental', e.target.checked)} className="rounded" />
</label>
)}
<label className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<Star size={15} className="text-amber-500" />
Плашка "Лучшая цена гарантирована"
</div>
<input
type="checkbox"
checked={settings.showPromo}
onChange={e => set('showPromo', e.target.checked)}
className="rounded"
/>
<input type="checkbox" checked={settings.showPromo} onChange={e => set('showPromo', e.target.checked)} className="rounded" />
</label>
<label className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<BedDouble size={15} className="text-orange-500" />
Дополнительные места (1 500 /ночь)
</div>
<input type="checkbox" checked={settings.allowExtraBeds} onChange={e => set('allowExtraBeds', e.target.checked)} className="rounded" />
</label>
<label className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<Baby size={15} className="text-pink-500" />
Количество детей
</div>
<input type="checkbox" checked={settings.allowChildren} onChange={e => set('allowChildren', e.target.checked)} className="rounded" />
</label>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Минимальное кол-во ночей
</label>
<input
type="number"
min={1}
max={30}
className="input w-24"
type="number" min={1} max={30} className="input w-24"
value={settings.minNights}
onChange={e => set('minNights', Math.max(1, parseInt(e.target.value) || 1))}
/>
</div>
</div>
{/* Additional services */}
<div className="card p-5 space-y-3">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Дополнительные услуги</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
Услуги, доступные для добавления при бронировании
</p>
<div className="space-y-2">
{settings.additionalServices.map(s => (
<div key={s.id} className="flex items-center gap-2">
<span className="text-base">{s.icon}</span>
<span className="text-sm text-slate-700 dark:text-slate-300 flex-1">{s.name}</span>
<span className="text-xs text-slate-400">{s.price.toLocaleString('ru-RU')} /{s.unit}</span>
<button
onClick={() => toggleService(s.id)}
className={cn('p-1 rounded-lg transition-colors', s.enabled ? 'text-brand-600' : 'text-slate-400')}
>
{s.enabled ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
</button>
</div>
))}
</div>
</div>
{/* Form fields */}
<div className="card p-5 space-y-3">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Поля формы бронирования</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
Настройте какие поля показывать гостю при бронировании
</p>
<div className="space-y-2">
{settings.formFields.map(field => (
<div key={field.id} className={cn('flex items-center gap-2 p-2 rounded-lg', field.enabled ? '' : 'opacity-50')}>
<div className="flex-1 min-w-0">
<p className="text-sm text-slate-700 dark:text-slate-300">{field.label}</p>
</div>
<button
onClick={() => toggleField(field.id, 'required')}
disabled={!field.enabled}
className={cn(
'text-[10px] px-1.5 py-0.5 rounded font-medium border transition-colors',
field.required && field.enabled
? 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800'
: 'bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-700 dark:text-slate-400 dark:border-slate-600',
)}
>
{field.required ? 'обяз.' : 'необяз.'}
</button>
<button
onClick={() => toggleField(field.id, 'enabled')}
className={cn('p-1 rounded transition-colors', field.enabled ? 'text-brand-600' : 'text-slate-400')}
>
{field.enabled ? <ToggleRight size={18} /> : <ToggleLeft size={18} />}
</button>
{field.isCustom && (
<button onClick={() => removeCustomField(field.id)} className="p-1 text-slate-400 hover:text-red-500 transition-colors">
<Trash2 size={13} />
</button>
)}
</div>
))}
</div>
{showAddField ? (
<div className="flex gap-2">
<input
autoFocus
type="text"
className="input flex-1 text-sm"
placeholder="Название поля"
value={newFieldLabel}
onChange={e => setNewFieldLabel(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addCustomField(); if (e.key === 'Escape') setShowAddField(false) }}
/>
<button onClick={addCustomField} disabled={!newFieldLabel.trim()} className="btn-primary px-3 disabled:opacity-50">
<Check size={14} />
</button>
<button onClick={() => setShowAddField(false)} className="btn-secondary px-3">
<XIcon size={14} />
</button>
</div>
) : (
<button onClick={() => setShowAddField(true)} className="flex items-center gap-1.5 text-xs text-brand-600 dark:text-brand-400 hover:underline">
<Plus size={12} /> Добавить поле
</button>
)}
</div>
{/* Payment */}
<div className="card p-5 space-y-3">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<CreditCard size={16} className="text-emerald-600" />
@@ -569,10 +937,7 @@ export function BookingWidgetPage() {
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Разместите виджет на сайте и подключите к нему первые бронирования
</p>
<button
onClick={() => setPageTab('code')}
className="btn-primary mt-4"
>
<button onClick={() => setPageTab('code')} className="btn-primary mt-4">
<ArrowRight size={14} />
Получить код установки
</button>