feat: YooKassa webhook, payment timeout, reserved status, BookingConfirmPage

- Add YooKassa webhook handler (POST /api/yookassa/webhook) that updates
  booking to confirmed+paid on payment.succeeded, cancelled on payment.canceled
- Add public status endpoint GET /api/online-bookings/:id for return URL polling
- Add 'reserved' booking status (violet) shown in calendar while awaiting payment
- Add 'website' to BookingSource type
- Payment timeout job (every 2 min) auto-cancels expired unpaid bookings
- Widget setting: "Время ожидания оплаты" (5–60 min, default 15)
- BookingConfirmPage at /booking-confirm/:id — polls status, countdown timer,
  shows success/pending/cancelled state
- Widget bookings now use status='reserved' instead of 'inquiry' when YooKassa
  is configured; set to 'confirmed' by webhook on payment.succeeded
- Migration 070: add 'reserved' to bookings status constraint + payment_expires_at

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 02:55:24 +03:00
parent b0bbb11c87
commit fb7d907b40
13 changed files with 380 additions and 10 deletions

View File

@@ -52,6 +52,7 @@ import { DepositSettingsPage } from './pages/DepositSettingsPage'
import { DepositHistoryPage } from './pages/DepositHistoryPage'
import { PayDepositPage } from './pages/PayDepositPage'
import { BookingWidgetStandalonePage } from './pages/BookingWidgetStandalonePage'
import { BookingConfirmPage } from './pages/BookingConfirmPage'
import { ModuleGuard } from './components/ModuleGuard'
export default function App() {
@@ -71,6 +72,7 @@ export default function App() {
<Route path="/room-service/:slug" element={<GuestRoomServicePage />} />
<Route path="/:slug/pay" element={<PayDepositPage />} />
<Route path="/:slug/book" element={<BookingWidgetStandalonePage />} />
<Route path="/booking-confirm/:id" element={<BookingConfirmPage />} />
{/* PMS routes */}
<Route element={<AppLayout />}>

View File

@@ -372,7 +372,7 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook
{/* Legend */}
<div className="hidden lg:flex items-center gap-2 text-xs text-slate-500">
{(['confirmed', 'checked_in', 'checked_out', 'inquiry'] as const).map(s => (
{(['confirmed', 'checked_in', 'checked_out', 'reserved', 'inquiry'] as const).map(s => (
<div key={s} className="flex items-center gap-1.5">
<div className={cn('w-2.5 h-2.5 rounded-sm shrink-0', BOOKING_STATUS_COLORS[s].split(' ')[0])} />
<span>{BOOKING_STATUS_LABELS[s]}</span>

View File

@@ -22,6 +22,7 @@ export function formatDate(date: string, opts?: Intl.DateTimeFormatOptions) {
export const BOOKING_STATUS_LABELS: Record<BookingStatus, string> = {
inquiry: 'Запрос',
reserved: 'Зарезервирована',
confirmed: 'Подтверждён',
checked_in: 'Заселён',
checked_out: 'Выехал',
@@ -31,6 +32,7 @@ export const BOOKING_STATUS_LABELS: Record<BookingStatus, string> = {
export const BOOKING_STATUS_COLORS: Record<BookingStatus, string> = {
inquiry: 'bg-amber-400 border-amber-600',
reserved: 'bg-violet-400 border-violet-600',
confirmed: 'bg-brand-500 border-brand-700',
checked_in: 'bg-emerald-500 border-emerald-700',
checked_out: 'bg-slate-400 border-slate-600',
@@ -40,6 +42,7 @@ export const BOOKING_STATUS_COLORS: Record<BookingStatus, string> = {
export const BOOKING_STATUS_BADGE: Record<BookingStatus, string> = {
inquiry: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
reserved: 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
confirmed: 'bg-brand-100 text-brand-800 dark:bg-brand-900/30 dark:text-brand-300',
checked_in: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300',
checked_out: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300',
@@ -54,6 +57,8 @@ export const SOURCE_LABELS: Record<BookingSource, string> = {
booking_com: 'Booking.com',
airbnb: 'Airbnb',
expedia: 'Expedia',
vrbo: 'VRBO',
website: 'Сайт',
other: 'Другое',
}
@@ -62,6 +67,8 @@ export const SOURCE_COLORS: Record<BookingSource, string> = {
booking_com: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
airbnb: 'bg-rose-100 text-rose-800 dark:bg-rose-900/30 dark:text-rose-300',
expedia: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
vrbo: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300',
website: 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
other: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
}

View File

@@ -0,0 +1,182 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams } from 'react-router-dom'
import { CheckCircle, XCircle, Clock, Loader2 } from 'lucide-react'
const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://api.hotelsync.ru'
interface OnlineBookingStatus {
id: string
status: string // pending | paid | confirmed | cancelled
yookassaStatus: string | null
paymentExpiresAt: string | null
guestName: string
checkIn: string
checkOut: string
totalAmount: string
paymentMethod: string
hotelName: string
slug: string
}
function formatDate(d: string) {
const [y, m, day] = d.split('-')
return `${day}.${m}.${y}`
}
function useCountdown(expiresAt: string | null) {
const [secondsLeft, setSecondsLeft] = useState<number | null>(null)
useEffect(() => {
if (!expiresAt) return
const update = () => {
const diff = Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000))
setSecondsLeft(diff)
}
update()
const id = setInterval(update, 1000)
return () => clearInterval(id)
}, [expiresAt])
if (secondsLeft === null) return null
const m = Math.floor(secondsLeft / 60)
const s = secondsLeft % 60
return `${m}:${String(s).padStart(2, '0')}`
}
export function BookingConfirmPage() {
const { id } = useParams<{ id: string }>()
const [booking, setBooking] = useState<OnlineBookingStatus | null>(null)
const [error, setError] = useState(false)
const fetchStatus = useCallback(async () => {
if (!id) return
try {
const r = await fetch(`${BASE}/api/online-bookings/${id}`)
if (!r.ok) { setError(true); return }
const data = await r.json()
// Transform snake_case keys
const b: OnlineBookingStatus = {
id: data.id,
status: data.status,
yookassaStatus: data.yookassa_status ?? null,
paymentExpiresAt: data.payment_expires_at ?? null,
guestName: data.guest_name,
checkIn: data.check_in,
checkOut: data.check_out,
totalAmount: data.total_amount,
paymentMethod: data.payment_method,
hotelName: data.hotel_name,
slug: data.slug,
}
setBooking(b)
} catch {
setError(true)
}
}, [id])
useEffect(() => {
fetchStatus()
}, [fetchStatus])
// Poll every 4 seconds while payment is pending
useEffect(() => {
if (!booking) return
if (booking.status === 'paid' || booking.status === 'cancelled') return
const timer = setInterval(fetchStatus, 4000)
return () => clearInterval(timer)
}, [booking, fetchStatus])
const countdown = useCountdown(booking?.paymentExpiresAt ?? null)
if (error) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-lg p-8 max-w-sm w-full text-center">
<XCircle className="mx-auto text-red-500 mb-4" size={56} />
<p className="text-slate-600">Бронирование не найдено</p>
</div>
</div>
)
}
if (!booking) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
<Loader2 className="animate-spin text-indigo-500" size={40} />
</div>
)
}
const isPaid = booking.status === 'paid' || booking.yookassaStatus === 'succeeded'
const isCancelled = booking.status === 'cancelled' || booking.yookassaStatus === 'canceled'
const isPending = !isPaid && !isCancelled
const amount = new Intl.NumberFormat('ru-RU').format(Number(booking.totalAmount))
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-lg p-8 max-w-sm w-full">
{/* Header */}
<p className="text-center text-sm text-slate-500 mb-6">{booking.hotelName}</p>
{/* Status icon */}
<div className="flex justify-center mb-4">
{isPaid && <CheckCircle className="text-emerald-500" size={64} />}
{isCancelled && <XCircle className="text-red-500" size={64} />}
{isPending && <Clock className="text-violet-500 animate-pulse" size={64} />}
</div>
{/* Status title */}
<h1 className="text-xl font-bold text-center text-slate-800 mb-2">
{isPaid && 'Оплата подтверждена'}
{isCancelled && 'Бронирование отменено'}
{isPending && 'Ожидание оплаты'}
</h1>
{/* Countdown */}
{isPending && countdown !== null && (
<p className="text-center text-slate-500 text-sm mb-4">
Осталось времени: <span className="font-mono font-semibold text-violet-600">{countdown}</span>
</p>
)}
{/* Booking details */}
<div className="bg-slate-50 rounded-xl p-4 text-sm space-y-2 mb-6">
<div className="flex justify-between">
<span className="text-slate-500">Гость</span>
<span className="font-medium">{booking.guestName}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Заезд</span>
<span className="font-medium">{formatDate(booking.checkIn)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Выезд</span>
<span className="font-medium">{formatDate(booking.checkOut)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Сумма</span>
<span className="font-semibold">{amount} </span>
</div>
</div>
{/* Description */}
<p className="text-center text-slate-500 text-sm">
{isPaid && 'Бронирование подтверждено. Ждём вас!'}
{isCancelled && 'Время оплаты истекло или платёж был отменён. Вы можете оформить новое бронирование.'}
{isPending && 'Ожидаем подтверждение платежа от ЮКассы. Страница обновляется автоматически.'}
</p>
{/* Back to hotel button */}
{isCancelled && (
<a
href={`/${booking.slug}/book`}
className="mt-6 block w-full text-center py-3 rounded-xl bg-indigo-600 text-white font-medium hover:bg-indigo-700 transition-colors"
>
Забронировать снова
</a>
)}
</div>
</div>
)
}

View File

@@ -40,6 +40,7 @@ export interface WidgetSettings {
showRental: boolean
roomDisplayMode: 'rooms' | 'categories'
minNights: number
paymentTimeout: number // minutes to wait for payment before auto-cancel
paymentProvider: 'yukassa' | 'tinkoff' | 'cloudpayments' | 'none'
showPromo: boolean
allowExtraBeds: boolean
@@ -1142,6 +1143,7 @@ export function BookingWidgetPage() {
showRental: (hs as any).widgetShowRental !== undefined ? Boolean((hs as any).widgetShowRental) : prev.showRental,
roomDisplayMode: (String((hs as any).widgetRoomMode ?? prev.roomDisplayMode)) as 'rooms' | 'categories',
minNights: (hs as any).widgetMinNights !== undefined ? Number((hs as any).widgetMinNights) : prev.minNights,
paymentTimeout: (hs as any).widgetPaymentTimeout !== undefined ? Number((hs as any).widgetPaymentTimeout) : prev.paymentTimeout,
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,
@@ -1158,6 +1160,7 @@ export function BookingWidgetPage() {
showRental: rentalActive,
roomDisplayMode: 'rooms',
minNights: 1,
paymentTimeout: 15,
paymentProvider: 'yukassa',
showPromo: true,
allowExtraBeds: true,
@@ -1188,6 +1191,7 @@ export function BookingWidgetPage() {
widget_show_rental: s.showRental,
widget_room_mode: s.roomDisplayMode,
widget_min_nights: s.minNights,
widget_payment_timeout: s.paymentTimeout,
widget_show_promo: s.showPromo,
widget_extra_beds: s.allowExtraBeds,
widget_children: s.allowChildren,
@@ -1442,6 +1446,17 @@ export function BookingWidgetPage() {
onChange={e => set('minNights', Math.max(1, parseInt(e.target.value) || 1))}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Время ожидания оплаты (минут)
</label>
<input
type="number" min={5} max={60} className="input w-24"
value={settings.paymentTimeout}
onChange={e => set('paymentTimeout', Math.max(5, parseInt(e.target.value) || 15))}
/>
<p className="mt-1 text-xs text-slate-400">По умолчанию 15 мин. Если не оплачено бронь отменяется.</p>
</div>
</div>
{/* Additional services */}

View File

@@ -19,6 +19,7 @@ export function BookingWidgetStandalonePage() {
showRental: searchParams.get('rental') === 'true',
roomDisplayMode: (searchParams.get('mode') ?? 'rooms') as 'rooms' | 'categories',
minNights: parseInt(searchParams.get('min-nights') ?? '1') || 1,
paymentTimeout: 15,
paymentProvider: 'yukassa',
showPromo: searchParams.get('promo') !== 'false',
allowExtraBeds: searchParams.get('extra-beds') !== 'false',

View File

@@ -17,6 +17,7 @@ const STATUS_FILTERS: { label: string; value: BookingStatus | 'all' }[] = [
{ label: 'Все', value: 'all' },
{ label: 'Подтверждённые', value: 'confirmed' },
{ label: 'Заселены', value: 'checked_in' },
{ label: 'Зарезервированы', value: 'reserved' },
{ label: 'Запросы', value: 'inquiry' },
{ label: 'Выехали', value: 'checked_out' },
{ label: 'Отменены', value: 'cancelled' },

View File

@@ -138,6 +138,7 @@ export interface Guest {
export type BookingStatus =
| 'inquiry'
| 'reserved'
| 'confirmed'
| 'checked_in'
| 'checked_out'
@@ -149,6 +150,8 @@ export type BookingSource =
| 'booking_com'
| 'airbnb'
| 'expedia'
| 'vrbo'
| 'website'
| 'other'
export interface Booking {