16ac56f62e
- Team carousel: simple swipe on mobile instead of drag - Schedule: filter button inline with hall tabs, larger on mobile - Schedule filters: fix nested button hydration error - Admin bookings: select dropdown on mobile, filter highlight on dashboard cards - Admin bookings: searchable dropdowns in add booking modal with class selector - Admin bookings: waiting list divider inside groups - Admin bookings: new bookings appear without page reload - Admin open-day: action buttons visible on mobile, confirm dialog, click-outside to close edit - API: pass groupInfo on group booking creation - SignupModal: Instagram link on success popup
387 lines
16 KiB
TypeScript
387 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { X, ChevronDown } 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; start_time: string; hall: string; trainer: string }
|
|
interface OdEvent { id: number; date: string; title?: string }
|
|
interface ScheduleClass { type: string; trainer: string; time: string; day: string; hall: string }
|
|
|
|
function shortName(fullName: string) {
|
|
const parts = fullName.trim().split(/\s+/);
|
|
// Names stored as "Имя Фамилия" → show "Фамилия И."
|
|
return parts.length > 1 ? `${parts[1]} ${parts[0][0]}.` : parts[0];
|
|
}
|
|
|
|
const SHORT_DAYS: Record<string, string> = {
|
|
"Понедельник": "Пн", "Вторник": "Вт", "Среда": "Ср",
|
|
"Четверг": "Чт", "Пятница": "Пт", "Суббота": "Сб", "Воскресенье": "Вс",
|
|
};
|
|
|
|
// --- Searchable dropdown ---
|
|
|
|
interface SearchSelectOption { value: string; label: string }
|
|
|
|
function SearchSelect({ options, value, onChange, placeholder }: {
|
|
options: SearchSelectOption[];
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
placeholder: string;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const selected = options.find((o) => o.value === value);
|
|
|
|
const filtered = search
|
|
? options.filter((o) => o.label.toLowerCase().includes(search.toLowerCase()))
|
|
: options;
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
function handle(e: MouseEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
setSearch("");
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handle);
|
|
return () => document.removeEventListener("mousedown", handle);
|
|
}, [open]);
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative">
|
|
<div
|
|
onClick={() => { setOpen(true); setTimeout(() => inputRef.current?.focus(), 0); }}
|
|
className={`flex items-center gap-2 w-full rounded-lg border px-3 py-2 text-sm cursor-text transition-colors ${
|
|
open ? "border-gold/40 bg-white/[0.06]" : "border-white/[0.08] bg-white/[0.04]"
|
|
}`}
|
|
>
|
|
{open ? (
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={selected ? selected.label : placeholder}
|
|
className="flex-1 bg-transparent text-white placeholder-neutral-500 outline-none text-sm"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") { setOpen(false); setSearch(""); }
|
|
if (e.key === "Backspace" && !search && value) { onChange(""); }
|
|
}}
|
|
/>
|
|
) : (
|
|
<span className={`flex-1 truncate ${selected ? "text-white" : "text-neutral-500"}`}>
|
|
{selected ? selected.label : placeholder}
|
|
</span>
|
|
)}
|
|
{value && !open ? (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); onChange(""); }}
|
|
className="text-neutral-500 hover:text-white transition-colors shrink-0"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
) : (
|
|
<ChevronDown size={14} className={`text-neutral-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} />
|
|
)}
|
|
</div>
|
|
|
|
{open && (
|
|
<div className="absolute z-20 mt-1 w-full rounded-lg border border-white/[0.08] shadow-xl overflow-hidden" style={{ backgroundColor: "#141414" }}>
|
|
<div className="max-h-48 overflow-y-auto styled-scrollbar">
|
|
{filtered.length === 0 && (
|
|
<p className="px-3 py-2 text-xs text-neutral-500">Ничего не найдено</p>
|
|
)}
|
|
{filtered.map((o) => (
|
|
<button
|
|
key={o.value}
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => { onChange(o.value); setOpen(false); setSearch(""); }}
|
|
className={`w-full px-3 py-2 text-left text-sm transition-colors ${
|
|
o.value === value ? "bg-gold/10 text-gold" : "text-white hover:bg-white/[0.05]"
|
|
}`}
|
|
>
|
|
{o.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Modal ---
|
|
|
|
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("+375 ");
|
|
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 [scheduleClasses, setScheduleClasses] = useState<ScheduleClass[]>([]);
|
|
const [classInfo, setClassInfo] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassInfo("");
|
|
|
|
// Fetch schedule classes
|
|
adminFetch("/api/admin/sections/schedule").then((r) => r.json()).then((data: { locations?: { name: string; days: { day: string; classes: { type: string; trainer: string; time: string }[] }[] }[] }) => {
|
|
const classes: ScheduleClass[] = [];
|
|
for (const loc of data.locations || []) {
|
|
for (const day of loc.days) {
|
|
for (const cls of day.classes) {
|
|
classes.push({ type: cls.type, trainer: cls.trainer, time: cls.time, day: day.day, hall: loc.name });
|
|
}
|
|
}
|
|
}
|
|
// Deduplicate by type+trainer+time+day+hall
|
|
const seen = new Set<string>();
|
|
const unique = classes.filter((c) => {
|
|
const key = `${c.type}|${c.trainer}|${c.time}|${c.day}|${c.hall}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
setScheduleClasses(unique);
|
|
}).catch(() => {});
|
|
|
|
// Fetch upcoming MCs
|
|
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]);
|
|
|
|
function handlePhoneChange(raw: string) {
|
|
let digits = raw.replace(/\D/g, "");
|
|
if (!digits.startsWith("375")) digits = "375" + digits.replace(/^375?/, "");
|
|
digits = digits.slice(0, 12);
|
|
let formatted = "+375";
|
|
const rest = digits.slice(3);
|
|
if (rest.length > 0) formatted += " (" + rest.slice(0, 2);
|
|
if (rest.length >= 2) formatted += ") ";
|
|
if (rest.length > 2) formatted += rest.slice(2, 5);
|
|
if (rest.length > 5) formatted += "-" + rest.slice(5, 7);
|
|
if (rest.length > 7) formatted += "-" + rest.slice(7, 9);
|
|
setPhone(formatted);
|
|
}
|
|
|
|
const hasUpcomingMc = mcOptions.length > 0;
|
|
const hasOpenDay = odEventId !== null && odClasses.length > 0;
|
|
|
|
// Build options for each dropdown
|
|
const classOptions: SearchSelectOption[] = scheduleClasses.map((c, i) => ({
|
|
value: String(i),
|
|
label: `${shortName(c.trainer)} — ${c.type} · ${SHORT_DAYS[c.day] || c.day} ${c.time} · ${c.hall}`,
|
|
}));
|
|
|
|
const mcSelectOptions: SearchSelectOption[] = mcOptions.map((mc) => ({
|
|
value: mc.title,
|
|
label: mc.title,
|
|
}));
|
|
|
|
const odSelectOptions: SearchSelectOption[] = odClasses.map((c) => ({
|
|
value: String(c.id),
|
|
label: `${shortName(c.trainer)} — ${c.start_time} · ${c.hall}`,
|
|
}));
|
|
|
|
async function handleSubmit() {
|
|
if (!name.trim() || !phone.trim()) return;
|
|
setSaving(true);
|
|
try {
|
|
if (tab === "classes") {
|
|
const selectedClass = classInfo ? scheduleClasses[Number(classInfo)] : null;
|
|
const groupInfo = selectedClass
|
|
? `${selectedClass.type}, ${shortName(selectedClass.trainer)}, ${SHORT_DAYS[selectedClass.day] || selectedClass.day} ${selectedClass.time}, ${selectedClass.hall}`
|
|
: undefined;
|
|
await adminFetch("/api/admin/group-bookings", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name: name.trim(),
|
|
phone: phone.trim(),
|
|
...(groupInfo && { groupInfo }),
|
|
...(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 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">
|
|
{/* Type selector — single row */}
|
|
<div className="flex rounded-lg border border-white/[0.08] bg-white/[0.03] p-0.5">
|
|
<button
|
|
onClick={() => setTab("classes")}
|
|
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
|
|
tab === "classes" ? "bg-gold/20 text-gold shadow-sm" : "text-neutral-400 hover:text-white"
|
|
}`}
|
|
>
|
|
Занятие
|
|
</button>
|
|
{hasUpcomingMc && (
|
|
<button
|
|
onClick={() => { setTab("events"); setEventType("master-class"); }}
|
|
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
|
|
tab === "events" && eventType === "master-class" ? "bg-purple-500/15 text-purple-400 shadow-sm" : "text-neutral-400 hover:text-white"
|
|
}`}
|
|
>
|
|
Мастер-класс
|
|
</button>
|
|
)}
|
|
{hasOpenDay && (
|
|
<button
|
|
onClick={() => { setTab("events"); setEventType("open-day"); }}
|
|
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
|
|
tab === "events" && eventType === "open-day" ? "bg-blue-500/15 text-blue-400 shadow-sm" : "text-neutral-400 hover:text-white"
|
|
}`}
|
|
>
|
|
Open Day
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Class selector (optional for Занятие) */}
|
|
{tab === "classes" && classOptions.length > 0 && (
|
|
<SearchSelect
|
|
options={classOptions}
|
|
value={classInfo}
|
|
onChange={setClassInfo}
|
|
placeholder="Выберите занятие (необязательно)"
|
|
/>
|
|
)}
|
|
|
|
{/* MC selector */}
|
|
{tab === "events" && eventType === "master-class" && mcSelectOptions.length > 0 && (
|
|
<SearchSelect
|
|
options={mcSelectOptions}
|
|
value={mcTitle}
|
|
onChange={setMcTitle}
|
|
placeholder="Выберите мастер-класс"
|
|
/>
|
|
)}
|
|
|
|
{/* Open Day class selector */}
|
|
{tab === "events" && eventType === "open-day" && odSelectOptions.length > 0 && (
|
|
<SearchSelect
|
|
options={odSelectOptions}
|
|
value={odClassId}
|
|
onChange={setOdClassId}
|
|
placeholder="Выберите занятие"
|
|
/>
|
|
)}
|
|
|
|
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Имя" className={inputClass} />
|
|
<input type="tel" value={phone} onChange={(e) => handlePhoneChange(e.target.value)} placeholder="+375 (__) ___-__-__" 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
|
|
);
|
|
}
|