From fb7d907b407971da57a9bc39673500c257e4da40 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Wed, 8 Apr 2026 02:55:24 +0300 Subject: [PATCH] feat: YooKassa webhook, payment timeout, reserved status, BookingConfirmPage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../070_reserved_status_payment_expires.sql | 9 + backend/src/app.ts | 2 + backend/src/jobs.ts | 44 ++++- backend/src/routes/publicWidget.ts | 28 ++- backend/src/routes/yookassaWebhook.ts | 94 +++++++++ src/App.tsx | 2 + src/components/calendar/BookingCalendar.tsx | 2 +- src/lib/utils.ts | 7 + src/pages/BookingConfirmPage.tsx | 182 ++++++++++++++++++ src/pages/BookingWidgetPage.tsx | 15 ++ src/pages/BookingWidgetStandalonePage.tsx | 1 + src/pages/BookingsPage.tsx | 1 + src/types/index.ts | 3 + 13 files changed, 380 insertions(+), 10 deletions(-) create mode 100644 backend/migrations/070_reserved_status_payment_expires.sql create mode 100644 backend/src/routes/yookassaWebhook.ts create mode 100644 src/pages/BookingConfirmPage.tsx diff --git a/backend/migrations/070_reserved_status_payment_expires.sql b/backend/migrations/070_reserved_status_payment_expires.sql new file mode 100644 index 0000000..843a8b1 --- /dev/null +++ b/backend/migrations/070_reserved_status_payment_expires.sql @@ -0,0 +1,9 @@ +-- Migration 070 — Add 'reserved' booking status + payment_expires_at for widget bookings + +-- Add 'reserved' to bookings status constraint +ALTER TABLE bookings DROP CONSTRAINT IF EXISTS bookings_status_check; +ALTER TABLE bookings ADD CONSTRAINT bookings_status_check + CHECK (status IN ('inquiry','confirmed','checked_in','checked_out','cancelled','no_show','reserved')); + +-- Add payment expiry column to online_bookings +ALTER TABLE online_bookings ADD COLUMN IF NOT EXISTS payment_expires_at TIMESTAMPTZ; diff --git a/backend/src/app.ts b/backend/src/app.ts index f0d4692..b3f0f49 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -45,6 +45,7 @@ import paymentsRoutes from './routes/payments' import paymentMethodsRoutes from './routes/paymentMethods' import paymentGatewaysRoutes from './routes/paymentGateways' import publicWidgetRoutes from './routes/publicWidget' +import yookassaWebhookRoutes from './routes/yookassaWebhook' import { setupAgentWsRoute } from './agent-ws' import { startJobs } from './jobs' @@ -142,6 +143,7 @@ export async function buildApp() { await fastify.register(paymentMethodsRoutes) await fastify.register(paymentGatewaysRoutes) await fastify.register(publicWidgetRoutes) + await fastify.register(yookassaWebhookRoutes) await fastify.register(setupAgentWsRoute) startJobs() diff --git a/backend/src/jobs.ts b/backend/src/jobs.ts index 2df20b4..2c2cb54 100644 --- a/backend/src/jobs.ts +++ b/backend/src/jobs.ts @@ -3,7 +3,8 @@ import { broadcast } from './routes/ws' import { createNotification } from './routes/notifications' import { getHkSettings } from './routes/housekeeping-settings' -const JOB_INTERVAL_MS = 60 * 60 * 1000 // every 1 hour +const JOB_INTERVAL_MS = 60 * 60 * 1000 // every 1 hour +const PAYMENT_EXPIRY_INTERVAL = 2 * 60 * 1000 // every 2 minutes async function runAutoJobs(): Promise { try { @@ -14,6 +15,42 @@ async function runAutoJobs(): Promise { } } +async function runPaymentExpiryJob(): Promise { + try { + // Find online bookings whose payment window has expired and are still pending + const { rows: expired } = await db.query<{ + id: string; booking_id: string | null; slug: string + }>( + `SELECT ob.id, ob.booking_id, h.slug + FROM online_bookings ob + JOIN hotels h ON h.id = ob.hotel_id + WHERE ob.payment_expires_at IS NOT NULL + AND ob.payment_expires_at < NOW() + AND ob.status = 'pending' + AND ob.payment_method = 'yookassa'`, + ) + + for (const ob of expired) { + await db.query( + `UPDATE online_bookings SET status = 'cancelled' WHERE id = $1`, + [ob.id], + ) + if (ob.booking_id) { + const { rows: bRows } = await db.query( + `UPDATE bookings SET status = 'cancelled', updated_at = NOW() + WHERE id = $1 AND status = 'reserved' RETURNING *`, + [ob.booking_id], + ) + if (bRows[0]) { + broadcast(ob.slug, { type: 'booking:updated', booking: bRows[0] }) + } + } + } + } catch (err) { + console.error('[jobs] payment expiry error:', err) + } +} + async function runAutoCancelNoShows(): Promise { // Find hotels where auto_cancel_noshow_enabled = true // Also fetch check_in_time to calculate from the correct arrival time @@ -160,9 +197,14 @@ export function startJobs(): void { // Small delay to let DB migrations finish on startup setTimeout(() => { runAutoJobs().catch(console.error) + runPaymentExpiryJob().catch(console.error) }, 15_000) setInterval(() => { runAutoJobs().catch(console.error) }, JOB_INTERVAL_MS) + + setInterval(() => { + runPaymentExpiryJob().catch(console.error) + }, PAYMENT_EXPIRY_INTERVAL) } diff --git a/backend/src/routes/publicWidget.ts b/backend/src/routes/publicWidget.ts index a8ea67c..cee8ee0 100644 --- a/backend/src/routes/publicWidget.ts +++ b/backend/src/routes/publicWidget.ts @@ -337,32 +337,44 @@ const publicWidget: FastifyPluginAsync = async (fastify) => { const gateway = await getGatewayForModule(hotel.id, 'booking-widget') const paymentMethod = (gateway?.shop_id && gateway?.secret_key) ? 'yookassa' : 'none' + // Read payment timeout setting (in minutes, default 15) + const { rows: tRows } = await db.query( + `SELECT value FROM hotel_settings WHERE hotel_id = $1 AND key = 'widget_payment_timeout'`, + [hotel.id], + ) + const paymentTimeoutMin: number = tRows[0]?.value ? Number(tRows[0].value) || 15 : 15 + const paymentExpiresAt = paymentMethod === 'yookassa' + ? new Date(Date.now() + paymentTimeoutMin * 60_000).toISOString() + : null + // Create online_booking record const { rows } = await db.query( `INSERT INTO online_bookings (hotel_id, room_id, guest_name, guest_email, guest_phone, check_in, check_out, - adults, children, total_amount, notes, services, payment_method) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`, + adults, children, total_amount, notes, services, payment_method, payment_expires_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING id`, [hotel.id, roomId, guestName, guestEmail ?? null, guestPhone ?? null, checkIn, checkOut, adults, children, totalAmount.toFixed(2), notes ?? null, - JSON.stringify(services ?? []), paymentMethod], + JSON.stringify(services ?? []), paymentMethod, paymentExpiresAt], ) const onlineBookingId = rows[0].id - // Also create draft booking in main bookings table + // Create booking in main table + // status='reserved' while awaiting payment → 'confirmed' after webhook; 'confirmed' directly if no payment + const bookingStatus = paymentMethod === 'yookassa' ? 'reserved' : 'confirmed' const { rows: bRows } = await db.query( `INSERT INTO bookings (hotel_id, room_id, guest_name, guest_email, guest_phone, check_in, check_out, adults, children, total_amount, source, status, notes) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'website','inquiry',$11) RETURNING id`, + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'website',$11,$12) RETURNING id`, [hotel.id, roomId, guestName, guestEmail ?? null, guestPhone ?? null, - checkIn, checkOut, adults, children, totalAmount.toFixed(2), notes ?? null], + checkIn, checkOut, adults, children, totalAmount.toFixed(2), bookingStatus, notes ?? null], ) const bookingId = bRows[0].id - // Update online_booking with the main booking id + // Link online_booking → booking await db.query('UPDATE online_bookings SET booking_id = $1 WHERE id = $2', - [bookingId, onlineBookingId]).catch(() => {}) // column may not exist yet, ignore + [bookingId, onlineBookingId]) if (paymentMethod === 'yookassa') { // Create YooKassa payment diff --git a/backend/src/routes/yookassaWebhook.ts b/backend/src/routes/yookassaWebhook.ts new file mode 100644 index 0000000..f2b79cf --- /dev/null +++ b/backend/src/routes/yookassaWebhook.ts @@ -0,0 +1,94 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' +import { broadcast } from './ws' + +const yookassaWebhookRoutes: FastifyPluginAsync = async (fastify) => { + // POST /api/yookassa/webhook + // Receives payment status notifications from YooKassa. + // YooKassa requires a 200 response; any non-200 triggers retries. + fastify.post('/api/yookassa/webhook', async (req, reply) => { + try { + const body = req.body as Record + const event = body?.event as string | undefined + const obj = body?.object as Record | undefined + const paymentId = obj?.id as string | undefined + + if (!paymentId || !event) return reply.code(200).send({ ok: true }) + + // Find the online booking linked to this payment + const { rows } = await db.query<{ + id: string; hotel_id: string; booking_id: string | null + total_amount: string; slug: string + }>( + `SELECT ob.id, ob.hotel_id, ob.booking_id, ob.total_amount, h.slug + FROM online_bookings ob + JOIN hotels h ON h.id = ob.hotel_id + WHERE ob.yookassa_payment_id = $1 + LIMIT 1`, + [paymentId], + ) + if (!rows[0]) return reply.code(200).send({ ok: true }) + + const ob = rows[0] + + if (event === 'payment.succeeded') { + await db.query( + `UPDATE online_bookings SET status = 'paid', yookassa_status = 'succeeded' WHERE id = $1`, + [ob.id], + ) + if (ob.booking_id) { + const { rows: bRows } = await db.query( + `UPDATE bookings + SET status = 'confirmed', + payment_status = 'paid', + paid_amount = total_amount, + updated_at = NOW() + WHERE id = $1 RETURNING *`, + [ob.booking_id], + ) + if (bRows[0]) { + broadcast(ob.slug, { type: 'booking:updated', booking: bRows[0] }) + } + } + } else if (event === 'payment.canceled') { + await db.query( + `UPDATE online_bookings SET status = 'cancelled', yookassa_status = 'canceled' WHERE id = $1`, + [ob.id], + ) + if (ob.booking_id) { + const { rows: bRows } = await db.query( + `UPDATE bookings + SET status = 'cancelled', updated_at = NOW() + WHERE id = $1 RETURNING *`, + [ob.booking_id], + ) + if (bRows[0]) { + broadcast(ob.slug, { type: 'booking:updated', booking: bRows[0] }) + } + } + } + } catch (err) { + req.log.error(err, 'yookassa webhook error') + } + + // Always return 200 so YooKassa does not retry + return reply.code(200).send({ ok: true }) + }) + + // GET /api/online-bookings/:id — public status check (used by BookingConfirmPage) + fastify.get<{ Params: { id: string } }>('/api/online-bookings/:id', async (req, reply) => { + const { rows } = await db.query( + `SELECT ob.id, ob.status, ob.yookassa_status, ob.payment_expires_at, + ob.guest_name, ob.check_in, ob.check_out, ob.total_amount, ob.payment_method, + h.name AS hotel_name, h.slug + FROM online_bookings ob + JOIN hotels h ON h.id = ob.hotel_id + WHERE ob.id = $1`, + [req.params.id], + ) + if (!rows[0]) return reply.code(404).send({ error: 'Not found' }) + return rows[0] + }) +} + +export default yookassaWebhookRoutes diff --git a/src/App.tsx b/src/App.tsx index 36cf4cf..0d4abfd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* PMS routes */} }> diff --git a/src/components/calendar/BookingCalendar.tsx b/src/components/calendar/BookingCalendar.tsx index 3b10eea..8ba758a 100644 --- a/src/components/calendar/BookingCalendar.tsx +++ b/src/components/calendar/BookingCalendar.tsx @@ -372,7 +372,7 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook {/* Legend */}
- {(['confirmed', 'checked_in', 'checked_out', 'inquiry'] as const).map(s => ( + {(['confirmed', 'checked_in', 'checked_out', 'reserved', 'inquiry'] as const).map(s => (
{BOOKING_STATUS_LABELS[s]} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ac746f5..73936ee 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -22,6 +22,7 @@ export function formatDate(date: string, opts?: Intl.DateTimeFormatOptions) { export const BOOKING_STATUS_LABELS: Record = { inquiry: 'Запрос', + reserved: 'Зарезервирована', confirmed: 'Подтверждён', checked_in: 'Заселён', checked_out: 'Выехал', @@ -31,6 +32,7 @@ export const BOOKING_STATUS_LABELS: Record = { export const BOOKING_STATUS_COLORS: Record = { 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 = { export const BOOKING_STATUS_BADGE: Record = { 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 = { booking_com: 'Booking.com', airbnb: 'Airbnb', expedia: 'Expedia', + vrbo: 'VRBO', + website: 'Сайт', other: 'Другое', } @@ -62,6 +67,8 @@ export const SOURCE_COLORS: Record = { 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', } diff --git a/src/pages/BookingConfirmPage.tsx b/src/pages/BookingConfirmPage.tsx new file mode 100644 index 0000000..76ea6eb --- /dev/null +++ b/src/pages/BookingConfirmPage.tsx @@ -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(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(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 ( +
+
+ +

Бронирование не найдено

+
+
+ ) + } + + if (!booking) { + return ( +
+ +
+ ) + } + + 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 ( +
+
+ {/* Header */} +

{booking.hotelName}

+ + {/* Status icon */} +
+ {isPaid && } + {isCancelled && } + {isPending && } +
+ + {/* Status title */} +

+ {isPaid && 'Оплата подтверждена'} + {isCancelled && 'Бронирование отменено'} + {isPending && 'Ожидание оплаты'} +

+ + {/* Countdown */} + {isPending && countdown !== null && ( +

+ Осталось времени: {countdown} +

+ )} + + {/* Booking details */} +
+
+ Гость + {booking.guestName} +
+
+ Заезд + {formatDate(booking.checkIn)} +
+
+ Выезд + {formatDate(booking.checkOut)} +
+
+ Сумма + {amount} ₽ +
+
+ + {/* Description */} +

+ {isPaid && 'Бронирование подтверждено. Ждём вас!'} + {isCancelled && 'Время оплаты истекло или платёж был отменён. Вы можете оформить новое бронирование.'} + {isPending && 'Ожидаем подтверждение платежа от ЮКассы. Страница обновляется автоматически.'} +

+ + {/* Back to hotel button */} + {isCancelled && ( + + Забронировать снова + + )} +
+
+ ) +} diff --git a/src/pages/BookingWidgetPage.tsx b/src/pages/BookingWidgetPage.tsx index c03e7f4..62a6b20 100644 --- a/src/pages/BookingWidgetPage.tsx +++ b/src/pages/BookingWidgetPage.tsx @@ -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))} />
+
+ + set('paymentTimeout', Math.max(5, parseInt(e.target.value) || 15))} + /> +

По умолчанию 15 мин. Если не оплачено — бронь отменяется.

+
{/* Additional services */} diff --git a/src/pages/BookingWidgetStandalonePage.tsx b/src/pages/BookingWidgetStandalonePage.tsx index 9e8d9c7..d1f75ee 100644 --- a/src/pages/BookingWidgetStandalonePage.tsx +++ b/src/pages/BookingWidgetStandalonePage.tsx @@ -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', diff --git a/src/pages/BookingsPage.tsx b/src/pages/BookingsPage.tsx index 2620b7e..42038c0 100644 --- a/src/pages/BookingsPage.tsx +++ b/src/pages/BookingsPage.tsx @@ -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' }, diff --git a/src/types/index.ts b/src/types/index.ts index cada2e4..e80e126 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 {