feat: widget — editable additional services with add/edit/delete + DB persistence
- Services can now be added, edited inline (icon/name/price/unit), and deleted - List persisted to DB as widget_services key in hotel_settings (auto-save) - Services loaded from DB on settings page init; fall back to DEFAULT_SERVICES - Toggle enable/disable retained; edit/delete buttons appear on row hover Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1248,6 +1248,7 @@ 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,
|
||||||
}))
|
}))
|
||||||
setSettingsReady(true)
|
setSettingsReady(true)
|
||||||
}).finally(() => setGatewayLoading(false))
|
}).finally(() => setGatewayLoading(false))
|
||||||
@@ -1276,6 +1277,9 @@ export function BookingWidgetPage() {
|
|||||||
const [pageTab, setPageTab] = useState<'constructor' | 'code' | 'stats'>('constructor')
|
const [pageTab, setPageTab] = useState<'constructor' | 'code' | 'stats'>('constructor')
|
||||||
const [newFieldLabel, setNewFieldLabel] = useState('')
|
const [newFieldLabel, setNewFieldLabel] = useState('')
|
||||||
const [showAddField, setShowAddField] = useState(false)
|
const [showAddField, setShowAddField] = useState(false)
|
||||||
|
const [showAddService, setShowAddService] = useState(false)
|
||||||
|
const [editingServiceId, setEditingServiceId] = useState<string | null>(null)
|
||||||
|
const [newService, setNewService] = useState<Omit<AdditionalService, 'id' | 'enabled'>>({ name: '', icon: '✨', price: 0, unit: 'раз' })
|
||||||
|
|
||||||
const set = <K extends keyof WidgetSettings>(k: K, v: WidgetSettings[K]) =>
|
const set = <K extends keyof WidgetSettings>(k: K, v: WidgetSettings[K]) =>
|
||||||
setSettings(prev => ({ ...prev, [k]: v }))
|
setSettings(prev => ({ ...prev, [k]: v }))
|
||||||
@@ -1296,6 +1300,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,
|
||||||
})
|
})
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
setTimeout(() => setSaved(false), 2000)
|
setTimeout(() => setSaved(false), 2000)
|
||||||
@@ -1320,6 +1325,34 @@ export function BookingWidgetPage() {
|
|||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const addService = () => {
|
||||||
|
if (!newService.name.trim()) return
|
||||||
|
setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
additionalServices: [...prev.additionalServices, {
|
||||||
|
id: `svc-${Date.now()}`,
|
||||||
|
...newService,
|
||||||
|
enabled: true,
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
setNewService({ name: '', icon: '✨', price: 0, unit: 'раз' })
|
||||||
|
setShowAddService(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateService = (id: string, patch: Partial<AdditionalService>) =>
|
||||||
|
setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
additionalServices: prev.additionalServices.map(s =>
|
||||||
|
s.id === id ? { ...s, ...patch } : s
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const removeService = (id: string) =>
|
||||||
|
setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
additionalServices: prev.additionalServices.filter(s => s.id !== id),
|
||||||
|
}))
|
||||||
|
|
||||||
const toggleField = (id: string, prop: 'enabled' | 'required') =>
|
const toggleField = (id: string, prop: 'enabled' | 'required') =>
|
||||||
setSettings(prev => ({
|
setSettings(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -1562,16 +1595,72 @@ export function BookingWidgetPage() {
|
|||||||
|
|
||||||
{/* Additional services */}
|
{/* Additional services */}
|
||||||
<div className="card p-5 space-y-3">
|
<div className="card p-5 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<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">
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
Услуги, доступные для добавления при бронировании
|
Платные услуги, доступные гостю при бронировании номера
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowAddService(true); setEditingServiceId(null) }}
|
||||||
|
className="flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={13} /> Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
{settings.additionalServices.map(s => (
|
{settings.additionalServices.map(s => (
|
||||||
<div key={s.id} className="flex items-center gap-2">
|
<div key={s.id}>
|
||||||
<span className="text-base">{s.icon}</span>
|
{editingServiceId === s.id ? (
|
||||||
|
<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={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-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>
|
<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
|
<button
|
||||||
onClick={() => toggleService(s.id)}
|
onClick={() => toggleService(s.id)}
|
||||||
className={cn('p-1 rounded-lg transition-colors', s.enabled ? 'text-brand-600' : 'text-slate-400')}
|
className={cn('p-1 rounded-lg transition-colors', s.enabled ? 'text-brand-600' : 'text-slate-400')}
|
||||||
@@ -1579,8 +1668,49 @@ export function BookingWidgetPage() {
|
|||||||
{s.enabled ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
|
{s.enabled ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</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>
|
||||||
|
|
||||||
{/* Form fields */}
|
{/* Form fields */}
|
||||||
|
|||||||
Reference in New Issue
Block a user