diff --git a/CLAUDE.md b/CLAUDE.md
index a0ff24a..71fdfd1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -25,23 +25,25 @@ Content language: Russian
src/
├── app/
│ ├── layout.tsx # Root layout, fonts, metadata
-│ ├── page.tsx # Landing: Hero → About → Team → Classes → MasterClasses → Schedule → Pricing → News → FAQ → Contact
+│ ├── page.tsx # Landing: Hero → [OpenDay] → About → Team → Classes → MasterClasses → Schedule → Pricing → News → FAQ → Contact
│ ├── globals.css # Tailwind imports
│ ├── styles/
│ │ ├── theme.css # Theme variables, semantic classes
│ │ └── animations.css # Keyframes, scroll reveal, modal animations
│ ├── admin/
-│ │ ├── page.tsx # Dashboard with 11 section cards
+│ │ ├── page.tsx # Dashboard with 13 section cards
│ │ ├── login/ # Password auth
-│ │ ├── layout.tsx # Sidebar nav shell
-│ │ ├── _components/ # SectionEditor, FormField, ArrayEditor
+│ │ ├── layout.tsx # Sidebar nav shell (14 items)
+│ │ ├── _components/ # SectionEditor, FormField, ArrayEditor, NotifyToggle
│ │ ├── meta/ # SEO editor
│ │ ├── hero/ # Hero editor
│ │ ├── about/ # About editor
│ │ ├── team/ # Team list + [id] editor
│ │ ├── classes/ # Classes editor with icon picker
-│ │ ├── master-classes/ # MC editor with registrations
+│ │ ├── master-classes/ # MC editor with registrations + notification toggles
+│ │ ├── open-day/ # Open Day event editor (settings + grid + bookings)
│ │ ├── schedule/ # Schedule editor
+│ │ ├── bookings/ # Group booking management with notification toggles
│ │ ├── pricing/ # Pricing editor
│ │ ├── faq/ # FAQ editor
│ │ ├── news/ # News editor
@@ -55,9 +57,15 @@ src/
│ │ ├── team/[id]/ # GET/PUT/DELETE single member
│ │ ├── team/reorder/ # PUT reorder
│ │ ├── upload/ # POST file upload (whitelisted folders)
-│ │ ├── mc-registrations/ # CRUD registrations
+│ │ ├── mc-registrations/ # CRUD registrations + notification toggle
+│ │ ├── group-bookings/ # CRUD group bookings + notification toggle
+│ │ ├── open-day/ # CRUD events
+│ │ ├── open-day/classes/ # CRUD event classes
+│ │ ├── open-day/bookings/ # CRUD event bookings + notification toggle
│ │ └── validate-instagram/ # GET check username
-│ └── master-class-register/ # POST public signup
+│ ├── master-class-register/ # POST public MC signup
+│ ├── group-booking/ # POST public group booking
+│ └── open-day-register/ # POST public Open Day booking
├── components/
│ ├── layout/
│ │ ├── Header.tsx # Sticky nav, mobile menu, booking modal ("use client")
@@ -68,6 +76,7 @@ src/
│ │ ├── Team.tsx # Carousel + profile view
│ │ ├── Classes.tsx # Showcase layout with icon selector
│ │ ├── MasterClasses.tsx # Cards with signup modal
+│ │ ├── OpenDay.tsx # Open Day schedule grid + booking (conditional)
│ │ ├── Schedule.tsx # Day/group views with filters
│ │ ├── Pricing.tsx # Tabs: prices, rental, rules
│ │ ├── News.tsx # Featured + compact articles
@@ -76,8 +85,9 @@ src/
│ └── ui/
│ ├── Button.tsx
│ ├── SectionHeading.tsx
-│ ├── BookingModal.tsx # Booking form → Instagram DM
+│ ├── BookingModal.tsx # Booking form → Instagram DM + DB save
│ ├── MasterClassSignupModal.tsx # MC registration form → API
+│ ├── OpenDaySignupModal.tsx # Open Day class booking → API
│ ├── NewsModal.tsx # News detail popup
│ ├── Reveal.tsx # Intersection Observer scroll reveal
│ ├── BackToTop.tsx
@@ -87,10 +97,11 @@ src/
├── lib/
│ ├── constants.ts # BRAND constants, NAV_LINKS
│ ├── config.ts # UI_CONFIG (thresholds, counts)
-│ ├── db.ts # SQLite DB, migrations, CRUD
+│ ├── db.ts # SQLite DB, 6 migrations, CRUD for all tables
│ ├── auth.ts # Token signing (Node.js)
│ ├── auth-edge.ts # Token verification (Edge/Web Crypto)
-│ └── content.ts # getContent() — DB with fallback
+│ ├── content.ts # getContent() — DB with fallback
+│ └── openDay.ts # getActiveOpenDay() — server-side Open Day loader
├── proxy.ts # Middleware: auth guard for /admin/*
└── types/
├── index.ts
@@ -119,7 +130,10 @@ src/
- Cookie: `bh-admin-token` (httpOnly, secure in prod)
- Auto-save with 800ms debounce on all section editors
- Team members: drag-reorder, photo upload, rich bio (experience, victories, education)
-- Master classes: slots, registration viewer, trainer/style autocomplete from existing data
+- Master classes: slots, registration viewer with notification tracking (confirm + reminder), trainer/style autocomplete
+- Group bookings: saved to DB from BookingModal, admin page at `/admin/bookings` with notification toggles
+- Open Day: event settings (date, pricing, discount rules, min bookings), schedule grid (halls × time slots), per-class booking with auto-cancel threshold, public section after Hero
+- Shared `NotifyToggle` component (`src/app/admin/_components/NotifyToggle.tsx`) used across MC registrations, group bookings, and Open Day bookings
- File upload: whitelisted folders (`team`, `master-classes`, `news`, `classes`), max 5MB, image types only
## Security Notes
@@ -128,6 +142,10 @@ src/
- API routes validate: input types, string lengths, numeric IDs
- Public MC registration: length-limited but **no rate limiting yet** (add before production)
+## Upcoming Features
+- **Rate limiting** on public endpoints (`/api/master-class-register`, `/api/group-booking`, `/api/open-day-register`)
+- **DB backup mechanism** — automated/manual backup of `db/blackheart.db` with rotation
+
## AST Index
- **Always use the AST index** at `memory/ast-index.md` when searching for components, props, hooks, types, or styles
- Contains: component tree, all exports, props, hooks, client/server status, CSS classes, keyframes
diff --git a/src/app/admin/_components/NotifyToggle.tsx b/src/app/admin/_components/NotifyToggle.tsx
new file mode 100644
index 0000000..79abd19
--- /dev/null
+++ b/src/app/admin/_components/NotifyToggle.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { Bell, CheckCircle2 } from "lucide-react";
+import type { LucideIcon } from "lucide-react";
+
+function Toggle({
+ done,
+ urgent,
+ icon: Icon,
+ label,
+ onToggle,
+}: {
+ done: boolean;
+ urgent: boolean;
+ icon: LucideIcon;
+ label: string;
+ onToggle: () => void;
+}) {
+ return (
+
+ );
+}
+
+export function NotifyToggle({
+ confirmed,
+ reminded,
+ confirmUrgent,
+ reminderUrgent,
+ onToggleConfirm,
+ onToggleReminder,
+}: {
+ confirmed: boolean;
+ reminded: boolean;
+ confirmUrgent?: boolean;
+ reminderUrgent?: boolean;
+ onToggleConfirm: () => void;
+ onToggleReminder: () => void;
+}) {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx
new file mode 100644
index 0000000..46d5504
--- /dev/null
+++ b/src/app/admin/bookings/page.tsx
@@ -0,0 +1,597 @@
+"use client";
+
+import { useState, useEffect, useMemo } from "react";
+import { Loader2, Trash2, Phone, Instagram, Send, ChevronDown, ChevronRight } from "lucide-react";
+import { adminFetch } from "@/lib/csrf";
+import { NotifyToggle } from "../_components/NotifyToggle";
+
+// --- Types ---
+
+interface GroupBooking {
+ id: number;
+ name: string;
+ phone: string;
+ groupInfo?: string;
+ notifiedConfirm: boolean;
+ notifiedReminder: boolean;
+ createdAt: string;
+}
+
+interface McRegistration {
+ id: number;
+ masterClassTitle: string;
+ name: string;
+ instagram: string;
+ telegram?: string;
+ notifiedConfirm: boolean;
+ notifiedReminder: boolean;
+ createdAt: string;
+}
+
+interface OpenDayBooking {
+ id: number;
+ classId: number;
+ eventId: number;
+ name: string;
+ phone: string;
+ instagram?: string;
+ telegram?: string;
+ notifiedConfirm: boolean;
+ notifiedReminder: boolean;
+ createdAt: string;
+ classStyle?: string;
+ classTrainer?: string;
+ classTime?: string;
+ classHall?: string;
+}
+
+interface MasterClassSlot {
+ date: string;
+ startTime: string;
+ endTime: string;
+}
+
+interface MasterClassItem {
+ title: string;
+ slots: MasterClassSlot[];
+}
+
+type Tab = "classes" | "master-classes" | "open-day";
+type NotifyFilter = "all" | "new" | "no-reminder";
+
+// --- Filter Chips ---
+
+function FilterChips({
+ filter,
+ setFilter,
+ newCount,
+ noReminderCount,
+}: {
+ filter: NotifyFilter;
+ setFilter: (f: NotifyFilter) => void;
+ newCount: number;
+ noReminderCount: number;
+}) {
+ const FILTERS: { key: NotifyFilter; label: string; count?: number }[] = [
+ { key: "all", label: "Все" },
+ { key: "new", label: "Новые", count: newCount },
+ { key: "no-reminder", label: "Без напоминания", count: noReminderCount },
+ ];
+
+ return (
+
+ {FILTERS.map((f) => (
+
+ ))}
+
+ );
+}
+
+// --- Group Bookings Tab ---
+
+function GroupBookingsTab() {
+ const [bookings, setBookings] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState("all");
+
+ useEffect(() => {
+ adminFetch("/api/admin/group-bookings")
+ .then((r) => r.json())
+ .then((data: GroupBooking[]) => setBookings(data))
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, []);
+
+ const newCount = bookings.filter((b) => !b.notifiedConfirm).length;
+ const noReminderCount = bookings.filter((b) => !b.notifiedReminder).length;
+
+ const filtered = useMemo(() => {
+ if (filter === "new") return bookings.filter((b) => !b.notifiedConfirm);
+ if (filter === "no-reminder") return bookings.filter((b) => !b.notifiedReminder);
+ return bookings;
+ }, [bookings, filter]);
+
+ async function handleToggle(id: number, field: "notified_confirm" | "notified_reminder") {
+ const b = bookings.find((x) => x.id === id);
+ if (!b) return;
+ const key = field === "notified_confirm" ? "notifiedConfirm" : "notifiedReminder";
+ const newValue = !b[key];
+ setBookings((prev) => prev.map((x) => x.id === id ? { ...x, [key]: newValue } : x));
+ await adminFetch("/api/admin/group-bookings", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ action: "toggle-notify", id, field, value: newValue }),
+ });
+ }
+
+ async function handleDelete(id: number) {
+ await adminFetch(`/api/admin/group-bookings?id=${id}`, { method: "DELETE" });
+ setBookings((prev) => prev.filter((b) => b.id !== id));
+ }
+
+ if (loading) return ;
+
+ return (
+ <>
+
+
+ {filtered.length === 0 &&
}
+ {filtered.map((b) => (
+
+
+
{b.name}
+
+ {b.phone}
+
+ {b.groupInfo && (
+ <>
+
·
+
{b.groupInfo}
+ >
+ )}
+
{fmtDate(b.createdAt)}
+
handleDelete(b.id)} />
+
+
handleToggle(b.id, "notified_confirm")}
+ onToggleReminder={() => handleToggle(b.id, "notified_reminder")}
+ />
+
+ ))}
+
+ >
+ );
+}
+
+// --- MC Registrations Tab ---
+
+function McRegistrationsTab() {
+ const [regs, setRegs] = useState([]);
+ const [mcItems, setMcItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState("all");
+
+ useEffect(() => {
+ Promise.all([
+ adminFetch("/api/admin/mc-registrations").then((r) => r.json()),
+ adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()),
+ ])
+ .then(([regData, mcData]: [McRegistration[], { items: MasterClassItem[] }]) => {
+ setRegs(regData);
+ setMcItems(mcData.items || []);
+ })
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, []);
+
+ // Compute reminder urgency per MC title
+ const urgencyMap = useMemo(() => {
+ const map: Record = {};
+ const now = Date.now();
+ const twoDays = 2 * 24 * 60 * 60 * 1000;
+ for (const mc of mcItems) {
+ map[mc.title] = (mc.slots || []).some((s) => {
+ if (!s.date) return false;
+ const slotTime = new Date(s.date + "T" + (s.startTime || "23:59")).getTime();
+ const diff = slotTime - now;
+ return diff >= 0 && diff <= twoDays;
+ });
+ }
+ return map;
+ }, [mcItems]);
+
+ const newCount = regs.filter((r) => !r.notifiedConfirm).length;
+ const noReminderCount = regs.filter((r) => !r.notifiedReminder).length;
+
+ const filtered = useMemo(() => {
+ if (filter === "new") return regs.filter((r) => !r.notifiedConfirm);
+ if (filter === "no-reminder") return regs.filter((r) => !r.notifiedReminder);
+ return regs;
+ }, [regs, filter]);
+
+ // Group by MC title
+ const grouped = useMemo(() => {
+ const map: Record = {};
+ for (const r of filtered) {
+ if (!map[r.masterClassTitle]) map[r.masterClassTitle] = [];
+ map[r.masterClassTitle].push(r);
+ }
+ return map;
+ }, [filtered]);
+
+ const [expanded, setExpanded] = useState>({});
+ function toggleExpand(key: string) {
+ setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));
+ }
+
+ async function handleToggle(id: number, field: "notified_confirm" | "notified_reminder") {
+ const r = regs.find((x) => x.id === id);
+ if (!r) return;
+ const key = field === "notified_confirm" ? "notifiedConfirm" : "notifiedReminder";
+ const newValue = !r[key];
+ setRegs((prev) => prev.map((x) => x.id === id ? { ...x, [key]: newValue } : x));
+ await adminFetch("/api/admin/mc-registrations", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ action: "toggle-notify", id, field, value: newValue }),
+ });
+ }
+
+ async function handleDelete(id: number) {
+ await adminFetch(`/api/admin/mc-registrations?id=${id}`, { method: "DELETE" });
+ setRegs((prev) => prev.filter((r) => r.id !== id));
+ }
+
+ if (loading) return ;
+
+ return (
+ <>
+
+
+ {Object.keys(grouped).length === 0 &&
}
+ {Object.entries(grouped).map(([title, items]) => {
+ const isOpen = expanded[title] ?? false;
+ const groupNewCount = items.filter((r) => !r.notifiedConfirm).length;
+ return (
+
+
+ {isOpen && (
+
+ {items.map((r) => (
+
+
+
handleToggle(r.id, "notified_confirm")}
+ onToggleReminder={() => handleToggle(r.id, "notified_reminder")}
+ />
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+ >
+ );
+}
+
+// --- Open Day Bookings Tab ---
+
+function OpenDayBookingsTab() {
+ const [bookings, setBookings] = useState([]);
+ const [eventDate, setEventDate] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState("all");
+
+ useEffect(() => {
+ // Get events to find active one
+ adminFetch("/api/admin/open-day")
+ .then((r) => r.json())
+ .then((events: { id: number; date: string }[]) => {
+ if (events.length === 0) {
+ setLoading(false);
+ return;
+ }
+ const ev = events[0];
+ setEventDate(ev.date);
+ return adminFetch(`/api/admin/open-day/bookings?eventId=${ev.id}`)
+ .then((r) => r.json())
+ .then((data: OpenDayBooking[]) => setBookings(data));
+ })
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, []);
+
+ const reminderUrgent = useMemo(() => {
+ if (!eventDate) return false;
+ const now = Date.now();
+ const twoDays = 2 * 24 * 60 * 60 * 1000;
+ const eventTime = new Date(eventDate + "T10:00").getTime();
+ const diff = eventTime - now;
+ return diff >= 0 && diff <= twoDays;
+ }, [eventDate]);
+
+ const newCount = bookings.filter((b) => !b.notifiedConfirm).length;
+ const noReminderCount = bookings.filter((b) => !b.notifiedReminder).length;
+
+ const filtered = useMemo(() => {
+ if (filter === "new") return bookings.filter((b) => !b.notifiedConfirm);
+ if (filter === "no-reminder") return bookings.filter((b) => !b.notifiedReminder);
+ return bookings;
+ }, [bookings, filter]);
+
+ // Group by class — sorted by hall then time
+ const grouped = useMemo(() => {
+ const map: Record = {};
+ for (const b of filtered) {
+ const key = `${b.classHall}|${b.classTime}|${b.classStyle}`;
+ if (!map[key]) map[key] = { hall: b.classHall || "—", time: b.classTime || "—", style: b.classStyle || "—", trainer: b.classTrainer || "—", items: [] };
+ map[key].items.push(b);
+ }
+ // Sort by hall, then time
+ return Object.entries(map).sort(([, a], [, b]) => {
+ const hallCmp = a.hall.localeCompare(b.hall);
+ return hallCmp !== 0 ? hallCmp : a.time.localeCompare(b.time);
+ });
+ }, [filtered]);
+
+ const [expanded, setExpanded] = useState>({});
+ function toggleExpand(key: string) {
+ setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));
+ }
+
+ async function handleToggle(id: number, field: "notified_confirm" | "notified_reminder") {
+ const b = bookings.find((x) => x.id === id);
+ if (!b) return;
+ const key = field === "notified_confirm" ? "notifiedConfirm" : "notifiedReminder";
+ const newValue = !b[key];
+ setBookings((prev) => prev.map((x) => x.id === id ? { ...x, [key]: newValue } : x));
+ await adminFetch("/api/admin/open-day/bookings", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ action: "toggle-notify", id, field, value: newValue }),
+ });
+ }
+
+ async function handleDelete(id: number) {
+ await adminFetch(`/api/admin/open-day/bookings?id=${id}`, { method: "DELETE" });
+ setBookings((prev) => prev.filter((b) => b.id !== id));
+ }
+
+ if (loading) return ;
+
+ return (
+ <>
+
+
+ {grouped.length === 0 &&
}
+ {grouped.map(([key, group]) => {
+ const isOpen = expanded[key] ?? false;
+ const groupNewCount = group.items.filter((b) => !b.notifiedConfirm).length;
+ return (
+
+
+ {isOpen && (
+
+ {group.items.map((b) => (
+
+
+
handleToggle(b.id, "notified_confirm")}
+ onToggleReminder={() => handleToggle(b.id, "notified_reminder")}
+ />
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+ >
+ );
+}
+
+// --- Shared helpers ---
+
+function LoadingSpinner() {
+ return (
+
+
+ Загрузка...
+
+ );
+}
+
+function EmptyState({ total }: { total: number }) {
+ return (
+
+ {total === 0 ? "Пока нет записей" : "Нет записей по фильтру"}
+
+ );
+}
+
+function DeleteBtn({ onClick }: { onClick: () => void }) {
+ return (
+
+ );
+}
+
+function fmtDate(iso: string): string {
+ return new Date(iso).toLocaleDateString("ru-RU");
+}
+
+// --- Main Page ---
+
+const TABS: { key: Tab; label: string }[] = [
+ { key: "classes", label: "Занятия" },
+ { key: "master-classes", label: "Мастер-классы" },
+ { key: "open-day", label: "День открытых дверей" },
+];
+
+export default function BookingsPage() {
+ const [tab, setTab] = useState("classes");
+
+ return (
+
+
Записи
+
+ Все заявки и записи в одном месте
+
+
+ {/* Tabs */}
+
+ {TABS.map((t) => (
+
+ ))}
+
+
+ {/* Tab content */}
+
+ {tab === "classes" && }
+ {tab === "master-classes" && }
+ {tab === "open-day" && }
+
+
+ );
+}
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
index 380481b..5ce60f4 100644
--- a/src/app/admin/layout.tsx
+++ b/src/app/admin/layout.tsx
@@ -1,8 +1,9 @@
"use client";
-import { useState } from "react";
+import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
+import { adminFetch } from "@/lib/csrf";
import {
LayoutDashboard,
Sparkles,
@@ -20,6 +21,8 @@ import {
Menu,
X,
ChevronLeft,
+ ClipboardList,
+ DoorOpen,
} from "lucide-react";
const NAV_ITEMS = [
@@ -30,7 +33,9 @@ const NAV_ITEMS = [
{ href: "/admin/team", label: "Команда", icon: Users },
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
+ { href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen },
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
+ { href: "/admin/bookings", label: "Записи", icon: ClipboardList },
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
{ href: "/admin/news", label: "Новости", icon: Newspaper },
@@ -45,12 +50,27 @@ export default function AdminLayout({
const pathname = usePathname();
const router = useRouter();
const [sidebarOpen, setSidebarOpen] = useState(false);
+ const [unreadTotal, setUnreadTotal] = useState(0);
// Don't render admin shell on login page
if (pathname === "/admin/login") {
return <>{children}>;
}
+ // Fetch unread counts — poll every 30s
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ useEffect(() => {
+ function fetchCounts() {
+ adminFetch("/api/admin/unread-counts")
+ .then((r) => r.json())
+ .then((data: { total: number }) => setUnreadTotal(data.total))
+ .catch(() => {});
+ }
+ fetchCounts();
+ const interval = setInterval(fetchCounts, 30000);
+ return () => clearInterval(interval);
+ }, []);
+
async function handleLogout() {
await fetch("/api/logout", { method: "POST" });
router.push("/admin/login");
@@ -106,6 +126,11 @@ export default function AdminLayout({
>
{item.label}
+ {item.href === "/admin/bookings" && unreadTotal > 0 && (
+
+ {unreadTotal > 99 ? "99+" : unreadTotal}
+
+ )}
);
})}
diff --git a/src/app/admin/master-classes/page.tsx b/src/app/admin/master-classes/page.tsx
index a2cd633..3533794 100644
--- a/src/app/admin/master-classes/page.tsx
+++ b/src/app/admin/master-classes/page.tsx
@@ -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 }) {
);
}
-// --- 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 (
-
-
- 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"
- />
-
-
-
-
-
-
-
- );
- }
-
- return (
-
-
{reg.name}
-
·
-
-
- {reg.instagram}
-
- {reg.telegram && (
- <>
-
·
-
-
- {reg.telegram}
-
- >
- )}
-
- {new Date(reg.createdAt).toLocaleDateString("ru-RU")}
-
-
-
-
- );
-}
-
-// --- Registrations List ---
-function RegistrationsList({ title }: { title: string }) {
- const [open, setOpen] = useState(false);
- const [regs, setRegs] = useState([]);
- const [loading, setLoading] = useState(false);
- const [count, setCount] = useState(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 (
-
-
-
- {open && (
-
- {loading && (
-
-
- Загрузка...
-
- )}
-
- {!loading && regs.length === 0 && !adding && (
-
Пока никто не записался
- )}
-
- {regs.map((reg) => (
-
handleDelete(reg.id)}
- />
- ))}
-
- {adding ? (
-
-
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"
- />
-
-
-
-
-
-
- ) : (
-
- )}
-
- )}
-
- );
-}
-
// --- Main page ---
export default function MasterClassesEditorPage() {
const [trainers, setTrainers] = useState([]);
@@ -952,7 +609,6 @@ export default function MasterClassesEditorPage() {
}
/>
-
)}
createItem={() => ({
diff --git a/src/app/admin/open-day/page.tsx b/src/app/admin/open-day/page.tsx
new file mode 100644
index 0000000..af786bc
--- /dev/null
+++ b/src/app/admin/open-day/page.tsx
@@ -0,0 +1,711 @@
+"use client";
+
+import { useState, useEffect, useMemo, useCallback } from "react";
+import {
+ Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, ChevronDown, ChevronUp,
+ Phone, Instagram, Send,
+} from "lucide-react";
+import { adminFetch } from "@/lib/csrf";
+import { NotifyToggle } from "../_components/NotifyToggle";
+
+// --- Types ---
+
+interface OpenDayEvent {
+ id: number;
+ date: string;
+ title: string;
+ description?: string;
+ pricePerClass: number;
+ discountPrice: number;
+ discountThreshold: number;
+ minBookings: number;
+ active: boolean;
+}
+
+interface OpenDayClass {
+ id: number;
+ eventId: number;
+ hall: string;
+ startTime: string;
+ endTime: string;
+ trainer: string;
+ style: string;
+ cancelled: boolean;
+ sortOrder: number;
+ bookingCount: number;
+}
+
+interface OpenDayBooking {
+ id: number;
+ classId: number;
+ eventId: number;
+ name: string;
+ phone: string;
+ instagram?: string;
+ telegram?: string;
+ notifiedConfirm: boolean;
+ notifiedReminder: boolean;
+ createdAt: string;
+ classStyle?: string;
+ classTrainer?: string;
+ classTime?: string;
+ classHall?: string;
+}
+
+// --- Helpers ---
+
+function generateTimeSlots(startHour: number, endHour: number): string[] {
+ const slots: string[] = [];
+ for (let h = startHour; h < endHour; h++) {
+ slots.push(`${h.toString().padStart(2, "0")}:00`);
+ }
+ return slots;
+}
+
+function addHour(time: string): string {
+ const [h, m] = time.split(":").map(Number);
+ return `${(h + 1).toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
+}
+
+// --- Event Settings ---
+
+function EventSettings({
+ event,
+ onChange,
+}: {
+ event: OpenDayEvent;
+ onChange: (patch: Partial) => void;
+}) {
+ return (
+
+
+
+ Настройки мероприятия
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {event.pricePerClass} BYN / занятие, от {event.discountThreshold} — {event.discountPrice} BYN
+
+
+
+ );
+}
+
+// --- Class Grid Cell ---
+
+function ClassCell({
+ cls,
+ minBookings,
+ trainers,
+ styles,
+ onUpdate,
+ onDelete,
+ onCancel,
+}: {
+ cls: OpenDayClass;
+ minBookings: number;
+ trainers: string[];
+ styles: string[];
+ onUpdate: (id: number, data: Partial) => void;
+ onDelete: (id: number) => void;
+ onCancel: (id: number) => void;
+}) {
+ const [editing, setEditing] = useState(false);
+ const [trainer, setTrainer] = useState(cls.trainer);
+ const [style, setStyle] = useState(cls.style);
+
+ const atRisk = cls.bookingCount < minBookings && !cls.cancelled;
+
+ function save() {
+ if (trainer.trim() && style.trim()) {
+ onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim() });
+ setEditing(false);
+ }
+ }
+
+ if (editing) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+ setEditing(true)}
+ >
+
{cls.style}
+
{cls.trainer}
+
+
+ {cls.bookingCount} чел.
+
+ {atRisk && !cls.cancelled && (
+ мин. {minBookings}
+ )}
+ {cls.cancelled && отменено}
+
+ {/* Actions */}
+
+
+
+
+
+ );
+}
+
+// --- Schedule Grid ---
+
+function ScheduleGrid({
+ eventId,
+ minBookings,
+ halls,
+ classes,
+ trainers,
+ styles,
+ onClassesChange,
+}: {
+ eventId: number;
+ minBookings: number;
+ halls: string[];
+ classes: OpenDayClass[];
+ trainers: string[];
+ styles: string[];
+ onClassesChange: () => void;
+}) {
+ const timeSlots = generateTimeSlots(10, 22);
+
+ // Build lookup: hall -> time -> class
+ const grid = useMemo(() => {
+ const map: Record> = {};
+ for (const hall of halls) map[hall] = {};
+ for (const cls of classes) {
+ if (!map[cls.hall]) map[cls.hall] = {};
+ map[cls.hall][cls.startTime] = cls;
+ }
+ return map;
+ }, [classes, halls]);
+
+ async function addClass(hall: string, startTime: string) {
+ const endTime = addHour(startTime);
+ await adminFetch("/api/admin/open-day/classes", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ eventId, hall, startTime, endTime, trainer: "—", style: "—" }),
+ });
+ onClassesChange();
+ }
+
+ async function updateClass(id: number, data: Partial) {
+ await adminFetch("/api/admin/open-day/classes", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ id, ...data }),
+ });
+ onClassesChange();
+ }
+
+ async function deleteClass(id: number) {
+ await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
+ onClassesChange();
+ }
+
+ async function cancelClass(id: number) {
+ const cls = classes.find((c) => c.id === id);
+ if (!cls) return;
+ await updateClass(id, { cancelled: !cls.cancelled });
+ }
+
+ return (
+
+
Расписание
+
+ {halls.length === 0 ? (
+
Нет залов в расписании. Добавьте локации в разделе «Расписание».
+ ) : (
+
+
+
+
+ | Время |
+ {halls.map((hall) => (
+
+ {hall}
+ |
+ ))}
+
+
+
+ {timeSlots.map((time) => (
+
+ | {time} |
+ {halls.map((hall) => {
+ const cls = grid[hall]?.[time];
+ return (
+
+ {cls ? (
+
+ ) : (
+
+ )}
+ |
+ );
+ })}
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+// --- Bookings Table ---
+
+function BookingsSection({
+ eventId,
+ eventDate,
+}: {
+ eventId: number;
+ eventDate: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const [bookings, setBookings] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const reminderUrgent = useMemo(() => {
+ if (!eventDate) return false;
+ const now = Date.now();
+ const twoDays = 2 * 24 * 60 * 60 * 1000;
+ const eventTime = new Date(eventDate + "T10:00").getTime();
+ const diff = eventTime - now;
+ return diff >= 0 && diff <= twoDays;
+ }, [eventDate]);
+
+ function load() {
+ setLoading(true);
+ adminFetch(`/api/admin/open-day/bookings?eventId=${eventId}`)
+ .then((r) => r.json())
+ .then((data: OpenDayBooking[]) => setBookings(data))
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }
+
+ function toggle() {
+ if (!open) load();
+ setOpen(!open);
+ }
+
+ async function handleToggle(id: number, field: "notified_confirm" | "notified_reminder") {
+ const b = bookings.find((x) => x.id === id);
+ if (!b) return;
+ const key = field === "notified_confirm" ? "notifiedConfirm" : "notifiedReminder";
+ const newValue = !b[key];
+ setBookings((prev) => prev.map((x) => x.id === id ? { ...x, [key]: newValue } : x));
+ await adminFetch("/api/admin/open-day/bookings", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ action: "toggle-notify", id, field, value: newValue }),
+ });
+ }
+
+ async function handleDelete(id: number) {
+ await adminFetch(`/api/admin/open-day/bookings?id=${id}`, { method: "DELETE" });
+ setBookings((prev) => prev.filter((x) => x.id !== id));
+ }
+
+ const newCount = bookings.filter((b) => !b.notifiedConfirm).length;
+
+ return (
+
+
+
+ {open && (
+
+ {loading && (
+
+
+ Загрузка...
+
+ )}
+
+ {!loading && bookings.length === 0 && (
+
Пока нет записей
+ )}
+
+ {bookings.map((b) => (
+
+
+
{b.name}
+
+
+ {b.phone}
+
+ {b.instagram && (
+
+
+ {b.instagram}
+
+ )}
+ {b.telegram && (
+
+
+ {b.telegram}
+
+ )}
+
+ {b.classHall} {b.classTime} · {b.classStyle}
+
+
+
+
handleToggle(b.id, "notified_confirm")}
+ onToggleReminder={() => handleToggle(b.id, "notified_reminder")}
+ />
+
+ ))}
+
+ )}
+
+ );
+}
+
+// --- Main Page ---
+
+export default function OpenDayAdminPage() {
+ const [event, setEvent] = useState(null);
+ const [classes, setClasses] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [trainers, setTrainers] = useState([]);
+ const [styles, setStyles] = useState([]);
+ const [halls, setHalls] = useState([]);
+ const saveTimerRef = { current: null as ReturnType | null };
+
+ // Load data
+ useEffect(() => {
+ Promise.all([
+ adminFetch("/api/admin/open-day").then((r) => r.json()),
+ adminFetch("/api/admin/team").then((r) => r.json()),
+ adminFetch("/api/admin/sections/classes").then((r) => r.json()),
+ adminFetch("/api/admin/sections/schedule").then((r) => r.json()),
+ ])
+ .then(([events, members, classesData, scheduleData]: [OpenDayEvent[], { name: string }[], { items: { name: string }[] }, { locations: { name: string }[] }]) => {
+ if (events.length > 0) {
+ setEvent(events[0]);
+ loadClasses(events[0].id);
+ }
+ setTrainers(members.map((m) => m.name));
+ setStyles(classesData.items.map((c) => c.name));
+ setHalls(scheduleData.locations.map((l) => l.name));
+ })
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, []);
+
+ function loadClasses(eventId: number) {
+ adminFetch(`/api/admin/open-day/classes?eventId=${eventId}`)
+ .then((r) => r.json())
+ .then((data: OpenDayClass[]) => setClasses(data))
+ .catch(() => {});
+ }
+
+ // Auto-save event changes
+ const saveEvent = useCallback(
+ (updated: OpenDayEvent) => {
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
+ saveTimerRef.current = setTimeout(async () => {
+ setSaving(true);
+ await adminFetch("/api/admin/open-day", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(updated),
+ });
+ setSaving(false);
+ }, 800);
+ },
+ []
+ );
+
+ function handleEventChange(patch: Partial) {
+ if (!event) return;
+ const updated = { ...event, ...patch };
+ setEvent(updated);
+ saveEvent(updated);
+ }
+
+ async function createEvent() {
+ const today = new Date().toISOString().split("T")[0];
+ const res = await adminFetch("/api/admin/open-day", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ date: today }),
+ });
+ const { id } = await res.json();
+ setEvent({
+ id,
+ date: today,
+ title: "День открытых дверей",
+ pricePerClass: 30,
+ discountPrice: 20,
+ discountThreshold: 3,
+ minBookings: 4,
+ active: true,
+ });
+ }
+
+ async function deleteEvent() {
+ if (!event) return;
+ await adminFetch(`/api/admin/open-day?id=${event.id}`, { method: "DELETE" });
+ setEvent(null);
+ setClasses([]);
+ }
+
+ if (loading) {
+ return (
+
+
+ Загрузка...
+
+ );
+ }
+
+ if (!event) {
+ return (
+
+
День открытых дверей
+
Создайте мероприятие, чтобы начать
+
+
+ );
+ }
+
+ return (
+
+
+
+
День открытых дверей
+ {saving && Сохранение...}
+
+
+
+
+
+
+
loadClasses(event.id)}
+ />
+
+
+
+ );
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 5b6886e..ebe3ce5 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -1,3 +1,6 @@
+"use client";
+
+import { useState, useEffect } from "react";
import Link from "next/link";
import {
Globe,
@@ -11,7 +14,18 @@ import {
HelpCircle,
Newspaper,
Phone,
+ ClipboardList,
+ DoorOpen,
+ UserPlus,
} from "lucide-react";
+import { adminFetch } from "@/lib/csrf";
+
+interface UnreadCounts {
+ groupBookings: number;
+ mcRegistrations: number;
+ openDayBookings: number;
+ total: number;
+}
const CARDS = [
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe, desc: "Заголовок и описание сайта" },
@@ -20,22 +34,82 @@ const CARDS = [
{ href: "/admin/team", label: "Команда", icon: Users, desc: "Тренеры и инструкторы" },
{ href: "/admin/classes", label: "Направления", icon: BookOpen, desc: "Типы занятий" },
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star, desc: "Мастер-классы и записи" },
+ { href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen, desc: "Открытые занятия, расписание, записи" },
{ href: "/admin/schedule", label: "Расписание", icon: Calendar, desc: "Расписание занятий" },
+ { href: "/admin/bookings", label: "Записи", icon: ClipboardList, desc: "Все записи и заявки" },
{ href: "/admin/pricing", label: "Цены", icon: DollarSign, desc: "Абонементы и аренда" },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, desc: "Часто задаваемые вопросы" },
{ href: "/admin/news", label: "Новости", icon: Newspaper, desc: "Новости и анонсы" },
{ href: "/admin/contact", label: "Контакты", icon: Phone, desc: "Адреса, телефон, карта" },
];
+function UnreadWidget({ counts }: { counts: UnreadCounts }) {
+ if (counts.total === 0) return null;
+
+ const items: { label: string; count: number; tab: string }[] = [];
+ if (counts.groupBookings > 0) items.push({ label: "Занятия", count: counts.groupBookings, tab: "classes" });
+ if (counts.mcRegistrations > 0) items.push({ label: "Мастер-классы", count: counts.mcRegistrations, tab: "master-classes" });
+ if (counts.openDayBookings > 0) items.push({ label: "День открытых дверей", count: counts.openDayBookings, tab: "open-day" });
+
+ return (
+
+
+
+
+
+
+
+ Новые записи
+
+ {counts.total}
+
+
+
Не подтверждённые заявки
+
+
+
+ {items.map((item) => (
+
+
+ {item.count}
+
+ {item.label}
+
+ ))}
+
+
+ );
+}
+
export default function AdminDashboard() {
+ const [counts, setCounts] = useState(null);
+
+ useEffect(() => {
+ adminFetch("/api/admin/unread-counts")
+ .then((r) => r.json())
+ .then((data: UnreadCounts) => setCounts(data))
+ .catch(() => {});
+ }, []);
+
return (
Панель управления
Выберите раздел для редактирования
-
+ {/* Unread bookings widget */}
+ {counts && counts.total > 0 && (
+
+
+
+ )}
+
+
{CARDS.map((card) => {
const Icon = card.icon;
+ const isBookings = card.href === "/admin/bookings";
return (
-
-
+
+
{card.label}
+ {isBookings && counts && counts.total > 0 && (
+
+ {counts.total}
+
+ )}
{card.desc}
diff --git a/src/app/admin/pricing/page.tsx b/src/app/admin/pricing/page.tsx
index 1ba68d9..c6ca414 100644
--- a/src/app/admin/pricing/page.tsx
+++ b/src/app/admin/pricing/page.tsx
@@ -19,6 +19,7 @@ interface PricingData {
rentalTitle: string;
rentalItems: { name: string; price: string; note?: string }[];
rules: string[];
+ showContactHint?: boolean;
}
function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
@@ -63,6 +64,25 @@ export default function PricingEditorPage() {
onChange={(v) => update({ ...data, subtitle: v })}
/>
+
+
{/* Featured selector */}
{(() => {
const itemOptions = data.items
diff --git a/src/app/api/admin/group-bookings/route.ts b/src/app/api/admin/group-bookings/route.ts
new file mode 100644
index 0000000..5e52739
--- /dev/null
+++ b/src/app/api/admin/group-bookings/route.ts
@@ -0,0 +1,40 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getGroupBookings, toggleGroupBookingNotification, deleteGroupBooking } from "@/lib/db";
+
+export async function GET() {
+ const bookings = getGroupBookings();
+ return NextResponse.json(bookings);
+}
+
+export async function PUT(request: NextRequest) {
+ try {
+ const body = await request.json();
+ if (body.action === "toggle-notify") {
+ const { id, field, value } = body;
+ if (!id || !field || typeof value !== "boolean") {
+ return NextResponse.json({ error: "id, field, value are required" }, { status: 400 });
+ }
+ if (field !== "notified_confirm" && field !== "notified_reminder") {
+ return NextResponse.json({ error: "Invalid field" }, { status: 400 });
+ }
+ toggleGroupBookingNotification(id, field, value);
+ return NextResponse.json({ ok: true });
+ }
+ return NextResponse.json({ error: "Unknown action" }, { status: 400 });
+ } catch {
+ return NextResponse.json({ error: "Internal error" }, { status: 500 });
+ }
+}
+
+export async function DELETE(request: NextRequest) {
+ const idStr = request.nextUrl.searchParams.get("id");
+ if (!idStr) {
+ return NextResponse.json({ error: "id parameter is required" }, { status: 400 });
+ }
+ const id = parseInt(idStr, 10);
+ if (isNaN(id)) {
+ return NextResponse.json({ error: "Invalid id" }, { status: 400 });
+ }
+ deleteGroupBooking(id);
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/admin/mc-registrations/route.ts b/src/app/api/admin/mc-registrations/route.ts
index 85f2de0..91f3a95 100644
--- a/src/app/api/admin/mc-registrations/route.ts
+++ b/src/app/api/admin/mc-registrations/route.ts
@@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
-import { getMcRegistrations, addMcRegistration, updateMcRegistration, deleteMcRegistration } from "@/lib/db";
+import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration } from "@/lib/db";
export async function GET(request: NextRequest) {
const title = request.nextUrl.searchParams.get("title");
- if (!title) {
- return NextResponse.json({ error: "title parameter is required" }, { status: 400 });
+ if (title) {
+ return NextResponse.json(getMcRegistrations(title));
}
- const registrations = getMcRegistrations(title);
- return NextResponse.json(registrations);
+ // No title = return all registrations
+ return NextResponse.json(getAllMcRegistrations());
}
export async function POST(request: NextRequest) {
@@ -27,6 +27,21 @@ export async function POST(request: NextRequest) {
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
+
+ // Toggle notification status
+ if (body.action === "toggle-notify") {
+ const { id, field, value } = body;
+ if (!id || !field || typeof value !== "boolean") {
+ return NextResponse.json({ error: "id, field, value are required" }, { status: 400 });
+ }
+ if (field !== "notified_confirm" && field !== "notified_reminder") {
+ return NextResponse.json({ error: "Invalid field" }, { status: 400 });
+ }
+ toggleMcNotification(id, field, value);
+ return NextResponse.json({ ok: true });
+ }
+
+ // Regular update
const { id, name, instagram, telegram } = body;
if (!id || !name || !instagram) {
return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 });
diff --git a/src/app/api/admin/open-day/bookings/route.ts b/src/app/api/admin/open-day/bookings/route.ts
new file mode 100644
index 0000000..ee7055e
--- /dev/null
+++ b/src/app/api/admin/open-day/bookings/route.ts
@@ -0,0 +1,43 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ getOpenDayBookings,
+ toggleOpenDayNotification,
+ deleteOpenDayBooking,
+} from "@/lib/db";
+
+export async function GET(request: NextRequest) {
+ const eventIdStr = request.nextUrl.searchParams.get("eventId");
+ if (!eventIdStr) return NextResponse.json({ error: "eventId is required" }, { status: 400 });
+ const eventId = parseInt(eventIdStr, 10);
+ if (isNaN(eventId)) return NextResponse.json({ error: "Invalid eventId" }, { status: 400 });
+ return NextResponse.json(getOpenDayBookings(eventId));
+}
+
+export async function PUT(request: NextRequest) {
+ try {
+ const body = await request.json();
+ if (body.action === "toggle-notify") {
+ const { id, field, value } = body;
+ if (!id || !field || typeof value !== "boolean") {
+ return NextResponse.json({ error: "id, field, value required" }, { status: 400 });
+ }
+ if (field !== "notified_confirm" && field !== "notified_reminder") {
+ return NextResponse.json({ error: "Invalid field" }, { status: 400 });
+ }
+ toggleOpenDayNotification(id, field, value);
+ return NextResponse.json({ ok: true });
+ }
+ return NextResponse.json({ error: "Unknown action" }, { status: 400 });
+ } catch {
+ return NextResponse.json({ error: "Internal error" }, { status: 500 });
+ }
+}
+
+export async function DELETE(request: NextRequest) {
+ const idStr = request.nextUrl.searchParams.get("id");
+ if (!idStr) return NextResponse.json({ error: "id is required" }, { status: 400 });
+ const id = parseInt(idStr, 10);
+ if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
+ deleteOpenDayBooking(id);
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/admin/open-day/classes/route.ts b/src/app/api/admin/open-day/classes/route.ts
new file mode 100644
index 0000000..08a1344
--- /dev/null
+++ b/src/app/api/admin/open-day/classes/route.ts
@@ -0,0 +1,54 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ getOpenDayClasses,
+ addOpenDayClass,
+ updateOpenDayClass,
+ deleteOpenDayClass,
+} from "@/lib/db";
+
+export async function GET(request: NextRequest) {
+ const eventIdStr = request.nextUrl.searchParams.get("eventId");
+ if (!eventIdStr) return NextResponse.json({ error: "eventId is required" }, { status: 400 });
+ const eventId = parseInt(eventIdStr, 10);
+ if (isNaN(eventId)) return NextResponse.json({ error: "Invalid eventId" }, { status: 400 });
+ return NextResponse.json(getOpenDayClasses(eventId));
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const { eventId, hall, startTime, endTime, trainer, style } = body;
+ if (!eventId || !hall || !startTime || !endTime || !trainer || !style) {
+ return NextResponse.json({ error: "All fields required" }, { status: 400 });
+ }
+ const id = addOpenDayClass(eventId, { hall, startTime, endTime, trainer, style });
+ return NextResponse.json({ ok: true, id });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : "Internal error";
+ if (msg.includes("UNIQUE")) {
+ return NextResponse.json({ error: "Этот слот уже занят" }, { status: 409 });
+ }
+ return NextResponse.json({ error: msg }, { status: 500 });
+ }
+}
+
+export async function PUT(request: NextRequest) {
+ try {
+ const body = await request.json();
+ if (!body.id) return NextResponse.json({ error: "id is required" }, { status: 400 });
+ const { id, ...data } = body;
+ updateOpenDayClass(id, data);
+ return NextResponse.json({ ok: true });
+ } catch {
+ return NextResponse.json({ error: "Internal error" }, { status: 500 });
+ }
+}
+
+export async function DELETE(request: NextRequest) {
+ const idStr = request.nextUrl.searchParams.get("id");
+ if (!idStr) return NextResponse.json({ error: "id is required" }, { status: 400 });
+ const id = parseInt(idStr, 10);
+ if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
+ deleteOpenDayClass(id);
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/admin/open-day/route.ts b/src/app/api/admin/open-day/route.ts
new file mode 100644
index 0000000..04ced9b
--- /dev/null
+++ b/src/app/api/admin/open-day/route.ts
@@ -0,0 +1,54 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ getOpenDayEvents,
+ getOpenDayEvent,
+ createOpenDayEvent,
+ updateOpenDayEvent,
+ deleteOpenDayEvent,
+} from "@/lib/db";
+
+export async function GET(request: NextRequest) {
+ const idStr = request.nextUrl.searchParams.get("id");
+ if (idStr) {
+ const id = parseInt(idStr, 10);
+ if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
+ const event = getOpenDayEvent(id);
+ if (!event) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ return NextResponse.json(event);
+ }
+ return NextResponse.json(getOpenDayEvents());
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ if (!body.date || typeof body.date !== "string") {
+ return NextResponse.json({ error: "date is required" }, { status: 400 });
+ }
+ const id = createOpenDayEvent(body);
+ return NextResponse.json({ ok: true, id });
+ } catch {
+ return NextResponse.json({ error: "Internal error" }, { status: 500 });
+ }
+}
+
+export async function PUT(request: NextRequest) {
+ try {
+ const body = await request.json();
+ if (!body.id) return NextResponse.json({ error: "id is required" }, { status: 400 });
+ const { id, ...data } = body;
+ updateOpenDayEvent(id, data);
+ return NextResponse.json({ ok: true });
+ } catch {
+ return NextResponse.json({ error: "Internal error" }, { status: 500 });
+ }
+}
+
+export async function DELETE(request: NextRequest) {
+ const idStr = request.nextUrl.searchParams.get("id");
+ if (!idStr) return NextResponse.json({ error: "id is required" }, { status: 400 });
+ const id = parseInt(idStr, 10);
+ if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
+ deleteOpenDayEvent(id);
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/admin/unread-counts/route.ts b/src/app/api/admin/unread-counts/route.ts
new file mode 100644
index 0000000..eb21d82
--- /dev/null
+++ b/src/app/api/admin/unread-counts/route.ts
@@ -0,0 +1,6 @@
+import { NextResponse } from "next/server";
+import { getUnreadBookingCounts } from "@/lib/db";
+
+export async function GET() {
+ return NextResponse.json(getUnreadBookingCounts());
+}
diff --git a/src/app/api/group-booking/route.ts b/src/app/api/group-booking/route.ts
new file mode 100644
index 0000000..a8d8337
--- /dev/null
+++ b/src/app/api/group-booking/route.ts
@@ -0,0 +1,24 @@
+import { NextRequest, NextResponse } from "next/server";
+import { addGroupBooking } from "@/lib/db";
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const { name, phone, groupInfo, instagram, telegram } = body;
+
+ if (!name || typeof name !== "string" || !phone || typeof phone !== "string") {
+ return NextResponse.json({ error: "name and phone are required" }, { status: 400 });
+ }
+
+ const cleanName = name.trim().slice(0, 100);
+ const cleanPhone = phone.trim().slice(0, 30);
+ const cleanGroup = typeof groupInfo === "string" ? groupInfo.trim().slice(0, 200) : undefined;
+ const cleanIg = typeof instagram === "string" ? instagram.trim().slice(0, 100) : undefined;
+ const cleanTg = typeof telegram === "string" ? telegram.trim().slice(0, 100) : undefined;
+
+ const id = addGroupBooking(cleanName, cleanPhone, cleanGroup, cleanIg, cleanTg);
+ return NextResponse.json({ ok: true, id });
+ } catch {
+ return NextResponse.json({ error: "Internal error" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/master-class-register/route.ts b/src/app/api/master-class-register/route.ts
index 650aa31..dd60809 100644
--- a/src/app/api/master-class-register/route.ts
+++ b/src/app/api/master-class-register/route.ts
@@ -4,7 +4,7 @@ import { addMcRegistration } from "@/lib/db";
export async function POST(request: Request) {
try {
const body = await request.json();
- const { masterClassTitle, name, instagram, telegram } = body;
+ const { masterClassTitle, name, phone, instagram, telegram } = body;
if (!masterClassTitle || typeof masterClassTitle !== "string" || masterClassTitle.length > 200) {
return NextResponse.json({ error: "masterClassTitle is required" }, { status: 400 });
@@ -12,18 +12,20 @@ export async function POST(request: Request) {
if (!name || typeof name !== "string" || !name.trim() || name.length > 100) {
return NextResponse.json({ error: "name is required (max 100 chars)" }, { status: 400 });
}
- if (!instagram || typeof instagram !== "string" || !instagram.trim() || instagram.length > 100) {
- return NextResponse.json({ error: "Instagram аккаунт обязателен" }, { status: 400 });
- }
- if (telegram && (typeof telegram !== "string" || telegram.length > 100)) {
- return NextResponse.json({ error: "Telegram too long" }, { status: 400 });
+ if (!phone || typeof phone !== "string" || !phone.trim()) {
+ return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
}
+ const cleanIg = instagram && typeof instagram === "string" ? instagram.trim().slice(0, 100) : "";
+ const cleanTg = telegram && typeof telegram === "string" ? telegram.trim().slice(0, 100) : undefined;
+ const cleanPhone = phone.trim().slice(0, 30);
+
const id = addMcRegistration(
masterClassTitle.trim().slice(0, 200),
name.trim().slice(0, 100),
- instagram.trim().slice(0, 100),
- telegram && typeof telegram === "string" ? telegram.trim().slice(0, 100) : undefined
+ cleanIg,
+ cleanTg,
+ cleanPhone
);
return NextResponse.json({ ok: true, id });
diff --git a/src/app/api/open-day-register/route.ts b/src/app/api/open-day-register/route.ts
new file mode 100644
index 0000000..e3987be
--- /dev/null
+++ b/src/app/api/open-day-register/route.ts
@@ -0,0 +1,54 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ addOpenDayBooking,
+ isOpenDayClassBookedByPhone,
+ getPersonOpenDayBookings,
+ getOpenDayEvent,
+} from "@/lib/db";
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const { classId, eventId, name, phone, instagram, telegram } = body;
+
+ if (!classId || !eventId || !name || !phone) {
+ return NextResponse.json({ error: "classId, eventId, name, phone are required" }, { status: 400 });
+ }
+
+ const cleanName = (name as string).trim().slice(0, 100);
+ const cleanPhone = (phone as string).replace(/\D/g, "").slice(0, 15);
+ const cleanIg = instagram ? (instagram as string).trim().slice(0, 100) : undefined;
+ const cleanTg = telegram ? (telegram as string).trim().slice(0, 100) : undefined;
+
+ if (!cleanPhone) {
+ return NextResponse.json({ error: "Invalid phone" }, { status: 400 });
+ }
+
+ // Check not already booked
+ if (isOpenDayClassBookedByPhone(classId, cleanPhone)) {
+ return NextResponse.json({ error: "Вы уже записаны на это занятие" }, { status: 409 });
+ }
+
+ const id = addOpenDayBooking(classId, eventId, {
+ name: cleanName,
+ phone: cleanPhone,
+ instagram: cleanIg,
+ telegram: cleanTg,
+ });
+
+ // Return total bookings for this person (for discount calculation)
+ const totalBookings = getPersonOpenDayBookings(eventId, cleanPhone);
+ const event = getOpenDayEvent(eventId);
+ const pricePerClass = event && totalBookings >= event.discountThreshold
+ ? event.discountPrice
+ : event?.pricePerClass ?? 30;
+
+ return NextResponse.json({ ok: true, id, totalBookings, pricePerClass });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : "Internal error";
+ if (msg.includes("UNIQUE")) {
+ return NextResponse.json({ error: "Вы уже записаны на это занятие" }, { status: 409 });
+ }
+ return NextResponse.json({ error: msg }, { status: 500 });
+ }
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 16862ee..2d0ac49 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -12,15 +12,19 @@ import { BackToTop } from "@/components/ui/BackToTop";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { getContent } from "@/lib/content";
+import { OpenDay } from "@/components/sections/OpenDay";
+import { getActiveOpenDay } from "@/lib/openDay";
export default function HomePage() {
const content = getContent();
+ const openDayData = getActiveOpenDay();
return (
<>
+ {openDayData && }
- setBookingOpen(false)} />
+ setBookingOpen(false)} endpoint="/api/group-booking" />
);
}
diff --git a/src/components/sections/MasterClasses.tsx b/src/components/sections/MasterClasses.tsx
index 609bba5..6ad9586 100644
--- a/src/components/sections/MasterClasses.tsx
+++ b/src/components/sections/MasterClasses.tsx
@@ -5,7 +5,7 @@ import Image from "next/image";
import { Calendar, Clock, User, MapPin, Instagram } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
-import { MasterClassSignupModal } from "@/components/ui/MasterClassSignupModal";
+import { SignupModal } from "@/components/ui/SignupModal";
import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
interface MasterClassesProps {
@@ -236,10 +236,12 @@ export function MasterClasses({ data }: MasterClassesProps) {
)}
-
setSignupTitle(null)}
- masterClassTitle={signupTitle ?? ""}
+ subtitle={signupTitle ?? ""}
+ endpoint="/api/master-class-register"
+ extraBody={{ masterClassTitle: signupTitle }}
successMessage={data.successMessage}
/>
diff --git a/src/components/sections/OpenDay.tsx b/src/components/sections/OpenDay.tsx
new file mode 100644
index 0000000..679ecfc
--- /dev/null
+++ b/src/components/sections/OpenDay.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+import { useState, useMemo } from "react";
+import { Calendar, Users, Sparkles } from "lucide-react";
+import { SectionHeading } from "@/components/ui/SectionHeading";
+import { Reveal } from "@/components/ui/Reveal";
+import { SignupModal } from "@/components/ui/SignupModal";
+import type { OpenDayEvent, OpenDayClass } from "@/lib/openDay";
+
+interface OpenDayProps {
+ data: {
+ event: OpenDayEvent;
+ classes: OpenDayClass[];
+ };
+}
+
+function formatDateRu(dateStr: string): string {
+ const d = new Date(dateStr + "T12:00:00");
+ return d.toLocaleDateString("ru-RU", {
+ weekday: "long",
+ day: "numeric",
+ month: "long",
+ });
+}
+
+export function OpenDay({ data }: OpenDayProps) {
+ const { event, classes } = data;
+ const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
+
+ // Group classes by hall
+ const hallGroups = useMemo(() => {
+ const groups: Record = {};
+ for (const cls of classes) {
+ if (!groups[cls.hall]) groups[cls.hall] = [];
+ groups[cls.hall].push(cls);
+ }
+ // Sort each hall's classes by time
+ for (const hall in groups) {
+ groups[hall].sort((a, b) => a.startTime.localeCompare(b.startTime));
+ }
+ return groups;
+ }, [classes]);
+
+ const halls = Object.keys(hallGroups);
+
+ if (classes.length === 0) return null;
+
+ return (
+
+
+
+ {event.title}
+
+
+
+
+
+
+ {formatDateRu(event.date)}
+
+
+
+
+ {/* Pricing info */}
+
+
+
+ {event.pricePerClass} BYN за занятие
+
+
+
+ От {event.discountThreshold} занятий — {event.discountPrice} BYN за каждое!
+
+
+
+
+ {event.description && (
+
+
+ {event.description}
+
+
+ )}
+
+ {/* Schedule Grid */}
+
+ {halls.length === 1 ? (
+ // Single hall — simple list
+
+
+
{halls[0]}
+ {hallGroups[halls[0]].map((cls) => (
+
+ ))}
+
+
+ ) : (
+ // Multiple halls — columns
+
+ {halls.map((hall) => (
+
+
+
{hall}
+
+ {hallGroups[hall].map((cls) => (
+
+ ))}
+
+
+
+ ))}
+
+ )}
+
+
+
+ {signup && (
+ setSignup(null)}
+ subtitle={signup.label}
+ endpoint="/api/open-day-register"
+ extraBody={{ classId: signup.classId, eventId: event.id }}
+ />
+ )}
+
+ );
+}
+
+function ClassCard({
+ cls,
+ onSignup,
+}: {
+ cls: OpenDayClass;
+ onSignup: (info: { classId: number; label: string }) => void;
+}) {
+ const label = `${cls.style} · ${cls.trainer} · ${cls.startTime}–${cls.endTime}`;
+
+ if (cls.cancelled) {
+ return (
+
+
+
+
{cls.startTime}–{cls.endTime}
+
{cls.style}
+
{cls.trainer}
+
+
+ Отменено
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
{cls.startTime}–{cls.endTime}
+
{cls.style}
+
+
+ {cls.trainer}
+
+
+
+
+
+ );
+}
diff --git a/src/components/sections/Pricing.tsx b/src/components/sections/Pricing.tsx
index 4544844..7f1d829 100644
--- a/src/components/sections/Pricing.tsx
+++ b/src/components/sections/Pricing.tsx
@@ -1,10 +1,10 @@
"use client";
import { useState } from "react";
-import { CreditCard, Building2, ScrollText, Crown, Sparkles } from "lucide-react";
+import { CreditCard, Building2, ScrollText, Crown, Sparkles, Instagram, Send, Phone } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
-import { BookingModal } from "@/components/ui/BookingModal";
+import { BRAND } from "@/lib/constants";
import type { SiteContent } from "@/types/content";
type Tab = "prices" | "rental" | "rules";
@@ -13,9 +13,42 @@ interface PricingProps {
data: SiteContent["pricing"];
}
+function ContactHint() {
+ return (
+
+ );
+}
+
export function Pricing({ data: pricing }: PricingProps) {
const [activeTab, setActiveTab] = useState("prices");
- const [bookingOpen, setBookingOpen] = useState(false);
+ const showHint = pricing.showContactHint !== false; // default true
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "prices", label: "Абонементы", icon: },
@@ -68,13 +101,12 @@ export function Pricing({ data: pricing }: PricingProps) {
{regularItems.map((item, i) => {
const isPopular = item.popular ?? false;
return (
-
-
+
);
})}
{/* Featured — big card */}
{featuredItem && (
-