Files
hotelsync/src/pages/BookingWidgetPage.tsx
HotelSync 56df6711e4 Refactor POS/Reviews/RoomService/BookingWidget/BookingModal
- POS: complete rewrite — shift management + room payments (not products); ATOL placeholder; room balance sidebar; receipts log
- BookingModal: remove "Источник" field; restructure layout (notes+summary as bottom full-width row, eliminating scroll)
- Reviews: negative (<2 stars = <4/10) → moderation with private manager reply; positive → redirect to external platforms (Booking.com, Яндекс, Google); new tabs: "Требуют ответа" / "Перенаправить"
- RoomService: add orderType (room delivery / restaurant table); service charge % for room delivery; scheduledTime for restaurant; paymentStatus (online_paid / pending); settings tab
- BookingWidget: complete redesign — live widget constructor with color picker, language, rooms+rental tabs, payment provider selector; live preview; install code tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 20:48:54 +03:00

585 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 } from 'react'
import {
Code2, CreditCard, Globe, Palette, CalendarCheck2, Copy, CheckCheck,
ChevronLeft, ChevronRight, Star, Users, Dumbbell, Waves,
Eye, Settings2, ArrowRight,
} from 'lucide-react'
import { cn } from '../lib/utils'
import { useModules } from '../contexts/ModulesContext'
// ── Widget settings type ───────────────────────────────────────────────────────
interface WidgetSettings {
hotelName: string
primaryColor: string
language: 'ru' | 'en'
showRooms: boolean
showRental: boolean
minNights: number
paymentProvider: 'yukassa' | 'tinkoff' | 'cloudpayments' | 'none'
showPromo: boolean
}
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: 'Без оплаты' },
]
const MOCK_ROOMS = [
{ id: 'r1', name: 'Стандарт', beds: 1, guests: 2, price: 4500, img: '🛏️' },
{ id: 'r2', name: 'Комфорт', beds: 1, guests: 2, price: 6500, img: '🛏️' },
{ id: 'r3', name: 'Делюкс', beds: 2, guests: 3, price: 8900, img: '🛏️' },
{ id: 'r4', name: 'Пентхаус', beds: 2, guests: 4, price: 15000, img: '🌇' },
]
const MOCK_RENTAL = [
{ id: 'o1', name: 'Теннисный корт', icon: '🎾', price: 1200, unit: 'ч' },
{ id: 'o2', name: 'Сауна', icon: '🧖', price: 2500, unit: 'ч' },
{ id: 'o3', name: 'Банкетный зал', icon: '🎪', price: 15000, unit: 'день' },
]
// ── Widget Preview Component ───────────────────────────────────────────────────
function WidgetPreview({ settings }: { settings: WidgetSettings }) {
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 [selected, setSelected] = useState<string | null>(null)
const nights = checkIn && checkOut
? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
: 0
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-3 gap-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"
style={{ '--focus-color': settings.primaryColor } as any}
/>
</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>
<label className="block text-xs text-slate-500 mb-1">
{settings.language === 'ru' ? 'Гостей' : 'Guests'}
</label>
<div className="flex items-center gap-1">
<button
onClick={() => setGuests(g => Math.max(1, g - 1))}
className="w-7 h-7 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700"
>
<ChevronLeft size={14} />
</button>
<span className="flex-1 text-center text-sm font-medium">{guests}</span>
<button
onClick={() => setGuests(g => Math.min(8, g + 1))}
className="w-7 h-7 rounded-lg border border-slate-200 flex items-center justify-center hover:bg-slate-100 text-slate-700"
>
<ChevronRight size={14} />
</button>
</div>
</div>
</div>
</div>
)}
{/* Rooms list */}
{previewTab === 'rooms' && (
<div className="p-4 space-y-3 max-h-80 overflow-y-auto">
{nights > 0 && (
<p className="text-xs text-slate-500">
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} · {guests} {settings.language === 'ru' ? 'гостя' : 'guests'}
</p>
)}
{MOCK_ROOMS.map(room => (
<div
key={room.id}
onClick={() => setSelected(room.id)}
className={cn(
'flex items-center gap-4 p-3 rounded-xl border-2 cursor-pointer transition-all',
selected === room.id ? 'border-2' : 'border-slate-200 hover:border-slate-300',
)}
style={selected === room.id ? { borderColor: settings.primaryColor, background: settings.primaryColor + '08' } : {}}
>
<div className="w-14 h-14 rounded-xl bg-slate-100 flex items-center justify-center text-2xl shrink-0">
{room.img}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900">{room.name}</p>
<div className="flex items-center gap-2 mt-0.5">
<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">
<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">
{nights > 0
? (room.price * nights).toLocaleString('ru-RU')
: room.price.toLocaleString('ru-RU')
}
</p>
<p className="text-xs text-slate-400">
{nights > 0 ? `за ${nights} ноч.` : 'за ночь'}
</p>
</div>
</div>
))}
</div>
)}
{/* Rental list */}
{previewTab === 'rental' && (
<div className="p-4 space-y-3">
<p className="text-xs text-slate-500">Выберите объект и удобное время</p>
{MOCK_RENTAL.map(obj => (
<div
key={obj.id}
onClick={() => setSelected(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
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' ? 'Забронировать' : 'Book now'}
{selected && nights > 0 && previewTab === 'rooms' && (() => {
const r = MOCK_ROOMS.find(r => r.id === selected)
return r ? ` · ${(r.price * nights).toLocaleString('ru-RU')}` : ''
})()}
</button>
{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 rentalActive = statuses['rental'] === 'active' || statuses['rental'] === 'trial'
const [settings, setSettings] = useState<WidgetSettings>({
hotelName: 'Grand Palace Hotel',
primaryColor: '#4F46E5',
language: 'ru',
showRooms: true,
showRental: rentalActive,
minNights: 1,
paymentProvider: 'yukassa',
showPromo: true,
})
const [copied, setCopied] = useState(false)
const [pageTab, setPageTab] = useState<'constructor' | 'code' | 'stats'>('constructor')
const set = <K extends keyof WidgetSettings>(k: K, v: WidgetSettings[K]) =>
setSettings(prev => ({ ...prev, [k]: v }))
const snippet = `<script src="https://widget.hotelsync.ru/v1/embed.js"
data-hotel="grand-palace"
data-color="${settings.primaryColor}"
data-lang="${settings.language}"
data-rental="${settings.showRental}"
data-min-nights="${settings.minNights}"
data-payment="${settings.paymentProvider}">
</script>`
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">
<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>
<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>
{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>
</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>
<div className="card p-5 space-y-3">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<CreditCard size={16} className="text-emerald-600" />
Онлайн-оплата
</h3>
<div className="grid grid-cols-2 gap-2">
{PAYMENT_PROVIDERS.map(p => (
<button
key={p.value}
onClick={() => set('paymentProvider', p.value as any)}
className={cn(
'px-3 py-2 rounded-xl text-sm font-medium border transition-colors text-left',
settings.paymentProvider === p.value
? '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 hover:border-brand-400',
)}
>
{p.label}
</button>
))}
</div>
{settings.paymentProvider !== 'none' && (
<p className="text-xs text-slate-500 dark:text-slate-400">
Настройте ключи API в разделе Настройки Оплата
</p>
)}
</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>
</div>
<div className="p-6 rounded-2xl bg-slate-100 dark:bg-slate-700/50">
<WidgetPreview settings={settings} />
</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>
)
}