Add expandable room detail panel to booking widget with photo gallery, amenities, and 3D tour placeholder

Each room card in the widget can now be expanded via chevron button to reveal:
- Photo gallery with navigation arrows, dot indicators, and thumbnail strip (gradient placeholders per room)
- Room description, area (m²), bed count
- Amenities chips with icons (Wi-Fi, TV, AC, etc.)
- 3D tour button for rooms that support it (Делюкс, Пентхаус); placeholder notice for others
- "Выбрать этот номер" button inside detail panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 16:13:35 +03:00
parent c431ea1486
commit c981b2ac27

View File

@@ -1,9 +1,11 @@
import { useState } from 'react'
import {
Code2, CreditCard, Globe, Palette, CalendarCheck2, Copy, CheckCheck,
Code2, CreditCard, Globe, CalendarCheck2, Copy, CheckCheck,
ChevronLeft, ChevronRight, Star, Users, Dumbbell, Waves,
Eye, Settings2, ArrowRight, Plus, Trash2, Check, X as XIcon,
BedDouble, Baby, ToggleLeft, ToggleRight, ChevronDown,
BedDouble, Baby, ToggleLeft, ToggleRight, ChevronDown, ChevronUp,
Maximize2, Wifi, Tv2, Wind, Coffee, Bath, Mountain, Shirt, Shield,
Scan, Image,
} from 'lucide-react'
import { cn } from '../lib/utils'
import { useModules } from '../contexts/ModulesContext'
@@ -61,13 +63,83 @@ const PAYMENT_PROVIDERS = [
{ 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: '🌇' },
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,
},
]
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
const DEFAULT_FORM_FIELDS: FormField[] = [
@@ -95,6 +167,8 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
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')
// Form state
@@ -575,50 +649,208 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
{/* Rooms list */}
{previewTab === 'rooms' && (
<div className="p-4 space-y-3 max-h-72 overflow-y-auto">
<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>
)}
{MOCK_ROOMS.map(room => (
<div
key={room.id}
onClick={() => setSelected(room.id === selected ? null : 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>
{MOCK_ROOMS.map(room => {
const isExpanded = expandedRoom === room.id
const isSelected = selected === room.id
const curPhoto = photoIndex[room.id] ?? 0
const photo = room.photos[curPhoto]
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 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>
)}