Compare commits
17 Commits
26cb9a9772
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bfd502930 | |||
| 8d1e3fb596 | |||
| 0ec2361a16 | |||
| e4a9b71bfe | |||
| e617660467 | |||
| 3458f88367 | |||
| 9e0aa5b5dc | |||
| 5cd23473c8 | |||
| b1adbbfe3d | |||
| e63b902081 | |||
| 66dce3f8f5 | |||
| 127990e532 | |||
| 4e766d6957 | |||
| b94ee69033 | |||
| 7497ede2fd | |||
| 6cbdba2197 | |||
| 3ac6a4d840 |
125
CLAUDE.md
125
CLAUDE.md
@@ -7,8 +7,9 @@ Content language: Russian
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
- **Next.js 16** (App Router, TypeScript, Turbopack)
|
- **Next.js 16** (App Router, TypeScript, Turbopack)
|
||||||
- **Tailwind CSS v4** (light + dark mode, class-based toggle)
|
- **Tailwind CSS v4** (dark mode only, gold/black theme)
|
||||||
- **lucide-react** for icons
|
- **lucide-react** for icons
|
||||||
|
- **better-sqlite3** for SQLite database
|
||||||
- **Fonts**: Inter (body) + Oswald (headings) via `next/font`
|
- **Fonts**: Inter (body) + Oswald (headings) via `next/font`
|
||||||
- **Hosting**: Vercel (planned)
|
- **Hosting**: Vercel (planned)
|
||||||
|
|
||||||
@@ -16,66 +17,138 @@ Content language: Russian
|
|||||||
- Function declarations for components (not arrow functions)
|
- Function declarations for components (not arrow functions)
|
||||||
- PascalCase for component files, camelCase for utils
|
- PascalCase for component files, camelCase for utils
|
||||||
- `@/` path alias for imports
|
- `@/` path alias for imports
|
||||||
- Semantic CSS classes via `@apply`: `surface-base`, `surface-muted`, `heading-text`, `body-text`, `nav-link`, `card`, `contact-item`, `contact-icon`, `theme-border`
|
|
||||||
- Only Header + ThemeToggle are client components (minimal JS shipped)
|
|
||||||
- `next/image` with `unoptimized` for PNGs that need transparency preserved
|
- `next/image` with `unoptimized` for PNGs that need transparency preserved
|
||||||
|
- Header nav uses `lg:` breakpoint (1024px) for desktop/mobile switch (9 nav links + CTA need the space)
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── layout.tsx # Root layout, fonts, metadata
|
│ ├── layout.tsx # Root layout, fonts, metadata
|
||||||
│ ├── page.tsx # Landing: Hero → Team → About → Classes → Contact
|
│ ├── page.tsx # Landing: Hero → [OpenDay] → About → Team → Classes → MasterClasses → Schedule → Pricing → News → FAQ → Contact
|
||||||
│ ├── globals.css # Tailwind imports
|
│ ├── globals.css # Tailwind imports
|
||||||
│ ├── styles/
|
│ ├── styles/
|
||||||
│ │ ├── theme.css # Theme variables, semantic classes
|
│ │ ├── theme.css # Theme variables, semantic classes
|
||||||
│ │ └── animations.css # Keyframes, scroll reveal, modal animations
|
│ │ └── animations.css # Keyframes, scroll reveal, modal animations
|
||||||
│ ├── icon.png # Favicon
|
│ ├── admin/
|
||||||
│ └── apple-icon.png
|
│ │ ├── page.tsx # Dashboard with 13 section cards
|
||||||
|
│ │ ├── login/ # Password auth
|
||||||
|
│ │ ├── 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 + 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
|
||||||
|
│ │ └── contact/ # Contact editor
|
||||||
|
│ └── api/
|
||||||
|
│ ├── auth/login/ # POST login
|
||||||
|
│ ├── logout/ # POST logout
|
||||||
|
│ ├── admin/
|
||||||
|
│ │ ├── sections/[key]/ # GET/PUT section data
|
||||||
|
│ │ ├── team/ # CRUD team members
|
||||||
|
│ │ ├── team/[id]/ # GET/PUT/DELETE single member
|
||||||
|
│ │ ├── team/reorder/ # PUT reorder
|
||||||
|
│ │ ├── upload/ # POST file upload (whitelisted folders)
|
||||||
|
│ │ ├── 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 MC signup
|
||||||
|
│ ├── group-booking/ # POST public group booking
|
||||||
|
│ └── open-day-register/ # POST public Open Day booking
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── layout/
|
│ ├── layout/
|
||||||
│ │ ├── Header.tsx # Sticky nav, mobile menu, theme toggle ("use client")
|
│ │ ├── Header.tsx # Sticky nav, mobile menu, booking modal ("use client")
|
||||||
│ │ └── Footer.tsx
|
│ │ └── Footer.tsx
|
||||||
│ ├── sections/
|
│ ├── sections/
|
||||||
│ │ ├── Hero.tsx
|
│ │ ├── Hero.tsx # Hero with animated logo, floating hearts
|
||||||
│ │ ├── Team.tsx # "use client" — clickable cards + modal
|
│ │ ├── About.tsx # About with stats (trainers, classes, locations)
|
||||||
│ │ ├── About.tsx
|
│ │ ├── Team.tsx # Carousel + profile view
|
||||||
│ │ ├── Classes.tsx
|
│ │ ├── Classes.tsx # Showcase layout with icon selector
|
||||||
│ │ └── Contact.tsx
|
│ │ ├── 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
|
||||||
|
│ │ ├── FAQ.tsx # Accordion with show more
|
||||||
|
│ │ └── Contact.tsx # Info + Yandex Maps iframe
|
||||||
│ └── ui/
|
│ └── ui/
|
||||||
│ ├── Button.tsx
|
│ ├── Button.tsx
|
||||||
│ ├── SectionHeading.tsx
|
│ ├── SectionHeading.tsx
|
||||||
│ ├── SocialLinks.tsx
|
│ ├── BookingModal.tsx # Booking form → Instagram DM + DB save
|
||||||
│ ├── ThemeToggle.tsx
|
│ ├── MasterClassSignupModal.tsx # MC registration form → API
|
||||||
|
│ ├── OpenDaySignupModal.tsx # Open Day class booking → API
|
||||||
|
│ ├── NewsModal.tsx # News detail popup
|
||||||
│ ├── Reveal.tsx # Intersection Observer scroll reveal
|
│ ├── Reveal.tsx # Intersection Observer scroll reveal
|
||||||
│ └── TeamMemberModal.tsx # "use client" — member popup
|
│ ├── BackToTop.tsx
|
||||||
|
│ └── ...
|
||||||
├── data/
|
├── data/
|
||||||
│ └── content.ts # ALL Russian text, structured for future CMS
|
│ └── content.ts # Fallback Russian text (DB takes priority)
|
||||||
├── lib/
|
├── lib/
|
||||||
│ └── constants.ts # BRAND constants, NAV_LINKS
|
│ ├── constants.ts # BRAND constants, NAV_LINKS
|
||||||
|
│ ├── config.ts # UI_CONFIG (thresholds, counts)
|
||||||
|
│ ├── 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
|
||||||
|
│ └── openDay.ts # getActiveOpenDay() — server-side Open Day loader
|
||||||
|
├── proxy.ts # Middleware: auth guard for /admin/*
|
||||||
└── types/
|
└── types/
|
||||||
├── index.ts
|
├── index.ts
|
||||||
├── content.ts # SiteContent, TeamMember, ClassItem, ContactInfo
|
├── content.ts # SiteContent, TeamMember, ClassItem, MasterClassItem, etc.
|
||||||
└── navigation.ts
|
└── navigation.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Brand / Styling
|
## Brand / Styling
|
||||||
- **Accent**: rose/red (`#e11d48`)
|
- **Accent**: gold (`#c9a96e` / `hsl(37, 42%, 61%)`)
|
||||||
- **Dark mode**: bg `#0a0a0a`, surface `#171717`
|
- **Background**: `#050505` – `#0a0a0a` (dark only)
|
||||||
- **Light mode**: bg `#fafafa`, surface `#ffffff`
|
- **Surface**: `#171717` dark cards
|
||||||
- Logo: transparent PNG, uses `dark:invert` + `unoptimized`
|
- Logo: transparent PNG heart with gold glow, uses `unoptimized`
|
||||||
|
|
||||||
## Content Data
|
## Content Data
|
||||||
- All text lives in `src/data/content.ts` (type-safe, one file to edit)
|
- Primary source: SQLite database (`db/blackheart.db`)
|
||||||
- 13 team members with photos, Instagram links, and personal descriptions
|
- Fallback: `src/data/content.ts` (auto-seeds DB on first access)
|
||||||
|
- Admin panel edits go to DB, site reads from DB via `getContent()`
|
||||||
|
- 12 team members with photos, Instagram links, bios, victories, education
|
||||||
- 6 class types (Exotic Pole Dance, Pole Dance, Body Plastic, etc.)
|
- 6 class types (Exotic Pole Dance, Pole Dance, Body Plastic, etc.)
|
||||||
|
- Master classes with date/time slots and public registration
|
||||||
- 2 addresses in Minsk, Yandex Maps embed with markers
|
- 2 addresses in Minsk, Yandex Maps embed with markers
|
||||||
- Contact: phone, Instagram
|
- Contact: phone, Instagram (no email)
|
||||||
|
|
||||||
|
## Admin Panel
|
||||||
|
- Password-based auth with HMAC-SHA256 signed JWT (24h TTL)
|
||||||
|
- 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 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
|
||||||
|
- **CSRF protection**: Double-submit cookie pattern. Login sets `bh-csrf-token` cookie (JS-readable). All admin fetch calls use `adminFetch()` from `src/lib/csrf.ts` which sends the token as `X-CSRF-Token` header. Middleware (`proxy.ts`) validates header matches cookie on POST/PUT/DELETE to `/api/admin/*`. **Always use `adminFetch()` instead of `fetch()` for admin API calls.**
|
||||||
|
- File upload validates: MIME type, file extension, whitelisted folder (no path traversal)
|
||||||
|
- 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
|
## AST Index
|
||||||
- **Always use the AST index** at `memory/ast-index.md` when searching for components, props, hooks, types, or styles
|
- **Always use the AST index** at `memory/ast-index.md` when searching for components, props, hooks, types, or styles
|
||||||
- Contains: component tree, all exports, props, hooks, client/server status, CSS classes, keyframes
|
- Contains: component tree, all exports, props, hooks, client/server status, CSS classes, keyframes
|
||||||
- Covers all 31 TS/TSX files + 4 CSS files
|
|
||||||
- Update the index when adding/removing/renaming files or exports
|
- Update the index when adding/removing/renaming files or exports
|
||||||
|
|
||||||
## Database Migrations
|
## Database Migrations
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useRef, useEffect, useState } from "react";
|
import { useRef, useEffect, useState } from "react";
|
||||||
import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react";
|
import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { RichListItem, VictoryItem } from "@/types/content";
|
import type { RichListItem, VictoryItem } from "@/types/content";
|
||||||
|
|
||||||
interface InputFieldProps {
|
interface InputFieldProps {
|
||||||
@@ -379,7 +380,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
|||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("folder", "team");
|
formData.append("folder", "team");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/upload", { method: "POST", body: formData });
|
const res = await adminFetch("/api/admin/upload", { method: "POST", body: formData });
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (result.path) {
|
if (result.path) {
|
||||||
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
|
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Loader2, Check, AlertCircle } from "lucide-react";
|
import { Loader2, Check, AlertCircle } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
|
||||||
interface SectionEditorProps<T> {
|
interface SectionEditorProps<T> {
|
||||||
sectionKey: string;
|
sectionKey: string;
|
||||||
@@ -24,7 +25,7 @@ export function SectionEditor<T>({
|
|||||||
const initialLoadRef = useRef(true);
|
const initialLoadRef = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/admin/sections/${sectionKey}`)
|
adminFetch(`/api/admin/sections/${sectionKey}`)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!r.ok) throw new Error("Failed to load");
|
if (!r.ok) throw new Error("Failed to load");
|
||||||
return r.json();
|
return r.json();
|
||||||
@@ -39,7 +40,7 @@ export function SectionEditor<T>({
|
|||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/sections/${sectionKey}`, {
|
const res = await adminFetch(`/api/admin/sections/${sectionKey}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(dataToSave),
|
body: JSON.stringify(dataToSave),
|
||||||
|
|||||||
978
src/app/admin/bookings/page.tsx
Normal file
978
src/app/admin/bookings/page.tsx
Normal file
@@ -0,0 +1,978 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Loader2, Trash2, Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
interface GroupBooking {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
groupInfo?: string;
|
||||||
|
instagram?: string;
|
||||||
|
telegram?: string;
|
||||||
|
notifiedConfirm: boolean;
|
||||||
|
notifiedReminder: boolean;
|
||||||
|
status: BookingStatus;
|
||||||
|
confirmedDate?: string;
|
||||||
|
confirmedGroup?: string;
|
||||||
|
confirmedComment?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McRegistration {
|
||||||
|
id: number;
|
||||||
|
masterClassTitle: string;
|
||||||
|
name: string;
|
||||||
|
phone?: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHORT_DAYS: Record<string, string> = {
|
||||||
|
"Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ",
|
||||||
|
"Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Tab = "reminders" | "classes" | "master-classes" | "open-day";
|
||||||
|
type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
|
||||||
|
type BookingFilter = "all" | BookingStatus;
|
||||||
|
|
||||||
|
const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [
|
||||||
|
{ key: "new", label: "Новая", color: "text-gold", bg: "bg-gold/10", border: "border-gold/30" },
|
||||||
|
{ key: "contacted", label: "Связались", color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" },
|
||||||
|
{ key: "confirmed", label: "Подтверждено", color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/30" },
|
||||||
|
{ key: "declined", label: "Отказ", color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Confirm Booking Modal ---
|
||||||
|
|
||||||
|
function ConfirmModal({
|
||||||
|
open,
|
||||||
|
bookingName,
|
||||||
|
groupInfo,
|
||||||
|
allClasses,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
bookingName: string;
|
||||||
|
groupInfo?: string;
|
||||||
|
allClasses: ScheduleClassInfo[];
|
||||||
|
onConfirm: (data: { group: string; date: string; comment?: string }) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [hall, setHall] = useState("");
|
||||||
|
const [trainer, setTrainer] = useState("");
|
||||||
|
const [group, setGroup] = useState("");
|
||||||
|
const [date, setDate] = useState("");
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setDate(""); setComment("");
|
||||||
|
// Try to match groupInfo against schedule to pre-fill
|
||||||
|
if (groupInfo && allClasses.length > 0) {
|
||||||
|
const info = groupInfo.toLowerCase();
|
||||||
|
// Score each class against groupInfo, pick best match
|
||||||
|
let bestMatch: ScheduleClassInfo | null = null;
|
||||||
|
let bestScore = 0;
|
||||||
|
for (const c of allClasses) {
|
||||||
|
let score = 0;
|
||||||
|
if (info.includes(c.type.toLowerCase())) score += 3;
|
||||||
|
if (info.includes(c.trainer.toLowerCase())) score += 3;
|
||||||
|
if (info.includes(c.time)) score += 2;
|
||||||
|
const dayShort = (SHORT_DAYS[c.day] || c.day.slice(0, 2)).toLowerCase();
|
||||||
|
if (info.includes(dayShort)) score += 1;
|
||||||
|
const hallWords = c.hall.toLowerCase().split(/[\s/,]+/);
|
||||||
|
if (hallWords.some((w) => w.length > 2 && info.includes(w))) score += 2;
|
||||||
|
if (score > bestScore) { bestScore = score; bestMatch = c; }
|
||||||
|
}
|
||||||
|
const match = bestScore >= 4 ? bestMatch : null;
|
||||||
|
if (match) {
|
||||||
|
setHall(match.hall);
|
||||||
|
setTrainer(match.trainer);
|
||||||
|
setGroup(match.groupId || `${match.type}|${match.time}|${match.address}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHall(""); setTrainer(""); setGroup("");
|
||||||
|
}, [open, groupInfo, allClasses]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
// Cascading options
|
||||||
|
const halls = useMemo(() => [...new Set(allClasses.map((c) => c.hall))], [allClasses]);
|
||||||
|
|
||||||
|
const trainers = useMemo(() => {
|
||||||
|
if (!hall) return [];
|
||||||
|
return [...new Set(allClasses.filter((c) => c.hall === hall).map((c) => c.trainer))].sort();
|
||||||
|
}, [allClasses, hall]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
if (!hall || !trainer) return [];
|
||||||
|
const filtered = allClasses.filter((c) => c.hall === hall && c.trainer === trainer);
|
||||||
|
// Group by groupId — merge days for the same group
|
||||||
|
const byId = new Map<string, { type: string; slots: { day: string; time: string }[]; id: string }>();
|
||||||
|
for (const c of filtered) {
|
||||||
|
const id = c.groupId || `${c.type}|${c.time}|${c.address}`;
|
||||||
|
const existing = byId.get(id);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.slots.some((s) => s.day === c.day)) existing.slots.push({ day: c.day, time: c.time });
|
||||||
|
} else {
|
||||||
|
byId.set(id, { type: c.type, slots: [{ day: c.day, time: c.time }], id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()].map((g) => {
|
||||||
|
const sameTime = g.slots.every((s) => s.time === g.slots[0].time);
|
||||||
|
const label = sameTime
|
||||||
|
? `${g.type}, ${g.slots.map((s) => SHORT_DAYS[s.day] || s.day.slice(0, 2)).join("/")} ${g.slots[0].time}`
|
||||||
|
: `${g.type}, ${g.slots.map((s) => `${SHORT_DAYS[s.day] || s.day.slice(0, 2)} ${s.time}`).join(", ")}`;
|
||||||
|
return { label, value: g.id };
|
||||||
|
}).sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
}, [allClasses, hall, trainer]);
|
||||||
|
|
||||||
|
// Reset downstream on upstream change (skip during initial pre-fill)
|
||||||
|
const initRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (initRef.current) { setTrainer(""); setGroup(""); }
|
||||||
|
initRef.current = true;
|
||||||
|
}, [hall]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (initRef.current && trainer === "") setGroup("");
|
||||||
|
}, [trainer]);
|
||||||
|
// Reset init flag when modal closes
|
||||||
|
useEffect(() => { if (!open) initRef.current = false; }, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const selectClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 [color-scheme:dark] disabled:opacity-30 disabled:cursor-not-allowed";
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" onClick={onClose}>
|
||||||
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
|
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={onClose} aria-label="Закрыть" className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3 className="text-base font-bold text-white">Подтвердить запись</h3>
|
||||||
|
<p className="mt-1 text-xs text-neutral-400">{bookingName}</p>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Зал</label>
|
||||||
|
<select value={hall} onChange={(e) => setHall(e.target.value)} className={selectClass}>
|
||||||
|
<option value="" className="bg-neutral-900">Выберите зал</option>
|
||||||
|
{halls.map((h) => <option key={h} value={h} className="bg-neutral-900">{h}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Тренер</label>
|
||||||
|
<select value={trainer} onChange={(e) => setTrainer(e.target.value)} disabled={!hall} className={selectClass}>
|
||||||
|
<option value="" className="bg-neutral-900">Выберите тренера</option>
|
||||||
|
{trainers.map((t) => <option key={t} value={t} className="bg-neutral-900">{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Группа</label>
|
||||||
|
<select value={group} onChange={(e) => setGroup(e.target.value)} disabled={!trainer} className={selectClass}>
|
||||||
|
<option value="" className="bg-neutral-900">Выберите группу</option>
|
||||||
|
{groups.map((g) => <option key={g.value} value={g.value} className="bg-neutral-900">{g.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Дата занятия</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
min={today}
|
||||||
|
disabled={!group}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={comment}
|
||||||
|
disabled={!group}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="Первое занятие, пробный"
|
||||||
|
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (group && date) {
|
||||||
|
onConfirm({ group, date, comment: comment.trim() || undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!group || !date}
|
||||||
|
className="mt-5 w-full rounded-lg bg-emerald-600 py-2.5 text-sm font-semibold text-white transition-all hover:bg-emerald-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Подтвердить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group Bookings Tab ---
|
||||||
|
|
||||||
|
interface ScheduleClassInfo { type: string; trainer: string; time: string; day: string; hall: string; address: string; groupId?: string }
|
||||||
|
interface ScheduleLocation { name: string; address: string; days: { day: string; classes: { time: string; trainer: string; type: string; groupId?: string }[] }[] }
|
||||||
|
|
||||||
|
function GroupBookingsTab() {
|
||||||
|
const [bookings, setBookings] = useState<GroupBooking[]>([]);
|
||||||
|
const [allClasses, setAllClasses] = useState<ScheduleClassInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filter, setFilter] = useState<BookingFilter>("all");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
adminFetch("/api/admin/group-bookings").then((r) => r.json()),
|
||||||
|
adminFetch("/api/admin/sections/schedule").then((r) => r.json()),
|
||||||
|
])
|
||||||
|
.then(([bookingData, scheduleData]: [GroupBooking[], { locations?: ScheduleLocation[] }]) => {
|
||||||
|
setBookings(bookingData);
|
||||||
|
const classes: ScheduleClassInfo[] = [];
|
||||||
|
for (const loc of scheduleData.locations || []) {
|
||||||
|
const shortAddr = loc.address?.split(",")[0] || loc.name;
|
||||||
|
for (const day of loc.days) {
|
||||||
|
for (const cls of day.classes) {
|
||||||
|
classes.push({
|
||||||
|
type: cls.type,
|
||||||
|
trainer: cls.trainer,
|
||||||
|
time: cls.time,
|
||||||
|
day: day.day,
|
||||||
|
hall: loc.name,
|
||||||
|
address: shortAddr,
|
||||||
|
groupId: cls.groupId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAllClasses(classes);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const c: Record<string, number> = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
|
||||||
|
for (const b of bookings) c[b.status] = (c[b.status] || 0) + 1;
|
||||||
|
return c;
|
||||||
|
}, [bookings]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const list = filter === "all" ? bookings : bookings.filter((b) => b.status === filter);
|
||||||
|
const order: Record<string, number> = { new: 0, contacted: 1, confirmed: 2, declined: 3 };
|
||||||
|
return [...list].sort((a, b) => (order[a.status] ?? 0) - (order[b.status] ?? 0));
|
||||||
|
}, [bookings, filter]);
|
||||||
|
|
||||||
|
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||||
|
const confirmingBooking = bookings.find((b) => b.id === confirmingId);
|
||||||
|
|
||||||
|
async function handleStatus(id: number, status: BookingStatus, confirmation?: { group: string; date: string; comment?: string }) {
|
||||||
|
setBookings((prev) => prev.map((b) => b.id === id ? {
|
||||||
|
...b, status,
|
||||||
|
confirmedDate: confirmation?.date,
|
||||||
|
confirmedGroup: confirmation?.group,
|
||||||
|
confirmedComment: confirmation?.comment,
|
||||||
|
} : b));
|
||||||
|
await adminFetch("/api/admin/group-bookings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "set-status", id, status, confirmation }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter("all")}
|
||||||
|
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Все <span className="text-neutral-500 ml-1">{bookings.length}</span>
|
||||||
|
</button>
|
||||||
|
{BOOKING_STATUSES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
onClick={() => setFilter(s.key)}
|
||||||
|
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
{counts[s.key] > 0 && <span className="ml-1.5">{counts[s.key]}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bookings list */}
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{filtered.length === 0 && <EmptyState total={bookings.length} />}
|
||||||
|
{filtered.map((b) => {
|
||||||
|
const statusConf = BOOKING_STATUSES.find((s) => s.key === b.status) || BOOKING_STATUSES[0];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
className={`rounded-xl border p-4 transition-colors ${
|
||||||
|
b.status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
|
||||||
|
: b.status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
|
||||||
|
: b.status === "new" ? "border-gold/20 bg-gold/[0.03]"
|
||||||
|
: "border-white/10 bg-neutral-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
|
||||||
|
<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-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>
|
||||||
|
)}
|
||||||
|
{b.groupInfo && (
|
||||||
|
<span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="text-neutral-600 text-xs">{fmtDate(b.createdAt)}</span>
|
||||||
|
<DeleteBtn onClick={() => handleDelete(b.id)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Linear status flow */}
|
||||||
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
|
{/* Current status badge */}
|
||||||
|
<span className={`text-[10px] font-medium ${statusConf.bg} ${statusConf.color} border ${statusConf.border} rounded-full px-2.5 py-0.5`}>
|
||||||
|
{statusConf.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{b.status === "confirmed" && (
|
||||||
|
<span className="text-[10px] text-emerald-400/70">
|
||||||
|
{b.confirmedGroup}
|
||||||
|
{b.confirmedDate && ` · ${new Date(b.confirmedDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`}
|
||||||
|
{b.confirmedComment && ` · ${b.confirmedComment}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons based on current state */}
|
||||||
|
<div className="flex gap-1 ml-auto">
|
||||||
|
{b.status === "new" && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatus(b.id, "contacted")}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20 transition-all"
|
||||||
|
>
|
||||||
|
Связались →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{b.status === "contacted" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmingId(b.id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20 transition-all"
|
||||||
|
>
|
||||||
|
Подтвердить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatus(b.id, "declined")}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-all"
|
||||||
|
>
|
||||||
|
Отказ
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(b.status === "confirmed" || b.status === "declined") && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatus(b.id, "contacted")}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300 transition-all"
|
||||||
|
>
|
||||||
|
Вернуть
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmingId !== null}
|
||||||
|
bookingName={confirmingBooking?.name ?? ""}
|
||||||
|
groupInfo={confirmingBooking?.groupInfo}
|
||||||
|
allClasses={allClasses}
|
||||||
|
onClose={() => setConfirmingId(null)}
|
||||||
|
onConfirm={(data) => {
|
||||||
|
if (confirmingId) handleStatus(confirmingId, "confirmed", data);
|
||||||
|
setConfirmingId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MC Registrations Tab ---
|
||||||
|
|
||||||
|
function McRegistrationsTab() {
|
||||||
|
const [regs, setRegs] = useState<McRegistration[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch("/api/admin/mc-registrations")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: McRegistration[]) => setRegs(data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Group by MC title
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map: Record<string, McRegistration[]> = {};
|
||||||
|
for (const r of regs) {
|
||||||
|
if (!map[r.masterClassTitle]) map[r.masterClassTitle] = [];
|
||||||
|
map[r.masterClassTitle].push(r);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [regs]);
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
function toggleExpand(key: string) {
|
||||||
|
setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.keys(grouped).length === 0 && <EmptyState total={regs.length} />}
|
||||||
|
{Object.entries(grouped).map(([title, items]) => {
|
||||||
|
const isOpen = expanded[title] ?? false;
|
||||||
|
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>
|
||||||
|
</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 border-white/5 bg-neutral-800/30 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-sm">
|
||||||
|
<span className="font-medium text-white">{r.name}</span>
|
||||||
|
{r.phone && (
|
||||||
|
<a href={`tel:${r.phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||||
|
<Phone size={10} />{r.phone}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{r.instagram && (
|
||||||
|
<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 text-xs"
|
||||||
|
>
|
||||||
|
<Instagram size={10} />{r.instagram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{r.telegram && (
|
||||||
|
<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 text-xs"
|
||||||
|
>
|
||||||
|
<Send size={10} />{r.telegram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="text-neutral-600 text-xs ml-auto">{fmtDate(r.createdAt)}</span>
|
||||||
|
<DeleteBtn onClick={() => handleDelete(r.id)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Open Day Bookings Tab ---
|
||||||
|
|
||||||
|
function OpenDayBookingsTab() {
|
||||||
|
const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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];
|
||||||
|
return adminFetch(`/api/admin/open-day/bookings?eventId=${ev.id}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: OpenDayBooking[]) => setBookings(data));
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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 bookings) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}, [bookings]);
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
function toggleExpand(key: string) {
|
||||||
|
setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{grouped.length === 0 && <EmptyState total={bookings.length} />}
|
||||||
|
{grouped.map(([key, group]) => {
|
||||||
|
const isOpen = expanded[key] ?? false;
|
||||||
|
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>
|
||||||
|
</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 border-white/5 bg-neutral-800/30 p-3"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reminders Tab ---
|
||||||
|
|
||||||
|
interface ReminderItem {
|
||||||
|
id: number;
|
||||||
|
type: "class" | "master-class" | "open-day";
|
||||||
|
table: "mc_registrations" | "group_bookings" | "open_day_bookings";
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
instagram?: string;
|
||||||
|
telegram?: string;
|
||||||
|
reminderStatus?: string;
|
||||||
|
eventLabel: string;
|
||||||
|
eventDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReminderStatus = "pending" | "coming" | "cancelled";
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<ReminderStatus, { label: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }> = {
|
||||||
|
pending: { label: "Нет ответа", icon: Clock, color: "text-amber-400", bg: "bg-amber-500/10", border: "border-amber-500/20" },
|
||||||
|
coming: { label: "Придёт", icon: CheckCircle2, color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
|
||||||
|
cancelled: { label: "Не придёт", icon: XCircle, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_CONFIG = {
|
||||||
|
"master-class": { label: "МК", icon: Star, color: "text-purple-400" },
|
||||||
|
"open-day": { label: "Open Day", icon: DoorOpen, color: "text-gold" },
|
||||||
|
"class": { label: "Занятие", icon: Calendar, color: "text-blue-400" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function RemindersTab() {
|
||||||
|
const [items, setItems] = useState<ReminderItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch("/api/admin/reminders")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: ReminderItem[]) => setItems(data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function setStatus(item: ReminderItem, status: ReminderStatus | null) {
|
||||||
|
setItems((prev) => prev.map((i) => i.id === item.id && i.table === item.table ? { ...i, reminderStatus: status ?? undefined } : i));
|
||||||
|
await adminFetch("/api/admin/reminders", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ table: item.table, id: item.id, status }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||||
|
|
||||||
|
const todayItems = items.filter((i) => i.eventDate === today);
|
||||||
|
const tomorrowItems = items.filter((i) => i.eventDate === tomorrow);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
function countByStatus(list: ReminderItem[]) {
|
||||||
|
const coming = list.filter((i) => i.reminderStatus === "coming").length;
|
||||||
|
const cancelled = list.filter((i) => i.reminderStatus === "cancelled").length;
|
||||||
|
const pending = list.filter((i) => i.reminderStatus === "pending").length;
|
||||||
|
const notAsked = list.filter((i) => !i.reminderStatus).length;
|
||||||
|
return { coming, cancelled, pending, notAsked, total: list.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<Bell size={32} className="mx-auto text-neutral-600 mb-3" />
|
||||||
|
<p className="text-neutral-400">Нет напоминаний — все на контроле</p>
|
||||||
|
<p className="text-xs text-neutral-600 mt-1">Здесь появятся записи на сегодня и завтра</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group items by event within each day
|
||||||
|
function groupByEvent(dayItems: ReminderItem[]) {
|
||||||
|
const map: Record<string, { type: ReminderItem["type"]; label: string; items: ReminderItem[] }> = {};
|
||||||
|
for (const item of dayItems) {
|
||||||
|
const key = `${item.type}|${item.eventLabel}`;
|
||||||
|
if (!map[key]) map[key] = { type: item.type, label: item.eventLabel, items: [] };
|
||||||
|
map[key].items.push(item);
|
||||||
|
}
|
||||||
|
return Object.values(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_SECTIONS = [
|
||||||
|
{ key: "not-asked", label: "Не спрошены", color: "text-gold", bg: "bg-gold/10", match: (i: ReminderItem) => !i.reminderStatus },
|
||||||
|
{ key: "pending", label: "Нет ответа", color: "text-amber-400", bg: "bg-amber-500/10", match: (i: ReminderItem) => i.reminderStatus === "pending" },
|
||||||
|
{ key: "coming", label: "Придёт", color: "text-emerald-400", bg: "bg-emerald-500/10", match: (i: ReminderItem) => i.reminderStatus === "coming" },
|
||||||
|
{ key: "cancelled", label: "Не придёт", color: "text-red-400", bg: "bg-red-500/10", match: (i: ReminderItem) => i.reminderStatus === "cancelled" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderPerson(item: ReminderItem) {
|
||||||
|
const currentStatus = item.reminderStatus as ReminderStatus | undefined;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${item.table}-${item.id}`}
|
||||||
|
className={`rounded-lg border p-3 transition-colors ${
|
||||||
|
!currentStatus ? "border-gold/20 bg-gold/[0.03]"
|
||||||
|
: currentStatus === "coming" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
|
||||||
|
: currentStatus === "cancelled" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
|
||||||
|
: currentStatus === "pending" ? "border-amber-500/15 bg-amber-500/[0.02]"
|
||||||
|
: "border-white/5 bg-neutral-800/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-sm">
|
||||||
|
<span className="font-medium text-white">{item.name}</span>
|
||||||
|
{item.phone && (
|
||||||
|
<a href={`tel:${item.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||||
|
<Phone size={10} />{item.phone}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{item.instagram && (
|
||||||
|
<a href={`https://ig.me/m/${item.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} />{item.instagram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{item.telegram && (
|
||||||
|
<a href={`https://t.me/${item.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} />{item.telegram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1 ml-auto">
|
||||||
|
{(["coming", "pending", "cancelled"] as ReminderStatus[]).map((st) => {
|
||||||
|
const conf = STATUS_CONFIG[st];
|
||||||
|
const Icon = conf.icon;
|
||||||
|
const active = currentStatus === st;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={st}
|
||||||
|
onClick={() => setStatus(item, active ? null : st)}
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium transition-all ${
|
||||||
|
active
|
||||||
|
? `${conf.bg} ${conf.color} border ${conf.border}`
|
||||||
|
: "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={10} />
|
||||||
|
{conf.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[
|
||||||
|
{ label: "Сегодня", date: today, items: todayItems },
|
||||||
|
{ label: "Завтра", date: tomorrow, items: tomorrowItems },
|
||||||
|
]
|
||||||
|
.filter((g) => g.items.length > 0)
|
||||||
|
.map((group) => {
|
||||||
|
const eventGroups = groupByEvent(group.items);
|
||||||
|
return (
|
||||||
|
<div key={group.date}>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<h3 className="text-sm font-bold text-white">{group.label}</h3>
|
||||||
|
<span className="text-[10px] text-neutral-500">
|
||||||
|
{new Date(group.date + "T12:00").toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{eventGroups.map((eg) => {
|
||||||
|
const typeConf = TYPE_CONFIG[eg.type];
|
||||||
|
const TypeIcon = typeConf.icon;
|
||||||
|
const egStats = countByStatus(eg.items);
|
||||||
|
return (
|
||||||
|
<div key={eg.label} className="rounded-xl border border-white/10 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-900">
|
||||||
|
<TypeIcon size={13} className={typeConf.color} />
|
||||||
|
<span className="text-sm font-medium text-white">{eg.label}</span>
|
||||||
|
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{eg.items.length} чел.</span>
|
||||||
|
<div className="flex gap-2 ml-auto text-[10px]">
|
||||||
|
{egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>}
|
||||||
|
{egStats.cancelled > 0 && <span className="text-red-400">{egStats.cancelled} не придёт</span>}
|
||||||
|
{egStats.pending > 0 && <span className="text-amber-400">{egStats.pending} нет ответа</span>}
|
||||||
|
{egStats.notAsked > 0 && <span className="text-gold">{egStats.notAsked} не спрошены</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-3 pt-1">
|
||||||
|
{STATUS_SECTIONS
|
||||||
|
.map((sec) => ({ ...sec, items: eg.items.filter(sec.match) }))
|
||||||
|
.filter((sec) => sec.items.length > 0)
|
||||||
|
.map((sec) => (
|
||||||
|
<div key={sec.key} className="mt-2 first:mt-0">
|
||||||
|
<span className={`text-[10px] font-medium ${sec.color} ${sec.bg} rounded-full px-2 py-0.5`}>
|
||||||
|
{sec.label} · {sec.items.length}
|
||||||
|
</span>
|
||||||
|
<div className="mt-1.5 space-y-1.5">
|
||||||
|
{sec.items.map(renderPerson)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</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: "reminders", label: "Напоминания" },
|
||||||
|
{ key: "classes", label: "Занятия" },
|
||||||
|
{ key: "master-classes", label: "Мастер-классы" },
|
||||||
|
{ key: "open-day", label: "День открытых дверей" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function BookingsPage() {
|
||||||
|
const [tab, setTab] = useState<Tab>("reminders");
|
||||||
|
|
||||||
|
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 === "reminders" && <RemindersTab />}
|
||||||
|
{tab === "classes" && <GroupBookingsTab />}
|
||||||
|
{tab === "master-classes" && <McRegistrationsTab />}
|
||||||
|
{tab === "open-day" && <OpenDayBookingsTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -20,6 +21,8 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
ClipboardList,
|
||||||
|
DoorOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
@@ -30,7 +33,9 @@ const NAV_ITEMS = [
|
|||||||
{ href: "/admin/team", label: "Команда", icon: Users },
|
{ href: "/admin/team", label: "Команда", icon: Users },
|
||||||
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
|
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
|
||||||
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
|
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
|
||||||
|
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen },
|
||||||
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
|
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
|
||||||
|
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
|
||||||
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
|
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
|
||||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
||||||
{ href: "/admin/news", label: "Новости", icon: Newspaper },
|
{ href: "/admin/news", label: "Новости", icon: Newspaper },
|
||||||
@@ -45,12 +50,27 @@ export default function AdminLayout({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [unreadTotal, setUnreadTotal] = useState(0);
|
||||||
|
|
||||||
// Don't render admin shell on login page
|
// Don't render admin shell on login page
|
||||||
if (pathname === "/admin/login") {
|
if (pathname === "/admin/login") {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch unread counts — poll every 30s
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
useEffect(() => {
|
||||||
|
function fetchCounts() {
|
||||||
|
adminFetch("/api/admin/unread-counts")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { total: number }) => setUnreadTotal(data.total))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
fetchCounts();
|
||||||
|
const interval = setInterval(fetchCounts, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await fetch("/api/logout", { method: "POST" });
|
await fetch("/api/logout", { method: "POST" });
|
||||||
router.push("/admin/login");
|
router.push("/admin/login");
|
||||||
@@ -106,6 +126,11 @@ export default function AdminLayout({
|
|||||||
>
|
>
|
||||||
<Icon size={18} />
|
<Icon size={18} />
|
||||||
{item.label}
|
{item.label}
|
||||||
|
{item.href === "/admin/bookings" && unreadTotal > 0 && (
|
||||||
|
<span className="ml-auto rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||||
|
{unreadTotal > 99 ? "99+" : unreadTotal}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useState, useRef, useEffect, useMemo } from "react";
|
|||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField, TextareaField } from "../_components/FormField";
|
import { InputField, TextareaField } from "../_components/FormField";
|
||||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check, ChevronDown, ChevronUp, Instagram, Send, Trash2, Pencil } from "lucide-react";
|
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
||||||
|
|
||||||
function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
|
function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||||
@@ -37,15 +38,6 @@ interface MasterClassesData {
|
|||||||
items: MasterClassItem[];
|
items: MasterClassItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface McRegistration {
|
|
||||||
id: number;
|
|
||||||
masterClassTitle: string;
|
|
||||||
name: string;
|
|
||||||
instagram: string;
|
|
||||||
telegram?: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Autocomplete Multi-Select ---
|
// --- Autocomplete Multi-Select ---
|
||||||
function AutocompleteMulti({
|
function AutocompleteMulti({
|
||||||
label,
|
label,
|
||||||
@@ -335,7 +327,7 @@ function ImageUploadField({
|
|||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("folder", "master-classes");
|
formData.append("folder", "master-classes");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/upload", {
|
const res = await adminFetch("/api/admin/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -481,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 fetch("/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;
|
|
||||||
fetch(`/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);
|
|
||||||
fetch(`/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 fetch("/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 fetch(`/api/admin/mc-registrations?id=${id}`, { method: "DELETE" });
|
|
||||||
setRegs((prev) => prev.filter((r) => r.id !== id));
|
|
||||||
setCount((prev) => (prev !== null ? prev - 1 : null));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUpdate(updated: McRegistration) {
|
|
||||||
setRegs((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!title) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border-t border-white/5 pt-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggle}
|
|
||||||
className="flex items-center gap-2 text-sm text-neutral-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
{open ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
||||||
Записи{count !== null ? ` (${count})` : ""}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="mt-2 space-y-1.5">
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-neutral-500">
|
|
||||||
<Loader2 size={12} className="animate-spin" />
|
|
||||||
Загрузка...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && regs.length === 0 && !adding && (
|
|
||||||
<p className="text-xs text-neutral-500">Пока никто не записался</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{regs.map((reg) => (
|
|
||||||
<RegistrationRow
|
|
||||||
key={reg.id}
|
|
||||||
reg={reg}
|
|
||||||
onUpdate={handleUpdate}
|
|
||||||
onDelete={() => handleDelete(reg.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{adding ? (
|
|
||||||
<div className="rounded-lg bg-neutral-800/50 px-3 py-2 space-y-2">
|
|
||||||
<input
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
placeholder="Имя"
|
|
||||||
className="w-full rounded-md border border-white/10 bg-neutral-800 px-2 py-1.5 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="flex flex-1 items-center rounded-md border border-white/10 bg-neutral-800 text-sm">
|
|
||||||
<span className="flex items-center gap-1 pl-2 text-neutral-500 select-none">
|
|
||||||
<Instagram size={11} className="text-pink-400" />@
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
value={newIg}
|
|
||||||
onChange={(e) => setNewIg(e.target.value.replace(/^@/, ""))}
|
|
||||||
placeholder="instagram"
|
|
||||||
className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 items-center rounded-md border border-white/10 bg-neutral-800 text-sm">
|
|
||||||
<span className="flex items-center gap-1 pl-2 text-neutral-500 select-none">
|
|
||||||
<Send size={11} className="text-blue-400" />@
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
value={newTg}
|
|
||||||
onChange={(e) => setNewTg(e.target.value.replace(/^@/, ""))}
|
|
||||||
placeholder="telegram"
|
|
||||||
className="flex-1 bg-transparent px-1 py-1.5 text-white placeholder-neutral-500 outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setAdding(false); setNewName(""); setNewIg(""); setNewTg(""); }}
|
|
||||||
className="rounded-md px-3 py-1 text-xs text-neutral-400 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={savingNew || !newName.trim() || !newIg.trim()}
|
|
||||||
className="rounded-md bg-gold/20 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{savingNew ? "..." : "Добавить"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setAdding(true)}
|
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-dashed border-white/10 px-3 py-1.5 text-xs text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus size={12} />
|
|
||||||
Добавить запись
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Main page ---
|
// --- Main page ---
|
||||||
export default function MasterClassesEditorPage() {
|
export default function MasterClassesEditorPage() {
|
||||||
const [trainers, setTrainers] = useState<string[]>([]);
|
const [trainers, setTrainers] = useState<string[]>([]);
|
||||||
@@ -823,7 +481,7 @@ export default function MasterClassesEditorPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch trainers from team
|
// Fetch trainers from team
|
||||||
fetch("/api/admin/team")
|
adminFetch("/api/admin/team")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((members: { name: string }[]) => {
|
.then((members: { name: string }[]) => {
|
||||||
setTrainers(members.map((m) => m.name));
|
setTrainers(members.map((m) => m.name));
|
||||||
@@ -831,7 +489,7 @@ export default function MasterClassesEditorPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
// Fetch styles from classes section
|
// Fetch styles from classes section
|
||||||
fetch("/api/admin/sections/classes")
|
adminFetch("/api/admin/sections/classes")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: { items: { name: string }[] }) => {
|
.then((data: { items: { name: string }[] }) => {
|
||||||
setStyles(data.items.map((c) => c.name));
|
setStyles(data.items.map((c) => c.name));
|
||||||
@@ -839,7 +497,7 @@ export default function MasterClassesEditorPage() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
// Fetch locations from schedule section
|
// Fetch locations from schedule section
|
||||||
fetch("/api/admin/sections/schedule")
|
adminFetch("/api/admin/sections/schedule")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: { locations: { name: string; address: string }[] }) => {
|
.then((data: { locations: { name: string; address: string }[] }) => {
|
||||||
setLocations(data.locations);
|
setLocations(data.locations);
|
||||||
@@ -951,7 +609,6 @@ export default function MasterClassesEditorPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RegistrationsList title={item.title} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
createItem={() => ({
|
createItem={() => ({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SectionEditor } from "../_components/SectionEditor";
|
|||||||
import { InputField, TextareaField } from "../_components/FormField";
|
import { InputField, TextareaField } from "../_components/FormField";
|
||||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
import { Upload, Loader2, ImageIcon, X } from "lucide-react";
|
import { Upload, Loader2, ImageIcon, X } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { NewsItem } from "@/types/content";
|
import type { NewsItem } from "@/types/content";
|
||||||
|
|
||||||
interface NewsData {
|
interface NewsData {
|
||||||
@@ -30,7 +31,7 @@ function ImageUploadField({
|
|||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("folder", "news");
|
formData.append("folder", "news");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/upload", {
|
const res = await adminFetch("/api/admin/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -122,13 +123,16 @@ export default function NewsEditorPage() {
|
|||||||
value={item.title}
|
value={item.title}
|
||||||
onChange={(v) => updateItem({ ...item, title: v })}
|
onChange={(v) => updateItem({ ...item, title: v })}
|
||||||
/>
|
/>
|
||||||
<InputField
|
<div>
|
||||||
label="Дата"
|
<label className="block text-sm text-neutral-400 mb-1.5">Дата</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
value={item.date}
|
value={item.date}
|
||||||
onChange={(v) => updateItem({ ...item, date: v })}
|
onChange={(e) => updateItem({ ...item, date: e.target.value })}
|
||||||
placeholder="2026-03-15"
|
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>
|
||||||
<TextareaField
|
<TextareaField
|
||||||
label="Текст"
|
label="Текст"
|
||||||
value={item.text}
|
value={item.text}
|
||||||
|
|||||||
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 Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Globe,
|
Globe,
|
||||||
@@ -5,11 +8,24 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Users,
|
Users,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Star,
|
||||||
Calendar,
|
Calendar,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
Newspaper,
|
||||||
Phone,
|
Phone,
|
||||||
|
ClipboardList,
|
||||||
|
DoorOpen,
|
||||||
|
UserPlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
|
||||||
|
interface UnreadCounts {
|
||||||
|
groupBookings: number;
|
||||||
|
mcRegistrations: number;
|
||||||
|
openDayBookings: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
const CARDS = [
|
const CARDS = [
|
||||||
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe, desc: "Заголовок и описание сайта" },
|
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe, desc: "Заголовок и описание сайта" },
|
||||||
@@ -17,21 +33,83 @@ const CARDS = [
|
|||||||
{ href: "/admin/about", label: "О студии", icon: FileText, desc: "Текст о студии" },
|
{ href: "/admin/about", label: "О студии", icon: FileText, desc: "Текст о студии" },
|
||||||
{ href: "/admin/team", label: "Команда", icon: Users, desc: "Тренеры и инструкторы" },
|
{ href: "/admin/team", label: "Команда", icon: Users, desc: "Тренеры и инструкторы" },
|
||||||
{ href: "/admin/classes", label: "Направления", icon: BookOpen, desc: "Типы занятий" },
|
{ href: "/admin/classes", label: "Направления", icon: BookOpen, desc: "Типы занятий" },
|
||||||
|
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star, desc: "Мастер-классы и записи" },
|
||||||
|
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen, desc: "Открытые занятия, расписание, записи" },
|
||||||
{ href: "/admin/schedule", label: "Расписание", icon: Calendar, desc: "Расписание занятий" },
|
{ href: "/admin/schedule", label: "Расписание", icon: Calendar, desc: "Расписание занятий" },
|
||||||
|
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList, desc: "Все записи и заявки" },
|
||||||
{ href: "/admin/pricing", label: "Цены", icon: DollarSign, desc: "Абонементы и аренда" },
|
{ href: "/admin/pricing", label: "Цены", icon: DollarSign, desc: "Абонементы и аренда" },
|
||||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, desc: "Часто задаваемые вопросы" },
|
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, desc: "Часто задаваемые вопросы" },
|
||||||
|
{ href: "/admin/news", label: "Новости", icon: Newspaper, desc: "Новости и анонсы" },
|
||||||
{ href: "/admin/contact", label: "Контакты", icon: Phone, desc: "Адреса, телефон, карта" },
|
{ href: "/admin/contact", label: "Контакты", icon: Phone, desc: "Адреса, телефон, карта" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function UnreadWidget({ counts }: { counts: UnreadCounts }) {
|
||||||
|
if (counts.total === 0) return null;
|
||||||
|
|
||||||
|
const items: { label: string; count: number; tab: string }[] = [];
|
||||||
|
if (counts.groupBookings > 0) items.push({ label: "Занятия", count: counts.groupBookings, tab: "classes" });
|
||||||
|
if (counts.mcRegistrations > 0) items.push({ label: "Мастер-классы", count: counts.mcRegistrations, tab: "master-classes" });
|
||||||
|
if (counts.openDayBookings > 0) items.push({ label: "День открытых дверей", count: counts.openDayBookings, tab: "open-day" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/admin/bookings"
|
||||||
|
className="block rounded-xl border border-gold/20 bg-gold/[0.03] p-5 transition-all hover:border-gold/40 hover:bg-gold/[0.06]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-500/10 text-red-400">
|
||||||
|
<UserPlus size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-medium text-white">
|
||||||
|
Новые записи
|
||||||
|
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-red-500 text-white text-[11px] font-bold min-w-[20px] h-[20px] px-1.5">
|
||||||
|
{counts.total}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-neutral-400">Не подтверждённые заявки</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.tab} className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="rounded-full bg-gold/15 text-gold font-medium px-2 py-0.5">
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-400">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
|
const [counts, setCounts] = useState<UnreadCounts | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch("/api/admin/unread-counts")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: UnreadCounts) => setCounts(data))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Панель управления</h1>
|
<h1 className="text-2xl font-bold">Панель управления</h1>
|
||||||
<p className="mt-1 text-neutral-400">Выберите раздел для редактирования</p>
|
<p className="mt-1 text-neutral-400">Выберите раздел для редактирования</p>
|
||||||
|
|
||||||
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
{/* Unread bookings widget */}
|
||||||
|
{counts && counts.total > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<UnreadWidget counts={counts} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{CARDS.map((card) => {
|
{CARDS.map((card) => {
|
||||||
const Icon = card.icon;
|
const Icon = card.icon;
|
||||||
|
const isBookings = card.href === "/admin/bookings";
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={card.href}
|
key={card.href}
|
||||||
@@ -42,9 +120,14 @@ export default function AdminDashboard() {
|
|||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gold/10 text-gold">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gold/10 text-gold">
|
||||||
<Icon size={20} />
|
<Icon size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<h2 className="font-medium text-white group-hover:text-gold transition-colors">
|
<h2 className="font-medium text-white group-hover:text-gold transition-colors flex items-center gap-2">
|
||||||
{card.label}
|
{card.label}
|
||||||
|
{isBookings && counts && counts.total > 0 && (
|
||||||
|
<span className="rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||||
|
{counts.total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-neutral-500">{card.desc}</p>
|
<p className="text-xs text-neutral-500">{card.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface PricingData {
|
|||||||
rentalTitle: string;
|
rentalTitle: string;
|
||||||
rentalItems: { name: string; price: string; note?: string }[];
|
rentalItems: { name: string; price: string; note?: string }[];
|
||||||
rules: string[];
|
rules: string[];
|
||||||
|
showContactHint?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||||
@@ -63,6 +64,25 @@ export default function PricingEditorPage() {
|
|||||||
onChange={(v) => update({ ...data, subtitle: v })}
|
onChange={(v) => update({ ...data, subtitle: v })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={data.showContactHint !== false}
|
||||||
|
onClick={() => update({ ...data, showContactHint: data.showContactHint === false })}
|
||||||
|
className={`relative h-5 w-9 rounded-full transition-colors ${
|
||||||
|
data.showContactHint !== false ? "bg-gold" : "bg-neutral-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||||
|
data.showContactHint !== false ? "translate-x-4" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-neutral-400">Показывать контакты для записи (Instagram, Telegram, телефон)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
{/* Featured selector */}
|
{/* Featured selector */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const itemOptions = data.items
|
const itemOptions = data.items
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField";
|
import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField";
|
||||||
import { Plus, X, Trash2 } from "lucide-react";
|
import { Plus, X, Trash2 } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
|
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
|
||||||
|
|
||||||
interface ScheduleData {
|
interface ScheduleData {
|
||||||
@@ -1113,21 +1114,21 @@ export default function ScheduleEditorPage() {
|
|||||||
const [classTypes, setClassTypes] = useState<string[]>([]);
|
const [classTypes, setClassTypes] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/admin/team")
|
adminFetch("/api/admin/team")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((members: { name: string }[]) => {
|
.then((members: { name: string }[]) => {
|
||||||
setTrainers(members.map((m) => m.name));
|
setTrainers(members.map((m) => m.name));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
fetch("/api/admin/sections/contact")
|
adminFetch("/api/admin/sections/contact")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((contact: { addresses?: string[] }) => {
|
.then((contact: { addresses?: string[] }) => {
|
||||||
setAddresses(contact.addresses ?? []);
|
setAddresses(contact.addresses ?? []);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
fetch("/api/admin/sections/classes")
|
adminFetch("/api/admin/sections/classes")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((classes: { items?: { name: string }[] }) => {
|
.then((classes: { items?: { name: string }[] }) => {
|
||||||
setClassTypes((classes.items ?? []).map((c) => c.name));
|
setClassTypes((classes.items ?? []).map((c) => c.name));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter, useParams } from "next/navigation";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
|
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
|
||||||
import { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField } from "../../_components/FormField";
|
import { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField } from "../../_components/FormField";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { RichListItem, VictoryItem } from "@/types/content";
|
import type { RichListItem, VictoryItem } from "@/types/content";
|
||||||
|
|
||||||
function extractUsername(value: string): string {
|
function extractUsername(value: string): string {
|
||||||
@@ -55,7 +56,7 @@ export default function TeamMemberEditorPage() {
|
|||||||
setIgStatus("checking");
|
setIgStatus("checking");
|
||||||
igTimerRef.current = setTimeout(async () => {
|
igTimerRef.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`);
|
const res = await adminFetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`);
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
setIgStatus(result.valid ? "valid" : "invalid");
|
setIgStatus(result.valid ? "valid" : "invalid");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -106,7 +107,7 @@ export default function TeamMemberEditorPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNew) return;
|
if (isNew) return;
|
||||||
fetch(`/api/admin/team/${id}`)
|
adminFetch(`/api/admin/team/${id}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((member) => {
|
.then((member) => {
|
||||||
const username = extractUsername(member.instagram || "");
|
const username = extractUsername(member.instagram || "");
|
||||||
@@ -139,7 +140,7 @@ export default function TeamMemberEditorPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
const res = await fetch("/api/admin/team", {
|
const res = await adminFetch("/api/admin/team", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -148,7 +149,7 @@ export default function TeamMemberEditorPage() {
|
|||||||
router.push("/admin/team");
|
router.push("/admin/team");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch(`/api/admin/team/${id}`, {
|
const res = await adminFetch(`/api/admin/team/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -171,7 +172,7 @@ export default function TeamMemberEditorPage() {
|
|||||||
formData.append("folder", "team");
|
formData.append("folder", "team");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/upload", {
|
const res = await adminFetch("/api/admin/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
GripVertical,
|
GripVertical,
|
||||||
Check,
|
Check,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { TeamMember } from "@/types/content";
|
import type { TeamMember } from "@/types/content";
|
||||||
|
|
||||||
type Member = TeamMember & { id: number };
|
type Member = TeamMember & { id: number };
|
||||||
@@ -29,7 +30,7 @@ export default function TeamEditorPage() {
|
|||||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/admin/team")
|
adminFetch("/api/admin/team")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(setMembers)
|
.then(setMembers)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -38,7 +39,7 @@ export default function TeamEditorPage() {
|
|||||||
const saveOrder = useCallback(async (updated: Member[]) => {
|
const saveOrder = useCallback(async (updated: Member[]) => {
|
||||||
setMembers(updated);
|
setMembers(updated);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await fetch("/api/admin/team/reorder", {
|
await adminFetch("/api/admin/team/reorder", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
||||||
@@ -159,7 +160,7 @@ export default function TeamEditorPage() {
|
|||||||
|
|
||||||
async function deleteMember(id: number) {
|
async function deleteMember(id: number) {
|
||||||
if (!confirm("Удалить этого участника?")) return;
|
if (!confirm("Удалить этого участника?")) return;
|
||||||
await fetch(`/api/admin/team/${id}`, { method: "DELETE" });
|
await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
|
||||||
setMembers((prev) => prev.filter((m) => m.id !== id));
|
setMembers((prev) => prev.filter((m) => m.id !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
53
src/app/api/admin/group-bookings/route.ts
Normal file
53
src/app/api/admin/group-bookings/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getGroupBookings, toggleGroupBookingNotification, deleteGroupBooking, setGroupBookingStatus } from "@/lib/db";
|
||||||
|
import type { BookingStatus } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const bookings = getGroupBookings();
|
||||||
|
return NextResponse.json(bookings, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=30" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
if (body.action === "set-status") {
|
||||||
|
const { id, status, confirmation } = body;
|
||||||
|
const valid: BookingStatus[] = ["new", "contacted", "confirmed", "declined"];
|
||||||
|
if (!id || !valid.includes(status)) {
|
||||||
|
return NextResponse.json({ error: "id and valid status are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
setGroupBookingStatus(id, status, confirmation);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/group-bookings] error:", err);
|
||||||
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { getMcRegistrations, addMcRegistration, updateMcRegistration, deleteMcRegistration } from "@/lib/db";
|
import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration } from "@/lib/db";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const title = request.nextUrl.searchParams.get("title");
|
const title = request.nextUrl.searchParams.get("title");
|
||||||
if (!title) {
|
if (title) {
|
||||||
return NextResponse.json({ error: "title parameter is required" }, { status: 400 });
|
return NextResponse.json(getMcRegistrations(title));
|
||||||
}
|
}
|
||||||
const registrations = getMcRegistrations(title);
|
// No title = return all registrations
|
||||||
return NextResponse.json(registrations);
|
return NextResponse.json(getAllMcRegistrations());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -19,7 +19,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
const id = addMcRegistration(masterClassTitle.trim(), name.trim(), instagram.trim(), telegram?.trim() || undefined);
|
const id = addMcRegistration(masterClassTitle.trim(), name.trim(), instagram.trim(), telegram?.trim() || undefined);
|
||||||
return NextResponse.json({ ok: true, id });
|
return NextResponse.json({ ok: true, id });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error("[admin/mc-registrations] error:", err);
|
||||||
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,13 +28,29 @@ export async function POST(request: NextRequest) {
|
|||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Toggle notification status
|
||||||
|
if (body.action === "toggle-notify") {
|
||||||
|
const { id, field, value } = body;
|
||||||
|
if (!id || !field || typeof value !== "boolean") {
|
||||||
|
return NextResponse.json({ error: "id, field, value are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (field !== "notified_confirm" && field !== "notified_reminder") {
|
||||||
|
return NextResponse.json({ error: "Invalid field" }, { status: 400 });
|
||||||
|
}
|
||||||
|
toggleMcNotification(id, field, value);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular update
|
||||||
const { id, name, instagram, telegram } = body;
|
const { id, name, instagram, telegram } = body;
|
||||||
if (!id || !name || !instagram) {
|
if (!id || !name || !instagram) {
|
||||||
return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 });
|
return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
updateMcRegistration(id, name.trim(), instagram.trim(), telegram?.trim() || undefined);
|
updateMcRegistration(id, name.trim(), instagram.trim(), telegram?.trim() || undefined);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error("[admin/mc-registrations] error:", err);
|
||||||
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/app/api/admin/open-day/bookings/route.ts
Normal file
44
src/app/api/admin/open-day/bookings/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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 (err) {
|
||||||
|
console.error("[admin/open-day/bookings] error:", err);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
55
src/app/api/admin/open-day/classes/route.ts
Normal file
55
src/app/api/admin/open-day/classes/route.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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 (err) {
|
||||||
|
console.error("[admin/open-day/classes] error:", err);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
69
src/app/api/admin/open-day/route.ts
Normal file
69
src/app/api/admin/open-day/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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(), {
|
||||||
|
headers: { "Cache-Control": "private, max-age=60" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
// Warn if date is in the past
|
||||||
|
const eventDate = new Date(body.date + "T23:59:59");
|
||||||
|
if (eventDate < new Date()) {
|
||||||
|
return NextResponse.json({ error: "Дата не может быть в прошлом" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const id = createOpenDayEvent(body);
|
||||||
|
return NextResponse.json({ ok: true, id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/open-day] error:", err);
|
||||||
|
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;
|
||||||
|
if (data.date) {
|
||||||
|
const eventDate = new Date(data.date + "T23:59:59");
|
||||||
|
if (eventDate < new Date()) {
|
||||||
|
return NextResponse.json({ error: "Дата не может быть в прошлом" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateOpenDayEvent(id, data);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/open-day] error:", err);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
37
src/app/api/admin/reminders/route.ts
Normal file
37
src/app/api/admin/reminders/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getUpcomingReminders, setReminderStatus } from "@/lib/db";
|
||||||
|
import type { ReminderStatus } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(getUpcomingReminders());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { table, id, status } = body;
|
||||||
|
|
||||||
|
const validTables = ["mc_registrations", "group_bookings", "open_day_bookings"];
|
||||||
|
const validStatuses = ["pending", "coming", "cancelled", null];
|
||||||
|
|
||||||
|
if (!validTables.includes(table)) {
|
||||||
|
return NextResponse.json({ error: "Invalid table" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!id || typeof id !== "number") {
|
||||||
|
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
return NextResponse.json({ error: "Invalid status" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
setReminderStatus(
|
||||||
|
table as "mc_registrations" | "group_bookings" | "open_day_bookings",
|
||||||
|
id,
|
||||||
|
status as ReminderStatus | null
|
||||||
|
);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/reminders] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { getSection, setSection, SECTION_KEYS } from "@/lib/db";
|
import { getSection, setSection, SECTION_KEYS } from "@/lib/db";
|
||||||
import { siteContent } from "@/data/content";
|
import { siteContent } from "@/data/content";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { invalidateContentCache } from "@/lib/content";
|
||||||
|
|
||||||
type Params = { params: Promise<{ key: string }> };
|
type Params = { params: Promise<{ key: string }> };
|
||||||
|
|
||||||
@@ -23,7 +24,9 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=60" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: NextRequest, { params }: Params) {
|
export async function PUT(request: NextRequest, { params }: Params) {
|
||||||
@@ -34,6 +37,7 @@ export async function PUT(request: NextRequest, { params }: Params) {
|
|||||||
|
|
||||||
const data = await request.json();
|
const data = await request.json();
|
||||||
setSection(key, data);
|
setSection(key, data);
|
||||||
|
invalidateContentCache();
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -4,9 +4,18 @@ import { revalidatePath } from "next/cache";
|
|||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
function parseId(raw: string): number | null {
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isInteger(n) && n > 0 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const member = getTeamMember(Number(id));
|
const numId = parseId(id);
|
||||||
|
if (!numId) {
|
||||||
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const member = getTeamMember(numId);
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -15,15 +24,23 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
|
|
||||||
export async function PUT(request: NextRequest, { params }: Params) {
|
export async function PUT(request: NextRequest, { params }: Params) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const numId = parseId(id);
|
||||||
|
if (!numId) {
|
||||||
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
|
}
|
||||||
const data = await request.json();
|
const data = await request.json();
|
||||||
updateTeamMember(Number(id), data);
|
updateTeamMember(numId, data);
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(_request: NextRequest, { params }: Params) {
|
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
deleteTeamMember(Number(id));
|
const numId = parseId(id);
|
||||||
|
if (!numId) {
|
||||||
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
deleteTeamMember(numId);
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { revalidatePath } from "next/cache";
|
|||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
const { ids } = await request.json() as { ids: number[] };
|
const { ids } = await request.json() as { ids: number[] };
|
||||||
|
|
||||||
if (!Array.isArray(ids) || ids.length === 0) {
|
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100 || !ids.every((id) => Number.isInteger(id) && id > 0)) {
|
||||||
return NextResponse.json({ error: "ids array required" }, { status: 400 });
|
return NextResponse.json({ error: "ids must be a non-empty array of positive integers (max 100)" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
reorderTeamMembers(ids);
|
reorderTeamMembers(ids);
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import type { RichListItem, VictoryItem } from "@/types/content";
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const members = getTeamMembers();
|
const members = getTeamMembers();
|
||||||
return NextResponse.json(members);
|
return NextResponse.json(members, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=60" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|||||||
8
src/app/api/admin/unread-counts/route.ts
Normal file
8
src/app/api/admin/unread-counts/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getUnreadBookingCounts } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(getUnreadBookingCounts(), {
|
||||||
|
headers: { "Cache-Control": "private, max-age=15" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,12 +3,15 @@ import { writeFile, mkdir } from "fs/promises";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
|
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
|
||||||
|
const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"];
|
||||||
|
const ALLOWED_FOLDERS = ["team", "master-classes", "news", "classes"];
|
||||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
const folder = (formData.get("folder") as string) || "team";
|
const rawFolder = (formData.get("folder") as string) || "team";
|
||||||
|
const folder = ALLOWED_FOLDERS.includes(rawFolder) ? rawFolder : "team";
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||||
@@ -28,8 +31,14 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize filename
|
// Validate and sanitize filename
|
||||||
const ext = path.extname(file.name) || ".webp";
|
const ext = path.extname(file.name).toLowerCase() || ".webp";
|
||||||
|
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid file extension" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
const baseName = file.name
|
const baseName = file.name
|
||||||
.replace(ext, "")
|
.replace(ext, "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export async function GET(request: NextRequest) {
|
|||||||
// Instagram returns 200 for existing profiles, 404 for non-existing
|
// Instagram returns 200 for existing profiles, 404 for non-existing
|
||||||
const valid = res.ok;
|
const valid = res.ok;
|
||||||
return NextResponse.json({ valid });
|
return NextResponse.json({ valid });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error("[admin/validate-instagram] error:", err);
|
||||||
// Network error or timeout — don't block the user
|
// Network error or timeout — don't block the user
|
||||||
return NextResponse.json({ valid: true, uncertain: true });
|
return NextResponse.json({ valid: true, uncertain: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { verifyPassword, signToken, COOKIE_NAME } from "@/lib/auth";
|
import { verifyPassword, signToken, generateCsrfToken, COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = await request.json() as { password?: string };
|
const body = await request.json() as { password?: string };
|
||||||
@@ -9,6 +9,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = signToken();
|
const token = signToken();
|
||||||
|
const csrfToken = generateCsrfToken();
|
||||||
const response = NextResponse.json({ ok: true });
|
const response = NextResponse.json({ ok: true });
|
||||||
|
|
||||||
response.cookies.set(COOKIE_NAME, token, {
|
response.cookies.set(COOKIE_NAME, token, {
|
||||||
@@ -16,7 +17,15 @@ export async function POST(request: NextRequest) {
|
|||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 60 * 60 * 24, // 24 hours
|
maxAge: 60 * 60 * 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
|
||||||
|
httpOnly: false, // JS must read this to send as header
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "strict",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
41
src/app/api/group-booking/route.ts
Normal file
41
src/app/api/group-booking/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { addGroupBooking } from "@/lib/db";
|
||||||
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
|
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 5, 60_000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Слишком много запросов. Попробуйте через минуту." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, phone, groupInfo, instagram, telegram } = body;
|
||||||
|
|
||||||
|
const cleanName = sanitizeName(name);
|
||||||
|
if (!cleanName) {
|
||||||
|
return NextResponse.json({ error: "Имя обязательно" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanPhone = sanitizePhone(phone);
|
||||||
|
if (!cleanPhone) {
|
||||||
|
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = addGroupBooking(
|
||||||
|
cleanName,
|
||||||
|
cleanPhone,
|
||||||
|
sanitizeText(groupInfo),
|
||||||
|
sanitizeHandle(instagram),
|
||||||
|
sanitizeHandle(telegram)
|
||||||
|
);
|
||||||
|
return NextResponse.json({ ok: true, id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[group-booking] POST error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { COOKIE_NAME } from "@/lib/auth";
|
import { COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const response = NextResponse.json({ ok: true });
|
const response = NextResponse.json({ ok: true });
|
||||||
@@ -8,5 +8,9 @@ export async function POST() {
|
|||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 0,
|
maxAge: 0,
|
||||||
});
|
});
|
||||||
|
response.cookies.set(CSRF_COOKIE_NAME, "", {
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,47 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { addMcRegistration } from "@/lib/db";
|
import { addMcRegistration } from "@/lib/db";
|
||||||
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
|
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 5, 60_000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Слишком много запросов. Попробуйте через минуту." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { masterClassTitle, name, instagram, telegram } = body;
|
const { masterClassTitle, name, phone, instagram, telegram } = body;
|
||||||
|
|
||||||
if (!masterClassTitle || typeof masterClassTitle !== "string") {
|
const cleanTitle = sanitizeText(masterClassTitle, 200);
|
||||||
|
if (!cleanTitle) {
|
||||||
return NextResponse.json({ error: "masterClassTitle is required" }, { status: 400 });
|
return NextResponse.json({ error: "masterClassTitle is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!name || typeof name !== "string" || !name.trim()) {
|
|
||||||
return NextResponse.json({ error: "name is required" }, { status: 400 });
|
const cleanName = sanitizeName(name);
|
||||||
|
if (!cleanName) {
|
||||||
|
return NextResponse.json({ error: "Имя обязательно" }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!instagram || typeof instagram !== "string" || !instagram.trim()) {
|
|
||||||
return NextResponse.json({ error: "Instagram аккаунт обязателен" }, { status: 400 });
|
const cleanPhone = sanitizePhone(phone);
|
||||||
|
if (!cleanPhone) {
|
||||||
|
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = addMcRegistration(
|
const id = addMcRegistration(
|
||||||
masterClassTitle.trim(),
|
cleanTitle,
|
||||||
name.trim(),
|
cleanName,
|
||||||
instagram.trim(),
|
sanitizeHandle(instagram) ?? "",
|
||||||
telegram && typeof telegram === "string" ? telegram.trim() : undefined
|
sanitizeHandle(telegram),
|
||||||
|
cleanPhone
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, id });
|
return NextResponse.json({ ok: true, id });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error("[master-class-register] POST error:", err);
|
||||||
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/app/api/open-day-register/route.ts
Normal file
60
src/app/api/open-day-register/route.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
addOpenDayBooking,
|
||||||
|
getPersonOpenDayBookings,
|
||||||
|
getOpenDayEvent,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
|
import { sanitizeName, sanitizePhone, sanitizeHandle } from "@/lib/validation";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 10, 60_000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Слишком много запросов. Попробуйте через минуту." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { classId, eventId, name, phone, instagram, telegram } = body;
|
||||||
|
|
||||||
|
if (!classId || !eventId) {
|
||||||
|
return NextResponse.json({ error: "classId and eventId are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanName = sanitizeName(name);
|
||||||
|
if (!cleanName) {
|
||||||
|
return NextResponse.json({ error: "Имя обязательно" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanPhone = sanitizePhone(phone);
|
||||||
|
if (!cleanPhone) {
|
||||||
|
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = addOpenDayBooking(classId, eventId, {
|
||||||
|
name: cleanName,
|
||||||
|
phone: cleanPhone,
|
||||||
|
instagram: sanitizeHandle(instagram),
|
||||||
|
telegram: sanitizeHandle(telegram),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
console.error("[open-day-register] POST error:", e);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,15 +12,19 @@ import { BackToTop } from "@/components/ui/BackToTop";
|
|||||||
import { Header } from "@/components/layout/Header";
|
import { Header } from "@/components/layout/Header";
|
||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import { getContent } from "@/lib/content";
|
import { getContent } from "@/lib/content";
|
||||||
|
import { OpenDay } from "@/components/sections/OpenDay";
|
||||||
|
import { getActiveOpenDay } from "@/lib/openDay";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const content = getContent();
|
const content = getContent();
|
||||||
|
const openDayData = getActiveOpenDay();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<Hero data={content.hero} />
|
<Hero data={content.hero} />
|
||||||
|
{openDayData && <OpenDay data={openDayData} />}
|
||||||
<About
|
<About
|
||||||
data={content.about}
|
data={content.about}
|
||||||
stats={{
|
stats={{
|
||||||
|
|||||||
@@ -128,6 +128,7 @@
|
|||||||
filter: blur(80px);
|
filter: blur(80px);
|
||||||
animation: pulse-glow 6s ease-in-out infinite;
|
animation: pulse-glow 6s ease-in-out infinite;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
will-change: filter, transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Gradient Text ===== */
|
/* ===== Gradient Text ===== */
|
||||||
@@ -322,6 +323,22 @@
|
|||||||
mask-composite: exclude;
|
mask-composite: exclude;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
will-change: background-position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 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 { BRAND, NAV_LINKS } from "@/lib/constants";
|
||||||
import { UI_CONFIG } from "@/lib/config";
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
import { HeroLogo } from "@/components/ui/HeroLogo";
|
import { HeroLogo } from "@/components/ui/HeroLogo";
|
||||||
import { BookingModal } from "@/components/ui/BookingModal";
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
@@ -15,8 +15,15 @@ export function Header() {
|
|||||||
const [bookingOpen, setBookingOpen] = useState(false);
|
const [bookingOpen, setBookingOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let ticking = false;
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
|
if (!ticking) {
|
||||||
|
ticking = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
setScrolled(window.scrollY > UI_CONFIG.scrollThresholds.header);
|
setScrolled(window.scrollY > UI_CONFIG.scrollThresholds.header);
|
||||||
|
ticking = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
@@ -100,14 +107,14 @@ export function Header() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden items-center gap-8 md:flex">
|
<nav className="hidden items-center gap-3 lg:gap-5 xl:gap-6 lg:flex">
|
||||||
{visibleLinks.map((link) => {
|
{visibleLinks.map((link) => {
|
||||||
const isActive = activeSection === link.href.replace("#", "");
|
const isActive = activeSection === link.href.replace("#", "");
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className={`relative py-1 text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${
|
className={`relative whitespace-nowrap py-1 text-xs lg:text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${
|
||||||
isActive
|
isActive
|
||||||
? "text-gold-light after:w-full"
|
? "text-gold-light after:w-full"
|
||||||
: "text-neutral-400 after:w-0 hover:text-white hover:after:w-full"
|
: "text-neutral-400 after:w-0 hover:text-white hover:after:w-full"
|
||||||
@@ -125,10 +132,11 @@ export function Header() {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 md:hidden">
|
<div className="flex items-center gap-2 lg:hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
aria-label="Меню"
|
aria-label={menuOpen ? "Закрыть меню" : "Открыть меню"}
|
||||||
|
aria-expanded={menuOpen}
|
||||||
className="rounded-lg p-2 text-neutral-400 transition-colors hover:text-white"
|
className="rounded-lg p-2 text-neutral-400 transition-colors hover:text-white"
|
||||||
>
|
>
|
||||||
{menuOpen ? <X size={24} /> : <Menu size={24} />}
|
{menuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||||
@@ -138,7 +146,7 @@ export function Header() {
|
|||||||
|
|
||||||
{/* Mobile menu */}
|
{/* Mobile menu */}
|
||||||
<div
|
<div
|
||||||
className={`overflow-hidden transition-all duration-300 md:hidden ${
|
className={`overflow-hidden transition-all duration-300 lg:hidden ${
|
||||||
menuOpen ? "max-h-80 opacity-100" : "max-h-0 opacity-0"
|
menuOpen ? "max-h-80 opacity-100" : "max-h-0 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -172,17 +180,8 @@ export function Header() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating booking button — visible on scroll, mobile */}
|
|
||||||
<button
|
|
||||||
onClick={() => setBookingOpen(true)}
|
|
||||||
className={`fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-full bg-gold px-5 py-3 text-sm font-semibold text-black shadow-lg shadow-gold/25 transition-all duration-500 hover:bg-gold-light hover:shadow-xl hover:shadow-gold/30 cursor-pointer md:hidden ${
|
|
||||||
scrolled ? "translate-y-0 opacity-100" : "translate-y-16 opacity-0 pointer-events-none"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Записаться
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<BookingModal open={bookingOpen} onClose={() => setBookingOpen(false)} />
|
<SignupModal open={bookingOpen} onClose={() => setBookingOpen(false)} endpoint="/api/group-booking" />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export function About({ data: about, stats }: AboutProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-14 mx-auto max-w-2xl space-y-8 text-center">
|
<div className="mt-14 mx-auto max-w-2xl space-y-8 text-center">
|
||||||
{about.paragraphs.map((text, i) => (
|
{about.paragraphs.map((text) => (
|
||||||
<Reveal key={i}>
|
<Reveal key={text}>
|
||||||
<p className="text-xl leading-relaxed text-neutral-600 dark:text-neutral-300 sm:text-2xl">
|
<p className="text-xl leading-relaxed text-neutral-600 dark:text-neutral-300 sm:text-2xl">
|
||||||
{text}
|
{text}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export function Classes({ data: classes }: ClassesProps) {
|
|||||||
src={item.images[0]}
|
src={item.images[0]}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
fill
|
fill
|
||||||
|
loading="lazy"
|
||||||
|
sizes="(min-width: 1024px) 60vw, 100vw"
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
{/* Gradient overlay */}
|
{/* Gradient overlay */}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { FloatingHearts } from "@/components/ui/FloatingHearts";
|
import { FloatingHearts } from "@/components/ui/FloatingHearts";
|
||||||
import { HeroLogo } from "@/components/ui/HeroLogo";
|
import { HeroLogo } from "@/components/ui/HeroLogo";
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
import type { SiteContent } from "@/types/content";
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
interface HeroProps {
|
interface HeroProps {
|
||||||
@@ -11,9 +11,65 @@ interface HeroProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Hero({ data: hero }: HeroProps) {
|
export function Hero({ data: hero }: HeroProps) {
|
||||||
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
|
const scrolledRef = useRef(false);
|
||||||
|
|
||||||
|
const scrollToNext = useCallback(() => {
|
||||||
|
const hero = sectionRef.current;
|
||||||
|
if (!hero) return;
|
||||||
|
// Find the next sibling section
|
||||||
|
let next = hero.nextElementSibling;
|
||||||
|
while (next && next.tagName !== "SECTION") {
|
||||||
|
next = next.nextElementSibling;
|
||||||
|
}
|
||||||
|
next?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hero = sectionRef.current;
|
||||||
|
if (!hero) return;
|
||||||
|
|
||||||
|
function handleWheel(e: WheelEvent) {
|
||||||
|
// Only trigger when scrolling down and still inside hero
|
||||||
|
if (e.deltaY <= 0 || scrolledRef.current) return;
|
||||||
|
if (window.scrollY > 10) return; // already scrolled past hero top
|
||||||
|
|
||||||
|
scrolledRef.current = true;
|
||||||
|
scrollToNext();
|
||||||
|
|
||||||
|
// Reset after animation completes
|
||||||
|
setTimeout(() => { scrolledRef.current = false; }, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchStart(e: TouchEvent) {
|
||||||
|
(hero as HTMLElement).dataset.touchY = String(e.touches[0].clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd(e: TouchEvent) {
|
||||||
|
const startY = Number((hero as HTMLElement).dataset.touchY);
|
||||||
|
const endY = e.changedTouches[0].clientY;
|
||||||
|
const diff = startY - endY;
|
||||||
|
|
||||||
|
// Swipe down (finger moves up) with enough distance
|
||||||
|
if (diff > 50 && !scrolledRef.current && window.scrollY < 10) {
|
||||||
|
scrolledRef.current = true;
|
||||||
|
scrollToNext();
|
||||||
|
setTimeout(() => { scrolledRef.current = false; }, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hero.addEventListener("wheel", handleWheel, { passive: true });
|
||||||
|
hero.addEventListener("touchstart", handleTouchStart, { passive: true });
|
||||||
|
hero.addEventListener("touchend", handleTouchEnd, { passive: true });
|
||||||
|
return () => {
|
||||||
|
hero.removeEventListener("wheel", handleWheel);
|
||||||
|
hero.removeEventListener("touchstart", handleTouchStart);
|
||||||
|
hero.removeEventListener("touchend", handleTouchEnd);
|
||||||
|
};
|
||||||
|
}, [scrollToNext]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
|
<section ref={sectionRef} className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
|
||||||
{/* Animated gradient background */}
|
{/* Animated gradient background */}
|
||||||
<div className="hero-bg-gradient absolute inset-0" />
|
<div className="hero-bg-gradient absolute inset-0" />
|
||||||
|
|
||||||
@@ -72,16 +128,6 @@ export function Hero({ data: hero }: HeroProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll indicator */}
|
|
||||||
<div className="hero-cta absolute bottom-8 left-1/2 -translate-x-1/2">
|
|
||||||
<a
|
|
||||||
href="#about"
|
|
||||||
className="flex flex-col items-center gap-1 text-neutral-600 transition-colors hover:text-gold-light"
|
|
||||||
>
|
|
||||||
<span className="text-xs uppercase tracking-widest">Scroll</span>
|
|
||||||
<ChevronDown size={20} className="animate-bounce" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Image from "next/image";
|
|||||||
import { Calendar, Clock, User, MapPin, Instagram } from "lucide-react";
|
import { Calendar, Clock, User, MapPin, Instagram } from "lucide-react";
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { MasterClassSignupModal } from "@/components/ui/MasterClassSignupModal";
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
|
import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
|
||||||
|
|
||||||
interface MasterClassesProps {
|
interface MasterClassesProps {
|
||||||
@@ -98,6 +98,7 @@ function MasterClassCard({
|
|||||||
src={item.image}
|
src={item.image}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
fill
|
fill
|
||||||
|
loading="lazy"
|
||||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||||
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
/>
|
/>
|
||||||
@@ -162,6 +163,7 @@ function MasterClassCard({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.open(item.instagramUrl, "_blank", "noopener,noreferrer")
|
window.open(item.instagramUrl, "_blank", "noopener,noreferrer")
|
||||||
}
|
}
|
||||||
|
aria-label={`Instagram ${item.trainer}`}
|
||||||
className="flex h-[46px] w-[46px] items-center justify-center rounded-xl border border-white/10 text-white/40 transition-all hover:border-gold/30 hover:text-gold cursor-pointer"
|
className="flex h-[46px] w-[46px] items-center justify-center rounded-xl border border-white/10 text-white/40 transition-all hover:border-gold/30 hover:text-gold cursor-pointer"
|
||||||
>
|
>
|
||||||
<Instagram size={18} />
|
<Instagram size={18} />
|
||||||
@@ -224,9 +226,9 @@ export function MasterClasses({ data }: MasterClassesProps) {
|
|||||||
) : (
|
) : (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="mx-auto mt-10 grid max-w-5xl grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="mx-auto mt-10 grid max-w-5xl grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{upcoming.map((item, i) => (
|
{upcoming.map((item) => (
|
||||||
<MasterClassCard
|
<MasterClassCard
|
||||||
key={i}
|
key={item.title}
|
||||||
item={item}
|
item={item}
|
||||||
onSignup={() => setSignupTitle(item.title)}
|
onSignup={() => setSignupTitle(item.title)}
|
||||||
/>
|
/>
|
||||||
@@ -236,10 +238,12 @@ export function MasterClasses({ data }: MasterClassesProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MasterClassSignupModal
|
<SignupModal
|
||||||
open={signupTitle !== null}
|
open={signupTitle !== null}
|
||||||
onClose={() => setSignupTitle(null)}
|
onClose={() => setSignupTitle(null)}
|
||||||
masterClassTitle={signupTitle ?? ""}
|
subtitle={signupTitle ?? ""}
|
||||||
|
endpoint="/api/master-class-register"
|
||||||
|
extraBody={{ masterClassTitle: signupTitle }}
|
||||||
successMessage={data.successMessage}
|
successMessage={data.successMessage}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function FeaturedArticle({
|
|||||||
src={item.image}
|
src={item.image}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
fill
|
fill
|
||||||
|
loading="lazy"
|
||||||
sizes="(min-width: 768px) 80vw, 100vw"
|
sizes="(min-width: 768px) 80vw, 100vw"
|
||||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
@@ -84,6 +85,7 @@ function CompactArticle({
|
|||||||
src={item.image}
|
src={item.image}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
fill
|
fill
|
||||||
|
loading="lazy"
|
||||||
sizes="112px"
|
sizes="112px"
|
||||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
@@ -130,9 +132,9 @@ export function News({ data }: NewsProps) {
|
|||||||
{rest.length > 0 && (
|
{rest.length > 0 && (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="rounded-2xl bg-neutral-50/80 px-5 sm:px-6 dark:bg-white/[0.02]">
|
<div className="rounded-2xl bg-neutral-50/80 px-5 sm:px-6 dark:bg-white/[0.02]">
|
||||||
{rest.map((item, i) => (
|
{rest.map((item) => (
|
||||||
<CompactArticle
|
<CompactArticle
|
||||||
key={i}
|
key={item.title}
|
||||||
item={item}
|
item={item}
|
||||||
onClick={() => setSelected(item)}
|
onClick={() => setSelected(item)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
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 centered>{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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CreditCard, Building2, ScrollText, Crown, Sparkles } from "lucide-react";
|
import { CreditCard, Building2, ScrollText, Crown, Sparkles, Instagram, Send, Phone } from "lucide-react";
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { BookingModal } from "@/components/ui/BookingModal";
|
import { BRAND } from "@/lib/constants";
|
||||||
import type { SiteContent } from "@/types/content";
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
type Tab = "prices" | "rental" | "rules";
|
type Tab = "prices" | "rental" | "rules";
|
||||||
@@ -13,9 +13,42 @@ interface PricingProps {
|
|||||||
data: SiteContent["pricing"];
|
data: SiteContent["pricing"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ContactHint() {
|
||||||
|
return (
|
||||||
|
<div className="mt-5 flex flex-wrap items-center justify-center gap-3 text-xs text-neutral-500">
|
||||||
|
<span>Для записи и бронирования:</span>
|
||||||
|
<a
|
||||||
|
href={BRAND.instagram}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-white/[0.06] px-3 py-1.5 text-pink-400 hover:text-pink-300 hover:border-pink-400/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Instagram size={12} />
|
||||||
|
Instagram
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://t.me/blackheartdancehouse"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-white/[0.06] px-3 py-1.5 text-blue-400 hover:text-blue-300 hover:border-blue-400/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Send size={12} />
|
||||||
|
Telegram
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="tel:+375293897001"
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-white/[0.06] px-3 py-1.5 text-emerald-400 hover:text-emerald-300 hover:border-emerald-400/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Phone size={12} />
|
||||||
|
Позвонить
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Pricing({ data: pricing }: PricingProps) {
|
export function Pricing({ data: pricing }: PricingProps) {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("prices");
|
const [activeTab, setActiveTab] = useState<Tab>("prices");
|
||||||
const [bookingOpen, setBookingOpen] = useState(false);
|
const showHint = pricing.showContactHint !== false; // default true
|
||||||
|
|
||||||
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||||
{ id: "prices", label: "Абонементы", icon: <CreditCard size={16} /> },
|
{ id: "prices", label: "Абонементы", icon: <CreditCard size={16} /> },
|
||||||
@@ -68,13 +101,12 @@ export function Pricing({ data: pricing }: PricingProps) {
|
|||||||
{regularItems.map((item, i) => {
|
{regularItems.map((item, i) => {
|
||||||
const isPopular = item.popular ?? false;
|
const isPopular = item.popular ?? false;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => setBookingOpen(true)}
|
className={`group relative rounded-2xl border p-5 transition-all duration-300 ${
|
||||||
className={`group relative cursor-pointer rounded-2xl border p-5 transition-all duration-300 text-left ${
|
|
||||||
isPopular
|
isPopular
|
||||||
? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10 hover:shadow-xl hover:shadow-gold/20"
|
? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10"
|
||||||
: "border-neutral-200 bg-white hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]"
|
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Popular badge */}
|
{/* Popular badge */}
|
||||||
@@ -105,14 +137,14 @@ export function Pricing({ data: pricing }: PricingProps) {
|
|||||||
{item.price}
|
{item.price}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Featured — big card */}
|
{/* Featured — big card */}
|
||||||
{featuredItem && (
|
{featuredItem && (
|
||||||
<button onClick={() => setBookingOpen(true)} className="mt-6 w-full cursor-pointer text-left team-card-glitter rounded-2xl border border-gold/30 bg-gradient-to-r from-gold/10 via-gold/5 to-gold/10 dark:from-gold/[0.06] dark:via-transparent dark:to-gold/[0.06] p-6 sm:p-8 transition-shadow duration-300 hover:shadow-xl hover:shadow-gold/20">
|
<div className="mt-6 w-full team-card-glitter rounded-2xl border border-gold/30 bg-gradient-to-r from-gold/10 via-gold/5 to-gold/10 dark:from-gold/[0.06] dark:via-transparent dark:to-gold/[0.06] p-6 sm:p-8">
|
||||||
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
||||||
<div className="text-center sm:text-left">
|
<div className="text-center sm:text-left">
|
||||||
<div className="flex items-center justify-center gap-2 sm:justify-start">
|
<div className="flex items-center justify-center gap-2 sm:justify-start">
|
||||||
@@ -131,8 +163,10 @@ export function Pricing({ data: pricing }: PricingProps) {
|
|||||||
{featuredItem.price}
|
{featuredItem.price}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showHint && <ContactHint />}
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
@@ -142,10 +176,9 @@ export function Pricing({ data: pricing }: PricingProps) {
|
|||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
||||||
{pricing.rentalItems.map((item, i) => (
|
{pricing.rentalItems.map((item, i) => (
|
||||||
<button
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => setBookingOpen(true)}
|
className="flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||||
className="w-full cursor-pointer text-left flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 transition-colors hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]"
|
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-neutral-900 dark:text-white">
|
<p className="font-medium text-neutral-900 dark:text-white">
|
||||||
@@ -160,8 +193,10 @@ export function Pricing({ data: pricing }: PricingProps) {
|
|||||||
<span className="shrink-0 font-display text-xl font-bold text-gold-dark dark:text-gold-light">
|
<span className="shrink-0 font-display text-xl font-bold text-gold-dark dark:text-gold-light">
|
||||||
{item.price}
|
{item.price}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{showHint && <ContactHint />}
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
@@ -187,8 +222,6 @@ export function Pricing({ data: pricing }: PricingProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BookingModal open={bookingOpen} onClose={() => setBookingOpen(false)} />
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useCallback } from "react";
|
import { useReducer, useMemo, useCallback } from "react";
|
||||||
import { BookingModal } from "@/components/ui/BookingModal";
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
import { CalendarDays, Users, LayoutGrid } from "lucide-react";
|
import { CalendarDays, Users, LayoutGrid } from "lucide-react";
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
@@ -16,20 +16,74 @@ import type { SiteContent } from "@/types/content";
|
|||||||
type ViewMode = "days" | "groups";
|
type ViewMode = "days" | "groups";
|
||||||
type LocationMode = "all" | number;
|
type LocationMode = "all" | number;
|
||||||
|
|
||||||
|
interface ScheduleState {
|
||||||
|
locationMode: LocationMode;
|
||||||
|
viewMode: ViewMode;
|
||||||
|
filterTrainer: string | null;
|
||||||
|
filterType: string | null;
|
||||||
|
filterStatus: StatusFilter;
|
||||||
|
filterTime: TimeFilter;
|
||||||
|
filterDaySet: Set<string>;
|
||||||
|
bookingGroup: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScheduleAction =
|
||||||
|
| { type: "SET_LOCATION"; mode: LocationMode }
|
||||||
|
| { type: "SET_VIEW"; mode: ViewMode }
|
||||||
|
| { type: "SET_TRAINER"; value: string | null }
|
||||||
|
| { type: "SET_TYPE"; value: string | null }
|
||||||
|
| { type: "SET_STATUS"; value: StatusFilter }
|
||||||
|
| { type: "SET_TIME"; value: TimeFilter }
|
||||||
|
| { type: "TOGGLE_DAY"; day: string }
|
||||||
|
| { type: "SET_BOOKING"; value: string | null }
|
||||||
|
| { type: "CLEAR_FILTERS" };
|
||||||
|
|
||||||
|
const initialState: ScheduleState = {
|
||||||
|
locationMode: "all",
|
||||||
|
viewMode: "days",
|
||||||
|
filterTrainer: null,
|
||||||
|
filterType: null,
|
||||||
|
filterStatus: "all",
|
||||||
|
filterTime: "all",
|
||||||
|
filterDaySet: new Set(),
|
||||||
|
bookingGroup: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function scheduleReducer(state: ScheduleState, action: ScheduleAction): ScheduleState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_LOCATION":
|
||||||
|
return { ...initialState, viewMode: state.viewMode, locationMode: action.mode };
|
||||||
|
case "SET_VIEW":
|
||||||
|
return { ...state, viewMode: action.mode };
|
||||||
|
case "SET_TRAINER":
|
||||||
|
return { ...state, filterTrainer: action.value };
|
||||||
|
case "SET_TYPE":
|
||||||
|
return { ...state, filterType: action.value };
|
||||||
|
case "SET_STATUS":
|
||||||
|
return { ...state, filterStatus: action.value };
|
||||||
|
case "SET_TIME":
|
||||||
|
return { ...state, filterTime: action.value };
|
||||||
|
case "TOGGLE_DAY": {
|
||||||
|
const next = new Set(state.filterDaySet);
|
||||||
|
if (next.has(action.day)) next.delete(action.day);
|
||||||
|
else next.add(action.day);
|
||||||
|
return { ...state, filterDaySet: next };
|
||||||
|
}
|
||||||
|
case "SET_BOOKING":
|
||||||
|
return { ...state, bookingGroup: action.value };
|
||||||
|
case "CLEAR_FILTERS":
|
||||||
|
return { ...state, filterTrainer: null, filterType: null, filterStatus: "all", filterTime: "all", filterDaySet: new Set() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ScheduleProps {
|
interface ScheduleProps {
|
||||||
data: SiteContent["schedule"];
|
data: SiteContent["schedule"];
|
||||||
classItems?: { name: string; color?: string }[];
|
classItems?: { name: string; color?: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
||||||
const [locationMode, setLocationMode] = useState<LocationMode>("all");
|
const [state, dispatch] = useReducer(scheduleReducer, initialState);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("days");
|
const { locationMode, viewMode, filterTrainer, filterType, filterStatus, filterTime, filterDaySet, bookingGroup } = state;
|
||||||
const [filterTrainer, setFilterTrainer] = useState<string | null>(null);
|
|
||||||
const [filterType, setFilterType] = useState<string | null>(null);
|
|
||||||
const [filterStatus, setFilterStatus] = useState<StatusFilter>("all");
|
|
||||||
const [filterTime, setFilterTime] = useState<TimeFilter>("all");
|
|
||||||
const [filterDaySet, setFilterDaySet] = useState<Set<string>>(new Set());
|
|
||||||
const [bookingGroup, setBookingGroup] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const isAllMode = locationMode === "all";
|
const isAllMode = locationMode === "all";
|
||||||
|
|
||||||
@@ -38,13 +92,18 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setFilterTrainer = useCallback((value: string | null) => dispatch({ type: "SET_TRAINER", value }), []);
|
||||||
|
const setFilterType = useCallback((value: string | null) => dispatch({ type: "SET_TYPE", value }), []);
|
||||||
|
const setFilterStatus = useCallback((value: StatusFilter) => dispatch({ type: "SET_STATUS", value }), []);
|
||||||
|
const setFilterTime = useCallback((value: TimeFilter) => dispatch({ type: "SET_TIME", value }), []);
|
||||||
|
|
||||||
const setFilterTrainerFromCard = useCallback((trainer: string | null) => {
|
const setFilterTrainerFromCard = useCallback((trainer: string | null) => {
|
||||||
setFilterTrainer(trainer);
|
dispatch({ type: "SET_TRAINER", value: trainer });
|
||||||
if (trainer) scrollToSchedule();
|
if (trainer) scrollToSchedule();
|
||||||
}, [scrollToSchedule]);
|
}, [scrollToSchedule]);
|
||||||
|
|
||||||
const setFilterTypeFromCard = useCallback((type: string | null) => {
|
const setFilterTypeFromCard = useCallback((type: string | null) => {
|
||||||
setFilterType(type);
|
dispatch({ type: "SET_TYPE", value: type });
|
||||||
if (type) scrollToSchedule();
|
if (type) scrollToSchedule();
|
||||||
}, [scrollToSchedule]);
|
}, [scrollToSchedule]);
|
||||||
|
|
||||||
@@ -146,11 +205,7 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all" || filterTime !== "all" || filterDaySet.size > 0);
|
const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all" || filterTime !== "all" || filterDaySet.size > 0);
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
setFilterTrainer(null);
|
dispatch({ type: "CLEAR_FILTERS" });
|
||||||
setFilterType(null);
|
|
||||||
setFilterStatus("all");
|
|
||||||
setFilterTime("all");
|
|
||||||
setFilterDaySet(new Set());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Available days for the day filter
|
// Available days for the day filter
|
||||||
@@ -160,19 +215,27 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
function toggleDay(day: string) {
|
function toggleDay(day: string) {
|
||||||
setFilterDaySet((prev) => {
|
dispatch({ type: "TOGGLE_DAY", day });
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(day)) next.delete(day);
|
|
||||||
else next.add(day);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchLocation(mode: LocationMode) {
|
function switchLocation(mode: LocationMode) {
|
||||||
setLocationMode(mode);
|
dispatch({ type: "SET_LOCATION", mode });
|
||||||
clearFilters();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gridLayout = useMemo(() => {
|
||||||
|
const len = filteredDays.length;
|
||||||
|
const cls = len >= 7 ? "sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7"
|
||||||
|
: len >= 6 ? "sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"
|
||||||
|
: len >= 4 ? "sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5"
|
||||||
|
: len === 3 ? "sm:grid-cols-2 lg:grid-cols-3"
|
||||||
|
: len === 2 ? "sm:grid-cols-2"
|
||||||
|
: "justify-items-center";
|
||||||
|
const style = len === 1 ? undefined
|
||||||
|
: len <= 3 && len > 0 ? { maxWidth: len * 340 + (len - 1) * 12, marginInline: "auto" as const }
|
||||||
|
: undefined;
|
||||||
|
return { cls, style };
|
||||||
|
}, [filteredDays.length]);
|
||||||
|
|
||||||
const activeTabClass = "bg-gold text-black shadow-[0_0_20px_rgba(201,169,110,0.3)]";
|
const activeTabClass = "bg-gold text-black shadow-[0_0_20px_rgba(201,169,110,0.3)]";
|
||||||
const inactiveTabClass = "border border-neutral-300 text-neutral-500 hover:border-neutral-400 hover:text-neutral-700 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20";
|
const inactiveTabClass = "border border-neutral-300 text-neutral-500 hover:border-neutral-400 hover:text-neutral-700 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20";
|
||||||
|
|
||||||
@@ -232,7 +295,7 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
<div className="mt-4 flex justify-center">
|
<div className="mt-4 flex justify-center">
|
||||||
<div className="inline-flex rounded-xl border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.08] dark:bg-white/[0.04]">
|
<div className="inline-flex rounded-xl border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.08] dark:bg-white/[0.04]">
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode("days")}
|
onClick={() => dispatch({ type: "SET_VIEW", mode: "days" })}
|
||||||
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
|
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
|
||||||
viewMode === "days"
|
viewMode === "days"
|
||||||
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
|
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
|
||||||
@@ -243,7 +306,7 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
По дням
|
По дням
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode("groups")}
|
onClick={() => dispatch({ type: "SET_VIEW", mode: "groups" })}
|
||||||
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
|
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
|
||||||
viewMode === "groups"
|
viewMode === "groups"
|
||||||
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
|
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
|
||||||
@@ -300,8 +363,8 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
{/* Desktop: grid layout */}
|
{/* Desktop: grid layout */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div
|
<div
|
||||||
className={`mt-8 hidden sm:grid grid-cols-1 gap-3 px-4 sm:px-6 lg:px-8 xl:px-6 ${filteredDays.length >= 7 ? "sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7" : filteredDays.length >= 6 ? "sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6" : filteredDays.length >= 4 ? "sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5" : filteredDays.length === 3 ? "sm:grid-cols-2 lg:grid-cols-3" : filteredDays.length === 2 ? "sm:grid-cols-2" : "justify-items-center"}`}
|
className={`mt-8 hidden sm:grid grid-cols-1 gap-3 px-4 sm:px-6 lg:px-8 xl:px-6 ${gridLayout.cls}`}
|
||||||
style={filteredDays.length === 1 ? undefined : filteredDays.length <= 3 && filteredDays.length > 0 ? { maxWidth: filteredDays.length * 340 + (filteredDays.length - 1) * 12, marginInline: "auto" } : undefined}
|
style={gridLayout.style}
|
||||||
>
|
>
|
||||||
{filteredDays.map((day) => (
|
{filteredDays.map((day) => (
|
||||||
<div
|
<div
|
||||||
@@ -331,14 +394,16 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
filterTrainer={filterTrainer}
|
filterTrainer={filterTrainer}
|
||||||
setFilterTrainer={setFilterTrainerFromCard}
|
setFilterTrainer={setFilterTrainerFromCard}
|
||||||
showLocation={isAllMode}
|
showLocation={isAllMode}
|
||||||
onBook={setBookingGroup}
|
onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })}
|
||||||
/>
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
<BookingModal
|
<SignupModal
|
||||||
open={bookingGroup !== null}
|
open={bookingGroup !== null}
|
||||||
onClose={() => setBookingGroup(null)}
|
onClose={() => dispatch({ type: "SET_BOOKING", value: null })}
|
||||||
groupInfo={bookingGroup ?? undefined}
|
subtitle={bookingGroup ?? undefined}
|
||||||
|
endpoint="/api/group-booking"
|
||||||
|
extraBody={{ groupInfo: bookingGroup }}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
|||||||
src={m.image}
|
src={m.image}
|
||||||
alt={m.name}
|
alt={m.name}
|
||||||
fill
|
fill
|
||||||
|
loading="lazy"
|
||||||
sizes="280px"
|
sizes="280px"
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Award, Scale, Clock, MapPin } from "lucide-react";
|
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Award, Scale, Clock, MapPin } from "lucide-react";
|
||||||
import type { TeamMember, RichListItem, VictoryItem, ScheduleLocation } from "@/types/content";
|
import type { TeamMember, RichListItem, VictoryItem, ScheduleLocation } from "@/types/content";
|
||||||
import { BookingModal } from "@/components/ui/BookingModal";
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
|
|
||||||
interface TeamProfileProps {
|
interface TeamProfileProps {
|
||||||
member: TeamMember;
|
member: TeamMember;
|
||||||
@@ -309,10 +309,14 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
|||||||
{lightbox && (
|
{lightbox && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Просмотр изображения"
|
||||||
onClick={() => setLightbox(null)}
|
onClick={() => setLightbox(null)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setLightbox(null)}
|
onClick={() => setLightbox(null)}
|
||||||
|
aria-label="Закрыть"
|
||||||
className="absolute top-4 right-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20 transition-colors"
|
className="absolute top-4 right-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20 transition-colors"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
@@ -329,10 +333,12 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<BookingModal
|
<SignupModal
|
||||||
open={bookingGroup !== null}
|
open={bookingGroup !== null}
|
||||||
onClose={() => setBookingGroup(null)}
|
onClose={() => setBookingGroup(null)}
|
||||||
groupInfo={bookingGroup ?? undefined}
|
subtitle={bookingGroup ?? undefined}
|
||||||
|
endpoint="/api/group-booking"
|
||||||
|
extraBody={{ groupInfo: bookingGroup }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { X, Instagram, Send, CheckCircle, Phone } from "lucide-react";
|
|
||||||
import { siteContent } from "@/data/content";
|
|
||||||
|
|
||||||
interface BookingModalProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
groupInfo?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BookingModal({ open, onClose, groupInfo }: BookingModalProps) {
|
|
||||||
const { contact } = siteContent;
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [phone, setPhone] = useState("+375 ");
|
|
||||||
|
|
||||||
// Format phone: +375 (XX) XXX-XX-XX
|
|
||||||
function handlePhoneChange(raw: string) {
|
|
||||||
// Strip everything except digits
|
|
||||||
let digits = raw.replace(/\D/g, "");
|
|
||||||
// Ensure starts with 375
|
|
||||||
if (!digits.startsWith("375")) {
|
|
||||||
digits = "375" + digits.replace(/^375?/, "");
|
|
||||||
}
|
|
||||||
// Limit to 12 digits (375 + 9 digits)
|
|
||||||
digits = digits.slice(0, 12);
|
|
||||||
|
|
||||||
// Format
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
const [submitted, setSubmitted] = useState(false);
|
|
||||||
|
|
||||||
// Close on Escape
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
return () => document.removeEventListener("keydown", onKey);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
// Lock body scroll
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
} else {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
(e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
// Build Instagram DM message with pre-filled text
|
|
||||||
const groupText = groupInfo ? ` (${groupInfo})` : "";
|
|
||||||
const message = `Здравствуйте! Меня зовут ${name}, хочу записаться на занятие${groupText}. Мой телефон: ${phone}`;
|
|
||||||
const instagramUrl = `https://ig.me/m/blackheartdancehouse?text=${encodeURIComponent(message)}`;
|
|
||||||
window.open(instagramUrl, "_blank");
|
|
||||||
setSubmitted(true);
|
|
||||||
},
|
|
||||||
[name, phone]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
onClose();
|
|
||||||
// Reset after animation
|
|
||||||
setTimeout(() => {
|
|
||||||
setName("");
|
|
||||||
setPhone("+375 ");
|
|
||||||
setSubmitted(false);
|
|
||||||
}, 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}
|
|
||||||
>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
<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()}
|
|
||||||
>
|
|
||||||
{/* Close button */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{submitted ? (
|
|
||||||
/* Success state */
|
|
||||||
<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">
|
|
||||||
Сообщение отправлено в Instagram. Мы свяжемся с вами в ближайшее время!
|
|
||||||
</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>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="text-xl font-bold text-white">Записаться</h3>
|
|
||||||
<p className="mt-1 text-sm text-neutral-400">
|
|
||||||
Оставьте данные и мы свяжемся с вами, или напишите нам напрямую
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<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>
|
|
||||||
<div>
|
|
||||||
<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] 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>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Send size={15} />
|
|
||||||
Отправить в Instagram
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="my-5 flex items-center gap-3">
|
|
||||||
<span className="h-px flex-1 bg-white/[0.06]" />
|
|
||||||
<span className="text-xs text-neutral-500">или напрямую</span>
|
|
||||||
<span className="h-px flex-1 bg-white/[0.06]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Direct links */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<a
|
|
||||||
href={contact.instagram}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.03] py-3 text-sm font-medium text-neutral-300 transition-all hover:border-gold/30 hover:text-gold-light cursor-pointer"
|
|
||||||
>
|
|
||||||
<Instagram size={16} />
|
|
||||||
Instagram
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={`tel:${contact.phone.replace(/\s/g, "")}`}
|
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.03] py-3 text-sm font-medium text-neutral-300 transition-all hover:border-gold/30 hover:text-gold-light cursor-pointer"
|
|
||||||
>
|
|
||||||
<Phone size={16} />
|
|
||||||
Позвонить
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -96,10 +96,10 @@ export function HeroLogo({ className = "", size = 220 }: HeroLogoProps) {
|
|||||||
d={FULL_PATH}
|
d={FULL_PATH}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Glitter sparkles on heart surface */}
|
{/* Glitter sparkles on heart surface — odd-indexed hidden on mobile via CSS class */}
|
||||||
<g clipPath="url(#heart-clip)" filter="url(#sparkle-glow)">
|
<g clipPath="url(#heart-clip)" filter="url(#sparkle-glow)">
|
||||||
{SPARKLES.map((s, i) => (
|
{SPARKLES.map((s, i) => (
|
||||||
<circle key={`sparkle-${i}`} cx={s.x} cy={s.y} r="1.8" fill="#d4b87a">
|
<circle key={`sparkle-${i}`} cx={s.x} cy={s.y} r="1.8" fill="#d4b87a" className={i % 2 ? "hidden sm:block" : ""}>
|
||||||
<animate
|
<animate
|
||||||
attributeName="opacity"
|
attributeName="opacity"
|
||||||
values="0;0;0.9;1;0.9;0;0"
|
values="0;0;0.9;1;0.9;0;0"
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { X, Instagram, Send, CheckCircle } from "lucide-react";
|
|
||||||
|
|
||||||
interface MasterClassSignupModalProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
masterClassTitle: string;
|
|
||||||
successMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MasterClassSignupModal({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
masterClassTitle,
|
|
||||||
successMessage,
|
|
||||||
}: MasterClassSignupModalProps) {
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [instagram, setInstagram] = useState("");
|
|
||||||
const [telegram, setTelegram] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [submitted, setSubmitted] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
// Close on Escape
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
return () => document.removeEventListener("keydown", onKey);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
// Lock body scroll
|
|
||||||
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);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/master-class-register", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
masterClassTitle,
|
|
||||||
name: name.trim(),
|
|
||||||
instagram: `@${instagram.trim()}`,
|
|
||||||
telegram: telegram.trim() ? `@${telegram.trim()}` : undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
throw new Error(data.error || "Ошибка регистрации");
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitted(true);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Ошибка регистрации");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[masterClassTitle, name, instagram, telegram]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
onClose();
|
|
||||||
setTimeout(() => {
|
|
||||||
setName("");
|
|
||||||
setInstagram("");
|
|
||||||
setTelegram("");
|
|
||||||
setSubmitted(false);
|
|
||||||
setError("");
|
|
||||||
}, 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>
|
|
||||||
|
|
||||||
{submitted ? (
|
|
||||||
<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">
|
|
||||||
{successMessage || "Вы записаны! Мы свяжемся с вами"}
|
|
||||||
</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">{masterClassTitle}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-0 rounded-xl border border-white/[0.08] bg-white/[0.04] transition-colors focus-within:border-gold/40 focus-within:bg-white/[0.06]">
|
|
||||||
<span className="flex items-center gap-1.5 pl-4 text-sm text-neutral-500 select-none">
|
|
||||||
<Instagram size={14} className="text-pink-400" />
|
|
||||||
@
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={instagram}
|
|
||||||
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
|
|
||||||
placeholder="username"
|
|
||||||
required
|
|
||||||
className="flex-1 bg-transparent px-2 py-3 text-sm text-white placeholder-neutral-500 outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-0 rounded-xl border border-white/[0.08] bg-white/[0.04] transition-colors focus-within:border-gold/40 focus-within:bg-white/[0.06]">
|
|
||||||
<span className="flex items-center gap-1.5 pl-4 text-sm text-neutral-500 select-none">
|
|
||||||
<Send size={14} className="text-blue-400" />
|
|
||||||
@
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={telegram}
|
|
||||||
onChange={(e) => setTelegram(e.target.value.replace(/^@/, ""))}
|
|
||||||
placeholder="username (необязательно)"
|
|
||||||
className="flex-1 bg-transparent px-2 py-3 text-sm text-white placeholder-neutral-500 outline-none"
|
|
||||||
/>
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
{submitting ? "Отправка..." : "Записаться"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -49,6 +49,9 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4"
|
className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={item.title}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
@@ -59,6 +62,7 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-label="Закрыть"
|
||||||
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/50 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-white/[0.1] hover:text-white cursor-pointer"
|
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/50 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-white/[0.1] hover:text-white cursor-pointer"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
|
|||||||
265
src/components/ui/SignupModal.tsx
Normal file
265
src/components/ui/SignupModal.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"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" role="dialog" aria-modal="true" aria-label={title} 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}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,13 @@ function getAdminPassword(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function verifyPassword(password: string): boolean {
|
export function verifyPassword(password: string): boolean {
|
||||||
return password === getAdminPassword();
|
const expected = getAdminPassword();
|
||||||
|
if (password.length !== expected.length) return false;
|
||||||
|
const a = Buffer.from(password);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
// Pad to equal length for timingSafeEqual
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return crypto.timingSafeEqual(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function signToken(): string {
|
export function signToken(): string {
|
||||||
@@ -51,7 +57,7 @@ function verifyTokenNode(token: string): boolean {
|
|||||||
.update(data)
|
.update(data)
|
||||||
.digest("base64url");
|
.digest("base64url");
|
||||||
|
|
||||||
if (sig !== expectedSig) return false;
|
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) return false;
|
||||||
|
|
||||||
const payload = JSON.parse(
|
const payload = JSON.parse(
|
||||||
Buffer.from(data, "base64url").toString()
|
Buffer.from(data, "base64url").toString()
|
||||||
@@ -63,4 +69,10 @@ function verifyTokenNode(token: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CSRF_COOKIE_NAME = "bh-csrf-token";
|
||||||
|
|
||||||
|
export function generateCsrfToken(): string {
|
||||||
|
return crypto.randomBytes(32).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
export { COOKIE_NAME };
|
export { COOKIE_NAME };
|
||||||
|
|||||||
@@ -2,12 +2,28 @@ import { getSiteContent } from "@/lib/db";
|
|||||||
import { siteContent as fallback } from "@/data/content";
|
import { siteContent as fallback } from "@/data/content";
|
||||||
import type { SiteContent } from "@/types/content";
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
|
let cached: { data: SiteContent; expiresAt: number } | null = null;
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
export function getContent(): SiteContent {
|
export function getContent(): SiteContent {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cached && now < cached.expiresAt) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = getSiteContent();
|
const content = getSiteContent();
|
||||||
if (content) return content;
|
if (content) {
|
||||||
|
cached = { data: content, expiresAt: now + CACHE_TTL };
|
||||||
|
return content;
|
||||||
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Invalidate the content cache (call after admin edits). */
|
||||||
|
export function invalidateContentCache() {
|
||||||
|
cached = null;
|
||||||
|
}
|
||||||
|
|||||||
17
src/lib/csrf.ts
Normal file
17
src/lib/csrf.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const CSRF_COOKIE_NAME = "bh-csrf-token";
|
||||||
|
|
||||||
|
function getCsrfToken(): string {
|
||||||
|
const match = document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find((c) => c.startsWith(`${CSRF_COOKIE_NAME}=`));
|
||||||
|
return match ? match.split("=")[1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrapper around fetch that auto-includes the CSRF token header for admin API calls */
|
||||||
|
export function adminFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
if (!headers.has("x-csrf-token")) {
|
||||||
|
headers.set("x-csrf-token", getCsrfToken());
|
||||||
|
}
|
||||||
|
return fetch(url, { ...init, headers });
|
||||||
|
}
|
||||||
802
src/lib/db.ts
802
src/lib/db.ts
@@ -80,6 +80,145 @@ 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");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 8,
|
||||||
|
name: "add_reminder_status",
|
||||||
|
up: (db) => {
|
||||||
|
// reminder_status: null = not reminded, 'pending' = reminded but no answer, 'coming' = confirmed, 'cancelled' = not coming
|
||||||
|
for (const table of ["mc_registrations", "group_bookings", "open_day_bookings"]) {
|
||||||
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
|
||||||
|
if (!cols.some((c) => c.name === "reminder_status")) {
|
||||||
|
db.exec(`ALTER TABLE ${table} ADD COLUMN reminder_status TEXT`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 9,
|
||||||
|
name: "add_performance_indexes",
|
||||||
|
up: (db) => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mc_registrations_title ON mc_registrations(master_class_title);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mc_registrations_created ON mc_registrations(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_group_bookings_created ON group_bookings(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_open_day_bookings_event ON open_day_bookings(event_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_open_day_bookings_class ON open_day_bookings(class_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_open_day_bookings_phone ON open_day_bookings(event_id, phone);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_open_day_bookings_class_phone ON open_day_bookings(class_id, phone);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_open_day_classes_event ON open_day_classes(event_id);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 10,
|
||||||
|
name: "add_group_booking_status",
|
||||||
|
up: (db) => {
|
||||||
|
const cols = db.prepare("PRAGMA table_info(group_bookings)").all() as { name: string }[];
|
||||||
|
if (!cols.some((c) => c.name === "status")) {
|
||||||
|
db.exec("ALTER TABLE group_bookings ADD COLUMN status TEXT NOT NULL DEFAULT 'new'");
|
||||||
|
}
|
||||||
|
if (!cols.some((c) => c.name === "confirmed_date")) {
|
||||||
|
db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_date TEXT");
|
||||||
|
}
|
||||||
|
if (!cols.some((c) => c.name === "confirmed_group")) {
|
||||||
|
db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_group TEXT");
|
||||||
|
}
|
||||||
|
if (!cols.some((c) => c.name === "confirmed_comment")) {
|
||||||
|
db.exec("ALTER TABLE group_bookings ADD COLUMN confirmed_comment TEXT");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function runMigrations(db: Database.Database) {
|
function runMigrations(db: Database.Database) {
|
||||||
@@ -350,8 +489,12 @@ interface McRegistrationRow {
|
|||||||
master_class_title: string;
|
master_class_title: string;
|
||||||
name: string;
|
name: string;
|
||||||
instagram: string;
|
instagram: string;
|
||||||
|
phone: string | null;
|
||||||
telegram: string | null;
|
telegram: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
notified_confirm: number;
|
||||||
|
notified_reminder: number;
|
||||||
|
reminder_status: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface McRegistration {
|
export interface McRegistration {
|
||||||
@@ -359,23 +502,28 @@ export interface McRegistration {
|
|||||||
masterClassTitle: string;
|
masterClassTitle: string;
|
||||||
name: string;
|
name: string;
|
||||||
instagram: string;
|
instagram: string;
|
||||||
|
phone?: string;
|
||||||
telegram?: string;
|
telegram?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
notifiedConfirm: boolean;
|
||||||
|
notifiedReminder: boolean;
|
||||||
|
reminderStatus?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addMcRegistration(
|
export function addMcRegistration(
|
||||||
masterClassTitle: string,
|
masterClassTitle: string,
|
||||||
name: string,
|
name: string,
|
||||||
instagram: string,
|
instagram: string,
|
||||||
telegram?: string
|
telegram?: string,
|
||||||
|
phone?: string
|
||||||
): number {
|
): number {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram)
|
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram, phone)
|
||||||
VALUES (?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
.run(masterClassTitle, name, instagram, telegram || null);
|
.run(masterClassTitle, name, instagram, telegram || null, phone || null);
|
||||||
return result.lastInsertRowid as number;
|
return result.lastInsertRowid as number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,14 +534,30 @@ export function getMcRegistrations(masterClassTitle: string): McRegistration[] {
|
|||||||
"SELECT * FROM mc_registrations WHERE master_class_title = ? ORDER BY created_at DESC"
|
"SELECT * FROM mc_registrations WHERE master_class_title = ? ORDER BY created_at DESC"
|
||||||
)
|
)
|
||||||
.all(masterClassTitle) as McRegistrationRow[];
|
.all(masterClassTitle) as McRegistrationRow[];
|
||||||
return rows.map((r) => ({
|
return rows.map(mapMcRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllMcRegistrations(): McRegistration[] {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db
|
||||||
|
.prepare("SELECT * FROM mc_registrations ORDER BY created_at DESC")
|
||||||
|
.all() as McRegistrationRow[];
|
||||||
|
return rows.map(mapMcRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMcRow(r: McRegistrationRow): McRegistration {
|
||||||
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
masterClassTitle: r.master_class_title,
|
masterClassTitle: r.master_class_title,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
instagram: r.instagram,
|
instagram: r.instagram,
|
||||||
|
phone: r.phone ?? undefined,
|
||||||
telegram: r.telegram ?? undefined,
|
telegram: r.telegram ?? undefined,
|
||||||
createdAt: r.created_at,
|
createdAt: r.created_at,
|
||||||
}));
|
notifiedConfirm: !!r.notified_confirm,
|
||||||
|
notifiedReminder: !!r.notified_reminder,
|
||||||
|
reminderStatus: r.reminder_status ?? undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateMcRegistration(
|
export function updateMcRegistration(
|
||||||
@@ -408,9 +572,635 @@ export function updateMcRegistration(
|
|||||||
).run(name, instagram, telegram || null, id);
|
).run(name, instagram, telegram || null, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toggleMcNotification(
|
||||||
|
id: number,
|
||||||
|
field: "notified_confirm" | "notified_reminder",
|
||||||
|
value: boolean
|
||||||
|
): void {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE mc_registrations SET ${field} = ? WHERE id = ?`
|
||||||
|
).run(value ? 1 : 0, id);
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteMcRegistration(id: number): void {
|
export function deleteMcRegistration(id: number): void {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
db.prepare("DELETE FROM mc_registrations WHERE id = ?").run(id);
|
db.prepare("DELETE FROM mc_registrations WHERE id = ?").run(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Group Bookings ---
|
||||||
|
|
||||||
|
interface GroupBookingRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
group_info: string | null;
|
||||||
|
instagram: string | null;
|
||||||
|
telegram: string | null;
|
||||||
|
notified_confirm: number;
|
||||||
|
notified_reminder: number;
|
||||||
|
reminder_status: string | null;
|
||||||
|
status: string;
|
||||||
|
confirmed_date: string | null;
|
||||||
|
confirmed_group: string | null;
|
||||||
|
confirmed_comment: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
|
||||||
|
|
||||||
|
export interface GroupBooking {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
groupInfo?: string;
|
||||||
|
instagram?: string;
|
||||||
|
telegram?: string;
|
||||||
|
notifiedConfirm: boolean;
|
||||||
|
notifiedReminder: boolean;
|
||||||
|
reminderStatus?: string;
|
||||||
|
status: BookingStatus;
|
||||||
|
confirmedDate?: string;
|
||||||
|
confirmedGroup?: string;
|
||||||
|
confirmedComment?: string;
|
||||||
|
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,
|
||||||
|
reminderStatus: r.reminder_status ?? undefined,
|
||||||
|
status: (r.status || "new") as BookingStatus,
|
||||||
|
confirmedDate: r.confirmed_date ?? undefined,
|
||||||
|
confirmedGroup: r.confirmed_group ?? undefined,
|
||||||
|
confirmedComment: r.confirmed_comment ?? undefined,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setGroupBookingStatus(
|
||||||
|
id: number,
|
||||||
|
status: BookingStatus,
|
||||||
|
confirmation?: { date: string; group: string; comment?: string }
|
||||||
|
): void {
|
||||||
|
const db = getDb();
|
||||||
|
if (status === "confirmed" && confirmation) {
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE group_bookings SET status = ?, confirmed_date = ?, confirmed_group = ?, confirmed_comment = ? WHERE id = ?"
|
||||||
|
).run(status, confirmation.date, confirmation.group, confirmation.comment || null, id);
|
||||||
|
} else {
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE group_bookings SET status = ?, confirmed_date = NULL, confirmed_group = NULL, confirmed_comment = NULL WHERE id = ?"
|
||||||
|
).run(status, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reminder status ---
|
||||||
|
|
||||||
|
export type ReminderStatus = "pending" | "coming" | "cancelled";
|
||||||
|
|
||||||
|
export function setReminderStatus(
|
||||||
|
table: "mc_registrations" | "group_bookings" | "open_day_bookings",
|
||||||
|
id: number,
|
||||||
|
status: ReminderStatus | null
|
||||||
|
): void {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(`UPDATE ${table} SET reminder_status = ? WHERE id = ?`).run(status, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReminderItem {
|
||||||
|
id: number;
|
||||||
|
type: "class" | "master-class" | "open-day";
|
||||||
|
table: "mc_registrations" | "group_bookings" | "open_day_bookings";
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
instagram?: string;
|
||||||
|
telegram?: string;
|
||||||
|
reminderStatus?: string;
|
||||||
|
eventLabel: string;
|
||||||
|
eventDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUpcomingReminders(): ReminderItem[] {
|
||||||
|
const db = getDb();
|
||||||
|
const items: ReminderItem[] = [];
|
||||||
|
|
||||||
|
// Tomorrow and today dates
|
||||||
|
const now = new Date();
|
||||||
|
const today = now.toISOString().split("T")[0];
|
||||||
|
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||||
|
|
||||||
|
// MC registrations — match slots from sections JSON
|
||||||
|
try {
|
||||||
|
const mcSection = db.prepare("SELECT data FROM sections WHERE key = 'masterClasses'").get() as { data: string } | undefined;
|
||||||
|
if (mcSection) {
|
||||||
|
const mcData = JSON.parse(mcSection.data) as { items: { title: string; slots: { date: string; startTime?: string }[] }[] };
|
||||||
|
// Find MC titles with slots today or tomorrow
|
||||||
|
const upcomingTitles: { title: string; date: string; time?: string }[] = [];
|
||||||
|
for (const mc of mcData.items || []) {
|
||||||
|
for (const slot of mc.slots || []) {
|
||||||
|
if (slot.date === today || slot.date === tomorrow) {
|
||||||
|
upcomingTitles.push({ title: mc.title, date: slot.date, time: slot.startTime });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (upcomingTitles.length > 0) {
|
||||||
|
const uniqueTitles = [...new Set(upcomingTitles.map((t) => t.title))];
|
||||||
|
const placeholders = uniqueTitles.map(() => "?").join(", ");
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT * FROM mc_registrations WHERE master_class_title IN (${placeholders})`
|
||||||
|
).all(...uniqueTitles) as McRegistrationRow[];
|
||||||
|
|
||||||
|
// Build a lookup: title → { date, time }
|
||||||
|
const titleInfo = new Map<string, { date: string; time?: string }>();
|
||||||
|
for (const t of upcomingTitles) {
|
||||||
|
titleInfo.set(t.title, { date: t.date, time: t.time });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
const info = titleInfo.get(r.master_class_title);
|
||||||
|
if (!info) continue;
|
||||||
|
items.push({
|
||||||
|
id: r.id,
|
||||||
|
type: "master-class",
|
||||||
|
table: "mc_registrations",
|
||||||
|
name: r.name,
|
||||||
|
phone: r.phone ?? undefined,
|
||||||
|
instagram: r.instagram ?? undefined,
|
||||||
|
telegram: r.telegram ?? undefined,
|
||||||
|
reminderStatus: r.reminder_status ?? undefined,
|
||||||
|
eventLabel: `${r.master_class_title}${info.time ? ` · ${info.time}` : ""}`,
|
||||||
|
eventDate: info.date,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Open Day bookings — check event date
|
||||||
|
try {
|
||||||
|
const events = db.prepare(
|
||||||
|
"SELECT id, date, title FROM open_day_events WHERE date IN (?, ?) AND active = 1"
|
||||||
|
).all(today, tomorrow) as { id: number; date: string; title: string }[];
|
||||||
|
for (const ev of events) {
|
||||||
|
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 = ? AND c.cancelled = 0`
|
||||||
|
).all(ev.id) as OpenDayBookingRow[];
|
||||||
|
for (const r of rows) {
|
||||||
|
items.push({
|
||||||
|
id: r.id,
|
||||||
|
type: "open-day",
|
||||||
|
table: "open_day_bookings",
|
||||||
|
name: r.name,
|
||||||
|
phone: r.phone ?? undefined,
|
||||||
|
instagram: r.instagram ?? undefined,
|
||||||
|
telegram: r.telegram ?? undefined,
|
||||||
|
reminderStatus: r.reminder_status ?? undefined,
|
||||||
|
eventLabel: `${r.class_style} · ${r.class_trainer} · ${r.class_time} (${r.class_hall})`,
|
||||||
|
eventDate: ev.date,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
reminder_status: string | null;
|
||||||
|
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;
|
||||||
|
reminderStatus?: string;
|
||||||
|
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,
|
||||||
|
reminderStatus: r.reminder_status ?? undefined,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
classStyle: r.class_style ?? undefined,
|
||||||
|
classTrainer: r.class_trainer ?? undefined,
|
||||||
|
classTime: r.class_time ?? undefined,
|
||||||
|
classHall: r.class_hall ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOpenDayEvent(data: {
|
||||||
|
date: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
pricePerClass?: number;
|
||||||
|
discountPrice?: number;
|
||||||
|
discountThreshold?: number;
|
||||||
|
minBookings?: number;
|
||||||
|
}): number {
|
||||||
|
const db = getDb();
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO open_day_events (date, title, description, price_per_class, discount_price, discount_threshold, min_bookings)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
data.date,
|
||||||
|
data.title || "День открытых дверей",
|
||||||
|
data.description || null,
|
||||||
|
data.pricePerClass ?? 30,
|
||||||
|
data.discountPrice ?? 20,
|
||||||
|
data.discountThreshold ?? 3,
|
||||||
|
data.minBookings ?? 4
|
||||||
|
);
|
||||||
|
return result.lastInsertRowid as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpenDayEvents(): OpenDayEvent[] {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db
|
||||||
|
.prepare("SELECT * FROM open_day_events ORDER BY date DESC")
|
||||||
|
.all() as OpenDayEventRow[];
|
||||||
|
return rows.map(mapEventRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpenDayEvent(id: number): OpenDayEvent | null {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT * FROM open_day_events WHERE id = ?")
|
||||||
|
.get(id) as OpenDayEventRow | undefined;
|
||||||
|
return row ? mapEventRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveOpenDayEvent(): OpenDayEvent | null {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT * FROM open_day_events WHERE active = 1 AND date >= date('now') ORDER BY date ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
.get() as OpenDayEventRow | undefined;
|
||||||
|
return row ? mapEventRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateOpenDayEvent(
|
||||||
|
id: number,
|
||||||
|
data: Partial<{
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
pricePerClass: number;
|
||||||
|
discountPrice: number;
|
||||||
|
discountThreshold: number;
|
||||||
|
minBookings: number;
|
||||||
|
active: boolean;
|
||||||
|
}>
|
||||||
|
): void {
|
||||||
|
const db = getDb();
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: unknown[] = [];
|
||||||
|
if (data.date !== undefined) { sets.push("date = ?"); vals.push(data.date); }
|
||||||
|
if (data.title !== undefined) { sets.push("title = ?"); vals.push(data.title); }
|
||||||
|
if (data.description !== undefined) { sets.push("description = ?"); vals.push(data.description || null); }
|
||||||
|
if (data.pricePerClass !== undefined) { sets.push("price_per_class = ?"); vals.push(data.pricePerClass); }
|
||||||
|
if (data.discountPrice !== undefined) { sets.push("discount_price = ?"); vals.push(data.discountPrice); }
|
||||||
|
if (data.discountThreshold !== undefined) { sets.push("discount_threshold = ?"); vals.push(data.discountThreshold); }
|
||||||
|
if (data.minBookings !== undefined) { sets.push("min_bookings = ?"); vals.push(data.minBookings); }
|
||||||
|
if (data.active !== undefined) { sets.push("active = ?"); vals.push(data.active ? 1 : 0); }
|
||||||
|
if (sets.length === 0) return;
|
||||||
|
sets.push("updated_at = datetime('now')");
|
||||||
|
vals.push(id);
|
||||||
|
db.prepare(`UPDATE open_day_events SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteOpenDayEvent(id: number): void {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare("DELETE FROM open_day_events WHERE id = ?").run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Open Day Classes ---
|
||||||
|
|
||||||
|
export function addOpenDayClass(
|
||||||
|
eventId: number,
|
||||||
|
data: { hall: string; startTime: string; endTime: string; trainer: string; style: string }
|
||||||
|
): number {
|
||||||
|
const db = getDb();
|
||||||
|
const maxOrder = (
|
||||||
|
db.prepare("SELECT MAX(sort_order) as m FROM open_day_classes WHERE event_id = ?").get(eventId) as { m: number | null }
|
||||||
|
).m ?? -1;
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO open_day_classes (event_id, hall, start_time, end_time, trainer, style, sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(eventId, data.hall, data.startTime, data.endTime, data.trainer, data.style, maxOrder + 1);
|
||||||
|
return result.lastInsertRowid as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpenDayClasses(eventId: number): OpenDayClass[] {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT c.*, COALESCE(b.cnt, 0) as booking_count
|
||||||
|
FROM open_day_classes c
|
||||||
|
LEFT JOIN (SELECT class_id, COUNT(*) as cnt FROM open_day_bookings GROUP BY class_id) b ON b.class_id = c.id
|
||||||
|
WHERE c.event_id = ?
|
||||||
|
ORDER BY c.hall, c.start_time`
|
||||||
|
)
|
||||||
|
.all(eventId) as OpenDayClassRow[];
|
||||||
|
return rows.map(mapClassRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateOpenDayClass(
|
||||||
|
id: number,
|
||||||
|
data: Partial<{ hall: string; startTime: string; endTime: string; trainer: string; style: string; cancelled: boolean; sortOrder: number }>
|
||||||
|
): void {
|
||||||
|
const db = getDb();
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: unknown[] = [];
|
||||||
|
if (data.hall !== undefined) { sets.push("hall = ?"); vals.push(data.hall); }
|
||||||
|
if (data.startTime !== undefined) { sets.push("start_time = ?"); vals.push(data.startTime); }
|
||||||
|
if (data.endTime !== undefined) { sets.push("end_time = ?"); vals.push(data.endTime); }
|
||||||
|
if (data.trainer !== undefined) { sets.push("trainer = ?"); vals.push(data.trainer); }
|
||||||
|
if (data.style !== undefined) { sets.push("style = ?"); vals.push(data.style); }
|
||||||
|
if (data.cancelled !== undefined) { sets.push("cancelled = ?"); vals.push(data.cancelled ? 1 : 0); }
|
||||||
|
if (data.sortOrder !== undefined) { sets.push("sort_order = ?"); vals.push(data.sortOrder); }
|
||||||
|
if (sets.length === 0) return;
|
||||||
|
vals.push(id);
|
||||||
|
db.prepare(`UPDATE open_day_classes SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteOpenDayClass(id: number): void {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare("DELETE FROM open_day_classes WHERE id = ?").run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Open Day Bookings ---
|
||||||
|
|
||||||
|
export function addOpenDayBooking(
|
||||||
|
classId: number,
|
||||||
|
eventId: number,
|
||||||
|
data: { name: string; phone: string; instagram?: string; telegram?: string }
|
||||||
|
): number {
|
||||||
|
const db = getDb();
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO open_day_bookings (class_id, event_id, name, phone, instagram, telegram)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(classId, eventId, data.name, data.phone, data.instagram || null, data.telegram || null);
|
||||||
|
return result.lastInsertRowid as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpenDayBookings(eventId: number): OpenDayBooking[] {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT b.*, c.style as class_style, c.trainer as class_trainer, c.start_time as class_time, c.hall as class_hall
|
||||||
|
FROM open_day_bookings b
|
||||||
|
JOIN open_day_classes c ON c.id = b.class_id
|
||||||
|
WHERE b.event_id = ?
|
||||||
|
ORDER BY b.created_at DESC`
|
||||||
|
)
|
||||||
|
.all(eventId) as OpenDayBookingRow[];
|
||||||
|
return rows.map(mapBookingRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpenDayBookingCountsByClass(eventId: number): Record<number, number> {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db
|
||||||
|
.prepare("SELECT class_id, COUNT(*) as cnt FROM open_day_bookings WHERE event_id = ? GROUP BY class_id")
|
||||||
|
.all(eventId) as { class_id: number; cnt: number }[];
|
||||||
|
const result: Record<number, number> = {};
|
||||||
|
for (const r of rows) result[r.class_id] = r.cnt;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPersonOpenDayBookings(eventId: number, phone: string): number {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT COUNT(*) as cnt FROM open_day_bookings WHERE event_id = ? AND phone = ?")
|
||||||
|
.get(eventId, phone) as { cnt: number };
|
||||||
|
return row.cnt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleOpenDayNotification(
|
||||||
|
id: number,
|
||||||
|
field: "notified_confirm" | "notified_reminder",
|
||||||
|
value: boolean
|
||||||
|
): void {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(`UPDATE open_day_bookings SET ${field} = ? WHERE id = ?`).run(value ? 1 : 0, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteOpenDayBooking(id: number): void {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare("DELETE FROM open_day_bookings WHERE id = ?").run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOpenDayClassBookedByPhone(classId: number, phone: string): boolean {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT id FROM open_day_bookings WHERE class_id = ? AND phone = ? LIMIT 1")
|
||||||
|
.get(classId, phone) as { id: number } | undefined;
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
export { SECTION_KEYS };
|
export { SECTION_KEYS };
|
||||||
|
|||||||
11
src/lib/openDay.ts
Normal file
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 };
|
||||||
|
}
|
||||||
62
src/lib/rateLimit.ts
Normal file
62
src/lib/rateLimit.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Simple in-memory rate limiter for public API endpoints.
|
||||||
|
* Limits requests per IP within a sliding time window.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
// Periodically clean up expired entries (every 5 minutes)
|
||||||
|
let cleanupScheduled = false;
|
||||||
|
function scheduleCleanup() {
|
||||||
|
if (cleanupScheduled) return;
|
||||||
|
cleanupScheduled = true;
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of store) {
|
||||||
|
if (now > entry.resetAt) store.delete(key);
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a request is within the rate limit.
|
||||||
|
* @param ip - Client IP address
|
||||||
|
* @param limit - Max requests per window (default: 10)
|
||||||
|
* @param windowMs - Time window in ms (default: 60_000 = 1 minute)
|
||||||
|
* @returns true if allowed, false if rate limited
|
||||||
|
*/
|
||||||
|
export function checkRateLimit(
|
||||||
|
ip: string,
|
||||||
|
limit: number = 10,
|
||||||
|
windowMs: number = 60_000
|
||||||
|
): boolean {
|
||||||
|
scheduleCleanup();
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = store.get(ip);
|
||||||
|
|
||||||
|
if (!entry || now > entry.resetAt) {
|
||||||
|
store.set(ip, { count: 1, resetAt: now + windowMs });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= limit) return false;
|
||||||
|
entry.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract client IP from request headers.
|
||||||
|
*/
|
||||||
|
export function getClientIp(request: Request): string {
|
||||||
|
return (
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|
request.headers.get("x-real-ip") ||
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/lib/validation.ts
Normal file
27
src/lib/validation.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Shared input sanitization for public registration endpoints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function sanitizeName(name: unknown): string | null {
|
||||||
|
if (!name || typeof name !== "string") return null;
|
||||||
|
const clean = name.trim().slice(0, 100);
|
||||||
|
return clean || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizePhone(phone: unknown): string | null {
|
||||||
|
if (!phone || typeof phone !== "string") return null;
|
||||||
|
const clean = phone.replace(/\D/g, "").slice(0, 15);
|
||||||
|
return clean.length >= 9 ? clean : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeHandle(value: unknown): string | undefined {
|
||||||
|
if (!value || typeof value !== "string") return undefined;
|
||||||
|
const clean = value.trim().slice(0, 100);
|
||||||
|
return clean || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeText(value: unknown, maxLength: number = 200): string | undefined {
|
||||||
|
if (!value || typeof value !== "string") return undefined;
|
||||||
|
const clean = value.trim().slice(0, maxLength);
|
||||||
|
return clean || undefined;
|
||||||
|
}
|
||||||
37
src/proxy.ts
37
src/proxy.ts
@@ -1,6 +1,18 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { verifyToken, COOKIE_NAME } from "@/lib/auth-edge";
|
import { verifyToken, COOKIE_NAME } from "@/lib/auth-edge";
|
||||||
|
|
||||||
|
const CSRF_COOKIE_NAME = "bh-csrf-token";
|
||||||
|
const CSRF_HEADER_NAME = "x-csrf-token";
|
||||||
|
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
|
||||||
|
|
||||||
|
function generateCsrfToken(): string {
|
||||||
|
const array = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
let binary = "";
|
||||||
|
for (const b of array) binary += String.fromCharCode(b);
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
export async function proxy(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
@@ -20,6 +32,31 @@ export async function proxy(request: NextRequest) {
|
|||||||
return NextResponse.redirect(new URL("/admin/login", request.url));
|
return NextResponse.redirect(new URL("/admin/login", request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-issue CSRF cookie if missing (e.g. session from before CSRF was added)
|
||||||
|
const hasCsrf = request.cookies.has(CSRF_COOKIE_NAME);
|
||||||
|
if (!hasCsrf) {
|
||||||
|
const csrfToken = generateCsrfToken();
|
||||||
|
const response = NextResponse.next();
|
||||||
|
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
|
||||||
|
httpOnly: false,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "strict",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF check on state-changing API requests
|
||||||
|
if (pathname.startsWith("/api/admin/") && STATE_CHANGING_METHODS.has(request.method)) {
|
||||||
|
const csrfCookie = request.cookies.get(CSRF_COOKIE_NAME)?.value;
|
||||||
|
const csrfHeader = request.headers.get(CSRF_HEADER_NAME);
|
||||||
|
|
||||||
|
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
|
||||||
|
return NextResponse.json({ error: "CSRF token mismatch" }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export interface SiteContent {
|
|||||||
rentalTitle: string;
|
rentalTitle: string;
|
||||||
rentalItems: PricingItem[];
|
rentalItems: PricingItem[];
|
||||||
rules: string[];
|
rules: string[];
|
||||||
|
showContactHint?: boolean;
|
||||||
};
|
};
|
||||||
masterClasses: {
|
masterClasses: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user