Files
hotelsync/src/pages/GuestRoomServicePage.tsx
HotelSync d661bbbc84 Add role permissions, loyalty accrual rules, guest statuses, and room service guest page
- UsersPage: add "Роли и права" tab with per-module permission editor per role; create/delete custom roles
- LoyaltyPage: add "Правила начисления" tab with togglable accrual rules editor; add "Ручное начисление" tab with guest search by name/phone/email, add/deduct points with reason and history; fix banner text (HotelSync is a PMS, not a hotel chain)
- GuestsPage: add "Статусы гостей" tab (moved from Settings)
- SettingsPage: remove Guests section; add compact calendar toggle in Appearance; toggle persists via localStorage
- BookingCalendar: read compact mode default from localStorage
- App.tsx: add public route /room-service/:slug → GuestRoomServicePage
- GuestRoomServicePage: new public guest-facing page for ordering room service with menu, cart, payment, and order status tracking
- RoomServicePage: QR/link sharing, payment mode toggles, new order notifications
- RentalPage/rentalData: buffer time between bookings (bufferMinutes field)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 20:13:22 +03:00

421 lines
21 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 { useParams } from 'react-router-dom'
import {
ShoppingCart, Plus, Minus, X, Clock, CheckCircle2,
ChefHat, Truck, CreditCard, Receipt, ArrowLeft,
} from 'lucide-react'
import { cn, formatCurrency } from '../lib/utils'
// ── Types ─────────────────────────────────────────────────────────────────────
interface MenuItem {
id: string
name: string
category: string
price: number
emoji: string
time: number
popular?: boolean
stopList?: boolean
}
type Step = 'menu' | 'cart' | 'payment' | 'status'
type OrderStatus = 'new' | 'preparing' | 'ready' | 'delivered'
// ── Mock data (same menu as staff side) ───────────────────────────────────────
const MENU: MenuItem[] = [
{ id: 'm1', name: 'Яичница с беконом', category: 'Завтраки', price: 450, emoji: '🍳', time: 15, popular: true },
{ id: 'm2', name: 'Омлет с овощами', category: 'Завтраки', price: 380, emoji: '🥚', time: 12 },
{ id: 'm3', name: 'Сырники', category: 'Завтраки', price: 320, emoji: '🧀', time: 10 },
{ id: 'm4', name: 'Овсяная каша', category: 'Завтраки', price: 250, emoji: '🥣', time: 8 },
{ id: 'm5', name: 'Стейк рибай', category: 'Основные', price: 1800, emoji: '🥩', time: 25, popular: true },
{ id: 'm6', name: 'Куриная грудка', category: 'Основные', price: 750, emoji: '🍗', time: 20 },
{ id: 'm7', name: 'Паста карбонара', category: 'Основные', price: 650, emoji: '🍝', time: 18 },
{ id: 'm8', name: 'Лосось на гриле', category: 'Основные', price: 1200, emoji: '🐟', time: 22 },
{ id: 'm9', name: 'Цезарь с курицей', category: 'Закуски', price: 550, emoji: '🥗', time: 10, popular: true },
{ id: 'm10', name: 'Сэндвич клуб', category: 'Закуски', price: 420, emoji: '🥪', time: 8 },
{ id: 'm11', name: 'Сырная тарелка', category: 'Закуски', price: 680, emoji: '🧀', time: 5 },
{ id: 'm12', name: 'Кофе американо', category: 'Напитки', price: 180, emoji: '☕', time: 5, popular: true },
{ id: 'm13', name: 'Капучино', category: 'Напитки', price: 220, emoji: '☕', time: 6 },
{ id: 'm14', name: 'Свежевыжатый сок', category: 'Напитки', price: 280, emoji: '🍊', time: 5 },
{ id: 'm15', name: 'Вино красное бокал', category: 'Напитки', price: 450, emoji: '🍷', time: 3 },
{ id: 'm16', name: 'Тирамису', category: 'Десерты', price: 380, emoji: '🍮', time: 3 },
{ id: 'm17', name: 'Чизкейк', category: 'Десерты', price: 350, emoji: '🍰', time: 3 },
]
const CATEGORIES = [...new Set(MENU.map(i => i.category))]
const HOTEL_CONFIG: Record<string, { name: string; color: string; serviceChargePct: number }> = {
'grand-palace': { name: 'Гранд Палас', color: '#2563eb', serviceChargePct: 15 },
}
const DEFAULT_CONFIG = { name: 'Отель', color: '#2563eb', serviceChargePct: 15 }
// ── Status display ─────────────────────────────────────────────────────────────
const STATUS_STEPS: { key: OrderStatus; label: string; icon: React.ElementType; desc: string }[] = [
{ key: 'new', label: 'Принят', icon: Receipt, desc: 'Заказ отправлен на кухню' },
{ key: 'preparing', label: 'Готовится', icon: ChefHat, desc: 'Повар готовит ваш заказ' },
{ key: 'ready', label: 'Готов', icon: CheckCircle2, desc: 'Заказ готов, передаём курьеру' },
{ key: 'delivered', label: 'Доставлен', icon: Truck, desc: 'Курьер в пути к вашему номеру' },
]
// ── Component ─────────────────────────────────────────────────────────────────
export function GuestRoomServicePage() {
const { slug } = useParams<{ slug: string }>()
const config = (slug ? HOTEL_CONFIG[slug] : null) ?? DEFAULT_CONFIG
const [category, setCategory] = useState(CATEGORIES[0])
const [cart, setCart] = useState<Record<string, number>>({})
const [step, setStep] = useState<Step>('menu')
const [roomNumber, setRoomNumber] = useState('')
const [notes, setNotes] = useState('')
const [payMode, setPayMode] = useState<'online' | 'bill'>('online')
const [orderStatus, setOrderStatus] = useState<OrderStatus>('new')
const cartItems = MENU.filter(i => (cart[i.id] ?? 0) > 0)
const subtotal = cartItems.reduce((s, i) => s + i.price * cart[i.id], 0)
const svcCharge = Math.round(subtotal * config.serviceChargePct / 100)
const total = subtotal + svcCharge
const cartCount = Object.values(cart).reduce((s, n) => s + n, 0)
const add = (id: string) => setCart(p => ({ ...p, [id]: (p[id] ?? 0) + 1 }))
const sub = (id: string) => setCart(p => { const n = Math.max(0, (p[id] ?? 0) - 1); return { ...p, [id]: n } })
const canOrder = cartCount > 0 && roomNumber.trim().length > 0
const placeOrder = () => {
// Simulate status progression
setStep('status')
setOrderStatus('new')
setTimeout(() => setOrderStatus('preparing'), 3000)
setTimeout(() => setOrderStatus('ready'), 8000)
setTimeout(() => setOrderStatus('delivered'), 13000)
}
const currentStatusIdx = STATUS_STEPS.findIndex(s => s.key === orderStatus)
return (
<div className="min-h-screen bg-slate-50" style={{ fontFamily: 'system-ui, sans-serif' }}>
{/* Header */}
<div className="sticky top-0 z-20 shadow-sm" style={{ backgroundColor: config.color }}>
<div className="max-w-lg mx-auto px-4 py-3 flex items-center gap-3">
{step !== 'menu' && step !== 'status' && (
<button onClick={() => setStep(step === 'payment' ? 'cart' : 'menu')} className="text-white/80 hover:text-white">
<ArrowLeft size={20} />
</button>
)}
<div className="flex-1">
<p className="text-white font-bold text-base leading-tight">{config.name}</p>
<p className="text-white/70 text-xs">Room Service</p>
</div>
{step === 'menu' && cartCount > 0 && (
<button
onClick={() => setStep('cart')}
className="flex items-center gap-2 bg-white/20 hover:bg-white/30 text-white px-3 py-1.5 rounded-xl text-sm font-medium transition-colors"
>
<ShoppingCart size={14} />
{cartCount}
<span className="font-bold">{formatCurrency(total)}</span>
</button>
)}
</div>
</div>
<div className="max-w-lg mx-auto px-4 py-4">
{/* ── MENU ── */}
{step === 'menu' && (
<div className="space-y-4">
{/* Category tabs */}
<div className="flex gap-2 overflow-x-auto pb-1 -mx-4 px-4">
{CATEGORIES.map(cat => (
<button
key={cat}
onClick={() => setCategory(cat)}
className={cn(
'shrink-0 px-3 py-1.5 rounded-full text-sm font-medium border transition-colors',
category === cat
? 'text-white border-transparent'
: 'bg-white text-slate-700 border-slate-200',
)}
style={category === cat ? { backgroundColor: config.color, borderColor: config.color } : {}}
>
{cat}
</button>
))}
</div>
{/* Items */}
<div className="space-y-2">
{MENU.filter(i => i.category === category && !i.stopList).map(item => (
<div key={item.id} className="bg-white rounded-2xl p-4 shadow-sm flex items-center gap-4">
<span className="text-3xl shrink-0">{item.emoji}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<p className="font-semibold text-slate-900 text-sm">{item.name}</p>
{item.popular && (
<span className="text-[10px] bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded-full font-medium">Хит</span>
)}
</div>
<p className="text-xs text-slate-400 flex items-center gap-1 mt-0.5">
<Clock size={10} />{item.time} мин
</p>
<p className="font-bold text-slate-900 mt-1">{formatCurrency(item.price)}</p>
</div>
{/* Counter */}
{(cart[item.id] ?? 0) === 0 ? (
<button
onClick={() => add(item.id)}
className="w-9 h-9 rounded-full flex items-center justify-center text-white shadow-md transition-transform active:scale-90"
style={{ backgroundColor: config.color }}
>
<Plus size={18} />
</button>
) : (
<div className="flex items-center gap-2">
<button onClick={() => sub(item.id)} className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-slate-700">
<Minus size={14} />
</button>
<span className="text-sm font-bold w-4 text-center">{cart[item.id]}</span>
<button
onClick={() => add(item.id)}
className="w-8 h-8 rounded-full flex items-center justify-center text-white"
style={{ backgroundColor: config.color }}
>
<Plus size={14} />
</button>
</div>
)}
</div>
))}
</div>
{/* Sticky cart button */}
{cartCount > 0 && (
<div className="sticky bottom-4">
<button
onClick={() => setStep('cart')}
className="w-full py-4 rounded-2xl text-white font-bold text-base shadow-xl transition-opacity hover:opacity-90 active:scale-95"
style={{ backgroundColor: config.color }}
>
Перейти к заказу · {formatCurrency(total)}
</button>
</div>
)}
</div>
)}
{/* ── CART ── */}
{step === 'cart' && (
<div className="space-y-4">
<h2 className="font-bold text-slate-900 text-lg">Ваш заказ</h2>
<div className="bg-white rounded-2xl shadow-sm overflow-hidden">
{cartItems.map((item, i) => (
<div key={item.id} className={cn('flex items-center gap-3 px-4 py-3', i < cartItems.length - 1 && 'border-b border-slate-100')}>
<span className="text-2xl">{item.emoji}</span>
<div className="flex-1">
<p className="text-sm font-medium text-slate-800">{item.name}</p>
<p className="text-xs text-slate-500">{formatCurrency(item.price)} × {cart[item.id]}</p>
</div>
<div className="flex items-center gap-2">
<button onClick={() => sub(item.id)} className="w-7 h-7 rounded-full bg-slate-100 flex items-center justify-center text-slate-600">
{cart[item.id] === 1 ? <X size={12} /> : <Minus size={12} />}
</button>
<span className="text-sm font-bold w-4 text-center">{cart[item.id]}</span>
<button
onClick={() => add(item.id)}
className="w-7 h-7 rounded-full flex items-center justify-center text-white"
style={{ backgroundColor: config.color }}
>
<Plus size={12} />
</button>
</div>
<span className="text-sm font-semibold text-slate-800 w-16 text-right">{formatCurrency(item.price * cart[item.id])}</span>
</div>
))}
<div className="px-4 py-3 bg-slate-50 space-y-1.5">
<div className="flex justify-between text-sm text-slate-600">
<span>Сумма заказа</span>
<span>{formatCurrency(subtotal)}</span>
</div>
<div className="flex justify-between text-sm text-slate-600">
<span>Обслуживание {config.serviceChargePct}%</span>
<span>+{formatCurrency(svcCharge)}</span>
</div>
<div className="flex justify-between font-bold text-slate-900 text-base pt-1 border-t border-slate-200">
<span>Итого</span>
<span>{formatCurrency(total)}</span>
</div>
</div>
</div>
{/* Room number */}
<div className="bg-white rounded-2xl shadow-sm p-4 space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Номер комнаты *</label>
<input
type="text"
className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm focus:outline-none focus:border-blue-400"
placeholder="Например: 301"
value={roomNumber}
onChange={e => setRoomNumber(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Пожелания (необязательно)</label>
<textarea
className="w-full border border-slate-200 rounded-xl px-3 py-2.5 text-sm resize-none focus:outline-none focus:border-blue-400"
rows={2}
placeholder="Без лука, аллергия на орехи..."
value={notes}
onChange={e => setNotes(e.target.value)}
/>
</div>
</div>
<button
onClick={() => setStep('payment')}
disabled={!canOrder}
className="w-full py-4 rounded-2xl text-white font-bold text-base disabled:opacity-40 transition-opacity hover:opacity-90"
style={{ backgroundColor: config.color }}
>
К оплате
</button>
</div>
)}
{/* ── PAYMENT ── */}
{step === 'payment' && (
<div className="space-y-4">
<h2 className="font-bold text-slate-900 text-lg">Способ оплаты</h2>
{[
{ key: 'online' as const, icon: CreditCard, label: 'Оплатить онлайн сейчас', desc: 'Банковской картой через защищённую форму' },
{ key: 'bill' as const, icon: Receipt, label: 'Включить в счёт при выезде', desc: 'Оплата при расчёте в конце проживания' },
].map(opt => (
<button
key={opt.key}
onClick={() => setPayMode(opt.key)}
className={cn(
'w-full flex items-center gap-4 p-4 rounded-2xl border-2 text-left bg-white transition-colors',
payMode === opt.key ? 'border-blue-500' : 'border-slate-200',
)}
style={payMode === opt.key ? { borderColor: config.color } : {}}
>
<div
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
style={payMode === opt.key ? { backgroundColor: `${config.color}20` } : { backgroundColor: '#f1f5f9' }}
>
<opt.icon size={18} style={payMode === opt.key ? { color: config.color } : { color: '#64748b' }} />
</div>
<div>
<p className="font-semibold text-slate-900 text-sm">{opt.label}</p>
<p className="text-xs text-slate-500 mt-0.5">{opt.desc}</p>
</div>
<div className={cn(
'ml-auto w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0',
payMode === opt.key ? 'border-blue-500' : 'border-slate-300',
)} style={payMode === opt.key ? { borderColor: config.color } : {}}>
{payMode === opt.key && (
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: config.color }} />
)}
</div>
</button>
))}
{/* Summary */}
<div className="bg-white rounded-2xl shadow-sm p-4">
<p className="text-sm font-semibold text-slate-700 mb-2">Номер {roomNumber}</p>
{cartItems.map(i => (
<div key={i.id} className="flex justify-between text-sm text-slate-600 py-0.5">
<span>{i.emoji} {i.name} ×{cart[i.id]}</span>
<span>{formatCurrency(i.price * cart[i.id])}</span>
</div>
))}
<div className="flex justify-between font-bold text-slate-900 text-base mt-2 pt-2 border-t border-slate-100">
<span>Итого</span>
<span>{formatCurrency(total)}</span>
</div>
</div>
<button
onClick={placeOrder}
className="w-full py-4 rounded-2xl text-white font-bold text-base transition-opacity hover:opacity-90"
style={{ backgroundColor: config.color }}
>
{payMode === 'online' ? `Оплатить ${formatCurrency(total)}` : 'Оформить заказ'}
</button>
</div>
)}
{/* ── STATUS ── */}
{step === 'status' && (
<div className="space-y-6 py-4">
<div className="text-center">
<div
className="w-20 h-20 rounded-full mx-auto flex items-center justify-center text-4xl mb-4"
style={{ backgroundColor: `${config.color}15` }}
>
🎉
</div>
<h2 className="text-xl font-bold text-slate-900">Заказ принят!</h2>
<p className="text-slate-500 text-sm mt-1">Номер {roomNumber} · {formatCurrency(total)}</p>
</div>
{/* Progress */}
<div className="bg-white rounded-2xl shadow-sm p-5">
<p className="text-sm font-semibold text-slate-700 mb-4">Статус заказа</p>
<div className="space-y-4">
{STATUS_STEPS.map((s, idx) => {
const done = idx <= currentStatusIdx
const current = idx === currentStatusIdx
const Icon = s.icon
return (
<div key={s.key} className="flex items-center gap-3">
<div className={cn(
'w-9 h-9 rounded-full flex items-center justify-center shrink-0 transition-colors',
done ? 'text-white' : 'bg-slate-100 text-slate-400',
)} style={done ? { backgroundColor: config.color } : {}}>
<Icon size={16} />
</div>
<div className="flex-1">
<p className={cn('text-sm font-semibold', done ? 'text-slate-900' : 'text-slate-400')}>
{s.label}
</p>
{current && <p className="text-xs text-slate-500 mt-0.5">{s.desc}</p>}
</div>
{current && (
<div className="flex gap-0.5">
{[0,1,2].map(i => (
<div
key={i}
className="w-1.5 h-1.5 rounded-full animate-bounce"
style={{ backgroundColor: config.color, animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
)}
</div>
)
})}
</div>
</div>
<button
onClick={() => { setCart({}); setStep('menu'); setRoomNumber(''); setNotes('') }}
className="w-full py-3 rounded-2xl border-2 text-sm font-semibold transition-colors"
style={{ borderColor: config.color, color: config.color }}
>
Сделать ещё заказ
</button>
</div>
)}
</div>
</div>
)
}