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:
@@ -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;
|
||||
@@ -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()
|
||||
|
||||
@@ -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<void> {
|
||||
try {
|
||||
@@ -14,6 +15,42 @@ async function runAutoJobs(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function runPaymentExpiryJob(): Promise<void> {
|
||||
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<void> {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
94
backend/src/routes/yookassaWebhook.ts
Normal file
94
backend/src/routes/yookassaWebhook.ts
Normal file
@@ -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<string, unknown>
|
||||
const event = body?.event as string | undefined
|
||||
const obj = body?.object as Record<string, unknown> | 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
|
||||
@@ -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 />}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
182
src/pages/BookingConfirmPage.tsx
Normal file
182
src/pages/BookingConfirmPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user