feat: mobile UX, admin polish, rate limiting, and media assets
- Mobile responsiveness improvements across admin and public sections - Admin: bookings modal, open-day page, team page, layout polish - Added rate limiting, CSRF hardening, auth-edge improvements - Scroll reveal, floating contact, back-to-top, Yandex map fixes - Schedule filters refactor, team profile/info component updates - New useTrainerPhotos hook - Added class, team, master-class, and news images
This commit is contained in:
@@ -5,6 +5,8 @@ import { createPortal } from "react-dom";
|
||||
import { Plus, Trash2, GripVertical, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
let nextItemId = 1;
|
||||
|
||||
interface ArrayEditorProps<T> {
|
||||
items: T[];
|
||||
onChange: (items: T[]) => void;
|
||||
@@ -50,6 +52,19 @@ export function ArrayEditor<T>({
|
||||
const [droppedIndex, setDroppedIndex] = useState<number | null>(null);
|
||||
const [collapsed, setCollapsed] = useState<Set<number>>(() => collapsible ? new Set(items.map((_, i) => i)) : new Set());
|
||||
|
||||
// Stable keys for items — avoids index-as-key issues during reorder
|
||||
const stableKeysRef = useRef<number[]>([]);
|
||||
if (stableKeysRef.current.length < items.length) {
|
||||
while (stableKeysRef.current.length < items.length) {
|
||||
stableKeysRef.current.push(nextItemId++);
|
||||
}
|
||||
} else if (stableKeysRef.current.length > items.length) {
|
||||
stableKeysRef.current = stableKeysRef.current.slice(0, items.length);
|
||||
}
|
||||
function getStableKey(index: number): number {
|
||||
return stableKeysRef.current[index];
|
||||
}
|
||||
|
||||
function toggleCollapse(index: number) {
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -76,6 +91,7 @@ export function ArrayEditor<T>({
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
stableKeysRef.current.splice(index, 1);
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
@@ -142,6 +158,11 @@ export function ArrayEditor<T>({
|
||||
const updated = [...items];
|
||||
const [moved] = updated.splice(capturedDrag, 1);
|
||||
updated.splice(targetIndex, 0, moved);
|
||||
// Sync stable keys
|
||||
const keys = [...stableKeysRef.current];
|
||||
const [movedKey] = keys.splice(capturedDrag, 1);
|
||||
keys.splice(targetIndex, 0, movedKey);
|
||||
stableKeysRef.current = keys;
|
||||
onChange(updated);
|
||||
setDroppedIndex(targetIndex);
|
||||
setTimeout(() => setDroppedIndex(null), 1500);
|
||||
@@ -167,7 +188,7 @@ export function ArrayEditor<T>({
|
||||
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
key={getStableKey(i)}
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
className={`rounded-lg border bg-neutral-900/50 mb-3 hover:border-white/25 hover:bg-neutral-800/50 focus-within:border-gold/50 focus-within:bg-neutral-800 transition-all ${
|
||||
newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
|
||||
@@ -281,7 +302,7 @@ export function ArrayEditor<T>({
|
||||
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||
elements.push(
|
||||
<div
|
||||
key={i}
|
||||
key={getStableKey(i)}
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
className={`rounded-lg border bg-neutral-900/50 mb-3 transition-colors ${
|
||||
"border-white/10"
|
||||
@@ -385,6 +406,7 @@ export function ArrayEditor<T>({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
stableKeysRef.current = [nextItemId++, ...stableKeysRef.current];
|
||||
onChange([createItem(), ...items]);
|
||||
setNewItemIndex(0);
|
||||
// Shift collapsed indices and ensure new item is expanded
|
||||
@@ -409,6 +431,7 @@ export function ArrayEditor<T>({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
stableKeysRef.current.push(nextItemId++);
|
||||
onChange([...items, createItem()]);
|
||||
setNewItemIndex(items.length);
|
||||
setCollapsed(prev => { const next = new Set(prev); next.delete(items.length); return next; });
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
@@ -26,6 +27,7 @@ export function ConfirmDialog({
|
||||
destructive = true,
|
||||
}: ConfirmDialogProps) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -41,6 +43,7 @@ export function ConfirmDialog({
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={focusTrapRef}
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -724,6 +724,7 @@ interface VictoryListFieldProps {
|
||||
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate, onUploadComplete }: VictoryListFieldProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
|
||||
function add() {
|
||||
const val = draft.trim();
|
||||
@@ -752,6 +753,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploadingIndex(index);
|
||||
setUploadError("");
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("folder", "team");
|
||||
@@ -761,8 +763,12 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
if (result.path) {
|
||||
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
|
||||
onUploadComplete?.();
|
||||
} else {
|
||||
setUploadError(result.error || "Ошибка загрузки");
|
||||
}
|
||||
} catch { /* upload failed */ } finally {
|
||||
} catch {
|
||||
setUploadError("Не удалось загрузить файл");
|
||||
} finally {
|
||||
setUploadingIndex(null);
|
||||
}
|
||||
}
|
||||
@@ -833,6 +839,9 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{uploadError && (
|
||||
<p role="alert" className="mt-1.5 text-xs text-red-400">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export function ImageCropField({
|
||||
label = "Фото",
|
||||
}: ImageCropFieldProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragStartRef = useRef({ x: 0, y: 0, startFocalX: 0, startFocalY: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -42,6 +43,7 @@ export function ImageCropField({
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setUploadError("");
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("folder", folder);
|
||||
@@ -53,8 +55,12 @@ export function ImageCropField({
|
||||
const result = await res.json();
|
||||
if (result.path) {
|
||||
onChange({ image: result.path, focalX: 50, focalY: 50, zoom: 1 });
|
||||
} else {
|
||||
setUploadError(result.error || "Ошибка загрузки");
|
||||
}
|
||||
} catch { /* upload failed */ } finally {
|
||||
} catch {
|
||||
setUploadError("Не удалось загрузить файл");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
@@ -170,6 +176,9 @@ export function ImageCropField({
|
||||
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
</label>
|
||||
)}
|
||||
{uploadError && (
|
||||
<p role="alert" className="mt-1.5 text-xs text-red-400">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,9 +16,10 @@ export function PriceField({ label, value, onChange, placeholder = "0" }: PriceF
|
||||
<div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={raw}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
const v = e.target.value.replace(/[^\d.,\s]/g, "");
|
||||
onChange(v ? `${v} BYN` : "");
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -24,11 +24,13 @@ export function SectionEditor<T>({
|
||||
}: SectionEditorProps<T>) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error" | "invalid">("idle");
|
||||
const [error, setError] = useState("");
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const initialLoadRef = useRef(true);
|
||||
const pendingSaveRef = useRef(false);
|
||||
const defaultDataRef = useRef(defaultData);
|
||||
defaultDataRef.current = defaultData;
|
||||
|
||||
useEffect(() => {
|
||||
adminFetch(`/api/admin/sections/${sectionKey}`)
|
||||
@@ -36,7 +38,7 @@ export function SectionEditor<T>({
|
||||
if (!r.ok) throw new Error("Failed to load");
|
||||
return r.json();
|
||||
})
|
||||
.then((loaded) => setData(defaultData ? { ...defaultData, ...loaded } as T : loaded))
|
||||
.then((loaded) => setData(defaultDataRef.current ? { ...defaultDataRef.current, ...loaded } as T : loaded))
|
||||
.catch(() => setError("Не удалось загрузить данные"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [sectionKey]);
|
||||
@@ -72,7 +74,10 @@ export function SectionEditor<T>({
|
||||
pendingSaveRef.current = true;
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (validate && !validate(data)) return;
|
||||
if (validate && !validate(data)) {
|
||||
setStatus("invalid");
|
||||
return;
|
||||
}
|
||||
save(data);
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
@@ -134,7 +139,7 @@ export function SectionEditor<T>({
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
|
||||
{/* Fixed toast popup */}
|
||||
{(status === "saved" || status === "error") && (
|
||||
{(status === "saved" || status === "error" || status === "invalid") && (
|
||||
<div role="status" aria-live="polite" className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
||||
status === "saved"
|
||||
? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
|
||||
@@ -142,6 +147,7 @@ export function SectionEditor<T>({
|
||||
}`}>
|
||||
{status === "saved" && <><Check size={14} /> Сохранено</>}
|
||||
{status === "error" && <><AlertCircle size={14} /> {error}</>}
|
||||
{status === "invalid" && <><AlertCircle size={14} /> Не сохранено — исправьте ошибки</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, ChevronDown } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { formatBelarusPhone, SHORT_DAYS } from "@/lib/formatting";
|
||||
|
||||
type Tab = "classes" | "events";
|
||||
type EventType = "master-class" | "open-day";
|
||||
@@ -11,7 +12,7 @@ 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 }
|
||||
interface ScheduleClass { type: string; trainer: string; time: string; day: string; hall: string; groupId?: string }
|
||||
|
||||
function shortName(fullName: string) {
|
||||
const parts = fullName.trim().split(/\s+/);
|
||||
@@ -19,10 +20,6 @@ function shortName(fullName: string) {
|
||||
return parts.length > 1 ? `${parts[1]} ${parts[0][0]}.` : parts[0];
|
||||
}
|
||||
|
||||
const SHORT_DAYS: Record<string, string> = {
|
||||
"Понедельник": "Пн", "Вторник": "Вт", "Среда": "Ср",
|
||||
"Четверг": "Чт", "Пятница": "Пт", "Суббота": "Сб", "Воскресенье": "Вс",
|
||||
};
|
||||
|
||||
// --- Searchable dropdown ---
|
||||
|
||||
@@ -42,7 +39,13 @@ function SearchSelect({ options, value, onChange, placeholder }: {
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
const filtered = search
|
||||
? options.filter((o) => o.label.toLowerCase().includes(search.toLowerCase()))
|
||||
? (() => {
|
||||
const tokens = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return options.filter((o) => {
|
||||
const label = o.label.toLowerCase();
|
||||
return tokens.every((t) => label.includes(t));
|
||||
});
|
||||
})()
|
||||
: options;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,7 +101,7 @@ function SearchSelect({ options, value, onChange, placeholder }: {
|
||||
|
||||
{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">
|
||||
<div className="max-h-48 overflow-y-scroll admin-scrollbar">
|
||||
{filtered.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-neutral-500">Ничего не найдено</p>
|
||||
)}
|
||||
@@ -145,32 +148,24 @@ export function AddBookingModal({
|
||||
const [odEventId, setOdEventId] = useState<number | null>(null);
|
||||
const [odClassId, setOdClassId] = useState("");
|
||||
const [scheduleClasses, setScheduleClasses] = useState<ScheduleClass[]>([]);
|
||||
const [classInfo, setClassInfo] = useState("");
|
||||
const [classGroup, setClassGroup] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassInfo("");
|
||||
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassGroup("");
|
||||
|
||||
// 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 }[] }[] }[] }) => {
|
||||
adminFetch("/api/admin/sections/schedule").then((r) => r.json()).then((data: { locations?: { name: string; days: { day: string; classes: { type: string; trainer: string; time: string; groupId?: 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 });
|
||||
classes.push({ type: cls.type, trainer: cls.trainer, time: cls.time, day: day.day, hall: loc.name, groupId: cls.groupId });
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
setScheduleClasses(classes);
|
||||
}).catch(() => {});
|
||||
|
||||
// Fetch upcoming MCs
|
||||
@@ -209,27 +204,35 @@ export function AddBookingModal({
|
||||
}, [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);
|
||||
setPhone(formatBelarusPhone(raw));
|
||||
}
|
||||
|
||||
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}`,
|
||||
}));
|
||||
// Flat group options: one searchable dropdown
|
||||
const classGroupOptions = useMemo((): SearchSelectOption[] => {
|
||||
const byKey = new Map<string, { type: string; trainer: string; hall: string; slots: { day: string; time: string }[]; id: string }>();
|
||||
for (const c of scheduleClasses) {
|
||||
const id = c.groupId || `${c.type}|${c.trainer}|${c.time}|${c.hall}`;
|
||||
const existing = byKey.get(id);
|
||||
if (existing) {
|
||||
if (!existing.slots.some((s) => s.day === c.day)) existing.slots.push({ day: c.day, time: c.time });
|
||||
} else {
|
||||
byKey.set(id, { type: c.type, trainer: c.trainer, hall: c.hall, slots: [{ day: c.day, time: c.time }], id });
|
||||
}
|
||||
}
|
||||
return [...byKey.values()].map((g) => {
|
||||
const sameTime = g.slots.every((s) => s.time === g.slots[0].time);
|
||||
const days = sameTime
|
||||
? `${g.slots.map((s) => SHORT_DAYS[s.day] || s.day.slice(0, 2)).join("/")} ${g.slots[0].time}`
|
||||
: g.slots.map((s) => `${SHORT_DAYS[s.day] || s.day.slice(0, 2)} ${s.time}`).join(", ");
|
||||
return {
|
||||
value: g.id,
|
||||
label: `${shortName(g.trainer)} · ${g.type} · ${days} · ${g.hall}`,
|
||||
};
|
||||
}).sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [scheduleClasses]);
|
||||
|
||||
const mcSelectOptions: SearchSelectOption[] = mcOptions.map((mc) => ({
|
||||
value: mc.title,
|
||||
@@ -246,9 +249,8 @@ export function AddBookingModal({
|
||||
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}`
|
||||
const groupInfo = classGroup
|
||||
? classGroupOptions.find((o) => o.value === classGroup)?.label
|
||||
: undefined;
|
||||
await adminFetch("/api/admin/group-bookings", {
|
||||
method: "POST",
|
||||
@@ -277,6 +279,8 @@ export function AddBookingModal({
|
||||
}
|
||||
onAdded();
|
||||
onClose();
|
||||
} catch {
|
||||
alert("Не удалось создать запись. Попробуйте ещё раз.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -335,12 +339,12 @@ export function AddBookingModal({
|
||||
</div>
|
||||
|
||||
{/* Class selector (optional for Занятие) */}
|
||||
{tab === "classes" && classOptions.length > 0 && (
|
||||
{tab === "classes" && classGroupOptions.length > 0 && (
|
||||
<SearchSelect
|
||||
options={classOptions}
|
||||
value={classInfo}
|
||||
onChange={setClassInfo}
|
||||
placeholder="Выберите занятие (необязательно)"
|
||||
options={classGroupOptions}
|
||||
value={classGroup}
|
||||
onChange={setClassGroup}
|
||||
placeholder="Группа (необязательно)"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -283,18 +283,23 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
|
||||
...b, status: "confirmed" as BookingStatus,
|
||||
confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes,
|
||||
} : b));
|
||||
await Promise.all([
|
||||
adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }),
|
||||
}),
|
||||
data.comment ? adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
|
||||
}) : Promise.resolve(),
|
||||
]);
|
||||
try {
|
||||
await Promise.all([
|
||||
adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }),
|
||||
}),
|
||||
data.comment ? adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
|
||||
}) : Promise.resolve(),
|
||||
]);
|
||||
} catch {
|
||||
// Revert optimistic update on failure
|
||||
setBookings((prev) => prev.map((b) => b.id === confirmingId ? { ...b, ...existing } : b));
|
||||
}
|
||||
setConfirmingId(null);
|
||||
onDataChange?.();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SHORT_DAYS } from "@/lib/formatting";
|
||||
|
||||
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
|
||||
export type BookingFilter = "all" | BookingStatus;
|
||||
|
||||
@@ -12,10 +14,7 @@ export interface BaseBooking {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const SHORT_DAYS: Record<string, string> = {
|
||||
"Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ",
|
||||
"Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС",
|
||||
};
|
||||
export { SHORT_DAYS };
|
||||
|
||||
export const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [
|
||||
{ key: "new", label: "Новая", color: "text-gold", bg: "bg-gold/10", border: "border-gold/30" },
|
||||
@@ -25,7 +24,16 @@ export const BOOKING_STATUSES: { key: BookingStatus; label: string; color: strin
|
||||
];
|
||||
|
||||
export function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("ru-RU");
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const sameYear = d.getFullYear() === now.getFullYear();
|
||||
const date = d.toLocaleDateString("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
...(sameYear ? {} : { year: "numeric" }),
|
||||
});
|
||||
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||
return `${date}, ${time}`;
|
||||
}
|
||||
|
||||
export function countStatuses(items: { status: string }[]): Record<string, number> {
|
||||
|
||||
@@ -56,17 +56,19 @@ export default function AdminLayout({
|
||||
const [unreadTotal, setUnreadTotal] = useState(0);
|
||||
const isLoginPage = pathname === "/admin/login";
|
||||
|
||||
// Fetch unread counts — poll every 10s
|
||||
// Fetch unread counts — poll every 10s, stop after 3 consecutive failures
|
||||
useEffect(() => {
|
||||
if (isLoginPage) return;
|
||||
let failures = 0;
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
function fetchCounts() {
|
||||
adminFetch("/api/admin/unread-counts")
|
||||
.then((r) => r.json())
|
||||
.then((data: { total: number }) => setUnreadTotal(data.total))
|
||||
.catch(() => {});
|
||||
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
||||
.then((data: { total: number }) => { setUnreadTotal(data.total); failures = 0; })
|
||||
.catch(() => { failures++; if (failures >= 3 && interval) clearInterval(interval); });
|
||||
}
|
||||
fetchCounts();
|
||||
const interval = setInterval(fetchCounts, 10000);
|
||||
interval = setInterval(fetchCounts, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isLoginPage]);
|
||||
|
||||
|
||||
@@ -411,30 +411,45 @@ function ScheduleGrid({
|
||||
const [creatingTime, setCreatingTime] = useState<string | null>(null);
|
||||
|
||||
async function confirmCreate(startTime: string, data: { trainer: string; style: string; endTime: string }) {
|
||||
await adminFetch("/api/admin/open-day/classes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }),
|
||||
});
|
||||
setCreatingTime(null);
|
||||
onClassesChange();
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/open-day/classes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
setCreatingTime(null);
|
||||
onClassesChange();
|
||||
} catch {
|
||||
alert("Не удалось создать занятие");
|
||||
}
|
||||
}
|
||||
|
||||
async function updateClass(id: number, data: Partial<OpenDayClass>) {
|
||||
await adminFetch("/api/admin/open-day/classes", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ...data }),
|
||||
});
|
||||
onClassesChange();
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/open-day/classes", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ...data }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
onClassesChange();
|
||||
} catch {
|
||||
alert("Не удалось обновить занятие");
|
||||
}
|
||||
}
|
||||
|
||||
function deleteClass(id: number) {
|
||||
setConfirmAction({
|
||||
message: "Удалить занятие? Это действие нельзя отменить.",
|
||||
onConfirm: async () => {
|
||||
await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
|
||||
onClassesChange();
|
||||
try {
|
||||
const res = await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error();
|
||||
onClassesChange();
|
||||
} catch {
|
||||
alert("Не удалось удалить занятие");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,9 +91,9 @@ export default function AdminDashboard() {
|
||||
|
||||
useEffect(() => {
|
||||
adminFetch("/api/admin/unread-counts")
|
||||
.then((r) => r.json())
|
||||
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
||||
.then((data: UnreadCounts) => setCounts(data))
|
||||
.catch(() => {});
|
||||
.catch(() => { /* initial load — non-critical */ });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -81,19 +81,32 @@ export default function TeamEditorPage() {
|
||||
}, [sectionTitle]);
|
||||
|
||||
const saveOrder = useCallback(async (updated: Member[]) => {
|
||||
const previous = members;
|
||||
setMembers(updated);
|
||||
const res = await adminFetch("/api/admin/team/reorder", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
||||
});
|
||||
setSaveStatus(res.ok ? "saved" : "error");
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/team/reorder", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
||||
});
|
||||
setSaveStatus(res.ok ? "saved" : "error");
|
||||
if (!res.ok) setMembers(previous);
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
setMembers(previous);
|
||||
}
|
||||
setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
}, []);
|
||||
}, [members]);
|
||||
|
||||
async function deleteMember(id: number) {
|
||||
await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
|
||||
setMembers((prev) => prev.filter((m) => m.id !== id));
|
||||
try {
|
||||
const res = await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error();
|
||||
setMembers((prev) => prev.filter((m) => m.id !== id));
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getGroupBookings, addGroupBooking, toggleGroupBookingNotification, deleteGroupBooking, setGroupBookingStatus, updateBookingNotes } from "@/lib/db";
|
||||
import type { BookingStatus } from "@/lib/db";
|
||||
import { sanitizeText } from "@/lib/validation";
|
||||
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||
|
||||
export async function GET() {
|
||||
const bookings = getGroupBookings();
|
||||
@@ -50,10 +50,12 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, phone, groupInfo, instagram, telegram } = body;
|
||||
if (!name?.trim() || !phone?.trim()) {
|
||||
const cleanName = sanitizeName(name);
|
||||
const cleanPhone = sanitizePhone(phone);
|
||||
if (!cleanName || !cleanPhone) {
|
||||
return NextResponse.json({ error: "name and phone are required" }, { status: 400 });
|
||||
}
|
||||
const id = addGroupBooking(name.trim(), phone.trim(), groupInfo?.trim() || undefined, instagram?.trim() || undefined, telegram?.trim() || undefined);
|
||||
const id = addGroupBooking(cleanName, cleanPhone, sanitizeText(groupInfo, 500), sanitizeHandle(instagram), sanitizeHandle(telegram));
|
||||
return NextResponse.json({ ok: true, id });
|
||||
} catch (err) {
|
||||
console.error("[admin/group-bookings] POST error:", err);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration, setMcRegistrationStatus, updateBookingNotes } from "@/lib/db";
|
||||
import { sanitizeText } from "@/lib/validation";
|
||||
import { sanitizeName, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const title = request.nextUrl.searchParams.get("title");
|
||||
@@ -15,10 +15,13 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { masterClassTitle, name, instagram, telegram } = body;
|
||||
if (!masterClassTitle || !name || !instagram) {
|
||||
const cleanTitle = sanitizeText(masterClassTitle, 200);
|
||||
const cleanName = sanitizeName(name);
|
||||
const cleanInstagram = sanitizeHandle(instagram);
|
||||
if (!cleanTitle || !cleanName || !cleanInstagram) {
|
||||
return NextResponse.json({ error: "masterClassTitle, name, instagram are required" }, { status: 400 });
|
||||
}
|
||||
const id = addMcRegistration(masterClassTitle.trim(), name.trim(), instagram.trim(), telegram?.trim() || undefined);
|
||||
const id = addMcRegistration(cleanTitle, cleanName, cleanInstagram, sanitizeHandle(telegram));
|
||||
return NextResponse.json({ ok: true, id });
|
||||
} catch (err) {
|
||||
console.error("[admin/mc-registrations] error:", err);
|
||||
@@ -64,10 +67,12 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
// Regular update
|
||||
const { id, name, instagram, telegram } = body;
|
||||
if (!id || !name || !instagram) {
|
||||
const cleanName = sanitizeName(name);
|
||||
const cleanInstagram = sanitizeHandle(instagram);
|
||||
if (!id || !cleanName || !cleanInstagram) {
|
||||
return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 });
|
||||
}
|
||||
updateMcRegistration(id, name.trim(), instagram.trim(), telegram?.trim() || undefined);
|
||||
updateMcRegistration(id, cleanName, cleanInstagram, sanitizeHandle(telegram));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("[admin/mc-registrations] error:", err);
|
||||
|
||||
@@ -22,13 +22,33 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Recursively sanitize string values: strip <script> and javascript: URIs */
|
||||
function sanitizeValue(val: unknown): unknown {
|
||||
if (typeof val === "string") {
|
||||
let s = val.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
|
||||
// Strip javascript: URIs in href/src-like values
|
||||
s = s.replace(/javascript\s*:/gi, "");
|
||||
return s;
|
||||
}
|
||||
if (Array.isArray(val)) return val.map(sanitizeValue);
|
||||
if (val && typeof val === "object") {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
|
||||
out[k] = sanitizeValue(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: Params) {
|
||||
const { key } = await params;
|
||||
if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) {
|
||||
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const raw = await request.json();
|
||||
const data = sanitizeValue(raw);
|
||||
setSection(key, data);
|
||||
invalidateContentCache();
|
||||
revalidatePath("/");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getTeamMember, updateTeamMember, deleteTeamMember } from "@/lib/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { sanitizeName, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -28,7 +29,14 @@ export async function PUT(request: NextRequest, { params }: Params) {
|
||||
if (!numId) {
|
||||
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||
}
|
||||
const data = await request.json();
|
||||
const raw = await request.json();
|
||||
// Sanitize string fields before storing
|
||||
const data = {
|
||||
...raw,
|
||||
name: typeof raw.name === "string" ? sanitizeName(raw.name) ?? raw.name : raw.name,
|
||||
instagram: typeof raw.instagram === "string" ? sanitizeHandle(raw.instagram) : raw.instagram,
|
||||
bio: typeof raw.bio === "string" ? sanitizeText(raw.bio, 2000) : raw.bio,
|
||||
};
|
||||
updateTeamMember(numId, data);
|
||||
revalidatePath("/");
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
@@ -6,6 +6,28 @@ const IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
|
||||
const VIDEO_TYPES = ["video/mp4", "video/webm"];
|
||||
const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"];
|
||||
const VIDEO_EXTENSIONS = [".mp4", ".webm"];
|
||||
|
||||
/** Magic byte signatures for allowed file types */
|
||||
const MAGIC_BYTES: Record<string, number[][]> = {
|
||||
"image/jpeg": [[0xFF, 0xD8, 0xFF]],
|
||||
"image/png": [[0x89, 0x50, 0x4E, 0x47]],
|
||||
"image/webp": [[0x52, 0x49, 0x46, 0x46]], // RIFF
|
||||
"image/avif": [], // AVIF uses ISOBMFF (ftyp), checked separately
|
||||
"video/mp4": [], // MP4 uses ISOBMFF (ftyp), checked separately
|
||||
"video/webm": [[0x1A, 0x45, 0xDF, 0xA3]], // EBML
|
||||
};
|
||||
|
||||
function validateMagicBytes(buffer: Buffer, mimeType: string): boolean {
|
||||
// ISOBMFF container (AVIF, MP4): check for 'ftyp' at offset 4
|
||||
if (mimeType === "image/avif" || mimeType === "video/mp4") {
|
||||
return buffer.length >= 8 && buffer.toString("ascii", 4, 8) === "ftyp";
|
||||
}
|
||||
const signatures = MAGIC_BYTES[mimeType];
|
||||
if (!signatures || signatures.length === 0) return true;
|
||||
return signatures.some((sig) =>
|
||||
sig.every((byte, i) => buffer.length > i && buffer[i] === byte)
|
||||
);
|
||||
}
|
||||
const IMAGE_FOLDERS = ["team", "master-classes", "news", "classes"];
|
||||
const VIDEO_FOLDERS = ["hero"];
|
||||
const ALL_FOLDERS = [...IMAGE_FOLDERS, ...VIDEO_FOLDERS];
|
||||
@@ -71,6 +93,15 @@ export async function POST(request: NextRequest) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// Validate file content matches claimed MIME type
|
||||
if (!validateMagicBytes(buffer, file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Содержимое файла не соответствует его типу" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const filePath = path.join(dir, fileName);
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const INSTAGRAM_USERNAME_RE = /^[a-zA-Z0-9_.]{1,30}$/;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = request.nextUrl.searchParams.get("username")?.trim();
|
||||
if (!username) {
|
||||
return NextResponse.json({ valid: false, error: "No username" });
|
||||
}
|
||||
|
||||
if (!INSTAGRAM_USERNAME_RE.test(username)) {
|
||||
return NextResponse.json({ valid: false, error: "Invalid username format" });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://www.instagram.com/${username}/`, {
|
||||
method: "HEAD",
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { verifyPassword, signToken, generateCsrfToken, COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = getClientIp(request);
|
||||
if (!checkRateLimit(ip, 5, 5 * 60_000)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Слишком много попыток. Попробуйте через 5 минут." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json() as { password?: string };
|
||||
|
||||
if (!body.password || !verifyPassword(body.password)) {
|
||||
@@ -23,7 +32,7 @@ export async function POST(request: NextRequest) {
|
||||
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
|
||||
httpOnly: false, // JS must read this to send as header
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
sameSite: "lax", // Match auth cookie; strict breaks admin access from external links
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
||||
|
||||
export async function POST() {
|
||||
export async function POST(request: NextRequest) {
|
||||
// Verify auth cookie exists (basic protection against cross-site logout)
|
||||
const token = request.cookies.get(COOKIE_NAME)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const response = NextResponse.json({ ok: true });
|
||||
response.cookies.set(COOKIE_NAME, "", {
|
||||
httpOnly: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { addMcRegistration, getMcRegistrations, getSection } from "@/lib/db";
|
||||
import { addMcRegistrationAtomic, getSection } from "@/lib/db";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||
import type { MasterClassItem } from "@/types/content";
|
||||
@@ -32,23 +32,20 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if MC is full — if so, booking goes to waiting list
|
||||
// Determine max participants from section config
|
||||
const mcSection = getSection("masterClasses") as { items?: MasterClassItem[] } | null;
|
||||
const mcItem = mcSection?.items?.find((mc) => mc.title === cleanTitle);
|
||||
let isWaiting = false;
|
||||
if (mcItem?.maxParticipants && mcItem.maxParticipants > 0) {
|
||||
const currentRegs = getMcRegistrations(cleanTitle);
|
||||
const confirmedCount = currentRegs.filter((r) => r.status === "confirmed").length;
|
||||
isWaiting = confirmedCount >= mcItem.maxParticipants;
|
||||
}
|
||||
const maxParticipants = mcItem?.maxParticipants && mcItem.maxParticipants > 0
|
||||
? mcItem.maxParticipants : undefined;
|
||||
|
||||
const id = addMcRegistration(
|
||||
// Atomic check-and-insert inside a transaction to prevent race condition
|
||||
const { id, isWaiting } = addMcRegistrationAtomic(
|
||||
cleanTitle,
|
||||
cleanName,
|
||||
sanitizeHandle(instagram) ?? "",
|
||||
sanitizeHandle(telegram),
|
||||
cleanPhone,
|
||||
isWaiting ? "Лист ожидания" : undefined
|
||||
maxParticipants
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, id, isWaiting });
|
||||
|
||||
+2
-7
@@ -14,19 +14,14 @@ import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { ClientShell } from "@/components/layout/ClientShell";
|
||||
import { getContent } from "@/lib/content";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
import { OpenDay } from "@/components/sections/OpenDay";
|
||||
import { getActiveOpenDay } from "@/lib/openDay";
|
||||
import { getAllMcRegistrations } from "@/lib/db";
|
||||
import { getMcRegistrationCounts } from "@/lib/db";
|
||||
|
||||
export default function HomePage() {
|
||||
const content = getContent();
|
||||
const openDayData = getActiveOpenDay();
|
||||
// Count MC registrations per title for capacity check
|
||||
const allMcRegs = getAllMcRegistrations();
|
||||
const mcRegCounts: Record<string, number> = {};
|
||||
for (const reg of allMcRegs) mcRegCounts[reg.masterClassTitle] = (mcRegCounts[reg.masterClassTitle] || 0) + 1;
|
||||
const mcRegCounts = getMcRegistrationCounts();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -348,6 +348,14 @@
|
||||
background: linear-gradient(90deg, transparent, rgba(201, 169, 110, 0.15), transparent);
|
||||
}
|
||||
|
||||
/* ===== No-JS Fallback ===== */
|
||||
/* When JS is disabled, ensure Reveal content is visible */
|
||||
noscript ~ * [style*="opacity: 0"],
|
||||
.no-js [style*="opacity: 0"] {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* ===== Reduced Motion ===== */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
Reference in New Issue
Block a user