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 && (