fix: booking widget payment flow and success message

- On API error: show error message instead of false success screen
- Success message: "Оплата прошла успешно" only when paidViaGateway=true (user actually clicked pay in YooKassa); otherwise "Бронирование принято. Менеджер свяжется с вами" or "Оплата на месте"
- Payment screen: simplified to YooKassa-only redirect (removed bank transfer variant)
- Removed bankDetails from WidgetSettings entirely (not needed per product decision)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 02:21:55 +03:00
parent bd27be72c1
commit 85b4ae4ee0
3 changed files with 32 additions and 101 deletions

View File

@@ -1680,7 +1680,6 @@ export interface WidgetConfig {
allowExtraBeds: boolean allowExtraBeds: boolean
allowChildren: boolean allowChildren: boolean
hotelName: string hotelName: string
bankDetails: string
} }
} }

View File

@@ -44,7 +44,6 @@ export interface WidgetSettings {
showPromo: boolean showPromo: boolean
allowExtraBeds: boolean allowExtraBeds: boolean
allowChildren: boolean allowChildren: boolean
bankDetails: string
formFields: FormField[] formFields: FormField[]
additionalServices: AdditionalService[] additionalServices: AdditionalService[]
} }
@@ -216,6 +215,7 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
// Hourly service time selections: { serviceId: { date, timeFrom, timeTo } } // Hourly service time selections: { serviceId: { date, timeFrom, timeTo } }
const [serviceSchedule, setServiceSchedule] = useState<Record<string, { date: string; timeFrom: string; timeTo: string }>>({}) const [serviceSchedule, setServiceSchedule] = useState<Record<string, { date: string; timeFrom: string; timeTo: string }>>({})
const [bookingError, setBookingError] = useState<string | null>(null) const [bookingError, setBookingError] = useState<string | null>(null)
const [paidViaGateway, setPaidViaGateway] = useState(false)
// Guest lookup // Guest lookup
const [guestLookupTimer, setGuestLookupTimer] = useState<ReturnType<typeof setTimeout> | null>(null) 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 [guestSuggestion, setGuestSuggestion] = useState<{ first_name: string; last_name: string; middle_name?: string; phone?: string; email?: string } | null>(null)
@@ -291,9 +291,6 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
if (result.confirmationUrl) { if (result.confirmationUrl) {
setConfirmUrl(result.confirmationUrl) setConfirmUrl(result.confirmationUrl)
setStep('payment') setStep('payment')
} else if (settings.bankDetails || needsPayment) {
// YooKassa failed or bank transfer configured — always show payment screen
setStep('payment')
} else { } else {
setStep('success') setStep('success')
} }
@@ -301,14 +298,14 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
if (err?.status === 403) { if (err?.status === 403) {
setBookingError(err?.error ?? 'Невозможно завершить бронирование. Попробуйте позже или свяжитесь с отелем.') setBookingError(err?.error ?? 'Невозможно завершить бронирование. Попробуйте позже или свяжитесь с отелем.')
} else { } else {
setStep('success') setBookingError('Ошибка при создании бронирования. Попробуйте ещё раз или свяжитесь с отелем.')
} }
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
} else { } else {
// Preview mode without real slug // Preview mode without real slug
if (needsPayment || settings.bankDetails) { if (needsPayment) {
setStep('payment') setStep('payment')
} else { } else {
setStep('success') setStep('success')
@@ -318,6 +315,7 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
const handlePay = () => { const handlePay = () => {
if (confirmUrl) { if (confirmUrl) {
setPaidViaGateway(true)
window.location.href = confirmUrl window.location.href = confirmUrl
} else { } else {
setStep('success') setStep('success')
@@ -332,6 +330,8 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
setServiceSchedule({}) setServiceSchedule({})
setBookingError(null) setBookingError(null)
setGuestSuggestion(null) setGuestSuggestion(null)
setPaidViaGateway(false)
setConfirmUrl(null)
} }
const doGuestLookup = (email?: string, phone?: string) => { const doGuestLookup = (email?: string, phone?: string) => {
@@ -357,8 +357,10 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
</div> </div>
<p className="text-lg font-bold text-slate-900">Бронирование принято!</p> <p className="text-lg font-bold text-slate-900">Бронирование принято!</p>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
{needsPayment {paidViaGateway
? 'Оплата прошла успешно. Подтверждение придёт на email.' ? 'Оплата прошла успешно. Подтверждение придёт на email.'
: needsPayment
? 'Бронирование принято. Менеджер свяжется с вами для оплаты.'
: 'Оплата на месте при заезде. Подтверждение придёт на email.'} : 'Оплата на месте при заезде. Подтверждение придёт на email.'}
</p> </p>
<div className="bg-slate-50 rounded-xl p-4 text-left space-y-1"> <div className="bg-slate-50 rounded-xl p-4 text-left space-y-1">
@@ -379,11 +381,8 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
) )
} }
// ── Payment screen ── // ── Payment screen (YooKassa redirect) ──
if (step === 'payment') { if (step === 'payment') {
const isYookassa = !!confirmUrl
const hasBankDetails = !isYookassa && !!settings.bankDetails
return ( return (
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden border border-slate-200" style={{ fontFamily: 'Inter, sans-serif' }}> <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 }}> <div className="px-6 py-4 text-white flex items-center gap-3" style={{ background: settings.primaryColor }}>
@@ -397,77 +396,33 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
</div> </div>
<div className="p-5 space-y-4"> <div className="p-5 space-y-4">
{/* Amount */}
<div className="bg-slate-50 rounded-xl p-3 flex items-center justify-between"> <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-sm text-slate-600">{selectedName} · {nights} ноч.</span>
<span className="text-base font-bold text-slate-900">{grandTotal.toLocaleString('ru-RU')} </span> <span className="text-base font-bold text-slate-900">{grandTotal.toLocaleString('ru-RU')} </span>
</div> </div>
{isYookassa ? (
/* YooKassa redirect */
<div className="text-center space-y-3 py-2"> <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"> <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" /> <CreditCard size={28} className="text-slate-500" />
</div> </div>
<p className="text-sm text-slate-700 font-medium"> <p className="text-sm text-slate-700 font-medium">
{settings.language === 'ru' ? 'Для оплаты вы будете перенаправлены на страницу ЮKassa' : 'You will be redirected to YooKassa to complete payment'} {settings.language === 'ru'
? 'Для завершения бронирования перейдите к оплате'
: 'Please proceed to payment to confirm your booking'}
</p> </p>
<p className="text-xs text-slate-400">🔒 Защищённое соединение · ЮKassa</p> <p className="text-xs text-slate-400">🔒 Защищённое соединение · ЮKassa</p>
</div> </div>
) : hasBankDetails ? (
/* 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>
) : (
/* No payment configured — pending confirmation */
<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-amber-50">
<AlertCircle size={28} className="text-amber-500" />
</div>
<p className="text-sm text-slate-700 font-medium">
{settings.language === 'ru' ? 'Бронирование принято. Менеджер отеля свяжется с вами для подтверждения оплаты.' : 'Booking received. Hotel manager will contact you to confirm payment.'}
</p>
</div>
)}
</div> </div>
<div className="px-5 pb-5"> <div className="px-5 pb-5">
{isYookassa ? (
<button <button
onClick={handlePay} onClick={handlePay}
className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90" className="w-full py-3 rounded-xl text-white font-semibold text-sm transition-opacity hover:opacity-90"
style={{ background: settings.primaryColor }} style={{ background: settings.primaryColor }}
> >
{settings.language === 'ru' ? `Перейти к оплате · ${grandTotal.toLocaleString('ru-RU')}` : `Proceed to pay · ${grandTotal.toLocaleString('ru-RU')}`} {settings.language === 'ru'
? `Оплатить · ${grandTotal.toLocaleString('ru-RU')}`
: `Pay · ${grandTotal.toLocaleString('ru-RU')}`}
</button> </button>
) : hasBankDetails ? (
<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>
) : (
<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' ? 'Понятно' : 'OK'}
</button>
)}
</div> </div>
</div> </div>
) )
@@ -696,11 +651,11 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
style={{ background: settings.primaryColor }} style={{ background: settings.primaryColor }}
> >
{submitting && <Loader2 size={14} className="animate-spin" />} {submitting && <Loader2 size={14} className="animate-spin" />}
{submitting ? 'Отправляем...' : (needsPayment || settings.bankDetails) {submitting ? 'Отправляем...' : needsPayment
? (settings.language === 'ru' ? `Перейти к оплате · ${grandTotal.toLocaleString('ru-RU')}` : `Proceed to payment · ${grandTotal.toLocaleString('ru-RU')}`) ? (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')}`)} : (settings.language === 'ru' ? `Подтвердить · ${grandTotal.toLocaleString('ru-RU')}` : `Confirm · ${grandTotal.toLocaleString('ru-RU')}`)}
</button> </button>
{!needsPayment && !settings.bankDetails && ( {!needsPayment && (
<p className="text-center text-xs text-slate-400">Оплата на месте при заезде</p> <p className="text-center text-xs text-slate-400">Оплата на месте при заезде</p>
)} )}
</div> </div>
@@ -1188,7 +1143,6 @@ export function BookingWidgetPage() {
showPromo: (hs as any).widgetShowPromo !== undefined ? Boolean((hs as any).widgetShowPromo) : prev.showPromo, 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, 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, allowChildren: (hs as any).widgetChildren !== undefined ? Boolean((hs as any).widgetChildren) : prev.allowChildren,
bankDetails: String((hs as any).widgetBankDetails ?? prev.bankDetails ?? ''),
})) }))
setSettingsReady(true) setSettingsReady(true)
}).finally(() => setGatewayLoading(false)) }).finally(() => setGatewayLoading(false))
@@ -1206,7 +1160,6 @@ export function BookingWidgetPage() {
showPromo: true, showPromo: true,
allowExtraBeds: true, allowExtraBeds: true,
allowChildren: true, allowChildren: true,
bankDetails: '',
formFields: DEFAULT_FORM_FIELDS, formFields: DEFAULT_FORM_FIELDS,
additionalServices: DEFAULT_SERVICES, additionalServices: DEFAULT_SERVICES,
}) })
@@ -1236,7 +1189,6 @@ export function BookingWidgetPage() {
widget_show_promo: s.showPromo, widget_show_promo: s.showPromo,
widget_extra_beds: s.allowExtraBeds, widget_extra_beds: s.allowExtraBeds,
widget_children: s.allowChildren, widget_children: s.allowChildren,
widget_bank_details: s.bankDetails,
}) })
setSaved(true) setSaved(true)
setTimeout(() => setSaved(false), 2000) setTimeout(() => setSaved(false), 2000)
@@ -1576,24 +1528,6 @@ export function BookingWidgetPage() {
)} )}
</div> </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 */} {/* Save button */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button

View File

@@ -23,7 +23,6 @@ export function BookingWidgetStandalonePage() {
showPromo: searchParams.get('promo') !== 'false', showPromo: searchParams.get('promo') !== 'false',
allowExtraBeds: searchParams.get('extra-beds') !== 'false', allowExtraBeds: searchParams.get('extra-beds') !== 'false',
allowChildren: searchParams.get('children') !== 'false', allowChildren: searchParams.get('children') !== 'false',
bankDetails: '',
formFields: DEFAULT_FORM_FIELDS, formFields: DEFAULT_FORM_FIELDS,
additionalServices: DEFAULT_SERVICES, additionalServices: DEFAULT_SERVICES,
}) })
@@ -48,7 +47,6 @@ export function BookingWidgetStandalonePage() {
showPromo: ws.showPromo ?? prev.showPromo, showPromo: ws.showPromo ?? prev.showPromo,
allowExtraBeds: ws.allowExtraBeds ?? prev.allowExtraBeds, allowExtraBeds: ws.allowExtraBeds ?? prev.allowExtraBeds,
allowChildren: ws.allowChildren ?? prev.allowChildren, allowChildren: ws.allowChildren ?? prev.allowChildren,
bankDetails: ws.bankDetails ?? prev.bankDetails,
})) }))
} else { } else {
setSettings(prev => ({ ...prev, hotelName: config.hotelName ?? prev.hotelName })) setSettings(prev => ({ ...prev, hotelName: config.hotelName ?? prev.hotelName }))