feat: widget rental tab — real objects, time slots, actual rental_bookings

- Add rental_objects to /api/widget/:slug/config response
- Add POST /api/widget/:slug/rental-bookings endpoint with availability check
- Widget rental tab now shows real rental objects (not additionalServices)
- Date picker, hourly or full-day toggle, start/end hour selectors
- Booking submit creates actual rental_booking record in DB
- Success screen shows rental object, date and time slot
- WidgetRentalObject + WidgetRentalBookingPayload types added to api.ts
- BookingWidgetStandalonePage passes rentalObjects to WidgetPreview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 11:32:10 +03:00
parent cda515c0d8
commit 59307ba6b4
4 changed files with 263 additions and 54 deletions

View File

@@ -39,6 +39,16 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
[hotel.id], [hotel.id],
) )
// Load rental objects (only if widget_show_rental = true)
const { rows: rentalObjects } = await db.query(
`SELECT id, name, icon, price_per_hour, price_per_day,
open_hour, close_hour, max_hours_per_slot, buffer_minutes, sort_order
FROM rental_objects
WHERE hotel_id = $1
ORDER BY sort_order, name`,
[hotel.id],
)
// Check if YooKassa gateway is configured for booking-widget // Check if YooKassa gateway is configured for booking-widget
const gateway = await getGatewayForModule(hotel.id, 'booking-widget') const gateway = await getGatewayForModule(hotel.id, 'booking-widget')
@@ -92,9 +102,79 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
minPrice: Number(c.min_price), minPrice: Number(c.min_price),
maxGuests: Number(c.max_guests), maxGuests: Number(c.max_guests),
})), })),
rentalObjects: rentalObjects.map((o: any) => ({
id: o.id,
name: o.name,
icon: o.icon ?? '🏨',
pricePerHour: Number(o.price_per_hour ?? 0),
pricePerDay: Number(o.price_per_day ?? 0),
openHour: Number(o.open_hour ?? 8),
closeHour: Number(o.close_hour ?? 22),
maxHoursPerSlot: o.max_hours_per_slot ? Number(o.max_hours_per_slot) : null,
bufferMinutes: Number(o.buffer_minutes ?? 0),
})),
} }
}) })
// ── POST /api/widget/:slug/rental-bookings ────────────────────────────────
fastify.post<SlugParam & {
Body: {
objectId: string
date: string
isFullDay?: boolean
startHour?: number
endHour?: number
guestName: string
guestEmail?: string
guestPhone?: string
totalAmount: number
notes?: string
}
}>('/api/widget/:slug/rental-bookings', async (req, reply) => {
const hotel = await getHotelId(req.params.slug)
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
const { objectId, date, isFullDay, startHour, endHour, guestName, guestEmail, guestPhone, totalAmount, notes } = req.body
if (!objectId || !date || !guestName) {
return reply.code(400).send({ error: 'Missing required fields' })
}
// Check object exists
const { rows: objRows } = await db.query(
`SELECT id FROM rental_objects WHERE id = $1 AND hotel_id = $2`,
[objectId, hotel.id],
)
if (!objRows[0]) return reply.code(404).send({ error: 'Rental object not found' })
// Availability check (hourly bookings)
if (!isFullDay && startHour !== undefined && endHour !== undefined) {
const { rows: conflicts } = await db.query(
`SELECT id FROM rental_bookings
WHERE object_id = $1 AND date = $2 AND status != 'cancelled'
AND NOT (end_hour <= $3 OR start_hour >= $4)`,
[objectId, date, startHour, endHour],
)
if (conflicts.length > 0) {
return reply.code(409).send({ error: 'This time slot is already booked' })
}
}
// Create rental booking
const { rows } = await db.query(
`INSERT INTO rental_bookings
(hotel_id, object_id, date, is_full_day, start_hour, end_hour,
guest_name, guest_phone, total_amount, notes, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'confirmed') RETURNING id`,
[hotel.id, objectId, date, isFullDay ?? false,
startHour ?? 0, endHour ?? 0,
guestName, guestPhone ?? '', Math.round(totalAmount * 100),
notes ?? null],
)
return { bookingId: rows[0].id, status: 'confirmed' }
})
// ── GET /api/widget/:slug/guests/lookup ─────────────────────────────────── // ── GET /api/widget/:slug/guests/lookup ───────────────────────────────────
// Lookup existing guest by email or phone (for auto-fill, no auth) // Lookup existing guest by email or phone (for auto-fill, no auth)
fastify.get<SlugParam & { Querystring: { email?: string; phone?: string } }>( fastify.get<SlugParam & { Querystring: { email?: string; phone?: string } }>(

View File

@@ -885,6 +885,16 @@ export const api = {
return r.json() as Promise<{ first_name: string; last_name: string; middle_name?: string; email?: string; phone?: string; is_blacklisted: boolean }> return r.json() as Promise<{ first_name: string; last_name: string; middle_name?: string; email?: string; phone?: string; is_blacklisted: boolean }>
}) })
}, },
createRentalBooking: (slug: string, data: WidgetRentalBookingPayload) =>
fetch(`${BASE}/api/widget/${slug}/rental-bookings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then(async r => {
const json = await r.json()
if (!r.ok) throw { status: r.status, ...json }
return json as { bookingId: string; status: string }
}),
}, },
} }
@@ -1662,6 +1672,31 @@ export interface WidgetCategory {
maxGuests: number maxGuests: number
} }
export interface WidgetRentalObject {
id: string
name: string
icon: string
pricePerHour: number
pricePerDay: number
openHour: number
closeHour: number
maxHoursPerSlot: number | null
bufferMinutes: number
}
export interface WidgetRentalBookingPayload {
objectId: string
date: string
isFullDay?: boolean
startHour?: number
endHour?: number
guestName: string
guestEmail?: string
guestPhone?: string
totalAmount: number
notes?: string
}
export interface WidgetConfig { export interface WidgetConfig {
hotelId: string hotelId: string
hotelName: string hotelName: string
@@ -1670,6 +1705,7 @@ export interface WidgetConfig {
currency: string currency: string
rooms: WidgetRoom[] rooms: WidgetRoom[]
categories: WidgetCategory[] categories: WidgetCategory[]
rentalObjects?: WidgetRentalObject[]
widgetSettings?: { widgetSettings?: {
primaryColor: string primaryColor: string
language: string language: string

View File

@@ -10,7 +10,7 @@ import {
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import { useModules } from '../contexts/ModulesContext' import { useModules } from '../contexts/ModulesContext'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { api, type WidgetRoom, type WidgetCategory, type PaymentGateway } from '../lib/api' import { api, type WidgetRoom, type WidgetCategory, type WidgetRentalObject, type PaymentGateway } from '../lib/api'
// ── Widget settings type ─────────────────────────────────────────────────────── // ── Widget settings type ───────────────────────────────────────────────────────
@@ -188,11 +188,12 @@ export const DEFAULT_SERVICES: AdditionalService[] = [
// ── Widget Preview Component ─────────────────────────────────────────────────── // ── Widget Preview Component ───────────────────────────────────────────────────
export function WidgetPreview({ settings, slug, realRooms, realCategories, paymentEnabled }: { export function WidgetPreview({ settings, slug, realRooms, realCategories, realRentalObjects, paymentEnabled }: {
settings: WidgetSettings settings: WidgetSettings
slug?: string slug?: string
realRooms?: WidgetRoom[] realRooms?: WidgetRoom[]
realCategories?: WidgetCategory[] realCategories?: WidgetCategory[]
realRentalObjects?: WidgetRentalObject[]
paymentEnabled?: boolean paymentEnabled?: boolean
}) { }) {
const [previewTab, setPreviewTab] = useState<'rooms' | 'rental'>('rooms') const [previewTab, setPreviewTab] = useState<'rooms' | 'rental'>('rooms')
@@ -222,6 +223,12 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
// 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)
// Rental booking state
const [rentalDate, setRentalDate] = useState(() => localDate(0))
const [rentalIsFullDay, setRentalIsFullDay] = useState(false)
const [rentalStartHour, setRentalStartHour] = useState(10)
const [rentalEndHour, setRentalEndHour] = useState(12)
const rentalObjects: WidgetRentalObject[] = realRentalObjects ?? []
const nights = checkIn && checkOut const nights = checkIn && checkOut
? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000) ? Math.max(0, (new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000)
@@ -262,7 +269,8 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
const needsPayment = paymentEnabled ?? (settings.paymentProvider !== 'none') const needsPayment = paymentEnabled ?? (settings.paymentProvider !== 'none')
const handleBook = () => { const handleBook = () => {
if (!selected || nights === 0) return if (!selected) return
if (previewTab === 'rooms' && nights === 0) return
setStep('form') setStep('form')
} }
@@ -272,9 +280,29 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
setBookingError(null) setBookingError(null)
if (slug && selected) { if (slug && selected) {
// Real API call
setSubmitting(true) setSubmitting(true)
try { try {
if (previewTab === 'rental') {
// Rental booking
const obj = rentalObjects.find(o => o.id === selected)
const rentalAmount = rentalIsFullDay
? obj?.pricePerDay ?? 0
: (rentalEndHour - rentalStartHour) * (obj?.pricePerHour ?? 0)
await api.widget.createRentalBooking(slug, {
objectId: selected,
date: rentalDate,
isFullDay: rentalIsFullDay,
startHour: rentalIsFullDay ? undefined : rentalStartHour,
endHour: rentalIsFullDay ? undefined : rentalEndHour,
guestName: formValues['name'] ?? formValues['full_name'] ?? 'Гость',
guestEmail: formValues['email'] ?? undefined,
guestPhone: formValues['phone'] ?? undefined,
totalAmount: rentalAmount,
notes: formValues['notes'] ?? formValues['comment'] ?? undefined,
})
setStep('success')
} else {
// Room booking
const services = selectedServices const services = selectedServices
.map(sid => settings.additionalServices.find(s => s.id === sid)) .map(sid => settings.additionalServices.find(s => s.id === sid))
.filter(Boolean) .filter(Boolean)
@@ -297,9 +325,12 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
} else { } else {
setStep('success') setStep('success')
} }
}
} catch (err: any) { } catch (err: any) {
if (err?.status === 403) { if (err?.status === 403) {
setBookingError(err?.error ?? 'Невозможно завершить бронирование. Попробуйте позже или свяжитесь с отелем.') setBookingError(err?.error ?? 'Невозможно завершить бронирование. Попробуйте позже или свяжитесь с отелем.')
} else if (err?.status === 409) {
setBookingError(err?.error ?? 'Выбранное время уже занято. Пожалуйста, выберите другое.')
} else { } else {
setBookingError('Ошибка при создании бронирования. Попробуйте ещё раз или свяжитесь с отелем.') setBookingError('Ошибка при создании бронирования. Попробуйте ещё раз или свяжитесь с отелем.')
} }
@@ -308,7 +339,7 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
} }
} else { } else {
// Preview mode without real slug // Preview mode without real slug
if (needsPayment) { if (needsPayment && previewTab === 'rooms') {
setStep('payment') setStep('payment')
} else { } else {
setStep('success') setStep('success')
@@ -367,10 +398,20 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
: 'Оплата на месте при заезде. Подтверждение придёт на 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">
{previewTab === 'rental' ? (
<>
<p className="text-xs text-slate-500">Объект: <span className="font-medium text-slate-700">{rentalObjects.find(o => o.id === selected)?.name ?? selected}</span></p>
<p className="text-xs text-slate-500">Дата: <span className="font-medium text-slate-700">{rentalDate}</span></p>
{!rentalIsFullDay && <p className="text-xs text-slate-500">Время: <span className="font-medium text-slate-700">{rentalStartHour}:00 {rentalEndHour}:00</span></p>}
</>
) : (
<>
<p className="text-xs text-slate-500">{isCategoryMode ? 'Категория' : 'Номер'}: <span className="font-medium text-slate-700">{selectedName}</span></p> <p className="text-xs text-slate-500">{isCategoryMode ? 'Категория' : 'Номер'}: <span className="font-medium text-slate-700">{selectedName}</span></p>
<p className="text-xs text-slate-500">Заезд: <span className="font-medium text-slate-700">{checkIn}</span></p> <p className="text-xs text-slate-500">Заезд: <span className="font-medium text-slate-700">{checkIn}</span></p>
<p className="text-xs text-slate-500">Выезд: <span className="font-medium text-slate-700">{checkOut}</span></p> <p className="text-xs text-slate-500">Выезд: <span className="font-medium text-slate-700">{checkOut}</span></p>
<p className="text-xs text-slate-500">Итого: <span className="font-bold text-slate-900">{grandTotal.toLocaleString('ru-RU')} </span></p> <p className="text-xs text-slate-500">Итого: <span className="font-bold text-slate-900">{grandTotal.toLocaleString('ru-RU')} </span></p>
</>
)}
</div> </div>
<button <button
onClick={() => { setStep('browse'); setSelected(null); setFormValues({}) }} onClick={() => { setStep('browse'); setSelected(null); setFormValues({}) }}
@@ -1049,29 +1090,78 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
{/* Rental list */} {/* Rental list */}
{previewTab === 'rental' && ( {previewTab === 'rental' && (
<div className="p-4 space-y-3"> <div className="p-4 space-y-3">
<p className="text-xs text-slate-500">Выберите объект и удобное время</p> {rentalObjects.length === 0 ? (
{settings.additionalServices.filter(s => s.enabled).map(obj => ( <p className="text-xs text-slate-400 text-center py-4">Объекты аренды не настроены</p>
) : (
<>
{/* Date picker for rental */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">
{settings.language === 'ru' ? 'Дата' : 'Date'}
</label>
<input
type="date"
value={rentalDate}
min={localDate(0)}
onChange={e => setRentalDate(e.target.value)}
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2"
style={{ '--tw-ring-color': settings.primaryColor } as React.CSSProperties}
/>
</div>
{/* Objects */}
{rentalObjects.map(obj => {
const isSelected = selected === obj.id
const hourlyAvailable = obj.pricePerHour > 0
const dayAvailable = obj.pricePerDay > 0
const hours = rentalIsFullDay ? 0 : Math.max(0, rentalEndHour - rentalStartHour)
const price = isSelected
? (rentalIsFullDay ? obj.pricePerDay : hours * obj.pricePerHour)
: (hourlyAvailable ? obj.pricePerHour : obj.pricePerDay)
const priceLabel = isSelected
? (rentalIsFullDay ? `${obj.pricePerDay.toLocaleString('ru-RU')} ₽/день` : `${price.toLocaleString('ru-RU')} ₽ (${hours} ч)`)
: (hourlyAvailable ? `${obj.pricePerHour.toLocaleString('ru-RU')} ₽/ч` : `${obj.pricePerDay.toLocaleString('ru-RU')} ₽/день`)
return (
<div key={obj.id} className="rounded-xl border-2 overflow-hidden" style={isSelected ? { borderColor: settings.primaryColor } : { borderColor: '#e2e8f0' }}>
<div <div
key={obj.id} onClick={() => setSelected(isSelected ? null : obj.id)}
onClick={() => setSelected(obj.id === selected ? null : obj.id)} className="flex items-center gap-3 p-3 cursor-pointer"
className={cn( style={isSelected ? { background: settings.primaryColor + '0d' } : {}}
'flex items-center gap-4 p-3 rounded-xl border-2 cursor-pointer transition-all',
selected === obj.id ? '' : 'border-slate-200 hover:border-slate-300',
)}
style={selected === obj.id ? { borderColor: settings.primaryColor, background: settings.primaryColor + '08' } : {}}
> >
<div className="w-12 h-12 rounded-xl bg-slate-100 flex items-center justify-center text-2xl shrink-0"> <div className="w-10 h-10 rounded-lg bg-slate-100 flex items-center justify-center text-xl shrink-0">{obj.icon}</div>
{obj.icon}
</div>
<div className="flex-1"> <div className="flex-1">
<p className="font-semibold text-slate-900">{obj.name}</p> <p className="font-semibold text-slate-900 text-sm">{obj.name}</p>
<p className="text-xs text-slate-400">{obj.openHour}:00 {obj.closeHour}:00</p>
</div> </div>
<div className="text-right"> <p className="font-bold text-sm text-slate-900">{priceLabel}</p>
<p className="font-bold text-slate-900">{obj.price.toLocaleString('ru-RU')} </p>
<p className="text-xs text-slate-400">/{obj.unit}</p>
</div> </div>
{/* Time controls when selected */}
{isSelected && (
<div className="px-3 pb-3 pt-1 space-y-2 border-t border-slate-100">
{hourlyAvailable && dayAvailable && (
<div className="flex gap-2">
<button onClick={() => setRentalIsFullDay(false)} className={cn('flex-1 text-xs py-1.5 rounded-lg border font-medium transition-colors', !rentalIsFullDay ? 'text-white border-transparent' : 'bg-white text-slate-500 border-slate-200')} style={!rentalIsFullDay ? { background: settings.primaryColor } : {}}>По часам</button>
<button onClick={() => setRentalIsFullDay(true)} className={cn('flex-1 text-xs py-1.5 rounded-lg border font-medium transition-colors', rentalIsFullDay ? 'text-white border-transparent' : 'bg-white text-slate-500 border-slate-200')} style={rentalIsFullDay ? { background: settings.primaryColor } : {}}>Весь день</button>
</div> </div>
))} )}
{!rentalIsFullDay && hourlyAvailable && (
<div className="flex items-center gap-2 text-xs">
<span className="text-slate-500">С</span>
<select value={rentalStartHour} onChange={e => { const v = Number(e.target.value); setRentalStartHour(v); if (rentalEndHour <= v) setRentalEndHour(v + 1) }} className="flex-1 border border-slate-200 rounded-lg px-2 py-1.5 text-xs">
{Array.from({ length: obj.closeHour - obj.openHour }, (_, i) => obj.openHour + i).map(h => <option key={h} value={h}>{h}:00</option>)}
</select>
<span className="text-slate-500">до</span>
<select value={rentalEndHour} onChange={e => setRentalEndHour(Number(e.target.value))} className="flex-1 border border-slate-200 rounded-lg px-2 py-1.5 text-xs">
{Array.from({ length: obj.closeHour - obj.openHour }, (_, i) => obj.openHour + i + 1).filter(h => h > rentalStartHour).map(h => <option key={h} value={h}>{h}:00</option>)}
</select>
</div>
)}
</div>
)}
</div>
)
})}
</>
)}
</div> </div>
)} )}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams, useSearchParams } from 'react-router-dom' import { useParams, useSearchParams } from 'react-router-dom'
import { api, type WidgetRoom, type WidgetCategory } from '../lib/api' import { api, type WidgetRoom, type WidgetCategory, type WidgetRentalObject } from '../lib/api'
import { WidgetPreview, DEFAULT_FORM_FIELDS, DEFAULT_SERVICES } from './BookingWidgetPage' import { WidgetPreview, DEFAULT_FORM_FIELDS, DEFAULT_SERVICES } from './BookingWidgetPage'
import type { WidgetSettings } from './BookingWidgetPage' import type { WidgetSettings } from './BookingWidgetPage'
@@ -10,6 +10,7 @@ export function BookingWidgetStandalonePage() {
const [realRooms, setRealRooms] = useState<WidgetRoom[]>([]) const [realRooms, setRealRooms] = useState<WidgetRoom[]>([])
const [realCategories, setRealCategories] = useState<WidgetCategory[]>([]) const [realCategories, setRealCategories] = useState<WidgetCategory[]>([])
const [realRentalObjects, setRealRentalObjects] = useState<WidgetRentalObject[]>([])
const [paymentEnabled, setPaymentEnabled] = useState<boolean | undefined>(undefined) const [paymentEnabled, setPaymentEnabled] = useState<boolean | undefined>(undefined)
const [settings, setSettings] = useState<WidgetSettings>({ const [settings, setSettings] = useState<WidgetSettings>({
hotelName: '', hotelName: '',
@@ -34,6 +35,7 @@ export function BookingWidgetStandalonePage() {
.then(config => { .then(config => {
setRealRooms(config.rooms ?? []) setRealRooms(config.rooms ?? [])
setRealCategories(config.categories ?? []) setRealCategories(config.categories ?? [])
setRealRentalObjects(config.rentalObjects ?? [])
setPaymentEnabled(config.paymentEnabled) setPaymentEnabled(config.paymentEnabled)
if (config.widgetSettings) { if (config.widgetSettings) {
const ws = config.widgetSettings const ws = config.widgetSettings
@@ -64,6 +66,7 @@ export function BookingWidgetStandalonePage() {
slug={slug} slug={slug}
realRooms={realRooms.length > 0 ? realRooms : undefined} realRooms={realRooms.length > 0 ? realRooms : undefined}
realCategories={realCategories.length > 0 ? realCategories : undefined} realCategories={realCategories.length > 0 ? realCategories : undefined}
realRentalObjects={realRentalObjects.length > 0 ? realRentalObjects : undefined}
paymentEnabled={paymentEnabled} paymentEnabled={paymentEnabled}
/> />
</div> </div>