From f62ccdd3f720feb6286a26ab645e19b3cedc09a5 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 16 Mar 2026 15:51:31 +0300 Subject: [PATCH] Add occupancy analytics, housekeeping maintenance notes, and booking widget form flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DynamicPricingPage: add 'Нагрузка' tab with monthly occupancy bar chart (2025/2026 data), year selector, summary stats, and editable occupancy-based pricing rules (threshold ranges → price modifiers) - HousekeepingPage: add maintenance note button (wrench icon) on task cards — housekeeper can report broken items with a description that gets sent to technical service; notes displayed in orange badge - BookingWidgetPage: fix 'Забронировать' button with full form flow (browse → form → success), add configurable form fields (required/optional/custom), add additional services toggle in settings and at checkout, add extra beds + children count in guest selector with auto-pricing Co-Authored-By: Claude Sonnet 4.6 --- src/pages/BookingWidgetPage.tsx | 487 +++++++++++++++++++++++++++---- src/pages/DynamicPricingPage.tsx | 328 ++++++++++++++++++++- src/pages/HousekeepingPage.tsx | 72 ++++- src/types/index.ts | 1 + 4 files changed, 817 insertions(+), 71 deletions(-) diff --git a/src/pages/BookingWidgetPage.tsx b/src/pages/BookingWidgetPage.tsx index 4c9a228..da21d6b 100644 --- a/src/pages/BookingWidgetPage.tsx +++ b/src/pages/BookingWidgetPage.tsx @@ -2,13 +2,32 @@ import { useState } from 'react' import { Code2, CreditCard, Globe, Palette, CalendarCheck2, Copy, CheckCheck, ChevronLeft, ChevronRight, Star, Users, Dumbbell, Waves, - Eye, Settings2, ArrowRight, + Eye, Settings2, ArrowRight, Plus, Trash2, Check, X as XIcon, + BedDouble, Baby, ToggleLeft, ToggleRight, ChevronDown, } from 'lucide-react' import { cn } from '../lib/utils' import { useModules } from '../contexts/ModulesContext' // ── Widget settings type ─────────────────────────────────────────────────────── +interface FormField { + id: string + label: string + type: 'text' | 'email' | 'phone' | 'textarea' | 'select' + required: boolean + enabled: boolean + isCustom?: boolean +} + +interface AdditionalService { + id: string + name: string + icon: string + price: number + unit: string + enabled: boolean +} + interface WidgetSettings { hotelName: string primaryColor: string @@ -18,6 +37,10 @@ interface WidgetSettings { minNights: number paymentProvider: 'yukassa' | 'tinkoff' | 'cloudpayments' | 'none' showPromo: boolean + allowExtraBeds: boolean + allowChildren: boolean + formFields: FormField[] + additionalServices: AdditionalService[] } const COLORS = [ @@ -45,10 +68,21 @@ const MOCK_ROOMS = [ { id: 'r4', name: 'Пентхаус', beds: 2, guests: 4, price: 15000, img: '🌇' }, ] -const MOCK_RENTAL = [ - { id: 'o1', name: 'Теннисный корт', icon: '🎾', price: 1200, unit: 'ч' }, - { id: 'o2', name: 'Сауна', icon: '🧖', price: 2500, unit: 'ч' }, - { id: 'o3', name: 'Банкетный зал', icon: '🎪', price: 15000, unit: 'день' }, +const EXTRA_BED_PRICE = 1500 + +const DEFAULT_FORM_FIELDS: FormField[] = [ + { id: 'name', label: 'Имя и фамилия', type: 'text', required: true, enabled: true }, + { id: 'email', label: 'Email', type: 'email', required: true, enabled: true }, + { id: 'phone', label: 'Телефон', type: 'phone', required: true, enabled: true }, + { id: 'comment', label: 'Пожелания', type: 'textarea',required: false, enabled: true }, + { id: 'arrival', label: 'Время заезда', type: 'select', required: false, enabled: false }, +] + +const DEFAULT_SERVICES: AdditionalService[] = [ + { id: 's1', name: 'Теннисный корт', icon: '🎾', price: 1200, unit: 'ч', enabled: true }, + { id: 's2', name: 'Сауна', icon: '🧖', price: 2500, unit: 'ч', enabled: true }, + { id: 's3', name: 'Банкетный зал', icon: '🎪', price: 15000, unit: 'день',enabled: false }, + { id: 's4', name: 'Трансфер', icon: '🚗', price: 3000, unit: 'раз', enabled: true }, ] // ── Widget Preview Component ─────────────────────────────────────────────────── @@ -58,12 +92,195 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) { const [checkIn, setCheckIn] = useState('2026-03-20') const [checkOut, setCheckOut] = useState('2026-03-22') const [guests, setGuests] = useState(2) + const [extraBeds, setExtraBeds] = useState(0) + const [children, setChildren] = useState(0) const [selected, setSelected] = useState(null) + const [step, setStep] = useState<'browse' | 'form' | 'success'>('browse') + + // Form state + const [formValues, setFormValues] = useState>({}) + const [selectedServices, setSelectedServices] = useState([]) const nights = checkIn && checkOut ? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000) : 0 + const selectedRoom = MOCK_ROOMS.find(r => r.id === selected) + const roomTotal = selectedRoom ? selectedRoom.price * Math.max(1, nights) : 0 + const extraTotal = extraBeds * EXTRA_BED_PRICE * Math.max(1, nights) + const servicesTotal = selectedServices.reduce((sum, sid) => { + const s = settings.additionalServices.find(s => s.id === sid) + return sum + (s ? s.price : 0) + }, 0) + const grandTotal = roomTotal + extraTotal + servicesTotal + + const activeFields = settings.formFields.filter(f => f.enabled) + + const handleBook = () => { + if (!selected || nights === 0) return + setStep('form') + } + + const handleSubmit = () => { + // Check required fields + const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim()) + if (missing.length > 0) return + setStep('success') + } + + const handleBack = () => { + setStep('browse') + setFormValues({}) + setSelectedServices([]) + } + + if (step === 'success') { + return ( +
+
+

{settings.hotelName || 'Название отеля'}

+
+
+
+ +
+

Бронирование принято!

+

Подтверждение придёт на email в течение нескольких минут

+
+

Номер: {selectedRoom?.name}

+

Заезд: {checkIn}

+

Выезд: {checkOut}

+

Итого: {grandTotal.toLocaleString('ru-RU')} ₽

+
+ +
+
+ ) + } + + if (step === 'form') { + return ( +
+
+ +
+

{settings.language === 'ru' ? 'Данные гостя' : 'Guest details'}

+

{selectedRoom?.name} · {nights} ноч.

+
+
+ +
+ {/* Summary */} +
+
+ {selectedRoom?.name} × {nights} ноч. + {roomTotal.toLocaleString('ru-RU')} ₽ +
+ {extraBeds > 0 && ( +
+ Доп. места × {extraBeds} + {extraTotal.toLocaleString('ru-RU')} ₽ +
+ )} + {selectedServices.map(sid => { + const s = settings.additionalServices.find(s => s.id === sid) + return s ? ( +
+ {s.icon} {s.name} + {s.price.toLocaleString('ru-RU')} ₽ +
+ ) : null + })} +
+ Итого + {grandTotal.toLocaleString('ru-RU')} ₽ +
+
+ + {/* Additional services */} + {settings.additionalServices.filter(s => s.enabled).length > 0 && ( +
+

+ {settings.language === 'ru' ? 'Дополнительные услуги' : 'Additional services'} +

+
+ {settings.additionalServices.filter(s => s.enabled).map(s => ( + + ))} +
+
+ )} + + {/* Form fields */} + {activeFields.map(field => ( +
+ + {field.type === 'textarea' ? ( +