diff --git a/backend/src/routes/publicWidget.ts b/backend/src/routes/publicWidget.ts index c07735b..3125068 100644 --- a/backend/src/routes/publicWidget.ts +++ b/backend/src/routes/publicWidget.ts @@ -20,13 +20,25 @@ const publicWidget: FastifyPluginAsync = async (fastify) => { const { rows: rooms } = await db.query( `SELECT id, number, name, type, floor, max_guests, base_rate, amenities, description, photos, - allow_hourly, hourly_rate, extra_place, child_policy, status + allow_hourly, hourly_rate, extra_place, child_policy, status, category_id FROM rooms WHERE hotel_id = $1 AND status != 'inactive' ORDER BY sort_order, number`, [hotel.id], ) + const { rows: categories } = await db.query( + `SELECT rc.id, rc.name, rc.description, rc.amenities, rc.photos, + COALESCE(MIN(r.base_rate), 0) AS min_price, + COALESCE(MAX(r.max_guests), 2) AS max_guests + FROM room_categories rc + LEFT JOIN rooms r ON r.category_id = rc.id AND r.status != 'inactive' + WHERE rc.hotel_id = $1 + GROUP BY rc.id, rc.name, rc.description, rc.amenities, rc.photos, rc.sort_order + ORDER BY rc.sort_order, rc.name`, + [hotel.id], + ) + // Check if YooKassa gateway is configured for booking-widget const gateway = await getGatewayForModule(hotel.id, 'booking-widget') @@ -47,9 +59,19 @@ const publicWidget: FastifyPluginAsync = async (fastify) => { amenities: r.amenities ?? [], description: r.description ?? '', photos: r.photos ?? [], + categoryId: r.category_id ?? null, allowHourly: r.allow_hourly, hourlyRate: r.hourly_rate ? Number(r.hourly_rate) : null, })), + categories: categories.map(c => ({ + id: c.id, + name: c.name, + description: c.description ?? '', + amenities: c.amenities ?? [], + photos: c.photos ?? [], + minPrice: Number(c.min_price), + maxGuests: Number(c.max_guests), + })), } }) @@ -74,7 +96,7 @@ const publicWidget: FastifyPluginAsync = async (fastify) => { const occupiedIds = new Set(occupied.map((r: any) => r.room_id)) const { rows: rooms } = await db.query( - `SELECT id, number, name, type, floor, max_guests, base_rate, amenities, description, photos + `SELECT id, number, name, type, floor, max_guests, base_rate, amenities, description, photos, category_id FROM rooms WHERE hotel_id = $1 AND status = 'available' ORDER BY sort_order, number`, [hotel.id], @@ -93,6 +115,7 @@ const publicWidget: FastifyPluginAsync = async (fastify) => { amenities: r.amenities ?? [], description: r.description ?? '', photos: r.photos ?? [], + categoryId: r.category_id ?? null, })) } ) @@ -101,7 +124,7 @@ const publicWidget: FastifyPluginAsync = async (fastify) => { // Create an online booking (no auth) fastify.post { const hotel = await getHotelId(req.params.slug) if (!hotel) return reply.code(404).send({ error: 'Hotel not found' }) - const { roomId, checkIn, checkOut, guestName, guestEmail, guestPhone, + let { roomId, categoryId, checkIn, checkOut, guestName, guestEmail, guestPhone, adults = 1, children = 0, totalAmount, notes, services } = req.body - if (!roomId || !checkIn || !checkOut || !guestName || !totalAmount) { + if (!checkIn || !checkOut || !guestName || !totalAmount) { return reply.code(400).send({ error: 'Missing required fields' }) } + if (!roomId && !categoryId) { + return reply.code(400).send({ error: 'roomId or categoryId required' }) + } - // Check availability + // If categoryId given, find first available room in category + if (categoryId && !roomId) { + const { rows: available } = await db.query( + `SELECT id FROM rooms + WHERE hotel_id = $1 AND category_id = $2 AND status = 'available' + AND id NOT IN ( + SELECT room_id FROM bookings + WHERE hotel_id = $1 + AND status NOT IN ('cancelled','no_show','checked_out') + AND check_in < $4 AND check_out > $3 + ) + ORDER BY sort_order, number LIMIT 1`, + [hotel.id, categoryId, checkIn, checkOut], + ) + if (!available[0]) return reply.code(409).send({ error: 'No rooms available in this category for selected dates' }) + roomId = available[0].id + } + + // Check availability for specific room const { rows: conflict } = await db.query( `SELECT id FROM bookings WHERE hotel_id = $1 AND room_id = $2 diff --git a/src/lib/api.ts b/src/lib/api.ts index d80f122..37d507f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1634,6 +1634,17 @@ export interface WidgetRoom { amenities: string[] description: string photos: string[] + categoryId: string | null +} + +export interface WidgetCategory { + id: string + name: string + description: string + amenities: string[] + photos: string[] + minPrice: number + maxGuests: number } export interface WidgetConfig { @@ -1643,10 +1654,12 @@ export interface WidgetConfig { paymentEnabled: boolean currency: string rooms: WidgetRoom[] + categories: WidgetCategory[] } export interface WidgetBookingPayload { - roomId: string + roomId?: string + categoryId?: string checkIn: string checkOut: string guestName: string diff --git a/src/pages/BookingWidgetPage.tsx b/src/pages/BookingWidgetPage.tsx index a64425a..64e1a6e 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 PaymentGateway } from '../lib/api' +import { api, type WidgetRoom, type WidgetCategory, type PaymentGateway } from '../lib/api' // ── Widget settings type ─────────────────────────────────────────────────────── @@ -38,6 +38,7 @@ interface WidgetSettings { language: 'ru' | 'en' showRooms: boolean showRental: boolean + roomDisplayMode: 'rooms' | 'categories' minNights: number paymentProvider: 'yukassa' | 'tinkoff' | 'cloudpayments' | 'none' showPromo: boolean @@ -131,6 +132,31 @@ const MOCK_ROOMS: MockRoom[] = [ }, ] +interface MockCategory { + id: string + name: string + minPrice: number + maxGuests: number + amenities: string[] + description: string + availableCount: number +} + +const MOCK_CATEGORIES: MockCategory[] = [ + { id: 'c1', name: 'Стандарт', minPrice: 4500, maxGuests: 2, availableCount: 3, + amenities: ['Wi-Fi', 'TV', 'Кондиционер', 'Фен'], + description: 'Уютные номера с современным ремонтом и всем необходимым для комфортного проживания.' }, + { id: 'c2', name: 'Комфорт', minPrice: 6500, maxGuests: 2, availableCount: 2, + amenities: ['Wi-Fi', 'TV', 'Кондиционер', 'Мини-бар', 'Кофемашина'], + description: 'Просторные номера с расширенными удобствами и зоной отдыха.' }, + { id: 'c3', name: 'Делюкс', minPrice: 8900, maxGuests: 3, availableCount: 1, + amenities: ['Wi-Fi', 'Smart TV', 'Кондиционер', 'Джакузи', 'Балкон'], + description: 'Роскошные номера с панорамными окнами и собственным джакузи.' }, + { id: 'c4', name: 'Пентхаус', minPrice: 15000, maxGuests: 4, availableCount: 1, + amenities: ['Wi-Fi 1Гбит', 'Smart TV 75"', 'Сауна', 'Терраса', 'Дворецкий'], + description: 'Эксклюзивный пентхаус на верхнем этаже с террасой и видом 360°.' }, +] + const AMENITY_ICONS: Record = { 'Wi-Fi': Wifi, 'Wi-Fi 1Гбит': Wifi, 'TV': Tv2, 'Smart TV': Tv2, 'Smart TV 75"': Tv2, @@ -161,10 +187,11 @@ const DEFAULT_SERVICES: AdditionalService[] = [ // ── Widget Preview Component ─────────────────────────────────────────────────── -function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: { +function WidgetPreview({ settings, slug, realRooms, realCategories, paymentEnabled }: { settings: WidgetSettings slug?: string realRooms?: WidgetRoom[] + realCategories?: WidgetCategory[] paymentEnabled?: boolean }) { const [previewTab, setPreviewTab] = useState<'rooms' | 'rental'>('rooms') @@ -205,8 +232,20 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: { })) : MOCK_ROOMS - const selectedRoom = displayRooms.find(r => r.id === selected) - const roomTotal = selectedRoom ? selectedRoom.price * Math.max(1, nights) : 0 + // Categories mode + const displayCategories: Array<{ id: string; name: string; minPrice: number; maxGuests: number; amenities: string[]; description: string; availableCount: number }> = + realCategories && realCategories.length > 0 + ? realCategories.map(c => ({ + ...c, + availableCount: realRooms ? realRooms.filter(r => r.categoryId === c.id).length : 0, + })) + : MOCK_CATEGORIES + + const isCategoryMode = settings.roomDisplayMode === 'categories' + const selectedRoom = !isCategoryMode ? displayRooms.find(r => r.id === selected) : undefined + const selectedCategory = isCategoryMode ? displayCategories.find(c => c.id === selected) : undefined + const selectedName = selectedRoom?.name ?? selectedCategory?.name + const roomTotal = (selectedRoom ? selectedRoom.price : (selectedCategory ? selectedCategory.minPrice : 0)) * Math.max(1, nights) const extraTotal = extraBeds * EXTRA_BED_PRICE * Math.max(1, nights) const servicesTotal = selectedServices.reduce((sum, sid) => { const s = settings.additionalServices.find(s => s.id === sid) @@ -236,7 +275,7 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: { .map(s => ({ name: s!.name, price: s!.price })) const result = await api.widget.createBooking(slug, { - roomId: selected, + ...(isCategoryMode ? { categoryId: selected! } : { roomId: selected! }), checkIn, checkOut, guestName: formValues['name'] ?? formValues['full_name'] ?? 'Гость', guestEmail: formValues['email'] ?? undefined, @@ -308,7 +347,7 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: { : 'Оплата на месте при заезде. Подтверждение придёт на email.'}

-

Номер: {selectedRoom?.name}

+

{isCategoryMode ? 'Категория' : 'Номер'}: {selectedName}

Заезд: {checkIn}

Выезд: {checkOut}

Итого: {grandTotal.toLocaleString('ru-RU')} ₽

@@ -346,7 +385,7 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
{/* Amount summary */}
- {selectedRoom?.name} · {nights} ноч. + {selectedName} · {nights} ноч. {grandTotal.toLocaleString('ru-RU')} ₽
@@ -431,7 +470,7 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {

{settings.language === 'ru' ? 'Данные гостя' : 'Guest details'}

-

{selectedRoom?.name} · {nights} ноч.

+

{selectedName} · {nights} ноч.

{/* Step indicator */}
@@ -447,7 +486,7 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: { {/* Summary */}
- {selectedRoom?.name} × {nights} ноч. + {selectedName} × {nights} ноч. {roomTotal.toLocaleString('ru-RU')} ₽
{extraBeds > 0 && ( @@ -706,8 +745,78 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
)} - {/* Rooms list */} - {previewTab === 'rooms' && ( + {/* Rooms list / Category list */} + {previewTab === 'rooms' && isCategoryMode && ( +
+ {nights > 0 && ( +

+ {nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} · {guests} {settings.language === 'ru' ? 'гостей' : 'guests'} + {children > 0 && ` · ${children} дет.`} +

+ )} + {displayCategories.map(cat => { + const isSelected = selected === cat.id + return ( +
setSelected(cat.id === selected ? null : cat.id)} + > +
+
+ {/* Icon */} +
+ 🛏️ +
+
+

{cat.name}

+
+ + до {cat.maxGuests} + + {cat.availableCount > 0 && ( + · {cat.availableCount} свободно + )} +
+ {cat.description && ( +

{cat.description}

+ )} +
+
+

+ {nights > 0 ? (cat.minPrice * nights).toLocaleString('ru-RU') : cat.minPrice.toLocaleString('ru-RU')} ₽ +

+

{nights > 0 ? `за ${nights} ноч.` : 'от/ночь'}

+
+
+ {/* Amenities */} + {cat.amenities.length > 0 && ( +
+ {cat.amenities.slice(0, 4).map(a => { + const Icon = AMENITY_ICONS[a] + return ( + + {Icon ? : null}{a} + + ) + })} + {cat.amenities.length > 4 && ( + +{cat.amenities.length - 4} + )} +
+ )} +
+
+ ) + })} +
+ )} + + {previewTab === 'rooms' && !isCategoryMode && (
{nights > 0 && (

@@ -952,8 +1061,7 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: { > {settings.language === 'ru' ? 'Забронировать' : 'Book now'} {selected && nights > 0 && previewTab === 'rooms' && (() => { - const r = displayRooms.find(r => r.id === selected) - const total = r ? r.price * nights + extraBeds * EXTRA_BED_PRICE * nights : 0 + const total = grandTotal return total ? ` · ${total.toLocaleString('ru-RU')} ₽` : '' })()} @@ -982,6 +1090,7 @@ export function BookingWidgetPage() { // Real data for preview const [realRooms, setRealRooms] = useState([]) + const [realCategories, setRealCategories] = useState([]) const [gateway, setGateway] = useState(null) const [gatewayLoading, setGatewayLoading] = useState(false) @@ -993,6 +1102,7 @@ export function BookingWidgetPage() { api.paymentGateways.list(slug).catch(() => [] as PaymentGateway[]), ]).then(([config, gws]) => { if (config?.rooms) setRealRooms(config.rooms) + if (config?.categories) setRealCategories(config.categories) const widgetGw = gws.find(g => g.isActive && g.modules?.includes('booking-widget')) setGateway(widgetGw ?? null) }).finally(() => setGatewayLoading(false)) @@ -1002,9 +1112,10 @@ export function BookingWidgetPage() { hotelName: 'Grand Palace Hotel', primaryColor: '#4F46E5', language: 'ru', - showRooms: true, - showRental: rentalActive, - minNights: 1, + showRooms: true, + showRental: rentalActive, + roomDisplayMode: 'rooms', + minNights: 1, paymentProvider: 'yukassa', showPromo: true, allowExtraBeds: true, @@ -1190,6 +1301,27 @@ export function BookingWidgetPage() {

set('showRooms', e.target.checked)} className="rounded" /> + {settings.showRooms && ( +
+

Показывать по:

+
+ {([['rooms', 'Номерам'], ['categories', 'Категориям']] as const).map(([k, l]) => ( + + ))} +
+
+ )} {rentalActive && (