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')} ₽

+ + )}
+ + + )} + {!rentalIsFullDay && hourlyAvailable && ( +
+ С + + до + +
+ )} + + )} + + ) + })} + + )} )} diff --git a/src/pages/BookingWidgetStandalonePage.tsx b/src/pages/BookingWidgetStandalonePage.tsx index d1f75ee..2835bf3 100644 --- a/src/pages/BookingWidgetStandalonePage.tsx +++ b/src/pages/BookingWidgetStandalonePage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' 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 type { WidgetSettings } from './BookingWidgetPage' @@ -8,8 +8,9 @@ export function BookingWidgetStandalonePage() { const { slug } = useParams<{ slug: string }>() const [searchParams] = useSearchParams() - const [realRooms, setRealRooms] = useState([]) - const [realCategories, setRealCategories] = useState([]) + const [realRooms, setRealRooms] = useState([]) + const [realCategories, setRealCategories] = useState([]) + const [realRentalObjects, setRealRentalObjects] = useState([]) const [paymentEnabled, setPaymentEnabled] = useState(undefined) const [settings, setSettings] = useState({ hotelName: '', @@ -34,6 +35,7 @@ export function BookingWidgetStandalonePage() { .then(config => { setRealRooms(config.rooms ?? []) setRealCategories(config.categories ?? []) + setRealRentalObjects(config.rentalObjects ?? []) setPaymentEnabled(config.paymentEnabled) if (config.widgetSettings) { const ws = config.widgetSettings @@ -64,6 +66,7 @@ export function BookingWidgetStandalonePage() { slug={slug} realRooms={realRooms.length > 0 ? realRooms : undefined} realCategories={realCategories.length > 0 ? realCategories : undefined} + realRentalObjects={realRentalObjects.length > 0 ? realRentalObjects : undefined} paymentEnabled={paymentEnabled} />