feat: widget additional services — sourced from rental objects module

- Additional services in widget are now derived from rental_objects (not a separate list)
- Settings page shows rental objects with enable/disable toggle; link to rental module
- Saves only {id, enabled}[] to widget_services — rental objects manage name/price/icon
- Standalone widget also derives additionalServices from config.rentalObjects
- Widget config API includes services[] in widgetSettings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 12:13:13 +03:00
parent c8ff5ad0ed
commit c153e12543
4 changed files with 73 additions and 126 deletions

View File

@@ -78,6 +78,7 @@ const publicWidget: FastifyPluginAsync = async (fastify) => {
allowChildren: (ws.widget_children as boolean) ?? true, allowChildren: (ws.widget_children as boolean) ?? true,
hotelName: (ws.widget_hotel_name as string) ?? hotel.name, hotelName: (ws.widget_hotel_name as string) ?? hotel.name,
bankDetails: (ws.widget_bank_details as string) ?? '', bankDetails: (ws.widget_bank_details as string) ?? '',
services: Array.isArray(ws.widget_services) ? ws.widget_services as { id: string; enabled: boolean }[] : null,
}, },
rooms: rooms.map(r => ({ rooms: rooms.map(r => ({
id: r.id, id: r.id,

View File

@@ -1716,6 +1716,7 @@ export interface WidgetConfig {
allowExtraBeds: boolean allowExtraBeds: boolean
allowChildren: boolean allowChildren: boolean
hotelName: string hotelName: string
services?: { id: string; enabled: boolean }[]
} }
} }

View File

@@ -1313,7 +1313,20 @@ export function BookingWidgetPage() {
showPromo: (hs as any).widgetShowPromo !== undefined ? Boolean((hs as any).widgetShowPromo) : prev.showPromo, showPromo: (hs as any).widgetShowPromo !== undefined ? Boolean((hs as any).widgetShowPromo) : prev.showPromo,
allowExtraBeds: (hs as any).widgetExtraBeds !== undefined ? Boolean((hs as any).widgetExtraBeds) : prev.allowExtraBeds, allowExtraBeds: (hs as any).widgetExtraBeds !== undefined ? Boolean((hs as any).widgetExtraBeds) : prev.allowExtraBeds,
allowChildren: (hs as any).widgetChildren !== undefined ? Boolean((hs as any).widgetChildren) : prev.allowChildren, allowChildren: (hs as any).widgetChildren !== undefined ? Boolean((hs as any).widgetChildren) : prev.allowChildren,
additionalServices: Array.isArray((hs as any).widgetServices) ? (hs as any).widgetServices : prev.additionalServices, additionalServices: (() => {
const rentalObjs = config?.rentalObjects ?? []
if (rentalObjs.length === 0) return prev.additionalServices
const saved: { id: string; enabled: boolean }[] = Array.isArray((hs as any).widgetServices)
? (hs as any).widgetServices : []
return rentalObjs.map(o => ({
id: o.id,
name: o.name,
icon: o.icon,
price: o.pricePerHour,
unit: 'ч',
enabled: saved.length > 0 ? (saved.find(s => s.id === o.id)?.enabled ?? true) : true,
}))
})(),
})) }))
setSettingsReady(true) setSettingsReady(true)
}).finally(() => setGatewayLoading(false)) }).finally(() => setGatewayLoading(false))
@@ -1365,7 +1378,7 @@ export function BookingWidgetPage() {
widget_show_promo: s.showPromo, widget_show_promo: s.showPromo,
widget_extra_beds: s.allowExtraBeds, widget_extra_beds: s.allowExtraBeds,
widget_children: s.allowChildren, widget_children: s.allowChildren,
widget_services: s.additionalServices, widget_services: s.additionalServices.map(s => ({ id: s.id, enabled: s.enabled })),
}) })
setSaved(true) setSaved(true)
setTimeout(() => setSaved(false), 2000) setTimeout(() => setSaved(false), 2000)
@@ -1658,122 +1671,38 @@ export function BookingWidgetPage() {
</div> </div>
</div> </div>
{/* Additional services */} {/* Additional services — from rental objects */}
<div className="card p-5 space-y-3"> <div className="card p-5 space-y-3">
<div className="flex items-center justify-between"> <div>
<div> <h3 className="font-semibold text-slate-900 dark:text-slate-100">Дополнительные услуги</h3>
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Дополнительные услуги</h3> <p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5"> Объекты аренды, доступные гостю при бронировании номера. Управляйте объектами в{' '}
Платные услуги, доступные гостю при бронировании номера <a href={`/${slug}/rental`} className="text-brand-600 hover:underline">модуле аренды</a>.
</p> </p>
</div>
{settings.additionalServices.length === 0 ? (
<div className="rounded-xl bg-slate-50 dark:bg-slate-700/30 p-4 text-center">
<p className="text-sm text-slate-500 dark:text-slate-400">Нет объектов аренды</p>
<a href={`/${slug}/rental`} className="text-xs text-brand-600 hover:underline mt-1 inline-block">
Добавить в модуле аренды
</a>
</div> </div>
<button ) : (
onClick={() => { setShowAddService(true); setEditingServiceId(null) }} <div className="space-y-1">
className="flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-700 transition-colors" {settings.additionalServices.map(s => (
> <div key={s.id} className="flex items-center gap-2 px-2 py-1.5 rounded-xl hover:bg-slate-50 dark:hover:bg-slate-700/50">
<Plus size={13} /> Добавить <span className="text-base w-6 text-center">{s.icon}</span>
</button> <span className="text-sm text-slate-700 dark:text-slate-300 flex-1">{s.name}</span>
</div> <span className="text-xs text-slate-400">{s.price.toLocaleString('ru-RU')} /{s.unit}</span>
<button
<div className="space-y-1"> onClick={() => toggleService(s.id)}
{settings.additionalServices.map(s => ( className={cn('p-1 rounded-lg transition-colors', s.enabled ? 'text-brand-600' : 'text-slate-400')}
<div key={s.id}> >
{editingServiceId === s.id ? ( {s.enabled ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
<div className="flex items-center gap-2 p-2 rounded-xl border border-brand-200 bg-brand-50 dark:bg-brand-900/10 dark:border-brand-800"> </button>
<input </div>
value={s.icon} ))}
onChange={e => updateService(s.id, { icon: e.target.value })}
className="w-10 text-center text-base border border-slate-200 rounded-lg p-1 bg-white dark:bg-slate-800 dark:border-slate-600"
placeholder="🎾"
maxLength={4}
/>
<input
value={s.name}
onChange={e => updateService(s.id, { name: e.target.value })}
className="flex-1 text-sm border border-slate-200 rounded-lg px-2 py-1 bg-white dark:bg-slate-800 dark:border-slate-600"
placeholder="Название"
/>
<input
type="number"
value={s.price}
onChange={e => updateService(s.id, { price: Number(e.target.value) })}
className="w-20 text-sm border border-slate-200 rounded-lg px-2 py-1 bg-white dark:bg-slate-800 dark:border-slate-600"
placeholder="Цена"
min={0}
/>
<input
value={s.unit}
onChange={e => updateService(s.id, { unit: e.target.value })}
className="w-16 text-sm border border-slate-200 rounded-lg px-2 py-1 bg-white dark:bg-slate-800 dark:border-slate-600"
placeholder="ч/день"
/>
<button onClick={() => setEditingServiceId(null)} className="p-1 text-brand-600"><Check size={15} /></button>
</div>
) : (
<div className="flex items-center gap-2 px-2 py-1.5 rounded-xl hover:bg-slate-50 dark:hover:bg-slate-700/50 group">
<span className="text-base w-6 text-center">{s.icon}</span>
<span className="text-sm text-slate-700 dark:text-slate-300 flex-1">{s.name}</span>
<span className="text-xs text-slate-400">{s.price.toLocaleString('ru-RU')} /{s.unit}</span>
<button
onClick={() => setEditingServiceId(s.id)}
className="p-1 text-slate-400 hover:text-slate-600 opacity-0 group-hover:opacity-100 transition-all"
>
<Settings2 size={13} />
</button>
<button
onClick={() => removeService(s.id)}
className="p-1 text-slate-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 size={13} />
</button>
<button
onClick={() => toggleService(s.id)}
className={cn('p-1 rounded-lg transition-colors', s.enabled ? 'text-brand-600' : 'text-slate-400')}
>
{s.enabled ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
</button>
</div>
)}
</div>
))}
{settings.additionalServices.length === 0 && !showAddService && (
<p className="text-xs text-slate-400 text-center py-3">Нет дополнительных услуг</p>
)}
</div>
{showAddService && (
<div className="flex items-center gap-2 p-2 rounded-xl border border-brand-200 bg-brand-50 dark:bg-brand-900/10 dark:border-brand-800">
<input
value={newService.icon}
onChange={e => setNewService(prev => ({ ...prev, icon: e.target.value }))}
className="w-10 text-center text-base border border-slate-200 rounded-lg p-1 bg-white dark:bg-slate-800 dark:border-slate-600"
placeholder="🎾"
maxLength={4}
/>
<input
value={newService.name}
onChange={e => setNewService(prev => ({ ...prev, name: e.target.value }))}
className="flex-1 text-sm border border-slate-200 rounded-lg px-2 py-1 bg-white dark:bg-slate-800 dark:border-slate-600"
placeholder="Название услуги"
autoFocus
/>
<input
type="number"
value={newService.price || ''}
onChange={e => setNewService(prev => ({ ...prev, price: Number(e.target.value) }))}
className="w-20 text-sm border border-slate-200 rounded-lg px-2 py-1 bg-white dark:bg-slate-800 dark:border-slate-600"
placeholder="Цена"
min={0}
/>
<input
value={newService.unit}
onChange={e => setNewService(prev => ({ ...prev, unit: e.target.value }))}
className="w-16 text-sm border border-slate-200 rounded-lg px-2 py-1 bg-white dark:bg-slate-800 dark:border-slate-600"
placeholder="ч/день"
/>
<button onClick={addService} disabled={!newService.name.trim()} className="p-1 text-brand-600 disabled:opacity-40"><Check size={15} /></button>
<button onClick={() => setShowAddService(false)} className="p-1 text-slate-400 hover:text-red-500"><XIcon size={13} /></button>
</div> </div>
)} )}
</div> </div>

View File

@@ -37,22 +37,38 @@ export function BookingWidgetStandalonePage() {
setRealCategories(config.categories ?? []) setRealCategories(config.categories ?? [])
setRealRentalObjects(config.rentalObjects ?? []) setRealRentalObjects(config.rentalObjects ?? [])
setPaymentEnabled(config.paymentEnabled) setPaymentEnabled(config.paymentEnabled)
const rentalObjs = config.rentalObjects ?? []
const deriveServices = (savedServices: { id: string; enabled: boolean }[] | null | undefined) =>
rentalObjs.map(o => ({
id: o.id,
name: o.name,
icon: o.icon,
price: o.pricePerHour,
unit: 'ч',
enabled: savedServices ? (savedServices.find(s => s.id === o.id)?.enabled ?? true) : true,
}))
if (config.widgetSettings) { if (config.widgetSettings) {
const ws = config.widgetSettings const ws = config.widgetSettings
setSettings(prev => ({ setSettings(prev => ({
...prev, ...prev,
hotelName: ws.hotelName ?? config.hotelName ?? prev.hotelName, hotelName: ws.hotelName ?? config.hotelName ?? prev.hotelName,
primaryColor: ws.primaryColor ?? prev.primaryColor, primaryColor: ws.primaryColor ?? prev.primaryColor,
language: (ws.language ?? prev.language) as 'ru' | 'en', language: (ws.language ?? prev.language) as 'ru' | 'en',
roomDisplayMode: (ws.roomDisplayMode ?? prev.roomDisplayMode) as 'rooms' | 'categories', roomDisplayMode: (ws.roomDisplayMode ?? prev.roomDisplayMode) as 'rooms' | 'categories',
minNights: ws.minNights ?? prev.minNights, minNights: ws.minNights ?? prev.minNights,
showRental: ws.showRental ?? prev.showRental, showRental: ws.showRental ?? prev.showRental,
showPromo: ws.showPromo ?? prev.showPromo, showPromo: ws.showPromo ?? prev.showPromo,
allowExtraBeds: ws.allowExtraBeds ?? prev.allowExtraBeds, allowExtraBeds: ws.allowExtraBeds ?? prev.allowExtraBeds,
allowChildren: ws.allowChildren ?? prev.allowChildren, allowChildren: ws.allowChildren ?? prev.allowChildren,
additionalServices: rentalObjs.length > 0 ? deriveServices(ws.services) : prev.additionalServices,
})) }))
} else { } else {
setSettings(prev => ({ ...prev, hotelName: config.hotelName ?? prev.hotelName })) setSettings(prev => ({
...prev,
hotelName: config.hotelName ?? prev.hotelName,
additionalServices: rentalObjs.length > 0 ? deriveServices(null) : prev.additionalServices,
}))
} }
}) })
.catch(() => {}) .catch(() => {})