Files
hotelsync/src/pages/GuestRoomServicePage.tsx
HotelSync 1975663cdd feat: full Room Service backend integration
- Migrations 086-088: room_service_settings, menu_items, orders+order_items
- Backend: /api/hotels/:slug/room-service/* routes (settings, menu CRUD,
  orders CRUD, public config, public order status)
- push.ts: sendPushToHotel() — push to all hotel staff on new order
- On new order: in-app notification + push to hotel staff
- api.ts: RoomService types + roomService API methods
- RoomServicePage: replaced mocked state with real API, 30s polling,
  add/edit/delete menu items modal, real advance/cancel order
- GuestRoomServicePage: fetches real menu + config, uses hotel payment
  methods, submits real order, polls status every 15s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:44:28 +03:00

457 lines
22 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, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import {
ShoppingCart, Plus, Minus, X, Clock, CheckCircle2,
ChefHat, Truck, Receipt, ArrowLeft, AlertCircle, Loader2,
} from 'lucide-react'
import { cn, formatCurrency } from '../lib/utils'
import { api } from '../lib/api'
import type { RoomServiceMenuItem, RoomServicePublicConfig } from '../lib/api'
const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://api.hotelsync.ru'
type Step = 'menu' | 'cart' | 'payment' | 'status'
type OrderStatus = 'new' | 'preparing' | 'ready' | 'delivered'
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: 'Курьер в пути к вашему номеру' },
]
export function GuestRoomServicePage() {
const { slug } = useParams<{ slug: string }>()
const [config, setConfig] = useState<RoomServicePublicConfig | null>(null)
const [menu, setMenu] = useState<RoomServiceMenuItem[]>([])
const [loadErr, setLoadErr] = useState('')
const [category, setCategory] = useState('')
const [cart, setCart] = useState<Record<string, number>>({})
const [step, setStep] = useState<Step>('menu')
const [roomNumber, setRoomNumber] = useState('')
const [guestName, setGuestName] = useState('')
const [notes, setNotes] = useState('')
const [payMethod, setPayMethod] = useState('')
const [submitting, setSubmitting] = useState(false)
const [submitErr, setSubmitErr] = useState('')
const [orderId, setOrderId] = useState('')
const [orderStatus, setOrderStatus] = useState<OrderStatus>('new')
// ── Load config + menu ────────────────────────────────────────────────────
useEffect(() => {
if (!slug) return
Promise.all([
api.roomService.getPublicConfig(slug),
api.roomService.getMenu(slug),
]).then(([cfg, items]) => {
setConfig(cfg)
setMenu(items)
if (items.length > 0) setCategory(items[0].category)
if (cfg.paymentMethods.length > 0) setPayMethod(cfg.paymentMethods[0].name)
}).catch(() => setLoadErr('Не удалось загрузить меню. Попробуйте позже.'))
}, [slug])
// ── Poll order status ─────────────────────────────────────────────────────
useEffect(() => {
if (!orderId || !slug || orderStatus === 'delivered') return
const poll = async () => {
try {
const res = await fetch(`${BASE}/api/hotels/${slug}/room-service/orders/${orderId}/status`)
if (res.ok) {
const data = await res.json() as { status: string }
if (['new','preparing','ready','delivered'].includes(data.status)) {
setOrderStatus(data.status as OrderStatus)
}
}
} catch { /* ignore */ }
}
const id = setInterval(poll, 15000)
return () => clearInterval(id)
}, [orderId, slug, orderStatus])
// ── Cart logic ────────────────────────────────────────────────────────────
const add = (id: string) => setCart(p => ({ ...p, [id]: (p[id] ?? 0) + 1 }))
const sub = (id: string) => setCart(p => ({ ...p, [id]: Math.max(0, (p[id] ?? 0) - 1) }))
const cartItems = menu.filter(i => (cart[i.id] ?? 0) > 0)
const subtotal = cartItems.reduce((s, i) => s + Number(i.price) * cart[i.id], 0)
const svcCharge = Math.round(subtotal * (config?.serviceChargePct ?? 0) / 100)
const total = subtotal + svcCharge
const cartCount = Object.values(cart).reduce((s, n) => s + n, 0)
const canOrder = cartCount > 0 && roomNumber.trim().length > 0
// ── Submit order ──────────────────────────────────────────────────────────
const placeOrder = async () => {
if (!slug || !canOrder) return
setSubmitting(true)
setSubmitErr('')
try {
const order = await api.roomService.createOrder(slug, {
roomNumber: roomNumber.trim(),
guestName: guestName.trim() || undefined,
orderType: 'room',
paymentMethod: payMethod || undefined,
notes: notes.trim() || undefined,
items: cartItems.map(i => ({ menuItemId: i.id, quantity: cart[i.id] })),
})
setOrderId(order.id)
setOrderStatus('new')
setStep('status')
} catch {
setSubmitErr('Не удалось отправить заказ. Попробуйте ещё раз.')
} finally {
setSubmitting(false)
}
}
const color = config ? '#2563eb' : '#2563eb' // hotel color if available in future
const currentStatusIdx = STATUS_STEPS.findIndex(s => s.key === orderStatus)
const categories = [...new Set(menu.map(i => i.category))]
// ── Loading ───────────────────────────────────────────────────────────────
if (loadErr) return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 p-4">
<div className="text-center">
<AlertCircle size={40} className="mx-auto mb-3 text-red-400" />
<p className="font-semibold text-slate-800">{loadErr}</p>
</div>
</div>
)
if (!config) return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<Loader2 size={32} className="animate-spin text-slate-400" />
</div>
)
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: 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.hotelName}</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">
<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: color, borderColor: color } : {}}
>
{cat}
</button>
))}
</div>
<div className="space-y-2">
{menu.filter(i => i.category === category && !i.isStopList).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.isPopular && (
<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.prepTimeMin} мин
</p>
<p className="font-bold text-slate-900 mt-1">{formatCurrency(Number(item.price))}</p>
</div>
{(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: 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: color }}>
<Plus size={14} />
</button>
</div>
)}
</div>
))}
</div>
{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: 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(Number(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: color }}>
<Plus size={12} />
</button>
</div>
<span className="text-sm font-semibold text-slate-800 w-16 text-right">{formatCurrency(Number(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>
{svcCharge > 0 && (
<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>
<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>
<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="Иван"
value={guestName}
onChange={e => setGuestName(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={() => config.paymentMethods.length > 0 ? setStep('payment') : placeOrder()}
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: color }}
>
{config.paymentMethods.length > 0 ? 'К оплате' : 'Оформить заказ'}
</button>
</div>
)}
{/* ── PAYMENT ── */}
{step === 'payment' && (
<div className="space-y-4">
<h2 className="font-bold text-slate-900 text-lg">Способ оплаты</h2>
{config.paymentMethods.map(method => (
<button
key={method.id}
onClick={() => setPayMethod(method.name)}
className={cn(
'w-full flex items-center gap-4 p-4 rounded-2xl border-2 text-left bg-white transition-colors',
payMethod === method.name ? 'border-blue-500' : 'border-slate-200',
)}
style={payMethod === method.name ? { borderColor: color } : {}}
>
<div
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 text-lg"
style={payMethod === method.name ? { backgroundColor: `${color}20` } : { backgroundColor: '#f1f5f9' }}
>
{method.type === 'cash' ? '💵' : method.type === 'card' ? '💳' : method.type === 'transfer' ? '📲' : '🏦'}
</div>
<div className="flex-1">
<p className="font-semibold text-slate-900 text-sm">{method.name}</p>
</div>
<div
className={cn('w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0', payMethod === method.name ? 'border-blue-500' : 'border-slate-300')}
style={payMethod === method.name ? { borderColor: color } : {}}
>
{payMethod === method.name && <div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: color }} />}
</div>
</button>
))}
<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(Number(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>
{submitErr && (
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-50 border border-red-200 text-sm text-red-700">
<AlertCircle size={14} />{submitErr}
</div>
)}
<button
onClick={placeOrder}
disabled={!payMethod || submitting}
className="w-full py-4 rounded-2xl text-white font-bold text-base transition-opacity hover:opacity-90 disabled:opacity-40 flex items-center justify-center gap-2"
style={{ backgroundColor: color }}
>
{submitting && <Loader2 size={16} className="animate-spin" />}
Оформить заказ {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: `${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>
{payMethod && <p className="text-slate-400 text-xs mt-0.5">Оплата: {payMethod}</p>}
</div>
<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: 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 && orderStatus !== 'delivered' && (
<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: color, animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
)}
</div>
)
})}
</div>
{orderStatus !== 'delivered' && (
<p className="text-xs text-slate-400 mt-4 text-center">Статус обновляется автоматически</p>
)}
</div>
<button
onClick={() => { setCart({}); setStep('menu'); setRoomNumber(''); setGuestName(''); setNotes(''); setOrderId('') }}
className="w-full py-3 rounded-2xl border-2 text-sm font-semibold transition-colors"
style={{ borderColor: color, color: color }}
>
Сделать ещё заказ
</button>
</div>
)}
</div>
</div>
)
}