diff --git a/backend/src/routes/publicWidget.ts b/backend/src/routes/publicWidget.ts
index cee8ee0..d657dc6 100644
--- a/backend/src/routes/publicWidget.ts
+++ b/backend/src/routes/publicWidget.ts
@@ -39,6 +39,16 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
[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
const gateway = await getGatewayForModule(hotel.id, 'booking-widget')
@@ -92,9 +102,79 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
minPrice: Number(c.min_price),
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('/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 ───────────────────────────────────
// Lookup existing guest by email or phone (for auto-fill, no auth)
fastify.get(
diff --git a/src/lib/api.ts b/src/lib/api.ts
index e2af903..99a9ccb 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -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 }>
})
},
+ 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
}
+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 {
hotelId: string
hotelName: string
@@ -1670,6 +1705,7 @@ export interface WidgetConfig {
currency: string
rooms: WidgetRoom[]
categories: WidgetCategory[]
+ rentalObjects?: WidgetRentalObject[]
widgetSettings?: {
primaryColor: string
language: string
diff --git a/src/pages/BookingWidgetPage.tsx b/src/pages/BookingWidgetPage.tsx
index 62a6b20..fa40dd6 100644
--- a/src/pages/BookingWidgetPage.tsx
+++ b/src/pages/BookingWidgetPage.tsx
@@ -10,7 +10,7 @@ import {
import { cn } from '../lib/utils'
import { useModules } from '../contexts/ModulesContext'
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 ───────────────────────────────────────────────────────
@@ -188,11 +188,12 @@ export const DEFAULT_SERVICES: AdditionalService[] = [
// ── Widget Preview Component ───────────────────────────────────────────────────
-export function WidgetPreview({ settings, slug, realRooms, realCategories, paymentEnabled }: {
+export function WidgetPreview({ settings, slug, realRooms, realCategories, realRentalObjects, paymentEnabled }: {
settings: WidgetSettings
slug?: string
realRooms?: WidgetRoom[]
realCategories?: WidgetCategory[]
+ realRentalObjects?: WidgetRentalObject[]
paymentEnabled?: boolean
}) {
const [previewTab, setPreviewTab] = useState<'rooms' | 'rental'>('rooms')
@@ -222,6 +223,12 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
// Guest lookup
const [guestLookupTimer, setGuestLookupTimer] = useState | 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
? 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 handleBook = () => {
- if (!selected || nights === 0) return
+ if (!selected) return
+ if (previewTab === 'rooms' && nights === 0) return
setStep('form')
}
@@ -272,34 +280,57 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
setBookingError(null)
if (slug && selected) {
- // Real API call
setSubmitting(true)
try {
- const services = selectedServices
- .map(sid => settings.additionalServices.find(s => s.id === sid))
- .filter(Boolean)
- .map(s => ({ name: s!.name, price: s!.price }))
-
- const result = await api.widget.createBooking(slug, {
- ...(isCategoryMode ? { categoryId: selected! } : { roomId: selected! }),
- checkIn, checkOut,
- guestName: formValues['name'] ?? formValues['full_name'] ?? 'Гость',
- guestEmail: formValues['email'] ?? undefined,
- guestPhone: formValues['phone'] ?? undefined,
- adults: guests, children,
- totalAmount: grandTotal,
- notes: formValues['notes'] ?? formValues['comment'] ?? undefined,
- services,
- })
- if (result.confirmationUrl) {
- setConfirmUrl(result.confirmationUrl)
- setStep('payment')
- } else {
+ 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
+ .map(sid => settings.additionalServices.find(s => s.id === sid))
+ .filter(Boolean)
+ .map(s => ({ name: s!.name, price: s!.price }))
+
+ const result = await api.widget.createBooking(slug, {
+ ...(isCategoryMode ? { categoryId: selected! } : { roomId: selected! }),
+ checkIn, checkOut,
+ guestName: formValues['name'] ?? formValues['full_name'] ?? 'Гость',
+ guestEmail: formValues['email'] ?? undefined,
+ guestPhone: formValues['phone'] ?? undefined,
+ adults: guests, children,
+ totalAmount: grandTotal,
+ notes: formValues['notes'] ?? formValues['comment'] ?? undefined,
+ services,
+ })
+ if (result.confirmationUrl) {
+ setConfirmUrl(result.confirmationUrl)
+ setStep('payment')
+ } else {
+ setStep('success')
+ }
}
} catch (err: any) {
if (err?.status === 403) {
setBookingError(err?.error ?? 'Невозможно завершить бронирование. Попробуйте позже или свяжитесь с отелем.')
+ } else if (err?.status === 409) {
+ setBookingError(err?.error ?? 'Выбранное время уже занято. Пожалуйста, выберите другое.')
} else {
setBookingError('Ошибка при создании бронирования. Попробуйте ещё раз или свяжитесь с отелем.')
}
@@ -308,7 +339,7 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
}
} else {
// Preview mode without real slug
- if (needsPayment) {
+ if (needsPayment && previewTab === 'rooms') {
setStep('payment')
} else {
setStep('success')
@@ -367,10 +398,20 @@ export function WidgetPreview({ settings, slug, realRooms, realCategories, payme
: 'Оплата на месте при заезде. Подтверждение придёт на email.'}
-
{isCategoryMode ? 'Категория' : 'Номер'}: {selectedName}
-
Заезд: {checkIn}
-
Выезд: {checkOut}
-
Итого: {grandTotal.toLocaleString('ru-RU')} ₽
+ {previewTab === 'rental' ? (
+ <>
+
Объект: {rentalObjects.find(o => o.id === selected)?.name ?? selected}
+
Дата: {rentalDate}
+ {!rentalIsFullDay &&
Время: {rentalStartHour}:00 – {rentalEndHour}:00
}
+ >
+ ) : (
+ <>
+
{isCategoryMode ? 'Категория' : 'Номер'}: {selectedName}
+
Заезд: {checkIn}
+
Выезд: {checkOut}
+
Итого: {grandTotal.toLocaleString('ru-RU')} ₽
+ >
+ )}