Multi-session master classes are a series — once the first session passes, the group has started and registration closes. Changed all MC date logic from "latest slot" / "any future slot" to "earliest slot": - DashboardSummary: upcoming = earliest slot >= today - McRegistrationsTab: archive = earliest slot < today - AddBookingModal: only show MCs where earliest slot >= today - Public MasterClasses: isUpcoming checks earliest slot
224 lines
9.7 KiB
TypeScript
224 lines
9.7 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { X } from "lucide-react";
|
||
import { adminFetch } from "@/lib/csrf";
|
||
|
||
type Tab = "classes" | "events";
|
||
type EventType = "master-class" | "open-day";
|
||
|
||
interface McOption { title: string; date: string }
|
||
interface OdClass { id: number; style: string; time: string; hall: string }
|
||
interface OdEvent { id: number; date: string; title?: string }
|
||
|
||
export function AddBookingModal({
|
||
open,
|
||
onClose,
|
||
onAdded,
|
||
}: {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onAdded: () => void;
|
||
}) {
|
||
const [tab, setTab] = useState<Tab>("classes");
|
||
const [eventType, setEventType] = useState<EventType>("master-class");
|
||
const [name, setName] = useState("");
|
||
const [phone, setPhone] = useState("");
|
||
const [instagram, setInstagram] = useState("");
|
||
const [telegram, setTelegram] = useState("");
|
||
const [mcTitle, setMcTitle] = useState("");
|
||
const [mcOptions, setMcOptions] = useState<McOption[]>([]);
|
||
const [odClasses, setOdClasses] = useState<OdClass[]>([]);
|
||
const [odEventId, setOdEventId] = useState<number | null>(null);
|
||
const [odClassId, setOdClassId] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setName(""); setPhone(""); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId("");
|
||
|
||
// Fetch upcoming MCs (filter out expired)
|
||
adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()).then((data: { items?: { title: string; slots: { date: string }[] }[] }) => {
|
||
const today = new Date().toISOString().split("T")[0];
|
||
const upcoming = (data.items || [])
|
||
.filter((mc) => {
|
||
const earliest = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
|
||
return earliest && earliest >= today;
|
||
})
|
||
.map((mc) => ({
|
||
title: mc.title,
|
||
date: mc.slots.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? ""),
|
||
}));
|
||
setMcOptions(upcoming);
|
||
if (upcoming.length === 0 && tab === "events") setEventType("open-day");
|
||
}).catch(() => {});
|
||
|
||
// Fetch active Open Day event + classes
|
||
adminFetch("/api/admin/open-day").then((r) => r.json()).then(async (events: OdEvent[]) => {
|
||
const today = new Date().toISOString().split("T")[0];
|
||
const active = events.find((e) => e.date >= today);
|
||
if (!active) { setOdEventId(null); setOdClasses([]); return; }
|
||
setOdEventId(active.id);
|
||
const classes = await adminFetch(`/api/admin/open-day/classes?eventId=${active.id}`).then((r) => r.json());
|
||
setOdClasses(classes);
|
||
}).catch(() => {});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [open]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
|
||
document.addEventListener("keydown", onKey);
|
||
return () => document.removeEventListener("keydown", onKey);
|
||
}, [open, onClose]);
|
||
|
||
const hasUpcomingMc = mcOptions.length > 0;
|
||
const hasOpenDay = odEventId !== null && odClasses.length > 0;
|
||
const hasEvents = hasUpcomingMc || hasOpenDay;
|
||
|
||
async function handleSubmit() {
|
||
if (!name.trim() || !phone.trim()) return;
|
||
setSaving(true);
|
||
try {
|
||
if (tab === "classes") {
|
||
await adminFetch("/api/admin/group-bookings", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name: name.trim(),
|
||
phone: phone.trim(),
|
||
...(instagram.trim() && { instagram: instagram.trim() }),
|
||
...(telegram.trim() && { telegram: telegram.trim() }),
|
||
}),
|
||
});
|
||
} else if (eventType === "master-class") {
|
||
const title = mcTitle || mcOptions[0]?.title || "Мастер-класс";
|
||
await adminFetch("/api/admin/mc-registrations", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ masterClassTitle: title, name: name.trim(), instagram: "-", phone: phone.trim() }),
|
||
});
|
||
} else if (eventType === "open-day" && odClassId && odEventId) {
|
||
await adminFetch("/api/open-day-register", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ classId: Number(odClassId), eventId: odEventId, name: name.trim(), phone: phone.trim() }),
|
||
});
|
||
}
|
||
onAdded();
|
||
onClose();
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
if (!open) return null;
|
||
|
||
const inputClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 placeholder-neutral-500";
|
||
const tabBtn = (key: Tab, label: string, disabled?: boolean) => (
|
||
<button
|
||
key={key}
|
||
onClick={() => !disabled && setTab(key)}
|
||
disabled={disabled}
|
||
className={`flex-1 rounded-lg py-2 text-xs font-medium transition-all ${
|
||
tab === key ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
|
||
const canSubmit = name.trim() && phone.trim() && !saving
|
||
&& (tab === "classes" || (tab === "events" && eventType === "master-class" && hasUpcomingMc)
|
||
|| (tab === "events" && eventType === "open-day" && odClassId));
|
||
|
||
return createPortal(
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||
<button onClick={onClose} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
|
||
<X size={16} />
|
||
</button>
|
||
|
||
<h3 className="text-base font-bold text-white">Добавить запись</h3>
|
||
<p className="mt-1 text-xs text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
|
||
|
||
<div className="mt-4 space-y-3">
|
||
{/* Tab: Classes vs Events */}
|
||
<div className="flex gap-2">
|
||
{tabBtn("classes", "Занятие")}
|
||
{tabBtn("events", "Мероприятие", !hasEvents)}
|
||
</div>
|
||
|
||
{/* Events sub-selector */}
|
||
{tab === "events" && (
|
||
<div className="flex gap-2">
|
||
{hasUpcomingMc && (
|
||
<button
|
||
onClick={() => setEventType("master-class")}
|
||
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${
|
||
eventType === "master-class" ? "bg-purple-500/15 text-purple-400 border border-purple-500/30" : "bg-neutral-800/50 text-neutral-500 border border-white/5 hover:text-neutral-300"
|
||
}`}
|
||
>
|
||
Мастер-класс
|
||
</button>
|
||
)}
|
||
{hasOpenDay && (
|
||
<button
|
||
onClick={() => setEventType("open-day")}
|
||
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${
|
||
eventType === "open-day" ? "bg-blue-500/15 text-blue-400 border border-blue-500/30" : "bg-neutral-800/50 text-neutral-500 border border-white/5 hover:text-neutral-300"
|
||
}`}
|
||
>
|
||
Open Day
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* MC selector */}
|
||
{tab === "events" && eventType === "master-class" && mcOptions.length > 0 && (
|
||
<select value={mcTitle} onChange={(e) => setMcTitle(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
|
||
<option value="" className="bg-neutral-900">Выберите мастер-класс</option>
|
||
{mcOptions.map((mc) => (
|
||
<option key={mc.title} value={mc.title} className="bg-neutral-900">
|
||
{mc.title}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
|
||
{/* Open Day class selector */}
|
||
{tab === "events" && eventType === "open-day" && odClasses.length > 0 && (
|
||
<select value={odClassId} onChange={(e) => setOdClassId(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
|
||
<option value="" className="bg-neutral-900">Выберите занятие</option>
|
||
{odClasses.map((c) => (
|
||
<option key={c.id} value={c.id} className="bg-neutral-900">
|
||
{c.time} · {c.style} · {c.hall}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
|
||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Имя" className={inputClass} />
|
||
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="Телефон" className={inputClass} />
|
||
<div className="flex gap-2">
|
||
<input type="text" value={instagram} onChange={(e) => setInstagram(e.target.value)} placeholder="Instagram" className={inputClass} />
|
||
<input type="text" value={telegram} onChange={(e) => setTelegram(e.target.value)} placeholder="Telegram" className={inputClass} />
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleSubmit}
|
||
disabled={!canSubmit}
|
||
className="mt-5 w-full rounded-lg bg-gold py-2.5 text-sm font-semibold text-black transition-all hover:bg-gold-light disabled:opacity-30 disabled:cursor-not-allowed"
|
||
>
|
||
{saving ? "Сохраняю..." : "Добавить"}
|
||
</button>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
);
|
||
}
|