From b94ee690332ee32e497ad9613804bee06b2d690a Mon Sep 17 00:00:00 2001 From: "diana.dolgolyova" Date: Thu, 19 Mar 2026 12:58:04 +0300 Subject: [PATCH] feat: add booking management, Open Day, unified signup modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MC registrations: notification toggles (confirm/remind) with urgency - Group bookings: save to DB from BookingModal, admin CRUD at /admin/bookings - Open Day: full event system with schedule grid (halls × time), per-class booking, discount pricing (30 BYN / 20 BYN from 3+), auto-cancel threshold - Unified SignupModal replaces 3 separate forms — consistent fields (name, phone, instagram, telegram), Instagram DM fallback on network error - Centralized /admin/bookings page with 3 tabs (classes, MC, Open Day), collapsible sections, notification toggles, filter chips - Unread booking badge on sidebar + dashboard widget with per-type breakdown - Pricing: contact hint (Instagram/Telegram/phone) on price & rental tabs, admin toggle to show/hide - DB migrations 5-7: group_bookings table, open_day tables, unified fields Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 40 +- src/app/admin/_components/NotifyToggle.tsx | 71 ++ src/app/admin/bookings/page.tsx | 597 ++++++++++++++++ src/app/admin/layout.tsx | 27 +- src/app/admin/master-classes/page.tsx | 346 +-------- src/app/admin/open-day/page.tsx | 711 +++++++++++++++++++ src/app/admin/page.tsx | 85 ++- src/app/admin/pricing/page.tsx | 20 + src/app/api/admin/group-bookings/route.ts | 40 ++ src/app/api/admin/mc-registrations/route.ts | 25 +- src/app/api/admin/open-day/bookings/route.ts | 43 ++ src/app/api/admin/open-day/classes/route.ts | 54 ++ src/app/api/admin/open-day/route.ts | 54 ++ src/app/api/admin/unread-counts/route.ts | 6 + src/app/api/group-booking/route.ts | 24 + src/app/api/master-class-register/route.ts | 18 +- src/app/api/open-day-register/route.ts | 54 ++ src/app/page.tsx | 4 + src/app/styles/animations.css | 15 + src/components/layout/Header.tsx | 4 +- src/components/sections/MasterClasses.tsx | 8 +- src/components/sections/OpenDay.tsx | 184 +++++ src/components/sections/Pricing.tsx | 67 +- src/components/sections/Schedule.tsx | 8 +- src/components/sections/team/TeamProfile.tsx | 8 +- src/components/ui/BookingModal.tsx | 6 + src/components/ui/OpenDaySignupModal.tsx | 210 ++++++ src/components/ui/SignupModal.tsx | 264 +++++++ src/lib/db.ts | 600 +++++++++++++++- src/lib/openDay.ts | 11 + src/types/content.ts | 1 + 31 files changed, 3198 insertions(+), 407 deletions(-) create mode 100644 src/app/admin/_components/NotifyToggle.tsx create mode 100644 src/app/admin/bookings/page.tsx create mode 100644 src/app/admin/open-day/page.tsx create mode 100644 src/app/api/admin/group-bookings/route.ts create mode 100644 src/app/api/admin/open-day/bookings/route.ts create mode 100644 src/app/api/admin/open-day/classes/route.ts create mode 100644 src/app/api/admin/open-day/route.ts create mode 100644 src/app/api/admin/unread-counts/route.ts create mode 100644 src/app/api/group-booking/route.ts create mode 100644 src/app/api/open-day-register/route.ts create mode 100644 src/components/sections/OpenDay.tsx create mode 100644 src/components/ui/OpenDaySignupModal.tsx create mode 100644 src/components/ui/SignupModal.tsx create mode 100644 src/lib/openDay.ts 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) => ( +
+
+ {r.name} + · + + + {r.instagram} + + {r.telegram && ( + <> + · + + + {r.telegram} + + + )} + {fmtDate(r.createdAt)} + handleDelete(r.id)} /> +
+ 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) => ( +
+
+ {b.name} + + {b.phone} + + {b.instagram && ( + + {b.instagram} + + )} + {b.telegram && ( + + {b.telegram} + + )} + {fmtDate(b.createdAt)} + handleDelete(b.id)} /> +
+ 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" - /> -
-
-
- - @ - - setIg(e.target.value.replace(/^@/, ""))} - placeholder="instagram" - className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none" - /> -
-
- - @ - - setTg(e.target.value.replace(/^@/, ""))} - placeholder="telegram" - className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none" - /> -
-
-
- - -
-
- ); - } - - 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" - /> -
-
- - @ - - setNewIg(e.target.value.replace(/^@/, ""))} - placeholder="instagram" - className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none" - /> -
-
- - @ - - setNewTg(e.target.value.replace(/^@/, ""))} - placeholder="telegram" - className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none" - /> -
-
-
- - -
-
- ) : ( - - )} -
- )} -
- ); -} - // --- 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 ( +
+

+ + Настройки мероприятия +

+ +
+
+ + onChange({ title: e.target.value })} + className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors" + /> +
+
+ + onChange({ date: e.target.value })} + className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors [color-scheme:dark]" + /> +
+
+ +
+ +