Add room categories, documents module, guest settings, and booking improvements

- New pages: RoomCategoriesPage (category CRUD with photos, color, amenities) and DocumentsPage (template constructor with variable substitution, print packages)
- Sidebar: added "Категории номеров" link under rooms, Documents module via modulesData
- App.tsx: routes for /room-categories and /documents
- RoomModal: now accepts categories prop for categoryId select
- BookingModal: added additional services dropdown (breakfast, transfer, parking etc.) accumulating into booking total; summary shows services breakdown
- SettingsPage: new "Гости" section with guest tag CRUD (add/edit/remove, color picker, live preview); booking section toggle "Показывать поле Источник бронирования"
- modulesData: added Documents module entry (free tier, sidebar item)
- RoomsPage: passes MOCK_CATEGORIES to RoomModal
- ReviewsPage: rewritten with auto-redirect logic, threshold slider, platform settings tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 22:17:54 +03:00
parent 56df6711e4
commit 96dfd755ed
11 changed files with 1816 additions and 529 deletions

View File

@@ -0,0 +1,405 @@
import { useState, useRef } from 'react'
import { Plus, Pencil, Trash2, ImagePlus, X as XIcon, Tag, ChevronRight } from 'lucide-react'
import { cn } from '../lib/utils'
import type { RoomCategory } from '../types'
// ── Mock data ─────────────────────────────────────────────────────────────────
const CATEGORY_COLORS = [
'#4F46E5', '#059669', '#2563EB', '#7C3AED',
'#DC2626', '#D97706', '#DB2777', '#475569',
]
const AMENITY_LIST = [
'Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi',
'Panoramic view', 'Kitchen', 'Terrace', 'Butler',
]
export const MOCK_CATEGORIES: RoomCategory[] = [
{
id: 'cat1',
hotelId: 'hotel-1',
name: 'Стандарт',
description: 'Уютные номера с базовым набором удобств. Идеально для деловых поездок и короткого отдыха.',
photos: [],
color: '#4F46E5',
amenities: ['Wi-Fi', 'TV', 'AC'],
},
{
id: 'cat2',
hotelId: 'hotel-1',
name: 'Делюкс',
description: 'Просторные номера с улучшенным интерьером, панорамными окнами и расширенным набором услуг.',
photos: [],
color: '#059669',
amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe'],
},
{
id: 'cat3',
hotelId: 'hotel-1',
name: 'Люкс & Пентхаус',
description: 'Роскошные апартаменты на верхних этажах с потрясающими видами, джакузи и персональным дворецким.',
photos: [],
color: '#7C3AED',
amenities: ['Wi-Fi', 'TV', 'AC', 'Mini-bar', 'Bathrobe', 'Jacuzzi', 'Panoramic view', 'Butler', 'Terrace'],
},
]
// Mock room counts per category
const MOCK_ROOM_COUNTS: Record<string, number> = {
cat1: 8,
cat2: 5,
cat3: 2,
}
// ── Category Form Modal ────────────────────────────────────────────────────────
interface CategoryFormProps {
category?: RoomCategory
onClose: () => void
onSave: (cat: RoomCategory) => void
}
function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
const isEdit = !!category
const [tab, setTab] = useState<'main' | 'photos'>('main')
const [name, setName] = useState(category?.name ?? '')
const [description, setDescription] = useState(category?.description ?? '')
const [color, setColor] = useState(category?.color ?? '#4F46E5')
const [amenities, setAmenities] = useState<string[]>(category?.amenities ?? [])
const [photos, setPhotos] = useState<string[]>(category?.photos ?? [])
const [photoIdx, setPhotoIdx] = useState(0)
const fileRef = useRef<HTMLInputElement>(null)
const toggleAmenity = (a: string) =>
setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
Array.from(e.target.files ?? []).forEach(file => {
const reader = new FileReader()
reader.onload = ev => {
const url = ev.target?.result as string
setPhotos(prev => { const next = [...prev, url]; setPhotoIdx(next.length - 1); return next })
}
reader.readAsDataURL(file)
})
e.target.value = ''
}
const removePhoto = (i: number) => {
setPhotos(prev => prev.filter((_, idx) => idx !== i))
setPhotoIdx(p => Math.max(0, p - 1))
}
const handleSave = () => {
if (!name.trim()) return
onSave({
id: category?.id ?? `cat-${Date.now()}`,
hotelId: category?.hotelId ?? 'hotel-1',
name: name.trim(),
description,
color,
amenities,
photos,
})
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-2xl bg-white dark:bg-slate-800 rounded-2xl shadow-2xl flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{isEdit ? `Редактировать: ${category.name}` : 'Новая категория'}
</h2>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500">
<XIcon size={18} />
</button>
</div>
{/* Tab bar */}
<div className="px-6 pt-3 flex gap-1 p-1 shrink-0">
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-700/50 rounded-xl w-full">
{(['main', 'photos'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
'flex-1 py-1.5 rounded-lg text-sm font-medium transition-colors',
tab === t
? 'bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 shadow-sm'
: 'text-slate-600 dark:text-slate-400',
)}
>
{t === 'main' ? 'Основное' : `Фото${photos.length > 0 ? ` (${photos.length})` : ''}`}
</button>
))}
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{tab === 'main' && (
<>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Название категории *</label>
<input type="text" className="input" placeholder="Стандарт" value={name} onChange={e => setName(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Цвет категории</label>
<div className="flex items-center gap-2 flex-wrap">
{CATEGORY_COLORS.map(c => (
<button
key={c}
onClick={() => setColor(c)}
className={cn(
'w-8 h-8 rounded-full border-2 transition-transform',
color === c ? 'border-slate-900 dark:border-slate-100 scale-110' : 'border-transparent',
)}
style={{ background: c }}
/>
))}
<input type="color" value={color} onChange={e => setColor(e.target.value)} className="w-8 h-8 rounded-full cursor-pointer" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Описание</label>
<p className="text-xs text-slate-500 mb-1">Отображается в виджете бронирования как описание категории</p>
<textarea
className="input resize-none w-full"
rows={4}
placeholder="Опишите категорию для гостей..."
value={description}
onChange={e => setDescription(e.target.value)}
/>
</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">
{AMENITY_LIST.map(a => {
const sel = amenities.includes(a)
return (
<button
key={a}
type="button"
onClick={() => toggleAmenity(a)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
sel
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-brand-400',
)}
>
{a}
</button>
)
})}
</div>
</div>
</>
)}
{tab === 'photos' && (
<>
{photos.length > 0 ? (
<div className="space-y-3">
<div className="relative rounded-xl overflow-hidden bg-slate-100 dark:bg-slate-700" style={{ height: 220 }}>
<img src={photos[photoIdx]} alt="" className="w-full h-full object-cover" />
<button
onClick={() => removePhoto(photoIdx)}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-red-600 text-white flex items-center justify-center"
>
<XIcon size={13} />
</button>
<div className="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-0.5 rounded-full">
{photoIdx + 1} / {photos.length}
</div>
</div>
<div className="flex gap-2 overflow-x-auto pb-1">
{photos.map((p, i) => (
<button key={i} onClick={() => setPhotoIdx(i)}
className={cn('w-14 h-14 rounded-lg overflow-hidden border-2 shrink-0', i === photoIdx ? 'border-brand-500' : 'border-transparent')}>
<img src={p} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-40 rounded-xl border-2 border-dashed border-slate-300 dark:border-slate-600 text-slate-400">
<ImagePlus size={28} className="mb-2 opacity-40" />
<p className="text-sm">Фото категории не загружены</p>
</div>
)}
<input ref={fileRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoUpload} />
<button onClick={() => fileRef.current?.click()} className="btn-secondary w-full justify-center">
<ImagePlus size={14} />
Загрузить фото
</button>
</>
)}
</div>
{/* Footer */}
<div className="shrink-0 px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3">
<button onClick={onClose} className="btn-secondary">Отмена</button>
<button onClick={handleSave} className="btn-primary" disabled={!name.trim()}>
{isEdit ? 'Сохранить' : 'Создать категорию'}
</button>
</div>
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export function RoomCategoriesPage() {
const [categories, setCategories] = useState<RoomCategory[]>(MOCK_CATEGORIES)
const [editingCat, setEditingCat] = useState<RoomCategory | undefined>(undefined)
const [formOpen, setFormOpen] = useState(false)
const openCreate = () => { setEditingCat(undefined); setFormOpen(true) }
const openEdit = (cat: RoomCategory) => { setEditingCat(cat); setFormOpen(true) }
const handleSave = (cat: RoomCategory) => {
setCategories(prev => {
const idx = prev.findIndex(c => c.id === cat.id)
if (idx >= 0) { const next = [...prev]; next[idx] = cat; return next }
return [...prev, cat]
})
setFormOpen(false)
}
const handleDelete = (id: string) =>
setCategories(prev => prev.filter(c => c.id !== id))
return (
<div className="p-4 md:p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<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>
<button className="btn-primary" onClick={openCreate}>
<Plus size={15} />Добавить категорию
</button>
</div>
{/* Explanation */}
<div className="card p-4 border-indigo-200 dark:border-indigo-700/50 bg-indigo-50/50 dark:bg-indigo-900/10">
<div className="flex items-start gap-3">
<Tag size={16} className="text-indigo-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Как работают категории</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Категории позволяют объединять номера в группы (Стандарт, Делюкс, Люкс).
В виджете бронирования гость выбирает категорию, а не конкретный номер.
Категория содержит описание и фотогалерею, которые видят гости на сайте.
</p>
</div>
</div>
</div>
{/* Categories list */}
<div className="space-y-4">
{categories.map(cat => {
const roomCount = MOCK_ROOM_COUNTS[cat.id] ?? 0
return (
<div key={cat.id} className="card p-5">
<div className="flex items-start gap-4">
{/* Color indicator + photo */}
<div
className="w-16 h-16 rounded-xl shrink-0 flex items-center justify-center overflow-hidden"
style={{ background: cat.photos.length > 0 ? undefined : cat.color + '20', borderColor: cat.color, borderWidth: 2 }}
>
{cat.photos.length > 0
? <img src={cat.photos[0]} alt="" className="w-full h-full object-cover" />
: <Tag size={24} style={{ color: cat.color }} />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">{cat.name}</h3>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{ background: cat.color + '20', color: cat.color }}
>
{roomCount} {roomCount === 1 ? 'номер' : roomCount < 5 ? 'номера' : 'номеров'}
</span>
</div>
{cat.description && (
<p className="text-sm text-slate-600 dark:text-slate-400 mb-2 line-clamp-2">{cat.description}</p>
)}
{/* Amenities */}
{cat.amenities.length > 0 && (
<div className="flex flex-wrap gap-1">
{cat.amenities.map(a => (
<span key={a} className="text-[10px] bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 px-1.5 py-0.5 rounded">
{a}
</span>
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 shrink-0">
{cat.photos.length > 0 && (
<span className="text-xs text-slate-400">{cat.photos.length} фото</span>
)}
<button
onClick={() => openEdit(cat)}
className="p-2 rounded-lg border border-slate-200 dark:border-slate-600 hover:border-brand-400 text-slate-500 hover:text-brand-600 transition-colors"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDelete(cat.id)}
className="p-2 rounded-lg border border-slate-200 dark:border-slate-600 hover:border-red-400 text-slate-500 hover:text-red-500 transition-colors"
>
<Trash2 size={14} />
</button>
<button
onClick={() => openEdit(cat)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-medium hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
>
Номера <ChevronRight size={12} />
</button>
</div>
</div>
</div>
)
})}
{categories.length === 0 && (
<div className="card p-10 text-center">
<Tag size={36} className="mx-auto mb-3 text-slate-300" />
<p className="font-medium text-slate-600 dark:text-slate-400">Категории не созданы</p>
<p className="text-sm text-slate-400 mt-1">Создайте первую категорию для группировки номеров</p>
<button className="btn-primary mt-4" onClick={openCreate}>
<Plus size={14} />Создать категорию
</button>
</div>
)}
</div>
{formOpen && (
<CategoryForm
category={editingCat}
onClose={() => setFormOpen(false)}
onSave={handleSave}
/>
)}
</div>
)
}