1649 lines
82 KiB
TypeScript
1649 lines
82 KiB
TypeScript
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:00–14:00</option>
|
||
<option>14:00–16: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>
|
||
)
|
||
}
|