Files
hotelsync/src/pages/BookingWidgetPage.tsx
HotelSync 60346f3580 feat: widget code tab — 3 install modes + width customization + better expand button
1. Expand button now shows "X фото / Детали ▾" and "Скрыть ▴" instead of bare chevron
2. Code tab: 3 install modes
   - Встроенный (iframe) — width presets 400/500/700px/full-width + custom input
   - Плавающая кнопка — floating button script with custom text, color from settings
   - По классу — script that opens modal on click of .hotelsync-book (or custom class)
3. Floating/trigger modes share modal logic, generate ready-to-paste <script> tag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 00:39:27 +03:00

2079 lines
108 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 WidgetRentalObject, 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
paymentTimeout: number // minutes to wait for payment before auto-cancel
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 }[]
photoUrls: string[] // real photo URLs (empty for mock rooms)
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', 'Кондиционер', 'Фен', 'Мини-холодильник', 'Сейф'],
photoUrls: [], 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', 'Кондиционер', 'Мини-бар', 'Халат', 'Фен', 'Сейф', 'Кофемашина'],
photoUrls: [], 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', 'Кондиционер', 'Джакузи', 'Мини-бар', 'Халат', 'Тапочки', 'Кофемашина', 'Сейф', 'Балкон'],
photoUrls: [], 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"', 'Кондиционер', 'Сауна', 'Мини-бар', 'Джакузи', 'Терраса', 'Дворецкий', 'Кофемашина', 'Халат', 'Тапочки', 'Сейф'],
photoUrls: [], has3dTour: true,
},
]
interface MockCategory {
id: string
name: string
minPrice: number
maxGuests: number
amenities: string[]
description: string
photos: string[]
availableCount: number
}
const MOCK_CATEGORIES: MockCategory[] = [
{ id: 'c1', name: 'Стандарт', minPrice: 4500, maxGuests: 2, availableCount: 3, photos: [],
amenities: ['Wi-Fi', 'TV', 'Кондиционер', 'Фен'],
description: 'Уютные номера с современным ремонтом и всем необходимым для комфортного проживания.' },
{ id: 'c2', name: 'Комфорт', minPrice: 6500, maxGuests: 2, availableCount: 2, photos: [],
amenities: ['Wi-Fi', 'TV', 'Кондиционер', 'Мини-бар', 'Кофемашина'],
description: 'Просторные номера с расширенными удобствами и зоной отдыха.' },
{ id: 'c3', name: 'Делюкс', minPrice: 8900, maxGuests: 3, availableCount: 1, photos: [],
amenities: ['Wi-Fi', 'Smart TV', 'Кондиционер', 'Джакузи', 'Балкон'],
description: 'Роскошные номера с панорамными окнами и собственным джакузи.' },
{ id: 'c4', name: 'Пентхаус', minPrice: 15000, maxGuests: 4, availableCount: 1, photos: [],
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, realRentalObjects, paymentEnabled }: {
settings: WidgetSettings
slug?: string
realRooms?: WidgetRoom[]
realCategories?: WidgetCategory[]
realRentalObjects?: WidgetRentalObject[]
paymentEnabled?: boolean
}) {
const [previewTab, setPreviewTab] = useState<'rooms' | 'rental'>('rooms')
const localDate = (offsetDays = 0) => {
const d = new Date(); d.setDate(d.getDate() + offsetDays)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const [checkIn, setCheckIn] = useState(() => localDate(0))
const [checkOut, setCheckOut] = useState(() => localDate(1))
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 [catPhotoIndex, setCatPhotoIndex] = 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 }>>({})
const [bookingError, setBookingError] = useState<string | null>(null)
const [paidViaGateway, setPaidViaGateway] = useState(false)
// Guest lookup
const [guestLookupTimer, setGuestLookupTimer] = useState<ReturnType<typeof setTimeout> | null>(null)
const [guestSuggestion, setGuestSuggestion] = useState<{ first_name: string; last_name: string; middle_name?: string; phone?: string; email?: string } | null>(null)
// Rental booking state
const [rentalDate, setRentalDate] = useState(() => localDate(0))
const [rentalIsFullDay, setRentalIsFullDay] = useState(false)
const [rentalStartHour, setRentalStartHour] = useState(10)
const [rentalEndHour, setRentalEndHour] = useState(12)
const rentalObjects: WidgetRentalObject[] = realRentalObjects ?? []
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: 0,
guests: r.maxGuests, price: r.baseRate, area: 0,
description: r.description ?? '',
photos: [] as { bg: string; emoji: string; label: string }[],
photoUrls: r.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; photos: 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) return
if (previewTab === 'rooms' && nights === 0) return
setStep('form')
}
const handleSubmit = async () => {
const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim())
if (missing.length > 0) return
setBookingError(null)
if (slug && selected) {
setSubmitting(true)
try {
if (previewTab === 'rental') {
// Rental booking
const obj = rentalObjects.find(o => o.id === selected)
const rentalAmount = rentalIsFullDay
? obj?.pricePerDay ?? 0
: (rentalEndHour - rentalStartHour) * (obj?.pricePerHour ?? 0)
await api.widget.createRentalBooking(slug, {
objectId: selected,
date: rentalDate,
isFullDay: rentalIsFullDay,
startHour: rentalIsFullDay ? undefined : rentalStartHour,
endHour: rentalIsFullDay ? undefined : rentalEndHour,
guestName: formValues['name'] ?? formValues['full_name'] ?? 'Гость',
guestEmail: formValues['email'] ?? undefined,
guestPhone: formValues['phone'] ?? undefined,
totalAmount: rentalAmount,
notes: formValues['notes'] ?? formValues['comment'] ?? undefined,
})
setStep('success')
} else {
// Room booking
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 (err: any) {
if (err?.status === 403) {
setBookingError(err?.error ?? 'Невозможно завершить бронирование. Попробуйте позже или свяжитесь с отелем.')
} else if (err?.status === 409) {
setBookingError(err?.error ?? 'Выбранное время уже занято. Пожалуйста, выберите другое.')
} else {
setBookingError('Ошибка при создании бронирования. Попробуйте ещё раз или свяжитесь с отелем.')
}
} finally {
setSubmitting(false)
}
} else {
// Preview mode without real slug
if (needsPayment && previewTab === 'rooms') {
setStep('payment')
} else {
setStep('success')
}
}
}
const handlePay = () => {
if (confirmUrl) {
setPaidViaGateway(true)
window.location.href = confirmUrl
} else {
setStep('success')
}
}
const handleBack = () => {
if (step === 'payment') { setStep('form'); return }
setStep('browse')
setFormValues({})
setSelectedServices([])
setServiceSchedule({})
setBookingError(null)
setGuestSuggestion(null)
setPaidViaGateway(false)
setConfirmUrl(null)
}
const doGuestLookup = (email?: string, phone?: string) => {
if (!slug || (!email && !phone)) return
if (guestLookupTimer) clearTimeout(guestLookupTimer)
const t = setTimeout(async () => {
const g = await api.widget.lookupGuest(slug, { email, phone })
if (g && !g.is_blacklisted) setGuestSuggestion(g)
}, 600)
setGuestLookupTimer(t)
}
// ── 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">
{paidViaGateway
? 'Оплата прошла успешно. Подтверждение придёт на email.'
: needsPayment
? 'Бронирование принято. Менеджер свяжется с вами для оплаты.'
: 'Оплата на месте при заезде. Подтверждение придёт на email.'}
</p>
<div className="bg-slate-50 rounded-xl p-4 text-left space-y-1">
{previewTab === 'rental' ? (
<>
<p className="text-xs text-slate-500">Объект: <span className="font-medium text-slate-700">{rentalObjects.find(o => o.id === selected)?.name ?? selected}</span></p>
<p className="text-xs text-slate-500">Дата: <span className="font-medium text-slate-700">{rentalDate}</span></p>
{!rentalIsFullDay && <p className="text-xs text-slate-500">Время: <span className="font-medium text-slate-700">{rentalStartHour}:00 {rentalEndHour}:00</span></p>}
</>
) : (
<>
<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 (YooKassa redirect) ──
if (step === 'payment') {
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">{selectedName} · {grandTotal.toLocaleString('ru-RU')} </p>
</div>
</div>
<div className="p-5 space-y-4">
<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>
<div className="text-center space-y-3 py-2">
<div className="w-14 h-14 rounded-full flex items-center justify-center mx-auto bg-slate-100">
<CreditCard size={28} className="text-slate-500" />
</div>
<p className="text-sm text-slate-700 font-medium">
{settings.language === 'ru'
? 'Для завершения бронирования перейдите к оплате'
: 'Please proceed to payment to confirm your booking'}
</p>
<p className="text-xs text-slate-400">🔒 Защищённое соединение · ЮKassa</p>
</div>
</div>
<div className="px-5 pb-5">
<button
onClick={handlePay}
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')}`
: `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 === 'phone' ? 'tel' : field.type}
inputMode={field.type === 'phone' ? 'tel' : undefined}
placeholder={field.type === 'phone' ? '+7 (___) ___-__-__' : undefined}
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none"
value={formValues[field.id] ?? ''}
onChange={e => {
let val = e.target.value
if (field.type === 'phone') {
const digits = val.replace(/\D/g, '').slice(0, 11)
if (digits.length === 0) { val = '' }
else {
const d = digits.startsWith('8') ? '7' + digits.slice(1) : digits
const n = d.startsWith('7') ? d : '7' + d
val = '+7'
if (n.length > 1) val += ' (' + n.slice(1, 4)
if (n.length > 4) val += ') ' + n.slice(4, 7)
if (n.length > 7) val += '-' + n.slice(7, 9)
if (n.length > 9) val += '-' + n.slice(9, 11)
}
}
setFormValues(prev => ({ ...prev, [field.id]: val }))
if (field.id === 'email') doGuestLookup(val, formValues['phone'])
if (field.id === 'phone') doGuestLookup(formValues['email'], val)
}}
/>
)}
</div>
))}
{/* Guest suggestion */}
{guestSuggestion && (
<div className="flex items-center justify-between bg-indigo-50 border border-indigo-200 rounded-xl px-3 py-2">
<div className="text-xs text-indigo-700">
<span className="font-medium">Гость найден: </span>
{guestSuggestion.last_name} {guestSuggestion.first_name} {guestSuggestion.middle_name ?? ''}
</div>
<button
className="text-xs font-semibold text-indigo-600 hover:text-indigo-800 ml-2 shrink-0"
onClick={() => {
const g = guestSuggestion
const fullName = [g.last_name, g.first_name, g.middle_name].filter(Boolean).join(' ')
setFormValues(prev => ({
...prev,
name: fullName,
full_name: fullName,
...(g.email && { email: g.email }),
...(g.phone && { phone: g.phone }),
}))
setGuestSuggestion(null)
}}
>
Заполнить
</button>
</div>
)}
</div>
<div className="px-5 pb-5 pt-2 space-y-2">
{bookingError && (
<div className="flex items-start gap-2 bg-red-50 border border-red-200 rounded-xl px-3 py-2">
<AlertCircle size={14} className="text-red-500 mt-0.5 shrink-0" />
<p className="text-xs text-red-700">{bookingError}</p>
</div>
)}
<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
const curIdx = catPhotoIndex[cat.id] ?? 0
const photos = cat.photos ?? []
return (
<div
key={cat.id}
className={cn('rounded-2xl border-2 overflow-hidden transition-all shadow-sm hover:shadow-md', isSelected ? 'border-2' : 'border-slate-200')}
style={isSelected ? { borderColor: settings.primaryColor } : {}}
>
{/* Photo gallery */}
<div className="relative w-full h-44 bg-slate-100 overflow-hidden group">
{photos.length > 0 ? (
<>
<img
src={photos[curIdx]}
alt={cat.name}
className="w-full h-full object-cover transition-transform duration-300"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
{/* Nav arrows */}
{photos.length > 1 && (
<>
<button
onClick={e => { e.stopPropagation(); setCatPhotoIndex(p => ({ ...p, [cat.id]: Math.max(0, (p[cat.id] ?? 0) - 1) })) }}
disabled={curIdx === 0}
className="absolute left-2 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-white/80 flex items-center justify-center shadow opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-0"
>
<ChevronLeft size={14} />
</button>
<button
onClick={e => { e.stopPropagation(); setCatPhotoIndex(p => ({ ...p, [cat.id]: Math.min(photos.length - 1, (p[cat.id] ?? 0) + 1) })) }}
disabled={curIdx === photos.length - 1}
className="absolute right-2 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-white/80 flex items-center justify-center shadow opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-0"
>
<ChevronRight size={14} />
</button>
{/* Dots */}
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1">
{photos.map((_, i) => (
<button
key={i}
onClick={e => { e.stopPropagation(); setCatPhotoIndex(p => ({ ...p, [cat.id]: i })) }}
className={cn('w-1.5 h-1.5 rounded-full transition-all', i === curIdx ? 'bg-white scale-125' : 'bg-white/50')}
/>
))}
</div>
{/* Counter */}
<span className="absolute top-2 right-2 text-[10px] text-white bg-black/40 rounded-full px-2 py-0.5">
{curIdx + 1}/{photos.length}
</span>
</>
)}
{/* Available badge */}
{cat.availableCount > 0 && (
<span className="absolute top-2 left-2 text-[10px] font-semibold text-white bg-emerald-500/90 rounded-full px-2 py-0.5">
{cat.availableCount} своб.
</span>
)}
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-2">
<span className="text-5xl">🛏</span>
<span className="text-xs text-slate-400">Нет фото</span>
</div>
)}
</div>
{/* Card content */}
<div
className={cn('p-3 cursor-pointer transition-colors', isSelected ? '' : 'hover:bg-slate-50')}
style={isSelected ? { background: settings.primaryColor + '08' } : {}}
onClick={() => setSelected(cat.id === selected ? null : cat.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 text-sm leading-tight">{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>
{photos.length > 0 && (
<span className="text-xs text-slate-400 flex items-center gap-0.5">
<Image size={10} /> {photos.length} фото
</span>
)}
</div>
{cat.description && (
<p className="text-xs text-slate-500 mt-1 line-clamp-2 leading-relaxed">{cat.description}</p>
)}
</div>
<div className="text-right shrink-0">
<p className="font-bold text-slate-900 text-base">
{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, 5).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 > 5 && (
<span className="text-[10px] text-slate-400">+{cat.amenities.length - 5}</span>
)}
</div>
)}
{/* Select button */}
<button
onClick={e => { e.stopPropagation(); setSelected(cat.id === selected ? null : cat.id) }}
className={cn('w-full mt-3 py-2 rounded-xl text-sm font-semibold transition-colors')}
style={isSelected
? { background: settings.primaryColor, color: '#fff' }
: { border: `2px solid ${settings.primaryColor}`, color: settings.primaryColor, background: 'transparent' }}
>
{isSelected ? '✓ Выбрана' : 'Выбрать категорию'}
</button>
</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 hasRealPhotos = room.photoUrls.length > 0
const totalPhotos = hasRealPhotos ? room.photoUrls.length : room.photos.length
const curPhoto = Math.min(photoIndex[room.id] ?? 0, Math.max(0, totalPhotos - 1))
const photo = room.photos[curPhoto] ?? { bg: 'from-slate-200 to-slate-300', emoji: '🛏️', label: 'Фото' }
const curPhotoUrl = hasRealPhotos ? room.photoUrls[curPhoto] : null
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 */}
{curPhotoUrl ? (
<img src={curPhotoUrl} alt={room.name} className="w-14 h-14 rounded-xl object-cover shrink-0" />
) : (
<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>
{room.beds > 0 && (
<span className="text-xs text-slate-500 flex items-center gap-0.5">
<BedDouble size={10} /> {room.beds} кр.
</span>
)}
{room.area > 0 && (
<span className="text-xs text-slate-500">{room.area} м²</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="flex items-center gap-0.5 text-[11px] font-medium text-slate-500 hover:text-brand-600 transition-colors shrink-0 px-1.5 py-1 rounded-lg hover:bg-slate-50"
>
{isExpanded ? (
<>Скрыть <ChevronUp size={12} /></>
) : (
<>{hasRealPhotos ? `${room.photoUrls.length} фото` : 'Детали'} <ChevronDown size={12} /></>
)}
</button>
</div>
{/* Expanded detail panel */}
{isExpanded && (
<div className="border-t border-slate-100">
{/* Photo gallery */}
<div className="relative">
{curPhotoUrl ? (
<img src={curPhotoUrl} alt={room.name} className="w-full h-44 object-cover" />
) : (
<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 */}
{totalPhotos > 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(totalPhotos - 1, (prev[room.id] ?? 0) + 1) }))}
disabled={curPhoto === totalPhotos - 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">
{Array.from({ length: totalPhotos }).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 */}
{totalPhotos > 0 && (
<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}/{totalPhotos}
</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 */}
{totalPhotos > 1 && (
<div className="flex gap-1.5 px-3 py-2 overflow-x-auto">
{hasRealPhotos
? room.photoUrls.map((url, i) => (
<button
key={i}
onClick={() => setPhotoIndex(prev => ({ ...prev, [room.id]: i }))}
className={cn('w-12 h-9 rounded-lg overflow-hidden shrink-0 transition-all', i === curPhoto ? 'ring-2 ring-offset-1' : 'opacity-60 hover:opacity-90')}
style={i === curPhoto ? { '--tw-ring-color': settings.primaryColor } as any : {}}
>
<img src={url} alt="" className="w-full h-full object-cover" />
</button>
))
: 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">
{rentalObjects.length === 0 ? (
<p className="text-xs text-slate-400 text-center py-4">Объекты аренды не настроены</p>
) : (
<>
{/* Date picker for rental */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
{settings.language === 'ru' ? 'Дата' : 'Date'}
</label>
<input
type="date"
value={rentalDate}
min={localDate(0)}
onChange={e => setRentalDate(e.target.value)}
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2"
style={{ '--tw-ring-color': settings.primaryColor } as React.CSSProperties}
/>
</div>
{/* Objects */}
{rentalObjects.map(obj => {
const isSelected = selected === obj.id
const hourlyAvailable = obj.pricePerHour > 0
const dayAvailable = obj.pricePerDay > 0
const hours = rentalIsFullDay ? 0 : Math.max(0, rentalEndHour - rentalStartHour)
const price = isSelected
? (rentalIsFullDay ? obj.pricePerDay : hours * obj.pricePerHour)
: (hourlyAvailable ? obj.pricePerHour : obj.pricePerDay)
const priceLabel = isSelected
? (rentalIsFullDay ? `${obj.pricePerDay.toLocaleString('ru-RU')} ₽/день` : `${price.toLocaleString('ru-RU')} ₽ (${hours} ч)`)
: (hourlyAvailable ? `${obj.pricePerHour.toLocaleString('ru-RU')} ₽/ч` : `${obj.pricePerDay.toLocaleString('ru-RU')} ₽/день`)
return (
<div key={obj.id} className="rounded-xl border-2 overflow-hidden" style={isSelected ? { borderColor: settings.primaryColor } : { borderColor: '#e2e8f0' }}>
<div
onClick={() => setSelected(isSelected ? null : obj.id)}
className="flex items-center gap-3 p-3 cursor-pointer"
style={isSelected ? { background: settings.primaryColor + '0d' } : {}}
>
<div className="w-10 h-10 rounded-lg bg-slate-100 flex items-center justify-center text-xl shrink-0">{obj.icon}</div>
<div className="flex-1">
<p className="font-semibold text-slate-900 text-sm">{obj.name}</p>
<p className="text-xs text-slate-400">{obj.openHour}:00 {obj.closeHour}:00</p>
</div>
<p className="font-bold text-sm text-slate-900">{priceLabel}</p>
</div>
{/* Time controls when selected */}
{isSelected && (
<div className="px-3 pb-3 pt-1 space-y-2 border-t border-slate-100">
{hourlyAvailable && dayAvailable && (
<div className="flex gap-2">
<button onClick={() => setRentalIsFullDay(false)} className={cn('flex-1 text-xs py-1.5 rounded-lg border font-medium transition-colors', !rentalIsFullDay ? 'text-white border-transparent' : 'bg-white text-slate-500 border-slate-200')} style={!rentalIsFullDay ? { background: settings.primaryColor } : {}}>По часам</button>
<button onClick={() => setRentalIsFullDay(true)} className={cn('flex-1 text-xs py-1.5 rounded-lg border font-medium transition-colors', rentalIsFullDay ? 'text-white border-transparent' : 'bg-white text-slate-500 border-slate-200')} style={rentalIsFullDay ? { background: settings.primaryColor } : {}}>Весь день</button>
</div>
)}
{!rentalIsFullDay && hourlyAvailable && (
<div className="flex items-center gap-2 text-xs">
<span className="text-slate-500">С</span>
<select value={rentalStartHour} onChange={e => { const v = Number(e.target.value); setRentalStartHour(v); if (rentalEndHour <= v) setRentalEndHour(v + 1) }} className="flex-1 border border-slate-200 rounded-lg px-2 py-1.5 text-xs">
{Array.from({ length: obj.closeHour - obj.openHour }, (_, i) => obj.openHour + i).map(h => <option key={h} value={h}>{h}:00</option>)}
</select>
<span className="text-slate-500">до</span>
<select value={rentalEndHour} onChange={e => setRentalEndHour(Number(e.target.value))} className="flex-1 border border-slate-200 rounded-lg px-2 py-1.5 text-xs">
{Array.from({ length: obj.closeHour - obj.openHour }, (_, i) => obj.openHour + i + 1).filter(h => h > rentalStartHour).map(h => <option key={h} value={h}>{h}:00</option>)}
</select>
</div>
)}
</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 [realRentalObjects, setRealRentalObjects] = useState<WidgetRentalObject[]>([])
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)
if (config?.rentalObjects) setRealRentalObjects(config.rentalObjects)
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,
paymentTimeout: (hs as any).widgetPaymentTimeout !== undefined ? Number((hs as any).widgetPaymentTimeout) : prev.paymentTimeout,
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,
additionalServices: (() => {
const rentalObjs = config?.rentalObjects ?? []
if (rentalObjs.length === 0) return prev.additionalServices
const saved: { id: string; enabled: boolean }[] = Array.isArray((hs as any).widgetServices)
? (hs as any).widgetServices : []
return rentalObjs.map(o => ({
id: o.id,
name: o.name,
icon: o.icon,
price: o.pricePerHour,
unit: 'ч',
enabled: saved.length > 0 ? (saved.find(s => s.id === o.id)?.enabled ?? true) : true,
}))
})(),
}))
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,
paymentTimeout: 15,
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 [codeMode, setCodeMode] = useState<'iframe' | 'floating' | 'trigger'>('iframe')
const [widgetMaxWidth, setWidgetMaxWidth] = useState<string>('500px')
const [triggerClass, setTriggerClass] = useState('hotelsync-book')
const [floatBtnText, setFloatBtnText] = useState('Забронировать')
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 [showAddService, setShowAddService] = useState(false)
const [editingServiceId, setEditingServiceId] = useState<string | null>(null)
const [newService, setNewService] = useState<Omit<AdditionalService, 'id' | 'enabled'>>({ name: '', icon: '✨', price: 0, unit: 'раз' })
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_payment_timeout: s.paymentTimeout,
widget_show_promo: s.showPromo,
widget_extra_beds: s.allowExtraBeds,
widget_children: s.allowChildren,
widget_services: s.additionalServices.map(s => ({ id: s.id, enabled: s.enabled })),
})
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 addService = () => {
if (!newService.name.trim()) return
setSettings(prev => ({
...prev,
additionalServices: [...prev.additionalServices, {
id: `svc-${Date.now()}`,
...newService,
enabled: true,
}],
}))
setNewService({ name: '', icon: '✨', price: 0, unit: 'раз' })
setShowAddService(false)
}
const updateService = (id: string, patch: Partial<AdditionalService>) =>
setSettings(prev => ({
...prev,
additionalServices: prev.additionalServices.map(s =>
s.id === id ? { ...s, ...patch } : s
),
}))
const removeService = (id: string) =>
setSettings(prev => ({
...prev,
additionalServices: prev.additionalServices.filter(s => s.id !== id),
}))
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 iframeSnippet = widgetMaxWidth === '100%'
? `<iframe\n src="${widgetUrl}"\n width="100%"\n height="680"\n frameborder="0"\n style="border-radius:16px;border:none;box-shadow:0 4px 24px rgba(0,0,0,0.10);display:block;"\n allow="payment"\n loading="lazy"\n></iframe>`
: `<div style="max-width:${widgetMaxWidth};margin:0 auto;">\n <iframe\n src="${widgetUrl}"\n width="100%"\n height="680"\n frameborder="0"\n style="border-radius:16px;border:none;box-shadow:0 4px 24px rgba(0,0,0,0.10);"\n allow="payment"\n loading="lazy"\n ></iframe>\n</div>`
const modalScript = `(function(){
var W='${widgetUrl}';
var m=document.createElement('div');
m.id='hs-modal';
m.style.cssText='display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:99998;align-items:center;justify-content:center;padding:16px;box-sizing:border-box;';
m.innerHTML='<div style="position:relative;width:100%;max-width:500px;max-height:90vh;overflow-y:auto;border-radius:16px;">'
+'<button onclick="document.getElementById(\\'hs-modal\\').style.display=\\'none\\'" style="position:absolute;top:8px;right:8px;z-index:1;width:32px;height:32px;border-radius:50%;background:rgba(0,0,0,.5);color:#fff;border:none;cursor:pointer;font-size:20px;line-height:1;display:flex;align-items:center;justify-content:center;">×</button>'
+'<iframe src="'+W+'" style="width:100%;height:680px;border:none;border-radius:16px;display:block;" allow="payment"></iframe>'
+'</div>';
m.addEventListener('click',function(e){if(e.target===m)m.style.display='none';});
document.body.appendChild(m);
window.hsOpenWidget=function(){m.style.display='flex';};
})()`
const floatingSnippet = `<script>
${modalScript}
;(function(){
var b=document.createElement('button');
b.textContent='${floatBtnText}';
b.style.cssText='position:fixed;bottom:24px;right:24px;background:${settings.primaryColor};color:#fff;border:none;padding:14px 24px;border-radius:50px;font-size:15px;font-weight:600;cursor:pointer;box-shadow:0 4px 20px rgba(0,0,0,.25);z-index:99997;transition:transform .15s,box-shadow .15s;';
b.onmouseenter=function(){b.style.transform='scale(1.05)';b.style.boxShadow='0 6px 28px rgba(0,0,0,.35)';};
b.onmouseleave=function(){b.style.transform='scale(1)';b.style.boxShadow='0 4px 20px rgba(0,0,0,.25)';};
b.onclick=function(){window.hsOpenWidget();};
document.body.appendChild(b);
})();
</script>`
const triggerSnippet = `<script>
${modalScript}
;document.addEventListener('click',function(e){
if(e.target.closest('.${triggerClass}')){e.preventDefault();window.hsOpenWidget();}
});
</script>`
const activeSnippet = codeMode === 'iframe' ? iframeSnippet : codeMode === 'floating' ? floatingSnippet : triggerSnippet
const copyCode = () => {
navigator.clipboard.writeText(activeSnippet).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>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Время ожидания оплаты (минут)
</label>
<input
type="number" min={5} max={60} className="input w-24"
value={settings.paymentTimeout}
onChange={e => set('paymentTimeout', Math.max(5, parseInt(e.target.value) || 15))}
/>
<p className="mt-1 text-xs text-slate-400">По умолчанию 15 мин. Если не оплачено бронь отменяется.</p>
</div>
</div>
{/* Additional services — from rental objects */}
<div className="card p-5 space-y-3">
<div>
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Дополнительные услуги</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Объекты аренды, доступные гостю при бронировании номера. Управляйте объектами в{' '}
<a href={`/${slug}/rental`} className="text-brand-600 hover:underline">модуле аренды</a>.
</p>
</div>
{settings.additionalServices.length === 0 ? (
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/30 p-4 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Нет объектов аренды</p>
<a href={`/${slug}/rental`} className="text-xs text-brand-600 hover:underline mt-1 inline-block">
Добавить в модуле аренды
</a>
</div>
) : (
<div className="space-y-1">
{settings.additionalServices.map(s => (
<div key={s.id} className="flex items-center gap-2 px-2 py-1.5 rounded-xl hover:bg-slate-50 dark:hover:bg-slate-700/50">
<span className="text-base w-6 text-center">{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}
realRentalObjects={realRentalObjects.length > 0 ? realRentalObjects : 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">
{/* Install mode selector */}
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Способ установки</h3>
<div className="grid grid-cols-3 gap-3">
{([
{ key: 'iframe', icon: Code2, title: 'Встроенный', desc: 'Вставить блоком на страницу' },
{ key: 'floating', icon: Zap, title: 'Плавающая кнопка', desc: 'Кнопка в углу сайта' },
{ key: 'trigger', icon: Scan, title: 'По классу', desc: 'Клик на любой элемент сайта' },
] as const).map(m => (
<button
key={m.key}
onClick={() => setCodeMode(m.key)}
className={cn(
'flex flex-col items-center gap-2 p-3 rounded-xl border-2 text-center transition-all',
codeMode === m.key
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/10'
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300',
)}
>
<m.icon size={18} className={codeMode === m.key ? 'text-brand-600' : 'text-slate-400'} />
<div>
<p className={cn('text-xs font-semibold', codeMode === m.key ? 'text-brand-700 dark:text-brand-400' : 'text-slate-700 dark:text-slate-300')}>{m.title}</p>
<p className="text-[10px] text-slate-400 mt-0.5 leading-tight">{m.desc}</p>
</div>
</button>
))}
</div>
</div>
{/* Options per mode */}
{codeMode === 'iframe' && (
<div className="card p-5 space-y-3">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Ширина виджета</h3>
<div className="flex flex-wrap gap-2">
{[
{ label: 'Узкий (400px)', value: '400px' },
{ label: 'Стандарт (500px)', value: '500px' },
{ label: 'Широкий (700px)', value: '700px' },
{ label: 'Во всю ширину', value: '100%' },
].map(opt => (
<button
key={opt.value}
onClick={() => setWidgetMaxWidth(opt.value)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
widgetMaxWidth === opt.value
? 'bg-brand-600 text-white border-brand-600'
: 'border-slate-200 text-slate-600 hover:border-brand-400 dark:border-slate-600 dark:text-slate-300',
)}
>
{opt.label}
</button>
))}
<input
value={widgetMaxWidth}
onChange={e => setWidgetMaxWidth(e.target.value)}
className="w-24 text-xs border border-slate-200 dark:border-slate-600 rounded-lg px-2 py-1.5 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300"
placeholder="700px"
/>
</div>
<p className="text-[11px] text-slate-400">Виджет автоматически отцентрируется на странице</p>
</div>
)}
{codeMode === 'floating' && (
<div className="card p-5 space-y-3">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Настройки кнопки</h3>
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="text-xs text-slate-500 mb-1 block">Текст кнопки</label>
<input
value={floatBtnText}
onChange={e => setFloatBtnText(e.target.value)}
className="w-full text-sm border border-slate-200 dark:border-slate-600 rounded-lg px-3 py-1.5 bg-white dark:bg-slate-800"
placeholder="Забронировать"
/>
</div>
<div className="shrink-0 mt-4">
<button
className="px-4 py-2 rounded-full text-sm font-semibold text-white shadow-lg"
style={{ background: settings.primaryColor }}
>
{floatBtnText || 'Забронировать'}
</button>
</div>
</div>
<p className="text-[11px] text-slate-400">Кнопка появится в правом нижнем углу сайта. Цвет берётся из настроек виджета.</p>
</div>
)}
{codeMode === 'trigger' && (
<div className="card p-5 space-y-3">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">CSS-класс триггера</h3>
<div className="flex items-center gap-2">
<span className="text-sm text-slate-400">.</span>
<input
value={triggerClass}
onChange={e => setTriggerClass(e.target.value.replace(/[^a-z0-9-_]/gi, ''))}
className="flex-1 text-sm border border-slate-200 dark:border-slate-600 rounded-lg px-3 py-1.5 bg-white dark:bg-slate-800 font-mono"
placeholder="hotelsync-book"
/>
</div>
<p className="text-[11px] text-slate-400">
Добавьте этот класс любой кнопке или ссылке на сайте при клике откроется виджет:
</p>
<pre className="bg-slate-900 text-emerald-400 text-xs rounded-lg p-3 font-mono">{`<button class="${triggerClass || 'hotelsync-book'}">Забронировать номер</button>`}</pre>
</div>
)}
{/* Code block */}
<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">
{codeMode === 'iframe' ? 'Код для вставки' : codeMode === 'floating' ? 'Скрипт плавающей кнопки' : 'Скрипт триггера по классу'}
</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 whitespace-pre-wrap break-all">
{activeSnippet}
</pre>
<p className="text-[11px] text-slate-400">
{codeMode === 'iframe'
? 'Вставьте в любое место страницы где должен отображаться виджет.'
: 'Вставьте перед закрывающим тегом </body>.'}
</p>
</div>
<div className="grid grid-cols-3 gap-3">
{[
{ icon: CalendarCheck2, title: 'Мгновенное подтверждение', desc: 'Email гостю' },
{ 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>
)
}