Add maintenance severity levels, notifications context, and widget payment step

- HousekeepingPage: 3-level severity for maintenance reports (Не срочно / Средняя / Экстренно!); Экстренно automatically blocks the room and triggers an urgent notification; severity displayed as colored badge on task card
- NotificationsContext: extract shared notifications state from Topbar into context so any page can push notifications; add 'maintenance' type with wrench icon
- Topbar: migrate to NotificationsContext; add maintenance notification type with red Wrench icon
- BookingWidgetPage: add payment step after guest form when payment provider is connected (card number/expiry/CVV/name form); Без оплаты mode skips payment and shows "pay on site" message; hourly services (unit='ч') show date + time-from/time-to picker in form step

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 16:04:19 +03:00
parent f62ccdd3f7
commit c431ea1486
6 changed files with 461 additions and 157 deletions

View File

@@ -95,11 +95,18 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
const [extraBeds, setExtraBeds] = useState(0)
const [children, setChildren] = useState(0)
const [selected, setSelected] = useState<string | null>(null)
const [step, setStep] = useState<'browse' | 'form' | 'success'>('browse')
const [step, setStep] = useState<'browse' | 'form' | 'payment' | 'success'>('browse')
// Form state
const [formValues, setFormValues] = useState<Record<string, string>>({})
const [selectedServices, setSelectedServices] = useState<string[]>([])
// Hourly service time selections: { serviceId: { date, timeFrom, timeTo } }
const [serviceSchedule, setServiceSchedule] = useState<Record<string, { date: string; timeFrom: string; timeTo: string }>>({})
// Payment state
const [cardNumber, setCardNumber] = useState('')
const [cardExpiry, setCardExpiry] = useState('')
const [cardCvv, setCardCvv] = useState('')
const [cardName, setCardName] = useState('')
const nights = checkIn && checkOut
? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
@@ -115,6 +122,7 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
const grandTotal = roomTotal + extraTotal + servicesTotal
const activeFields = settings.formFields.filter(f => f.enabled)
const needsPayment = settings.paymentProvider !== 'none'
const handleBook = () => {
if (!selected || nights === 0) return
@@ -122,18 +130,36 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
}
const handleSubmit = () => {
// Check required fields
const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim())
if (missing.length > 0) return
if (needsPayment) {
setStep('payment')
} else {
setStep('success')
}
}
const handlePay = () => {
// Mock payment — just proceed to success
setStep('success')
}
const handleBack = () => {
if (step === 'payment') { setStep('form'); return }
setStep('browse')
setFormValues({})
setSelectedServices([])
setServiceSchedule({})
setCardNumber(''); setCardExpiry(''); setCardCvv(''); setCardName('')
}
const formatCard = (v: string) => v.replace(/\D/g, '').slice(0, 16).replace(/(.{4})/g, '$1 ').trim()
const formatExpiry = (v: string) => {
const d = v.replace(/\D/g, '').slice(0, 4)
return d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d
}
// ── Success screen ──
if (step === 'success') {
return (
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
@@ -145,7 +171,11 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
<Check size={32} style={{ color: settings.primaryColor }} />
</div>
<p className="text-lg font-bold text-slate-900">Бронирование принято!</p>
<p className="text-sm text-slate-500">Подтверждение придёт на email в течение нескольких минут</p>
<p className="text-sm text-slate-500">
{needsPayment
? 'Оплата прошла успешно. Подтверждение придёт на email.'
: 'Оплата на месте при заезде. Подтверждение придёт на email.'}
</p>
<div className="bg-slate-50 rounded-xl p-4 text-left space-y-1">
<p className="text-xs text-slate-500">Номер: <span className="font-medium text-slate-700">{selectedRoom?.name}</span></p>
<p className="text-xs text-slate-500">Заезд: <span className="font-medium text-slate-700">{checkIn}</span></p>
@@ -164,6 +194,103 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
)
}
// ── Payment screen ──
if (step === 'payment') {
const payLabel = settings.paymentProvider === 'yukassa' ? 'ЮKassa'
: settings.paymentProvider === 'tinkoff' ? 'Тинькофф Pay'
: 'CloudPayments'
return (
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
<div className="px-6 py-4 text-white flex items-center gap-3" style={{ background: settings.primaryColor }}>
<button onClick={handleBack} className="opacity-80 hover:opacity-100">
<ChevronLeft size={18} />
</button>
<div>
<p className="font-bold">{settings.language === 'ru' ? 'Оплата' : 'Payment'}</p>
<p className="text-xs opacity-80">{payLabel} · {grandTotal.toLocaleString('ru-RU')} </p>
</div>
</div>
<div className="p-5 space-y-4">
{/* Amount summary */}
<div className="bg-slate-50 rounded-xl p-3 flex items-center justify-between">
<span className="text-sm text-slate-600">{selectedRoom?.name} · {nights} ноч.</span>
<span className="text-base font-bold text-slate-900">{grandTotal.toLocaleString('ru-RU')} </span>
</div>
{/* Card form */}
<div className="space-y-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Номер карты</label>
<input
type="text"
inputMode="numeric"
placeholder="0000 0000 0000 0000"
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none font-mono tracking-wider"
value={cardNumber}
onChange={e => setCardNumber(formatCard(e.target.value))}
maxLength={19}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Срок действия</label>
<input
type="text"
inputMode="numeric"
placeholder="ММ/ГГ"
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none font-mono"
value={cardExpiry}
onChange={e => setCardExpiry(formatExpiry(e.target.value))}
maxLength={5}
/>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">CVV</label>
<input
type="password"
inputMode="numeric"
placeholder="•••"
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none font-mono"
value={cardCvv}
onChange={e => setCardCvv(e.target.value.replace(/\D/g, '').slice(0, 3))}
maxLength={3}
/>
</div>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Имя на карте</label>
<input
type="text"
placeholder="IVAN PETROV"
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none uppercase"
value={cardName}
onChange={e => setCardName(e.target.value.toUpperCase())}
/>
</div>
</div>
<p className="text-[11px] text-slate-400 text-center flex items-center justify-center gap-1">
🔒 Платёж защищён 3-D Secure · {payLabel}
</p>
</div>
<div className="px-5 pb-5">
<button
onClick={handlePay}
disabled={cardNumber.length < 19 || cardExpiry.length < 5 || cardCvv.length < 3 || !cardName.trim()}
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: settings.primaryColor }}
>
{settings.language === 'ru' ? `Оплатить ${grandTotal.toLocaleString('ru-RU')}` : `Pay ${grandTotal.toLocaleString('ru-RU')}`}
</button>
</div>
</div>
)
}
// ── Guest form screen ──
if (step === 'form') {
return (
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
@@ -175,6 +302,14 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
<p className="font-bold">{settings.language === 'ru' ? 'Данные гостя' : 'Guest details'}</p>
<p className="text-xs opacity-80">{selectedRoom?.name} · {nights} ноч.</p>
</div>
{/* Step indicator */}
<div className="ml-auto flex items-center gap-1">
{[1, 2, needsPayment ? 3 : null].filter(Boolean).map((s, i) => (
<div key={i} className={cn('w-2 h-2 rounded-full', step === 'form' && s === 1 ? 'bg-white' : 'bg-white/40')} />
))}
<div className={cn('w-2 h-2 rounded-full', step === 'form' ? 'bg-white' : 'bg-white/40')} />
{needsPayment && <div className="w-2 h-2 rounded-full bg-white/40" />}
</div>
</div>
<div className="p-5 space-y-3 max-h-[500px] overflow-y-auto">
@@ -211,21 +346,71 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
<p className="text-xs font-medium text-slate-700 mb-1.5">
{settings.language === 'ru' ? 'Дополнительные услуги' : 'Additional services'}
</p>
<div className="space-y-1.5">
{settings.additionalServices.filter(s => s.enabled).map(s => (
<label key={s.id} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedServices.includes(s.id)}
onChange={e => setSelectedServices(prev =>
e.target.checked ? [...prev, s.id] : prev.filter(x => x !== s.id)
<div className="space-y-2">
{settings.additionalServices.filter(s => s.enabled).map(s => {
const isHourly = s.unit === 'ч'
const schedule = serviceSchedule[s.id]
const isSelected = selectedServices.includes(s.id)
return (
<div key={s.id} className="space-y-1.5">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isSelected}
onChange={e => {
setSelectedServices(prev =>
e.target.checked ? [...prev, s.id] : prev.filter(x => x !== s.id)
)
if (!e.target.checked) {
setServiceSchedule(prev => { const n = {...prev}; delete n[s.id]; return n })
}
}}
className="rounded"
/>
<span className="text-sm text-slate-700 flex-1">{s.icon} {s.name}</span>
<span className="text-xs text-slate-500">{s.price.toLocaleString('ru-RU')} /{s.unit}</span>
</label>
{/* Hourly time picker */}
{isSelected && isHourly && (
<div className="ml-5 grid grid-cols-3 gap-2 p-2 bg-slate-50 rounded-lg border border-slate-200">
<div>
<label className="block text-[10px] text-slate-500 mb-0.5">Дата</label>
<input
type="date"
className="w-full text-xs border border-slate-200 rounded px-1.5 py-1 bg-white focus:outline-none"
value={schedule?.date ?? checkIn}
onChange={e => setServiceSchedule(prev => ({ ...prev, [s.id]: { ...prev[s.id], date: e.target.value, timeFrom: prev[s.id]?.timeFrom ?? '10:00', timeTo: prev[s.id]?.timeTo ?? '12:00' } }))}
/>
</div>
<div>
<label className="block text-[10px] text-slate-500 mb-0.5">С</label>
<select
className="w-full text-xs border border-slate-200 rounded px-1.5 py-1 bg-white focus:outline-none"
value={schedule?.timeFrom ?? '10:00'}
onChange={e => setServiceSchedule(prev => ({ ...prev, [s.id]: { ...prev[s.id], date: prev[s.id]?.date ?? checkIn, timeFrom: e.target.value, timeTo: prev[s.id]?.timeTo ?? '12:00' } }))}
>
{Array.from({ length: 17 }, (_, i) => i + 7).map(h => (
<option key={h} value={`${String(h).padStart(2,'0')}:00`}>{String(h).padStart(2,'0')}:00</option>
))}
</select>
</div>
<div>
<label className="block text-[10px] text-slate-500 mb-0.5">По</label>
<select
className="w-full text-xs border border-slate-200 rounded px-1.5 py-1 bg-white focus:outline-none"
value={schedule?.timeTo ?? '12:00'}
onChange={e => setServiceSchedule(prev => ({ ...prev, [s.id]: { ...prev[s.id], date: prev[s.id]?.date ?? checkIn, timeFrom: prev[s.id]?.timeFrom ?? '10:00', timeTo: e.target.value } }))}
>
{Array.from({ length: 17 }, (_, i) => i + 8).map(h => (
<option key={h} value={`${String(h).padStart(2,'0')}:00`}>{String(h).padStart(2,'0')}:00</option>
))}
</select>
</div>
</div>
)}
className="rounded text-sm"
/>
<span className="text-sm text-slate-700 flex-1">{s.icon} {s.name}</span>
<span className="text-xs text-slate-500">{s.price.toLocaleString('ru-RU')} /{s.unit}</span>
</label>
))}
</div>
)
})}
</div>
</div>
)}
@@ -240,7 +425,6 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
<textarea
rows={2}
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none resize-none"
style={{ '--focus-color': settings.primaryColor } as any}
value={formValues[field.id] ?? ''}
onChange={e => setFormValues(prev => ({ ...prev, [field.id]: e.target.value }))}
/>
@@ -268,14 +452,19 @@ function WidgetPreview({ settings }: { settings: WidgetSettings }) {
))}
</div>
<div className="px-5 pb-5 pt-2">
<div className="px-5 pb-5 pt-2 space-y-2">
<button
onClick={handleSubmit}
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90"
style={{ background: settings.primaryColor }}
>
{settings.language === 'ru' ? `Подтвердить бронирование · ${grandTotal.toLocaleString('ru-RU')}` : `Confirm booking · ${grandTotal.toLocaleString('ru-RU')}`}
{needsPayment
? (settings.language === 'ru' ? `Перейти к оплате · ${grandTotal.toLocaleString('ru-RU')}` : `Proceed to payment · ${grandTotal.toLocaleString('ru-RU')}`)
: (settings.language === 'ru' ? `Подтвердить · ${grandTotal.toLocaleString('ru-RU')}` : `Confirm · ${grandTotal.toLocaleString('ru-RU')}`)}
</button>
{!needsPayment && (
<p className="text-center text-xs text-slate-400">Оплата на месте при заезде</p>
)}
</div>
</div>
)