feat: widget — room display mode toggle (by room / by category)
This commit is contained in:
@@ -20,13 +20,25 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
const { rows: rooms } = await db.query(
|
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,
|
||||||
allow_hourly, hourly_rate, extra_place, child_policy, status
|
allow_hourly, hourly_rate, extra_place, child_policy, status, category_id
|
||||||
FROM rooms
|
FROM rooms
|
||||||
WHERE hotel_id = $1 AND status != 'inactive'
|
WHERE hotel_id = $1 AND status != 'inactive'
|
||||||
ORDER BY sort_order, number`,
|
ORDER BY sort_order, number`,
|
||||||
[hotel.id],
|
[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
|
// 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')
|
||||||
|
|
||||||
@@ -47,9 +59,19 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
|
|||||||
amenities: r.amenities ?? [],
|
amenities: r.amenities ?? [],
|
||||||
description: r.description ?? '',
|
description: r.description ?? '',
|
||||||
photos: r.photos ?? [],
|
photos: r.photos ?? [],
|
||||||
|
categoryId: r.category_id ?? null,
|
||||||
allowHourly: r.allow_hourly,
|
allowHourly: r.allow_hourly,
|
||||||
hourlyRate: r.hourly_rate ? Number(r.hourly_rate) : null,
|
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 occupiedIds = new Set(occupied.map((r: any) => r.room_id))
|
||||||
|
|
||||||
const { rows: rooms } = await db.query(
|
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'
|
FROM rooms WHERE hotel_id = $1 AND status = 'available'
|
||||||
ORDER BY sort_order, number`,
|
ORDER BY sort_order, number`,
|
||||||
[hotel.id],
|
[hotel.id],
|
||||||
@@ -93,6 +115,7 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
|
|||||||
amenities: r.amenities ?? [],
|
amenities: r.amenities ?? [],
|
||||||
description: r.description ?? '',
|
description: r.description ?? '',
|
||||||
photos: r.photos ?? [],
|
photos: r.photos ?? [],
|
||||||
|
categoryId: r.category_id ?? null,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -101,7 +124,7 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Create an online booking (no auth)
|
// Create an online booking (no auth)
|
||||||
fastify.post<SlugParam & {
|
fastify.post<SlugParam & {
|
||||||
Body: {
|
Body: {
|
||||||
roomId: string; checkIn: string; checkOut: string
|
roomId?: string; categoryId?: string; checkIn: string; checkOut: string
|
||||||
guestName: string; guestEmail?: string; guestPhone?: string
|
guestName: string; guestEmail?: string; guestPhone?: string
|
||||||
adults?: number; children?: number
|
adults?: number; children?: number
|
||||||
totalAmount: number; notes?: string
|
totalAmount: number; notes?: string
|
||||||
@@ -111,14 +134,35 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotel = await getHotelId(req.params.slug)
|
const hotel = await getHotelId(req.params.slug)
|
||||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
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
|
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' })
|
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(
|
const { rows: conflict } = await db.query(
|
||||||
`SELECT id FROM bookings
|
`SELECT id FROM bookings
|
||||||
WHERE hotel_id = $1 AND room_id = $2
|
WHERE hotel_id = $1 AND room_id = $2
|
||||||
|
|||||||
@@ -1634,6 +1634,17 @@ export interface WidgetRoom {
|
|||||||
amenities: string[]
|
amenities: string[]
|
||||||
description: string
|
description: string
|
||||||
photos: 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 {
|
export interface WidgetConfig {
|
||||||
@@ -1643,10 +1654,12 @@ export interface WidgetConfig {
|
|||||||
paymentEnabled: boolean
|
paymentEnabled: boolean
|
||||||
currency: string
|
currency: string
|
||||||
rooms: WidgetRoom[]
|
rooms: WidgetRoom[]
|
||||||
|
categories: WidgetCategory[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WidgetBookingPayload {
|
export interface WidgetBookingPayload {
|
||||||
roomId: string
|
roomId?: string
|
||||||
|
categoryId?: string
|
||||||
checkIn: string
|
checkIn: string
|
||||||
checkOut: string
|
checkOut: string
|
||||||
guestName: string
|
guestName: string
|
||||||
|
|||||||
@@ -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 PaymentGateway } from '../lib/api'
|
import { api, type WidgetRoom, type WidgetCategory, type PaymentGateway } from '../lib/api'
|
||||||
|
|
||||||
// ── Widget settings type ───────────────────────────────────────────────────────
|
// ── Widget settings type ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ interface WidgetSettings {
|
|||||||
language: 'ru' | 'en'
|
language: 'ru' | 'en'
|
||||||
showRooms: boolean
|
showRooms: boolean
|
||||||
showRental: boolean
|
showRental: boolean
|
||||||
|
roomDisplayMode: 'rooms' | 'categories'
|
||||||
minNights: number
|
minNights: number
|
||||||
paymentProvider: 'yukassa' | 'tinkoff' | 'cloudpayments' | 'none'
|
paymentProvider: 'yukassa' | 'tinkoff' | 'cloudpayments' | 'none'
|
||||||
showPromo: boolean
|
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<string, React.ElementType> = {
|
const AMENITY_ICONS: Record<string, React.ElementType> = {
|
||||||
'Wi-Fi': Wifi, 'Wi-Fi 1Гбит': Wifi,
|
'Wi-Fi': Wifi, 'Wi-Fi 1Гбит': Wifi,
|
||||||
'TV': Tv2, 'Smart TV': Tv2, 'Smart TV 75"': Tv2,
|
'TV': Tv2, 'Smart TV': Tv2, 'Smart TV 75"': Tv2,
|
||||||
@@ -161,10 +187,11 @@ const DEFAULT_SERVICES: AdditionalService[] = [
|
|||||||
|
|
||||||
// ── Widget Preview Component ───────────────────────────────────────────────────
|
// ── Widget Preview Component ───────────────────────────────────────────────────
|
||||||
|
|
||||||
function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
|
function WidgetPreview({ settings, slug, realRooms, realCategories, paymentEnabled }: {
|
||||||
settings: WidgetSettings
|
settings: WidgetSettings
|
||||||
slug?: string
|
slug?: string
|
||||||
realRooms?: WidgetRoom[]
|
realRooms?: WidgetRoom[]
|
||||||
|
realCategories?: WidgetCategory[]
|
||||||
paymentEnabled?: boolean
|
paymentEnabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [previewTab, setPreviewTab] = useState<'rooms' | 'rental'>('rooms')
|
const [previewTab, setPreviewTab] = useState<'rooms' | 'rental'>('rooms')
|
||||||
@@ -205,8 +232,20 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
|
|||||||
}))
|
}))
|
||||||
: MOCK_ROOMS
|
: MOCK_ROOMS
|
||||||
|
|
||||||
const selectedRoom = displayRooms.find(r => r.id === selected)
|
// Categories mode
|
||||||
const roomTotal = selectedRoom ? selectedRoom.price * Math.max(1, nights) : 0
|
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 extraTotal = extraBeds * EXTRA_BED_PRICE * Math.max(1, nights)
|
||||||
const servicesTotal = selectedServices.reduce((sum, sid) => {
|
const servicesTotal = selectedServices.reduce((sum, sid) => {
|
||||||
const s = settings.additionalServices.find(s => s.id === 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 }))
|
.map(s => ({ name: s!.name, price: s!.price }))
|
||||||
|
|
||||||
const result = await api.widget.createBooking(slug, {
|
const result = await api.widget.createBooking(slug, {
|
||||||
roomId: selected,
|
...(isCategoryMode ? { categoryId: selected! } : { roomId: selected! }),
|
||||||
checkIn, checkOut,
|
checkIn, checkOut,
|
||||||
guestName: formValues['name'] ?? formValues['full_name'] ?? 'Гость',
|
guestName: formValues['name'] ?? formValues['full_name'] ?? 'Гость',
|
||||||
guestEmail: formValues['email'] ?? undefined,
|
guestEmail: formValues['email'] ?? undefined,
|
||||||
@@ -308,7 +347,7 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
|
|||||||
: 'Оплата на месте при заезде. Подтверждение придёт на 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">
|
||||||
<p className="text-xs text-slate-500">Номер: <span className="font-medium text-slate-700">{selectedRoom?.name}</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>
|
||||||
@@ -346,7 +385,7 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
|
|||||||
<div className="p-5 space-y-4">
|
<div className="p-5 space-y-4">
|
||||||
{/* Amount summary */}
|
{/* Amount summary */}
|
||||||
<div className="bg-slate-50 rounded-xl p-3 flex items-center justify-between">
|
<div className="bg-slate-50 rounded-xl p-3 flex items-center justify-between">
|
||||||
<span className="text-sm text-slate-600">{selectedRoom?.name} · {nights} ноч.</span>
|
<span className="text-sm text-slate-600">{selectedName} · {nights} ноч.</span>
|
||||||
<span className="text-base font-bold text-slate-900">{grandTotal.toLocaleString('ru-RU')} ₽</span>
|
<span className="text-base font-bold text-slate-900">{grandTotal.toLocaleString('ru-RU')} ₽</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -431,7 +470,7 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
|
|||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold">{settings.language === 'ru' ? 'Данные гостя' : 'Guest details'}</p>
|
<p className="font-bold">{settings.language === 'ru' ? 'Данные гостя' : 'Guest details'}</p>
|
||||||
<p className="text-xs opacity-80">{selectedRoom?.name} · {nights} ноч.</p>
|
<p className="text-xs opacity-80">{selectedName} · {nights} ноч.</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Step indicator */}
|
{/* Step indicator */}
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
@@ -447,7 +486,7 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
|
|||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<div className="bg-slate-50 rounded-xl p-3 space-y-1">
|
<div className="bg-slate-50 rounded-xl p-3 space-y-1">
|
||||||
<div className="flex justify-between text-xs">
|
<div className="flex justify-between text-xs">
|
||||||
<span className="text-slate-500">{selectedRoom?.name} × {nights} ноч.</span>
|
<span className="text-slate-500">{selectedName} × {nights} ноч.</span>
|
||||||
<span className="font-medium">{roomTotal.toLocaleString('ru-RU')} ₽</span>
|
<span className="font-medium">{roomTotal.toLocaleString('ru-RU')} ₽</span>
|
||||||
</div>
|
</div>
|
||||||
{extraBeds > 0 && (
|
{extraBeds > 0 && (
|
||||||
@@ -706,8 +745,78 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rooms list */}
|
{/* Rooms list / Category list */}
|
||||||
{previewTab === 'rooms' && (
|
{previewTab === 'rooms' && isCategoryMode && (
|
||||||
|
<div className="p-4 space-y-3 max-h-[480px] overflow-y-auto">
|
||||||
|
{nights > 0 && (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} · {guests} {settings.language === 'ru' ? 'гостей' : 'guests'}
|
||||||
|
{children > 0 && ` · ${children} дет.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{displayCategories.map(cat => {
|
||||||
|
const isSelected = selected === cat.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cat.id}
|
||||||
|
className={cn('rounded-xl border-2 overflow-hidden transition-all cursor-pointer', isSelected ? 'border-2' : 'border-slate-200')}
|
||||||
|
style={isSelected ? { borderColor: settings.primaryColor } : {}}
|
||||||
|
onClick={() => setSelected(cat.id === selected ? null : cat.id)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn('p-3 transition-colors', isSelected ? '' : 'hover:bg-slate-50')}
|
||||||
|
style={isSelected ? { background: settings.primaryColor + '08' } : {}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center text-2xl shrink-0">
|
||||||
|
🛏️
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-slate-900 text-sm">{cat.name}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||||
|
<span className="text-xs text-slate-500 flex items-center gap-0.5">
|
||||||
|
<Users size={10} /> до {cat.maxGuests}
|
||||||
|
</span>
|
||||||
|
{cat.availableCount > 0 && (
|
||||||
|
<span className="text-xs text-emerald-600">· {cat.availableCount} свободно</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{cat.description && (
|
||||||
|
<p className="text-xs text-slate-500 mt-1 line-clamp-2">{cat.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="font-bold text-slate-900 text-sm">
|
||||||
|
{nights > 0 ? (cat.minPrice * nights).toLocaleString('ru-RU') : cat.minPrice.toLocaleString('ru-RU')} ₽
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-400">{nights > 0 ? `за ${nights} ноч.` : 'от/ночь'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Amenities */}
|
||||||
|
{cat.amenities.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{cat.amenities.slice(0, 4).map(a => {
|
||||||
|
const Icon = AMENITY_ICONS[a]
|
||||||
|
return (
|
||||||
|
<span key={a} className="flex items-center gap-0.5 text-[10px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">
|
||||||
|
{Icon ? <Icon size={8} /> : null}{a}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{cat.amenities.length > 4 && (
|
||||||
|
<span className="text-[10px] text-slate-400">+{cat.amenities.length - 4}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewTab === 'rooms' && !isCategoryMode && (
|
||||||
<div className="p-4 space-y-3 max-h-[480px] overflow-y-auto">
|
<div className="p-4 space-y-3 max-h-[480px] overflow-y-auto">
|
||||||
{nights > 0 && (
|
{nights > 0 && (
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
@@ -952,8 +1061,7 @@ function WidgetPreview({ settings, slug, realRooms, paymentEnabled }: {
|
|||||||
>
|
>
|
||||||
{settings.language === 'ru' ? 'Забронировать' : 'Book now'}
|
{settings.language === 'ru' ? 'Забронировать' : 'Book now'}
|
||||||
{selected && nights > 0 && previewTab === 'rooms' && (() => {
|
{selected && nights > 0 && previewTab === 'rooms' && (() => {
|
||||||
const r = displayRooms.find(r => r.id === selected)
|
const total = grandTotal
|
||||||
const total = r ? r.price * nights + extraBeds * EXTRA_BED_PRICE * nights : 0
|
|
||||||
return total ? ` · ${total.toLocaleString('ru-RU')} ₽` : ''
|
return total ? ` · ${total.toLocaleString('ru-RU')} ₽` : ''
|
||||||
})()}
|
})()}
|
||||||
</button>
|
</button>
|
||||||
@@ -982,6 +1090,7 @@ export function BookingWidgetPage() {
|
|||||||
|
|
||||||
// Real data for preview
|
// Real data for preview
|
||||||
const [realRooms, setRealRooms] = useState<WidgetRoom[]>([])
|
const [realRooms, setRealRooms] = useState<WidgetRoom[]>([])
|
||||||
|
const [realCategories, setRealCategories] = useState<WidgetCategory[]>([])
|
||||||
const [gateway, setGateway] = useState<PaymentGateway | null>(null)
|
const [gateway, setGateway] = useState<PaymentGateway | null>(null)
|
||||||
const [gatewayLoading, setGatewayLoading] = useState(false)
|
const [gatewayLoading, setGatewayLoading] = useState(false)
|
||||||
|
|
||||||
@@ -993,6 +1102,7 @@ export function BookingWidgetPage() {
|
|||||||
api.paymentGateways.list(slug).catch(() => [] as PaymentGateway[]),
|
api.paymentGateways.list(slug).catch(() => [] as PaymentGateway[]),
|
||||||
]).then(([config, gws]) => {
|
]).then(([config, gws]) => {
|
||||||
if (config?.rooms) setRealRooms(config.rooms)
|
if (config?.rooms) setRealRooms(config.rooms)
|
||||||
|
if (config?.categories) setRealCategories(config.categories)
|
||||||
const widgetGw = gws.find(g => g.isActive && g.modules?.includes('booking-widget'))
|
const widgetGw = gws.find(g => g.isActive && g.modules?.includes('booking-widget'))
|
||||||
setGateway(widgetGw ?? null)
|
setGateway(widgetGw ?? null)
|
||||||
}).finally(() => setGatewayLoading(false))
|
}).finally(() => setGatewayLoading(false))
|
||||||
@@ -1004,6 +1114,7 @@ export function BookingWidgetPage() {
|
|||||||
language: 'ru',
|
language: 'ru',
|
||||||
showRooms: true,
|
showRooms: true,
|
||||||
showRental: rentalActive,
|
showRental: rentalActive,
|
||||||
|
roomDisplayMode: 'rooms',
|
||||||
minNights: 1,
|
minNights: 1,
|
||||||
paymentProvider: 'yukassa',
|
paymentProvider: 'yukassa',
|
||||||
showPromo: true,
|
showPromo: true,
|
||||||
@@ -1190,6 +1301,27 @@ export function BookingWidgetPage() {
|
|||||||
</div>
|
</div>
|
||||||
<input type="checkbox" checked={settings.showRooms} onChange={e => set('showRooms', e.target.checked)} className="rounded" />
|
<input type="checkbox" checked={settings.showRooms} onChange={e => set('showRooms', e.target.checked)} className="rounded" />
|
||||||
</label>
|
</label>
|
||||||
|
{settings.showRooms && (
|
||||||
|
<div className="ml-5 pl-3 border-l-2 border-slate-200 dark:border-slate-600">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Показывать по:</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{([['rooms', 'Номерам'], ['categories', 'Категориям']] as const).map(([k, l]) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
onClick={() => set('roomDisplayMode', k)}
|
||||||
|
className={cn(
|
||||||
|
'px-2.5 py-1 text-xs rounded-lg border transition-colors',
|
||||||
|
settings.roomDisplayMode === k
|
||||||
|
? 'bg-brand-600 text-white border-brand-600'
|
||||||
|
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{rentalActive && (
|
{rentalActive && (
|
||||||
<label className="flex items-center justify-between">
|
<label className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||||
@@ -1380,6 +1512,7 @@ export function BookingWidgetPage() {
|
|||||||
settings={settings}
|
settings={settings}
|
||||||
slug={slug || undefined}
|
slug={slug || undefined}
|
||||||
realRooms={realRooms.length > 0 ? realRooms : undefined}
|
realRooms={realRooms.length > 0 ? realRooms : undefined}
|
||||||
|
realCategories={realCategories.length > 0 ? realCategories : undefined}
|
||||||
paymentEnabled={gateway ? gateway.isActive : undefined}
|
paymentEnabled={gateway ? gateway.isActive : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user