feat: blacklist, guest lookup, bank transfer payment page

- Add is_blacklisted column to guests (migration 068)
- POST /widget/bookings: block blacklisted guests (403, generic message)
- GET /widget/:slug/guests/lookup: find existing guest by email/phone for auto-fill
- Widget form: debounce guest lookup on email/phone input, show "Заполнить" suggestion
- Widget payment screen: replace fake card form with proper YooKassa redirect screen or bank transfer details page
- Add bank transfer details field to widget settings (saved as widget_bank_details)
- GuestsPage: blacklist toggle button in edit modal + 🚫 ЧС badge in table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 01:31:10 +03:00
parent 08d6629ae8
commit 2b20fc18b3
7 changed files with 277 additions and 90 deletions

View File

@@ -44,6 +44,7 @@ export interface WidgetSettings {
showPromo: boolean
allowExtraBeds: boolean
allowChildren: boolean
bankDetails: string
formFields: FormField[]
additionalServices: AdditionalService[]
}
@@ -212,11 +213,10 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
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 (kept for mock display)
const [cardNumber, setCardNumber] = useState('')
const [cardExpiry, setCardExpiry] = useState('')
const [cardCvv, setCardCvv] = useState('')
const [cardName, setCardName] = useState('')
const [bookingError, setBookingError] = useState<string | null>(null)
// Guest lookup
const [guestLookupTimer, setGuestLookupTimer] = useState<ReturnType<typeof setTimeout> | null>(null)
const [guestSuggestion, setGuestSuggestion] = useState<{ first_name: string; last_name: string; middle_name?: string; phone?: string; email?: string } | null>(null)
const nights = checkIn && checkOut
? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
@@ -264,6 +264,7 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
const handleSubmit = async () => {
const missing = activeFields.filter(f => f.required && !formValues[f.id]?.trim())
if (missing.length > 0) return
setBookingError(null)
if (slug && selected) {
// Real API call
@@ -288,18 +289,23 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
if (result.confirmationUrl) {
setConfirmUrl(result.confirmationUrl)
setStep('payment')
} else if (settings.bankDetails) {
setStep('payment')
} else {
setStep('success')
}
} catch (err: any) {
if (err?.status === 403) {
setBookingError(err?.error ?? 'Невозможно завершить бронирование. Попробуйте позже или свяжитесь с отелем.')
} else {
setStep('success')
}
} catch {
// fallback — still show success in preview
setStep('success')
} finally {
setSubmitting(false)
}
} else {
// Preview mode without real slug
if (needsPayment) {
if (needsPayment || settings.bankDetails) {
setStep('payment')
} else {
setStep('success')
@@ -309,9 +315,10 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
const handlePay = () => {
if (confirmUrl) {
window.open(confirmUrl, '_blank')
window.location.href = confirmUrl
} else {
setStep('success')
}
setStep('success')
}
const handleBack = () => {
@@ -320,13 +327,18 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
setFormValues({})
setSelectedServices([])
setServiceSchedule({})
setCardNumber(''); setCardExpiry(''); setCardCvv(''); setCardName('')
setBookingError(null)
setGuestSuggestion(null)
}
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
const doGuestLookup = (email?: string, phone?: string) => {
if (!slug || (!email && !phone)) return
if (guestLookupTimer) clearTimeout(guestLookupTimer)
const t = setTimeout(async () => {
const g = await api.widget.lookupGuest(slug, { email, phone })
if (g && !g.is_blacklisted) setGuestSuggestion(g)
}, 600)
setGuestLookupTimer(t)
}
// ── Success screen ──
@@ -366,9 +378,7 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
// ── Payment screen ──
if (step === 'payment') {
const payLabel = settings.paymentProvider === 'yukassa' ? 'ЮKassa'
: settings.paymentProvider === 'tinkoff' ? 'Тинькофф Pay'
: 'CloudPayments'
const isYookassa = !!confirmUrl
return (
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}>
@@ -378,83 +388,64 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
</button>
<div>
<p className="font-bold">{settings.language === 'ru' ? 'Оплата' : 'Payment'}</p>
<p className="text-xs opacity-80">{payLabel} · {grandTotal.toLocaleString('ru-RU')} </p>
<p className="text-xs opacity-80">{selectedName} · {grandTotal.toLocaleString('ru-RU')} </p>
</div>
</div>
<div className="p-5 space-y-4">
{/* Amount summary */}
{/* Amount */}
<div className="bg-slate-50 rounded-xl p-3 flex items-center justify-between">
<span className="text-sm text-slate-600">{selectedName} · {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}
/>
{isYookassa ? (
/* YooKassa redirect */
<div className="text-center space-y-3 py-2">
<div className="w-14 h-14 rounded-full flex items-center justify-center mx-auto bg-slate-100">
<CreditCard size={28} className="text-slate-500" />
</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}
/>
<p className="text-sm text-slate-700 font-medium">
{settings.language === 'ru' ? 'Для оплаты вы будете перенаправлены на страницу ЮKassa' : 'You will be redirected to YooKassa to complete payment'}
</p>
<p className="text-xs text-slate-400">🔒 Защищённое соединение · ЮKassa</p>
</div>
) : (
/* Bank transfer details */
<div className="space-y-3">
<p className="text-sm font-medium text-slate-700">
{settings.language === 'ru' ? 'Реквизиты для оплаты' : 'Payment details'}
</p>
<div className="bg-slate-50 rounded-xl p-4 text-sm text-slate-700 whitespace-pre-wrap leading-relaxed">
{settings.bankDetails || 'Реквизиты не указаны'}
</div>
<p className="text-xs text-slate-400">
{settings.language === 'ru'
? 'После перевода отель подтвердит бронирование. Укажите в назначении платежа вашу фамилию и даты.'
: 'After transfer, the hotel will confirm your booking. Include your name and dates in the payment reference.'}
</p>
</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>
{isYookassa ? (
<button
onClick={handlePay}
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')}` : `Proceed to pay · ${grandTotal.toLocaleString('ru-RU')}`}
</button>
) : (
<button
onClick={() => setStep('success')}
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' ? 'Я перевёл оплату' : 'I have paid'}
</button>
)}
</div>
</div>
)
@@ -615,14 +606,51 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
type={field.type}
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none"
value={formValues[field.id] ?? ''}
onChange={e => setFormValues(prev => ({ ...prev, [field.id]: e.target.value }))}
onChange={e => {
setFormValues(prev => ({ ...prev, [field.id]: e.target.value }))
if (field.id === 'email') doGuestLookup(e.target.value, formValues['phone'])
if (field.id === 'phone') doGuestLookup(formValues['email'], e.target.value)
}}
/>
)}
</div>
))}
{/* Guest suggestion */}
{guestSuggestion && (
<div className="flex items-center justify-between bg-indigo-50 border border-indigo-200 rounded-xl px-3 py-2">
<div className="text-xs text-indigo-700">
<span className="font-medium">Гость найден: </span>
{guestSuggestion.last_name} {guestSuggestion.first_name} {guestSuggestion.middle_name ?? ''}
</div>
<button
className="text-xs font-semibold text-indigo-600 hover:text-indigo-800 ml-2 shrink-0"
onClick={() => {
const g = guestSuggestion
const fullName = [g.last_name, g.first_name, g.middle_name].filter(Boolean).join(' ')
setFormValues(prev => ({
...prev,
name: fullName,
full_name: fullName,
...(g.email && { email: g.email }),
...(g.phone && { phone: g.phone }),
}))
setGuestSuggestion(null)
}}
>
Заполнить
</button>
</div>
)}
</div>
<div className="px-5 pb-5 pt-2 space-y-2">
{bookingError && (
<div className="flex items-start gap-2 bg-red-50 border border-red-200 rounded-xl px-3 py-2">
<AlertCircle size={14} className="text-red-500 mt-0.5 shrink-0" />
<p className="text-xs text-red-700">{bookingError}</p>
</div>
)}
<button
onClick={handleSubmit}
disabled={submitting}
@@ -630,11 +658,11 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
style={{ background: settings.primaryColor }}
>
{submitting && <Loader2 size={14} className="animate-spin" />}
{submitting ? 'Отправляем...' : needsPayment
{submitting ? 'Отправляем...' : (needsPayment || settings.bankDetails)
? (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 && (
{!needsPayment && !settings.bankDetails && (
<p className="text-center text-xs text-slate-400">Оплата на месте при заезде</p>
)}
</div>
@@ -1122,6 +1150,7 @@ export function BookingWidgetPage() {
showPromo: (hs as any).widgetShowPromo !== undefined ? Boolean((hs as any).widgetShowPromo) : prev.showPromo,
allowExtraBeds: (hs as any).widgetExtraBeds !== undefined ? Boolean((hs as any).widgetExtraBeds) : prev.allowExtraBeds,
allowChildren: (hs as any).widgetChildren !== undefined ? Boolean((hs as any).widgetChildren) : prev.allowChildren,
bankDetails: String((hs as any).widgetBankDetails ?? prev.bankDetails ?? ''),
}))
setSettingsReady(true)
}).finally(() => setGatewayLoading(false))
@@ -1139,6 +1168,7 @@ export function BookingWidgetPage() {
showPromo: true,
allowExtraBeds: true,
allowChildren: true,
bankDetails: '',
formFields: DEFAULT_FORM_FIELDS,
additionalServices: DEFAULT_SERVICES,
})
@@ -1168,6 +1198,7 @@ export function BookingWidgetPage() {
widget_show_promo: s.showPromo,
widget_extra_beds: s.allowExtraBeds,
widget_children: s.allowChildren,
widget_bank_details: s.bankDetails,
})
setSaved(true)
setTimeout(() => setSaved(false), 2000)
@@ -1507,6 +1538,24 @@ export function BookingWidgetPage() {
)}
</div>
{/* Bank transfer details */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<CreditCard size={14} className="text-slate-400" />
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">Реквизиты для оплаты переводом</p>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Если ЮKassa не подключена, гость увидит эти реквизиты после бронирования. Оставьте пустым если оплата на месте.
</p>
<textarea
rows={4}
className="input w-full text-sm resize-none font-mono"
placeholder={'Номер карты: 4276 1234 5678 9012\nПолучатель: Иванов Иван Иванович\nБанк: Сбербанк\nКомментарий: укажите даты и фамилию'}
value={settings.bankDetails}
onChange={e => set('bankDetails', e.target.value)}
/>
</div>
{/* Save button */}
<div className="flex items-center gap-3">
<button