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:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user