- MC registrations: notification toggles (confirm/remind) with urgency - Group bookings: save to DB from BookingModal, admin CRUD at /admin/bookings - Open Day: full event system with schedule grid (halls × time), per-class booking, discount pricing (30 BYN / 20 BYN from 3+), auto-cancel threshold - Unified SignupModal replaces 3 separate forms — consistent fields (name, phone, instagram, telegram), Instagram DM fallback on network error - Centralized /admin/bookings page with 3 tabs (classes, MC, Open Day), collapsible sections, notification toggles, filter chips - Unread booking badge on sidebar + dashboard widget with per-type breakdown - Pricing: contact hint (Instagram/Telegram/phone) on price & rental tabs, admin toggle to show/hide - DB migrations 5-7: group_bookings table, open_day tables, unified fields Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
206 lines
7.5 KiB
TypeScript
206 lines
7.5 KiB
TypeScript
"use client";
|
||
|
||
import { SectionEditor } from "../_components/SectionEditor";
|
||
import { InputField, SelectField } from "../_components/FormField";
|
||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||
|
||
interface PricingItem {
|
||
name: string;
|
||
price: string;
|
||
note?: string;
|
||
popular?: boolean;
|
||
featured?: boolean;
|
||
}
|
||
|
||
interface PricingData {
|
||
title: string;
|
||
subtitle: string;
|
||
items: PricingItem[];
|
||
rentalTitle: string;
|
||
rentalItems: { name: string; price: string; note?: string }[];
|
||
rules: string[];
|
||
showContactHint?: boolean;
|
||
}
|
||
|
||
function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||
// Strip "BYN" suffix for editing, add back on save
|
||
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
|
||
|
||
return (
|
||
<div>
|
||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||
<div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors">
|
||
<input
|
||
type="text"
|
||
value={raw}
|
||
onChange={(e) => {
|
||
const v = e.target.value;
|
||
onChange(v ? `${v} BYN` : "");
|
||
}}
|
||
placeholder="0"
|
||
className="flex-1 bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none min-w-0"
|
||
/>
|
||
<span className="flex items-center pr-4 text-sm font-medium text-gold select-none">
|
||
BYN
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function PricingEditorPage() {
|
||
return (
|
||
<SectionEditor<PricingData> sectionKey="pricing" title="Цены">
|
||
{(data, update) => (
|
||
<>
|
||
<InputField
|
||
label="Заголовок секции"
|
||
value={data.title}
|
||
onChange={(v) => update({ ...data, title: v })}
|
||
/>
|
||
<InputField
|
||
label="Подзаголовок"
|
||
value={data.subtitle}
|
||
onChange={(v) => update({ ...data, subtitle: v })}
|
||
/>
|
||
|
||
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={data.showContactHint !== false}
|
||
onClick={() => update({ ...data, showContactHint: data.showContactHint === false })}
|
||
className={`relative h-5 w-9 rounded-full transition-colors ${
|
||
data.showContactHint !== false ? "bg-gold" : "bg-neutral-600"
|
||
}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
|
||
data.showContactHint !== false ? "translate-x-4" : ""
|
||
}`}
|
||
/>
|
||
</button>
|
||
<span className="text-sm text-neutral-400">Показывать контакты для записи (Instagram, Telegram, телефон)</span>
|
||
</label>
|
||
|
||
{/* Featured selector */}
|
||
{(() => {
|
||
const itemOptions = data.items
|
||
.map((it, idx) => ({ value: String(idx), label: it.name }))
|
||
.filter((o) => o.label.trim() !== "");
|
||
const noneOption = { value: "", label: "— Нет —" };
|
||
const featuredIdx = data.items.findIndex((it) => it.featured);
|
||
|
||
return (
|
||
<SelectField
|
||
label="Выделенный абонемент (безлимит)"
|
||
value={featuredIdx >= 0 ? String(featuredIdx) : ""}
|
||
onChange={(v) => {
|
||
const items = data.items.map((it, idx) => ({
|
||
...it,
|
||
featured: v ? idx === Number(v) : false,
|
||
}));
|
||
update({ ...data, items });
|
||
}}
|
||
options={[noneOption, ...itemOptions]}
|
||
placeholder="Выберите..."
|
||
/>
|
||
);
|
||
})()}
|
||
|
||
<ArrayEditor
|
||
label="Абонементы"
|
||
items={data.items}
|
||
onChange={(items) => update({ ...data, items })}
|
||
renderItem={(item, _i, updateItem) => (
|
||
<div className="space-y-3">
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
<InputField
|
||
label="Название"
|
||
value={item.name}
|
||
onChange={(v) => updateItem({ ...item, name: v })}
|
||
/>
|
||
<PriceField
|
||
label="Цена"
|
||
value={item.price}
|
||
onChange={(v) => updateItem({ ...item, price: v })}
|
||
/>
|
||
<InputField
|
||
label="Примечание"
|
||
value={item.note || ""}
|
||
onChange={(v) => updateItem({ ...item, note: v })}
|
||
/>
|
||
</div>
|
||
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={!!item.popular}
|
||
onClick={() => updateItem({ ...item, popular: !item.popular })}
|
||
className={`relative h-5 w-9 rounded-full transition-colors ${
|
||
item.popular ? "bg-gold" : "bg-neutral-600"
|
||
}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
|
||
item.popular ? "translate-x-4" : ""
|
||
}`}
|
||
/>
|
||
</button>
|
||
<span className="text-sm text-neutral-400">Популярный</span>
|
||
</label>
|
||
</div>
|
||
)}
|
||
createItem={() => ({ name: "", price: "", note: "" })}
|
||
addLabel="Добавить абонемент"
|
||
/>
|
||
|
||
<InputField
|
||
label="Заголовок аренды"
|
||
value={data.rentalTitle}
|
||
onChange={(v) => update({ ...data, rentalTitle: v })}
|
||
/>
|
||
|
||
<ArrayEditor
|
||
label="Аренда"
|
||
items={data.rentalItems}
|
||
onChange={(rentalItems) => update({ ...data, rentalItems })}
|
||
renderItem={(item, _i, updateItem) => (
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
<InputField
|
||
label="Название"
|
||
value={item.name}
|
||
onChange={(v) => updateItem({ ...item, name: v })}
|
||
/>
|
||
<PriceField
|
||
label="Цена"
|
||
value={item.price}
|
||
onChange={(v) => updateItem({ ...item, price: v })}
|
||
/>
|
||
<InputField
|
||
label="Примечание"
|
||
value={item.note || ""}
|
||
onChange={(v) => updateItem({ ...item, note: v })}
|
||
/>
|
||
</div>
|
||
)}
|
||
createItem={() => ({ name: "", price: "", note: "" })}
|
||
addLabel="Добавить вариант аренды"
|
||
/>
|
||
|
||
<ArrayEditor
|
||
label="Правила"
|
||
items={data.rules}
|
||
onChange={(rules) => update({ ...data, rules })}
|
||
renderItem={(rule, _i, updateItem) => (
|
||
<InputField label="Правило" value={rule} onChange={updateItem} />
|
||
)}
|
||
createItem={() => ""}
|
||
addLabel="Добавить правило"
|
||
/>
|
||
</>
|
||
)}
|
||
</SectionEditor>
|
||
);
|
||
}
|