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:
40
CLAUDE.md
40
CLAUDE.md
@@ -25,23 +25,25 @@ Content language: Russian
|
||||
src/
|
||||
├── app/
|
||||
│ ├── layout.tsx # Root layout, fonts, metadata
|
||||
│ ├── page.tsx # Landing: Hero → About → Team → Classes → MasterClasses → Schedule → Pricing → News → FAQ → Contact
|
||||
│ ├── page.tsx # Landing: Hero → [OpenDay] → About → Team → Classes → MasterClasses → Schedule → Pricing → News → FAQ → Contact
|
||||
│ ├── globals.css # Tailwind imports
|
||||
│ ├── styles/
|
||||
│ │ ├── theme.css # Theme variables, semantic classes
|
||||
│ │ └── animations.css # Keyframes, scroll reveal, modal animations
|
||||
│ ├── admin/
|
||||
│ │ ├── page.tsx # Dashboard with 11 section cards
|
||||
│ │ ├── page.tsx # Dashboard with 13 section cards
|
||||
│ │ ├── login/ # Password auth
|
||||
│ │ ├── layout.tsx # Sidebar nav shell
|
||||
│ │ ├── _components/ # SectionEditor, FormField, ArrayEditor
|
||||
│ │ ├── layout.tsx # Sidebar nav shell (14 items)
|
||||
│ │ ├── _components/ # SectionEditor, FormField, ArrayEditor, NotifyToggle
|
||||
│ │ ├── meta/ # SEO editor
|
||||
│ │ ├── hero/ # Hero editor
|
||||
│ │ ├── about/ # About editor
|
||||
│ │ ├── team/ # Team list + [id] editor
|
||||
│ │ ├── classes/ # Classes editor with icon picker
|
||||
│ │ ├── master-classes/ # MC editor with registrations
|
||||
│ │ ├── master-classes/ # MC editor with registrations + notification toggles
|
||||
│ │ ├── open-day/ # Open Day event editor (settings + grid + bookings)
|
||||
│ │ ├── schedule/ # Schedule editor
|
||||
│ │ ├── bookings/ # Group booking management with notification toggles
|
||||
│ │ ├── pricing/ # Pricing editor
|
||||
│ │ ├── faq/ # FAQ editor
|
||||
│ │ ├── news/ # News editor
|
||||
@@ -55,9 +57,15 @@ src/
|
||||
│ │ ├── team/[id]/ # GET/PUT/DELETE single member
|
||||
│ │ ├── team/reorder/ # PUT reorder
|
||||
│ │ ├── upload/ # POST file upload (whitelisted folders)
|
||||
│ │ ├── mc-registrations/ # CRUD registrations
|
||||
│ │ ├── mc-registrations/ # CRUD registrations + notification toggle
|
||||
│ │ ├── group-bookings/ # CRUD group bookings + notification toggle
|
||||
│ │ ├── open-day/ # CRUD events
|
||||
│ │ ├── open-day/classes/ # CRUD event classes
|
||||
│ │ ├── open-day/bookings/ # CRUD event bookings + notification toggle
|
||||
│ │ └── validate-instagram/ # GET check username
|
||||
│ └── master-class-register/ # POST public signup
|
||||
│ ├── master-class-register/ # POST public MC signup
|
||||
│ ├── group-booking/ # POST public group booking
|
||||
│ └── open-day-register/ # POST public Open Day booking
|
||||
├── components/
|
||||
│ ├── layout/
|
||||
│ │ ├── Header.tsx # Sticky nav, mobile menu, booking modal ("use client")
|
||||
@@ -68,6 +76,7 @@ src/
|
||||
│ │ ├── Team.tsx # Carousel + profile view
|
||||
│ │ ├── Classes.tsx # Showcase layout with icon selector
|
||||
│ │ ├── MasterClasses.tsx # Cards with signup modal
|
||||
│ │ ├── OpenDay.tsx # Open Day schedule grid + booking (conditional)
|
||||
│ │ ├── Schedule.tsx # Day/group views with filters
|
||||
│ │ ├── Pricing.tsx # Tabs: prices, rental, rules
|
||||
│ │ ├── News.tsx # Featured + compact articles
|
||||
@@ -76,8 +85,9 @@ src/
|
||||
│ └── ui/
|
||||
│ ├── Button.tsx
|
||||
│ ├── SectionHeading.tsx
|
||||
│ ├── BookingModal.tsx # Booking form → Instagram DM
|
||||
│ ├── BookingModal.tsx # Booking form → Instagram DM + DB save
|
||||
│ ├── MasterClassSignupModal.tsx # MC registration form → API
|
||||
│ ├── OpenDaySignupModal.tsx # Open Day class booking → API
|
||||
│ ├── NewsModal.tsx # News detail popup
|
||||
│ ├── Reveal.tsx # Intersection Observer scroll reveal
|
||||
│ ├── BackToTop.tsx
|
||||
@@ -87,10 +97,11 @@ src/
|
||||
├── lib/
|
||||
│ ├── constants.ts # BRAND constants, NAV_LINKS
|
||||
│ ├── config.ts # UI_CONFIG (thresholds, counts)
|
||||
│ ├── db.ts # SQLite DB, migrations, CRUD
|
||||
│ ├── db.ts # SQLite DB, 6 migrations, CRUD for all tables
|
||||
│ ├── auth.ts # Token signing (Node.js)
|
||||
│ ├── auth-edge.ts # Token verification (Edge/Web Crypto)
|
||||
│ └── content.ts # getContent() — DB with fallback
|
||||
│ ├── content.ts # getContent() — DB with fallback
|
||||
│ └── openDay.ts # getActiveOpenDay() — server-side Open Day loader
|
||||
├── proxy.ts # Middleware: auth guard for /admin/*
|
||||
└── types/
|
||||
├── index.ts
|
||||
@@ -119,7 +130,10 @@ src/
|
||||
- Cookie: `bh-admin-token` (httpOnly, secure in prod)
|
||||
- Auto-save with 800ms debounce on all section editors
|
||||
- Team members: drag-reorder, photo upload, rich bio (experience, victories, education)
|
||||
- Master classes: slots, registration viewer, trainer/style autocomplete from existing data
|
||||
- Master classes: slots, registration viewer with notification tracking (confirm + reminder), trainer/style autocomplete
|
||||
- Group bookings: saved to DB from BookingModal, admin page at `/admin/bookings` with notification toggles
|
||||
- Open Day: event settings (date, pricing, discount rules, min bookings), schedule grid (halls × time slots), per-class booking with auto-cancel threshold, public section after Hero
|
||||
- Shared `NotifyToggle` component (`src/app/admin/_components/NotifyToggle.tsx`) used across MC registrations, group bookings, and Open Day bookings
|
||||
- File upload: whitelisted folders (`team`, `master-classes`, `news`, `classes`), max 5MB, image types only
|
||||
|
||||
## Security Notes
|
||||
@@ -128,6 +142,10 @@ src/
|
||||
- API routes validate: input types, string lengths, numeric IDs
|
||||
- Public MC registration: length-limited but **no rate limiting yet** (add before production)
|
||||
|
||||
## Upcoming Features
|
||||
- **Rate limiting** on public endpoints (`/api/master-class-register`, `/api/group-booking`, `/api/open-day-register`)
|
||||
- **DB backup mechanism** — automated/manual backup of `db/blackheart.db` with rotation
|
||||
|
||||
## AST Index
|
||||
- **Always use the AST index** at `memory/ast-index.md` when searching for components, props, hooks, types, or styles
|
||||
- Contains: component tree, all exports, props, hooks, client/server status, CSS classes, keyframes
|
||||
|
||||
71
src/app/admin/_components/NotifyToggle.tsx
Normal file
71
src/app/admin/_components/NotifyToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
597
src/app/admin/bookings/page.tsx
Normal file
597
src/app/admin/bookings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Sparkles,
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
Menu,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ClipboardList,
|
||||
DoorOpen,
|
||||
} from "lucide-react";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@@ -30,7 +33,9 @@ const NAV_ITEMS = [
|
||||
{ href: "/admin/team", label: "Команда", icon: Users },
|
||||
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
|
||||
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
|
||||
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen },
|
||||
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
|
||||
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
|
||||
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
|
||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
||||
{ href: "/admin/news", label: "Новости", icon: Newspaper },
|
||||
@@ -45,12 +50,27 @@ export default function AdminLayout({
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [unreadTotal, setUnreadTotal] = useState(0);
|
||||
|
||||
// Don't render admin shell on login page
|
||||
if (pathname === "/admin/login") {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Fetch unread counts — poll every 30s
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
function fetchCounts() {
|
||||
adminFetch("/api/admin/unread-counts")
|
||||
.then((r) => r.json())
|
||||
.then((data: { total: number }) => setUnreadTotal(data.total))
|
||||
.catch(() => {});
|
||||
}
|
||||
fetchCounts();
|
||||
const interval = setInterval(fetchCounts, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/logout", { method: "POST" });
|
||||
router.push("/admin/login");
|
||||
@@ -106,6 +126,11 @@ export default function AdminLayout({
|
||||
>
|
||||
<Icon size={18} />
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { SectionEditor } from "../_components/SectionEditor";
|
||||
import { InputField, TextareaField } from "../_components/FormField";
|
||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check, ChevronDown, ChevronUp, Instagram, Send, Trash2, Pencil } from "lucide-react";
|
||||
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
||||
|
||||
@@ -38,15 +38,6 @@ interface MasterClassesData {
|
||||
items: MasterClassItem[];
|
||||
}
|
||||
|
||||
interface McRegistration {
|
||||
id: number;
|
||||
masterClassTitle: string;
|
||||
name: string;
|
||||
instagram: string;
|
||||
telegram?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// --- Autocomplete Multi-Select ---
|
||||
function AutocompleteMulti({
|
||||
label,
|
||||
@@ -482,340 +473,6 @@ function ValidationHint({ fields }: { fields: Record<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 ---
|
||||
export default function MasterClassesEditorPage() {
|
||||
const [trainers, setTrainers] = useState<string[]>([]);
|
||||
@@ -952,7 +609,6 @@ export default function MasterClassesEditorPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<RegistrationsList title={item.title} />
|
||||
</div>
|
||||
)}
|
||||
createItem={() => ({
|
||||
|
||||
711
src/app/admin/open-day/page.tsx
Normal file
711
src/app/admin/open-day/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Globe,
|
||||
@@ -11,7 +14,18 @@ import {
|
||||
HelpCircle,
|
||||
Newspaper,
|
||||
Phone,
|
||||
ClipboardList,
|
||||
DoorOpen,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
|
||||
interface UnreadCounts {
|
||||
groupBookings: number;
|
||||
mcRegistrations: number;
|
||||
openDayBookings: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const CARDS = [
|
||||
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe, desc: "Заголовок и описание сайта" },
|
||||
@@ -20,22 +34,82 @@ const CARDS = [
|
||||
{ href: "/admin/team", label: "Команда", icon: Users, desc: "Тренеры и инструкторы" },
|
||||
{ href: "/admin/classes", label: "Направления", icon: BookOpen, desc: "Типы занятий" },
|
||||
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star, desc: "Мастер-классы и записи" },
|
||||
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen, desc: "Открытые занятия, расписание, записи" },
|
||||
{ href: "/admin/schedule", label: "Расписание", icon: Calendar, desc: "Расписание занятий" },
|
||||
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList, desc: "Все записи и заявки" },
|
||||
{ href: "/admin/pricing", label: "Цены", icon: DollarSign, desc: "Абонементы и аренда" },
|
||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, desc: "Часто задаваемые вопросы" },
|
||||
{ href: "/admin/news", label: "Новости", icon: Newspaper, desc: "Новости и анонсы" },
|
||||
{ href: "/admin/contact", label: "Контакты", icon: Phone, desc: "Адреса, телефон, карта" },
|
||||
];
|
||||
|
||||
function UnreadWidget({ counts }: { counts: UnreadCounts }) {
|
||||
if (counts.total === 0) return null;
|
||||
|
||||
const items: { label: string; count: number; tab: string }[] = [];
|
||||
if (counts.groupBookings > 0) items.push({ label: "Занятия", count: counts.groupBookings, tab: "classes" });
|
||||
if (counts.mcRegistrations > 0) items.push({ label: "Мастер-классы", count: counts.mcRegistrations, tab: "master-classes" });
|
||||
if (counts.openDayBookings > 0) items.push({ label: "День открытых дверей", count: counts.openDayBookings, tab: "open-day" });
|
||||
|
||||
return (
|
||||
<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() {
|
||||
const [counts, setCounts] = useState<UnreadCounts | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
adminFetch("/api/admin/unread-counts")
|
||||
.then((r) => r.json())
|
||||
.then((data: UnreadCounts) => setCounts(data))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Панель управления</h1>
|
||||
<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) => {
|
||||
const Icon = card.icon;
|
||||
const isBookings = card.href === "/admin/bookings";
|
||||
return (
|
||||
<Link
|
||||
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">
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-medium text-white group-hover:text-gold transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="font-medium text-white group-hover:text-gold transition-colors flex items-center gap-2">
|
||||
{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>
|
||||
<p className="text-xs text-neutral-500">{card.desc}</p>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ interface PricingData {
|
||||
rentalTitle: string;
|
||||
rentalItems: { name: string; price: string; note?: string }[];
|
||||
rules: string[];
|
||||
showContactHint?: boolean;
|
||||
}
|
||||
|
||||
function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||
@@ -63,6 +64,25 @@ export default function PricingEditorPage() {
|
||||
onChange={(v) => update({ ...data, subtitle: v })}
|
||||
/>
|
||||
|
||||
<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 */}
|
||||
{(() => {
|
||||
const itemOptions = data.items
|
||||
|
||||
40
src/app/api/admin/group-bookings/route.ts
Normal file
40
src/app/api/admin/group-bookings/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getMcRegistrations, addMcRegistration, updateMcRegistration, deleteMcRegistration } from "@/lib/db";
|
||||
import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration } from "@/lib/db";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const title = request.nextUrl.searchParams.get("title");
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: "title parameter is required" }, { status: 400 });
|
||||
if (title) {
|
||||
return NextResponse.json(getMcRegistrations(title));
|
||||
}
|
||||
const registrations = getMcRegistrations(title);
|
||||
return NextResponse.json(registrations);
|
||||
// No title = return all registrations
|
||||
return NextResponse.json(getAllMcRegistrations());
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -27,6 +27,21 @@ export async function POST(request: NextRequest) {
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Toggle notification status
|
||||
if (body.action === "toggle-notify") {
|
||||
const { id, field, value } = body;
|
||||
if (!id || !field || typeof value !== "boolean") {
|
||||
return NextResponse.json({ error: "id, field, value are required" }, { status: 400 });
|
||||
}
|
||||
if (field !== "notified_confirm" && field !== "notified_reminder") {
|
||||
return NextResponse.json({ error: "Invalid field" }, { status: 400 });
|
||||
}
|
||||
toggleMcNotification(id, field, value);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
// Regular update
|
||||
const { id, name, instagram, telegram } = body;
|
||||
if (!id || !name || !instagram) {
|
||||
return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 });
|
||||
|
||||
43
src/app/api/admin/open-day/bookings/route.ts
Normal file
43
src/app/api/admin/open-day/bookings/route.ts
Normal 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 });
|
||||
}
|
||||
54
src/app/api/admin/open-day/classes/route.ts
Normal file
54
src/app/api/admin/open-day/classes/route.ts
Normal 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 });
|
||||
}
|
||||
54
src/app/api/admin/open-day/route.ts
Normal file
54
src/app/api/admin/open-day/route.ts
Normal 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 });
|
||||
}
|
||||
6
src/app/api/admin/unread-counts/route.ts
Normal file
6
src/app/api/admin/unread-counts/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getUnreadBookingCounts } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(getUnreadBookingCounts());
|
||||
}
|
||||
24
src/app/api/group-booking/route.ts
Normal file
24
src/app/api/group-booking/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { addMcRegistration } from "@/lib/db";
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { masterClassTitle, name, instagram, telegram } = body;
|
||||
const { masterClassTitle, name, phone, instagram, telegram } = body;
|
||||
|
||||
if (!masterClassTitle || typeof masterClassTitle !== "string" || masterClassTitle.length > 200) {
|
||||
return NextResponse.json({ error: "masterClassTitle is required" }, { status: 400 });
|
||||
@@ -12,18 +12,20 @@ export async function POST(request: Request) {
|
||||
if (!name || typeof name !== "string" || !name.trim() || name.length > 100) {
|
||||
return NextResponse.json({ error: "name is required (max 100 chars)" }, { status: 400 });
|
||||
}
|
||||
if (!instagram || typeof instagram !== "string" || !instagram.trim() || instagram.length > 100) {
|
||||
return NextResponse.json({ error: "Instagram аккаунт обязателен" }, { status: 400 });
|
||||
}
|
||||
if (telegram && (typeof telegram !== "string" || telegram.length > 100)) {
|
||||
return NextResponse.json({ error: "Telegram too long" }, { status: 400 });
|
||||
if (!phone || typeof phone !== "string" || !phone.trim()) {
|
||||
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
||||
}
|
||||
|
||||
const cleanIg = instagram && typeof instagram === "string" ? instagram.trim().slice(0, 100) : "";
|
||||
const cleanTg = telegram && typeof telegram === "string" ? telegram.trim().slice(0, 100) : undefined;
|
||||
const cleanPhone = phone.trim().slice(0, 30);
|
||||
|
||||
const id = addMcRegistration(
|
||||
masterClassTitle.trim().slice(0, 200),
|
||||
name.trim().slice(0, 100),
|
||||
instagram.trim().slice(0, 100),
|
||||
telegram && typeof telegram === "string" ? telegram.trim().slice(0, 100) : undefined
|
||||
cleanIg,
|
||||
cleanTg,
|
||||
cleanPhone
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, id });
|
||||
|
||||
54
src/app/api/open-day-register/route.ts
Normal file
54
src/app/api/open-day-register/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,19 @@ import { BackToTop } from "@/components/ui/BackToTop";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { getContent } from "@/lib/content";
|
||||
import { OpenDay } from "@/components/sections/OpenDay";
|
||||
import { getActiveOpenDay } from "@/lib/openDay";
|
||||
|
||||
export default function HomePage() {
|
||||
const content = getContent();
|
||||
const openDayData = getActiveOpenDay();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<Hero data={content.hero} />
|
||||
{openDayData && <OpenDay data={openDayData} />}
|
||||
<About
|
||||
data={content.about}
|
||||
stats={{
|
||||
|
||||
@@ -324,6 +324,21 @@
|
||||
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 {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState, useEffect } from "react";
|
||||
import { BRAND, NAV_LINKS } from "@/lib/constants";
|
||||
import { UI_CONFIG } from "@/lib/config";
|
||||
import { HeroLogo } from "@/components/ui/HeroLogo";
|
||||
import { BookingModal } from "@/components/ui/BookingModal";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
|
||||
export function Header() {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
@@ -182,7 +182,7 @@ export function Header() {
|
||||
Записаться
|
||||
</button>
|
||||
|
||||
<BookingModal open={bookingOpen} onClose={() => setBookingOpen(false)} />
|
||||
<SignupModal open={bookingOpen} onClose={() => setBookingOpen(false)} endpoint="/api/group-booking" />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import Image from "next/image";
|
||||
import { Calendar, Clock, User, MapPin, Instagram } from "lucide-react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { MasterClassSignupModal } from "@/components/ui/MasterClassSignupModal";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
|
||||
|
||||
interface MasterClassesProps {
|
||||
@@ -236,10 +236,12 @@ export function MasterClasses({ data }: MasterClassesProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MasterClassSignupModal
|
||||
<SignupModal
|
||||
open={signupTitle !== null}
|
||||
onClose={() => setSignupTitle(null)}
|
||||
masterClassTitle={signupTitle ?? ""}
|
||||
subtitle={signupTitle ?? ""}
|
||||
endpoint="/api/master-class-register"
|
||||
extraBody={{ masterClassTitle: signupTitle }}
|
||||
successMessage={data.successMessage}
|
||||
/>
|
||||
</section>
|
||||
|
||||
184
src/components/sections/OpenDay.tsx
Normal file
184
src/components/sections/OpenDay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CreditCard, Building2, ScrollText, Crown, Sparkles } from "lucide-react";
|
||||
import { CreditCard, Building2, ScrollText, Crown, Sparkles, Instagram, Send, Phone } from "lucide-react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { BookingModal } from "@/components/ui/BookingModal";
|
||||
import { BRAND } from "@/lib/constants";
|
||||
import type { SiteContent } from "@/types/content";
|
||||
|
||||
type Tab = "prices" | "rental" | "rules";
|
||||
@@ -13,9 +13,42 @@ interface PricingProps {
|
||||
data: SiteContent["pricing"];
|
||||
}
|
||||
|
||||
function ContactHint() {
|
||||
return (
|
||||
<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) {
|
||||
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 }[] = [
|
||||
{ id: "prices", label: "Абонементы", icon: <CreditCard size={16} /> },
|
||||
@@ -68,13 +101,12 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
{regularItems.map((item, i) => {
|
||||
const isPopular = item.popular ?? false;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setBookingOpen(true)}
|
||||
className={`group relative cursor-pointer rounded-2xl border p-5 transition-all duration-300 text-left ${
|
||||
className={`group relative rounded-2xl border p-5 transition-all duration-300 ${
|
||||
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-neutral-200 bg-white hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]"
|
||||
? "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 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||
}`}
|
||||
>
|
||||
{/* Popular badge */}
|
||||
@@ -105,14 +137,14 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
{item.price}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Featured — big card */}
|
||||
{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="text-center sm:text-left">
|
||||
<div className="flex items-center justify-center gap-2 sm:justify-start">
|
||||
@@ -131,8 +163,10 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
{featuredItem.price}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showHint && <ContactHint />}
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
@@ -142,10 +176,9 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
<Reveal>
|
||||
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
||||
{pricing.rentalItems.map((item, i) => (
|
||||
<button
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setBookingOpen(true)}
|
||||
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]"
|
||||
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]"
|
||||
>
|
||||
<div>
|
||||
<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">
|
||||
{item.price}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showHint && <ContactHint />}
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
@@ -187,8 +222,6 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BookingModal open={bookingOpen} onClose={() => setBookingOpen(false)} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
@@ -335,10 +335,12 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
||||
/>
|
||||
</Reveal>
|
||||
)}
|
||||
<BookingModal
|
||||
<SignupModal
|
||||
open={bookingGroup !== null}
|
||||
onClose={() => setBookingGroup(null)}
|
||||
groupInfo={bookingGroup ?? undefined}
|
||||
subtitle={bookingGroup ?? undefined}
|
||||
endpoint="/api/group-booking"
|
||||
extraBody={{ groupInfo: bookingGroup }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Award, Scale, Clock, MapPin } from "lucide-react";
|
||||
import type { TeamMember, RichListItem, VictoryItem, ScheduleLocation } from "@/types/content";
|
||||
import { BookingModal } from "@/components/ui/BookingModal";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
|
||||
interface TeamProfileProps {
|
||||
member: TeamMember;
|
||||
@@ -329,10 +329,12 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BookingModal
|
||||
<SignupModal
|
||||
open={bookingGroup !== null}
|
||||
onClose={() => setBookingGroup(null)}
|
||||
groupInfo={bookingGroup ?? undefined}
|
||||
subtitle={bookingGroup ?? undefined}
|
||||
endpoint="/api/group-booking"
|
||||
extraBody={{ groupInfo: bookingGroup }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -71,6 +71,12 @@ export function BookingModal({ open, onClose, groupInfo, contact: contactProp }:
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
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
|
||||
const groupText = groupInfo ? ` (${groupInfo})` : "";
|
||||
const message = `Здравствуйте! Меня зовут ${name}, хочу записаться на занятие${groupText}. Мой телефон: ${phone}`;
|
||||
|
||||
210
src/components/ui/OpenDaySignupModal.tsx
Normal file
210
src/components/ui/OpenDaySignupModal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
264
src/components/ui/SignupModal.tsx
Normal file
264
src/components/ui/SignupModal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
600
src/lib/db.ts
600
src/lib/db.ts
@@ -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) {
|
||||
@@ -350,8 +441,11 @@ interface McRegistrationRow {
|
||||
master_class_title: string;
|
||||
name: string;
|
||||
instagram: string;
|
||||
phone: string | null;
|
||||
telegram: string | null;
|
||||
created_at: string;
|
||||
notified_confirm: number;
|
||||
notified_reminder: number;
|
||||
}
|
||||
|
||||
export interface McRegistration {
|
||||
@@ -359,23 +453,27 @@ export interface McRegistration {
|
||||
masterClassTitle: string;
|
||||
name: string;
|
||||
instagram: string;
|
||||
phone?: string;
|
||||
telegram?: string;
|
||||
createdAt: string;
|
||||
notifiedConfirm: boolean;
|
||||
notifiedReminder: boolean;
|
||||
}
|
||||
|
||||
export function addMcRegistration(
|
||||
masterClassTitle: string,
|
||||
name: string,
|
||||
instagram: string,
|
||||
telegram?: string
|
||||
telegram?: string,
|
||||
phone?: string
|
||||
): number {
|
||||
const db = getDb();
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram, phone)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(masterClassTitle, name, instagram, telegram || null);
|
||||
.run(masterClassTitle, name, instagram, telegram || null, phone || null);
|
||||
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"
|
||||
)
|
||||
.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,
|
||||
masterClassTitle: r.master_class_title,
|
||||
name: r.name,
|
||||
instagram: r.instagram,
|
||||
phone: r.phone ?? undefined,
|
||||
telegram: r.telegram ?? undefined,
|
||||
createdAt: r.created_at,
|
||||
}));
|
||||
notifiedConfirm: !!r.notified_confirm,
|
||||
notifiedReminder: !!r.notified_reminder,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateMcRegistration(
|
||||
@@ -408,9 +521,484 @@ export function updateMcRegistration(
|
||||
).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 {
|
||||
const db = getDb();
|
||||
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 };
|
||||
|
||||
11
src/lib/openDay.ts
Normal file
11
src/lib/openDay.ts
Normal 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 };
|
||||
}
|
||||
@@ -139,6 +139,7 @@ export interface SiteContent {
|
||||
rentalTitle: string;
|
||||
rentalItems: PricingItem[];
|
||||
rules: string[];
|
||||
showContactHint?: boolean;
|
||||
};
|
||||
masterClasses: {
|
||||
title: string;
|
||||
|
||||
Reference in New Issue
Block a user