feat: add booking management, Open Day, unified signup modal

- 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>
This commit is contained in:
2026-03-19 12:58:04 +03:00
parent 7497ede2fd
commit b94ee69033
31 changed files with 3198 additions and 407 deletions

View File

@@ -4,7 +4,7 @@ import { useState, useRef, useEffect, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check, ChevronDown, ChevronUp, Instagram, Send, Trash2, Pencil } from "lucide-react";
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
@@ -38,15 +38,6 @@ interface MasterClassesData {
items: MasterClassItem[];
}
interface McRegistration {
id: number;
masterClassTitle: string;
name: string;
instagram: string;
telegram?: string;
createdAt: string;
}
// --- Autocomplete Multi-Select ---
function AutocompleteMulti({
label,
@@ -482,340 +473,6 @@ function ValidationHint({ fields }: { fields: Record<string, string> }) {
);
}
// --- Registration Row (inline edit) ---
function RegistrationRow({
reg,
onUpdate,
onDelete,
}: {
reg: McRegistration;
onUpdate: (updated: McRegistration) => void;
onDelete: () => void;
}) {
const [editing, setEditing] = useState(false);
const [name, setName] = useState(reg.name);
const [ig, setIg] = useState(reg.instagram.replace(/^@/, ""));
const [tg, setTg] = useState((reg.telegram || "").replace(/^@/, ""));
const [saving, setSaving] = useState(false);
async function save() {
if (!name.trim() || !ig.trim()) return;
setSaving(true);
const body = {
id: reg.id,
name: name.trim(),
instagram: `@${ig.trim()}`,
telegram: tg.trim() ? `@${tg.trim()}` : undefined,
};
const res = await adminFetch("/api/admin/mc-registrations", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
onUpdate({ ...reg, name: body.name, instagram: body.instagram, telegram: body.telegram });
setEditing(false);
}
setSaving(false);
}
function cancel() {
setName(reg.name);
setIg(reg.instagram.replace(/^@/, ""));
setTg((reg.telegram || "").replace(/^@/, ""));
setEditing(false);
}
if (editing) {
return (
<div className="rounded-lg bg-neutral-800/50 px-3 py-2 space-y-2">
<div className="flex gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Имя"
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2 py-1.5 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold"
/>
</div>
<div className="flex gap-2">
<div className="flex flex-1 items-center rounded-md border border-white/10 bg-neutral-800 text-sm">
<span className="flex items-center gap-1 pl-2 text-neutral-500 select-none">
<Instagram size={11} className="text-pink-400" />@
</span>
<input
value={ig}
onChange={(e) => setIg(e.target.value.replace(/^@/, ""))}
placeholder="instagram"
className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none"
/>
</div>
<div className="flex flex-1 items-center rounded-md border border-white/10 bg-neutral-800 text-sm">
<span className="flex items-center gap-1 pl-2 text-neutral-500 select-none">
<Send size={11} className="text-blue-400" />@
</span>
<input
value={tg}
onChange={(e) => setTg(e.target.value.replace(/^@/, ""))}
placeholder="telegram"
className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={cancel}
className="rounded-md px-3 py-1 text-xs text-neutral-400 hover:text-white transition-colors"
>
Отмена
</button>
<button
type="button"
onClick={save}
disabled={saving || !name.trim() || !ig.trim()}
className="rounded-md bg-gold/20 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors disabled:opacity-40"
>
{saving ? "..." : "Сохранить"}
</button>
</div>
</div>
);
}
return (
<div className="flex items-center gap-2 rounded-lg bg-neutral-800/50 px-3 py-2 text-sm">
<span className="font-medium text-white">{reg.name}</span>
<span className="text-neutral-500">·</span>
<a
href={`https://ig.me/m/${reg.instagram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 transition-colors"
title="Написать в Instagram"
>
<Instagram size={12} />
<span className="text-neutral-300">{reg.instagram}</span>
</a>
{reg.telegram && (
<>
<span className="text-neutral-600">·</span>
<a
href={`https://t.me/${reg.telegram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 transition-colors"
title="Написать в Telegram"
>
<Send size={12} />
<span className="text-neutral-300">{reg.telegram}</span>
</a>
</>
)}
<span className="text-neutral-600 text-xs ml-auto">
{new Date(reg.createdAt).toLocaleDateString("ru-RU")}
</span>
<button
type="button"
onClick={() => setEditing(true)}
className="rounded p-1 text-neutral-500 hover:text-gold transition-colors"
title="Редактировать"
>
<Pencil size={12} />
</button>
<button
type="button"
onClick={onDelete}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
title="Удалить"
>
<Trash2 size={12} />
</button>
</div>
);
}
// --- Registrations List ---
function RegistrationsList({ title }: { title: string }) {
const [open, setOpen] = useState(false);
const [regs, setRegs] = useState<McRegistration[]>([]);
const [loading, setLoading] = useState(false);
const [count, setCount] = useState<number | null>(null);
const [adding, setAdding] = useState(false);
const [newName, setNewName] = useState("");
const [newIg, setNewIg] = useState("");
const [newTg, setNewTg] = useState("");
const [savingNew, setSavingNew] = useState(false);
useEffect(() => {
if (!title) return;
adminFetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
.then((r) => r.json())
.then((data: McRegistration[]) => {
setCount(data.length);
setRegs(data);
})
.catch(() => {});
}, [title]);
function toggle() {
if (!open && regs.length === 0 && count !== 0) {
setLoading(true);
adminFetch(`/api/admin/mc-registrations?title=${encodeURIComponent(title)}`)
.then((r) => r.json())
.then((data: McRegistration[]) => {
setRegs(data);
setCount(data.length);
})
.catch(() => {})
.finally(() => setLoading(false));
}
setOpen(!open);
}
async function handleAdd() {
if (!newName.trim() || !newIg.trim()) return;
setSavingNew(true);
const body = {
masterClassTitle: title,
name: newName.trim(),
instagram: `@${newIg.trim()}`,
telegram: newTg.trim() ? `@${newTg.trim()}` : undefined,
};
const res = await adminFetch("/api/admin/mc-registrations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
const { id } = await res.json();
setRegs((prev) => [{
id,
masterClassTitle: title,
name: body.name,
instagram: body.instagram,
telegram: body.telegram,
createdAt: new Date().toISOString(),
}, ...prev]);
setCount((prev) => (prev !== null ? prev + 1 : 1));
setNewName("");
setNewIg("");
setNewTg("");
setAdding(false);
}
setSavingNew(false);
}
async function handleDelete(id: number) {
await adminFetch(`/api/admin/mc-registrations?id=${id}`, { method: "DELETE" });
setRegs((prev) => prev.filter((r) => r.id !== id));
setCount((prev) => (prev !== null ? prev - 1 : null));
}
function handleUpdate(updated: McRegistration) {
setRegs((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
}
if (!title) return null;
return (
<div className="border-t border-white/5 pt-3">
<button
type="button"
onClick={toggle}
className="flex items-center gap-2 text-sm text-neutral-400 hover:text-white transition-colors"
>
{open ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
Записи{count !== null ? ` (${count})` : ""}
</button>
{open && (
<div className="mt-2 space-y-1.5">
{loading && (
<div className="flex items-center gap-2 text-xs text-neutral-500">
<Loader2 size={12} className="animate-spin" />
Загрузка...
</div>
)}
{!loading && regs.length === 0 && !adding && (
<p className="text-xs text-neutral-500">Пока никто не записался</p>
)}
{regs.map((reg) => (
<RegistrationRow
key={reg.id}
reg={reg}
onUpdate={handleUpdate}
onDelete={() => handleDelete(reg.id)}
/>
))}
{adding ? (
<div className="rounded-lg bg-neutral-800/50 px-3 py-2 space-y-2">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Имя"
className="w-full rounded-md border border-white/10 bg-neutral-800 px-2 py-1.5 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold"
/>
<div className="flex gap-2">
<div className="flex flex-1 items-center rounded-md border border-white/10 bg-neutral-800 text-sm">
<span className="flex items-center gap-1 pl-2 text-neutral-500 select-none">
<Instagram size={11} className="text-pink-400" />@
</span>
<input
value={newIg}
onChange={(e) => setNewIg(e.target.value.replace(/^@/, ""))}
placeholder="instagram"
className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none"
/>
</div>
<div className="flex flex-1 items-center rounded-md border border-white/10 bg-neutral-800 text-sm">
<span className="flex items-center gap-1 pl-2 text-neutral-500 select-none">
<Send size={11} className="text-blue-400" />@
</span>
<input
value={newTg}
onChange={(e) => setNewTg(e.target.value.replace(/^@/, ""))}
placeholder="telegram"
className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => { setAdding(false); setNewName(""); setNewIg(""); setNewTg(""); }}
className="rounded-md px-3 py-1 text-xs text-neutral-400 hover:text-white transition-colors"
>
Отмена
</button>
<button
type="button"
onClick={handleAdd}
disabled={savingNew || !newName.trim() || !newIg.trim()}
className="rounded-md bg-gold/20 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors disabled:opacity-40"
>
{savingNew ? "..." : "Добавить"}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="flex items-center gap-1.5 rounded-lg border border-dashed border-white/10 px-3 py-1.5 text-xs text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
>
<Plus size={12} />
Добавить запись
</button>
)}
</div>
)}
</div>
);
}
// --- Main page ---
export default function MasterClassesEditorPage() {
const [trainers, setTrainers] = useState<string[]>([]);
@@ -952,7 +609,6 @@ export default function MasterClassesEditorPage() {
}
/>
<RegistrationsList title={item.title} />
</div>
)}
createItem={() => ({