feat: add booking management, Open Day, unified signup modal

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:58:04 +03:00
parent 7497ede2fd
commit b94ee69033
31 changed files with 3198 additions and 407 deletions

View File

@@ -25,23 +25,25 @@ Content language: Russian
src/ src/
├── app/ ├── app/
│ ├── layout.tsx # Root layout, fonts, metadata │ ├── 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 │ ├── globals.css # Tailwind imports
│ ├── styles/ │ ├── styles/
│ │ ├── theme.css # Theme variables, semantic classes │ │ ├── theme.css # Theme variables, semantic classes
│ │ └── animations.css # Keyframes, scroll reveal, modal animations │ │ └── animations.css # Keyframes, scroll reveal, modal animations
│ ├── admin/ │ ├── admin/
│ │ ├── page.tsx # Dashboard with 11 section cards │ │ ├── page.tsx # Dashboard with 13 section cards
│ │ ├── login/ # Password auth │ │ ├── login/ # Password auth
│ │ ├── layout.tsx # Sidebar nav shell │ │ ├── layout.tsx # Sidebar nav shell (14 items)
│ │ ├── _components/ # SectionEditor, FormField, ArrayEditor │ │ ├── _components/ # SectionEditor, FormField, ArrayEditor, NotifyToggle
│ │ ├── meta/ # SEO editor │ │ ├── meta/ # SEO editor
│ │ ├── hero/ # Hero editor │ │ ├── hero/ # Hero editor
│ │ ├── about/ # About editor │ │ ├── about/ # About editor
│ │ ├── team/ # Team list + [id] editor │ │ ├── team/ # Team list + [id] editor
│ │ ├── classes/ # Classes editor with icon picker │ │ ├── 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 │ │ ├── schedule/ # Schedule editor
│ │ ├── bookings/ # Group booking management with notification toggles
│ │ ├── pricing/ # Pricing editor │ │ ├── pricing/ # Pricing editor
│ │ ├── faq/ # FAQ editor │ │ ├── faq/ # FAQ editor
│ │ ├── news/ # News editor │ │ ├── news/ # News editor
@@ -55,9 +57,15 @@ src/
│ │ ├── team/[id]/ # GET/PUT/DELETE single member │ │ ├── team/[id]/ # GET/PUT/DELETE single member
│ │ ├── team/reorder/ # PUT reorder │ │ ├── team/reorder/ # PUT reorder
│ │ ├── upload/ # POST file upload (whitelisted folders) │ │ ├── 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 │ │ └── 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/ ├── components/
│ ├── layout/ │ ├── layout/
│ │ ├── Header.tsx # Sticky nav, mobile menu, booking modal ("use client") │ │ ├── Header.tsx # Sticky nav, mobile menu, booking modal ("use client")
@@ -68,6 +76,7 @@ src/
│ │ ├── Team.tsx # Carousel + profile view │ │ ├── Team.tsx # Carousel + profile view
│ │ ├── Classes.tsx # Showcase layout with icon selector │ │ ├── Classes.tsx # Showcase layout with icon selector
│ │ ├── MasterClasses.tsx # Cards with signup modal │ │ ├── MasterClasses.tsx # Cards with signup modal
│ │ ├── OpenDay.tsx # Open Day schedule grid + booking (conditional)
│ │ ├── Schedule.tsx # Day/group views with filters │ │ ├── Schedule.tsx # Day/group views with filters
│ │ ├── Pricing.tsx # Tabs: prices, rental, rules │ │ ├── Pricing.tsx # Tabs: prices, rental, rules
│ │ ├── News.tsx # Featured + compact articles │ │ ├── News.tsx # Featured + compact articles
@@ -76,8 +85,9 @@ src/
│ └── ui/ │ └── ui/
│ ├── Button.tsx │ ├── Button.tsx
│ ├── SectionHeading.tsx │ ├── SectionHeading.tsx
│ ├── BookingModal.tsx # Booking form → Instagram DM │ ├── BookingModal.tsx # Booking form → Instagram DM + DB save
│ ├── MasterClassSignupModal.tsx # MC registration form → API │ ├── MasterClassSignupModal.tsx # MC registration form → API
│ ├── OpenDaySignupModal.tsx # Open Day class booking → API
│ ├── NewsModal.tsx # News detail popup │ ├── NewsModal.tsx # News detail popup
│ ├── Reveal.tsx # Intersection Observer scroll reveal │ ├── Reveal.tsx # Intersection Observer scroll reveal
│ ├── BackToTop.tsx │ ├── BackToTop.tsx
@@ -87,10 +97,11 @@ src/
├── lib/ ├── lib/
│ ├── constants.ts # BRAND constants, NAV_LINKS │ ├── constants.ts # BRAND constants, NAV_LINKS
│ ├── config.ts # UI_CONFIG (thresholds, counts) │ ├── 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.ts # Token signing (Node.js)
│ ├── auth-edge.ts # Token verification (Edge/Web Crypto) │ ├── 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/* ├── proxy.ts # Middleware: auth guard for /admin/*
└── types/ └── types/
├── index.ts ├── index.ts
@@ -119,7 +130,10 @@ src/
- Cookie: `bh-admin-token` (httpOnly, secure in prod) - Cookie: `bh-admin-token` (httpOnly, secure in prod)
- Auto-save with 800ms debounce on all section editors - Auto-save with 800ms debounce on all section editors
- Team members: drag-reorder, photo upload, rich bio (experience, victories, education) - 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 - File upload: whitelisted folders (`team`, `master-classes`, `news`, `classes`), max 5MB, image types only
## Security Notes ## Security Notes
@@ -128,6 +142,10 @@ src/
- API routes validate: input types, string lengths, numeric IDs - API routes validate: input types, string lengths, numeric IDs
- Public MC registration: length-limited but **no rate limiting yet** (add before production) - 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 ## AST Index
- **Always use the AST index** at `memory/ast-index.md` when searching for components, props, hooks, types, or styles - **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 - Contains: component tree, all exports, props, hooks, client/server status, CSS classes, keyframes

View File

@@ -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 (
<button
type="button"
onClick={onToggle}
title={label}
className={`relative flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium transition-all ${
done
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30"
: urgent
? "bg-red-500/15 text-red-400 border border-red-500/30 pulse-urgent"
: "bg-neutral-700/50 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
}`}
>
<Icon size={10} />
{label}
</button>
);
}
export function NotifyToggle({
confirmed,
reminded,
confirmUrgent,
reminderUrgent,
onToggleConfirm,
onToggleReminder,
}: {
confirmed: boolean;
reminded: boolean;
confirmUrgent?: boolean;
reminderUrgent?: boolean;
onToggleConfirm: () => void;
onToggleReminder: () => void;
}) {
return (
<div className="flex items-center gap-1.5">
<Toggle
done={confirmed}
urgent={confirmUrgent ?? !confirmed}
icon={CheckCircle2}
label="Подтверждение"
onToggle={onToggleConfirm}
/>
<Toggle
done={reminded}
urgent={reminderUrgent ?? false}
icon={Bell}
label="Напоминание"
onToggle={onToggleReminder}
/>
</div>
);
}

View File

@@ -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 (
<div className="flex items-center gap-2">
{FILTERS.map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
filter === f.key
? "bg-gold/20 text-gold border border-gold/40"
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
}`}
>
{f.label}
{f.count !== undefined && f.count > 0 && (
<span className="ml-1.5 rounded-full bg-red-500/20 text-red-400 px-1.5 py-0.5 text-[10px]">
{f.count}
</span>
)}
</button>
))}
</div>
);
}
// --- Group Bookings Tab ---
function GroupBookingsTab() {
const [bookings, setBookings] = useState<GroupBooking[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<NotifyFilter>("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 <LoadingSpinner />;
return (
<>
<FilterChips filter={filter} setFilter={setFilter} newCount={newCount} noReminderCount={noReminderCount} />
<div className="mt-3 space-y-2">
{filtered.length === 0 && <EmptyState total={bookings.length} />}
{filtered.map((b) => (
<div
key={b.id}
className={`rounded-xl border p-4 space-y-2 transition-colors ${
!b.notifiedConfirm ? "border-gold/20 bg-gold/[0.03]" : "border-white/10 bg-neutral-900"
}`}
>
<div className="flex items-center gap-3 flex-wrap">
<span className="font-medium text-white">{b.name}</span>
<a href={`tel:${b.phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-sm">
<Phone size={12} />{b.phone}
</a>
{b.groupInfo && (
<>
<span className="text-neutral-600">·</span>
<span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>
</>
)}
<span className="text-neutral-600 text-xs ml-auto">{fmtDate(b.createdAt)}</span>
<DeleteBtn onClick={() => handleDelete(b.id)} />
</div>
<NotifyToggle
confirmed={b.notifiedConfirm}
reminded={b.notifiedReminder}
onToggleConfirm={() => handleToggle(b.id, "notified_confirm")}
onToggleReminder={() => handleToggle(b.id, "notified_reminder")}
/>
</div>
))}
</div>
</>
);
}
// --- MC Registrations Tab ---
function McRegistrationsTab() {
const [regs, setRegs] = useState<McRegistration[]>([]);
const [mcItems, setMcItems] = useState<MasterClassItem[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<NotifyFilter>("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<string, boolean> = {};
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<string, McRegistration[]> = {};
for (const r of filtered) {
if (!map[r.masterClassTitle]) map[r.masterClassTitle] = [];
map[r.masterClassTitle].push(r);
}
return map;
}, [filtered]);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
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 <LoadingSpinner />;
return (
<>
<FilterChips filter={filter} setFilter={setFilter} newCount={newCount} noReminderCount={noReminderCount} />
<div className="mt-3 space-y-2">
{Object.keys(grouped).length === 0 && <EmptyState total={regs.length} />}
{Object.entries(grouped).map(([title, items]) => {
const isOpen = expanded[title] ?? false;
const groupNewCount = items.filter((r) => !r.notifiedConfirm).length;
return (
<div key={title} className="rounded-xl border border-white/10 overflow-hidden">
<button
onClick={() => toggleExpand(title)}
className="w-full flex items-center gap-3 px-4 py-3 bg-neutral-900 hover:bg-neutral-800/80 transition-colors text-left"
>
{isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
<span className="font-medium text-white text-sm truncate">{title}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">{items.length}</span>
{urgencyMap[title] && (
<span className="text-[10px] text-red-400 bg-red-500/10 rounded-full px-2 py-0.5 shrink-0">скоро</span>
)}
{groupNewCount > 0 && (
<span className="text-[10px] text-gold bg-gold/10 rounded-full px-2 py-0.5 shrink-0">{groupNewCount} новых</span>
)}
</button>
{isOpen && (
<div className="px-4 pb-3 pt-1 space-y-1.5">
{items.map((r) => (
<div
key={r.id}
className={`rounded-lg border p-3 space-y-1.5 transition-colors ${
!r.notifiedConfirm ? "border-gold/20 bg-gold/[0.03]" : "border-white/5 bg-neutral-800/30"
}`}
>
<div className="flex items-center gap-2 flex-wrap text-sm">
<span className="font-medium text-white">{r.name}</span>
<span className="text-neutral-500">·</span>
<a
href={`https://ig.me/m/${r.instagram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300"
>
<Instagram size={12} />
<span className="text-neutral-300">{r.instagram}</span>
</a>
{r.telegram && (
<>
<span className="text-neutral-600">·</span>
<a
href={`https://t.me/${r.telegram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300"
>
<Send size={12} />
<span className="text-neutral-300">{r.telegram}</span>
</a>
</>
)}
<span className="text-neutral-600 text-xs ml-auto">{fmtDate(r.createdAt)}</span>
<DeleteBtn onClick={() => handleDelete(r.id)} />
</div>
<NotifyToggle
confirmed={r.notifiedConfirm}
reminded={r.notifiedReminder}
reminderUrgent={urgencyMap[r.masterClassTitle] && !r.notifiedReminder}
onToggleConfirm={() => handleToggle(r.id, "notified_confirm")}
onToggleReminder={() => handleToggle(r.id, "notified_reminder")}
/>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</>
);
}
// --- Open Day Bookings Tab ---
function OpenDayBookingsTab() {
const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
const [eventDate, setEventDate] = useState("");
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<NotifyFilter>("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<string, { hall: string; time: string; style: string; trainer: string; items: OpenDayBooking[] }> = {};
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<Record<string, boolean>>({});
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 <LoadingSpinner />;
return (
<>
<FilterChips filter={filter} setFilter={setFilter} newCount={newCount} noReminderCount={noReminderCount} />
<div className="mt-3 space-y-2">
{grouped.length === 0 && <EmptyState total={bookings.length} />}
{grouped.map(([key, group]) => {
const isOpen = expanded[key] ?? false;
const groupNewCount = group.items.filter((b) => !b.notifiedConfirm).length;
return (
<div key={key} className="rounded-xl border border-white/10 overflow-hidden">
<button
onClick={() => toggleExpand(key)}
className="w-full flex items-center gap-3 px-4 py-3 bg-neutral-900 hover:bg-neutral-800/80 transition-colors text-left"
>
{isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
<span className="text-gold text-xs font-medium shrink-0">{group.time}</span>
<span className="font-medium text-white text-sm truncate">{group.style}</span>
<span className="text-xs text-neutral-500 truncate hidden sm:inline">· {group.trainer}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0 ml-auto">{group.hall}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">{group.items.length} чел.</span>
{groupNewCount > 0 && (
<span className="text-[10px] text-gold bg-gold/10 rounded-full px-2 py-0.5 shrink-0">{groupNewCount} новых</span>
)}
</button>
{isOpen && (
<div className="px-4 pb-3 pt-1 space-y-1.5">
{group.items.map((b) => (
<div
key={b.id}
className={`rounded-lg border p-3 space-y-1.5 transition-colors ${
!b.notifiedConfirm ? "border-gold/20 bg-gold/[0.03]" : "border-white/5 bg-neutral-800/30"
}`}
>
<div className="flex items-center gap-2 flex-wrap text-sm">
<span className="font-medium text-white">{b.name}</span>
<a href={`tel:${b.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
<Phone size={10} />{b.phone}
</a>
{b.instagram && (
<a
href={`https://ig.me/m/${b.instagram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs"
>
<Instagram size={10} />{b.instagram}
</a>
)}
{b.telegram && (
<a
href={`https://t.me/${b.telegram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs"
>
<Send size={10} />{b.telegram}
</a>
)}
<span className="text-neutral-600 text-xs ml-auto">{fmtDate(b.createdAt)}</span>
<DeleteBtn onClick={() => handleDelete(b.id)} />
</div>
<NotifyToggle
confirmed={b.notifiedConfirm}
reminded={b.notifiedReminder}
reminderUrgent={reminderUrgent && !b.notifiedReminder}
onToggleConfirm={() => handleToggle(b.id, "notified_confirm")}
onToggleReminder={() => handleToggle(b.id, "notified_reminder")}
/>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</>
);
}
// --- Shared helpers ---
function LoadingSpinner() {
return (
<div className="flex items-center gap-2 py-8 text-neutral-500 justify-center">
<Loader2 size={16} className="animate-spin" />
Загрузка...
</div>
);
}
function EmptyState({ total }: { total: number }) {
return (
<p className="text-sm text-neutral-500 py-8 text-center">
{total === 0 ? "Пока нет записей" : "Нет записей по фильтру"}
</p>
);
}
function DeleteBtn({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
title="Удалить"
>
<Trash2 size={14} />
</button>
);
}
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<Tab>("classes");
return (
<div>
<h1 className="text-2xl font-bold">Записи</h1>
<p className="mt-1 text-neutral-400 text-sm">
Все заявки и записи в одном месте
</p>
{/* Tabs */}
<div className="mt-5 flex border-b border-white/10">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
tab === t.key
? "text-gold"
: "text-neutral-400 hover:text-white"
}`}
>
{t.label}
{tab === t.key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gold rounded-full" />
)}
</button>
))}
</div>
{/* Tab content */}
<div className="mt-4">
{tab === "classes" && <GroupBookingsTab />}
{tab === "master-classes" && <McRegistrationsTab />}
{tab === "open-day" && <OpenDayBookingsTab />}
</div>
</div>
);
}

View File

@@ -1,8 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { adminFetch } from "@/lib/csrf";
import { import {
LayoutDashboard, LayoutDashboard,
Sparkles, Sparkles,
@@ -20,6 +21,8 @@ import {
Menu, Menu,
X, X,
ChevronLeft, ChevronLeft,
ClipboardList,
DoorOpen,
} from "lucide-react"; } from "lucide-react";
const NAV_ITEMS = [ const NAV_ITEMS = [
@@ -30,7 +33,9 @@ const NAV_ITEMS = [
{ href: "/admin/team", label: "Команда", icon: Users }, { href: "/admin/team", label: "Команда", icon: Users },
{ href: "/admin/classes", label: "Направления", icon: BookOpen }, { href: "/admin/classes", label: "Направления", icon: BookOpen },
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star }, { href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen },
{ href: "/admin/schedule", label: "Расписание", icon: Calendar }, { href: "/admin/schedule", label: "Расписание", icon: Calendar },
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
{ href: "/admin/pricing", label: "Цены", icon: DollarSign }, { href: "/admin/pricing", label: "Цены", icon: DollarSign },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle }, { href: "/admin/faq", label: "FAQ", icon: HelpCircle },
{ href: "/admin/news", label: "Новости", icon: Newspaper }, { href: "/admin/news", label: "Новости", icon: Newspaper },
@@ -45,12 +50,27 @@ export default function AdminLayout({
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [unreadTotal, setUnreadTotal] = useState(0);
// Don't render admin shell on login page // Don't render admin shell on login page
if (pathname === "/admin/login") { if (pathname === "/admin/login") {
return <>{children}</>; 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() { async function handleLogout() {
await fetch("/api/logout", { method: "POST" }); await fetch("/api/logout", { method: "POST" });
router.push("/admin/login"); router.push("/admin/login");
@@ -106,6 +126,11 @@ export default function AdminLayout({
> >
<Icon size={18} /> <Icon size={18} />
{item.label} {item.label}
{item.href === "/admin/bookings" && unreadTotal > 0 && (
<span className="ml-auto rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
{unreadTotal > 99 ? "99+" : unreadTotal}
</span>
)}
</Link> </Link>
); );
})} })}

View File

@@ -4,7 +4,7 @@ import { useState, useRef, useEffect, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField"; import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor"; 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 { adminFetch } from "@/lib/csrf";
import type { MasterClassItem, MasterClassSlot } from "@/types/content"; import type { MasterClassItem, MasterClassSlot } from "@/types/content";
@@ -38,15 +38,6 @@ interface MasterClassesData {
items: MasterClassItem[]; items: MasterClassItem[];
} }
interface McRegistration {
id: number;
masterClassTitle: string;
name: string;
instagram: string;
telegram?: string;
createdAt: string;
}
// --- Autocomplete Multi-Select --- // --- Autocomplete Multi-Select ---
function AutocompleteMulti({ function AutocompleteMulti({
label, label,
@@ -482,340 +473,6 @@ function ValidationHint({ fields }: { fields: Record<string, string> }) {
); );
} }
// --- 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 (
<div className="rounded-lg bg-neutral-800/50 px-3 py-2 space-y-2">
<div className="flex gap-2">
<input
value={name}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-2">
<div className="flex flex-1 items-center rounded-md border border-white/10 bg-neutral-800 text-sm">
<span className="flex items-center gap-1 pl-2 text-neutral-500 select-none">
<Instagram size={11} className="text-pink-400" />@
</span>
<input
value={ig}
onChange={(e) => setIg(e.target.value.replace(/^@/, ""))}
placeholder="instagram"
className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none"
/>
</div>
<div className="flex flex-1 items-center rounded-md border border-white/10 bg-neutral-800 text-sm">
<span className="flex items-center gap-1 pl-2 text-neutral-500 select-none">
<Send size={11} className="text-blue-400" />@
</span>
<input
value={tg}
onChange={(e) => setTg(e.target.value.replace(/^@/, ""))}
placeholder="telegram"
className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={cancel}
className="rounded-md px-3 py-1 text-xs text-neutral-400 hover:text-white transition-colors"
>
Отмена
</button>
<button
type="button"
onClick={save}
disabled={saving || !name.trim() || !ig.trim()}
className="rounded-md bg-gold/20 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors disabled:opacity-40"
>
{saving ? "..." : "Сохранить"}
</button>
</div>
</div>
);
}
return (
<div className="flex items-center gap-2 rounded-lg bg-neutral-800/50 px-3 py-2 text-sm">
<span className="font-medium text-white">{reg.name}</span>
<span className="text-neutral-500">·</span>
<a
href={`https://ig.me/m/${reg.instagram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 transition-colors"
title="Написать в Instagram"
>
<Instagram size={12} />
<span className="text-neutral-300">{reg.instagram}</span>
</a>
{reg.telegram && (
<>
<span className="text-neutral-600">·</span>
<a
href={`https://t.me/${reg.telegram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 transition-colors"
title="Написать в Telegram"
>
<Send size={12} />
<span className="text-neutral-300">{reg.telegram}</span>
</a>
</>
)}
<span className="text-neutral-600 text-xs ml-auto">
{new Date(reg.createdAt).toLocaleDateString("ru-RU")}
</span>
<button
type="button"
onClick={() => setEditing(true)}
className="rounded p-1 text-neutral-500 hover:text-gold transition-colors"
title="Редактировать"
>
<Pencil size={12} />
</button>
<button
type="button"
onClick={onDelete}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
title="Удалить"
>
<Trash2 size={12} />
</button>
</div>
);
}
// --- Registrations List ---
function RegistrationsList({ title }: { title: string }) {
const [open, setOpen] = useState(false);
const [regs, setRegs] = useState<McRegistration[]>([]);
const [loading, setLoading] = useState(false);
const [count, setCount] = useState<number | null>(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 (
<div className="border-t border-white/5 pt-3">
<button
type="button"
onClick={toggle}
className="flex items-center gap-2 text-sm text-neutral-400 hover:text-white transition-colors"
>
{open ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
Записи{count !== null ? ` (${count})` : ""}
</button>
{open && (
<div className="mt-2 space-y-1.5">
{loading && (
<div className="flex items-center gap-2 text-xs text-neutral-500">
<Loader2 size={12} className="animate-spin" />
Загрузка...
</div>
)}
{!loading && regs.length === 0 && !adding && (
<p className="text-xs text-neutral-500">Пока никто не записался</p>
)}
{regs.map((reg) => (
<RegistrationRow
key={reg.id}
reg={reg}
onUpdate={handleUpdate}
onDelete={() => handleDelete(reg.id)}
/>
))}
{adding ? (
<div className="rounded-lg bg-neutral-800/50 px-3 py-2 space-y-2">
<input
value={newName}
onChange={(e) => 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"
/>
<div className="flex gap-2">
<div className="flex flex-1 items-center rounded-md border border-white/10 bg-neutral-800 text-sm">
<span className="flex items-center gap-1 pl-2 text-neutral-500 select-none">
<Instagram size={11} className="text-pink-400" />@
</span>
<input
value={newIg}
onChange={(e) => setNewIg(e.target.value.replace(/^@/, ""))}
placeholder="instagram"
className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none"
/>
</div>
<div className="flex flex-1 items-center rounded-md border border-white/10 bg-neutral-800 text-sm">
<span className="flex items-center gap-1 pl-2 text-neutral-500 select-none">
<Send size={11} className="text-blue-400" />@
</span>
<input
value={newTg}
onChange={(e) => setNewTg(e.target.value.replace(/^@/, ""))}
placeholder="telegram"
className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none"
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => { setAdding(false); setNewName(""); setNewIg(""); setNewTg(""); }}
className="rounded-md px-3 py-1 text-xs text-neutral-400 hover:text-white transition-colors"
>
Отмена
</button>
<button
type="button"
onClick={handleAdd}
disabled={savingNew || !newName.trim() || !newIg.trim()}
className="rounded-md bg-gold/20 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors disabled:opacity-40"
>
{savingNew ? "..." : "Добавить"}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="flex items-center gap-1.5 rounded-lg border border-dashed border-white/10 px-3 py-1.5 text-xs text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
>
<Plus size={12} />
Добавить запись
</button>
)}
</div>
)}
</div>
);
}
// --- Main page --- // --- Main page ---
export default function MasterClassesEditorPage() { export default function MasterClassesEditorPage() {
const [trainers, setTrainers] = useState<string[]>([]); const [trainers, setTrainers] = useState<string[]>([]);
@@ -952,7 +609,6 @@ export default function MasterClassesEditorPage() {
} }
/> />
<RegistrationsList title={item.title} />
</div> </div>
)} )}
createItem={() => ({ createItem={() => ({

View File

@@ -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<OpenDayEvent>) => void;
}) {
return (
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-4">
<h2 className="text-lg font-bold flex items-center gap-2">
<Calendar size={18} className="text-gold" />
Настройки мероприятия
</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Название</label>
<input
type="text"
value={event.title}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Дата</label>
<input
type="date"
value={event.date}
onChange={(e) => 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]"
/>
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Описание</label>
<textarea
value={event.description || ""}
onChange={(e) => onChange({ description: e.target.value || undefined })}
rows={2}
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 resize-none"
placeholder="Описание мероприятия..."
/>
</div>
<div className="grid gap-4 sm:grid-cols-4">
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label>
<input
type="number"
value={event.pricePerClass}
onChange={(e) => onChange({ pricePerClass: parseInt(e.target.value) || 0 })}
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"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Скидка (BYN)</label>
<input
type="number"
value={event.discountPrice}
onChange={(e) => onChange({ discountPrice: parseInt(e.target.value) || 0 })}
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"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">От N занятий</label>
<input
type="number"
value={event.discountThreshold}
onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 1 })}
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"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Мин. записей</label>
<input
type="number"
value={event.minBookings}
onChange={(e) => onChange({ minBookings: parseInt(e.target.value) || 1 })}
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"
/>
</div>
</div>
<div className="flex items-center gap-3 pt-1">
<button
type="button"
onClick={() => onChange({ active: !event.active })}
className={`relative flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${
event.active
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30"
: "bg-neutral-800 text-neutral-400 border border-white/10"
}`}
>
{event.active ? <CheckCircle2 size={14} /> : <Ban size={14} />}
{event.active ? "Опубликовано" : "Черновик"}
</button>
<span className="text-xs text-neutral-500">
{event.pricePerClass} BYN / занятие, от {event.discountThreshold} {event.discountPrice} BYN
</span>
</div>
</div>
);
}
// --- 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<OpenDayClass>) => 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 (
<div className="p-2 space-y-1.5">
<select
value={trainer}
onChange={(e) => setTrainer(e.target.value)}
className="w-full rounded-md border border-white/10 bg-neutral-800 px-2 py-1 text-xs text-white outline-none focus:border-gold"
>
<option value="">Тренер...</option>
{trainers.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<select
value={style}
onChange={(e) => setStyle(e.target.value)}
className="w-full rounded-md border border-white/10 bg-neutral-800 px-2 py-1 text-xs text-white outline-none focus:border-gold"
>
<option value="">Стиль...</option>
{styles.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<div className="flex gap-1 justify-end">
<button onClick={() => setEditing(false)} className="text-[10px] text-neutral-500 hover:text-white px-1">
Отмена
</button>
<button onClick={save} className="text-[10px] text-gold hover:text-gold-light px-1 font-medium">
OK
</button>
</div>
</div>
);
}
return (
<div
className={`group relative p-2 rounded-lg cursor-pointer transition-all ${
cls.cancelled
? "bg-neutral-800/30 opacity-50"
: atRisk
? "bg-red-500/5 border border-red-500/20"
: "bg-gold/5 border border-gold/15 hover:border-gold/30"
}`}
onClick={() => setEditing(true)}
>
<div className="text-xs font-medium text-white truncate">{cls.style}</div>
<div className="text-[10px] text-neutral-400 truncate">{cls.trainer}</div>
<div className="flex items-center gap-1 mt-1">
<span className={`text-[10px] font-medium ${
cls.cancelled
? "text-neutral-500 line-through"
: atRisk
? "text-red-400"
: "text-emerald-400"
}`}>
{cls.bookingCount} чел.
</span>
{atRisk && !cls.cancelled && (
<span className="text-[9px] text-red-400">мин. {minBookings}</span>
)}
{cls.cancelled && <span className="text-[9px] text-neutral-500">отменено</span>}
</div>
{/* Actions */}
<div className="absolute top-1 right-1 hidden group-hover:flex gap-0.5">
<button
onClick={(e) => { e.stopPropagation(); onCancel(cls.id); }}
className="rounded p-0.5 text-neutral-500 hover:text-yellow-400"
title={cls.cancelled ? "Восстановить" : "Отменить"}
>
<Ban size={10} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete(cls.id); }}
className="rounded p-0.5 text-neutral-500 hover:text-red-400"
title="Удалить"
>
<Trash2 size={10} />
</button>
</div>
</div>
);
}
// --- 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<string, Record<string, OpenDayClass>> = {};
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<OpenDayClass>) {
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 (
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-3">
<h2 className="text-lg font-bold">Расписание</h2>
{halls.length === 0 ? (
<p className="text-sm text-neutral-500">Нет залов в расписании. Добавьте локации в разделе «Расписание».</p>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[500px]">
<thead>
<tr>
<th className="text-left text-xs text-neutral-500 font-medium pb-2 w-16">Время</th>
{halls.map((hall) => (
<th key={hall} className="text-left text-xs text-neutral-400 font-medium pb-2 px-1">
{hall}
</th>
))}
</tr>
</thead>
<tbody>
{timeSlots.map((time) => (
<tr key={time} className="border-t border-white/5">
<td className="text-xs text-neutral-500 py-1 pr-2 align-top pt-2">{time}</td>
{halls.map((hall) => {
const cls = grid[hall]?.[time];
return (
<td key={hall} className="py-1 px-1 align-top">
{cls ? (
<ClassCell
cls={cls}
minBookings={minBookings}
trainers={trainers}
styles={styles}
onUpdate={updateClass}
onDelete={deleteClass}
onCancel={cancelClass}
/>
) : (
<button
onClick={() => addClass(hall, time)}
className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors"
>
<Plus size={12} className="mx-auto" />
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// --- Bookings Table ---
function BookingsSection({
eventId,
eventDate,
}: {
eventId: number;
eventDate: string;
}) {
const [open, setOpen] = useState(false);
const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
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 (
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5">
<button
onClick={toggle}
className="flex items-center gap-2 text-lg font-bold hover:text-gold transition-colors"
>
{open ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
Записи
{bookings.length > 0 && (
<span className="text-sm font-normal text-neutral-400">({bookings.length})</span>
)}
{newCount > 0 && (
<span className="rounded-full bg-red-500/20 text-red-400 px-2 py-0.5 text-[10px] font-medium">
{newCount} новых
</span>
)}
</button>
{open && (
<div className="mt-3 space-y-2">
{loading && (
<div className="flex items-center gap-2 text-neutral-500 text-sm py-4 justify-center">
<Loader2 size={14} className="animate-spin" />
Загрузка...
</div>
)}
{!loading && bookings.length === 0 && (
<p className="text-sm text-neutral-500 text-center py-4">Пока нет записей</p>
)}
{bookings.map((b) => (
<div
key={b.id}
className={`rounded-lg p-3 space-y-1.5 ${
!b.notifiedConfirm ? "bg-gold/[0.03] border border-gold/20" : "bg-neutral-800/50 border border-white/5"
}`}
>
<div className="flex items-center gap-2 flex-wrap text-sm">
<span className="font-medium text-white">{b.name}</span>
<a
href={`tel:${b.phone}`}
className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs"
>
<Phone size={10} />
{b.phone}
</a>
{b.instagram && (
<a
href={`https://ig.me/m/${b.instagram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs"
>
<Instagram size={10} />
{b.instagram}
</a>
)}
{b.telegram && (
<a
href={`https://t.me/${b.telegram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs"
>
<Send size={10} />
{b.telegram}
</a>
)}
<span className="text-[10px] text-neutral-500 ml-auto">
{b.classHall} {b.classTime} · {b.classStyle}
</span>
<button
onClick={() => handleDelete(b.id)}
className="rounded p-1 text-neutral-500 hover:text-red-400"
>
<Trash2 size={12} />
</button>
</div>
<NotifyToggle
confirmed={b.notifiedConfirm}
reminded={b.notifiedReminder}
reminderUrgent={reminderUrgent && !b.notifiedReminder}
onToggleConfirm={() => handleToggle(b.id, "notified_confirm")}
onToggleReminder={() => handleToggle(b.id, "notified_reminder")}
/>
</div>
))}
</div>
)}
</div>
);
}
// --- Main Page ---
export default function OpenDayAdminPage() {
const [event, setEvent] = useState<OpenDayEvent | null>(null);
const [classes, setClasses] = useState<OpenDayClass[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [trainers, setTrainers] = useState<string[]>([]);
const [styles, setStyles] = useState<string[]>([]);
const [halls, setHalls] = useState<string[]>([]);
const saveTimerRef = { current: null as ReturnType<typeof setTimeout> | 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<OpenDayEvent>) {
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 (
<div className="flex items-center gap-2 py-12 text-neutral-500 justify-center">
<Loader2 size={18} className="animate-spin" />
Загрузка...
</div>
);
}
if (!event) {
return (
<div className="text-center py-12">
<h1 className="text-2xl font-bold">День открытых дверей</h1>
<p className="mt-2 text-neutral-400">Создайте мероприятие, чтобы начать</p>
<button
onClick={createEvent}
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-gold px-6 py-3 text-sm font-semibold text-black hover:bg-gold-light transition-colors"
>
<Plus size={16} />
Создать мероприятие
</button>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">День открытых дверей</h1>
{saving && <span className="text-xs text-neutral-500">Сохранение...</span>}
</div>
<button
onClick={deleteEvent}
className="flex items-center gap-1.5 rounded-lg border border-red-500/20 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 size={12} />
Удалить
</button>
</div>
<EventSettings event={event} onChange={handleEventChange} />
<ScheduleGrid
eventId={event.id}
minBookings={event.minBookings}
halls={halls}
classes={classes}
trainers={trainers}
styles={styles}
onClassesChange={() => loadClasses(event.id)}
/>
<BookingsSection eventId={event.id} eventDate={event.date} />
</div>
);
}

View File

@@ -1,3 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { import {
Globe, Globe,
@@ -11,7 +14,18 @@ import {
HelpCircle, HelpCircle,
Newspaper, Newspaper,
Phone, Phone,
ClipboardList,
DoorOpen,
UserPlus,
} from "lucide-react"; } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
interface UnreadCounts {
groupBookings: number;
mcRegistrations: number;
openDayBookings: number;
total: number;
}
const CARDS = [ const CARDS = [
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe, desc: "Заголовок и описание сайта" }, { href: "/admin/meta", label: "SEO / Мета", icon: Globe, desc: "Заголовок и описание сайта" },
@@ -20,22 +34,82 @@ const CARDS = [
{ href: "/admin/team", label: "Команда", icon: Users, desc: "Тренеры и инструкторы" }, { href: "/admin/team", label: "Команда", icon: Users, desc: "Тренеры и инструкторы" },
{ href: "/admin/classes", label: "Направления", icon: BookOpen, desc: "Типы занятий" }, { href: "/admin/classes", label: "Направления", icon: BookOpen, desc: "Типы занятий" },
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star, 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/schedule", label: "Расписание", icon: Calendar, desc: "Расписание занятий" },
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList, desc: "Все записи и заявки" },
{ href: "/admin/pricing", label: "Цены", icon: DollarSign, desc: "Абонементы и аренда" }, { href: "/admin/pricing", label: "Цены", icon: DollarSign, desc: "Абонементы и аренда" },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, desc: "Часто задаваемые вопросы" }, { href: "/admin/faq", label: "FAQ", icon: HelpCircle, desc: "Часто задаваемые вопросы" },
{ href: "/admin/news", label: "Новости", icon: Newspaper, desc: "Новости и анонсы" }, { href: "/admin/news", label: "Новости", icon: Newspaper, desc: "Новости и анонсы" },
{ href: "/admin/contact", label: "Контакты", icon: Phone, 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 (
<Link
href="/admin/bookings"
className="block rounded-xl border border-gold/20 bg-gold/[0.03] p-5 transition-all hover:border-gold/40 hover:bg-gold/[0.06]"
>
<div className="flex items-center gap-3 mb-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-500/10 text-red-400">
<UserPlus size={20} />
</div>
<div>
<h2 className="font-medium text-white">
Новые записи
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-red-500 text-white text-[11px] font-bold min-w-[20px] h-[20px] px-1.5">
{counts.total}
</span>
</h2>
<p className="text-xs text-neutral-400">Не подтверждённые заявки</p>
</div>
</div>
<div className="flex gap-3">
{items.map((item) => (
<div key={item.tab} className="flex items-center gap-1.5 text-xs">
<span className="rounded-full bg-gold/15 text-gold font-medium px-2 py-0.5">
{item.count}
</span>
<span className="text-neutral-400">{item.label}</span>
</div>
))}
</div>
</Link>
);
}
export default function AdminDashboard() { export default function AdminDashboard() {
const [counts, setCounts] = useState<UnreadCounts | null>(null);
useEffect(() => {
adminFetch("/api/admin/unread-counts")
.then((r) => r.json())
.then((data: UnreadCounts) => setCounts(data))
.catch(() => {});
}, []);
return ( return (
<div> <div>
<h1 className="text-2xl font-bold">Панель управления</h1> <h1 className="text-2xl font-bold">Панель управления</h1>
<p className="mt-1 text-neutral-400">Выберите раздел для редактирования</p> <p className="mt-1 text-neutral-400">Выберите раздел для редактирования</p>
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> {/* Unread bookings widget */}
{counts && counts.total > 0 && (
<div className="mt-6">
<UnreadWidget counts={counts} />
</div>
)}
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{CARDS.map((card) => { {CARDS.map((card) => {
const Icon = card.icon; const Icon = card.icon;
const isBookings = card.href === "/admin/bookings";
return ( return (
<Link <Link
key={card.href} key={card.href}
@@ -46,9 +120,14 @@ export default function AdminDashboard() {
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gold/10 text-gold"> <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gold/10 text-gold">
<Icon size={20} /> <Icon size={20} />
</div> </div>
<div> <div className="flex-1 min-w-0">
<h2 className="font-medium text-white group-hover:text-gold transition-colors"> <h2 className="font-medium text-white group-hover:text-gold transition-colors flex items-center gap-2">
{card.label} {card.label}
{isBookings && counts && counts.total > 0 && (
<span className="rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
{counts.total}
</span>
)}
</h2> </h2>
<p className="text-xs text-neutral-500">{card.desc}</p> <p className="text-xs text-neutral-500">{card.desc}</p>
</div> </div>

View File

@@ -19,6 +19,7 @@ interface PricingData {
rentalTitle: string; rentalTitle: string;
rentalItems: { name: string; price: string; note?: string }[]; rentalItems: { name: string; price: string; note?: string }[];
rules: string[]; rules: string[];
showContactHint?: boolean;
} }
function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) { 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 })} onChange={(v) => update({ ...data, subtitle: v })}
/> />
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
<button
type="button"
role="switch"
aria-checked={data.showContactHint !== false}
onClick={() => update({ ...data, showContactHint: data.showContactHint === false })}
className={`relative h-5 w-9 rounded-full transition-colors ${
data.showContactHint !== false ? "bg-gold" : "bg-neutral-600"
}`}
>
<span
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
data.showContactHint !== false ? "translate-x-4" : ""
}`}
/>
</button>
<span className="text-sm text-neutral-400">Показывать контакты для записи (Instagram, Telegram, телефон)</span>
</label>
{/* Featured selector */} {/* Featured selector */}
{(() => { {(() => {
const itemOptions = data.items const itemOptions = data.items

View File

@@ -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 });
}

View File

@@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from "next/server"; 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) { export async function GET(request: NextRequest) {
const title = request.nextUrl.searchParams.get("title"); const title = request.nextUrl.searchParams.get("title");
if (!title) { if (title) {
return NextResponse.json({ error: "title parameter is required" }, { status: 400 }); return NextResponse.json(getMcRegistrations(title));
} }
const registrations = getMcRegistrations(title); // No title = return all registrations
return NextResponse.json(registrations); return NextResponse.json(getAllMcRegistrations());
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -27,6 +27,21 @@ export async function POST(request: NextRequest) {
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
const body = await request.json(); 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; const { id, name, instagram, telegram } = body;
if (!id || !name || !instagram) { if (!id || !name || !instagram) {
return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 }); return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 });

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -0,0 +1,6 @@
import { NextResponse } from "next/server";
import { getUnreadBookingCounts } from "@/lib/db";
export async function GET() {
return NextResponse.json(getUnreadBookingCounts());
}

View File

@@ -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 });
}
}

View File

@@ -4,7 +4,7 @@ import { addMcRegistration } from "@/lib/db";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); 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) { if (!masterClassTitle || typeof masterClassTitle !== "string" || masterClassTitle.length > 200) {
return NextResponse.json({ error: "masterClassTitle is required" }, { status: 400 }); 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) { if (!name || typeof name !== "string" || !name.trim() || name.length > 100) {
return NextResponse.json({ error: "name is required (max 100 chars)" }, { status: 400 }); return NextResponse.json({ error: "name is required (max 100 chars)" }, { status: 400 });
} }
if (!instagram || typeof instagram !== "string" || !instagram.trim() || instagram.length > 100) { if (!phone || typeof phone !== "string" || !phone.trim()) {
return NextResponse.json({ error: "Instagram аккаунт обязателен" }, { status: 400 }); return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
}
if (telegram && (typeof telegram !== "string" || telegram.length > 100)) {
return NextResponse.json({ error: "Telegram too long" }, { 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( const id = addMcRegistration(
masterClassTitle.trim().slice(0, 200), masterClassTitle.trim().slice(0, 200),
name.trim().slice(0, 100), name.trim().slice(0, 100),
instagram.trim().slice(0, 100), cleanIg,
telegram && typeof telegram === "string" ? telegram.trim().slice(0, 100) : undefined cleanTg,
cleanPhone
); );
return NextResponse.json({ ok: true, id }); return NextResponse.json({ ok: true, id });

View File

@@ -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 });
}
}

View File

@@ -12,15 +12,19 @@ import { BackToTop } from "@/components/ui/BackToTop";
import { Header } from "@/components/layout/Header"; import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import { getContent } from "@/lib/content"; import { getContent } from "@/lib/content";
import { OpenDay } from "@/components/sections/OpenDay";
import { getActiveOpenDay } from "@/lib/openDay";
export default function HomePage() { export default function HomePage() {
const content = getContent(); const content = getContent();
const openDayData = getActiveOpenDay();
return ( return (
<> <>
<Header /> <Header />
<main> <main>
<Hero data={content.hero} /> <Hero data={content.hero} />
{openDayData && <OpenDay data={openDayData} />}
<About <About
data={content.about} data={content.about}
stats={{ stats={{

View File

@@ -324,6 +324,21 @@
z-index: 1; z-index: 1;
} }
/* ===== Notification Pulse ===== */
@keyframes pulse-urgent {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
}
50% {
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
}
}
.pulse-urgent {
animation: pulse-urgent 1.5s ease-in-out infinite;
}
/* ===== Section Divider ===== */ /* ===== Section Divider ===== */
.section-divider { .section-divider {

View File

@@ -6,7 +6,7 @@ import { useState, useEffect } from "react";
import { BRAND, NAV_LINKS } from "@/lib/constants"; import { BRAND, NAV_LINKS } from "@/lib/constants";
import { UI_CONFIG } from "@/lib/config"; import { UI_CONFIG } from "@/lib/config";
import { HeroLogo } from "@/components/ui/HeroLogo"; import { HeroLogo } from "@/components/ui/HeroLogo";
import { BookingModal } from "@/components/ui/BookingModal"; import { SignupModal } from "@/components/ui/SignupModal";
export function Header() { export function Header() {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@@ -182,7 +182,7 @@ export function Header() {
Записаться Записаться
</button> </button>
<BookingModal open={bookingOpen} onClose={() => setBookingOpen(false)} /> <SignupModal open={bookingOpen} onClose={() => setBookingOpen(false)} endpoint="/api/group-booking" />
</header> </header>
); );
} }

View File

@@ -5,7 +5,7 @@ import Image from "next/image";
import { Calendar, Clock, User, MapPin, Instagram } from "lucide-react"; import { Calendar, Clock, User, MapPin, Instagram } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; 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"; import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
interface MasterClassesProps { interface MasterClassesProps {
@@ -236,10 +236,12 @@ export function MasterClasses({ data }: MasterClassesProps) {
)} )}
</div> </div>
<MasterClassSignupModal <SignupModal
open={signupTitle !== null} open={signupTitle !== null}
onClose={() => setSignupTitle(null)} onClose={() => setSignupTitle(null)}
masterClassTitle={signupTitle ?? ""} subtitle={signupTitle ?? ""}
endpoint="/api/master-class-register"
extraBody={{ masterClassTitle: signupTitle }}
successMessage={data.successMessage} successMessage={data.successMessage}
/> />
</section> </section>

View File

@@ -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<string, OpenDayClass[]> = {};
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 (
<section id="open-day" className="py-10 sm:py-14">
<div className="mx-auto max-w-6xl px-4">
<Reveal>
<SectionHeading>{event.title}</SectionHeading>
</Reveal>
<Reveal>
<div className="mt-4 text-center">
<div className="inline-flex items-center gap-2 rounded-full bg-gold/10 border border-gold/20 px-5 py-2.5 text-sm font-medium text-gold">
<Calendar size={16} />
{formatDateRu(event.date)}
</div>
</div>
</Reveal>
{/* Pricing info */}
<Reveal>
<div className="mt-6 text-center space-y-1">
<p className="text-lg font-semibold text-white">
{event.pricePerClass} BYN <span className="text-neutral-400 font-normal text-sm">за занятие</span>
</p>
<p className="text-sm text-gold">
<Sparkles size={12} className="inline mr-1" />
От {event.discountThreshold} занятий {event.discountPrice} BYN за каждое!
</p>
</div>
</Reveal>
{event.description && (
<Reveal>
<p className="mt-4 text-center text-sm text-neutral-400 max-w-2xl mx-auto">
{event.description}
</p>
</Reveal>
)}
{/* Schedule Grid */}
<div className="mt-8">
{halls.length === 1 ? (
// Single hall — simple list
<Reveal>
<div className="max-w-lg mx-auto space-y-3">
<h3 className="text-sm font-medium text-neutral-400 text-center">{halls[0]}</h3>
{hallGroups[halls[0]].map((cls) => (
<ClassCard
key={cls.id}
cls={cls}
onSignup={setSignup}
/>
))}
</div>
</Reveal>
) : (
// Multiple halls — columns
<div className={`grid gap-6 ${halls.length === 2 ? "sm:grid-cols-2" : "sm:grid-cols-2 lg:grid-cols-3"}`}>
{halls.map((hall) => (
<Reveal key={hall}>
<div>
<h3 className="text-sm font-medium text-neutral-400 mb-3 text-center">{hall}</h3>
<div className="space-y-3">
{hallGroups[hall].map((cls) => (
<ClassCard
key={cls.id}
cls={cls}
onSignup={setSignup}
/>
))}
</div>
</div>
</Reveal>
))}
</div>
)}
</div>
</div>
{signup && (
<SignupModal
open
onClose={() => setSignup(null)}
subtitle={signup.label}
endpoint="/api/open-day-register"
extraBody={{ classId: signup.classId, eventId: event.id }}
/>
)}
</section>
);
}
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 (
<div className="rounded-xl border border-white/5 bg-neutral-900/30 p-4 opacity-50">
<div className="flex items-center justify-between">
<div>
<span className="text-xs text-neutral-500">{cls.startTime}{cls.endTime}</span>
<p className="text-sm text-neutral-500 line-through">{cls.style}</p>
<p className="text-xs text-neutral-600">{cls.trainer}</p>
</div>
<span className="text-xs text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">
Отменено
</span>
</div>
</div>
);
}
return (
<div className="rounded-xl border border-white/10 bg-neutral-900 p-4 transition-all hover:border-gold/20">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<span className="text-xs text-gold font-medium">{cls.startTime}{cls.endTime}</span>
<p className="text-sm font-medium text-white mt-0.5">{cls.style}</p>
<p className="text-xs text-neutral-400 flex items-center gap-1 mt-0.5">
<Users size={10} />
{cls.trainer}
</p>
</div>
<button
onClick={() => onSignup({ classId: cls.id, label })}
className="shrink-0 rounded-full bg-gold/10 border border-gold/20 px-4 py-2 text-xs font-medium text-gold hover:bg-gold/20 transition-colors cursor-pointer"
>
Записаться
</button>
</div>
</div>
);
}

View File

@@ -1,10 +1,10 @@
"use client"; "use client";
import { useState } from "react"; 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 { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { BookingModal } from "@/components/ui/BookingModal"; import { BRAND } from "@/lib/constants";
import type { SiteContent } from "@/types/content"; import type { SiteContent } from "@/types/content";
type Tab = "prices" | "rental" | "rules"; type Tab = "prices" | "rental" | "rules";
@@ -13,9 +13,42 @@ interface PricingProps {
data: SiteContent["pricing"]; data: SiteContent["pricing"];
} }
function ContactHint() {
return (
<div className="mt-5 flex flex-wrap items-center justify-center gap-3 text-xs text-neutral-500">
<span>Для записи и бронирования:</span>
<a
href={BRAND.instagram}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-white/[0.06] px-3 py-1.5 text-pink-400 hover:text-pink-300 hover:border-pink-400/30 transition-colors"
>
<Instagram size={12} />
Instagram
</a>
<a
href="https://t.me/blackheartdancehouse"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-white/[0.06] px-3 py-1.5 text-blue-400 hover:text-blue-300 hover:border-blue-400/30 transition-colors"
>
<Send size={12} />
Telegram
</a>
<a
href="tel:+375293897001"
className="inline-flex items-center gap-1 rounded-full border border-white/[0.06] px-3 py-1.5 text-emerald-400 hover:text-emerald-300 hover:border-emerald-400/30 transition-colors"
>
<Phone size={12} />
Позвонить
</a>
</div>
);
}
export function Pricing({ data: pricing }: PricingProps) { export function Pricing({ data: pricing }: PricingProps) {
const [activeTab, setActiveTab] = useState<Tab>("prices"); const [activeTab, setActiveTab] = useState<Tab>("prices");
const [bookingOpen, setBookingOpen] = useState(false); const showHint = pricing.showContactHint !== false; // default true
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "prices", label: "Абонементы", icon: <CreditCard size={16} /> }, { id: "prices", label: "Абонементы", icon: <CreditCard size={16} /> },
@@ -68,13 +101,12 @@ export function Pricing({ data: pricing }: PricingProps) {
{regularItems.map((item, i) => { {regularItems.map((item, i) => {
const isPopular = item.popular ?? false; const isPopular = item.popular ?? false;
return ( return (
<button <div
key={i} key={i}
onClick={() => setBookingOpen(true)} className={`group relative rounded-2xl border p-5 transition-all duration-300 ${
className={`group relative cursor-pointer rounded-2xl border p-5 transition-all duration-300 text-left ${
isPopular isPopular
? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10 hover:shadow-xl hover:shadow-gold/20" ? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10"
: "border-neutral-200 bg-white hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]" : "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a]"
}`} }`}
> >
{/* Popular badge */} {/* Popular badge */}
@@ -105,14 +137,14 @@ export function Pricing({ data: pricing }: PricingProps) {
{item.price} {item.price}
</p> </p>
</div> </div>
</button> </div>
); );
})} })}
</div> </div>
{/* Featured — big card */} {/* Featured — big card */}
{featuredItem && ( {featuredItem && (
<button onClick={() => setBookingOpen(true)} className="mt-6 w-full cursor-pointer text-left team-card-glitter rounded-2xl border border-gold/30 bg-gradient-to-r from-gold/10 via-gold/5 to-gold/10 dark:from-gold/[0.06] dark:via-transparent dark:to-gold/[0.06] p-6 sm:p-8 transition-shadow duration-300 hover:shadow-xl hover:shadow-gold/20"> <div className="mt-6 w-full team-card-glitter rounded-2xl border border-gold/30 bg-gradient-to-r from-gold/10 via-gold/5 to-gold/10 dark:from-gold/[0.06] dark:via-transparent dark:to-gold/[0.06] p-6 sm:p-8">
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between"> <div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
<div className="text-center sm:text-left"> <div className="text-center sm:text-left">
<div className="flex items-center justify-center gap-2 sm:justify-start"> <div className="flex items-center justify-center gap-2 sm:justify-start">
@@ -131,8 +163,10 @@ export function Pricing({ data: pricing }: PricingProps) {
{featuredItem.price} {featuredItem.price}
</p> </p>
</div> </div>
</button> </div>
)} )}
{showHint && <ContactHint />}
</div> </div>
</Reveal> </Reveal>
)} )}
@@ -142,10 +176,9 @@ export function Pricing({ data: pricing }: PricingProps) {
<Reveal> <Reveal>
<div className="mx-auto mt-10 max-w-2xl space-y-3"> <div className="mx-auto mt-10 max-w-2xl space-y-3">
{pricing.rentalItems.map((item, i) => ( {pricing.rentalItems.map((item, i) => (
<button <div
key={i} key={i}
onClick={() => setBookingOpen(true)} className="flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
className="w-full cursor-pointer text-left flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 transition-colors hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]"
> >
<div> <div>
<p className="font-medium text-neutral-900 dark:text-white"> <p className="font-medium text-neutral-900 dark:text-white">
@@ -160,8 +193,10 @@ export function Pricing({ data: pricing }: PricingProps) {
<span className="shrink-0 font-display text-xl font-bold text-gold-dark dark:text-gold-light"> <span className="shrink-0 font-display text-xl font-bold text-gold-dark dark:text-gold-light">
{item.price} {item.price}
</span> </span>
</button> </div>
))} ))}
{showHint && <ContactHint />}
</div> </div>
</Reveal> </Reveal>
)} )}
@@ -187,8 +222,6 @@ export function Pricing({ data: pricing }: PricingProps) {
</Reveal> </Reveal>
)} )}
</div> </div>
<BookingModal open={bookingOpen} onClose={() => setBookingOpen(false)} />
</section> </section>
); );
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useMemo, useCallback } from "react"; import { useState, useMemo, useCallback } from "react";
import { BookingModal } from "@/components/ui/BookingModal"; import { SignupModal } from "@/components/ui/SignupModal";
import { CalendarDays, Users, LayoutGrid } from "lucide-react"; import { CalendarDays, Users, LayoutGrid } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
@@ -335,10 +335,12 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
/> />
</Reveal> </Reveal>
)} )}
<BookingModal <SignupModal
open={bookingGroup !== null} open={bookingGroup !== null}
onClose={() => setBookingGroup(null)} onClose={() => setBookingGroup(null)}
groupInfo={bookingGroup ?? undefined} subtitle={bookingGroup ?? undefined}
endpoint="/api/group-booking"
extraBody={{ groupInfo: bookingGroup }}
/> />
</section> </section>
); );

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
import Image from "next/image"; import Image from "next/image";
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Award, Scale, Clock, MapPin } from "lucide-react"; import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Award, Scale, Clock, MapPin } from "lucide-react";
import type { TeamMember, RichListItem, VictoryItem, ScheduleLocation } from "@/types/content"; import type { TeamMember, RichListItem, VictoryItem, ScheduleLocation } from "@/types/content";
import { BookingModal } from "@/components/ui/BookingModal"; import { SignupModal } from "@/components/ui/SignupModal";
interface TeamProfileProps { interface TeamProfileProps {
member: TeamMember; member: TeamMember;
@@ -329,10 +329,12 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
</div> </div>
)} )}
<BookingModal <SignupModal
open={bookingGroup !== null} open={bookingGroup !== null}
onClose={() => setBookingGroup(null)} onClose={() => setBookingGroup(null)}
groupInfo={bookingGroup ?? undefined} subtitle={bookingGroup ?? undefined}
endpoint="/api/group-booking"
extraBody={{ groupInfo: bookingGroup }}
/> />
</div> </div>
); );

View File

@@ -71,6 +71,12 @@ export function BookingModal({ open, onClose, groupInfo, contact: contactProp }:
const handleSubmit = useCallback( const handleSubmit = useCallback(
(e: React.FormEvent) => { (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// Save booking to DB (fire-and-forget)
fetch("/api/group-booking", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, phone, groupInfo }),
}).catch(() => {});
// Build Instagram DM message with pre-filled text // Build Instagram DM message with pre-filled text
const groupText = groupInfo ? ` (${groupInfo})` : ""; const groupText = groupInfo ? ` (${groupInfo})` : "";
const message = `Здравствуйте! Меня зовут ${name}, хочу записаться на занятие${groupText}. Мой телефон: ${phone}`; const message = `Здравствуйте! Меня зовут ${name}, хочу записаться на занятие${groupText}. Мой телефон: ${phone}`;

View File

@@ -0,0 +1,210 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { X, CheckCircle, Send, Phone as PhoneIcon } from "lucide-react";
interface OpenDaySignupModalProps {
open: boolean;
onClose: () => void;
classId: number;
eventId: number;
classLabel: string;
}
export function OpenDaySignupModal({ open, onClose, classId, eventId, classLabel }: OpenDaySignupModalProps) {
const [name, setName] = useState("");
const [phone, setPhone] = useState("+375 ");
const [instagram, setInstagram] = useState("");
const [telegram, setTelegram] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState<{ totalBookings: number; pricePerClass: number } | null>(null);
function handlePhoneChange(raw: string) {
let digits = raw.replace(/\D/g, "");
if (!digits.startsWith("375")) {
digits = "375" + digits.replace(/^375?/, "");
}
digits = digits.slice(0, 12);
let formatted = "+375";
const rest = digits.slice(3);
if (rest.length > 0) formatted += " (" + rest.slice(0, 2);
if (rest.length >= 2) formatted += ") ";
if (rest.length > 2) formatted += rest.slice(2, 5);
if (rest.length > 5) formatted += "-" + rest.slice(5, 7);
if (rest.length > 7) formatted += "-" + rest.slice(7, 9);
setPhone(formatted);
}
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
useEffect(() => {
if (open) document.body.style.overflow = "hidden";
else document.body.style.overflow = "";
return () => { document.body.style.overflow = ""; };
}, [open]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setSubmitting(true);
const cleanPhone = phone.replace(/\D/g, "");
if (cleanPhone.length < 12) {
setError("Введите корректный номер телефона");
setSubmitting(false);
return;
}
try {
const res = await fetch("/api/open-day-register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
classId,
eventId,
name: name.trim(),
phone: cleanPhone,
instagram: instagram.trim() ? `@${instagram.trim()}` : undefined,
telegram: telegram.trim() ? `@${telegram.trim()}` : undefined,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Ошибка при записи");
setSubmitting(false);
return;
}
setResult({ totalBookings: data.totalBookings, pricePerClass: data.pricePerClass });
} catch {
setError("Ошибка сети");
} finally {
setSubmitting(false);
}
}, [classId, eventId, name, phone, instagram, telegram]);
const handleClose = useCallback(() => {
onClose();
setTimeout(() => {
setName("");
setPhone("+375 ");
setInstagram("");
setTelegram("");
setError("");
setResult(null);
}, 300);
}, [onClose]);
if (!open) return null;
return createPortal(
<div className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div
className="modal-content relative w-full max-w-md rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 sm:p-8 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={handleClose}
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-white cursor-pointer"
>
<X size={18} />
</button>
{result ? (
<div className="py-4 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10">
<CheckCircle size={28} className="text-emerald-500" />
</div>
<h3 className="text-lg font-bold text-white">Вы записаны!</h3>
<p className="mt-2 text-sm text-neutral-400">{classLabel}</p>
<p className="mt-3 text-sm text-white">
Вы записаны на <span className="text-gold font-semibold">{result.totalBookings}</span> занятий.
<br />
Стоимость: <span className="text-gold font-semibold">{result.pricePerClass} BYN</span> за занятие
</p>
<button
onClick={handleClose}
className="mt-6 rounded-full bg-gold px-6 py-2.5 text-sm font-semibold text-black transition-all hover:bg-gold-light cursor-pointer"
>
Закрыть
</button>
</div>
) : (
<>
<div className="mb-6">
<h3 className="text-xl font-bold text-white">Записаться</h3>
<p className="mt-1 text-sm text-neutral-400">{classLabel}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ваше имя"
required
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
<div className="relative">
<PhoneIcon size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="tel"
value={phone}
onChange={(e) => handlePhoneChange(e.target.value)}
placeholder="+375 (__) ___-__-__"
required
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-9 pr-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
<input
type="text"
value={instagram}
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
placeholder="Instagram"
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-7 pr-3 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
</div>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
<input
type="text"
value={telegram}
onChange={(e) => setTelegram(e.target.value.replace(/^@/, ""))}
placeholder="Telegram"
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-7 pr-3 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
</div>
</div>
{error && (
<p className="text-sm text-red-400">{error}</p>
)}
<button
type="submit"
disabled={submitting}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gold py-3 text-sm font-semibold text-black transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/20 cursor-pointer disabled:opacity-50"
>
<Send size={15} />
{submitting ? "Записываем..." : "Записаться"}
</button>
</form>
</>
)}
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1,264 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { X, CheckCircle, Send, Phone as PhoneIcon, Instagram } from "lucide-react";
import { BRAND } from "@/lib/constants";
interface SignupModalProps {
open: boolean;
onClose: () => void;
title?: string;
subtitle?: string;
/** API endpoint to POST to */
endpoint: string;
/** Extra fields merged into the POST body (e.g. masterClassTitle, classId, eventId, groupInfo) */
extraBody?: Record<string, unknown>;
/** Custom success message */
successMessage?: string;
/** Callback with API response data on success */
onSuccess?: (data: Record<string, unknown>) => void;
}
export function SignupModal({
open,
onClose,
title = "Записаться",
subtitle,
endpoint,
extraBody,
successMessage,
onSuccess,
}: SignupModalProps) {
const [name, setName] = useState("");
const [phone, setPhone] = useState("+375 ");
const [instagram, setInstagram] = useState("");
const [telegram, setTelegram] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [successData, setSuccessData] = useState<Record<string, unknown> | null>(null);
function handlePhoneChange(raw: string) {
let digits = raw.replace(/\D/g, "");
if (!digits.startsWith("375")) {
digits = "375" + digits.replace(/^375?/, "");
}
digits = digits.slice(0, 12);
let formatted = "+375";
const rest = digits.slice(3);
if (rest.length > 0) formatted += " (" + rest.slice(0, 2);
if (rest.length >= 2) formatted += ") ";
if (rest.length > 2) formatted += rest.slice(2, 5);
if (rest.length > 5) formatted += "-" + rest.slice(5, 7);
if (rest.length > 7) formatted += "-" + rest.slice(7, 9);
setPhone(formatted);
}
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
useEffect(() => {
if (open) document.body.style.overflow = "hidden";
else document.body.style.overflow = "";
return () => { document.body.style.overflow = ""; };
}, [open]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setError("");
const cleanPhone = phone.replace(/\D/g, "");
if (cleanPhone.length < 12) {
setError("Введите корректный номер телефона");
return;
}
setSubmitting(true);
try {
const body: Record<string, unknown> = {
name: name.trim(),
phone: cleanPhone,
...extraBody,
};
if (instagram.trim()) body.instagram = `@${instagram.trim()}`;
if (telegram.trim()) body.telegram = `@${telegram.trim()}`;
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Ошибка при записи");
return;
}
setSuccess(true);
setSuccessData(data);
onSuccess?.(data);
} catch {
setError("network");
} finally {
setSubmitting(false);
}
}, [name, phone, instagram, telegram, endpoint, extraBody, onSuccess]);
const handleClose = useCallback(() => {
onClose();
setTimeout(() => {
setName("");
setPhone("+375 ");
setInstagram("");
setTelegram("");
setError("");
setSuccess(false);
setSuccessData(null);
}, 300);
}, [onClose]);
function openInstagramDM() {
const text = `Здравствуйте! Меня зовут ${name}. Хочу записаться${subtitle ? ` (${subtitle})` : ""}. Мой телефон: ${phone}`;
window.open(`https://ig.me/m/blackheartdancehouse?text=${encodeURIComponent(text)}`, "_blank");
handleClose();
}
if (!open) return null;
return createPortal(
<div className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div
className="modal-content relative w-full max-w-md rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 sm:p-8 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={handleClose}
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-white cursor-pointer"
>
<X size={18} />
</button>
{success ? (
<div className="py-4 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10">
<CheckCircle size={28} className="text-emerald-500" />
</div>
<h3 className="text-lg font-bold text-white">
{successMessage || "Вы записаны!"}
</h3>
{subtitle && <p className="mt-1 text-sm text-neutral-400">{subtitle}</p>}
{successData?.totalBookings !== undefined && (
<p className="mt-3 text-sm text-white">
Вы записаны на <span className="text-gold font-semibold">{String(successData.totalBookings)}</span> занятий.
<br />
Стоимость: <span className="text-gold font-semibold">{String(successData.pricePerClass)} BYN</span> за занятие
</p>
)}
<button
onClick={handleClose}
className="mt-6 rounded-full bg-gold px-6 py-2.5 text-sm font-semibold text-black transition-all hover:bg-gold-light cursor-pointer"
>
Закрыть
</button>
</div>
) : error === "network" ? (
/* Network error — fallback to Instagram DM */
<div className="py-4 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-amber-500/10">
<Instagram size={28} className="text-amber-400" />
</div>
<h3 className="text-lg font-bold text-white">Что-то пошло не так</h3>
<p className="mt-2 text-sm text-neutral-400">
Не удалось отправить заявку. Свяжитесь с нами через Instagram мы запишем вас!
</p>
<button
onClick={openInstagramDM}
className="mt-5 flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 to-pink-500 py-3 text-sm font-semibold text-white transition-all hover:opacity-90 cursor-pointer"
>
<Instagram size={16} />
Написать в Instagram
</button>
<button
onClick={() => setError("")}
className="mt-2 text-xs text-neutral-500 hover:text-white transition-colors cursor-pointer"
>
Попробовать снова
</button>
</div>
) : (
<>
<div className="mb-6">
<h3 className="text-xl font-bold text-white">{title}</h3>
{subtitle && <p className="mt-1 text-sm text-neutral-400">{subtitle}</p>}
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ваше имя"
required
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
<div className="relative">
<PhoneIcon size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="tel"
value={phone}
onChange={(e) => handlePhoneChange(e.target.value)}
placeholder="+375 (__) ___-__-__"
required
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-9 pr-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
<input
type="text"
value={instagram}
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
placeholder="Instagram"
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-7 pr-3 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
</div>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
<input
type="text"
value={telegram}
onChange={(e) => setTelegram(e.target.value.replace(/^@/, ""))}
placeholder="Telegram"
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-7 pr-3 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
</div>
</div>
{error && error !== "network" && (
<p className="text-sm text-red-400">{error}</p>
)}
<button
type="submit"
disabled={submitting}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gold py-3 text-sm font-semibold text-black transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/20 cursor-pointer disabled:opacity-50"
>
<Send size={15} />
{submitting ? "Записываем..." : "Записаться"}
</button>
</form>
</>
)}
</div>
</div>,
document.body
);
}

View File

@@ -80,6 +80,97 @@ const migrations: Migration[] = [
`); `);
}, },
}, },
{
version: 4,
name: "add_mc_notification_tracking",
up: (db) => {
const cols = db.prepare("PRAGMA table_info(mc_registrations)").all() as { name: string }[];
const colNames = new Set(cols.map((c) => c.name));
if (!colNames.has("notified_confirm"))
db.exec("ALTER TABLE mc_registrations ADD COLUMN notified_confirm INTEGER NOT NULL DEFAULT 0");
if (!colNames.has("notified_reminder"))
db.exec("ALTER TABLE mc_registrations ADD COLUMN notified_reminder INTEGER NOT NULL DEFAULT 0");
},
},
{
version: 5,
name: "create_group_bookings",
up: (db) => {
db.exec(`
CREATE TABLE IF NOT EXISTS group_bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT NOT NULL,
group_info TEXT,
notified_confirm INTEGER NOT NULL DEFAULT 0,
notified_reminder INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
`);
},
},
{
version: 6,
name: "create_open_day_tables",
up: (db) => {
db.exec(`
CREATE TABLE IF NOT EXISTS open_day_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'День открытых дверей',
description TEXT,
price_per_class INTEGER NOT NULL DEFAULT 30,
discount_price INTEGER NOT NULL DEFAULT 20,
discount_threshold INTEGER NOT NULL DEFAULT 3,
min_bookings INTEGER NOT NULL DEFAULT 4,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS open_day_classes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES open_day_events(id) ON DELETE CASCADE,
hall TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
trainer TEXT NOT NULL,
style TEXT NOT NULL,
cancelled INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE(event_id, hall, start_time)
);
CREATE TABLE IF NOT EXISTS open_day_bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id INTEGER NOT NULL REFERENCES open_day_classes(id) ON DELETE CASCADE,
event_id INTEGER NOT NULL REFERENCES open_day_events(id) ON DELETE CASCADE,
name TEXT NOT NULL,
phone TEXT NOT NULL,
instagram TEXT,
telegram TEXT,
notified_confirm INTEGER NOT NULL DEFAULT 0,
notified_reminder INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(class_id, phone)
);
`);
},
},
{
version: 7,
name: "unify_booking_fields",
up: (db) => {
// Add phone to mc_registrations
const mcCols = db.prepare("PRAGMA table_info(mc_registrations)").all() as { name: string }[];
const mcColNames = new Set(mcCols.map((c) => c.name));
if (!mcColNames.has("phone")) db.exec("ALTER TABLE mc_registrations ADD COLUMN phone TEXT");
// Add instagram/telegram to group_bookings
const gbCols = db.prepare("PRAGMA table_info(group_bookings)").all() as { name: string }[];
const gbColNames = new Set(gbCols.map((c) => c.name));
if (!gbColNames.has("instagram")) db.exec("ALTER TABLE group_bookings ADD COLUMN instagram TEXT");
if (!gbColNames.has("telegram")) db.exec("ALTER TABLE group_bookings ADD COLUMN telegram TEXT");
},
},
]; ];
function runMigrations(db: Database.Database) { function runMigrations(db: Database.Database) {
@@ -350,8 +441,11 @@ interface McRegistrationRow {
master_class_title: string; master_class_title: string;
name: string; name: string;
instagram: string; instagram: string;
phone: string | null;
telegram: string | null; telegram: string | null;
created_at: string; created_at: string;
notified_confirm: number;
notified_reminder: number;
} }
export interface McRegistration { export interface McRegistration {
@@ -359,23 +453,27 @@ export interface McRegistration {
masterClassTitle: string; masterClassTitle: string;
name: string; name: string;
instagram: string; instagram: string;
phone?: string;
telegram?: string; telegram?: string;
createdAt: string; createdAt: string;
notifiedConfirm: boolean;
notifiedReminder: boolean;
} }
export function addMcRegistration( export function addMcRegistration(
masterClassTitle: string, masterClassTitle: string,
name: string, name: string,
instagram: string, instagram: string,
telegram?: string telegram?: string,
phone?: string
): number { ): number {
const db = getDb(); const db = getDb();
const result = db const result = db
.prepare( .prepare(
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram) `INSERT INTO mc_registrations (master_class_title, name, instagram, telegram, phone)
VALUES (?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?)`
) )
.run(masterClassTitle, name, instagram, telegram || null); .run(masterClassTitle, name, instagram, telegram || null, phone || null);
return result.lastInsertRowid as number; return result.lastInsertRowid as number;
} }
@@ -386,14 +484,29 @@ export function getMcRegistrations(masterClassTitle: string): McRegistration[] {
"SELECT * FROM mc_registrations WHERE master_class_title = ? ORDER BY created_at DESC" "SELECT * FROM mc_registrations WHERE master_class_title = ? ORDER BY created_at DESC"
) )
.all(masterClassTitle) as McRegistrationRow[]; .all(masterClassTitle) as McRegistrationRow[];
return rows.map((r) => ({ return rows.map(mapMcRow);
}
export function getAllMcRegistrations(): McRegistration[] {
const db = getDb();
const rows = db
.prepare("SELECT * FROM mc_registrations ORDER BY created_at DESC")
.all() as McRegistrationRow[];
return rows.map(mapMcRow);
}
function mapMcRow(r: McRegistrationRow): McRegistration {
return {
id: r.id, id: r.id,
masterClassTitle: r.master_class_title, masterClassTitle: r.master_class_title,
name: r.name, name: r.name,
instagram: r.instagram, instagram: r.instagram,
phone: r.phone ?? undefined,
telegram: r.telegram ?? undefined, telegram: r.telegram ?? undefined,
createdAt: r.created_at, createdAt: r.created_at,
})); notifiedConfirm: !!r.notified_confirm,
notifiedReminder: !!r.notified_reminder,
};
} }
export function updateMcRegistration( export function updateMcRegistration(
@@ -408,9 +521,484 @@ export function updateMcRegistration(
).run(name, instagram, telegram || null, id); ).run(name, instagram, telegram || null, id);
} }
export function toggleMcNotification(
id: number,
field: "notified_confirm" | "notified_reminder",
value: boolean
): void {
const db = getDb();
db.prepare(
`UPDATE mc_registrations SET ${field} = ? WHERE id = ?`
).run(value ? 1 : 0, id);
}
export function deleteMcRegistration(id: number): void { export function deleteMcRegistration(id: number): void {
const db = getDb(); const db = getDb();
db.prepare("DELETE FROM mc_registrations WHERE id = ?").run(id); db.prepare("DELETE FROM mc_registrations WHERE id = ?").run(id);
} }
// --- Group Bookings ---
interface GroupBookingRow {
id: number;
name: string;
phone: string;
group_info: string | null;
instagram: string | null;
telegram: string | null;
notified_confirm: number;
notified_reminder: number;
created_at: string;
}
export interface GroupBooking {
id: number;
name: string;
phone: string;
groupInfo?: string;
instagram?: string;
telegram?: string;
notifiedConfirm: boolean;
notifiedReminder: boolean;
createdAt: string;
}
export function addGroupBooking(
name: string,
phone: string,
groupInfo?: string,
instagram?: string,
telegram?: string
): number {
const db = getDb();
const result = db
.prepare(
"INSERT INTO group_bookings (name, phone, group_info, instagram, telegram) VALUES (?, ?, ?, ?, ?)"
)
.run(name, phone, groupInfo || null, instagram || null, telegram || null);
return result.lastInsertRowid as number;
}
export function getGroupBookings(): GroupBooking[] {
const db = getDb();
const rows = db
.prepare("SELECT * FROM group_bookings ORDER BY created_at DESC")
.all() as GroupBookingRow[];
return rows.map((r) => ({
id: r.id,
name: r.name,
phone: r.phone,
groupInfo: r.group_info ?? undefined,
instagram: r.instagram ?? undefined,
telegram: r.telegram ?? undefined,
notifiedConfirm: !!r.notified_confirm,
notifiedReminder: !!r.notified_reminder,
createdAt: r.created_at,
}));
}
export function updateGroupBooking(
id: number,
name: string,
phone: string,
groupInfo?: string
): void {
const db = getDb();
db.prepare(
"UPDATE group_bookings SET name = ?, phone = ?, group_info = ? WHERE id = ?"
).run(name, phone, groupInfo || null, id);
}
export function toggleGroupBookingNotification(
id: number,
field: "notified_confirm" | "notified_reminder",
value: boolean
): void {
const db = getDb();
db.prepare(`UPDATE group_bookings SET ${field} = ? WHERE id = ?`).run(
value ? 1 : 0,
id
);
}
export function deleteGroupBooking(id: number): void {
const db = getDb();
db.prepare("DELETE FROM group_bookings WHERE id = ?").run(id);
}
// --- Unread booking counts (lightweight) ---
export interface UnreadCounts {
groupBookings: number;
mcRegistrations: number;
openDayBookings: number;
total: number;
}
export function getUnreadBookingCounts(): UnreadCounts {
const db = getDb();
const gb = (db.prepare("SELECT COUNT(*) as c FROM group_bookings WHERE notified_confirm = 0").get() as { c: number }).c;
const mc = (db.prepare("SELECT COUNT(*) as c FROM mc_registrations WHERE notified_confirm = 0").get() as { c: number }).c;
let od = 0;
// Check if open_day_bookings table exists (might not if no migration yet)
try {
od = (db.prepare("SELECT COUNT(*) as c FROM open_day_bookings WHERE notified_confirm = 0").get() as { c: number }).c;
} catch { /* table doesn't exist yet */ }
return { groupBookings: gb, mcRegistrations: mc, openDayBookings: od, total: gb + mc + od };
}
// --- Open Day Events ---
interface OpenDayEventRow {
id: number;
date: string;
title: string;
description: string | null;
price_per_class: number;
discount_price: number;
discount_threshold: number;
min_bookings: number;
active: number;
created_at: string;
updated_at: string;
}
export interface OpenDayEvent {
id: number;
date: string;
title: string;
description?: string;
pricePerClass: number;
discountPrice: number;
discountThreshold: number;
minBookings: number;
active: boolean;
}
interface OpenDayClassRow {
id: number;
event_id: number;
hall: string;
start_time: string;
end_time: string;
trainer: string;
style: string;
cancelled: number;
sort_order: number;
booking_count?: number;
}
export interface OpenDayClass {
id: number;
eventId: number;
hall: string;
startTime: string;
endTime: string;
trainer: string;
style: string;
cancelled: boolean;
sortOrder: number;
bookingCount: number;
}
interface OpenDayBookingRow {
id: number;
class_id: number;
event_id: number;
name: string;
phone: string;
instagram: string | null;
telegram: string | null;
notified_confirm: number;
notified_reminder: number;
created_at: string;
class_style?: string;
class_trainer?: string;
class_time?: string;
class_hall?: string;
}
export 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;
}
function mapEventRow(r: OpenDayEventRow): OpenDayEvent {
return {
id: r.id,
date: r.date,
title: r.title,
description: r.description ?? undefined,
pricePerClass: r.price_per_class,
discountPrice: r.discount_price,
discountThreshold: r.discount_threshold,
minBookings: r.min_bookings,
active: !!r.active,
};
}
function mapClassRow(r: OpenDayClassRow): OpenDayClass {
return {
id: r.id,
eventId: r.event_id,
hall: r.hall,
startTime: r.start_time,
endTime: r.end_time,
trainer: r.trainer,
style: r.style,
cancelled: !!r.cancelled,
sortOrder: r.sort_order,
bookingCount: r.booking_count ?? 0,
};
}
function mapBookingRow(r: OpenDayBookingRow): OpenDayBooking {
return {
id: r.id,
classId: r.class_id,
eventId: r.event_id,
name: r.name,
phone: r.phone,
instagram: r.instagram ?? undefined,
telegram: r.telegram ?? undefined,
notifiedConfirm: !!r.notified_confirm,
notifiedReminder: !!r.notified_reminder,
createdAt: r.created_at,
classStyle: r.class_style ?? undefined,
classTrainer: r.class_trainer ?? undefined,
classTime: r.class_time ?? undefined,
classHall: r.class_hall ?? undefined,
};
}
export function createOpenDayEvent(data: {
date: string;
title?: string;
description?: string;
pricePerClass?: number;
discountPrice?: number;
discountThreshold?: number;
minBookings?: number;
}): number {
const db = getDb();
const result = db
.prepare(
`INSERT INTO open_day_events (date, title, description, price_per_class, discount_price, discount_threshold, min_bookings)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(
data.date,
data.title || "День открытых дверей",
data.description || null,
data.pricePerClass ?? 30,
data.discountPrice ?? 20,
data.discountThreshold ?? 3,
data.minBookings ?? 4
);
return result.lastInsertRowid as number;
}
export function getOpenDayEvents(): OpenDayEvent[] {
const db = getDb();
const rows = db
.prepare("SELECT * FROM open_day_events ORDER BY date DESC")
.all() as OpenDayEventRow[];
return rows.map(mapEventRow);
}
export function getOpenDayEvent(id: number): OpenDayEvent | null {
const db = getDb();
const row = db
.prepare("SELECT * FROM open_day_events WHERE id = ?")
.get(id) as OpenDayEventRow | undefined;
return row ? mapEventRow(row) : null;
}
export function getActiveOpenDayEvent(): OpenDayEvent | null {
const db = getDb();
const row = db
.prepare(
"SELECT * FROM open_day_events WHERE active = 1 AND date >= date('now') ORDER BY date ASC LIMIT 1"
)
.get() as OpenDayEventRow | undefined;
return row ? mapEventRow(row) : null;
}
export function updateOpenDayEvent(
id: number,
data: Partial<{
date: string;
title: string;
description: string;
pricePerClass: number;
discountPrice: number;
discountThreshold: number;
minBookings: number;
active: boolean;
}>
): void {
const db = getDb();
const sets: string[] = [];
const vals: unknown[] = [];
if (data.date !== undefined) { sets.push("date = ?"); vals.push(data.date); }
if (data.title !== undefined) { sets.push("title = ?"); vals.push(data.title); }
if (data.description !== undefined) { sets.push("description = ?"); vals.push(data.description || null); }
if (data.pricePerClass !== undefined) { sets.push("price_per_class = ?"); vals.push(data.pricePerClass); }
if (data.discountPrice !== undefined) { sets.push("discount_price = ?"); vals.push(data.discountPrice); }
if (data.discountThreshold !== undefined) { sets.push("discount_threshold = ?"); vals.push(data.discountThreshold); }
if (data.minBookings !== undefined) { sets.push("min_bookings = ?"); vals.push(data.minBookings); }
if (data.active !== undefined) { sets.push("active = ?"); vals.push(data.active ? 1 : 0); }
if (sets.length === 0) return;
sets.push("updated_at = datetime('now')");
vals.push(id);
db.prepare(`UPDATE open_day_events SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
}
export function deleteOpenDayEvent(id: number): void {
const db = getDb();
db.prepare("DELETE FROM open_day_events WHERE id = ?").run(id);
}
// --- Open Day Classes ---
export function addOpenDayClass(
eventId: number,
data: { hall: string; startTime: string; endTime: string; trainer: string; style: string }
): number {
const db = getDb();
const maxOrder = (
db.prepare("SELECT MAX(sort_order) as m FROM open_day_classes WHERE event_id = ?").get(eventId) as { m: number | null }
).m ?? -1;
const result = db
.prepare(
`INSERT INTO open_day_classes (event_id, hall, start_time, end_time, trainer, style, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(eventId, data.hall, data.startTime, data.endTime, data.trainer, data.style, maxOrder + 1);
return result.lastInsertRowid as number;
}
export function getOpenDayClasses(eventId: number): OpenDayClass[] {
const db = getDb();
const rows = db
.prepare(
`SELECT c.*, COALESCE(b.cnt, 0) as booking_count
FROM open_day_classes c
LEFT JOIN (SELECT class_id, COUNT(*) as cnt FROM open_day_bookings GROUP BY class_id) b ON b.class_id = c.id
WHERE c.event_id = ?
ORDER BY c.hall, c.start_time`
)
.all(eventId) as OpenDayClassRow[];
return rows.map(mapClassRow);
}
export function updateOpenDayClass(
id: number,
data: Partial<{ hall: string; startTime: string; endTime: string; trainer: string; style: string; cancelled: boolean; sortOrder: number }>
): void {
const db = getDb();
const sets: string[] = [];
const vals: unknown[] = [];
if (data.hall !== undefined) { sets.push("hall = ?"); vals.push(data.hall); }
if (data.startTime !== undefined) { sets.push("start_time = ?"); vals.push(data.startTime); }
if (data.endTime !== undefined) { sets.push("end_time = ?"); vals.push(data.endTime); }
if (data.trainer !== undefined) { sets.push("trainer = ?"); vals.push(data.trainer); }
if (data.style !== undefined) { sets.push("style = ?"); vals.push(data.style); }
if (data.cancelled !== undefined) { sets.push("cancelled = ?"); vals.push(data.cancelled ? 1 : 0); }
if (data.sortOrder !== undefined) { sets.push("sort_order = ?"); vals.push(data.sortOrder); }
if (sets.length === 0) return;
vals.push(id);
db.prepare(`UPDATE open_day_classes SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
}
export function deleteOpenDayClass(id: number): void {
const db = getDb();
db.prepare("DELETE FROM open_day_classes WHERE id = ?").run(id);
}
// --- Open Day Bookings ---
export function addOpenDayBooking(
classId: number,
eventId: number,
data: { name: string; phone: string; instagram?: string; telegram?: string }
): number {
const db = getDb();
const result = db
.prepare(
`INSERT INTO open_day_bookings (class_id, event_id, name, phone, instagram, telegram)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(classId, eventId, data.name, data.phone, data.instagram || null, data.telegram || null);
return result.lastInsertRowid as number;
}
export function getOpenDayBookings(eventId: number): OpenDayBooking[] {
const db = getDb();
const rows = db
.prepare(
`SELECT b.*, c.style as class_style, c.trainer as class_trainer, c.start_time as class_time, c.hall as class_hall
FROM open_day_bookings b
JOIN open_day_classes c ON c.id = b.class_id
WHERE b.event_id = ?
ORDER BY b.created_at DESC`
)
.all(eventId) as OpenDayBookingRow[];
return rows.map(mapBookingRow);
}
export function getOpenDayBookingCountsByClass(eventId: number): Record<number, number> {
const db = getDb();
const rows = db
.prepare("SELECT class_id, COUNT(*) as cnt FROM open_day_bookings WHERE event_id = ? GROUP BY class_id")
.all(eventId) as { class_id: number; cnt: number }[];
const result: Record<number, number> = {};
for (const r of rows) result[r.class_id] = r.cnt;
return result;
}
export function getPersonOpenDayBookings(eventId: number, phone: string): number {
const db = getDb();
const row = db
.prepare("SELECT COUNT(*) as cnt FROM open_day_bookings WHERE event_id = ? AND phone = ?")
.get(eventId, phone) as { cnt: number };
return row.cnt;
}
export function toggleOpenDayNotification(
id: number,
field: "notified_confirm" | "notified_reminder",
value: boolean
): void {
const db = getDb();
db.prepare(`UPDATE open_day_bookings SET ${field} = ? WHERE id = ?`).run(value ? 1 : 0, id);
}
export function deleteOpenDayBooking(id: number): void {
const db = getDb();
db.prepare("DELETE FROM open_day_bookings WHERE id = ?").run(id);
}
export function isOpenDayClassBookedByPhone(classId: number, phone: string): boolean {
const db = getDb();
const row = db
.prepare("SELECT id FROM open_day_bookings WHERE class_id = ? AND phone = ? LIMIT 1")
.get(classId, phone) as { id: number } | undefined;
return !!row;
}
export { SECTION_KEYS }; export { SECTION_KEYS };

11
src/lib/openDay.ts Normal file
View File

@@ -0,0 +1,11 @@
import { getActiveOpenDayEvent, getOpenDayClasses } from "@/lib/db";
import type { OpenDayEvent, OpenDayClass } from "@/lib/db";
export type { OpenDayEvent, OpenDayClass };
export function getActiveOpenDay(): { event: OpenDayEvent; classes: OpenDayClass[] } | null {
const event = getActiveOpenDayEvent();
if (!event) return null;
const classes = getOpenDayClasses(event.id);
return { event, classes };
}

View File

@@ -139,6 +139,7 @@ export interface SiteContent {
rentalTitle: string; rentalTitle: string;
rentalItems: PricingItem[]; rentalItems: PricingItem[];
rules: string[]; rules: string[];
showContactHint?: boolean;
}; };
masterClasses: { masterClasses: {
title: string; title: string;