Files
hotelsync/src/pages/BookingWidgetPage.tsx

1649 lines
82 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import {
Code2, CreditCard, Globe, CalendarCheck2, Copy, CheckCheck, Save,
ChevronLeft, ChevronRight, Star, Users, Dumbbell, Waves,
Eye, Settings2, ArrowRight, Plus, Trash2, Check, X as XIcon,
BedDouble, Baby, ToggleLeft, ToggleRight, ChevronDown, ChevronUp,
Maximize2, Wifi, Tv2, Wind, Coffee, Bath, Mountain, Shirt, Shield,
Scan, Image, Zap, AlertCircle, Loader2,
} from 'lucide-react'
import { cn } from '../lib/utils'
import { useModules } from '../contexts/ModulesContext'
import { useAuth } from '../contexts/AuthContext'
import { api, type WidgetRoom, type WidgetCategory, type PaymentGateway } from '../lib/api'
// ── Widget settings type ───────────────────────────────────────────────────────
export interface FormField {
id: string
label: string
type: 'text' | 'email' | 'phone' | 'textarea' | 'select'
required: boolean
enabled: boolean
isCustom?: boolean
}
export interface AdditionalService {
id: string
name: string
icon: string
price: number
unit: string
enabled: boolean
}
export interface WidgetSettings {
hotelName: string
primaryColor: string
language: 'ru' | 'en'
showRooms: boolean
showRental: boolean
roomDisplayMode: 'rooms' | 'categories'
minNights: number
paymentProvider: 'yukassa' | 'tinkoff' | 'cloudpayments' | 'none'
showPromo: boolean
allowExtraBeds: boolean
allowChildren: boolean
formFields: FormField[]
additionalServices: AdditionalService[]
}
const COLORS = [
{ label: 'Индиго', value: '#4F46E5' },
{ label: 'Изумруд', value: '#059669' },
{ label: 'Синий', value: '#2563EB' },
{ label: 'Фиолетовый',value: '#7C3AED' },
{ label: 'Красный', value: '#DC2626' },
{ label: 'Янтарный', value: '#D97706' },
{ label: 'Розовый', value: '#DB2777' },
{ label: 'Серый', value: '#475569' },
]
const PAYMENT_PROVIDERS = [
{ value: 'yukassa', label: 'ЮKassa' },
{ value: 'tinkoff', label: 'Тинькофф Pay' },
{ value: 'cloudpayments',label: 'CloudPayments' },
{ value: 'none', label: 'Без оплаты' },
]
interface MockRoom {
id: string
name: string
beds: number
guests: number
price: number
area: number // m²
description: string
photos: { bg: string; emoji: string; label: string }[]
amenities: string[]
has3dTour: boolean
}
const MOCK_ROOMS: MockRoom[] = [
{
id: 'r1', name: 'Стандарт', beds: 1, guests: 2, price: 4500, area: 22,
description: 'Уютный номер с двуспальной кроватью, современным ремонтом и всем необходимым для комфортного проживания. Вид во двор.',
photos: [
{ bg: 'from-slate-200 to-slate-300', emoji: '🛏️', label: 'Спальня' },
{ bg: 'from-sky-100 to-sky-200', emoji: '🚿', label: 'Ванная' },
{ bg: 'from-amber-100 to-amber-200', emoji: '🪟', label: 'Окно' },
],
amenities: ['Wi-Fi', 'TV', 'Кондиционер', 'Фен', 'Мини-холодильник', 'Сейф'],
has3dTour: false,
},
{
id: 'r2', name: 'Комфорт', beds: 1, guests: 2, price: 6500, area: 30,
description: 'Просторный номер с зоной отдыха, большой кроватью и улучшенной ванной комнатой. Вид на парк.',
photos: [
{ bg: 'from-emerald-100 to-emerald-200', emoji: '🛏️', label: 'Спальня' },
{ bg: 'from-teal-100 to-teal-200', emoji: '🛁', label: 'Ванная' },
{ bg: 'from-green-100 to-green-200', emoji: '🌳', label: 'Вид из окна' },
{ bg: 'from-lime-100 to-lime-200', emoji: '🛋️', label: 'Зона отдыха' },
],
amenities: ['Wi-Fi', 'TV', 'Кондиционер', 'Мини-бар', 'Халат', 'Фен', 'Сейф', 'Кофемашина'],
has3dTour: false,
},
{
id: 'r3', name: 'Делюкс', beds: 2, guests: 3, price: 8900, area: 45,
description: 'Роскошный двухкомнатный номер с панорамными окнами. Отдельная гостиная, большая ванная с джакузи. Вид на город.',
photos: [
{ bg: 'from-violet-100 to-violet-200', emoji: '🛏️', label: 'Спальня' },
{ bg: 'from-purple-100 to-purple-200', emoji: '🛁', label: 'Джакузи' },
{ bg: 'from-indigo-100 to-indigo-200', emoji: '🌆', label: 'Вид на город' },
{ bg: 'from-blue-100 to-blue-200', emoji: '🛋️', label: 'Гостиная' },
{ bg: 'from-cyan-100 to-cyan-200', emoji: '🍽️', label: 'Мини-кухня' },
],
amenities: ['Wi-Fi', 'Smart TV', 'Кондиционер', 'Джакузи', 'Мини-бар', 'Халат', 'Тапочки', 'Кофемашина', 'Сейф', 'Балкон'],
has3dTour: true,
},
{
id: 'r4', name: 'Пентхаус', beds: 2, guests: 4, price: 15000, area: 80,
description: 'Эксклюзивный пентхаус на верхнем этаже с панорамной террасой и видом 360°. Собственная сауна, бар, гостиная класса люкс.',
photos: [
{ bg: 'from-amber-200 to-orange-200', emoji: '🌇', label: 'Панорама' },
{ bg: 'from-yellow-100 to-amber-200', emoji: '🛏️', label: 'Мастер-спальня' },
{ bg: 'from-orange-100 to-red-100', emoji: '🧖', label: 'Сауна' },
{ bg: 'from-rose-100 to-pink-100', emoji: '🛋️', label: 'Гостиная' },
{ bg: 'from-red-100 to-orange-100', emoji: '🌅', label: 'Терраса' },
{ bg: 'from-amber-100 to-yellow-100', emoji: '🍸', label: 'Бар' },
],
amenities: ['Wi-Fi 1Гбит', 'Smart TV 75"', 'Кондиционер', 'Сауна', 'Мини-бар', 'Джакузи', 'Терраса', 'Дворецкий', 'Кофемашина', 'Халат', 'Тапочки', 'Сейф'],
has3dTour: true,
},
]
interface MockCategory {
id: string
name: string
minPrice: number
maxGuests: number
amenities: string[]
description: string
availableCount: number
}
const MOCK_CATEGORIES: MockCategory[] = [
{ id: 'c1', name: 'Стандарт', minPrice: 4500, maxGuests: 2, availableCount: 3,
amenities: ['Wi-Fi', 'TV', 'Кондиционер', 'Фен'],
description: 'Уютные номера с современным ремонтом и всем необходимым для комфортного проживания.' },
{ id: 'c2', name: 'Комфорт', minPrice: 6500, maxGuests: 2, availableCount: 2,
amenities: ['Wi-Fi', 'TV', 'Кондиционер', 'Мини-бар', 'Кофемашина'],
description: 'Просторные номера с расширенными удобствами и зоной отдыха.' },
{ id: 'c3', name: 'Делюкс', minPrice: 8900, maxGuests: 3, availableCount: 1,
amenities: ['Wi-Fi', 'Smart TV', 'Кондиционер', 'Джакузи', 'Балкон'],
description: 'Роскошные номера с панорамными окнами и собственным джакузи.' },
{ id: 'c4', name: 'Пентхаус', minPrice: 15000, maxGuests: 4, availableCount: 1,
amenities: ['Wi-Fi 1Гбит', 'Smart TV 75"', 'Сауна', 'Терраса', 'Дворецкий'],
description: 'Эксклюзивный пентхаус на верхнем этаже с террасой и видом 360°.' },
]
const AMENITY_ICONS: Record<string, React.ElementType> = {
'Wi-Fi': Wifi, 'Wi-Fi 1Гбит': Wifi,
'TV': Tv2, 'Smart TV': Tv2, 'Smart TV 75"': Tv2,
'Кондиционер': Wind,
'Кофемашина': Coffee,
'Халат': Shirt, 'Тапочки': Shirt,
'Джакузи': Bath, 'Ванная': Bath,
'Балкон': Mountain, 'Терраса': Mountain,
'Сейф': Shield,
}
const EXTRA_BED_PRICE = 1500
export 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 },
]
export 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 ───────────────────────────────────────────────────
export function WidgetPreview({ settings, slug, realRooms, realCategories, paymentEnabled }: {
settings: WidgetSettings
slug?: string
realRooms?: WidgetRoom[]
realCategories?: WidgetCategory[]
paymentEnabled?: boolean
}) {
const [previewTab, setPreviewTab] = useState<'rooms' | 'rental'>('rooms')
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 [expandedRoom, setExpandedRoom] = useState<string | null>(null)
const [photoIndex, setPhotoIndex] = useState<Record<string, number>>({})
const [step, setStep] = useState<'browse' | 'form' | 'payment' | 'success'>('browse')
const [submitting, setSubmitting] = useState(false)
const [confirmUrl, setConfirmUrl] = useState<string | null>(null)
// Form state
const [formValues, setFormValues] = useState<Record<string, string>>({})
const [selectedServices, setSelectedServices] = useState<string[]>([])
// Hourly service time selections: { serviceId: { date, timeFrom, timeTo } }
const [serviceSchedule, setServiceSchedule] = useState<Record<string, { date: string; timeFrom: string; timeTo: string }>>({})
// Payment state (kept for mock display)
const [cardNumber, setCardNumber] = useState('')
const [cardExpiry, setCardExpiry] = useState('')
const [cardCvv, setCardCvv] = useState('')
const [cardName, setCardName] = useState('')
const nights = checkIn && checkOut
? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
: 0
// Use real rooms if available, otherwise mock
const displayRooms = realRooms && realRooms.length > 0
? realRooms.map(r => ({
id: r.id, name: r.name || `Номер ${r.number}`, beds: 1,
guests: r.maxGuests, price: r.baseRate, area: 0,
description: r.description, photos: [], amenities: r.amenities,
has3dTour: false,
}))
: MOCK_ROOMS
// Categories mode
const displayCategories: Array<{ id: string; name: string; minPrice: number; maxGuests: number; amenities: string[]; description: string; availableCount: number }> =
realCategories && realCategories.length > 0
? realCategories.map(c => ({
...c,
availableCount: realRooms ? realRooms.filter(r => r.categoryId === c.id).length : 0,
}))
: MOCK_CATEGORIES
const isCategoryMode = settings.roomDisplayMode === 'categories'
const selectedRoom = !isCategoryMode ? displayRooms.find(r => r.id === selected) : undefined
const selectedCategory = isCategoryMode ? displayCategories.find(c => c.id === selected) : undefined
const selectedName = selectedRoom?.name ?? selectedCategory?.name
const roomTotal = (selectedRoom ? selectedRoom.price : (selectedCategory ? selectedCategory.minPrice : 0)) * Math.max(1, nights)
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 needsPayment = paymentEnabled ?? (settings.paymentProvider !== 'none')
const handleBook = () => {
if (!selected || nights === 0) return
setStep('form')
}
const handleSubmit = async () => {
const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim())
if (missing.length > 0) return
if (slug && selected) {
// Real API call
setSubmitting(true)
try {
const services = selectedServices
.map(sid => settings.additionalServices.find(s => s.id === sid))
.filter(Boolean)
.map(s => ({ name: s!.name, price: s!.price }))
const result = await api.widget.createBooking(slug, {
...(isCategoryMode ? { categoryId: selected! } : { roomId: selected! }),
checkIn, checkOut,
guestName: formValues['name'] ?? formValues['full_name'] ?? 'Гость',
guestEmail: formValues['email'] ?? undefined,
guestPhone: formValues['phone'] ?? undefined,
adults: guests, children,
totalAmount: grandTotal,
notes: formValues['notes'] ?? formValues['comment'] ?? undefined,
services,
})
if (result.confirmationUrl) {
setConfirmUrl(result.confirmationUrl)
setStep('payment')
} else {
setStep('success')
}
} catch {
// fallback — still show success in preview
setStep('success')
} finally {
setSubmitting(false)
}
} else {
// Preview mode without real slug
if (needsPayment) {
setStep('payment')
} else {
setStep('success')
}
}
}
const handlePay = () => {
if (confirmUrl) {
window.open(confirmUrl, '_blank')
}
setStep('success')
}
const handleBack = () => {
if (step === 'payment') { setStep('form'); return }
setStep('browse')
setFormValues({})
setSelectedServices([])
setServiceSchedule({})
setCardNumber(''); setCardExpiry(''); setCardCvv(''); setCardName('')
}
const formatCard = (v: string) => v.replace(/\D/g, '').slice(0, 16).replace(/(.{4})/g, '$1 ').trim()
const formatExpiry = (v: string) => {
const d = v.replace(/\D/g, '').slice(0, 4)
return d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d
}
// ── Success screen ──
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">
{needsPayment
? 'Оплата прошла успешно. Подтверждение придёт на email.'
: 'Оплата на месте при заезде. Подтверждение придёт на email.'}
</p>
<div className="bg-slate-50 rounded-xl p-4 text-left space-y-1">
<p className="text-xs text-slate-500">{isCategoryMode ? 'Категория' : 'Номер'}: <span className="font-medium text-slate-700">{selectedName}</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>
)
}
// ── Payment screen ──
if (step === 'payment') {
const payLabel = settings.paymentProvider === 'yukassa' ? 'ЮKassa'
: settings.paymentProvider === 'tinkoff' ? 'Тинькофф Pay'
: 'CloudPayments'
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' ? 'Оплата' : 'Payment'}</p>
<p className="text-xs opacity-80">{payLabel} · {grandTotal.toLocaleString('ru-RU')} </p>
</div>
</div>
<div className="p-5 space-y-4">
{/* Amount summary */}
<div className="bg-slate-50 rounded-xl p-3 flex items-center justify-between">
<span className="text-sm text-slate-600">{selectedName} · {nights} ноч.</span>
<span className="text-base font-bold text-slate-900">{grandTotal.toLocaleString('ru-RU')} </span>
</div>
{/* Card form */}
<div className="space-y-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Номер карты</label>
<input
type="text"
inputMode="numeric"
placeholder="0000 0000 0000 0000"
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none font-mono tracking-wider"
value={cardNumber}
onChange={e => setCardNumber(formatCard(e.target.value))}
maxLength={19}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Срок действия</label>
<input
type="text"
inputMode="numeric"
placeholder="ММ/ГГ"
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none font-mono"
value={cardExpiry}
onChange={e => setCardExpiry(formatExpiry(e.target.value))}
maxLength={5}
/>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">CVV</label>
<input
type="password"
inputMode="numeric"
placeholder="•••"
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none font-mono"
value={cardCvv}
onChange={e => setCardCvv(e.target.value.replace(/\D/g, '').slice(0, 3))}
maxLength={3}
/>
</div>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Имя на карте</label>
<input
type="text"
placeholder="IVAN PETROV"
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none uppercase"
value={cardName}
onChange={e => setCardName(e.target.value.toUpperCase())}
/>
</div>
</div>
<p className="text-[11px] text-slate-400 text-center flex items-center justify-center gap-1">
🔒 Платёж защищён 3-D Secure · {payLabel}
</p>
</div>
<div className="px-5 pb-5">
<button
onClick={handlePay}
disabled={cardNumber.length < 19 || cardExpiry.length < 5 || cardCvv.length < 3 || !cardName.trim()}
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' ? `Оплатить ${grandTotal.toLocaleString('ru-RU')}` : `Pay ${grandTotal.toLocaleString('ru-RU')}`}
</button>
</div>
</div>
)
}
// ── Guest form screen ──
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">{selectedName} · {nights} ноч.</p>
</div>
{/* Step indicator */}
<div className="ml-auto flex items-center gap-1">
{[1, 2, needsPayment ? 3 : null].filter(Boolean).map((s, i) => (
<div key={i} className={cn('w-2 h-2 rounded-full', step === 'form' && s === 1 ? 'bg-white' : 'bg-white/40')} />
))}
<div className={cn('w-2 h-2 rounded-full', step === 'form' ? 'bg-white' : 'bg-white/40')} />
{needsPayment && <div className="w-2 h-2 rounded-full bg-white/40" />}
</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">{selectedName} × {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-2">
{settings.additionalServices.filter(s => s.enabled).map(s => {
const isHourly = s.unit === 'ч'
const schedule = serviceSchedule[s.id]
const isSelected = selectedServices.includes(s.id)
return (
<div key={s.id} className="space-y-1.5">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isSelected}
onChange={e => {
setSelectedServices(prev =>
e.target.checked ? [...prev, s.id] : prev.filter(x => x !== s.id)
)
if (!e.target.checked) {
setServiceSchedule(prev => { const n = {...prev}; delete n[s.id]; return n })
}
}}
className="rounded"
/>
<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>
{/* Hourly time picker */}
{isSelected && isHourly && (
<div className="ml-5 grid grid-cols-3 gap-2 p-2 bg-slate-50 rounded-lg border border-slate-200">
<div>
<label className="block text-[10px] text-slate-500 mb-0.5">Дата</label>
<input
type="date"
className="w-full text-xs border border-slate-200 rounded px-1.5 py-1 bg-white focus:outline-none"
value={schedule?.date ?? checkIn}
onChange={e => setServiceSchedule(prev => ({ ...prev, [s.id]: { ...prev[s.id], date: e.target.value, timeFrom: prev[s.id]?.timeFrom ?? '10:00', timeTo: prev[s.id]?.timeTo ?? '12:00' } }))}
/>
</div>
<div>
<label className="block text-[10px] text-slate-500 mb-0.5">С</label>
<select
className="w-full text-xs border border-slate-200 rounded px-1.5 py-1 bg-white focus:outline-none"
value={schedule?.timeFrom ?? '10:00'}
onChange={e => setServiceSchedule(prev => ({ ...prev, [s.id]: { ...prev[s.id], date: prev[s.id]?.date ?? checkIn, timeFrom: e.target.value, timeTo: prev[s.id]?.timeTo ?? '12:00' } }))}
>
{Array.from({ length: 17 }, (_, i) => i + 7).map(h => (
<option key={h} value={`${String(h).padStart(2,'0')}:00`}>{String(h).padStart(2,'0')}:00</option>
))}
</select>
</div>
<div>
<label className="block text-[10px] text-slate-500 mb-0.5">По</label>
<select
className="w-full text-xs border border-slate-200 rounded px-1.5 py-1 bg-white focus:outline-none"
value={schedule?.timeTo ?? '12:00'}
onChange={e => setServiceSchedule(prev => ({ ...prev, [s.id]: { ...prev[s.id], date: prev[s.id]?.date ?? checkIn, timeFrom: prev[s.id]?.timeFrom ?? '10:00', timeTo: e.target.value } }))}
>
{Array.from({ length: 17 }, (_, i) => i + 8).map(h => (
<option key={h} value={`${String(h).padStart(2,'0')}:00`}>{String(h).padStart(2,'0')}:00</option>
))}
</select>
</div>
</div>
)}
</div>
)
})}
</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"
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 space-y-2">
<button
onClick={handleSubmit}
disabled={submitting}
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90 disabled:opacity-70 flex items-center justify-center gap-2"
style={{ background: settings.primaryColor }}
>
{submitting && <Loader2 size={14} className="animate-spin" />}
{submitting ? 'Отправляем...' : needsPayment
? (settings.language === 'ru' ? `Перейти к оплате · ${grandTotal.toLocaleString('ru-RU')}` : `Proceed to payment · ${grandTotal.toLocaleString('ru-RU')}`)
: (settings.language === 'ru' ? `Подтвердить · ${grandTotal.toLocaleString('ru-RU')}` : `Confirm · ${grandTotal.toLocaleString('ru-RU')}`)}
</button>
{!needsPayment && (
<p className="text-center text-xs text-slate-400">Оплата на месте при заезде</p>
)}
</div>
</div>
)
}
return (
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
{/* Widget header */}
<div className="px-6 py-4 text-white" style={{ background: settings.primaryColor }}>
<p className="font-bold text-lg">{settings.hotelName || 'Название отеля'}</p>
<p className="text-sm opacity-80">
{settings.language === 'ru' ? 'Бронирование онлайн · Без комиссии' : 'Book directly · No fees'}
</p>
</div>
{/* Tabs */}
{settings.showRooms && settings.showRental && (
<div className="flex border-b border-slate-200">
{[
['rooms', settings.language === 'ru' ? 'Номера' : 'Rooms'],
['rental', settings.language === 'ru' ? 'Аренда' : 'Rental'],
].map(([key, label]) => (
<button
key={key}
onClick={() => setPreviewTab(key as any)}
className={cn(
'flex-1 py-3 text-sm font-medium border-b-2 transition-colors',
previewTab === key
? 'border-b-2 text-slate-900'
: 'border-transparent text-slate-500 hover:text-slate-700',
)}
style={previewTab === key ? { borderColor: settings.primaryColor, color: settings.primaryColor } : {}}
>
{label}
</button>
))}
</div>
)}
{/* Search bar */}
{previewTab === 'rooms' && (
<div className="px-5 py-4 bg-slate-50 border-b border-slate-200">
<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'}
</label>
<input
type="date"
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"
/>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">
{settings.language === 'ru' ? 'Выезд' : 'Check-out'}
</label>
<input
type="date"
value={checkOut}
onChange={e => setCheckOut(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"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<label className="block text-xs text-slate-500 mb-1">
{settings.language === 'ru' ? 'Взрослых' : 'Adults'}
</label>
<div className="flex items-center gap-1">
<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-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 / Category list */}
{previewTab === 'rooms' && isCategoryMode && (
<div className="p-4 space-y-3 max-h-[480px] overflow-y-auto">
{nights > 0 && (
<p className="text-xs text-slate-500">
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} · {guests} {settings.language === 'ru' ? 'гостей' : 'guests'}
{children > 0 && ` · ${children} дет.`}
</p>
)}
{displayCategories.map(cat => {
const isSelected = selected === cat.id
return (
<div
key={cat.id}
className={cn('rounded-xl border-2 overflow-hidden transition-all cursor-pointer', isSelected ? 'border-2' : 'border-slate-200')}
style={isSelected ? { borderColor: settings.primaryColor } : {}}
onClick={() => setSelected(cat.id === selected ? null : cat.id)}
>
<div
className={cn('p-3 transition-colors', isSelected ? '' : 'hover:bg-slate-50')}
style={isSelected ? { background: settings.primaryColor + '08' } : {}}
>
<div className="flex items-start gap-3">
{/* Icon */}
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center text-2xl shrink-0">
🛏
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 text-sm">{cat.name}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<span className="text-xs text-slate-500 flex items-center gap-0.5">
<Users size={10} /> до {cat.maxGuests}
</span>
{cat.availableCount > 0 && (
<span className="text-xs text-emerald-600">· {cat.availableCount} свободно</span>
)}
</div>
{cat.description && (
<p className="text-xs text-slate-500 mt-1 line-clamp-2">{cat.description}</p>
)}
</div>
<div className="text-right shrink-0">
<p className="font-bold text-slate-900 text-sm">
{nights > 0 ? (cat.minPrice * nights).toLocaleString('ru-RU') : cat.minPrice.toLocaleString('ru-RU')}
</p>
<p className="text-[10px] text-slate-400">{nights > 0 ? `за ${nights} ноч.` : 'от/ночь'}</p>
</div>
</div>
{/* Amenities */}
{cat.amenities.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{cat.amenities.slice(0, 4).map(a => {
const Icon = AMENITY_ICONS[a]
return (
<span key={a} className="flex items-center gap-0.5 text-[10px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">
{Icon ? <Icon size={8} /> : null}{a}
</span>
)
})}
{cat.amenities.length > 4 && (
<span className="text-[10px] text-slate-400">+{cat.amenities.length - 4}</span>
)}
</div>
)}
</div>
</div>
)
})}
</div>
)}
{previewTab === 'rooms' && !isCategoryMode && (
<div className="p-4 space-y-3 max-h-[480px] overflow-y-auto">
{nights > 0 && (
<p className="text-xs text-slate-500">
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} · {guests} {settings.language === 'ru' ? 'гостей' : 'guests'}
{children > 0 && ` · ${children} дет.`}
</p>
)}
{displayRooms.map(room => {
const isExpanded = expandedRoom === room.id
const isSelected = selected === room.id
const curPhoto = photoIndex[room.id] ?? 0
const photo = room.photos[curPhoto] ?? { bg: 'from-slate-200 to-slate-300', emoji: '🛏️', label: 'Фото' }
return (
<div
key={room.id}
className={cn(
'rounded-xl border-2 overflow-hidden transition-all',
isSelected ? 'border-2' : 'border-slate-200',
)}
style={isSelected ? { borderColor: settings.primaryColor } : {}}
>
{/* Card header — always visible */}
<div
className={cn(
'flex items-center gap-3 p-3 cursor-pointer transition-colors',
isSelected ? '' : 'hover:bg-slate-50',
)}
style={isSelected ? { background: settings.primaryColor + '08' } : {}}
onClick={() => setSelected(room.id === selected ? null : room.id)}
>
{/* Thumbnail */}
<div className={cn('w-14 h-14 rounded-xl bg-gradient-to-br flex items-center justify-center text-2xl shrink-0', photo.bg)}>
{photo.emoji}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 text-sm">{room.name}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<span className="text-xs text-slate-500 flex items-center gap-0.5">
<Users size={10} /> до {room.guests}
</span>
<span className="text-xs text-slate-500 flex items-center gap-0.5">
<BedDouble size={10} /> {room.beds} кровати
</span>
<span className="text-xs text-slate-500">{room.area} м²</span>
<span className="text-xs text-slate-500 flex items-center gap-0.5">
<Star size={10} className="fill-amber-400 text-amber-400" />4.8
</span>
</div>
</div>
<div className="text-right shrink-0">
<p className="font-bold text-slate-900 text-sm">
{nights > 0
? (room.price * nights).toLocaleString('ru-RU')
: room.price.toLocaleString('ru-RU')
}
</p>
<p className="text-[10px] text-slate-400">
{nights > 0 ? `за ${nights} ноч.` : 'за ночь'}
</p>
</div>
{/* Expand toggle */}
<button
onClick={e => { e.stopPropagation(); setExpandedRoom(isExpanded ? null : room.id) }}
className="p-1 rounded-lg hover:bg-slate-100 text-slate-400 shrink-0 transition-colors"
>
{isExpanded ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
</button>
</div>
{/* Expanded detail panel */}
{isExpanded && (
<div className="border-t border-slate-100">
{/* Photo gallery */}
<div className="relative">
<div className={cn(
'h-44 bg-gradient-to-br flex items-center justify-center',
photo.bg,
)}>
<span className="text-6xl">{photo.emoji}</span>
<span className="absolute bottom-2 left-3 text-xs text-white/90 font-medium drop-shadow">{photo.label}</span>
</div>
{/* Photo nav */}
{room.photos.length > 1 && (
<>
<button
onClick={() => setPhotoIndex(prev => ({ ...prev, [room.id]: Math.max(0, (prev[room.id] ?? 0) - 1) }))}
disabled={curPhoto === 0}
className="absolute left-2 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-black/30 hover:bg-black/50 flex items-center justify-center text-white disabled:opacity-0 transition-all"
>
<ChevronLeft size={14} />
</button>
<button
onClick={() => setPhotoIndex(prev => ({ ...prev, [room.id]: Math.min(room.photos.length - 1, (prev[room.id] ?? 0) + 1) }))}
disabled={curPhoto === room.photos.length - 1}
className="absolute right-2 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-black/30 hover:bg-black/50 flex items-center justify-center text-white disabled:opacity-0 transition-all"
>
<ChevronRight size={14} />
</button>
{/* Dots */}
<div className="absolute bottom-2 right-3 flex gap-1">
{room.photos.map((_, i) => (
<button
key={i}
onClick={() => setPhotoIndex(prev => ({ ...prev, [room.id]: i }))}
className={cn('w-1.5 h-1.5 rounded-full transition-all', i === curPhoto ? 'bg-white' : 'bg-white/50')}
/>
))}
</div>
</>
)}
{/* Photo count */}
<div className="absolute top-2 right-2 flex items-center gap-1 bg-black/30 text-white text-[10px] px-1.5 py-0.5 rounded-full">
<Image size={9} />
{curPhoto + 1}/{room.photos.length}
</div>
{/* 3D tour badge */}
{room.has3dTour && (
<button className="absolute top-2 left-2 flex items-center gap-1 bg-indigo-600/90 hover:bg-indigo-700 text-white text-[10px] font-semibold px-2 py-1 rounded-full transition-colors">
<Scan size={10} />
3D тур
</button>
)}
</div>
{/* Thumbnail strip */}
<div className="flex gap-1.5 px-3 py-2 overflow-x-auto">
{room.photos.map((p, i) => (
<button
key={i}
onClick={() => setPhotoIndex(prev => ({ ...prev, [room.id]: i }))}
className={cn(
'w-12 h-9 rounded-lg bg-gradient-to-br flex items-center justify-center text-lg shrink-0 transition-all',
p.bg,
i === curPhoto ? 'ring-2 ring-offset-1' : 'opacity-60 hover:opacity-90',
)}
style={i === curPhoto ? { '--tw-ring-color': settings.primaryColor } as any : {}}
>
{p.emoji}
</button>
))}
</div>
{/* Description */}
<div className="px-3 pb-2">
<p className="text-xs text-slate-600 leading-relaxed">{room.description}</p>
</div>
{/* Amenities */}
<div className="px-3 pb-3">
<p className="text-[10px] font-semibold text-slate-500 uppercase tracking-wide mb-1.5">Удобства в номере</p>
<div className="flex flex-wrap gap-1.5">
{room.amenities.map(a => {
const Icon = AMENITY_ICONS[a]
return (
<span
key={a}
className="flex items-center gap-1 text-[11px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full"
>
{Icon ? <Icon size={9} /> : null}
{a}
</span>
)
})}
</div>
</div>
{/* 3D tour placeholder — future feature */}
{!room.has3dTour && (
<div className="mx-3 mb-3 flex items-center gap-2 p-2.5 rounded-xl bg-indigo-50 border border-indigo-100">
<Maximize2 size={13} className="text-indigo-400 shrink-0" />
<p className="text-[11px] text-indigo-600">3D-тур для этого номера появится в ближайшее время</p>
</div>
)}
{/* Book button in detail panel */}
<div className="px-3 pb-3">
<button
onClick={() => setSelected(room.id)}
className={cn(
'w-full py-2 rounded-xl text-sm font-semibold transition-colors',
isSelected
? 'text-white'
: 'border-2 bg-white',
)}
style={isSelected
? { background: settings.primaryColor }
: { borderColor: settings.primaryColor, color: settings.primaryColor }}
>
{isSelected ? '✓ Выбран' : 'Выбрать этот номер'}
</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
{/* Rental list */}
{previewTab === 'rental' && (
<div className="p-4 space-y-3">
<p className="text-xs text-slate-500">Выберите объект и удобное время</p>
{settings.additionalServices.filter(s => s.enabled).map(obj => (
<div
key={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',
)}
style={selected === obj.id ? { borderColor: settings.primaryColor, background: settings.primaryColor + '08' } : {}}
>
<div className="w-12 h-12 rounded-xl bg-slate-100 flex items-center justify-center text-2xl shrink-0">
{obj.icon}
</div>
<div className="flex-1">
<p className="font-semibold text-slate-900">{obj.name}</p>
</div>
<div className="text-right">
<p className="font-bold text-slate-900">{obj.price.toLocaleString('ru-RU')} </p>
<p className="text-xs text-slate-400">/{obj.unit}</p>
</div>
</div>
))}
</div>
)}
{/* CTA */}
<div className="px-5 pb-5">
<button
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 total = grandTotal
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'}
</p>
)}
</div>
</div>
)
}
// ── Main Page ──────────────────────────────────────────────────────────────────
export function BookingWidgetPage() {
const { statuses } = useModules()
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const rentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
// Real data for preview
const [realRooms, setRealRooms] = useState<WidgetRoom[]>([])
const [realCategories, setRealCategories] = useState<WidgetCategory[]>([])
const [gateway, setGateway] = useState<PaymentGateway | null>(null)
const [gatewayLoading, setGatewayLoading] = useState(false)
useEffect(() => {
if (!slug) return
setGatewayLoading(true)
Promise.all([
api.widget.getConfig(slug).catch(() => null),
api.paymentGateways.list(slug).catch(() => [] as PaymentGateway[]),
api.hotelSettings.get(slug).catch(() => ({}) as Record<string, unknown>),
]).then(([config, gws, hsRaw]) => {
const hs = hsRaw as Record<string, unknown>
if (config?.rooms) setRealRooms(config.rooms)
if (config?.categories) setRealCategories(config.categories)
const widgetGw = gws.find(g => g.isActive && g.modules?.includes('booking-widget'))
setGateway(widgetGw ?? null)
// Restore saved widget settings
// Note: transformKeys() converts snake_case→camelCase in responses,
// so widget_color → widgetColor, widget_show_rooms → widgetShowRooms, etc.
setSettings(prev => ({
...prev,
hotelName: String((hs as any).widgetHotelName ?? config?.hotelName ?? prev.hotelName),
primaryColor: String((hs as any).widgetColor ?? prev.primaryColor),
language: (String((hs as any).widgetLang ?? prev.language)) as 'ru' | 'en',
showRooms: (hs as any).widgetShowRooms !== undefined ? Boolean((hs as any).widgetShowRooms) : prev.showRooms,
showRental: (hs as any).widgetShowRental !== undefined ? Boolean((hs as any).widgetShowRental) : prev.showRental,
roomDisplayMode: (String((hs as any).widgetRoomMode ?? prev.roomDisplayMode)) as 'rooms' | 'categories',
minNights: (hs as any).widgetMinNights !== undefined ? Number((hs as any).widgetMinNights) : prev.minNights,
showPromo: (hs as any).widgetShowPromo !== undefined ? Boolean((hs as any).widgetShowPromo) : prev.showPromo,
allowExtraBeds: (hs as any).widgetExtraBeds !== undefined ? Boolean((hs as any).widgetExtraBeds) : prev.allowExtraBeds,
allowChildren: (hs as any).widgetChildren !== undefined ? Boolean((hs as any).widgetChildren) : prev.allowChildren,
}))
setSettingsReady(true)
}).finally(() => setGatewayLoading(false))
}, [slug])
const [settings, setSettings] = useState<WidgetSettings>({
hotelName: 'Grand Palace Hotel',
primaryColor: '#4F46E5',
language: 'ru',
showRooms: true,
showRental: rentalActive,
roomDisplayMode: 'rooms',
minNights: 1,
paymentProvider: 'yukassa',
showPromo: true,
allowExtraBeds: true,
allowChildren: true,
formFields: DEFAULT_FORM_FIELDS,
additionalServices: DEFAULT_SERVICES,
})
const [copied, setCopied] = useState(false)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [settingsReady, setSettingsReady] = 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 doSave = async (s: WidgetSettings) => {
if (!slug) return
setSaving(true)
try {
await api.hotelSettings.update(slug, {
widget_hotel_name: s.hotelName,
widget_color: s.primaryColor,
widget_lang: s.language,
widget_show_rooms: s.showRooms,
widget_show_rental: s.showRental,
widget_room_mode: s.roomDisplayMode,
widget_min_nights: s.minNights,
widget_show_promo: s.showPromo,
widget_extra_beds: s.allowExtraBeds,
widget_children: s.allowChildren,
})
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch { /* ignore */ }
finally { setSaving(false) }
}
const handleSaveSettings = () => doSave(settings)
// Auto-save 1.5s after any settings change (only after initial load)
useEffect(() => {
if (!settingsReady) return
const t = setTimeout(() => doSave(settings), 1500)
return () => clearTimeout(t)
}, [settings, settingsReady])
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 widgetUrl = `https://app.hotelsync.ru/${slug || 'grand-palace'}/book?color=${encodeURIComponent(settings.primaryColor)}&lang=${settings.language}&mode=${settings.roomDisplayMode}&min-nights=${settings.minNights}&rental=${settings.showRental}&promo=${settings.showPromo}&extra-beds=${settings.allowExtraBeds}&children=${settings.allowChildren}`
const snippet = `<iframe
src="${widgetUrl}"
width="100%"
height="680"
frameborder="0"
style="border-radius:16px;border:none;box-shadow:0 4px 24px rgba(0,0,0,0.10);"
allow="payment"
loading="lazy"
></iframe>`
const copyCode = () => {
navigator.clipboard.writeText(snippet).catch(() => {})
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="p-4 md:p-6 space-y-5">
{/* Header */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-indigo-600 flex items-center justify-center shrink-0">
<CalendarCheck2 size={20} className="text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Виджет онлайн-бронирования</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">Конструктор виджета для размещения на сайте отеля</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-700/50 rounded-xl w-fit">
{([
['constructor', <Settings2 size={13} />, 'Конструктор'],
['code', <Code2 size={13} />, 'Код установки'],
['stats', <Eye size={13} />, 'Статистика'],
] as const).map(([key, icon, label]) => (
<button
key={key}
onClick={() => setPageTab(key)}
className={cn(
'flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-sm font-medium transition-colors',
pageTab === key
? 'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200',
)}
>
{icon}{label}
</button>
))}
</div>
{/* ── Constructor ── */}
{pageTab === 'constructor' && (
<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">
Название отеля в виджете
</label>
<input
type="text"
className="input"
value={settings.hotelName}
onChange={e => set('hotelName', e.target.value)}
placeholder="Grand Palace Hotel"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Основной цвет
</label>
<div className="flex flex-wrap gap-2">
{COLORS.map(c => (
<button
key={c.value}
onClick={() => set('primaryColor', c.value)}
title={c.label}
className={cn(
'w-8 h-8 rounded-full border-2 transition-transform',
settings.primaryColor === c.value ? 'border-slate-900 dark:border-slate-100 scale-110' : 'border-transparent',
)}
style={{ background: c.value }}
/>
))}
<div className="flex items-center gap-2">
<input
type="color"
value={settings.primaryColor}
onChange={e => set('primaryColor', e.target.value)}
className="w-8 h-8 rounded-full cursor-pointer border-0 p-0 bg-transparent"
/>
<span className="text-xs text-slate-500 font-mono">{settings.primaryColor}</span>
</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">
{([['ru', '🇷🇺 Русский'], ['en', '🇬🇧 English']] as const).map(([k, l]) => (
<button
key={k}
onClick={() => set('language', k)}
className={cn(
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
settings.language === k
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-700 dark:text-slate-300',
)}
>
{l}
</button>
))}
</div>
</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" />
</label>
{settings.showRooms && (
<div className="ml-5 pl-3 border-l-2 border-slate-200 dark:border-slate-600">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Показывать по:</p>
<div className="flex gap-2">
{([['rooms', 'Номерам'], ['categories', 'Категориям']] as const).map(([k, l]) => (
<button
key={k}
onClick={() => set('roomDisplayMode', k)}
className={cn(
'px-2.5 py-1 text-xs rounded-lg border transition-colors',
settings.roomDisplayMode === k
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
)}
>
{l}
</button>
))}
</div>
</div>
)}
{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" />
</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" />
</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"
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>
{/* Save button */}
<div className="flex items-center gap-3">
<button
onClick={handleSaveSettings}
disabled={saving}
className="btn-primary flex items-center gap-2"
>
{saving ? <Loader2 size={15} className="animate-spin" /> : <Save size={15} />}
Сохранить
</button>
{saving && <span className="text-sm text-slate-400">Сохранение...</span>}
{saved && !saving && <span className="text-sm text-emerald-600 dark:text-emerald-400"> Сохранено</span>}
</div>
</div>
{/* Live preview */}
<div>
<div className="flex items-center gap-2 mb-3">
<Eye size={14} className="text-slate-400" />
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Предпросмотр виджета</p>
{gatewayLoading && <Loader2 size={12} className="animate-spin text-slate-400" />}
</div>
{/* Gateway status banner */}
{!gatewayLoading && slug && (
gateway ? (
<div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-700 text-xs text-emerald-700 dark:text-emerald-400">
<Zap size={12} className="shrink-0" />
Онлайн-оплата активна: <strong>{gateway.label}</strong> ({gateway.currency})
· {realRooms.length > 0 ? `${realRooms.length} номеров в превью` : 'данные отеля загружены'}
</div>
) : (
<div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 text-xs text-amber-700 dark:text-amber-400">
<AlertCircle size={12} className="shrink-0" />
Онлайн-оплата не настроена. Добавьте шлюз ЮКасса в{' '}
<a href="/settings/payments" className="underline font-medium">Настройки оплаты</a>
{' '}и укажите модуль «Онлайн-бронирование».
</div>
)
)}
<div className="p-6 rounded-2xl bg-slate-100 dark:bg-slate-700/50">
<WidgetPreview
settings={settings}
slug={slug || undefined}
realRooms={realRooms.length > 0 ? realRooms : undefined}
realCategories={realCategories.length > 0 ? realCategories : undefined}
paymentEnabled={gateway ? gateway.isActive : undefined}
/>
</div>
<p className="text-xs text-slate-400 mt-2 text-center">Так виджет будет выглядеть на сайте отеля</p>
</div>
</div>
)}
{/* ── Code ── */}
{pageTab === 'code' && (
<div className="max-w-2xl space-y-5">
<div className="card p-5 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Code2 size={16} className="text-indigo-500" />
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Код для вставки на сайт</h3>
</div>
<button
onClick={copyCode}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 text-xs font-medium text-slate-700 dark:text-slate-300 hover:border-brand-400 transition-colors"
>
{copied ? <CheckCheck size={12} className="text-emerald-600" /> : <Copy size={12} />}
{copied ? 'Скопировано' : 'Копировать'}
</button>
</div>
<pre className="bg-slate-900 text-emerald-400 text-xs rounded-xl p-4 overflow-x-auto font-mono leading-relaxed">
{snippet}
</pre>
</div>
<div className="card p-5 space-y-3">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Инструкция по установке</h3>
{[
{ step: '1', text: 'Скопируйте код выше' },
{ step: '2', text: 'Вставьте перед закрывающим тегом </body> на страницах, где хотите показать виджет' },
{ step: '3', text: 'Виджет автоматически добавит кнопку "Забронировать" или встроится в нужный блок' },
{ step: '4', text: 'Настройте цвета и параметры в конструкторе — изменения применяются сразу' },
].map(s => (
<div key={s.step} className="flex items-start gap-3">
<span className="w-6 h-6 rounded-full bg-indigo-600 text-white text-xs font-bold flex items-center justify-center shrink-0">{s.step}</span>
<p className="text-sm text-slate-600 dark:text-slate-400 pt-0.5">{s.text}</p>
</div>
))}
</div>
<div className="grid grid-cols-3 gap-3">
{[
{ icon: CalendarCheck2, title: 'Мгновенное подтверждение', desc: 'Email + SMS гостю' },
{ icon: CreditCard, title: 'Онлайн-оплата', desc: 'ЮKassa, Тинькофф, СБП' },
{ icon: Globe, title: 'Без комиссии OTA', desc: 'Только платёжная комиссия ~1.5%' },
].map(f => (
<div key={f.title} className="card p-4 text-center">
<f.icon size={22} className="text-indigo-500 mx-auto mb-2" />
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">{f.title}</p>
<p className="text-xs text-slate-400 mt-0.5">{f.desc}</p>
</div>
))}
</div>
</div>
)}
{/* ── Stats ── */}
{pageTab === 'stats' && (
<div className="max-w-2xl">
<div className="grid grid-cols-3 gap-3 mb-5">
{[
{ label: 'Бронирований через виджет', value: '—', sub: 'нет данных' },
{ label: 'Конверсия посетителей', value: '—', sub: 'нет данных' },
{ label: 'Прямая выручка', value: '—', sub: 'нет данных' },
].map(s => (
<div key={s.label} className="card p-4 text-center">
<p className="text-2xl font-bold text-slate-400">{s.value}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 leading-tight">{s.label}</p>
</div>
))}
</div>
<div className="card p-6 text-center">
<Waves size={40} className="mx-auto mb-3 text-slate-300" />
<p className="font-semibold text-slate-700 dark:text-slate-300">Статистика появится после установки виджета</p>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Разместите виджет на сайте и подключите к нему первые бронирования
</p>
<button onClick={() => setPageTab('code')} className="btn-primary mt-4">
<ArrowRight size={14} />
Получить код установки
</button>
</div>
</div>
)}
</div>
)
}