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:
@@ -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={() => ({
|
||||
|
||||
Reference in New Issue
Block a user