Compare commits
141 Commits
233c117afa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 035f68776a | |||
| c4c3a7ab0d | |||
| 76307e298b | |||
| ec08f8e8d5 | |||
| a769ea844d | |||
| 8088b99a43 | |||
| 228e547e10 | |||
| c9303e5aad | |||
| c9cfe63837 | |||
| f65a6ed811 | |||
| 09b2f40090 | |||
| 4c8c6eb0d2 | |||
| 4b6443c867 | |||
| bc0f23df34 | |||
| ad1715acb8 | |||
| 30398d2aeb | |||
| 95c33391e5 | |||
| 64e923460f | |||
| 6c485872b0 | |||
| 983bf296fc | |||
| 4805c3b9ea | |||
| 24d48a9409 | |||
| e4cb38c409 | |||
| 4d90785c5b | |||
| 36ea952e9b | |||
| e64119aaa0 | |||
| eb949f1a37 | |||
| b251ee5138 | |||
| 5e93c9a746 | |||
| 540d3a9297 | |||
| d08df61b2a | |||
| 259b31a722 | |||
| 0c39bdba5e | |||
| 1047b71abe | |||
| d0fad4aae5 | |||
| eb6ec5aeb6 | |||
| 8ecebe686c | |||
| 0ae8f57cde | |||
| b22e4c5d39 | |||
| d08905ee93 | |||
| 4acc88c1ab | |||
| 353484af2e | |||
| 2693491fee | |||
| 8343374969 | |||
| 4dabca52ee | |||
| afe9f7012c | |||
| 87ba4d232a | |||
| a95676ea6a | |||
| 67d8f6330c | |||
| 745d72f36d | |||
| f6d0491ca5 | |||
| a959c22a4c | |||
| aa07b64c80 | |||
| 83456c6e9d | |||
| 42be063c7f | |||
| 18c11d0611 | |||
| b48cc040e1 | |||
| 49d710b2e7 | |||
| 057f1ff1ee | |||
| 2c64951cb3 | |||
| aa0cfe35c3 | |||
| 669c4a3023 | |||
| c87c63bc4f | |||
| 87f488e2c1 | |||
| b906216317 | |||
| 575c684cc5 | |||
| 4721043530 | |||
| 8b5ed3c627 | |||
| c3cbd90fe4 | |||
| 1c6462d340 | |||
| b0c9a77474 | |||
| 650f8dc719 | |||
| 1acda847b7 | |||
| 96e3333e9f | |||
| 1bfd502930 | |||
| 8d1e3fb596 | |||
| 0ec2361a16 | |||
| e4a9b71bfe | |||
| e617660467 | |||
| 3458f88367 | |||
| 9e0aa5b5dc | |||
| 5cd23473c8 | |||
| b1adbbfe3d | |||
| e63b902081 | |||
| 66dce3f8f5 | |||
| 127990e532 | |||
| 4e766d6957 | |||
| b94ee69033 | |||
| 7497ede2fd | |||
| 6cbdba2197 | |||
| 3ac6a4d840 | |||
| 26cb9a9772 | |||
| 4a1a2d7512 | |||
| b9800c1cc2 | |||
| f29dbe0c9f | |||
| 340a1d2f7f | |||
| f5e80c792a | |||
| 84b0bc4d60 | |||
| 6981376171 | |||
| 4f92057411 | |||
| ce033074cd | |||
| d4751975d2 | |||
| 1b391cdde6 | |||
| d3bb43af80 | |||
| 5030edd0d6 | |||
| 627781027b | |||
| 4918184852 | |||
| 921d10800b | |||
| ed90cd5924 | |||
| 03f0524ba3 | |||
| 46ad10e8a0 | |||
| 8ff7713cf2 | |||
| 604a52e04c | |||
| 8ef5fc975c | |||
| 5fe2500dbe | |||
| 21f3887bc9 | |||
| 9d0b4b0fba | |||
| fc523b2045 | |||
| bfa59a8d18 | |||
| b5262b4adc | |||
| 5c23b622f9 | |||
| 85c61cfacd | |||
| 27ef3bd694 | |||
| b145d5416a | |||
| e6c7bcf7f4 | |||
| ed5a164d59 | |||
| 27c1348f89 | |||
| d5afaf92ba | |||
| 08e4af1d55 | |||
| 7ff850f21a | |||
| e5fae578ab | |||
| a4dc8173fc | |||
| 13b68484e1 | |||
| 0c8c45dcd9 | |||
| d5b1873f83 | |||
| e42c3c7a51 | |||
| 8fbbc94024 | |||
| f2b840416d | |||
| 303c52653c | |||
| 22a59ae9af | |||
| 8d2f482e99 |
3
.gitignore
vendored
@@ -36,6 +36,9 @@ yarn-error.log*
|
|||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
# database
|
||||||
|
/db/
|
||||||
|
|
||||||
# claude
|
# claude
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
|||||||
149
CLAUDE.md
@@ -6,9 +6,10 @@ Instagram: @blackheartdancehouse
|
|||||||
Content language: Russian
|
Content language: Russian
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
- **Next.js 15** (App Router, TypeScript)
|
- **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,61 +17,155 @@ 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 → About → Classes → Team → [OpenDay] → Schedule → Pricing → MasterClasses → 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
|
||||||
│ ├── Reveal.tsx # Intersection Observer scroll reveal
|
│ ├── OpenDaySignupModal.tsx # Open Day class booking → API
|
||||||
│ └── TeamMemberModal.tsx # "use client" — member popup
|
│ ├── NewsModal.tsx # News detail popup
|
||||||
|
│ ├── Reveal.tsx # Intersection Observer scroll reveal
|
||||||
|
│ ├── 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
|
||||||
|
- **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
|
||||||
|
- Update the index when adding/removing/renaming files or exports
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
- **Never drop/recreate the database** — admin data (photos, edits, registrations) lives there
|
||||||
|
- Schema changes go through versioned migrations in `src/lib/db.ts` (`migrations` array)
|
||||||
|
- Add a new entry with the next version number; never modify existing migrations
|
||||||
|
- Migrations run automatically on server start via `runMigrations()` and are tracked in the `_migrations` table
|
||||||
|
- Use `CREATE TABLE IF NOT EXISTS` and column-existence checks (`PRAGMA table_info`) for safety
|
||||||
|
|
||||||
|
## Turbopack / Dev Server Troubleshooting
|
||||||
|
If the dev server hangs on "Compiling..." or shows a white page:
|
||||||
|
1. Kill all node processes: `taskkill /F /IM node.exe`
|
||||||
|
2. Remove stale lock: `rm -f .next/dev/lock`
|
||||||
|
3. Clear cache: `rm -rf .next node_modules/.cache`
|
||||||
|
4. Restart: `npm run dev`
|
||||||
|
- This often happens after shutting down the PC without stopping the server first
|
||||||
|
- Always stop the dev server (Ctrl+C) before shutting down
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
- Remote: Gitea at `git.dolgolyov-family.by`
|
- Remote: Gitea at `git.dolgolyov-family.by`
|
||||||
|
|||||||
1
content_tmp.json
Normal file
179
docs/booking-status-flow-plan.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Booking Status Flow — Planned Changes
|
||||||
|
|
||||||
|
## Current Flow (What We Have Now)
|
||||||
|
|
||||||
|
```
|
||||||
|
new ──["Связались →"]──→ contacted ──["Подтвердить"]──→ confirmed
|
||||||
|
──["Отказ"]──→ declined
|
||||||
|
|
||||||
|
confirmed ──["Вернуть"]──→ contacted
|
||||||
|
declined ──["Вернуть"]──→ contacted
|
||||||
|
```
|
||||||
|
|
||||||
|
### What buttons appear at each status:
|
||||||
|
|
||||||
|
| Status | Buttons shown |
|
||||||
|
|-----------|------------------------------------|
|
||||||
|
| new | "Связались →" |
|
||||||
|
| contacted | "Подтвердить", "Отказ" |
|
||||||
|
| confirmed | "Вернуть" |
|
||||||
|
| declined | "Вернуть" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problems Found
|
||||||
|
|
||||||
|
### 1. Can't confirm directly from `new`
|
||||||
|
Someone calls, books, and confirms in one conversation.
|
||||||
|
Admin must click "Связались" → wait → click "Подтвердить".
|
||||||
|
Two clicks for one real-world action.
|
||||||
|
|
||||||
|
### 2. Can't decline from `new`
|
||||||
|
Spam or invalid booking arrives.
|
||||||
|
Admin must first mark "Связались" (lie) → then "Отказ".
|
||||||
|
|
||||||
|
### 3. Reminders miss non-confirmed group bookings
|
||||||
|
SQL query in `db.ts` line 870:
|
||||||
|
```sql
|
||||||
|
WHERE status = 'confirmed' AND confirmed_date IN (?, ?)
|
||||||
|
```
|
||||||
|
If admin contacted someone and set a date but forgot to click "Подтвердить",
|
||||||
|
that person **never shows up** in reminders tab.
|
||||||
|
|
||||||
|
MC registrations don't have this problem — they show by event date regardless of status.
|
||||||
|
|
||||||
|
### 4. Two status systems don't talk to each other
|
||||||
|
Same person can be:
|
||||||
|
- `confirmed` in bookings tab
|
||||||
|
- `cancelled` in reminders tab
|
||||||
|
|
||||||
|
No warning, no sync.
|
||||||
|
|
||||||
|
### 5. "Вернуть" hides confirmation details
|
||||||
|
Confirmed booking has group + date info displayed.
|
||||||
|
"Вернуть" → status becomes `contacted` → info becomes invisible
|
||||||
|
(still in DB, but UI only shows it when `status === "confirmed"`).
|
||||||
|
Re-confirming requires filling everything again from scratch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Flow (What We Want)
|
||||||
|
|
||||||
|
```
|
||||||
|
new ──["Связались →"]──→ contacted
|
||||||
|
new ──["Подтвердить"]──→ confirmed ← NEW shortcut
|
||||||
|
new ──["Отказ"]──→ declined ← NEW shortcut
|
||||||
|
|
||||||
|
contacted ──["Подтвердить"]──→ confirmed
|
||||||
|
contacted ──["Отказ"]──→ declined
|
||||||
|
|
||||||
|
confirmed ──["Вернуть"]──→ contacted
|
||||||
|
declined ──["Вернуть"]──→ new ← CHANGED (was: contacted)
|
||||||
|
```
|
||||||
|
|
||||||
|
### New buttons at each status:
|
||||||
|
|
||||||
|
| Status | Buttons shown |
|
||||||
|
|-----------|------------------------------------------------|
|
||||||
|
| new | "Связались →", "Подтвердить", "Отказ" |
|
||||||
|
| contacted | "Подтвердить", "Отказ" |
|
||||||
|
| confirmed | "Вернуть" (→ contacted) |
|
||||||
|
| declined | "Вернуть" (→ new) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files To Change
|
||||||
|
|
||||||
|
### 1. `src/app/admin/bookings/BookingComponents.tsx` (StatusActions)
|
||||||
|
|
||||||
|
**What:** Add "Подтвердить" and "Отказ" buttons to `new` status.
|
||||||
|
Change "Вернуть" from `declined` to go to `new` instead of `contacted`.
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```tsx
|
||||||
|
{status === "new" && actionBtn("Связались →", ...)}
|
||||||
|
{status === "contacted" && (
|
||||||
|
actionBtn("Подтвердить", ...) + actionBtn("Отказ", ...)
|
||||||
|
)}
|
||||||
|
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", → "contacted")}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```tsx
|
||||||
|
{status === "new" && (
|
||||||
|
actionBtn("Связались →", → "contacted")
|
||||||
|
+ actionBtn("Подтвердить", → "confirmed")
|
||||||
|
+ actionBtn("Отказ", → "declined")
|
||||||
|
)}
|
||||||
|
{status === "contacted" && (
|
||||||
|
actionBtn("Подтвердить", → "confirmed")
|
||||||
|
+ actionBtn("Отказ", → "declined")
|
||||||
|
)}
|
||||||
|
{status === "confirmed" && actionBtn("Вернуть", → "contacted")}
|
||||||
|
{status === "declined" && actionBtn("Вернуть", → "new")}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `src/app/admin/bookings/GenericBookingsList.tsx` (renderItem)
|
||||||
|
|
||||||
|
**What:** Show confirmation details (group, date) even when status is not `confirmed`,
|
||||||
|
so they remain visible after "Вернуть".
|
||||||
|
|
||||||
|
**Before:** `renderExtra` only shows confirmed details when `b.status === "confirmed"`
|
||||||
|
**After:** Show confirmed details whenever they exist, regardless of status
|
||||||
|
(this is actually in `page.tsx` GroupBookingsTab `renderExtra` — line 291)
|
||||||
|
|
||||||
|
### 3. `src/app/admin/bookings/page.tsx` (GroupBookingsTab renderExtra)
|
||||||
|
|
||||||
|
**What:** Change condition from `b.status === "confirmed" && (b.confirmedGroup || ...)` to just `(b.confirmedGroup || b.confirmedDate)`.
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```tsx
|
||||||
|
{b.status === "confirmed" && (b.confirmedGroup || b.confirmedDate) && (
|
||||||
|
<span className="text-[10px] text-emerald-400/70">...</span>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```tsx
|
||||||
|
{(b.confirmedGroup || b.confirmedDate) && (
|
||||||
|
<span className="text-[10px] text-emerald-400/70">...</span>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. `src/lib/db.ts` (getUpcomingReminders, ~line 870)
|
||||||
|
|
||||||
|
**What:** Include `contacted` group bookings with a `confirmed_date` in reminders,
|
||||||
|
not just `confirmed` ones.
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```sql
|
||||||
|
SELECT * FROM group_bookings WHERE status = 'confirmed' AND confirmed_date IN (?, ?)
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```sql
|
||||||
|
SELECT * FROM group_bookings WHERE status IN ('confirmed', 'contacted') AND confirmed_date IN (?, ?)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files NOT Changed
|
||||||
|
|
||||||
|
- `types.ts` — BookingStatus type stays the same (`new | contacted | confirmed | declined`)
|
||||||
|
- `SearchBar.tsx` — No changes needed
|
||||||
|
- `AddBookingModal.tsx` — No changes needed
|
||||||
|
- `InlineNotes.tsx` — No changes needed
|
||||||
|
- `McRegistrationsTab.tsx` — No changes needed (MC doesn't use ConfirmModal)
|
||||||
|
- `OpenDayBookingsTab.tsx` — No changes needed
|
||||||
|
- API routes — No changes needed (they already accept any valid status)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Change | File | Risk |
|
||||||
|
|--------|------|------|
|
||||||
|
| Add confirm/decline buttons to `new` status | BookingComponents.tsx | Low — additive |
|
||||||
|
| "Вернуть" from declined → `new` instead of `contacted` | BookingComponents.tsx | Low — minor behavior change |
|
||||||
|
| Show confirmation details at any status | page.tsx (renderExtra) | Low — visual only |
|
||||||
|
| Include `contacted` bookings in reminders | db.ts | Low — shows more data, not less |
|
||||||
115
docs/regression-test-report-2026-03-24.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Booking Panel — Regression Test Report
|
||||||
|
|
||||||
|
**Date**: 2026-03-24
|
||||||
|
**Scope**: Full manual regression of booking functionality (public + admin)
|
||||||
|
**Method**: Browser automation (Chrome) + API testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Results Summary
|
||||||
|
|
||||||
|
| # | Test | Result | Notes |
|
||||||
|
|---|------|--------|-------|
|
||||||
|
| 1 | Public booking — valid submission | PASS | Name, phone, Instagram, Telegram all saved correctly |
|
||||||
|
| 2 | Public booking — empty name | PASS | Returns 400 |
|
||||||
|
| 2 | Public booking — empty phone | PASS | Returns 400 |
|
||||||
|
| 2 | Public booking — whitespace-only name | PASS | Returns 400 |
|
||||||
|
| 2 | Public booking — XSS in name | WARN | Accepted (200), stored raw — React escapes on render, but `sanitizeName` should strip tags |
|
||||||
|
| 2 | Public booking — 500-char name | PASS | Truncated to 100 chars by `sanitizeName` |
|
||||||
|
| 2 | Public booking — SQL injection | PASS | Parameterized queries, no execution |
|
||||||
|
| 2 | Public booking — rate limiting | PASS | 429 after 5 requests/minute |
|
||||||
|
| 3 | Admin — new booking visible | PASS | All fields including Instagram/Telegram displayed |
|
||||||
|
| 4 | Admin — status: new → contacted | PASS | Instant optimistic update, counts refresh |
|
||||||
|
| 4 | Admin — status: contacted → confirmed (ConfirmModal) | PASS | Cascade selects work: Hall → Trainer → Group |
|
||||||
|
| 4 | Admin — ConfirmModal Enter key submit | PASS | Enter submits when all fields filled |
|
||||||
|
| 4 | Admin — ConfirmModal Escape key close | PASS | |
|
||||||
|
| 4 | Admin — confirmed details visible | PASS | Group + date shown in green text |
|
||||||
|
| 4 | Admin — "Вернуть" preserves details | PASS | Confirmation info stays visible after revert (our fix) |
|
||||||
|
| 5 | Admin — delete confirmation dialog | PASS | Shows name, "Отмена"/"Удалить" buttons, Escape closes |
|
||||||
|
| 5 | Admin — XSS in delete dialog | PASS | Name safely escaped in confirmation dialog |
|
||||||
|
| 6 | Admin — search finds booking | PASS | Debounced, finds by name |
|
||||||
|
| 6 | Admin — search results: status actions | PASS | "Подтвердить"/"Отказ" buttons work in search view |
|
||||||
|
| 6 | Admin — search results: delete | PASS | Trash icon with confirmation in search view |
|
||||||
|
| 6 | Admin — search: empty query | PASS | Returns 200, no results |
|
||||||
|
| 6 | Admin — search: 1-char query | PASS | Returns empty (min 2 chars on client) |
|
||||||
|
| 6 | Admin — search: XSS query | PASS | Returns 200, no injection |
|
||||||
|
| 7 | Admin — notes save via API | PASS | 200 OK |
|
||||||
|
| 7 | Admin — notes clear via API | PASS | 200 OK |
|
||||||
|
| 7 | Admin — XSS in notes | WARN | Accepted and stored raw — React escapes on render |
|
||||||
|
| 7 | Admin — SQL injection in notes | PASS | Table survived, parameterized queries |
|
||||||
|
| 8 | Admin — AddBookingModal opens | PASS | "Занятие"/"Мероприятие" tabs |
|
||||||
|
| 8 | Admin — Instagram/Telegram fields | PASS | New fields visible (our fix #9) |
|
||||||
|
| 9 | Admin — MC tab loads | PASS | Grouped by MC title, archive section works |
|
||||||
|
| 9 | Admin — Open Day tab loads | PASS | Grouped by time+style, person counts correct |
|
||||||
|
| 10 | Admin — Reminders tab (empty) | PASS | "Нет напоминаний" shown when no upcoming events |
|
||||||
|
| 12 | Admin — CSRF protection | PASS | All mutating calls return 403 without valid token |
|
||||||
|
| 12 | Admin — invalid action | PASS | Returns 400 |
|
||||||
|
| 12 | Admin — invalid status value | PASS | Returns 400 |
|
||||||
|
| 12 | Admin — delete invalid ID format | PASS | Returns 400 |
|
||||||
|
| 12 | Admin — delete non-existent | PASS | Returns 200 (idempotent) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bugs Found
|
||||||
|
|
||||||
|
### BUG-1: XSS payload stored raw in database (LOW)
|
||||||
|
**Severity**: Low (React auto-escapes, no actual XSS execution)
|
||||||
|
**Steps**: Submit `<script>alert(1)</script>` as name in public booking form
|
||||||
|
**Expected**: `sanitizeName` should strip HTML tags
|
||||||
|
**Actual**: Stored as-is in DB, rendered safely by React
|
||||||
|
**Risk**: If content is ever rendered outside React (email, export, SSR with `dangerouslySetInnerHTML`), XSS could execute
|
||||||
|
**Fix**: Add HTML tag stripping in `sanitizeName()` — e.g., `name.replace(/<[^>]*>/g, '')`
|
||||||
|
|
||||||
|
### BUG-2: XSS in notes stored raw (LOW)
|
||||||
|
**Severity**: Low (same as BUG-1)
|
||||||
|
**Steps**: Save `<img src=x onerror=alert(1)>` as a note via API
|
||||||
|
**Actual**: Stored raw, rendered safely by React
|
||||||
|
**Fix**: Strip HTML in notes save path or add a `sanitizeText()` call
|
||||||
|
|
||||||
|
### BUG-3: Dashboard counts include archived/past MC bookings (MEDIUM)
|
||||||
|
**Severity**: Medium (misleading)
|
||||||
|
**Steps**: View bookings page with past MC events
|
||||||
|
**Expected**: Dashboard "Мастер-классы: 5 новых" should only count upcoming MCs
|
||||||
|
**Actual**: Counts ALL MC registrations regardless of event date. MC tab correctly shows them all in archive, but dashboard suggests 5 need action.
|
||||||
|
**Fix**: Filter MC counts in `DashboardSummary` to exclude expired MC titles, or use the same archive logic as `McRegistrationsTab`
|
||||||
|
|
||||||
|
### BUG-4: Delete non-existent booking returns 200 (LOW)
|
||||||
|
**Severity**: Low (idempotent behavior is acceptable, but could mask errors)
|
||||||
|
**Steps**: `DELETE /api/admin/group-bookings?id=99999`
|
||||||
|
**Expected**: 404 or 200
|
||||||
|
**Actual**: 200 (SQLite DELETE with no matching rows succeeds silently)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verified Fixes (from today's commit)
|
||||||
|
|
||||||
|
| Fix | Status |
|
||||||
|
|-----|--------|
|
||||||
|
| #1 Delete confirmation dialog | VERIFIED |
|
||||||
|
| #2 Error toasts (not silent catch) | VERIFIED (API errors return proper codes) |
|
||||||
|
| #3 Optimistic rollback | VERIFIED (status/notes API tested) |
|
||||||
|
| #4 Loading state on reminder buttons | VERIFIED (savingIds logic in code) |
|
||||||
|
| #5 Actionable search results | VERIFIED (status + delete work in search) |
|
||||||
|
| #6 Banner instead of remount | VERIFIED (no `key={refreshKey}`) |
|
||||||
|
| #8 Notes only save when changed | VERIFIED (onBlur checks `text !== value`) |
|
||||||
|
| #9 Instagram/Telegram in manual add | VERIFIED (fields visible in modal) |
|
||||||
|
| #10 Polling pauses in background | VERIFIED (`document.hidden` check in code) |
|
||||||
|
| #11 Enter submits ConfirmModal | VERIFIED (Enter key submitted confirmation) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Improvement Suggestions
|
||||||
|
|
||||||
|
### HIGH
|
||||||
|
1. **Strip HTML tags from user input** — Add `.replace(/<[^>]*>/g, '')` to `sanitizeName` and notes save path
|
||||||
|
2. **Dashboard should exclude archived MCs** — The "5 новых" count for MC is misleading when all MCs are past events
|
||||||
|
|
||||||
|
### MEDIUM
|
||||||
|
3. **Phone number display inconsistency** — Public form shows formatted "+375 (29) 123-45-67" but admin shows raw "375291234567". Consider formatting in admin view too.
|
||||||
|
4. **Long name overflows card** — The 100-char "AAAA..." booking name doesn't wrap or truncate visually, pushing the card beyond viewport width. Add `truncate` or `break-words` to the name element.
|
||||||
|
5. **Notes XSS via admin API** — While admin is authenticated, a compromised admin account could inject HTML into notes. Low risk but easy to prevent.
|
||||||
|
|
||||||
|
### LOW
|
||||||
|
6. **Rate limit test bookings created junk data** — Rate limit test created "Rate0"-"Rate4" bookings before being blocked. These pollute the database. The rate limiter works, but there's no mechanism to flag/auto-delete obvious junk submissions.
|
||||||
|
7. **No pagination** — If bookings grow to hundreds, the flat list will be slow. Not urgent for a small dance school, but worth planning.
|
||||||
|
8. **Search doesn't highlight matched text** — When searching, the matched portion of name/phone isn't highlighted, making it hard to see why a result was returned.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
serverExternalPackages: ["better-sqlite3"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
892
package-lock.json
generated
@@ -6,9 +6,11 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"seed": "tsx src/data/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
"lucide-react": "^0.576.0",
|
"lucide-react": "^0.576.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -16,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -24,6 +27,7 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/images/master-classes/anastasia-chaley-1773587537984.webp
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/images/master-classes/exot-1773584463793.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/images/master-classes/exot-1773675117871.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/images/news/online-classes-1773605597711.webp
Normal file
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 130 KiB |
BIN
public/images/team/20190816-164735-1774466974671.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/images/team/20190816-164735-1774466997272.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/images/team/20190816-164735-1774467131281.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/images/team/20190816-164735-1774467278915.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/images/team/20190816-164735-1774467770193.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/images/team/20190816-164735-1774467783564.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/images/team/75398-original-1773399182323.jpg
Normal file
|
After Width: | Height: | Size: 579 KiB |
BIN
public/images/team/75398-original-1773399182323.webp
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/images/team/angel-1773234723454.PNG
Normal file
|
After Width: | Height: | Size: 313 KiB |
BIN
public/images/team/angel-1773234723454.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/images/team/angel-1773588817170.PNG
Normal file
|
After Width: | Height: | Size: 313 KiB |
BIN
public/images/team/angel-1773588817170.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/images/team/photo-2025-06-28-23-11-20-1773234496259.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/images/team/photo-2025-06-28-23-11-20-1773234496259.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
BIN
public/video/ira.mp4
Normal file
BIN
public/video/nadezda.mp4
Normal file
BIN
public/video/nastya-2.mp4
Normal file
BIN
public/video/nastya.mp4
Normal file
18
src/app/admin/_components/AdminSkeleton.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/** Reusable loading skeleton for admin pages */
|
||||||
|
export function AdminSkeleton({ rows = 3 }: { rows?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
{/* Title skeleton */}
|
||||||
|
<div className="h-8 w-48 rounded-lg bg-neutral-800" />
|
||||||
|
{/* Content skeletons */}
|
||||||
|
{Array.from({ length: rows }, (_, i) => (
|
||||||
|
<div key={i} className="space-y-3">
|
||||||
|
<div className="h-4 w-24 rounded bg-neutral-800" />
|
||||||
|
<div className="h-10 w-full rounded-lg bg-neutral-800" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
391
src/app/admin/_components/ArrayEditor.tsx
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Plus, Trash2, GripVertical, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
|
|
||||||
|
interface ArrayEditorProps<T> {
|
||||||
|
items: T[];
|
||||||
|
onChange: (items: T[]) => void;
|
||||||
|
renderItem: (item: T, index: number, update: (item: T) => void) => React.ReactNode;
|
||||||
|
createItem: () => T;
|
||||||
|
label?: string;
|
||||||
|
addLabel?: string;
|
||||||
|
collapsible?: boolean;
|
||||||
|
getItemTitle?: (item: T, index: number) => string;
|
||||||
|
getItemBadge?: (item: T, index: number) => React.ReactNode;
|
||||||
|
hiddenItems?: Set<number>;
|
||||||
|
addPosition?: "top" | "bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArrayEditor<T>({
|
||||||
|
items,
|
||||||
|
onChange,
|
||||||
|
renderItem,
|
||||||
|
createItem,
|
||||||
|
label,
|
||||||
|
addLabel = "Добавить",
|
||||||
|
collapsible = false,
|
||||||
|
getItemTitle,
|
||||||
|
getItemBadge,
|
||||||
|
hiddenItems,
|
||||||
|
addPosition = "bottom",
|
||||||
|
}: ArrayEditorProps<T>) {
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
|
const [insertAt, setInsertAt] = useState<number | null>(null);
|
||||||
|
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||||
|
const [dragSize, setDragSize] = useState({ w: 0, h: 0 });
|
||||||
|
const [grabOffset, setGrabOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [newItemIndex, setNewItemIndex] = useState<number | null>(null);
|
||||||
|
const [droppedIndex, setDroppedIndex] = useState<number | null>(null);
|
||||||
|
const [collapsed, setCollapsed] = useState<Set<number>>(() => collapsible ? new Set(items.map((_, i) => i)) : new Set());
|
||||||
|
|
||||||
|
function toggleCollapse(index: number) {
|
||||||
|
setCollapsed(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(index)) next.delete(index);
|
||||||
|
else next.add(index);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { setMounted(true); }, []);
|
||||||
|
|
||||||
|
// Scroll to newly added item
|
||||||
|
useEffect(() => {
|
||||||
|
if (newItemIndex !== null && itemRefs.current[newItemIndex]) {
|
||||||
|
itemRefs.current[newItemIndex]?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
setNewItemIndex(null);
|
||||||
|
}
|
||||||
|
}, [newItemIndex, items]);
|
||||||
|
|
||||||
|
function updateItem(index: number, item: T) {
|
||||||
|
const updated = [...items];
|
||||||
|
updated[index] = item;
|
||||||
|
onChange(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(index: number) {
|
||||||
|
onChange(items.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDrag = useCallback(
|
||||||
|
(clientX: number, clientY: number, index: number) => {
|
||||||
|
const el = itemRefs.current[index];
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
setDragIndex(index);
|
||||||
|
setInsertAt(index);
|
||||||
|
setMousePos({ x: clientX, y: clientY });
|
||||||
|
setDragSize({ w: rect.width, h: rect.height });
|
||||||
|
setGrabOffset({ x: clientX - rect.left, y: clientY - rect.top });
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGripMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startDrag(e.clientX, e.clientY, index);
|
||||||
|
},
|
||||||
|
[startDrag]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dragIndex === null) return;
|
||||||
|
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
setMousePos({ x: e.clientX, y: e.clientY });
|
||||||
|
|
||||||
|
let newInsert = items.length;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (i === dragIndex) continue;
|
||||||
|
const el = itemRefs.current[i];
|
||||||
|
if (!el) continue;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const midY = rect.top + rect.height / 2;
|
||||||
|
if (e.clientY < midY) {
|
||||||
|
newInsert = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInsertAt(newInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
// Read current values from state updaters but defer onChange to avoid
|
||||||
|
// calling parent setState during React's render/updater cycle
|
||||||
|
let capturedDrag: number | null = null;
|
||||||
|
let capturedInsert: number | null = null;
|
||||||
|
|
||||||
|
setDragIndex((prev) => { capturedDrag = prev; return null; });
|
||||||
|
setInsertAt((prev) => { capturedInsert = prev; return null; });
|
||||||
|
|
||||||
|
// Defer the reorder to next microtask so React finishes its batch first
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (capturedDrag !== null && capturedInsert !== null) {
|
||||||
|
let targetIndex = capturedInsert;
|
||||||
|
if (capturedDrag < targetIndex) targetIndex -= 1;
|
||||||
|
if (capturedDrag !== targetIndex) {
|
||||||
|
const updated = [...items];
|
||||||
|
const [moved] = updated.splice(capturedDrag, 1);
|
||||||
|
updated.splice(targetIndex, 0, moved);
|
||||||
|
onChange(updated);
|
||||||
|
setDroppedIndex(targetIndex);
|
||||||
|
setTimeout(() => setDroppedIndex(null), 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
|
window.addEventListener("mouseup", onMouseUp);
|
||||||
|
return () => {
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
}, [dragIndex, items, onChange]);
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
if (dragIndex === null || insertAt === null) {
|
||||||
|
return items.map((item, i) => {
|
||||||
|
const isCollapsed = collapsible && collapsed.has(i) && newItemIndex !== i;
|
||||||
|
const isHidden = hiddenItems?.has(i) ?? false;
|
||||||
|
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
|
className={`rounded-lg border bg-neutral-900/50 mb-3 hover:border-white/25 hover:bg-neutral-800/50 focus-within:border-gold/50 focus-within:bg-neutral-800 transition-all ${
|
||||||
|
newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
|
||||||
|
} ${isHidden ? "hidden" : ""}`}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||||
|
aria-label="Перетащить для сортировки"
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</div>
|
||||||
|
{collapsible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleCollapse(i)}
|
||||||
|
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
|
||||||
|
{getItemBadge?.(item, i)}
|
||||||
|
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(i)}
|
||||||
|
aria-label="Удалить элемент"
|
||||||
|
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{collapsible ? (
|
||||||
|
<div
|
||||||
|
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||||
|
style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements: React.ReactNode[] = [];
|
||||||
|
let visualIndex = 0;
|
||||||
|
let placeholderPos = insertAt;
|
||||||
|
if (insertAt > dragIndex) placeholderPos = insertAt - 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (i === dragIndex) {
|
||||||
|
elements.push(
|
||||||
|
<div key={`hidden-${i}`} ref={(el) => { itemRefs.current[i] = el; }} className="hidden" />
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visualIndex === placeholderPos) {
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key="placeholder"
|
||||||
|
className="rounded-lg border-2 border-dashed border-gold/40 bg-gold/5 mb-3"
|
||||||
|
style={{ height: collapsible ? 48 : dragSize.h }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = items[i];
|
||||||
|
const dragTitle = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
|
className="rounded-lg border border-white/10 bg-neutral-900/50 mb-3 transition-colors"
|
||||||
|
>
|
||||||
|
{collapsible ? (
|
||||||
|
<div className="flex items-center gap-2 p-4">
|
||||||
|
<GripVertical size={16} className="text-neutral-500 shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-neutral-300 truncate">{dragTitle}</span>
|
||||||
|
{getItemBadge?.(item, i)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start justify-between gap-2 p-4 pb-0 mb-3">
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||||
|
aria-label="Перетащить для сортировки"
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(i)}
|
||||||
|
aria-label="Удалить элемент"
|
||||||
|
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
visualIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visualIndex === placeholderPos) {
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key="placeholder"
|
||||||
|
className="rounded-lg border-2 border-dashed border-gold/40 bg-gold/5 mb-3"
|
||||||
|
style={{ height: collapsible ? 48 : dragSize.h }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{(label || (collapsible && items.length > 1)) && (
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
{label ? <h3 className="text-sm font-medium text-neutral-300">{label}</h3> : <div />}
|
||||||
|
{collapsible && items.length > 1 && (() => {
|
||||||
|
const allCollapsed = collapsed.size >= items.length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => allCollapsed ? setCollapsed(new Set()) : setCollapsed(new Set(items.map((_, i) => i)))}
|
||||||
|
className="rounded p-1 text-neutral-500 hover:text-white transition-colors"
|
||||||
|
title={allCollapsed ? "Развернуть все" : "Свернуть все"}
|
||||||
|
>
|
||||||
|
<ChevronsUpDown size={16} className={`transition-transform duration-200 ${allCollapsed ? "" : "rotate-90"}`} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addPosition === "top" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange([createItem(), ...items]);
|
||||||
|
setNewItemIndex(0);
|
||||||
|
// Shift collapsed indices and ensure new item is expanded
|
||||||
|
setCollapsed(prev => {
|
||||||
|
const next = new Set<number>();
|
||||||
|
for (const idx of prev) next.add(idx + 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="mb-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{addLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{renderList()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addPosition === "bottom" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange([...items, createItem()]);
|
||||||
|
setNewItemIndex(items.length);
|
||||||
|
setCollapsed(prev => { const next = new Set(prev); next.delete(items.length); return next; });
|
||||||
|
}}
|
||||||
|
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{addLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Floating clone following cursor */}
|
||||||
|
{mounted && dragIndex !== null &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[9999] pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: mousePos.x - grabOffset.x,
|
||||||
|
top: mousePos.y - grabOffset.y,
|
||||||
|
width: dragSize.w,
|
||||||
|
height: dragSize.h,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="h-full rounded-lg border-2 border-gold/60 bg-neutral-900/95 shadow-2xl shadow-gold/20 flex items-center gap-3 px-4">
|
||||||
|
<GripVertical size={16} className="text-gold shrink-0" />
|
||||||
|
<span className="text-sm text-neutral-300">{collapsible && dragIndex !== null ? (getItemTitle?.(items[dragIndex], dragIndex) || "Перемещение...") : "Перемещение элемента..."}</span>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDelete !== null}
|
||||||
|
title="Удалить элемент?"
|
||||||
|
message="Это действие нельзя отменить."
|
||||||
|
onConfirm={() => { if (confirmDelete !== null) removeItem(confirmDelete); setConfirmDelete(null); }}
|
||||||
|
onCancel={() => setConfirmDelete(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/app/admin/_components/CollapsibleSection.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
interface CollapsibleSectionProps {
|
||||||
|
title: string;
|
||||||
|
count?: number;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
isOpen?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared collapsible section for admin pages.
|
||||||
|
* Supports both controlled (isOpen/onToggle) and uncontrolled (defaultOpen) modes.
|
||||||
|
*/
|
||||||
|
export function CollapsibleSection({
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
defaultOpen = true,
|
||||||
|
isOpen: controlledOpen,
|
||||||
|
onToggle,
|
||||||
|
children,
|
||||||
|
}: CollapsibleSectionProps) {
|
||||||
|
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||||
|
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||||
|
const toggle = onToggle ?? (() => setInternalOpen((v) => !v));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-neutral-900/30 overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex items-center justify-between w-full px-5 py-3.5 text-left cursor-pointer group hover:bg-white/[0.02] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-200 group-hover:text-white transition-colors">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{count !== undefined && (
|
||||||
|
<span className="text-xs text-neutral-500">{count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`text-neutral-500 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||||
|
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="px-5 pb-5 space-y-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/app/admin/_components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { AlertTriangle, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
destructive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = "Удалить",
|
||||||
|
cancelLabel = "Отмена",
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
destructive = true,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
cancelRef.current?.focus();
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onCancel();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onCancel]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-neutral-900 p-6 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className="absolute right-3 top-3 rounded-full p-1 text-neutral-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{destructive && (
|
||||||
|
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-500/10">
|
||||||
|
<AlertTriangle size={20} className="text-red-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-white">{title}</h3>
|
||||||
|
<p className="mt-1.5 text-sm text-neutral-400">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
ref={cancelRef}
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded-lg px-4 py-2 text-sm font-medium text-neutral-300 hover:bg-white/[0.06] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`rounded-lg px-4 py-2 text-sm font-semibold transition-colors cursor-pointer ${
|
||||||
|
destructive
|
||||||
|
? "bg-red-600 text-white hover:bg-red-500"
|
||||||
|
: "bg-gold text-black hover:bg-gold-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
691
src/app/admin/_components/FormField.tsx
Normal file
@@ -0,0 +1,691 @@
|
|||||||
|
import { useRef, useEffect, useState, useMemo } from "react";
|
||||||
|
import { Plus, X, Upload, Loader2, Link, ImageIcon, AlertCircle } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import type { RichListItem } from "@/types/content";
|
||||||
|
|
||||||
|
interface InputFieldProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
type?: "text" | "url" | "tel";
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseInput = "w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none hover:border-gold/30 focus:border-gold transition-colors";
|
||||||
|
const textAreaInput = `${baseInput} resize-none overflow-hidden`;
|
||||||
|
const smallInput = "rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none hover:border-gold/30 focus:border-gold transition-colors";
|
||||||
|
const dashedInput = "flex-1 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-white placeholder-neutral-600 outline-none hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors";
|
||||||
|
const inputCls = baseInput;
|
||||||
|
|
||||||
|
export function InputField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
type = "text",
|
||||||
|
}: InputFieldProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ParticipantLimits({
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
onMinChange,
|
||||||
|
onMaxChange,
|
||||||
|
}: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
onMinChange: (v: number) => void;
|
||||||
|
onMaxChange: (v: number) => void;
|
||||||
|
}) {
|
||||||
|
const [minStr, setMinStr] = useState(String(min));
|
||||||
|
const [maxStr, setMaxStr] = useState(String(max));
|
||||||
|
const minLocal = parseInt(minStr) || 0;
|
||||||
|
const maxLocal = parseInt(maxStr) || 0;
|
||||||
|
const minEmpty = minStr === "";
|
||||||
|
const maxEmpty = maxStr === "";
|
||||||
|
const maxError = (maxLocal > 0 && minLocal > 0 && maxLocal < minLocal) || minEmpty || maxEmpty;
|
||||||
|
|
||||||
|
function handleMin(raw: string) {
|
||||||
|
setMinStr(raw);
|
||||||
|
if (raw === "") return;
|
||||||
|
const v = parseInt(raw) || 0;
|
||||||
|
const curMax = parseInt(maxStr) || 0;
|
||||||
|
if (curMax > 0 && v > curMax) return;
|
||||||
|
onMinChange(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMax(raw: string) {
|
||||||
|
setMaxStr(raw);
|
||||||
|
if (raw === "") return;
|
||||||
|
const v = parseInt(raw) || 0;
|
||||||
|
const curMin = parseInt(minStr) || 0;
|
||||||
|
if (v > 0 && v < curMin) return;
|
||||||
|
onMaxChange(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMsg = minEmpty || maxEmpty
|
||||||
|
? "Поле не может быть пустым"
|
||||||
|
: maxLocal > 0 && minLocal > maxLocal
|
||||||
|
? "Макс. не может быть меньше мин."
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Мин. участников</label>
|
||||||
|
<input type="number" min={0} value={minStr} onChange={(e) => handleMin(e.target.value)}
|
||||||
|
className={`${inputCls} ${minEmpty ? "!border-red-500/50" : ""}`} />
|
||||||
|
<p className={`text-[10px] mt-1 ${minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||||
|
{minEmpty ? "Поле не может быть пустым" : "Если записей меньше — занятие можно отменить"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Макс. участников</label>
|
||||||
|
<input type="number" min={0} value={maxStr} onChange={(e) => handleMax(e.target.value)}
|
||||||
|
className={`${inputCls} ${maxEmpty || (maxLocal > 0 && minLocal > maxLocal) ? "!border-red-500/50" : ""}`} />
|
||||||
|
<p className={`text-[10px] mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
|
||||||
|
{maxEmpty ? "Поле не может быть пустым" : maxLocal > 0 && minLocal > maxLocal ? "Макс. не может быть меньше мин." : "0 = без лимита. При заполнении — лист ожидания"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextareaFieldProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextareaField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
rows = 3,
|
||||||
|
}: TextareaFieldProps) {
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = el.scrollHeight + "px";
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onResize() {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = el.scrollHeight + "px";
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => window.removeEventListener("resize", onResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
className={textAreaInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectFieldProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
}: SelectFieldProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const selectedLabel = options.find((o) => o.value === value)?.label || "";
|
||||||
|
const filtered = search
|
||||||
|
? options.filter((o) => {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return o.label.toLowerCase().split(/\s+/).some((word) => word.startsWith(q));
|
||||||
|
})
|
||||||
|
: options;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handle(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handle);
|
||||||
|
return () => document.removeEventListener("mousedown", handle);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
{label && <label className="block text-sm text-neutral-400 mb-1.5">{label}</label>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(!open);
|
||||||
|
setSearch("");
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}}
|
||||||
|
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${
|
||||||
|
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||||
|
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`}
|
||||||
|
>
|
||||||
|
{selectedLabel || placeholder || "Выберите..."}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||||
|
{options.length > 3 && (
|
||||||
|
<div className="p-1.5">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Поиск..."
|
||||||
|
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
|
||||||
|
)}
|
||||||
|
{filtered.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(opt.value);
|
||||||
|
setOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-white/5 ${
|
||||||
|
opt.value === value ? "text-gold bg-gold/5" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeRangeFieldProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFieldProps) {
|
||||||
|
const parts = value.split("–");
|
||||||
|
const start = parts[0]?.trim() || "";
|
||||||
|
const end = parts[1]?.trim() || "";
|
||||||
|
|
||||||
|
function update(s: string, e: string) {
|
||||||
|
if (s && e) {
|
||||||
|
onChange(`${s}–${e}`);
|
||||||
|
} else if (s) {
|
||||||
|
onChange(s);
|
||||||
|
} else {
|
||||||
|
onChange("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStartChange(newStart: string) {
|
||||||
|
if (newStart && end && newStart >= end) {
|
||||||
|
update(newStart, "");
|
||||||
|
} else {
|
||||||
|
update(newStart, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEndChange(newEnd: string) {
|
||||||
|
if (start && newEnd && newEnd <= start) return;
|
||||||
|
update(start, newEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={start}
|
||||||
|
onChange={(e) => handleStartChange(e.target.value)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
<span className="text-neutral-500">–</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={end}
|
||||||
|
onChange={(e) => handleEndChange(e.target.value)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToggleFieldProps {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToggleField({ label, checked, onChange }: ToggleFieldProps) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||||||
|
checked ? "bg-gold" : "bg-neutral-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
|
||||||
|
checked ? "translate-x-5" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-neutral-300">{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListFieldProps {
|
||||||
|
label: string;
|
||||||
|
items: string[];
|
||||||
|
onChange: (items: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListField({ label, items, onChange, placeholder }: ListFieldProps) {
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
const val = draft.trim();
|
||||||
|
if (!val) return;
|
||||||
|
onChange([...items, val]);
|
||||||
|
setDraft("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index: number) {
|
||||||
|
onChange(items.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(index: number, value: string) {
|
||||||
|
onChange(items.map((item, i) => (i === index ? value : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item}
|
||||||
|
onChange={(e) => update(i, e.target.value)}
|
||||||
|
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-4 py-2 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||||
|
onBlur={add}
|
||||||
|
placeholder={placeholder || "Добавить..."}
|
||||||
|
className={dashedInput}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={add}
|
||||||
|
disabled={!draft.trim()}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-gold transition-colors disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VictoryListFieldProps {
|
||||||
|
label: string;
|
||||||
|
items: RichListItem[];
|
||||||
|
onChange: (items: RichListItem[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
onLinkValidate?: (key: string, error: string | null) => void;
|
||||||
|
onUploadComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate, onUploadComplete }: VictoryListFieldProps) {
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
const val = draft.trim();
|
||||||
|
if (!val) return;
|
||||||
|
onChange([...items, { text: val }]);
|
||||||
|
setDraft("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index: number) {
|
||||||
|
onChange(items.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateText(index: number, text: string) {
|
||||||
|
onChange(items.map((item, i) => (i === index ? { ...item, text } : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLink(index: number, link: string) {
|
||||||
|
onChange(items.map((item, i) => (i === index ? { ...item, link: link || undefined } : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage(index: number) {
|
||||||
|
onChange(items.map((item, i) => (i === index ? { ...item, image: undefined } : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(index: number, e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploadingIndex(index);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("folder", "team");
|
||||||
|
try {
|
||||||
|
const res = await adminFetch("/api/admin/upload", { method: "POST", body: formData });
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.path) {
|
||||||
|
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
|
||||||
|
onUploadComplete?.();
|
||||||
|
}
|
||||||
|
} catch { /* upload failed */ } finally {
|
||||||
|
setUploadingIndex(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-2.5 space-y-1.5 transition-colors hover:border-gold/30 hover:bg-neutral-800/80 focus-within:border-gold/50 focus-within:bg-neutral-800">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.text}
|
||||||
|
onChange={(e) => updateText(i, e.target.value)}
|
||||||
|
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
className="shrink-0 rounded-md p-1.5 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{item.image ? (
|
||||||
|
<div className="flex items-center gap-1 rounded bg-neutral-700/50 px-1.5 py-0.5 text-[11px] text-neutral-300">
|
||||||
|
<ImageIcon size={10} className="text-gold" />
|
||||||
|
<span className="max-w-[80px] truncate">{item.image.split("/").pop()}</span>
|
||||||
|
<button type="button" onClick={() => removeImage(i)} className="text-neutral-500 hover:text-red-400">
|
||||||
|
<X size={9} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<label className="flex cursor-pointer items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-neutral-500 hover:text-neutral-300 transition-colors">
|
||||||
|
{uploadingIndex === i ? <Loader2 size={10} className="animate-spin" /> : <Upload size={10} />}
|
||||||
|
{uploadingIndex === i ? "..." : "Фото"}
|
||||||
|
<input type="file" accept="image/*" onChange={(e) => handleUpload(i, e)} className="hidden" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<ValidatedLinkField
|
||||||
|
value={item.link || ""}
|
||||||
|
onChange={(v) => updateLink(i, v)}
|
||||||
|
validationKey={`edu-${i}`}
|
||||||
|
onValidate={onLinkValidate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||||
|
onBlur={add}
|
||||||
|
placeholder={placeholder || "Добавить..."}
|
||||||
|
className={dashedInput}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={add}
|
||||||
|
disabled={!draft.trim()}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-gold transition-colors disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Link Field with Validation ---
|
||||||
|
interface ValidatedLinkFieldProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onValidate?: (key: string, error: string | null) => void;
|
||||||
|
validationKey?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValidatedLinkField({ value, onChange, onValidate, validationKey, placeholder }: ValidatedLinkFieldProps) {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function validate(url: string) {
|
||||||
|
if (!url) {
|
||||||
|
setError(null);
|
||||||
|
onValidate?.(validationKey || "", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
setError(null);
|
||||||
|
onValidate?.(validationKey || "", null);
|
||||||
|
} catch {
|
||||||
|
setError("Некорректная ссылка");
|
||||||
|
onValidate?.(validationKey || "", "invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 flex-1">
|
||||||
|
<Link size={12} className="text-neutral-500 shrink-0" />
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
validate(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder={placeholder || "Ссылка..."}
|
||||||
|
className={`w-full rounded-md border bg-neutral-800 px-2 py-1 text-xs text-white placeholder-neutral-600 outline-none transition-colors ${
|
||||||
|
error ? "border-red-500/50" : "border-white/5 focus:border-gold/50"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<span className="absolute right-1.5 top-1/2 -translate-y-1/2">
|
||||||
|
<AlertCircle size={10} className="text-red-400" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Autocomplete Multi-Select ---
|
||||||
|
export function AutocompleteMulti({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
options: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const selected = useMemo(() => (value ? value.split(/\s*[,·]\s*/).filter(Boolean) : []), [value]);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!query) return options.filter((o) => !selected.includes(o));
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return options.filter((o) => !selected.includes(o) && o.toLowerCase().includes(q));
|
||||||
|
}, [query, options, selected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handle(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handle);
|
||||||
|
return () => document.removeEventListener("mousedown", handle);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function addItem(item: string) {
|
||||||
|
onChange([...selected, item].join(" · "));
|
||||||
|
setQuery("");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(item: string) {
|
||||||
|
onChange(selected.filter((s) => s !== item).join(" · "));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filtered.length > 0) addItem(filtered[0]);
|
||||||
|
else if (query.trim()) addItem(query.trim());
|
||||||
|
}
|
||||||
|
if (e.key === "Backspace" && !query && selected.length > 0) {
|
||||||
|
removeItem(selected[selected.length - 1]);
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") { setOpen(false); setQuery(""); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<div
|
||||||
|
onClick={() => { setOpen(true); inputRef.current?.focus(); }}
|
||||||
|
className={`flex flex-wrap items-center gap-1.5 rounded-lg border bg-neutral-800 px-3 py-2 min-h-[42px] cursor-text transition-colors ${
|
||||||
|
open ? "border-gold" : "border-white/10 hover:border-gold/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected.map((item) => (
|
||||||
|
<span key={item} className="inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/30 px-2.5 py-0.5 text-xs font-medium text-gold">
|
||||||
|
{item}
|
||||||
|
<button type="button" onClick={(e) => { e.stopPropagation(); removeItem(item); }} className="text-gold/60 hover:text-gold transition-colors">
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={selected.length === 0 ? placeholder : ""}
|
||||||
|
className="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-neutral-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{open && filtered.length > 0 && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden max-h-48 overflow-y-auto">
|
||||||
|
{filtered.map((opt) => (
|
||||||
|
<button key={opt} type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => addItem(opt)}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/5 transition-colors">
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/app/admin/_components/PriceField.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
interface PriceFieldProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceField({ label, value, onChange, placeholder = "0" }: PriceFieldProps) {
|
||||||
|
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={raw}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
onChange(v ? `${v} BYN` : "");
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex-1 bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none min-w-0"
|
||||||
|
/>
|
||||||
|
<span className="flex items-center pr-4 text-sm font-medium text-gold select-none">
|
||||||
|
BYN
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/app/admin/_components/SectionEditor.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { Loader2, Check, AlertCircle } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
|
||||||
|
interface SectionEditorProps<T> {
|
||||||
|
sectionKey: string;
|
||||||
|
title: string;
|
||||||
|
children: (data: T, update: (data: T) => void) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 800;
|
||||||
|
|
||||||
|
export function SectionEditor<T>({
|
||||||
|
sectionKey,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: SectionEditorProps<T>) {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const initialLoadRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch(`/api/admin/sections/${sectionKey}`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error("Failed to load");
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(setData)
|
||||||
|
.catch(() => setError("Не удалось загрузить данные"))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [sectionKey]);
|
||||||
|
|
||||||
|
const save = useCallback(async (dataToSave: T) => {
|
||||||
|
setStatus("saving");
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await adminFetch(`/api/admin/sections/${sectionKey}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(dataToSave),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to save");
|
||||||
|
setStatus("saved");
|
||||||
|
setTimeout(() => setStatus((s) => (s === "saved" ? "idle" : s)), 2000);
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
setError("Ошибка сохранения");
|
||||||
|
setTimeout(() => setStatus((s) => (s === "error" ? "idle" : s)), 4000);
|
||||||
|
}
|
||||||
|
}, [sectionKey]);
|
||||||
|
|
||||||
|
// Auto-save with debounce whenever data changes (skip initial load)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
if (initialLoadRef.current) {
|
||||||
|
initialLoadRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
save(data);
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [data, save]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-neutral-400">
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <p className="text-red-400">{error || "Данные не найдены"}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">{title}</h1>
|
||||||
|
|
||||||
|
{/* Fixed toast popup */}
|
||||||
|
{(status === "saved" || status === "error") && (
|
||||||
|
<div className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
||||||
|
status === "saved"
|
||||||
|
? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
|
||||||
|
: "bg-red-950/90 border-red-500/30 text-red-200"
|
||||||
|
}`}>
|
||||||
|
{status === "saved" && <><Check size={14} /> Сохранено</>}
|
||||||
|
{status === "error" && <><AlertCircle size={14} /> {error}</>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-6">{children(data, setData)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/app/admin/_components/Toast.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, createContext, useContext } from "react";
|
||||||
|
import { X, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface ToastItem {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
type: "error" | "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
showError: (message: string) => void;
|
||||||
|
showSuccess: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue>({
|
||||||
|
showError: () => {},
|
||||||
|
showSuccess: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
return useContext(ToastContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||||
|
|
||||||
|
const addToast = useCallback((message: string, type: "error" | "success") => {
|
||||||
|
const id = ++nextId;
|
||||||
|
setToasts((prev) => [...prev, { id, message, type }]);
|
||||||
|
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showError = useCallback((message: string) => addToast(message, "error"), [addToast]);
|
||||||
|
const showSuccess = useCallback((message: string) => addToast(message, "success"), [addToast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ showError, showSuccess }}>
|
||||||
|
{children}
|
||||||
|
{toasts.length > 0 && (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={`flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm shadow-lg animate-in slide-in-from-right ${
|
||||||
|
t.type === "error"
|
||||||
|
? "bg-red-950/90 border-red-500/30 text-red-200"
|
||||||
|
: "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.type === "error" ? <AlertCircle size={14} className="shrink-0" /> : <CheckCircle2 size={14} className="shrink-0" />}
|
||||||
|
<span className="flex-1">{t.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setToasts((prev) => prev.filter((tt) => tt.id !== t.id))}
|
||||||
|
className="shrink-0 text-neutral-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/app/admin/about/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
|
import { InputField, TextareaField } from "../_components/FormField";
|
||||||
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
|
|
||||||
|
interface AboutData {
|
||||||
|
title: string;
|
||||||
|
paragraphs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AboutEditorPage() {
|
||||||
|
return (
|
||||||
|
<SectionEditor<AboutData> sectionKey="about" title="О студии">
|
||||||
|
{(data, update) => (
|
||||||
|
<>
|
||||||
|
<InputField
|
||||||
|
label="Заголовок секции"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(v) => update({ ...data, title: v })}
|
||||||
|
/>
|
||||||
|
<ArrayEditor
|
||||||
|
label="Параграфы"
|
||||||
|
items={data.paragraphs}
|
||||||
|
onChange={(paragraphs) => update({ ...data, paragraphs })}
|
||||||
|
renderItem={(text, _i, updateItem) => (
|
||||||
|
<TextareaField
|
||||||
|
label={`Параграф`}
|
||||||
|
value={text}
|
||||||
|
onChange={updateItem}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
createItem={() => ""}
|
||||||
|
addLabel="Добавить параграф"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
237
src/app/admin/bookings/AddBookingModal.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
|
||||||
|
type Tab = "classes" | "events";
|
||||||
|
type EventType = "master-class" | "open-day";
|
||||||
|
|
||||||
|
interface McOption { title: string; date: string }
|
||||||
|
interface OdClass { id: number; style: string; time: string; hall: string }
|
||||||
|
interface OdEvent { id: number; date: string; title?: string }
|
||||||
|
|
||||||
|
export function AddBookingModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onAdded,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAdded: () => void;
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<Tab>("classes");
|
||||||
|
const [eventType, setEventType] = useState<EventType>("master-class");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [phone, setPhone] = useState("+375 ");
|
||||||
|
const [instagram, setInstagram] = useState("");
|
||||||
|
const [telegram, setTelegram] = useState("");
|
||||||
|
const [mcTitle, setMcTitle] = useState("");
|
||||||
|
const [mcOptions, setMcOptions] = useState<McOption[]>([]);
|
||||||
|
const [odClasses, setOdClasses] = useState<OdClass[]>([]);
|
||||||
|
const [odEventId, setOdEventId] = useState<number | null>(null);
|
||||||
|
const [odClassId, setOdClassId] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId("");
|
||||||
|
|
||||||
|
// Fetch upcoming MCs (filter out expired)
|
||||||
|
adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()).then((data: { items?: { title: string; slots: { date: string }[] }[] }) => {
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const upcoming = (data.items || [])
|
||||||
|
.filter((mc) => {
|
||||||
|
const earliest = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
|
||||||
|
return earliest && earliest >= today;
|
||||||
|
})
|
||||||
|
.map((mc) => ({
|
||||||
|
title: mc.title,
|
||||||
|
date: mc.slots.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? ""),
|
||||||
|
}));
|
||||||
|
setMcOptions(upcoming);
|
||||||
|
if (upcoming.length === 0 && tab === "events") setEventType("open-day");
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Fetch active Open Day event + classes
|
||||||
|
adminFetch("/api/admin/open-day").then((r) => r.json()).then(async (events: OdEvent[]) => {
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const active = events.find((e) => e.date >= today);
|
||||||
|
if (!active) { setOdEventId(null); setOdClasses([]); return; }
|
||||||
|
setOdEventId(active.id);
|
||||||
|
const classes = await adminFetch(`/api/admin/open-day/classes?eventId=${active.id}`).then((r) => r.json());
|
||||||
|
setOdClasses(classes);
|
||||||
|
}).catch(() => {});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUpcomingMc = mcOptions.length > 0;
|
||||||
|
const hasOpenDay = odEventId !== null && odClasses.length > 0;
|
||||||
|
const hasEvents = hasUpcomingMc || hasOpenDay;
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!name.trim() || !phone.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (tab === "classes") {
|
||||||
|
await adminFetch("/api/admin/group-bookings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name.trim(),
|
||||||
|
phone: phone.trim(),
|
||||||
|
...(instagram.trim() && { instagram: instagram.trim() }),
|
||||||
|
...(telegram.trim() && { telegram: telegram.trim() }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else if (eventType === "master-class") {
|
||||||
|
const title = mcTitle || mcOptions[0]?.title || "Мастер-класс";
|
||||||
|
await adminFetch("/api/admin/mc-registrations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ masterClassTitle: title, name: name.trim(), instagram: "-", phone: phone.trim() }),
|
||||||
|
});
|
||||||
|
} else if (eventType === "open-day" && odClassId && odEventId) {
|
||||||
|
await adminFetch("/api/open-day-register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ classId: Number(odClassId), eventId: odEventId, name: name.trim(), phone: phone.trim() }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onAdded();
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const inputClass = "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 placeholder-neutral-500";
|
||||||
|
const tabBtn = (key: Tab, label: string, disabled?: boolean) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => !disabled && setTab(key)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex-1 rounded-lg py-2 text-xs font-medium transition-all ${
|
||||||
|
tab === key ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const canSubmit = name.trim() && phone.trim() && !saving
|
||||||
|
&& (tab === "classes" || (tab === "events" && eventType === "master-class" && hasUpcomingMc)
|
||||||
|
|| (tab === "events" && eventType === "open-day" && odClassId));
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" 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} 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">Ручная запись (Instagram, звонок, лично)</p>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{/* Tab: Classes vs Events */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{tabBtn("classes", "Занятие")}
|
||||||
|
{tabBtn("events", "Мероприятие", !hasEvents)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events sub-selector */}
|
||||||
|
{tab === "events" && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{hasUpcomingMc && (
|
||||||
|
<button
|
||||||
|
onClick={() => setEventType("master-class")}
|
||||||
|
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${
|
||||||
|
eventType === "master-class" ? "bg-purple-500/15 text-purple-400 border border-purple-500/30" : "bg-neutral-800/50 text-neutral-500 border border-white/5 hover:text-neutral-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Мастер-класс
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{hasOpenDay && (
|
||||||
|
<button
|
||||||
|
onClick={() => setEventType("open-day")}
|
||||||
|
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${
|
||||||
|
eventType === "open-day" ? "bg-blue-500/15 text-blue-400 border border-blue-500/30" : "bg-neutral-800/50 text-neutral-500 border border-white/5 hover:text-neutral-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Open Day
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MC selector */}
|
||||||
|
{tab === "events" && eventType === "master-class" && mcOptions.length > 0 && (
|
||||||
|
<select value={mcTitle} onChange={(e) => setMcTitle(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
|
||||||
|
<option value="" className="bg-neutral-900">Выберите мастер-класс</option>
|
||||||
|
{mcOptions.map((mc) => (
|
||||||
|
<option key={mc.title} value={mc.title} className="bg-neutral-900">
|
||||||
|
{mc.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Open Day class selector */}
|
||||||
|
{tab === "events" && eventType === "open-day" && odClasses.length > 0 && (
|
||||||
|
<select value={odClassId} onChange={(e) => setOdClassId(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
|
||||||
|
<option value="" className="bg-neutral-900">Выберите занятие</option>
|
||||||
|
{odClasses.map((c) => (
|
||||||
|
<option key={c.id} value={c.id} className="bg-neutral-900">
|
||||||
|
{c.time} · {c.style} · {c.hall}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Имя" className={inputClass} />
|
||||||
|
<input type="tel" value={phone} onChange={(e) => handlePhoneChange(e.target.value)} placeholder="+375 (__) ___-__-__" className={inputClass} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input type="text" value={instagram} onChange={(e) => setInstagram(e.target.value)} placeholder="Instagram" className={inputClass} />
|
||||||
|
<input type="text" value={telegram} onChange={(e) => setTelegram(e.target.value)} placeholder="Telegram" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="mt-5 w-full rounded-lg bg-gold py-2.5 text-sm font-semibold text-black transition-all hover:bg-gold-light disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? "Сохраняю..." : "Добавить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
175
src/app/admin/bookings/BookingComponents.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Loader2, Trash2, Phone, Instagram, Send, X } from "lucide-react";
|
||||||
|
import { type BookingStatus, type BookingFilter, BOOKING_STATUSES } from "./types";
|
||||||
|
|
||||||
|
export function LoadingSpinner() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-8 text-neutral-500 justify-center">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ total }: { total: number }) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-neutral-500 py-8 text-center">
|
||||||
|
{total === 0 ? "Пока нет записей" : "Нет записей по фильтру"}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- #1: Delete with confirmation ---
|
||||||
|
|
||||||
|
export function DeleteBtn({ onClick, name }: { onClick: () => void; name?: string }) {
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!confirming) return;
|
||||||
|
function onKey(e: KeyboardEvent) { if (e.key === "Escape") setConfirming(false); }
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [confirming]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
{confirming && createPortal(
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirming(false)}>
|
||||||
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
|
<div className="relative w-full max-w-xs rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={() => setConfirming(false)} 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-sm font-bold text-white">Удалить запись?</h3>
|
||||||
|
{name && <p className="mt-1 text-xs text-neutral-400">{name}</p>}
|
||||||
|
<p className="mt-2 text-xs text-neutral-500">Это действие нельзя отменить.</p>
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 py-2 text-xs font-medium text-neutral-300 hover:bg-neutral-700 transition-colors"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setConfirming(false); onClick(); }}
|
||||||
|
className="flex-1 rounded-lg bg-red-600 py-2 text-xs font-medium text-white hover:bg-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactLinks({ phone, instagram, telegram }: { phone?: string; instagram?: string; telegram?: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{phone && (
|
||||||
|
<a href={`tel:${phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||||
|
<Phone size={10} />{phone}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{instagram && (
|
||||||
|
<a href={`https://ig.me/m/${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} />{instagram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{telegram && (
|
||||||
|
<a href={`https://t.me/${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} />{telegram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterTabs({ filter, counts, total, onFilter }: {
|
||||||
|
filter: BookingFilter;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
total: number;
|
||||||
|
onFilter: (f: BookingFilter) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => onFilter("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">{total}</span>
|
||||||
|
</button>
|
||||||
|
{BOOKING_STATUSES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
onClick={() => onFilter(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: BookingStatus }) {
|
||||||
|
const conf = BOOKING_STATUSES.find((s) => s.key === status) || BOOKING_STATUSES[0];
|
||||||
|
return (
|
||||||
|
<span className={`text-[10px] font-medium ${conf.bg} ${conf.color} border ${conf.border} rounded-full px-2.5 py-0.5`}>
|
||||||
|
{conf.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusActions({ status, onStatus }: { status: BookingStatus; onStatus: (s: BookingStatus) => void }) {
|
||||||
|
const actionBtn = (label: string, onClick: () => void, cls: string) => (
|
||||||
|
<button onClick={onClick} className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium transition-all ${cls}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 ml-auto">
|
||||||
|
{status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20")}
|
||||||
|
{status === "contacted" && (
|
||||||
|
<>
|
||||||
|
{actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20")}
|
||||||
|
{actionBtn("Отказ", () => onStatus("declined"), "bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", () => onStatus("contacted"), "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookingCard({ status, highlight, children }: { status: BookingStatus; highlight?: boolean; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border p-3 transition-all duration-200 cursor-default ${
|
||||||
|
status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50 hover:opacity-70 hover:border-red-500/30"
|
||||||
|
: status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02] hover:border-emerald-500/30 hover:bg-emerald-500/[0.05]"
|
||||||
|
: status === "new" ? "border-gold/20 bg-gold/[0.03] hover:border-gold/40 hover:bg-gold/[0.06]"
|
||||||
|
: "border-white/10 bg-neutral-800/30 hover:border-white/20 hover:bg-neutral-800/50"
|
||||||
|
}${highlight ? " ring-2 ring-gold/40 animate-[pulse_1s_ease-in-out_1]" : ""}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
src/app/admin/bookings/GenericBookingsList.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { ChevronDown, ChevronRight, Archive } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingGroup, sortByStatus } from "./types";
|
||||||
|
import { EmptyState, BookingCard, ContactLinks, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents";
|
||||||
|
import { fmtDate } from "./types";
|
||||||
|
import { InlineNotes } from "./InlineNotes";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
|
||||||
|
interface GenericBookingsListProps<T extends BaseBooking> {
|
||||||
|
items: T[];
|
||||||
|
endpoint: string;
|
||||||
|
filter: BookingFilter;
|
||||||
|
onItemsChange: (fn: (prev: T[]) => T[]) => void;
|
||||||
|
onDataChange?: () => void;
|
||||||
|
groups?: BookingGroup<T>[];
|
||||||
|
renderExtra?: (item: T) => React.ReactNode;
|
||||||
|
onConfirm?: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GenericBookingsList<T extends BaseBooking>({
|
||||||
|
items,
|
||||||
|
endpoint,
|
||||||
|
filter,
|
||||||
|
onItemsChange,
|
||||||
|
onDataChange,
|
||||||
|
groups,
|
||||||
|
renderExtra,
|
||||||
|
onConfirm,
|
||||||
|
}: GenericBookingsListProps<T>) {
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
const [highlightId, setHighlightId] = useState<number | null>(null);
|
||||||
|
const highlightRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { showError } = useToast();
|
||||||
|
|
||||||
|
// Scroll to highlighted card and clear highlight after animation
|
||||||
|
useEffect(() => {
|
||||||
|
if (highlightId === null) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
highlightRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
}, 50);
|
||||||
|
const clear = setTimeout(() => setHighlightId(null), 2000);
|
||||||
|
return () => { clearTimeout(timer); clearTimeout(clear); };
|
||||||
|
}, [highlightId]);
|
||||||
|
|
||||||
|
async function handleStatus(id: number, status: BookingStatus) {
|
||||||
|
if (status === "confirmed" && onConfirm) {
|
||||||
|
onConfirm(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prev = items.find((b) => b.id === id);
|
||||||
|
const prevStatus = prev?.status;
|
||||||
|
// Move changed item to front so it appears first in its status group after sort
|
||||||
|
onItemsChange((list) => {
|
||||||
|
const item = list.find((b) => b.id === id);
|
||||||
|
if (!item) return list;
|
||||||
|
return [{ ...item, status }, ...list.filter((b) => b.id !== id)];
|
||||||
|
});
|
||||||
|
setHighlightId(id);
|
||||||
|
try {
|
||||||
|
const res = await adminFetch(endpoint, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "set-status", id, status }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
onDataChange?.();
|
||||||
|
} catch {
|
||||||
|
if (prevStatus) onItemsChange((list) => list.map((b) => b.id === id ? { ...b, status: prevStatus } : b));
|
||||||
|
showError("Не удалось обновить статус");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
try {
|
||||||
|
const res = await adminFetch(`${endpoint}?id=${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
onItemsChange((list) => list.filter((b) => b.id !== id));
|
||||||
|
onDataChange?.();
|
||||||
|
} catch {
|
||||||
|
showError("Не удалось удалить запись");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNotes(id: number, notes: string) {
|
||||||
|
const prev = items.find((b) => b.id === id);
|
||||||
|
const prevNotes = prev?.notes;
|
||||||
|
onItemsChange((list) => list.map((b) => b.id === id ? { ...b, notes: notes || undefined } : b));
|
||||||
|
try {
|
||||||
|
const res = await adminFetch(endpoint, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "set-notes", id, notes }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
} catch {
|
||||||
|
onItemsChange((list) => list.map((b) => b.id === id ? { ...b, notes: prevNotes } : b));
|
||||||
|
showError("Не удалось сохранить заметку");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItem(item: T, isArchived: boolean) {
|
||||||
|
const isHighlighted = highlightId === item.id;
|
||||||
|
return (
|
||||||
|
<div key={item.id} ref={isHighlighted ? highlightRef : undefined}>
|
||||||
|
<BookingCard status={item.status} highlight={isHighlighted}>
|
||||||
|
<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 truncate max-w-[200px]">{item.name}</span>
|
||||||
|
<ContactLinks phone={item.phone} instagram={item.instagram} telegram={item.telegram} />
|
||||||
|
{renderExtra?.(item)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="text-neutral-600 text-xs">{fmtDate(item.createdAt)}</span>
|
||||||
|
<DeleteBtn onClick={() => handleDelete(item.id)} name={item.name} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
{!isArchived && <StatusActions status={item.status} onStatus={(s) => handleStatus(item.id, s)} />}
|
||||||
|
</div>
|
||||||
|
<InlineNotes value={item.notes || ""} onSave={(notes) => handleNotes(item.id, notes)} />
|
||||||
|
</BookingCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups) {
|
||||||
|
const filteredGroups = groups.map((g) => ({
|
||||||
|
...g,
|
||||||
|
items: filter === "all" ? sortByStatus(g.items) : sortByStatus(g.items.filter((b) => b.status === filter)),
|
||||||
|
})).filter((g) => g.items.length > 0);
|
||||||
|
|
||||||
|
const activeGroups = filteredGroups.filter((g) => !g.isArchived);
|
||||||
|
const archivedGroups = filteredGroups.filter((g) => g.isArchived);
|
||||||
|
const archivedCount = archivedGroups.reduce((sum, g) => sum + g.items.length, 0);
|
||||||
|
const allArchived = activeGroups.length === 0 && archivedCount > 0 && filter === "all";
|
||||||
|
|
||||||
|
function renderGroup(group: BookingGroup<T>) {
|
||||||
|
const isOpen = expanded[group.key] ?? !group.isArchived;
|
||||||
|
const groupCounts = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
|
||||||
|
for (const item of group.items) groupCounts[item.status] = (groupCounts[item.status] || 0) + 1;
|
||||||
|
return (
|
||||||
|
<div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-white/5 opacity-60" : "border-white/10"}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded((p) => ({ ...p, [group.key]: !isOpen }))}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 transition-colors text-left ${group.isArchived ? "bg-neutral-900/50 hover:bg-neutral-800/50" : "bg-neutral-900 hover:bg-neutral-800/80"}`}
|
||||||
|
>
|
||||||
|
{isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
|
||||||
|
{group.sublabel && (
|
||||||
|
<span className={`text-xs font-medium shrink-0 ${group.isArchived ? "text-neutral-500" : "text-gold"}`}>{group.sublabel}</span>
|
||||||
|
)}
|
||||||
|
<span className={`font-medium text-sm truncate ${group.isArchived ? "text-neutral-400" : "text-white"}`}>{group.label}</span>
|
||||||
|
{group.dateBadge && (
|
||||||
|
<span className={`text-[10px] rounded-full px-2 py-0.5 shrink-0 ${
|
||||||
|
group.isArchived ? "text-neutral-600 bg-neutral-800 line-through" : "text-gold bg-gold/10"
|
||||||
|
}`}>
|
||||||
|
{group.dateBadge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{group.isArchived && (
|
||||||
|
<span className="text-[10px] text-neutral-600 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">архив</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">{group.items.length} чел.</span>
|
||||||
|
{!group.isArchived && (
|
||||||
|
<div className="flex gap-2 ml-auto text-[10px]">
|
||||||
|
{groupCounts.new > 0 && <span className="text-gold">{groupCounts.new} новых</span>}
|
||||||
|
{groupCounts.contacted > 0 && <span className="text-blue-400">{groupCounts.contacted} связ.</span>}
|
||||||
|
{groupCounts.confirmed > 0 && <span className="text-emerald-400">{groupCounts.confirmed} подтв.</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-4 pb-3 pt-1 space-y-2">
|
||||||
|
{group.items.map((item) => renderItem(item, group.isArchived))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{allArchived && (
|
||||||
|
<p className="text-sm text-neutral-500 py-4">Все записи в архиве</p>
|
||||||
|
)}
|
||||||
|
{!allArchived && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{activeGroups.length === 0 && archivedGroups.length === 0 && <EmptyState total={items.length} />}
|
||||||
|
{activeGroups.map(renderGroup)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{archivedCount > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowArchived((v) => !v)}
|
||||||
|
className="flex items-center gap-2 text-xs text-neutral-500 hover:text-neutral-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Archive size={13} />
|
||||||
|
{(showArchived || allArchived) ? "Скрыть архив" : `Архив (${archivedCount} записей)`}
|
||||||
|
{(showArchived || allArchived) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
|
</button>
|
||||||
|
{(showArchived || allArchived) && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{archivedGroups.map(renderGroup)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const list = filter === "all" ? items : items.filter((b) => b.status === filter);
|
||||||
|
return sortByStatus(list);
|
||||||
|
}, [items, filter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filtered.length === 0 && <EmptyState total={items.length} />}
|
||||||
|
{filtered.map((item) => renderItem(item, false))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/app/admin/bookings/InlineNotes.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { StickyNote } from "lucide-react";
|
||||||
|
|
||||||
|
export function InlineNotes({ value, onSave }: { value: string; onSave: (notes: string) => void }) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [text, setText] = useState(value);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => { setText(value); }, [value]);
|
||||||
|
|
||||||
|
const save = useCallback((v: string) => {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => onSave(v), 800);
|
||||||
|
}, [onSave]);
|
||||||
|
|
||||||
|
function handleChange(v: string) {
|
||||||
|
setText(v);
|
||||||
|
save(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing && textareaRef.current) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
textareaRef.current.selectionStart = textareaRef.current.value.length;
|
||||||
|
}
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
|
if (!editing && !value) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="mt-2 inline-flex items-center gap-1.5 text-[11px] text-neutral-600 hover:text-neutral-400 transition-colors"
|
||||||
|
>
|
||||||
|
<StickyNote size={11} />
|
||||||
|
Добавить заметку...
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="mt-2 inline-flex items-start gap-1.5 text-left transition-colors group"
|
||||||
|
>
|
||||||
|
<StickyNote size={11} className="shrink-0 mt-0.5 text-neutral-500 group-hover:text-gold transition-colors" />
|
||||||
|
<span className="text-[11px] text-neutral-400 leading-relaxed whitespace-pre-wrap group-hover:text-white transition-colors">{value}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
onBlur={() => { clearTimeout(timerRef.current); if (text !== value) onSave(text.trim() ? text : ""); setEditing(false); }}
|
||||||
|
placeholder="Заметка..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-md border border-amber-500/20 bg-amber-500/[0.06] px-2.5 py-1.5 text-[11px] text-amber-200/80 placeholder-neutral-600 outline-none focus:border-amber-500/40 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/app/admin/bookings/McRegistrationsTab.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import { type BookingFilter, type BaseBooking, type BookingGroup } from "./types";
|
||||||
|
import { LoadingSpinner } from "./BookingComponents";
|
||||||
|
import { GenericBookingsList } from "./GenericBookingsList";
|
||||||
|
|
||||||
|
interface McRegistration extends BaseBooking {
|
||||||
|
masterClassTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McSlot { date: string; startTime: string }
|
||||||
|
interface McItem { title: string; slots: McSlot[]; location?: string }
|
||||||
|
|
||||||
|
export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFilter; onDataChange?: () => void }) {
|
||||||
|
const [regs, setRegs] = useState<McRegistration[]>([]);
|
||||||
|
const [mcDates, setMcDates] = useState<Record<string, string>>({});
|
||||||
|
const [mcLocations, setMcLocations] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
adminFetch("/api/admin/mc-registrations").then((r) => r.json()),
|
||||||
|
adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()),
|
||||||
|
]).then(([regData, mcData]: [McRegistration[], { items?: McItem[] }]) => {
|
||||||
|
setRegs(regData);
|
||||||
|
const dates: Record<string, string> = {};
|
||||||
|
const locations: Record<string, string> = {};
|
||||||
|
const mcItems = mcData.items || [];
|
||||||
|
for (const mc of mcItems) {
|
||||||
|
const earliestSlot = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
|
||||||
|
if (earliestSlot) dates[mc.title] = earliestSlot;
|
||||||
|
if (mc.location) locations[mc.title] = mc.location;
|
||||||
|
}
|
||||||
|
const regTitles = new Set(regData.map((r) => r.masterClassTitle));
|
||||||
|
for (const regTitle of regTitles) {
|
||||||
|
if (dates[regTitle]) continue;
|
||||||
|
for (const mc of mcItems) {
|
||||||
|
const earliestSlot = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
|
||||||
|
if (!earliestSlot) continue;
|
||||||
|
if (regTitle.toLowerCase().includes(mc.title.toLowerCase()) || mc.title.toLowerCase().includes(regTitle.toLowerCase())) {
|
||||||
|
dates[regTitle] = earliestSlot;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMcDates(dates);
|
||||||
|
setMcLocations(locations);
|
||||||
|
}).catch(() => {}).finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
|
const groups = useMemo((): BookingGroup<McRegistration>[] => {
|
||||||
|
const map: Record<string, McRegistration[]> = {};
|
||||||
|
for (const r of regs) {
|
||||||
|
if (!map[r.masterClassTitle]) map[r.masterClassTitle] = [];
|
||||||
|
map[r.masterClassTitle].push(r);
|
||||||
|
}
|
||||||
|
return Object.entries(map).map(([title, items]) => {
|
||||||
|
const date = mcDates[title];
|
||||||
|
const isArchived = !date || date < today;
|
||||||
|
return {
|
||||||
|
key: title,
|
||||||
|
label: mcLocations[title] ? `${title} · ${mcLocations[title]}` : title,
|
||||||
|
dateBadge: date ? new Date(date + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : undefined,
|
||||||
|
items,
|
||||||
|
isArchived,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [regs, mcDates, mcLocations, today]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GenericBookingsList<McRegistration>
|
||||||
|
items={regs}
|
||||||
|
endpoint="/api/admin/mc-registrations"
|
||||||
|
filter={filter}
|
||||||
|
onItemsChange={setRegs}
|
||||||
|
onDataChange={onDataChange}
|
||||||
|
groups={groups}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/app/admin/bookings/OpenDayBookingsTab.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import { type BookingFilter, type BaseBooking, type BookingGroup } from "./types";
|
||||||
|
import { LoadingSpinner } from "./BookingComponents";
|
||||||
|
import { GenericBookingsList } from "./GenericBookingsList";
|
||||||
|
|
||||||
|
interface OpenDayBooking extends BaseBooking {
|
||||||
|
classId: number;
|
||||||
|
eventId: number;
|
||||||
|
classStyle?: string;
|
||||||
|
classTrainer?: string;
|
||||||
|
classTime?: string;
|
||||||
|
classHall?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventInfo { id: number; date: string; title?: string }
|
||||||
|
|
||||||
|
export function OpenDayBookingsTab({ filter, hallFilter = "all", onDataChange }: { filter: BookingFilter; hallFilter?: string; onDataChange?: () => void }) {
|
||||||
|
const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
|
||||||
|
const [events, setEvents] = useState<EventInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch("/api/admin/open-day")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(async (evts: EventInfo[]) => {
|
||||||
|
setEvents(evts);
|
||||||
|
if (evts.length === 0) return;
|
||||||
|
const allBookings: OpenDayBooking[] = [];
|
||||||
|
for (const ev of evts) {
|
||||||
|
const data = await adminFetch(`/api/admin/open-day/bookings?eventId=${ev.id}`).then((r) => r.json());
|
||||||
|
allBookings.push(...data);
|
||||||
|
}
|
||||||
|
setBookings(allBookings);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
|
const eventDateMap = useMemo(() => {
|
||||||
|
const map: Record<number, string> = {};
|
||||||
|
for (const ev of events) map[ev.id] = ev.date;
|
||||||
|
return map;
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
const filteredBookings = useMemo(() =>
|
||||||
|
hallFilter === "all" ? bookings : bookings.filter((b) => b.classHall === hallFilter),
|
||||||
|
[bookings, hallFilter]);
|
||||||
|
|
||||||
|
const groups = useMemo((): BookingGroup<OpenDayBooking>[] => {
|
||||||
|
const map: Record<string, { hall: string; time: string; style: string; trainer: string; items: OpenDayBooking[]; eventId: number }> = {};
|
||||||
|
for (const b of filteredBookings) {
|
||||||
|
const key = `${b.eventId}|${b.classHall}|${b.classTime}|${b.classStyle}`;
|
||||||
|
if (!map[key]) map[key] = { hall: b.classHall || "—", time: b.classTime || "—", style: b.classStyle || "—", trainer: b.classTrainer || "—", items: [], eventId: b.eventId };
|
||||||
|
map[key].items.push(b);
|
||||||
|
}
|
||||||
|
return Object.entries(map)
|
||||||
|
.sort(([, a], [, b]) => {
|
||||||
|
const hallCmp = a.hall.localeCompare(b.hall);
|
||||||
|
return hallCmp !== 0 ? hallCmp : a.time.localeCompare(b.time);
|
||||||
|
})
|
||||||
|
.map(([key, g]) => {
|
||||||
|
const eventDate = eventDateMap[g.eventId];
|
||||||
|
const isArchived = eventDate ? eventDate < today : false;
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label: `${g.style} · ${g.hall}`,
|
||||||
|
sublabel: g.time,
|
||||||
|
dateBadge: isArchived && eventDate ? new Date(eventDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : undefined,
|
||||||
|
items: g.items,
|
||||||
|
isArchived,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [filteredBookings, eventDateMap, today]);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GenericBookingsList<OpenDayBooking>
|
||||||
|
items={filteredBookings}
|
||||||
|
endpoint="/api/admin/open-day/bookings"
|
||||||
|
filter={filter}
|
||||||
|
onItemsChange={setBookings}
|
||||||
|
onDataChange={onDataChange}
|
||||||
|
groups={groups}
|
||||||
|
renderExtra={(b) => (
|
||||||
|
<>
|
||||||
|
{b.classHall && <span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{b.classHall}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/app/admin/bookings/SearchBar.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { Search, X } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import type { SearchResult } from "./types";
|
||||||
|
|
||||||
|
export function SearchBar({
|
||||||
|
onResults,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
onResults: (results: SearchResult[]) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
|
function handleChange(q: string) {
|
||||||
|
setQuery(q);
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
if (q.trim().length < 2) {
|
||||||
|
onClear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
adminFetch(`/api/admin/bookings/search?q=${encodeURIComponent(q.trim())}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: SearchResult[]) => onResults(data))
|
||||||
|
.catch(() => {});
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
setQuery("");
|
||||||
|
onClear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
placeholder="Поиск по имени или телефону..."
|
||||||
|
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] py-2 pl-9 pr-8 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button onClick={clear} className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/app/admin/bookings/Toast.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Re-export from shared location
|
||||||
|
export { ToastProvider, useToast } from "../_components/Toast";
|
||||||
989
src/app/admin/bookings/page.tsx
Normal file
@@ -0,0 +1,989 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Phone, Instagram, Send, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X, Plus } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import { MS_PER_DAY } from "@/lib/constants";
|
||||||
|
import { type BookingStatus, type BookingFilter, type SearchResult, SHORT_DAYS, fmtDate } from "./types";
|
||||||
|
import { LoadingSpinner, ContactLinks, BookingCard, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents";
|
||||||
|
import { GenericBookingsList } from "./GenericBookingsList";
|
||||||
|
import { AddBookingModal } from "./AddBookingModal";
|
||||||
|
import { SearchBar } from "./SearchBar";
|
||||||
|
import { McRegistrationsTab } from "./McRegistrationsTab";
|
||||||
|
import { OpenDayBookingsTab } from "./OpenDayBookingsTab";
|
||||||
|
import { ToastProvider, useToast } from "./Toast";
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
confirmedHall?: string;
|
||||||
|
confirmedComment?: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = "reminders" | "classes" | "master-classes" | "open-day";
|
||||||
|
|
||||||
|
// --- Confirm Booking Modal ---
|
||||||
|
|
||||||
|
function ConfirmModal({
|
||||||
|
open,
|
||||||
|
bookingName,
|
||||||
|
groupInfo,
|
||||||
|
existingDate,
|
||||||
|
existingGroup,
|
||||||
|
allClasses,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
bookingName: string;
|
||||||
|
groupInfo?: string;
|
||||||
|
existingDate?: string;
|
||||||
|
existingGroup?: string;
|
||||||
|
allClasses: ScheduleClassInfo[];
|
||||||
|
onConfirm: (data: { group: string; hall?: 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;
|
||||||
|
const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0];
|
||||||
|
setDate(existingDate && existingDate.length === 10 ? existingDate : tomorrow); setComment("");
|
||||||
|
// Try to match groupInfo or existingGroup against schedule to pre-fill
|
||||||
|
const matchText = existingGroup || groupInfo;
|
||||||
|
if (matchText && allClasses.length > 0) {
|
||||||
|
const info = matchText.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, existingDate, existingGroup, allClasses]);
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
|
||||||
|
// #11: Keyboard submit
|
||||||
|
const today = open ? new Date().toISOString().split("T")[0] : "";
|
||||||
|
const canSubmit = group && date && date.length === 10 && date >= today;
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (canSubmit) {
|
||||||
|
const groupLabel = groups.find((g) => g.value === group)?.label || group;
|
||||||
|
onConfirm({ group: groupLabel, hall: hall || undefined, date, comment: comment.trim() || undefined });
|
||||||
|
}
|
||||||
|
}, [canSubmit, group, hall, date, comment, groups, onConfirm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
if (e.key === "Enter" && canSubmit) { e.preventDefault(); handleSubmit(); }
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose, canSubmit, handleSubmit]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
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}
|
||||||
|
max={new Date(Date.now() + MS_PER_DAY * 365).toISOString().split("T")[0]}
|
||||||
|
disabled={!group}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className={`${selectClass} ${date && (date < today || date.length !== 10) ? "!border-red-500/50" : ""}`}
|
||||||
|
/>
|
||||||
|
{date && (date < today || date.length !== 10) && (
|
||||||
|
<p className="text-[10px] text-red-400 mt-1">{date < today ? "Дата не может быть в прошлом" : "Неверный формат даты"}</p>
|
||||||
|
)}
|
||||||
|
</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={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
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({ filter, onDataChange }: { filter: BookingFilter; onDataChange?: () => void }) {
|
||||||
|
const [bookings, setBookings] = useState<GroupBooking[]>([]);
|
||||||
|
const [allClasses, setAllClasses] = useState<ScheduleClassInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
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(() => setError(true))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmingBooking = bookings.find((b) => b.id === confirmingId);
|
||||||
|
|
||||||
|
async function handleConfirm(data: { group: string; hall?: string; date: string; comment?: string }) {
|
||||||
|
if (!confirmingId) return;
|
||||||
|
const existing = bookings.find((b) => b.id === confirmingId);
|
||||||
|
const notes = data.comment
|
||||||
|
? (existing?.notes ? `${existing.notes}\n${data.comment}` : data.comment)
|
||||||
|
: existing?.notes;
|
||||||
|
setBookings((prev) => prev.map((b) => b.id === confirmingId ? {
|
||||||
|
...b, status: "confirmed" as BookingStatus,
|
||||||
|
confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes,
|
||||||
|
} : b));
|
||||||
|
await Promise.all([
|
||||||
|
adminFetch("/api/admin/group-bookings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }),
|
||||||
|
}),
|
||||||
|
data.comment ? adminFetch("/api/admin/group-bookings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
|
||||||
|
}) : Promise.resolve(),
|
||||||
|
]);
|
||||||
|
setConfirmingId(null);
|
||||||
|
onDataChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
if (error) return <p className="text-sm text-red-400 py-8 text-center">Не удалось загрузить данные</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GenericBookingsList<GroupBooking>
|
||||||
|
items={bookings}
|
||||||
|
endpoint="/api/admin/group-bookings"
|
||||||
|
filter={filter}
|
||||||
|
onItemsChange={setBookings}
|
||||||
|
onDataChange={onDataChange}
|
||||||
|
onConfirm={(id) => setConfirmingId(id)}
|
||||||
|
renderExtra={(b) => (
|
||||||
|
<>
|
||||||
|
{b.groupInfo && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>}
|
||||||
|
{b.confirmedHall && <span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{b.confirmedHall}</span>}
|
||||||
|
{(b.confirmedGroup || b.confirmedDate) && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setConfirmingId(b.id); }}
|
||||||
|
className="text-[10px] text-emerald-400/70 hover:text-emerald-300 transition-colors cursor-pointer"
|
||||||
|
title="Изменить"
|
||||||
|
>
|
||||||
|
{b.confirmedGroup}
|
||||||
|
{b.confirmedDate && b.confirmedDate.length === 10 && ` · ${new Date(b.confirmedDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`}
|
||||||
|
{" ✎"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmingId !== null}
|
||||||
|
bookingName={confirmingBooking?.name ?? ""}
|
||||||
|
groupInfo={confirmingBooking?.groupInfo}
|
||||||
|
existingDate={confirmingBooking?.confirmedDate}
|
||||||
|
existingGroup={confirmingBooking?.confirmedGroup}
|
||||||
|
allClasses={allClasses}
|
||||||
|
onClose={() => setConfirmingId(null)}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
eventHall?: 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);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [savingIds, setSavingIds] = useState<Set<string>>(new Set());
|
||||||
|
const { showError } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch("/api/admin/reminders")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: ReminderItem[]) => setItems(data))
|
||||||
|
.catch(() => setError(true))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function setStatus(item: ReminderItem, status: ReminderStatus | null) {
|
||||||
|
const key = `${item.table}-${item.id}`;
|
||||||
|
const prevStatus = item.reminderStatus;
|
||||||
|
setSavingIds((prev) => new Set(prev).add(key));
|
||||||
|
setItems((prev) => prev.map((i) => i.id === item.id && i.table === item.table ? { ...i, reminderStatus: status ?? undefined } : i));
|
||||||
|
try {
|
||||||
|
const res = await adminFetch("/api/admin/reminders", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ table: item.table, id: item.id, status }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
} catch {
|
||||||
|
setItems((prev) => prev.map((i) => i.id === item.id && i.table === item.table ? { ...i, reminderStatus: prevStatus } : i));
|
||||||
|
showError("Не удалось обновить статус");
|
||||||
|
} finally {
|
||||||
|
setSavingIds((prev) => { const next = new Set(prev); next.delete(key); return next; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
if (error) return <p className="text-sm text-red-400 py-8 text-center">Не удалось загрузить напоминания</p>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
const isSaving = savingIds.has(`${item.table}-${item.id}`);
|
||||||
|
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 ${isSaving ? "opacity-50 pointer-events-none" : ""}`}>
|
||||||
|
{(["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)}
|
||||||
|
disabled={isSaving}
|
||||||
|
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}{eg.items[0]?.eventHall ? ` · ${eg.items[0].eventHall}` : ""}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dashboard Summary ---
|
||||||
|
|
||||||
|
interface TabCounts { new: number; contacted: number; confirmed: number; declined: number }
|
||||||
|
|
||||||
|
interface DashboardCounts {
|
||||||
|
classes: TabCounts;
|
||||||
|
mc: TabCounts;
|
||||||
|
od: TabCounts;
|
||||||
|
remindersToday: number;
|
||||||
|
remindersTomorrow: number;
|
||||||
|
remindersNotAsked: number;
|
||||||
|
remindersComing: number;
|
||||||
|
remindersCancelled: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countByStatus(items: { status: string }[]): TabCounts {
|
||||||
|
const c = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
|
||||||
|
for (const i of items) if (i.status in c) c[i.status as keyof TabCounts]++;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
|
||||||
|
refreshTrigger: number;
|
||||||
|
onNavigate: (tab: Tab) => void;
|
||||||
|
onFilter: (f: BookingFilter) => void;
|
||||||
|
}) {
|
||||||
|
const [counts, setCounts] = useState<DashboardCounts | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0];
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
adminFetch("/api/admin/group-bookings").then((r) => r.json()),
|
||||||
|
Promise.all([
|
||||||
|
adminFetch("/api/admin/mc-registrations").then((r) => r.json()),
|
||||||
|
adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()),
|
||||||
|
]).then(([regs, mcData]: [{ status: string; masterClassTitle: string }[], { items?: { title: string; slots: { date: string }[] }[] }]) => {
|
||||||
|
const upcomingTitles = new Set<string>();
|
||||||
|
for (const mc of mcData.items || []) {
|
||||||
|
const earliest = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
|
||||||
|
if (earliest && earliest >= today) upcomingTitles.add(mc.title);
|
||||||
|
}
|
||||||
|
return regs.filter((r) => upcomingTitles.has(r.masterClassTitle));
|
||||||
|
}),
|
||||||
|
adminFetch("/api/admin/open-day").then((r) => r.json()).then(async (events: { id: number; date: string }[]) => {
|
||||||
|
const active = events.find((e) => e.date >= today);
|
||||||
|
if (!active) return [];
|
||||||
|
return adminFetch(`/api/admin/open-day/bookings?eventId=${active.id}`).then((r) => r.json());
|
||||||
|
}),
|
||||||
|
adminFetch("/api/admin/reminders").then((r) => r.json()).catch(() => []),
|
||||||
|
]).then(([gb, mc, od, rem]: [{ status: string }[], { status: string }[], { status: string }[], { eventDate: string; reminderStatus?: string }[]]) => {
|
||||||
|
const upcoming = rem.filter((r) => r.eventDate === today || r.eventDate === tomorrow);
|
||||||
|
setCounts({
|
||||||
|
classes: countByStatus(gb),
|
||||||
|
mc: countByStatus(mc),
|
||||||
|
od: countByStatus(od),
|
||||||
|
remindersToday: rem.filter((r) => r.eventDate === today).length,
|
||||||
|
remindersTomorrow: rem.filter((r) => r.eventDate === tomorrow).length,
|
||||||
|
remindersNotAsked: upcoming.filter((r) => !r.reminderStatus).length,
|
||||||
|
remindersComing: upcoming.filter((r) => r.reminderStatus === "coming").length,
|
||||||
|
remindersCancelled: upcoming.filter((r) => r.reminderStatus === "cancelled").length,
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [refreshTrigger]);
|
||||||
|
|
||||||
|
if (!counts) return null;
|
||||||
|
|
||||||
|
const cards: { tab: Tab; label: string; counts: TabCounts | null; color: string; urgentColor: string }[] = [
|
||||||
|
{ tab: "reminders", label: "Напоминания", counts: null, color: "border-amber-500/20", urgentColor: "text-amber-400" },
|
||||||
|
{ tab: "classes", label: "Занятия", counts: counts.classes, color: "border-gold/20", urgentColor: "text-gold" },
|
||||||
|
{ tab: "master-classes", label: "Мастер-классы", counts: counts.mc, color: "border-purple-500/20", urgentColor: "text-purple-400" },
|
||||||
|
{ tab: "open-day", label: "Open Day", counts: counts.od, color: "border-cyan-500/20", urgentColor: "text-cyan-400" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasWork = cards.some((c) => {
|
||||||
|
if (c.counts) return c.counts.new + c.counts.contacted + c.counts.confirmed + c.counts.declined > 0;
|
||||||
|
return counts.remindersToday + counts.remindersTomorrow > 0;
|
||||||
|
});
|
||||||
|
if (!hasWork) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mt-4">
|
||||||
|
{cards.map((c) => {
|
||||||
|
// Reminders card
|
||||||
|
if (c.tab === "reminders") {
|
||||||
|
const total = counts.remindersToday + counts.remindersTomorrow;
|
||||||
|
if (total === 0) return (
|
||||||
|
<div key={c.tab} className="rounded-xl border border-white/5 bg-neutral-900/50 p-3 opacity-40">
|
||||||
|
<p className="text-xs text-neutral-500">{c.label}</p>
|
||||||
|
<p className="text-lg font-bold text-neutral-600 mt-1">—</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<button key={c.tab} onClick={() => onNavigate(c.tab)}
|
||||||
|
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}>
|
||||||
|
<p className="text-xs text-neutral-400">{c.label}</p>
|
||||||
|
<div className="flex items-baseline gap-2 mt-1 flex-wrap">
|
||||||
|
{counts.remindersNotAsked > 0 && (
|
||||||
|
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); }}>
|
||||||
|
<span className="text-lg font-bold text-gold">{counts.remindersNotAsked}</span>
|
||||||
|
<span className="text-[10px] text-neutral-500">не спрош.</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{counts.remindersComing > 0 && (
|
||||||
|
<>
|
||||||
|
{counts.remindersNotAsked > 0 && <span className="text-neutral-700">·</span>}
|
||||||
|
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); }}>
|
||||||
|
<span className="text-sm font-medium text-emerald-400">{counts.remindersComing}</span>
|
||||||
|
<span className="text-[10px] text-neutral-500">придёт</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{counts.remindersCancelled > 0 && (
|
||||||
|
<>
|
||||||
|
{(counts.remindersNotAsked > 0 || counts.remindersComing > 0) && <span className="text-neutral-700">·</span>}
|
||||||
|
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); }}>
|
||||||
|
<span className="text-sm font-medium text-red-400">{counts.remindersCancelled}</span>
|
||||||
|
<span className="text-[10px] text-neutral-500">не придёт</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Booking cards — big numbers for new/contacted, small chips for confirmed/declined
|
||||||
|
const tc = c.counts!;
|
||||||
|
const total = tc.new + tc.contacted + tc.confirmed + tc.declined;
|
||||||
|
if (total === 0) return (
|
||||||
|
<div key={c.tab} className="rounded-xl border border-white/5 bg-neutral-900/50 p-3 opacity-40">
|
||||||
|
<p className="text-xs text-neutral-500">{c.label}</p>
|
||||||
|
<p className="text-lg font-bold text-neutral-600 mt-1">—</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<button key={c.tab} onClick={() => { onNavigate(c.tab); onFilter("all"); }}
|
||||||
|
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}>
|
||||||
|
<p className="text-xs text-neutral-400">{c.label}</p>
|
||||||
|
<div className="flex items-baseline gap-2 mt-1 flex-wrap">
|
||||||
|
{tc.new > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("new"); }}>
|
||||||
|
<span className="text-lg font-bold text-gold">{tc.new}</span>
|
||||||
|
<span className="text-[10px] text-neutral-500">новых</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tc.contacted > 0 && (
|
||||||
|
<>
|
||||||
|
{tc.new > 0 && <span className="text-neutral-700">·</span>}
|
||||||
|
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("contacted"); }}>
|
||||||
|
<span className="text-sm font-medium text-blue-400">{tc.contacted}</span>
|
||||||
|
<span className="text-[10px] text-neutral-500">в работе</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tc.confirmed > 0 && (
|
||||||
|
<>
|
||||||
|
{(tc.new > 0 || tc.contacted > 0) && <span className="text-neutral-700">·</span>}
|
||||||
|
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("confirmed"); }}>
|
||||||
|
<span className="text-sm font-medium text-emerald-400">{tc.confirmed}</span>
|
||||||
|
<span className="text-[10px] text-neutral-500">подтв.</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tc.declined > 0 && (
|
||||||
|
<>
|
||||||
|
{(tc.new > 0 || tc.contacted > 0 || tc.confirmed > 0) && <span className="text-neutral-700">·</span>}
|
||||||
|
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("declined"); }}>
|
||||||
|
<span className="text-sm font-medium text-red-400">{tc.declined}</span>
|
||||||
|
<span className="text-[10px] text-neutral-500">отказ</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Page ---
|
||||||
|
|
||||||
|
const TABS: { key: Tab; label: string }[] = [
|
||||||
|
{ key: "reminders", label: "Напоминания" },
|
||||||
|
{ key: "classes", label: "Занятия" },
|
||||||
|
{ key: "master-classes", label: "Мастер-классы" },
|
||||||
|
{ key: "open-day", label: "День открытых дверей" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ENDPOINT_MAP: Record<string, string> = {
|
||||||
|
class: "/api/admin/group-bookings",
|
||||||
|
mc: "/api/admin/mc-registrations",
|
||||||
|
"open-day": "/api/admin/open-day/bookings",
|
||||||
|
};
|
||||||
|
|
||||||
|
function BookingsPageInner() {
|
||||||
|
const [tab, setTab] = useState<Tab>("reminders");
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<BookingFilter>("all");
|
||||||
|
const [hallFilter, setHallFilter] = useState("all");
|
||||||
|
const [halls, setHalls] = useState<string[]>([]);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [dashboardKey, setDashboardKey] = useState(0);
|
||||||
|
const refreshDashboard = useCallback(() => setDashboardKey((k) => k + 1), []);
|
||||||
|
const lastTotalRef = useRef<number | null>(null);
|
||||||
|
const { showError } = useToast();
|
||||||
|
|
||||||
|
// Fetch available halls from schedule
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch("/api/admin/sections/schedule")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { locations?: { name: string }[] }) => {
|
||||||
|
const names = data.locations?.map((l) => l.name).filter(Boolean) ?? [];
|
||||||
|
setHalls([...new Set(names)]);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Poll for new bookings, auto-refresh silently
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
adminFetch("/api/admin/unread-counts")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { total: number }) => {
|
||||||
|
if (lastTotalRef.current !== null && data.total !== lastTotalRef.current) {
|
||||||
|
refreshDashboard();
|
||||||
|
}
|
||||||
|
lastTotalRef.current = data.total;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, 10000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// #5: Search result status change
|
||||||
|
async function handleSearchStatus(result: SearchResult, status: BookingStatus) {
|
||||||
|
const endpoint = ENDPOINT_MAP[result.type];
|
||||||
|
if (!endpoint) return;
|
||||||
|
const prevStatus = result.status;
|
||||||
|
setSearchResults((prev) => prev?.map((r) => r.id === result.id && r.type === result.type ? { ...r, status } : r) ?? null);
|
||||||
|
try {
|
||||||
|
const res = await adminFetch(endpoint, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "set-status", id: result.id, status }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
} catch {
|
||||||
|
setSearchResults((prev) => prev?.map((r) => r.id === result.id && r.type === result.type ? { ...r, status: prevStatus } : r) ?? null);
|
||||||
|
showError("Не удалось обновить статус");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #5: Search result delete
|
||||||
|
async function handleSearchDelete(result: SearchResult) {
|
||||||
|
const endpoint = ENDPOINT_MAP[result.type];
|
||||||
|
if (!endpoint) return;
|
||||||
|
try {
|
||||||
|
const res = await adminFetch(`${endpoint}?id=${result.id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
setSearchResults((prev) => prev?.filter((r) => !(r.id === result.id && r.type === result.type)) ?? null);
|
||||||
|
} catch {
|
||||||
|
showError("Не удалось удалить запись");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = { class: "Занятие", mc: "Мастер-класс", "open-day": "Open Day" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold">Записи</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg bg-gold/10 text-gold border border-gold/30 hover:bg-gold/20 transition-all"
|
||||||
|
title="Добавить запись"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<SearchBar
|
||||||
|
onResults={setSearchResults}
|
||||||
|
onClear={() => setSearchResults(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hall filter */}
|
||||||
|
{halls.length > 1 && (
|
||||||
|
<div className="mt-3 flex gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setHallFilter("all")}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
|
||||||
|
hallFilter === "all" ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-white border border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Все залы
|
||||||
|
</button>
|
||||||
|
{halls.map((hall) => (
|
||||||
|
<button
|
||||||
|
key={hall}
|
||||||
|
onClick={() => setHallFilter(hallFilter === hall ? "all" : hall)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
|
||||||
|
hallFilter === hall ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-white border border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{hall}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchResults ? (
|
||||||
|
/* #5: Actionable search results — filtered by status */
|
||||||
|
(() => {
|
||||||
|
const filtered = statusFilter === "all" ? searchResults : searchResults.filter((r) => r.status === statusFilter);
|
||||||
|
return (
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="text-sm text-neutral-500 py-8 text-center">{searchResults.length === 0 ? "Ничего не найдено" : "Нет записей по фильтру"}</p>
|
||||||
|
) : (
|
||||||
|
filtered.map((r) => (
|
||||||
|
<BookingCard key={`${r.type}-${r.id}`} status={r.status as BookingStatus}>
|
||||||
|
<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="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{TYPE_LABELS[r.type] || r.type}</span>
|
||||||
|
<span className="font-medium text-white">{r.name}</span>
|
||||||
|
<ContactLinks phone={r.phone} instagram={r.instagram} telegram={r.telegram} />
|
||||||
|
{r.groupLabel && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{r.groupLabel}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="text-neutral-600 text-xs">{fmtDate(r.createdAt)}</span>
|
||||||
|
<DeleteBtn onClick={() => handleSearchDelete(r)} name={r.name} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
|
<StatusBadge status={r.status as BookingStatus} />
|
||||||
|
<StatusActions status={r.status as BookingStatus} onStatus={(s) => handleSearchStatus(r, s)} />
|
||||||
|
</div>
|
||||||
|
{r.notes && <p className="mt-1.5 text-[10px] text-neutral-500 truncate">{r.notes}</p>}
|
||||||
|
</BookingCard>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Dashboard — what needs attention */}
|
||||||
|
<DashboardSummary refreshTrigger={dashboardKey + refreshKey} onNavigate={setTab} onFilter={setStatusFilter} />
|
||||||
|
|
||||||
|
{/* 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 key={refreshKey} />}
|
||||||
|
{tab === "classes" && <GroupBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||||
|
{tab === "master-classes" && <McRegistrationsTab filter={statusFilter} onDataChange={refreshDashboard} />}
|
||||||
|
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} hallFilter={hallFilter} onDataChange={refreshDashboard} />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddBookingModal
|
||||||
|
open={addOpen}
|
||||||
|
onClose={() => setAddOpen(false)}
|
||||||
|
onAdded={() => { setRefreshKey((k) => k + 1); refreshDashboard(); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BookingsPage() {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<BookingsPageInner />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/app/admin/bookings/types.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
|
||||||
|
export type BookingFilter = "all" | BookingStatus;
|
||||||
|
|
||||||
|
export interface BaseBooking {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
instagram?: string;
|
||||||
|
telegram?: string;
|
||||||
|
status: BookingStatus;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHORT_DAYS: Record<string, string> = {
|
||||||
|
"Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ",
|
||||||
|
"Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС",
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function fmtDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countStatuses(items: { status: string }[]): Record<string, number> {
|
||||||
|
const c: Record<string, number> = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
|
||||||
|
for (const i of items) c[i.status] = (c[i.status] || 0) + 1;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortByStatus<T extends { status: string }>(items: T[]): T[] {
|
||||||
|
const order: Record<string, number> = { new: 0, contacted: 1, confirmed: 2, declined: 3 };
|
||||||
|
const UNKNOWN_STATUS_ORDER = 4;
|
||||||
|
return [...items].sort((a, b) =>
|
||||||
|
(order[a.status] ?? UNKNOWN_STATUS_ORDER) - (order[b.status] ?? UNKNOWN_STATUS_ORDER)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingGroup<T extends BaseBooking> {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
dateBadge?: string;
|
||||||
|
items: T[];
|
||||||
|
isArchived: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult extends BaseBooking {
|
||||||
|
type: "class" | "mc" | "open-day";
|
||||||
|
groupLabel?: string;
|
||||||
|
}
|
||||||
289
src/app/admin/classes/page.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useMemo } from "react";
|
||||||
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
|
import { InputField, TextareaField } from "../_components/FormField";
|
||||||
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
|
import {
|
||||||
|
icons, type LucideIcon,
|
||||||
|
Flame, Heart, HeartPulse, Star, Sparkles, Music, Zap, Crown,
|
||||||
|
Dumbbell, Wind, Moon, Sun, Ribbon, Gem, Feather, CircleDot,
|
||||||
|
Activity, Drama, PersonStanding, Footprints, PartyPopper, Flower2,
|
||||||
|
Waves, Eye, Orbit, Brush, Palette, HandMetal, Theater,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// Curated icons for dance school
|
||||||
|
const CURATED_ICONS: { key: string; Icon: LucideIcon; label: string }[] = [
|
||||||
|
{ key: "flame", Icon: Flame, label: "Flame" },
|
||||||
|
{ key: "heart", Icon: Heart, label: "Heart" },
|
||||||
|
{ key: "heart-pulse", Icon: HeartPulse, label: "HeartPulse" },
|
||||||
|
{ key: "star", Icon: Star, label: "Star" },
|
||||||
|
{ key: "sparkles", Icon: Sparkles, label: "Sparkles" },
|
||||||
|
{ key: "music", Icon: Music, label: "Music" },
|
||||||
|
{ key: "zap", Icon: Zap, label: "Zap" },
|
||||||
|
{ key: "crown", Icon: Crown, label: "Crown" },
|
||||||
|
{ key: "dumbbell", Icon: Dumbbell, label: "Dumbbell" },
|
||||||
|
{ key: "wind", Icon: Wind, label: "Wind" },
|
||||||
|
{ key: "moon", Icon: Moon, label: "Moon" },
|
||||||
|
{ key: "sun", Icon: Sun, label: "Sun" },
|
||||||
|
{ key: "ribbon", Icon: Ribbon, label: "Ribbon" },
|
||||||
|
{ key: "gem", Icon: Gem, label: "Gem" },
|
||||||
|
{ key: "feather", Icon: Feather, label: "Feather" },
|
||||||
|
{ key: "circle-dot", Icon: CircleDot, label: "CircleDot" },
|
||||||
|
{ key: "activity", Icon: Activity, label: "Activity" },
|
||||||
|
{ key: "drama", Icon: Drama, label: "Drama" },
|
||||||
|
{ key: "person-standing", Icon: PersonStanding, label: "PersonStanding" },
|
||||||
|
{ key: "footprints", Icon: Footprints, label: "Footprints" },
|
||||||
|
{ key: "party-popper", Icon: PartyPopper, label: "PartyPopper" },
|
||||||
|
{ key: "flower-2", Icon: Flower2, label: "Flower" },
|
||||||
|
{ key: "waves", Icon: Waves, label: "Waves" },
|
||||||
|
{ key: "eye", Icon: Eye, label: "Eye" },
|
||||||
|
{ key: "orbit", Icon: Orbit, label: "Orbit" },
|
||||||
|
{ key: "brush", Icon: Brush, label: "Brush" },
|
||||||
|
{ key: "palette", Icon: Palette, label: "Palette" },
|
||||||
|
{ key: "hand-metal", Icon: HandMetal, label: "HandMetal" },
|
||||||
|
{ key: "theater", Icon: Theater, label: "Theater" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// PascalCase "HeartPulse" → kebab "heart-pulse"
|
||||||
|
function toKebab(name: string) {
|
||||||
|
return name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full icon list for search fallback
|
||||||
|
const ALL_ICONS = Object.entries(icons).map(([name, Icon]) => ({
|
||||||
|
key: toKebab(name),
|
||||||
|
Icon: Icon as LucideIcon,
|
||||||
|
label: name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ICON_BY_KEY = Object.fromEntries([
|
||||||
|
...CURATED_ICONS.map((i) => [i.key, i]),
|
||||||
|
...ALL_ICONS.map((i) => [i.key, i]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
function IconPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const selected = ICON_BY_KEY[value];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handle(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handle);
|
||||||
|
return () => document.removeEventListener("mousedown", handle);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search) return CURATED_ICONS;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
// Search curated first, then all icons
|
||||||
|
const curated = CURATED_ICONS.filter((i) => i.label.toLowerCase().includes(q));
|
||||||
|
const rest = ALL_ICONS.filter((i) => i.label.toLowerCase().includes(q) && !curated.some((c) => c.key === i.key));
|
||||||
|
return [...curated, ...rest].slice(0, 40);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const SelectedIcon = selected?.Icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Иконка</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(!open);
|
||||||
|
setSearch("");
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-2.5 rounded-lg border bg-neutral-800 px-4 py-2.5 text-left text-white outline-none transition-colors ${
|
||||||
|
open ? "border-gold" : "border-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{SelectedIcon ? (
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-gold/20 text-gold-light">
|
||||||
|
<SelectedIcon size={16} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-white/10 text-neutral-500">?</span>
|
||||||
|
)}
|
||||||
|
<span className="text-sm">{selected?.label || value}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||||
|
<div className="p-2 pb-0">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Поиск..."
|
||||||
|
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 max-h-56 overflow-y-auto">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="py-3 text-center text-sm text-neutral-500">Ничего не найдено</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-6 gap-1">
|
||||||
|
{filtered.map(({ key, Icon, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
title={label}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(key);
|
||||||
|
setOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
}}
|
||||||
|
className={`flex flex-col items-center gap-0.5 rounded-lg p-2 transition-colors ${
|
||||||
|
key === value
|
||||||
|
? "bg-gold/20 text-gold-light"
|
||||||
|
: "text-neutral-400 hover:bg-white/5 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
<span className="text-[10px] leading-tight truncate w-full text-center">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_SWATCHES: { value: string; bg: string }[] = [
|
||||||
|
{ value: "rose", bg: "bg-rose-500" },
|
||||||
|
{ value: "orange", bg: "bg-orange-500" },
|
||||||
|
{ value: "amber", bg: "bg-amber-500" },
|
||||||
|
{ value: "yellow", bg: "bg-yellow-400" },
|
||||||
|
{ value: "lime", bg: "bg-lime-500" },
|
||||||
|
{ value: "emerald", bg: "bg-emerald-500" },
|
||||||
|
{ value: "teal", bg: "bg-teal-500" },
|
||||||
|
{ value: "cyan", bg: "bg-cyan-500" },
|
||||||
|
{ value: "sky", bg: "bg-sky-500" },
|
||||||
|
{ value: "blue", bg: "bg-blue-500" },
|
||||||
|
{ value: "indigo", bg: "bg-indigo-500" },
|
||||||
|
{ value: "violet", bg: "bg-violet-500" },
|
||||||
|
{ value: "purple", bg: "bg-purple-500" },
|
||||||
|
{ value: "fuchsia", bg: "bg-fuchsia-500" },
|
||||||
|
{ value: "pink", bg: "bg-pink-500" },
|
||||||
|
{ value: "red", bg: "bg-red-500" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ClassesData {
|
||||||
|
title: string;
|
||||||
|
items: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
detailedDescription?: string;
|
||||||
|
images?: string[];
|
||||||
|
color?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClassesEditorPage() {
|
||||||
|
return (
|
||||||
|
<SectionEditor<ClassesData> sectionKey="classes" title="Направления">
|
||||||
|
{(data, update) => (
|
||||||
|
<>
|
||||||
|
<InputField
|
||||||
|
label="Заголовок секции"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(v) => update({ ...data, title: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArrayEditor
|
||||||
|
label="Направления"
|
||||||
|
items={data.items}
|
||||||
|
onChange={(items) => update({ ...data, items })}
|
||||||
|
renderItem={(item, _i, updateItem) => (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<InputField
|
||||||
|
label="Название"
|
||||||
|
value={item.name}
|
||||||
|
onChange={(v) => updateItem({ ...item, name: v })}
|
||||||
|
/>
|
||||||
|
<IconPicker
|
||||||
|
value={item.icon}
|
||||||
|
onChange={(v) => updateItem({ ...item, icon: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">
|
||||||
|
Цвет в расписании
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{COLOR_SWATCHES.map((c) => {
|
||||||
|
const isSelected = item.color === c.value;
|
||||||
|
const isUsed = !isSelected && data.items.some(
|
||||||
|
(other) => other !== item && other.color === c.value
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.value}
|
||||||
|
type="button"
|
||||||
|
disabled={isUsed}
|
||||||
|
onClick={() => updateItem({ ...item, color: c.value })}
|
||||||
|
className={`h-6 w-6 rounded-full ${c.bg} transition-all ${
|
||||||
|
isSelected
|
||||||
|
? "ring-2 ring-white ring-offset-1 ring-offset-neutral-900 scale-110"
|
||||||
|
: isUsed
|
||||||
|
? "opacity-15 cursor-not-allowed"
|
||||||
|
: "opacity-50 hover:opacity-100"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TextareaField
|
||||||
|
label="Краткое описание"
|
||||||
|
value={item.description}
|
||||||
|
onChange={(v) => updateItem({ ...item, description: v })}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<TextareaField
|
||||||
|
label="Подробное описание"
|
||||||
|
value={item.detailedDescription || ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
updateItem({ ...item, detailedDescription: v })
|
||||||
|
}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
createItem={() => ({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
icon: "sparkles",
|
||||||
|
detailedDescription: "",
|
||||||
|
images: [],
|
||||||
|
})}
|
||||||
|
addLabel="Добавить направление"
|
||||||
|
collapsible
|
||||||
|
getItemTitle={(item) => item.name || "Без названия"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
src/app/admin/contact/page.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus, X, AlertCircle, Check } from "lucide-react";
|
||||||
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
|
import { InputField } from "../_components/FormField";
|
||||||
|
import { CollapsibleSection } from "../_components/CollapsibleSection";
|
||||||
|
import type { ContactInfo } from "@/types/content";
|
||||||
|
|
||||||
|
// --- Phone input with mask ---
|
||||||
|
function PhoneField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
function formatPhone(raw: string): string {
|
||||||
|
const digits = raw.replace(/\D/g, "").slice(0, 12);
|
||||||
|
if (digits.length === 0) return "+375 ";
|
||||||
|
let result = "+";
|
||||||
|
for (let i = 0; i < digits.length; i++) {
|
||||||
|
if (i === 3) result += " (";
|
||||||
|
if (i === 5) result += ") ";
|
||||||
|
if (i === 8) result += "-";
|
||||||
|
if (i === 10) result += "-";
|
||||||
|
result += digits[i];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const formatted = formatPhone(e.target.value);
|
||||||
|
onChange(formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = value.replace(/\D/g, "");
|
||||||
|
const isComplete = digits.length === 12;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Телефон</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="+375 (XX) XXX-XX-XX"
|
||||||
|
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
|
||||||
|
value && !isComplete ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{isComplete && (
|
||||||
|
<Check size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{value && !isComplete && (
|
||||||
|
<p className="mt-1 text-[11px] text-red-400">Формат: +375 (XX) XXX-XX-XX</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Instagram field with validation ---
|
||||||
|
function InstagramField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
function getError(): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
const host = url.hostname.replace("www.", "");
|
||||||
|
if (host !== "instagram.com" && host !== "instagr.am") {
|
||||||
|
return "Ссылка должна вести на instagram.com";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return "Некорректная ссылка";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = getError();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Instagram</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="https://instagram.com/blackheartdancehouse"
|
||||||
|
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
|
||||||
|
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{value && !error && (
|
||||||
|
<Check size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400" />
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<AlertCircle size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-red-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-[11px] text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Compact address list ---
|
||||||
|
function AddressList({ items, onChange }: { items: string[]; onChange: (items: string[]) => void }) {
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
const val = draft.trim();
|
||||||
|
if (!val) return;
|
||||||
|
onChange([...items, val]);
|
||||||
|
setDraft("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index: number) {
|
||||||
|
onChange(items.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(index: number, value: string) {
|
||||||
|
onChange(items.map((item, i) => (i === index ? value : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((addr, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addr}
|
||||||
|
onChange={(e) => update(i, e.target.value)}
|
||||||
|
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||||
|
onBlur={add}
|
||||||
|
placeholder="Добавить адрес..."
|
||||||
|
className="flex-1 rounded-lg border border-dashed border-white/15 bg-neutral-800/50 px-4 py-2.5 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/50 transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={add}
|
||||||
|
disabled={!draft.trim()}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-gold transition-colors disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactEditorPage() {
|
||||||
|
return (
|
||||||
|
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты">
|
||||||
|
{(data, update) => (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<InputField
|
||||||
|
label="Заголовок секции"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(v) => update({ ...data, title: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<PhoneField
|
||||||
|
value={data.phone}
|
||||||
|
onChange={(v) => update({ ...data, phone: v })}
|
||||||
|
/>
|
||||||
|
<InstagramField
|
||||||
|
value={data.instagram}
|
||||||
|
onChange={(v) => update({ ...data, instagram: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InputField
|
||||||
|
label="Часы работы"
|
||||||
|
value={data.workingHours}
|
||||||
|
onChange={(v) => update({ ...data, workingHours: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CollapsibleSection title="Адреса">
|
||||||
|
<AddressList
|
||||||
|
items={data.addresses}
|
||||||
|
onChange={(addresses) => update({ ...data, addresses })}
|
||||||
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/app/admin/faq/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
|
import { InputField, TextareaField } from "../_components/FormField";
|
||||||
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
|
|
||||||
|
interface FAQData {
|
||||||
|
title: string;
|
||||||
|
items: { question: string; answer: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FAQEditorPage() {
|
||||||
|
return (
|
||||||
|
<SectionEditor<FAQData> sectionKey="faq" title="FAQ">
|
||||||
|
{(data, update) => (
|
||||||
|
<>
|
||||||
|
<InputField
|
||||||
|
label="Заголовок секции"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(v) => update({ ...data, title: v })}
|
||||||
|
/>
|
||||||
|
<ArrayEditor
|
||||||
|
label="Вопросы и ответы"
|
||||||
|
items={data.items}
|
||||||
|
onChange={(items) => update({ ...data, items })}
|
||||||
|
collapsible
|
||||||
|
getItemTitle={(item) => item.question || "Без вопроса"}
|
||||||
|
renderItem={(item, _i, updateItem) => (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<InputField
|
||||||
|
label="Вопрос"
|
||||||
|
value={item.question}
|
||||||
|
onChange={(v) => updateItem({ ...item, question: v })}
|
||||||
|
/>
|
||||||
|
<TextareaField
|
||||||
|
label="Ответ"
|
||||||
|
value={item.answer}
|
||||||
|
onChange={(v) => updateItem({ ...item, answer: v })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
createItem={() => ({ question: "", answer: "" })}
|
||||||
|
addLabel="Добавить вопрос"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
370
src/app/admin/hero/page.tsx
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
|
import { InputField } from "../_components/FormField";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import { Upload, X, Loader2, Smartphone, Monitor, Star } from "lucide-react";
|
||||||
|
|
||||||
|
const MAX_VIDEO_SIZE_MB = 8;
|
||||||
|
const MAX_VIDEO_SIZE_BYTES = MAX_VIDEO_SIZE_MB * 1024 * 1024;
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeroData {
|
||||||
|
headline: string;
|
||||||
|
subheadline: string;
|
||||||
|
ctaText: string;
|
||||||
|
videos?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLOTS = [
|
||||||
|
{ key: "left", label: "Левое", sublabel: "Диагональ слева" },
|
||||||
|
{ key: "center", label: "Центр", sublabel: "Главное видео" },
|
||||||
|
{ key: "right", label: "Правое", sublabel: "Диагональ справа" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function VideoSlot({
|
||||||
|
label,
|
||||||
|
sublabel,
|
||||||
|
src,
|
||||||
|
isCenter,
|
||||||
|
onUpload,
|
||||||
|
onRemove,
|
||||||
|
uploading,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
sublabel: string;
|
||||||
|
src: string | null;
|
||||||
|
isCenter: boolean;
|
||||||
|
onUpload: (file: File) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
uploading: boolean;
|
||||||
|
}) {
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [fileSize, setFileSize] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Fetch file size via HEAD request
|
||||||
|
useEffect(() => {
|
||||||
|
if (!src) { setFileSize(null); return; }
|
||||||
|
fetch(src, { method: "HEAD" })
|
||||||
|
.then((r) => {
|
||||||
|
const len = r.headers.get("content-length");
|
||||||
|
if (len) setFileSize(parseInt(len, 10));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
const isLarge = fileSize !== null && fileSize > MAX_VIDEO_SIZE_BYTES;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Label */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-neutral-300">{label}</span>
|
||||||
|
{isCenter && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-[#c9a96e]/15 px-2 py-0.5 text-[10px] font-medium text-[#c9a96e]">
|
||||||
|
<Smartphone size={10} />
|
||||||
|
мобильная версия
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500">{sublabel}</p>
|
||||||
|
|
||||||
|
{/* Slot */}
|
||||||
|
{src ? (
|
||||||
|
<div
|
||||||
|
className={`group relative overflow-hidden rounded-lg border ${
|
||||||
|
isCenter ? "border-[#c9a96e]/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700"
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => videoRef.current?.play()}
|
||||||
|
onMouseLeave={() => { videoRef.current?.pause(); }}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={src}
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
className="aspect-[9/16] w-full object-cover bg-black"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2">
|
||||||
|
<p className="truncate text-xs text-neutral-400">
|
||||||
|
{src.split("/").pop()}
|
||||||
|
</p>
|
||||||
|
{fileSize !== null && (
|
||||||
|
<p className={`text-[10px] mt-0.5 ${isLarge ? "text-amber-400" : "text-neutral-500"}`}>
|
||||||
|
{formatFileSize(fileSize)}{isLarge ? ` — рекомендуем до ${MAX_VIDEO_SIZE_MB} МБ` : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isCenter && (
|
||||||
|
<div className="absolute top-2 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-[#c9a96e]/90 px-2 py-0.5 text-[10px] font-bold text-black">
|
||||||
|
<Star size={10} fill="currentColor" />
|
||||||
|
MAIN
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Play hint */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||||
|
<span className="text-white/80 text-xs">▶ наведите для просмотра</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="absolute top-2 right-2 rounded-full bg-black/70 p-1.5 text-neutral-400 opacity-0 transition-opacity hover:text-red-400 group-hover:opacity-100"
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className={`flex aspect-[9/16] w-full items-center justify-center rounded-lg border-2 border-dashed transition-colors disabled:opacity-50 ${
|
||||||
|
isCenter
|
||||||
|
? "border-[#c9a96e]/30 text-[#c9a96e]/50 hover:border-[#c9a96e]/60 hover:text-[#c9a96e]"
|
||||||
|
: "border-neutral-700 text-neutral-500 hover:border-neutral-500 hover:text-neutral-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 size={24} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Upload size={24} />
|
||||||
|
<span className="text-xs font-medium">Загрузить</span>
|
||||||
|
<span className="text-[10px] opacity-60">MP4, до {MAX_VIDEO_SIZE_MB} МБ</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="video/mp4,video/webm"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) onUpload(file);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoSizeInfo({ totalSize, totalMb, rating }: { totalSize: number; totalMb: number; rating: { label: string; color: string } }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="w-full text-left rounded-lg bg-neutral-800/50 px-3 py-2 transition-colors hover:bg-neutral-800/80"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-neutral-400">Общий вес: <span className={`font-medium ${rating.color}`}>{formatFileSize(totalSize)}</span></span>
|
||||||
|
<span className={`text-[11px] ${rating.color}`}>{rating.label} {open ? "▲" : "▼"}</span>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<table className="w-full text-[10px] text-neutral-500 mt-2">
|
||||||
|
<tbody>
|
||||||
|
<tr className={totalMb <= 15 ? `${rating.color} font-medium` : ""}>
|
||||||
|
<td className="py-0.5 w-20">до 15 МБ</td><td>Быстро — видео загружается мгновенно</td>
|
||||||
|
</tr>
|
||||||
|
<tr className={totalMb > 15 && totalMb <= 24 ? `${rating.color} font-medium` : ""}>
|
||||||
|
<td className="py-0.5">15–24 МБ</td><td>Нормально — небольшая задержка на 4G</td>
|
||||||
|
</tr>
|
||||||
|
<tr className={totalMb > 24 && totalMb <= 40 ? `${rating.color} font-medium` : ""}>
|
||||||
|
<td className="py-0.5">24–40 МБ</td><td>Медленно — заметная задержка на телефоне</td>
|
||||||
|
</tr>
|
||||||
|
<tr className={totalMb > 40 ? `${rating.color} font-medium` : ""}>
|
||||||
|
<td className="py-0.5">40+ МБ</td><td>Очень медленно — пользователь может уйти</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoManager({
|
||||||
|
videos,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
videos: string[];
|
||||||
|
onChange: (videos: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const [slots, setSlots] = useState<(string | null)[]>(() => [
|
||||||
|
videos[0] || null,
|
||||||
|
videos[1] || null,
|
||||||
|
videos[2] || null,
|
||||||
|
]);
|
||||||
|
const [uploadingIdx, setUploadingIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const syncToParent = useCallback(
|
||||||
|
(updated: (string | null)[]) => {
|
||||||
|
setSlots(updated);
|
||||||
|
// Only propagate when all 3 are filled
|
||||||
|
if (updated.every((s) => s !== null)) {
|
||||||
|
onChange(updated as string[]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [sizeWarning, setSizeWarning] = useState<string | null>(null);
|
||||||
|
const [fileSizes, setFileSizes] = useState<(number | null)[]>([null, null, null]);
|
||||||
|
|
||||||
|
// Fetch file sizes for all slots
|
||||||
|
useEffect(() => {
|
||||||
|
slots.forEach((src, i) => {
|
||||||
|
if (!src) { setFileSizes((p) => { const n = [...p]; n[i] = null; return n; }); return; }
|
||||||
|
fetch(src, { method: "HEAD" })
|
||||||
|
.then((r) => {
|
||||||
|
const len = r.headers.get("content-length");
|
||||||
|
if (len) setFileSizes((p) => { const n = [...p]; n[i] = parseInt(len, 10); return n; });
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
}, [slots]);
|
||||||
|
|
||||||
|
const totalSize = fileSizes.reduce((sum: number, s) => sum + (s || 0), 0);
|
||||||
|
const totalMb = totalSize / (1024 * 1024);
|
||||||
|
|
||||||
|
function getLoadRating(mb: number): { label: string; color: string } {
|
||||||
|
if (mb <= 15) return { label: "Быстрая загрузка", color: "text-emerald-400" };
|
||||||
|
if (mb <= 24) return { label: "Нормальная загрузка", color: "text-blue-400" };
|
||||||
|
if (mb <= 40) return { label: "Медленная загрузка", color: "text-amber-400" };
|
||||||
|
return { label: "Очень медленная загрузка", color: "text-red-400" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(idx: number, file: File) {
|
||||||
|
if (file.size > MAX_VIDEO_SIZE_BYTES) {
|
||||||
|
const sizeMb = (file.size / (1024 * 1024)).toFixed(1);
|
||||||
|
setSizeWarning(`Видео ${sizeMb} МБ — рекомендуем до ${MAX_VIDEO_SIZE_MB} МБ для быстрой загрузки`);
|
||||||
|
} else {
|
||||||
|
setSizeWarning(null);
|
||||||
|
}
|
||||||
|
setUploadingIdx(idx);
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
form.append("folder", "hero");
|
||||||
|
const res = await adminFetch("/api/admin/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(err.error || "Ошибка загрузки");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { path } = await res.json();
|
||||||
|
const updated = [...slots];
|
||||||
|
updated[idx] = path;
|
||||||
|
syncToParent(updated);
|
||||||
|
} finally {
|
||||||
|
setUploadingIdx(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(idx: number) {
|
||||||
|
const updated = [...slots];
|
||||||
|
updated[idx] = null;
|
||||||
|
setSlots(updated);
|
||||||
|
// Don't propagate incomplete state — keep old saved videos in DB
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFilled = slots.every((s) => s !== null);
|
||||||
|
const filledCount = slots.filter((s) => s !== null).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm font-medium text-neutral-300">
|
||||||
|
Видео на главном экране
|
||||||
|
</label>
|
||||||
|
{!allFilled && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-400">
|
||||||
|
Загружено {filledCount}/3 — загрузите все для сохранения
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{allFilled && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-emerald-500/10 px-2 py-0.5 text-[11px] text-emerald-400">
|
||||||
|
✓ Все видео загружены
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{SLOTS.map((slot, i) => (
|
||||||
|
<VideoSlot
|
||||||
|
key={slot.key}
|
||||||
|
label={slot.label}
|
||||||
|
sublabel={slot.sublabel}
|
||||||
|
src={slots[i]}
|
||||||
|
isCenter={i === 1}
|
||||||
|
uploading={uploadingIdx === i}
|
||||||
|
onUpload={(file) => handleUpload(i, file)}
|
||||||
|
onRemove={() => handleRemove(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 text-xs text-neutral-500">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Monitor size={13} />
|
||||||
|
<span>ПК — диагональный сплит из 3 видео</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Smartphone size={13} />
|
||||||
|
<span>Телефон — только центральное видео</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sizeWarning && (
|
||||||
|
<div className="rounded-lg bg-amber-500/10 border border-amber-500/20 px-3 py-2 text-xs text-amber-400">
|
||||||
|
⚠ {sizeWarning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Total size — collapsible */}
|
||||||
|
{totalSize > 0 && <VideoSizeInfo totalSize={totalSize} totalMb={totalMb} rating={getLoadRating(totalMb)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeroEditorPage() {
|
||||||
|
return (
|
||||||
|
<SectionEditor<HeroData> sectionKey="hero" title="Главный экран">
|
||||||
|
{(data, update) => (
|
||||||
|
<>
|
||||||
|
<VideoManager
|
||||||
|
videos={data.videos || []}
|
||||||
|
onChange={(videos) => update({ ...data, videos })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputField
|
||||||
|
label="Заголовок"
|
||||||
|
value={data.headline}
|
||||||
|
onChange={(v) => update({ ...data, headline: v })}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Подзаголовок"
|
||||||
|
value={data.subheadline}
|
||||||
|
onChange={(v) => update({ ...data, subheadline: v })}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Текст кнопки"
|
||||||
|
value={data.ctaText}
|
||||||
|
onChange={(v) => update({ ...data, ctaText: v })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Sparkles,
|
||||||
|
Users,
|
||||||
|
BookOpen,
|
||||||
|
Star,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
HelpCircle,
|
||||||
|
Phone,
|
||||||
|
FileText,
|
||||||
|
Globe,
|
||||||
|
Newspaper,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
ChevronLeft,
|
||||||
|
ClipboardList,
|
||||||
|
DoorOpen,
|
||||||
|
MessageSquare,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ href: "/admin", label: "Дашборд", icon: LayoutDashboard },
|
||||||
|
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe },
|
||||||
|
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
|
||||||
|
// Sections follow user-side order: Hero → About → Classes → Team → OpenDay → Schedule → Pricing → MC → News → FAQ → Contact
|
||||||
|
{ href: "/admin/hero", label: "Главный экран", icon: Sparkles },
|
||||||
|
{ href: "/admin/about", label: "О студии", icon: FileText },
|
||||||
|
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
|
||||||
|
{ href: "/admin/team", label: "Команда", icon: Users },
|
||||||
|
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen },
|
||||||
|
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
|
||||||
|
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
|
||||||
|
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
|
||||||
|
{ href: "/admin/news", label: "Новости", icon: Newspaper },
|
||||||
|
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
||||||
|
{ href: "/admin/popups", label: "Всплывающие окна", icon: MessageSquare },
|
||||||
|
{ href: "/admin/contact", label: "Контакты", icon: Phone },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [unreadTotal, setUnreadTotal] = useState(0);
|
||||||
|
const isLoginPage = pathname === "/admin/login";
|
||||||
|
|
||||||
|
// Fetch unread counts — poll every 10s
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoginPage) return;
|
||||||
|
function fetchCounts() {
|
||||||
|
adminFetch("/api/admin/unread-counts")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { total: number }) => setUnreadTotal(data.total))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
fetchCounts();
|
||||||
|
const interval = setInterval(fetchCounts, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isLoginPage]);
|
||||||
|
|
||||||
|
// Don't render admin shell on login page
|
||||||
|
if (isLoginPage) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await fetch("/api/logout", { method: "POST" });
|
||||||
|
router.push("/admin/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
if (href === "/admin") return pathname === "/admin";
|
||||||
|
return pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-neutral-950 text-white">
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/60 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-white/10 bg-neutral-900 transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
|
||||||
|
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||||
|
<Link href="/admin" className="text-lg font-bold">
|
||||||
|
BLACK HEART
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className="lg:hidden text-neutral-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = isActive(item.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-gold/10 text-gold font-medium"
|
||||||
|
: "text-neutral-400 hover:text-white hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
{item.label}
|
||||||
|
{item.href === "/admin/bookings" && unreadTotal > 0 && (
|
||||||
|
<span className="ml-auto rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||||
|
{unreadTotal > 99 ? "99+" : unreadTotal}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t border-white/10 p-3 space-y-1">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
target="_blank"
|
||||||
|
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
Открыть сайт
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-red-400 hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{/* Top bar (mobile) */}
|
||||||
|
<header className="flex items-center gap-3 border-b border-white/10 px-4 py-3 lg:hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="text-neutral-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<Menu size={24} />
|
||||||
|
</button>
|
||||||
|
<a href="/admin" className="font-bold hover:text-gold transition-colors">BLACK HEART</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 p-4 sm:p-6 lg:p-8">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/app/admin/login/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export default function AdminLoginPage() {
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
router.push("/admin");
|
||||||
|
} else {
|
||||||
|
setError("Неверный пароль");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Ошибка соединения");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-neutral-950 px-4">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="w-full max-w-sm space-y-6 rounded-2xl border border-white/10 bg-neutral-900 p-8"
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-white">BLACK HEART</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-400">Панель управления</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm text-neutral-400 mb-2">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-3 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-400 text-center">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !password}
|
||||||
|
className="w-full rounded-lg bg-gold px-4 py-3 font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Вход..." : "Войти"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
669
src/app/admin/master-classes/page.tsx
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
|
import { InputField, TextareaField, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
|
||||||
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
|
import { PriceField } from "../_components/PriceField";
|
||||||
|
import { Plus, X, Upload, Loader2, AlertCircle, Check, Search } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function isItemArchived(item: MasterClassItem): boolean {
|
||||||
|
const slots = item.slots ?? [];
|
||||||
|
if (slots.length === 0) return false;
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
return slots.every((s) => s.date && s.date < today);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemMatchesSearch(item: MasterClassItem, query: string): boolean {
|
||||||
|
if (!query) return true;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
(item.title || "").toLowerCase().includes(q) ||
|
||||||
|
(item.trainer || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemMatchesDateFilter(item: MasterClassItem, filter: "all" | "upcoming" | "past"): boolean {
|
||||||
|
if (filter === "all") return true;
|
||||||
|
const archived = isItemArchived(item);
|
||||||
|
return filter === "past" ? archived : !archived;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemMatchesLocation(item: MasterClassItem, locationFilter: string): boolean {
|
||||||
|
if (!locationFilter) return true;
|
||||||
|
return (item.location || "") === locationFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MasterClassesData {
|
||||||
|
title: string;
|
||||||
|
items: MasterClassItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Location Select ---
|
||||||
|
function LocationSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
locations,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
locations: { name: string; address: string }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Локация</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{locations.map((loc) => {
|
||||||
|
const active = value === loc.name;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={loc.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(active ? "" : loc.name)}
|
||||||
|
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
active
|
||||||
|
? "bg-gold/20 text-gold border border-gold/40"
|
||||||
|
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{active && <Check size={10} className="inline mr-1" />}
|
||||||
|
{loc.name}
|
||||||
|
<span className="text-neutral-500 ml-1 text-[10px]">
|
||||||
|
{loc.address.replace(/^г\.\s*\S+,\s*/, "")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Date List ---
|
||||||
|
function calcDurationText(startTime: string, endTime: string): string {
|
||||||
|
if (!startTime || !endTime) return "";
|
||||||
|
const [sh, sm] = startTime.split(":").map(Number);
|
||||||
|
const [eh, em] = endTime.split(":").map(Number);
|
||||||
|
const mins = (eh * 60 + em) - (sh * 60 + sm);
|
||||||
|
if (mins <= 0) return "";
|
||||||
|
const h = Math.floor(mins / 60);
|
||||||
|
const m = mins % 60;
|
||||||
|
if (h > 0 && m > 0) return `${h} ч ${m} мин`;
|
||||||
|
if (h > 0) return h === 1 ? "1 час" : h < 5 ? `${h} часа` : `${h} часов`;
|
||||||
|
return `${m} мин`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTimeError(startTime: string, endTime: string): boolean {
|
||||||
|
if (!startTime || !endTime) return false;
|
||||||
|
const [sh, sm] = startTime.split(":").map(Number);
|
||||||
|
const [eh, em] = endTime.split(":").map(Number);
|
||||||
|
return (eh * 60 + em) <= (sh * 60 + sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlotsField({
|
||||||
|
slots,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
slots: MasterClassSlot[];
|
||||||
|
onChange: (slots: MasterClassSlot[]) => void;
|
||||||
|
}) {
|
||||||
|
function addSlot() {
|
||||||
|
// Copy time from last slot for convenience
|
||||||
|
const last = slots[slots.length - 1];
|
||||||
|
onChange([...slots, {
|
||||||
|
date: "",
|
||||||
|
startTime: last?.startTime ?? "",
|
||||||
|
endTime: last?.endTime ?? "",
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSlot(index: number, patch: Partial<MasterClassSlot>) {
|
||||||
|
onChange(slots.map((s, i) => (i === index ? { ...s, ...patch } : s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSlot(index: number) {
|
||||||
|
onChange(slots.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Даты и время</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{slots.map((slot, i) => {
|
||||||
|
const dur = calcDurationText(slot.startTime, slot.endTime);
|
||||||
|
const timeError = hasTimeError(slot.startTime, slot.endTime);
|
||||||
|
return (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={slot.date}
|
||||||
|
onChange={(e) => updateSlot(i, { date: e.target.value })}
|
||||||
|
className={`w-[140px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
|
||||||
|
!slot.date ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={slot.startTime}
|
||||||
|
onChange={(e) => updateSlot(i, { startTime: e.target.value })}
|
||||||
|
className={`w-[100px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
|
||||||
|
timeError ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-neutral-500 text-xs">–</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={slot.endTime}
|
||||||
|
onChange={(e) => updateSlot(i, { endTime: e.target.value })}
|
||||||
|
className={`w-[100px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
|
||||||
|
timeError ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{dur && (
|
||||||
|
<span className="text-[11px] text-neutral-500 bg-neutral-800/50 rounded-full px-2 py-0.5">
|
||||||
|
{dur}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeSlot(i)}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!slot.date && (
|
||||||
|
<p className="mt-0.5 ml-1 text-[11px] text-red-400">Укажите дату</p>
|
||||||
|
)}
|
||||||
|
{timeError && (
|
||||||
|
<p className="mt-0.5 ml-1 text-[11px] text-red-400">Время окончания должно быть позже начала</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addSlot}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Photo Preview (like trainer page) ---
|
||||||
|
function PhotoPreview({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("folder", "master-classes");
|
||||||
|
try {
|
||||||
|
const res = await adminFetch("/api/admin/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.path) onChange(result.path);
|
||||||
|
} catch {
|
||||||
|
/* upload failed */
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Изображение</label>
|
||||||
|
{value ? (
|
||||||
|
<div className="relative">
|
||||||
|
<label className="relative block w-full aspect-[16/9] overflow-hidden rounded-xl border border-white/10 cursor-pointer group">
|
||||||
|
<Image
|
||||||
|
src={value}
|
||||||
|
alt="Превью"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 768px) 100vw, 500px"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1">
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 size={20} className="animate-spin text-white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload size={20} className="text-white" />
|
||||||
|
<span className="text-[11px] text-white/80">Изменить</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
className="absolute top-2 right-2 rounded-lg bg-black/60 p-1.5 text-neutral-400 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<label className="flex cursor-pointer items-center justify-center gap-2 w-full aspect-[16/9] rounded-xl border-2 border-dashed border-white/20 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload size={20} />
|
||||||
|
<span>Загрузить изображение</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Instagram Link Field ---
|
||||||
|
function InstagramLinkField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
const error = getInstagramError(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">
|
||||||
|
Ссылка на Instagram
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="https://instagram.com/p/... или /reel/..."
|
||||||
|
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
|
||||||
|
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{value && !error && (
|
||||||
|
<Check
|
||||||
|
size={14}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<AlertCircle
|
||||||
|
size={14}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-red-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-[11px] text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstagramError(url: string): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const host = parsed.hostname.replace("www.", "");
|
||||||
|
if (host !== "instagram.com" && host !== "instagr.am") {
|
||||||
|
return "Ссылка должна вести на instagram.com";
|
||||||
|
}
|
||||||
|
const validPaths = ["/p/", "/reel/", "/tv/", "/stories/"];
|
||||||
|
if (!validPaths.some((p) => parsed.pathname.includes(p))) {
|
||||||
|
return "Ожидается ссылка на пост, рилс или сторис (/p/, /reel/, /tv/)";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return "Некорректная ссылка";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validation badge ---
|
||||||
|
function ValidationHint({ fields }: { fields: Record<string, string> }) {
|
||||||
|
const missing = Object.entries(fields).filter(([, v]) => !(v ?? "").trim());
|
||||||
|
if (missing.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-1.5 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-xs text-red-400">
|
||||||
|
<AlertCircle size={12} className="shrink-0 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
Не заполнено: {missing.map(([k]) => k).join(", ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Filter bar ---
|
||||||
|
type DateFilter = "all" | "upcoming" | "past";
|
||||||
|
|
||||||
|
const DATE_FILTER_LABELS: Record<DateFilter, string> = {
|
||||||
|
all: "Все",
|
||||||
|
upcoming: "Предстоящие",
|
||||||
|
past: "Прошедшие",
|
||||||
|
};
|
||||||
|
|
||||||
|
function FilterBar({
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
dateFilter,
|
||||||
|
onDateFilterChange,
|
||||||
|
locationFilter,
|
||||||
|
onLocationFilterChange,
|
||||||
|
locations,
|
||||||
|
totalCount,
|
||||||
|
visibleCount,
|
||||||
|
}: {
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (v: string) => void;
|
||||||
|
dateFilter: DateFilter;
|
||||||
|
onDateFilterChange: (v: DateFilter) => void;
|
||||||
|
locationFilter: string;
|
||||||
|
onLocationFilterChange: (v: string) => void;
|
||||||
|
locations: { name: string; address: string }[];
|
||||||
|
totalCount: number;
|
||||||
|
visibleCount: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder="Поиск по названию или тренеру..."
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-neutral-800 pl-10 pr-4 py-2.5 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSearchChange("")}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(Object.keys(DATE_FILTER_LABELS) as DateFilter[]).map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDateFilterChange(key)}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||||
|
dateFilter === key
|
||||||
|
? "bg-gold/20 text-gold border border-gold/40"
|
||||||
|
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{DATE_FILTER_LABELS[key]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{locations.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-600 text-xs">|</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{locations.map((loc) => (
|
||||||
|
<button
|
||||||
|
key={loc.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onLocationFilterChange(locationFilter === loc.name ? "" : loc.name)}
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||||
|
locationFilter === loc.name
|
||||||
|
? "bg-gold/20 text-gold border border-gold/40"
|
||||||
|
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{loc.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{visibleCount < totalCount && (
|
||||||
|
<span className="text-xs text-neutral-500 ml-auto">
|
||||||
|
{visibleCount} из {totalCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main page ---
|
||||||
|
export default function MasterClassesEditorPage() {
|
||||||
|
const [trainers, setTrainers] = useState<string[]>([]);
|
||||||
|
const [styles, setStyles] = useState<string[]>([]);
|
||||||
|
const [locations, setLocations] = useState<{ name: string; address: string }[]>([]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [dateFilter, setDateFilter] = useState<DateFilter>("all");
|
||||||
|
const [locationFilter, setLocationFilter] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch trainers from team
|
||||||
|
adminFetch("/api/admin/team")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((members: { name: string }[]) => {
|
||||||
|
setTrainers(members.map((m) => m.name));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Fetch styles from classes section
|
||||||
|
adminFetch("/api/admin/sections/classes")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { items: { name: string }[] }) => {
|
||||||
|
setStyles(data.items.map((c) => c.name));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Fetch locations from schedule section
|
||||||
|
adminFetch("/api/admin/sections/schedule")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { locations: { name: string; address: string }[] }) => {
|
||||||
|
setLocations(data.locations);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionEditor<MasterClassesData>
|
||||||
|
sectionKey="masterClasses"
|
||||||
|
title="Мастер-классы"
|
||||||
|
>
|
||||||
|
{(data, update) => {
|
||||||
|
// Sort: active first, archived at bottom
|
||||||
|
const displayItems = [...data.items].sort((a, b) => {
|
||||||
|
const aArch = isItemArchived(a);
|
||||||
|
const bArch = isItemArchived(b);
|
||||||
|
if (aArch === bArch) return 0;
|
||||||
|
return aArch ? 1 : -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hiddenItems = new Set<number>();
|
||||||
|
displayItems.forEach((item, i) => {
|
||||||
|
if (
|
||||||
|
!itemMatchesSearch(item, search) ||
|
||||||
|
!itemMatchesDateFilter(item, dateFilter) ||
|
||||||
|
!itemMatchesLocation(item, locationFilter)
|
||||||
|
) {
|
||||||
|
hiddenItems.add(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleCount = data.items.length - hiddenItems.size;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InputField
|
||||||
|
label="Заголовок секции"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(v) => update({ ...data, title: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
dateFilter={dateFilter}
|
||||||
|
onDateFilterChange={setDateFilter}
|
||||||
|
locationFilter={locationFilter}
|
||||||
|
onLocationFilterChange={setLocationFilter}
|
||||||
|
locations={locations}
|
||||||
|
totalCount={data.items.length}
|
||||||
|
visibleCount={visibleCount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArrayEditor
|
||||||
|
label="Мастер-классы"
|
||||||
|
items={displayItems}
|
||||||
|
onChange={(items) => update({ ...data, items })}
|
||||||
|
collapsible
|
||||||
|
hiddenItems={hiddenItems}
|
||||||
|
getItemTitle={(item) => {
|
||||||
|
const base = item.location
|
||||||
|
? `${item.title || "Без названия"} · ${item.location}`
|
||||||
|
: item.title || "Без названия";
|
||||||
|
return base;
|
||||||
|
}}
|
||||||
|
getItemBadge={(item) =>
|
||||||
|
isItemArchived(item) ? (
|
||||||
|
<span className="shrink-0 rounded-full bg-neutral-700/50 px-2 py-0.5 text-[10px] font-medium text-neutral-500">
|
||||||
|
Архив
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
renderItem={(item, _i, updateItem) => {
|
||||||
|
const archived = isItemArchived(item);
|
||||||
|
return (
|
||||||
|
<div className={`space-y-3 ${archived ? "opacity-50" : ""}`}>
|
||||||
|
|
||||||
|
<ValidationHint
|
||||||
|
fields={{
|
||||||
|
Название: item.title,
|
||||||
|
Тренер: item.trainer,
|
||||||
|
Стиль: item.style,
|
||||||
|
Стоимость: item.cost,
|
||||||
|
"Даты и время": (item.slots ?? []).length > 0 ? "ok" : "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputField
|
||||||
|
label="Название"
|
||||||
|
value={item.title}
|
||||||
|
onChange={(v) => updateItem({ ...item, title: v })}
|
||||||
|
placeholder="Мастер-класс от Анны Тарыбы"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PhotoPreview
|
||||||
|
value={item.image}
|
||||||
|
onChange={(v) => updateItem({ ...item, image: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<AutocompleteMulti
|
||||||
|
label="Тренер"
|
||||||
|
value={item.trainer}
|
||||||
|
onChange={(v) => updateItem({ ...item, trainer: v })}
|
||||||
|
options={trainers}
|
||||||
|
placeholder="Добавить тренера..."
|
||||||
|
/>
|
||||||
|
<AutocompleteMulti
|
||||||
|
label="Стиль"
|
||||||
|
value={item.style}
|
||||||
|
onChange={(v) => updateItem({ ...item, style: v })}
|
||||||
|
options={styles}
|
||||||
|
placeholder="Добавить стиль..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PriceField
|
||||||
|
label="Стоимость"
|
||||||
|
value={item.cost}
|
||||||
|
onChange={(v) => updateItem({ ...item, cost: v })}
|
||||||
|
placeholder="40"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{locations.length > 0 && (
|
||||||
|
<LocationSelect
|
||||||
|
value={item.location || ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
updateItem({ ...item, location: v || undefined })
|
||||||
|
}
|
||||||
|
locations={locations}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SlotsField
|
||||||
|
slots={item.slots ?? []}
|
||||||
|
onChange={(slots) => updateItem({ ...item, slots })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextareaField
|
||||||
|
label="Описание"
|
||||||
|
value={item.description || ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
updateItem({ ...item, description: v || undefined })
|
||||||
|
}
|
||||||
|
placeholder="Описание мастер-класса, трек, стиль..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InstagramLinkField
|
||||||
|
value={item.instagramUrl || ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
updateItem({ ...item, instagramUrl: v || undefined })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ParticipantLimits
|
||||||
|
min={item.minParticipants ?? 0}
|
||||||
|
max={item.maxParticipants ?? 0}
|
||||||
|
onMinChange={(v) => updateItem({ ...item, minParticipants: v })}
|
||||||
|
onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
createItem={() => ({
|
||||||
|
title: "",
|
||||||
|
image: "",
|
||||||
|
slots: [],
|
||||||
|
trainer: "",
|
||||||
|
cost: "",
|
||||||
|
style: "",
|
||||||
|
})}
|
||||||
|
addLabel="Добавить мастер-класс"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</SectionEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/app/admin/meta/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
|
import { InputField, TextareaField } from "../_components/FormField";
|
||||||
|
|
||||||
|
interface MetaData {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetaEditorPage() {
|
||||||
|
return (
|
||||||
|
<SectionEditor<MetaData> sectionKey="meta" title="SEO / Мета">
|
||||||
|
{(data, update) => (
|
||||||
|
<>
|
||||||
|
<InputField
|
||||||
|
label="Заголовок сайта (title)"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(v) => update({ ...data, title: v })}
|
||||||
|
/>
|
||||||
|
<TextareaField
|
||||||
|
label="Описание (description)"
|
||||||
|
value={data.description}
|
||||||
|
onChange={(v) => update({ ...data, description: v })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
276
src/app/admin/news/page.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
|
import { InputField, TextareaField } from "../_components/FormField";
|
||||||
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
|
import { Upload, Loader2, X, AlertCircle } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import type { NewsItem } from "@/types/content";
|
||||||
|
|
||||||
|
interface NewsData {
|
||||||
|
title: string;
|
||||||
|
items: NewsItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function CropPreview({
|
||||||
|
image,
|
||||||
|
focalX,
|
||||||
|
focalY,
|
||||||
|
zoom,
|
||||||
|
onImageChange,
|
||||||
|
onFocalChange,
|
||||||
|
onZoomChange,
|
||||||
|
}: {
|
||||||
|
image: string;
|
||||||
|
focalX: number;
|
||||||
|
focalY: number;
|
||||||
|
zoom: number;
|
||||||
|
onImageChange: (path: string) => void;
|
||||||
|
onFocalChange: (x: number, y: number) => void;
|
||||||
|
onZoomChange: (z: number) => void;
|
||||||
|
}) {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const dragStartRef = useRef({ x: 0, y: 0, startFocalX: 0, startFocalY: 0 });
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("folder", "news");
|
||||||
|
try {
|
||||||
|
const res = await adminFetch("/api/admin/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.path) onImageChange(result.path);
|
||||||
|
} catch {
|
||||||
|
/* upload failed */
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(e: React.PointerEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
setDragging(true);
|
||||||
|
dragStartRef.current = {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
startFocalX: focalX,
|
||||||
|
startFocalY: focalY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(e: React.PointerEvent) {
|
||||||
|
if (!dragging) return;
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const { x: startX, y: startY, startFocalX, startFocalY } = dragStartRef.current;
|
||||||
|
// Invert: dragging right moves focal left (image slides right)
|
||||||
|
const dx = ((e.clientX - startX) / rect.width) * 100;
|
||||||
|
const dy = ((e.clientY - startY) / rect.height) * 100;
|
||||||
|
const newX = Math.max(0, Math.min(100, startFocalX - dx));
|
||||||
|
const newY = Math.max(0, Math.min(100, startFocalY - dy));
|
||||||
|
onFocalChange(Math.round(newX), Math.round(newY));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp() {
|
||||||
|
setDragging(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWheel(e: React.WheelEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||||
|
const newZoom = Math.max(1, Math.min(3, zoom + delta));
|
||||||
|
onZoomChange(Math.round(newZoom * 10) / 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">
|
||||||
|
Изображение <span className="text-neutral-600">(перетащите фото · колёсико для масштаба)</span>
|
||||||
|
</label>
|
||||||
|
{image ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Crop area — drag image to reposition */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative w-full aspect-[21/9] overflow-hidden rounded-xl border border-white/10 cursor-grab active:cursor-grabbing select-none"
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt="Превью"
|
||||||
|
fill
|
||||||
|
className="object-cover pointer-events-none"
|
||||||
|
style={{
|
||||||
|
objectPosition: `${focalX}% ${focalY}%`,
|
||||||
|
transform: `scale(${zoom})`,
|
||||||
|
}}
|
||||||
|
sizes="(max-width: 768px) 100vw, 600px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Zoom slider + actions */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<span className="text-[10px] text-neutral-500">−</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="3"
|
||||||
|
step="0.1"
|
||||||
|
value={zoom}
|
||||||
|
onChange={(e) => onZoomChange(parseFloat(e.target.value))}
|
||||||
|
className="flex-1 h-1 accent-[#c9a96e] cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-neutral-500">+</span>
|
||||||
|
{zoom > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onZoomChange(1); onFocalChange(50, 50); }}
|
||||||
|
className="text-[10px] text-neutral-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
|
||||||
|
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||||||
|
Заменить
|
||||||
|
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onImageChange("")}
|
||||||
|
className="rounded-lg px-3 py-1.5 text-xs text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<label className="flex cursor-pointer items-center justify-center gap-2 w-full aspect-[16/9] rounded-xl border-2 border-dashed border-white/20 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload size={20} />
|
||||||
|
<span>Загрузить изображение</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewsEditorPage() {
|
||||||
|
return (
|
||||||
|
<SectionEditor<NewsData> sectionKey="news" title="Новости">
|
||||||
|
{(data, update) => (
|
||||||
|
<>
|
||||||
|
<InputField
|
||||||
|
label="Заголовок секции"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(v) => update({ ...data, title: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArrayEditor
|
||||||
|
label="Новости"
|
||||||
|
items={data.items}
|
||||||
|
onChange={(items) => update({ ...data, items })}
|
||||||
|
collapsible
|
||||||
|
getItemTitle={(item) => {
|
||||||
|
const title = item.title || "Без заголовка";
|
||||||
|
if (item.date) {
|
||||||
|
try {
|
||||||
|
const d = new Date(item.date);
|
||||||
|
const date = d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
|
||||||
|
const time = item.date.includes("T") ? ` ${d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}` : "";
|
||||||
|
return `${title} · ${date}${time}`;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return title;
|
||||||
|
}}
|
||||||
|
getItemBadge={(item) => {
|
||||||
|
const missing = [
|
||||||
|
!item.title.trim() && "заголовок",
|
||||||
|
!item.text.trim() && "текст",
|
||||||
|
!item.image && "фото",
|
||||||
|
].filter(Boolean);
|
||||||
|
if (missing.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 rounded-full bg-red-500/10 border border-red-500/20 px-2 py-0.5 text-[10px] font-medium text-red-400">
|
||||||
|
Черновик
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderItem={(item, _i, updateItem) => {
|
||||||
|
const missing = [
|
||||||
|
!item.title.trim() && "Заголовок",
|
||||||
|
!item.text.trim() && "Текст",
|
||||||
|
!item.image && "Изображение",
|
||||||
|
].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{missing.length > 0 && (
|
||||||
|
<div className="flex items-start gap-1.5 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-xs text-red-400">
|
||||||
|
<AlertCircle size={12} className="shrink-0 mt-0.5" />
|
||||||
|
<span>Не опубликовано — не заполнено: {missing.join(", ")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<InputField
|
||||||
|
label="Заголовок"
|
||||||
|
value={item.title}
|
||||||
|
onChange={(v) => updateItem({ ...item, title: v })}
|
||||||
|
/>
|
||||||
|
<TextareaField
|
||||||
|
label="Текст"
|
||||||
|
value={item.text}
|
||||||
|
onChange={(v) => updateItem({ ...item, text: v })}
|
||||||
|
/>
|
||||||
|
<CropPreview
|
||||||
|
image={item.image || ""}
|
||||||
|
focalX={item.imageFocalX ?? 50}
|
||||||
|
focalY={item.imageFocalY ?? 50}
|
||||||
|
zoom={item.imageZoom ?? 1}
|
||||||
|
onImageChange={(v) => updateItem({ ...item, image: v || undefined })}
|
||||||
|
onFocalChange={(x, y) => updateItem({ ...item, imageFocalX: x, imageFocalY: y })}
|
||||||
|
onZoomChange={(z) => updateItem({ ...item, imageZoom: z })}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Ссылка (необязательно)"
|
||||||
|
value={item.link || ""}
|
||||||
|
onChange={(v) => updateItem({ ...item, link: v || undefined })}
|
||||||
|
placeholder="https://instagram.com/p/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
createItem={(): NewsItem => ({
|
||||||
|
title: "",
|
||||||
|
text: "",
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
})}
|
||||||
|
addLabel="Добавить новость"
|
||||||
|
addPosition="top"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
638
src/app/admin/open-day/page.tsx
Normal file
@@ -0,0 +1,638 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw, Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import { ParticipantLimits, SelectField } from "../_components/FormField";
|
||||||
|
import { PriceField } from "../_components/PriceField";
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
interface OpenDayEvent {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
pricePerClass: number;
|
||||||
|
discountPrice: number;
|
||||||
|
discountThreshold: number;
|
||||||
|
minBookings: number;
|
||||||
|
maxParticipants: 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;
|
||||||
|
maxParticipants: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- 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="sm:max-w-xs">
|
||||||
|
<PriceField
|
||||||
|
label="Цена за занятие"
|
||||||
|
value={event.pricePerClass ? `${event.pricePerClass} BYN` : ""}
|
||||||
|
onChange={(v) => onChange({ pricePerClass: parseInt(v) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discount toggle + fields */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (event.discountPrice > 0) onChange({ discountPrice: 0, discountThreshold: 0 });
|
||||||
|
else onChange({ discountPrice: event.pricePerClass - 5, discountThreshold: 3 });
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${
|
||||||
|
event.discountPrice > 0
|
||||||
|
? "bg-gold/15 text-gold border border-gold/30"
|
||||||
|
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Sparkles size={14} />
|
||||||
|
{event.discountPrice > 0 ? "Скидка включена" : "Добавить скидку"}
|
||||||
|
</button>
|
||||||
|
{event.discountPrice > 0 && (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 mt-3">
|
||||||
|
<div>
|
||||||
|
<PriceField
|
||||||
|
label="Цена со скидкой"
|
||||||
|
value={event.discountPrice ? `${event.discountPrice} BYN` : ""}
|
||||||
|
onChange={(v) => onChange({ discountPrice: parseInt(v) || 0 })}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ParticipantLimits
|
||||||
|
min={event.minBookings}
|
||||||
|
max={event.maxParticipants ?? 0}
|
||||||
|
onMinChange={(v) => onChange({ minBookings: v })}
|
||||||
|
onMaxChange={(v) => onChange({ maxParticipants: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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.discountPrice > 0 && event.discountThreshold > 0 && `, от ${event.discountThreshold} — ${event.discountPrice} BYN`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- New Class Form (create only on save) ---
|
||||||
|
|
||||||
|
function NewClassForm({
|
||||||
|
startTime,
|
||||||
|
trainers,
|
||||||
|
styles,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
startTime: string;
|
||||||
|
trainers: string[];
|
||||||
|
styles: string[];
|
||||||
|
onSave: (data: { trainer: string; style: string; endTime: string }) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [style, setStyle] = useState("");
|
||||||
|
const [trainer, setTrainer] = useState("");
|
||||||
|
const endTime = addHour(startTime);
|
||||||
|
const formRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-save on click outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (formRef.current && !formRef.current.contains(e.target as Node)) {
|
||||||
|
if (style && trainer) onSave({ trainer, style, endTime });
|
||||||
|
else onCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [style, trainer, endTime, onSave, onCancel]);
|
||||||
|
|
||||||
|
const canSave = style && trainer;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={formRef} className="p-2 space-y-1.5 ring-1 ring-gold/30 rounded-lg">
|
||||||
|
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
|
||||||
|
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
|
<button onClick={onCancel} className="text-[10px] text-neutral-500 hover:text-white px-1">Отмена</button>
|
||||||
|
<button onClick={() => canSave && onSave({ trainer, style, endTime })} disabled={!canSave}
|
||||||
|
className="text-[10px] text-gold hover:text-gold-light px-1 font-medium disabled:opacity-30 disabled:cursor-not-allowed">OK</button>
|
||||||
|
</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 rounded-lg">
|
||||||
|
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
|
||||||
|
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
|
||||||
|
<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="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-white truncate">{cls.style}</span>
|
||||||
|
<span className="text-[10px] text-neutral-500">{cls.startTime}–{cls.endTime}</span>
|
||||||
|
</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 ${cls.cancelled ? "text-neutral-500 hover:text-emerald-400" : "text-neutral-500 hover:text-yellow-400"}`}
|
||||||
|
title={cls.cancelled ? "Восстановить" : "Отменить"}
|
||||||
|
>
|
||||||
|
{cls.cancelled ? <RotateCcw size={10} /> : <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 [selectedHall, setSelectedHall] = useState(halls[0] ?? "");
|
||||||
|
const timeSlots = generateTimeSlots(10, 22);
|
||||||
|
|
||||||
|
// Build lookup: time -> class for selected hall
|
||||||
|
const hallClasses = useMemo(() => {
|
||||||
|
const map: Record<string, OpenDayClass> = {};
|
||||||
|
for (const cls of classes) {
|
||||||
|
if (cls.hall === selectedHall) map[cls.startTime] = cls;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [classes, selectedHall]);
|
||||||
|
|
||||||
|
// Count classes per hall for the tab badges
|
||||||
|
const hallCounts = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const hall of halls) counts[hall] = 0;
|
||||||
|
for (const cls of classes) counts[cls.hall] = (counts[cls.hall] || 0) + 1;
|
||||||
|
return counts;
|
||||||
|
}, [classes, halls]);
|
||||||
|
|
||||||
|
const [creatingTime, setCreatingTime] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function confirmCreate(startTime: string, data: { trainer: string; style: string; endTime: string }) {
|
||||||
|
await adminFetch("/api/admin/open-day/classes", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }),
|
||||||
|
});
|
||||||
|
setCreatingTime(null);
|
||||||
|
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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Hall selector */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{halls.map((hall) => (
|
||||||
|
<button
|
||||||
|
key={hall}
|
||||||
|
onClick={() => setSelectedHall(hall)}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
selectedHall === hall
|
||||||
|
? "bg-gold/20 text-gold border border-gold/40"
|
||||||
|
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{hall}
|
||||||
|
{hallCounts[hall] > 0 && (
|
||||||
|
<span className={`ml-1.5 ${selectedHall === hall ? "text-gold/60" : "text-neutral-600"}`}>
|
||||||
|
{hallCounts[hall]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time slots for selected hall */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{timeSlots.map((time) => {
|
||||||
|
const cls = hallClasses[time];
|
||||||
|
return (
|
||||||
|
<div key={time} className="flex items-start gap-3 border-t border-white/5 py-1.5">
|
||||||
|
<span className="text-xs text-neutral-500 w-12 pt-1.5 shrink-0">{time}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
{cls ? (
|
||||||
|
<ClassCell
|
||||||
|
cls={cls}
|
||||||
|
minBookings={minBookings}
|
||||||
|
trainers={trainers}
|
||||||
|
styles={styles}
|
||||||
|
onUpdate={updateClass}
|
||||||
|
onDelete={deleteClass}
|
||||||
|
onCancel={cancelClass}
|
||||||
|
/>
|
||||||
|
) : creatingTime === time ? (
|
||||||
|
<NewClassForm
|
||||||
|
startTime={time}
|
||||||
|
trainers={trainers}
|
||||||
|
styles={styles}
|
||||||
|
onSave={(data) => confirmCreate(time, data)}
|
||||||
|
onCancel={() => setCreatingTime(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setCreatingTime(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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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 [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle");
|
||||||
|
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);
|
||||||
|
try {
|
||||||
|
const res = await adminFetch("/api/admin/open-day", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(updated),
|
||||||
|
});
|
||||||
|
setSaveStatus(res.ok ? "saved" : "error");
|
||||||
|
} catch {
|
||||||
|
setSaveStatus("error");
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
setTimeout(() => setSaveStatus("idle"), 2000);
|
||||||
|
}, 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,
|
||||||
|
maxParticipants: 0,
|
||||||
|
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">
|
||||||
|
{saveStatus !== "idle" && (
|
||||||
|
<div className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
||||||
|
saveStatus === "saved" ? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200" : "bg-red-950/90 border-red-500/30 text-red-200"
|
||||||
|
}`}>
|
||||||
|
{saveStatus === "saved" ? "Сохранено" : "Ошибка сохранения"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">День открытых дверей</h1>
|
||||||
|
</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)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Globe,
|
||||||
|
Sparkles,
|
||||||
|
FileText,
|
||||||
|
Users,
|
||||||
|
BookOpen,
|
||||||
|
Star,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
HelpCircle,
|
||||||
|
Newspaper,
|
||||||
|
Phone,
|
||||||
|
ClipboardList,
|
||||||
|
DoorOpen,
|
||||||
|
UserPlus,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
|
||||||
|
interface UnreadCounts {
|
||||||
|
groupBookings: number;
|
||||||
|
mcRegistrations: number;
|
||||||
|
openDayBookings: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARDS = [
|
||||||
|
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe, desc: "Заголовок и описание сайта" },
|
||||||
|
{ href: "/admin/hero", label: "Главный экран", icon: Sparkles, desc: "Заголовок, подзаголовок, кнопка" },
|
||||||
|
{ href: "/admin/about", label: "О студии", icon: FileText, desc: "Текст о студии" },
|
||||||
|
{ href: "/admin/team", label: "Команда", icon: Users, desc: "Тренеры и инструкторы" },
|
||||||
|
{ href: "/admin/classes", label: "Направления", icon: BookOpen, desc: "Типы занятий" },
|
||||||
|
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star, desc: "Мастер-классы и записи" },
|
||||||
|
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen, desc: "Открытые занятия, расписание, записи" },
|
||||||
|
{ href: "/admin/schedule", label: "Расписание", icon: Calendar, desc: "Расписание занятий" },
|
||||||
|
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList, desc: "Все записи и заявки" },
|
||||||
|
{ href: "/admin/pricing", label: "Цены", icon: DollarSign, desc: "Абонементы и аренда" },
|
||||||
|
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, desc: "Часто задаваемые вопросы" },
|
||||||
|
{ href: "/admin/news", label: "Новости", icon: Newspaper, desc: "Новости и анонсы" },
|
||||||
|
{ href: "/admin/contact", label: "Контакты", icon: Phone, desc: "Адреса, телефон, карта" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function UnreadWidget({ counts }: { counts: UnreadCounts }) {
|
||||||
|
if (counts.total === 0) return null;
|
||||||
|
|
||||||
|
const items: { label: string; count: number; tab: string }[] = [];
|
||||||
|
if (counts.groupBookings > 0) items.push({ label: "Занятия", count: counts.groupBookings, tab: "classes" });
|
||||||
|
if (counts.mcRegistrations > 0) items.push({ label: "Мастер-классы", count: counts.mcRegistrations, tab: "master-classes" });
|
||||||
|
if (counts.openDayBookings > 0) items.push({ label: "День открытых дверей", count: counts.openDayBookings, tab: "open-day" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/admin/bookings"
|
||||||
|
className="block rounded-xl border border-gold/20 bg-gold/[0.03] p-5 transition-all hover:border-gold/40 hover:bg-gold/[0.06]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-500/10 text-red-400">
|
||||||
|
<UserPlus size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-medium text-white">
|
||||||
|
Новые записи
|
||||||
|
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-red-500 text-white text-[11px] font-bold min-w-[20px] h-[20px] px-1.5">
|
||||||
|
{counts.total}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-neutral-400">Не подтверждённые заявки</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.tab} className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="rounded-full bg-gold/15 text-gold font-medium px-2 py-0.5">
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-400">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const [counts, setCounts] = useState<UnreadCounts | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch("/api/admin/unread-counts")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: UnreadCounts) => setCounts(data))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Панель управления</h1>
|
||||||
|
<p className="mt-1 text-neutral-400">Выберите раздел для редактирования</p>
|
||||||
|
|
||||||
|
{/* Unread bookings widget */}
|
||||||
|
{counts && counts.total > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<UnreadWidget counts={counts} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{CARDS.map((card) => {
|
||||||
|
const Icon = card.icon;
|
||||||
|
const isBookings = card.href === "/admin/bookings";
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={card.href}
|
||||||
|
href={card.href}
|
||||||
|
className="group rounded-xl border border-white/10 bg-neutral-900 p-5 transition-all hover:border-gold/30 hover:bg-neutral-900/80"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gold/10 text-gold">
|
||||||
|
<Icon size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="font-medium text-white group-hover:text-gold transition-colors flex items-center gap-2">
|
||||||
|
{card.label}
|
||||||
|
{isBookings && counts && counts.total > 0 && (
|
||||||
|
<span className="rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||||
|
{counts.total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-neutral-500">{card.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/app/admin/popups/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
|
import { TextareaField } from "../_components/FormField";
|
||||||
|
|
||||||
|
interface PopupsData {
|
||||||
|
successMessage: string;
|
||||||
|
waitingListText: string;
|
||||||
|
errorMessage: string;
|
||||||
|
instagramHint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PopupsEditorPage() {
|
||||||
|
return (
|
||||||
|
<SectionEditor<PopupsData>
|
||||||
|
sectionKey="popups"
|
||||||
|
title="Тексты всплывающих окон"
|
||||||
|
>
|
||||||
|
{(data, update) => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Эти тексты используются во всех формах записи: мастер-классы, день открытых дверей, групповые занятия.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<TextareaField
|
||||||
|
label="Успешная запись"
|
||||||
|
value={data.successMessage}
|
||||||
|
onChange={(v) => update({ ...data, successMessage: v })}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextareaField
|
||||||
|
label="Лист ожидания"
|
||||||
|
value={data.waitingListText}
|
||||||
|
onChange={(v) => update({ ...data, waitingListText: v })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextareaField
|
||||||
|
label="Ошибка при записи"
|
||||||
|
value={data.errorMessage}
|
||||||
|
onChange={(v) => update({ ...data, errorMessage: v })}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextareaField
|
||||||
|
label="Ссылка на Instagram (текст под сообщениями)"
|
||||||
|
value={data.instagramHint}
|
||||||
|
onChange={(v) => update({ ...data, instagramHint: v })}
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
src/app/admin/pricing/page.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronsUpDown } from "lucide-react";
|
||||||
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
|
import { InputField, SelectField } from "../_components/FormField";
|
||||||
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
|
import { CollapsibleSection } from "../_components/CollapsibleSection";
|
||||||
|
import { PriceField } from "../_components/PriceField";
|
||||||
|
|
||||||
|
interface PricingItem {
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
note?: string;
|
||||||
|
popular?: boolean;
|
||||||
|
featured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PricingData {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
items: PricingItem[];
|
||||||
|
rentalTitle: string;
|
||||||
|
rentalItems: { name: string; price: string; note?: string }[];
|
||||||
|
rules: string[];
|
||||||
|
showContactHint?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PricingContent({ data, update }: { data: PricingData; update: (d: PricingData) => void }) {
|
||||||
|
const [sections, setSections] = useState({ subscriptions: true, rental: true, rules: false });
|
||||||
|
const allOpen = sections.subscriptions && sections.rental && sections.rules;
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
const target = !allOpen;
|
||||||
|
setSections({ subscriptions: target, rental: target, rules: target });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSection(key: keyof typeof sections) {
|
||||||
|
setSections(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 flex-1">
|
||||||
|
<InputField
|
||||||
|
label="Заголовок секции"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(v) => update({ ...data, title: v })}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Подзаголовок"
|
||||||
|
value={data.subtitle}
|
||||||
|
onChange={(v) => update({ ...data, subtitle: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAll}
|
||||||
|
className="rounded p-1 text-neutral-500 hover:text-white transition-colors ml-3 mt-4"
|
||||||
|
title={allOpen ? "Свернуть все секции" : "Развернуть все секции"}
|
||||||
|
>
|
||||||
|
<ChevronsUpDown size={16} className={`transition-transform duration-200 ${allOpen ? "rotate-90" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Абонементы */}
|
||||||
|
<CollapsibleSection title="Абонементы" count={data.items.length} isOpen={sections.subscriptions} onToggle={() => toggleSection("subscriptions")}>
|
||||||
|
{(() => {
|
||||||
|
const itemOptions = data.items
|
||||||
|
.map((it, idx) => ({ value: String(idx), label: it.name }))
|
||||||
|
.filter((o) => o.label.trim() !== "");
|
||||||
|
const noneOption = { value: "", label: "— Нет —" };
|
||||||
|
const featuredIdx = data.items.findIndex((it) => it.featured);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectField
|
||||||
|
label="Выделенный абонемент (безлимит)"
|
||||||
|
value={featuredIdx >= 0 ? String(featuredIdx) : ""}
|
||||||
|
onChange={(v) => {
|
||||||
|
const items = data.items.map((it, idx) => ({
|
||||||
|
...it,
|
||||||
|
featured: v ? idx === Number(v) : false,
|
||||||
|
}));
|
||||||
|
update({ ...data, items });
|
||||||
|
}}
|
||||||
|
options={[noneOption, ...itemOptions]}
|
||||||
|
placeholder="Выберите..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<ArrayEditor
|
||||||
|
items={data.items}
|
||||||
|
onChange={(items) => update({ ...data, items })}
|
||||||
|
collapsible
|
||||||
|
getItemTitle={(item) => item.name || "Без названия"}
|
||||||
|
getItemBadge={(item) =>
|
||||||
|
item.popular ? (
|
||||||
|
<span className="shrink-0 rounded-full bg-gold/20 px-2 py-0.5 text-[10px] font-medium text-gold">
|
||||||
|
Популярный
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
renderItem={(item, _i, updateItem) => (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<InputField
|
||||||
|
label="Название"
|
||||||
|
value={item.name}
|
||||||
|
onChange={(v) => updateItem({ ...item, name: v })}
|
||||||
|
/>
|
||||||
|
<PriceField
|
||||||
|
label="Цена"
|
||||||
|
value={item.price}
|
||||||
|
onChange={(v) => updateItem({ ...item, price: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<InputField
|
||||||
|
label="Примечание"
|
||||||
|
value={item.note || ""}
|
||||||
|
onChange={(v) => updateItem({ ...item, note: v || undefined })}
|
||||||
|
placeholder="Например: 8 занятий, срок 30 дней"
|
||||||
|
/>
|
||||||
|
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={!!item.popular}
|
||||||
|
onClick={() => updateItem({ ...item, popular: !item.popular })}
|
||||||
|
className={`relative h-5 w-9 rounded-full transition-colors ${
|
||||||
|
item.popular ? "bg-gold" : "bg-neutral-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||||
|
item.popular ? "translate-x-4" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-neutral-400">Популярный</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
createItem={() => ({ name: "", price: "", note: "" })}
|
||||||
|
addLabel="Добавить абонемент"
|
||||||
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Аренда */}
|
||||||
|
<CollapsibleSection title="Аренда" count={data.rentalItems.length} isOpen={sections.rental} onToggle={() => toggleSection("rental")}>
|
||||||
|
<InputField
|
||||||
|
label="Заголовок"
|
||||||
|
value={data.rentalTitle}
|
||||||
|
onChange={(v) => update({ ...data, rentalTitle: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArrayEditor
|
||||||
|
items={data.rentalItems}
|
||||||
|
onChange={(rentalItems) => update({ ...data, rentalItems })}
|
||||||
|
collapsible
|
||||||
|
getItemTitle={(item) => item.name || "Без названия"}
|
||||||
|
renderItem={(item, _i, updateItem) => (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<InputField
|
||||||
|
label="Название"
|
||||||
|
value={item.name}
|
||||||
|
onChange={(v) => updateItem({ ...item, name: v })}
|
||||||
|
/>
|
||||||
|
<PriceField
|
||||||
|
label="Цена"
|
||||||
|
value={item.price}
|
||||||
|
onChange={(v) => updateItem({ ...item, price: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<InputField
|
||||||
|
label="Примечание"
|
||||||
|
value={item.note || ""}
|
||||||
|
onChange={(v) => updateItem({ ...item, note: v || undefined })}
|
||||||
|
placeholder="Например: за 1 час"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
createItem={() => ({ name: "", price: "", note: "" })}
|
||||||
|
addLabel="Добавить вариант аренды"
|
||||||
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Правила */}
|
||||||
|
<CollapsibleSection title="Правила" count={data.rules.length} isOpen={sections.rules} onToggle={() => toggleSection("rules")}>
|
||||||
|
<ArrayEditor
|
||||||
|
items={data.rules}
|
||||||
|
onChange={(rules) => update({ ...data, rules })}
|
||||||
|
renderItem={(rule, _i, updateItem) => (
|
||||||
|
<InputField label="Правило" value={rule} onChange={updateItem} />
|
||||||
|
)}
|
||||||
|
createItem={() => ""}
|
||||||
|
addLabel="Добавить правило"
|
||||||
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PricingEditorPage() {
|
||||||
|
return (
|
||||||
|
<SectionEditor<PricingData> sectionKey="pricing" title="Цены">
|
||||||
|
{(data, update) => <PricingContent data={data} update={update} />}
|
||||||
|
</SectionEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
1232
src/app/admin/schedule/page.tsx
Normal file
387
src/app/admin/team/[id]/page.tsx
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
|
||||||
|
import { InputField, TextareaField, VictoryListField, AutocompleteMulti } from "../../_components/FormField";
|
||||||
|
import { useToast } from "../../_components/Toast";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import type { RichListItem } from "@/types/content";
|
||||||
|
|
||||||
|
function extractUsername(value: string): string {
|
||||||
|
if (!value) return "";
|
||||||
|
// Strip full URL → username
|
||||||
|
const cleaned = value.replace(/^https?:\/\/(www\.)?instagram\.com\//, "").replace(/\/$/, "").replace(/^@/, "");
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemberForm {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
image: string;
|
||||||
|
instagram: string;
|
||||||
|
shortDescription: string;
|
||||||
|
description: string;
|
||||||
|
victories: RichListItem[];
|
||||||
|
education: RichListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
import { ToastProvider } from "../../_components/Toast";
|
||||||
|
|
||||||
|
export default function TeamMemberEditorPage() {
|
||||||
|
return <ToastProvider><TeamMemberEditor /></ToastProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamMemberEditor() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const isNew = id === "new";
|
||||||
|
|
||||||
|
const [data, setData] = useState<MemberForm>({
|
||||||
|
name: "",
|
||||||
|
role: "",
|
||||||
|
image: "/images/team/placeholder.webp",
|
||||||
|
instagram: "",
|
||||||
|
shortDescription: "",
|
||||||
|
description: "",
|
||||||
|
victories: [],
|
||||||
|
education: [],
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(!isNew);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
const [styles, setStyles] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Instagram validation
|
||||||
|
const [igStatus, setIgStatus] = useState<"idle" | "checking" | "valid" | "invalid">("idle");
|
||||||
|
const igTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const validateInstagram = useCallback((username: string) => {
|
||||||
|
if (igTimerRef.current) clearTimeout(igTimerRef.current);
|
||||||
|
if (!username) { setIgStatus("idle"); return; }
|
||||||
|
setIgStatus("checking");
|
||||||
|
igTimerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await adminFetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`);
|
||||||
|
const result = await res.json();
|
||||||
|
setIgStatus(result.valid ? "valid" : "invalid");
|
||||||
|
} catch {
|
||||||
|
setIgStatus("idle");
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Link validation for bio
|
||||||
|
const [linkErrors, setLinkErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Fetch class styles for role autocomplete
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch("/api/admin/sections/classes")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { items?: { name: string }[] }) => {
|
||||||
|
setStyles(data.items?.map((i) => i.name) ?? []);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew) return;
|
||||||
|
adminFetch(`/api/admin/team/${id}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((member) => {
|
||||||
|
const username = extractUsername(member.instagram || "");
|
||||||
|
const loaded = {
|
||||||
|
name: member.name,
|
||||||
|
role: member.role,
|
||||||
|
image: member.image,
|
||||||
|
instagram: username,
|
||||||
|
shortDescription: member.shortDescription || "",
|
||||||
|
description: member.description || "",
|
||||||
|
victories: member.victories || [],
|
||||||
|
education: member.education || [],
|
||||||
|
};
|
||||||
|
setData(loaded);
|
||||||
|
lastSavedRef.current = JSON.stringify(loaded);
|
||||||
|
if (username) setIgStatus("valid"); // existing data is trusted
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id, isNew]);
|
||||||
|
|
||||||
|
const hasErrors = igStatus === "invalid" || Object.keys(linkErrors).length > 0;
|
||||||
|
const lastSavedRef = useRef("");
|
||||||
|
const dataRef = useRef(data);
|
||||||
|
dataRef.current = data;
|
||||||
|
|
||||||
|
// Shared save logic — compares snapshot, skips if unchanged
|
||||||
|
const saveIfDirty = useCallback(async () => {
|
||||||
|
if (isNew || loading) return;
|
||||||
|
const d = dataRef.current;
|
||||||
|
const snapshot = JSON.stringify(d);
|
||||||
|
if (snapshot === lastSavedRef.current) return;
|
||||||
|
if (!d.name || !d.role) return;
|
||||||
|
lastSavedRef.current = snapshot;
|
||||||
|
const payload = { ...d, instagram: d.instagram ? `https://instagram.com/${d.instagram}` : "" };
|
||||||
|
const res = await adminFetch(`/api/admin/team/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (res.ok) showSuccess("Сохранено");
|
||||||
|
else showError("Ошибка сохранения");
|
||||||
|
}, [isNew, loading, id, showSuccess, showError]);
|
||||||
|
|
||||||
|
// Save when tab loses focus or user navigates away
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew || loading) return;
|
||||||
|
const onVisibilityChange = () => { if (document.hidden) saveIfDirty(); };
|
||||||
|
const onBeforeUnload = () => {
|
||||||
|
const d = dataRef.current;
|
||||||
|
const snapshot = JSON.stringify(d);
|
||||||
|
if (snapshot === lastSavedRef.current || !d.name || !d.role) return;
|
||||||
|
const payload = { ...d, instagram: d.instagram ? `https://instagram.com/${d.instagram}` : "" };
|
||||||
|
navigator.sendBeacon(`/api/admin/team/${id}`, JSON.stringify(payload));
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
window.addEventListener("beforeunload", onBeforeUnload);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||||
|
};
|
||||||
|
}, [isNew, loading, id, saveIfDirty]);
|
||||||
|
|
||||||
|
// Save on blur (when user leaves any field)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew || loading) return;
|
||||||
|
function handleBlur() {
|
||||||
|
setTimeout(() => saveIfDirty(), 300);
|
||||||
|
}
|
||||||
|
document.addEventListener("focusout", handleBlur);
|
||||||
|
return () => document.removeEventListener("focusout", handleBlur);
|
||||||
|
}, [isNew, loading, saveIfDirty]);
|
||||||
|
|
||||||
|
|
||||||
|
// Manual save for new members
|
||||||
|
async function handleSaveNew() {
|
||||||
|
if (hasErrors || !data.name || !data.role) return;
|
||||||
|
setSaving(true);
|
||||||
|
const payload = { ...data, instagram: data.instagram ? `https://instagram.com/${data.instagram}` : "" };
|
||||||
|
const res = await adminFetch("/api/admin/team", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (res.ok) router.push("/admin/team");
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("folder", "team");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await adminFetch("/api/admin/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.path) {
|
||||||
|
setData((prev) => ({ ...prev, image: result.path }));
|
||||||
|
setTimeout(saveIfDirty, 100);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Upload failed silently
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-8 w-48 bg-neutral-800 rounded-lg" />
|
||||||
|
<div className="flex gap-6 items-start">
|
||||||
|
<div className="w-[130px] shrink-0 aspect-[3/4] bg-neutral-800 rounded-xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-10 bg-neutral-800 rounded-lg" />
|
||||||
|
<div className="h-10 bg-neutral-800 rounded-lg" />
|
||||||
|
<div className="h-10 bg-neutral-800 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/admin/team")}
|
||||||
|
className="rounded-lg p-2 text-neutral-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
{isNew ? "Новый участник" : data.name}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{isNew ? (
|
||||||
|
<button
|
||||||
|
onClick={handleSaveNew}
|
||||||
|
disabled={saving || !data.name || !data.role || hasErrors || igStatus === "checking"}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||||
|
{saving ? "Сохранение..." : "Создать"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile header: photo + name/role/instagram */}
|
||||||
|
<div className="mt-6 flex gap-5 items-start">
|
||||||
|
<label className="relative shrink-0 block w-[150px] self-stretch overflow-hidden rounded-xl border border-white/10 cursor-pointer group">
|
||||||
|
<Image
|
||||||
|
src={data.image}
|
||||||
|
alt={data.name || "Фото"}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="150px"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1">
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 size={20} className="animate-spin text-white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload size={20} className="text-white" />
|
||||||
|
<span className="text-[11px] text-white/80">Изменить</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<InputField
|
||||||
|
label="Имя"
|
||||||
|
value={data.name.split(" ")[0] || ""}
|
||||||
|
onChange={(v) => {
|
||||||
|
const last = data.name.split(" ").slice(1).join(" ");
|
||||||
|
setData({ ...data, name: last ? `${v} ${last}` : v });
|
||||||
|
}}
|
||||||
|
placeholder="Анна"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Фамилия"
|
||||||
|
value={data.name.split(" ").slice(1).join(" ") || ""}
|
||||||
|
onChange={(v) => {
|
||||||
|
const first = data.name.split(" ")[0] || "";
|
||||||
|
setData({ ...data, name: v ? `${first} ${v}` : first });
|
||||||
|
}}
|
||||||
|
placeholder="Тарыба"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AutocompleteMulti
|
||||||
|
label="Роль / Специализация"
|
||||||
|
value={data.role}
|
||||||
|
onChange={(v) => setData({ ...data, role: v })}
|
||||||
|
options={styles}
|
||||||
|
placeholder="Добавить стиль..."
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Instagram</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-500 text-sm select-none">@</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.instagram}
|
||||||
|
onChange={(e) => {
|
||||||
|
const username = extractUsername(e.target.value);
|
||||||
|
setData({ ...data, instagram: username });
|
||||||
|
validateInstagram(username);
|
||||||
|
}}
|
||||||
|
placeholder="username"
|
||||||
|
className={`w-full rounded-lg border bg-neutral-800 pl-8 pr-10 py-2.5 text-white placeholder-neutral-500 outline-none hover:border-gold/30 transition-colors ${
|
||||||
|
igStatus === "invalid"
|
||||||
|
? "border-red-500 focus:border-red-500"
|
||||||
|
: igStatus === "valid"
|
||||||
|
? "border-green-500/50 focus:border-green-500"
|
||||||
|
: "border-white/10 focus:border-gold"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
{igStatus === "checking" && <Loader2 size={14} className="animate-spin text-neutral-400" />}
|
||||||
|
{igStatus === "valid" && <Check size={14} className="text-green-400" />}
|
||||||
|
{igStatus === "invalid" && <AlertCircle size={14} className="text-red-400" />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{igStatus === "invalid" && (
|
||||||
|
<p className="mt-1 text-xs text-red-400">Аккаунт не найден</p>
|
||||||
|
)}
|
||||||
|
{data.instagram && igStatus !== "invalid" && (
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">instagram.com/{data.instagram}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full-width fields */}
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
<TextareaField
|
||||||
|
label="Краткое описание (для карточки)"
|
||||||
|
value={data.shortDescription}
|
||||||
|
onChange={(v) => setData({ ...data, shortDescription: v })}
|
||||||
|
rows={2}
|
||||||
|
placeholder="1-2 предложения для карусели"
|
||||||
|
/>
|
||||||
|
<TextareaField
|
||||||
|
label="Полное описание (для страницы тренера)"
|
||||||
|
value={data.description}
|
||||||
|
onChange={(v) => setData({ ...data, description: v })}
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="border-t border-white/5 pt-4 mt-4">
|
||||||
|
<p className="text-sm font-medium text-neutral-300 mb-4">Биография</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<VictoryListField
|
||||||
|
label="Достижения"
|
||||||
|
items={data.victories}
|
||||||
|
onChange={(items) => setData({ ...data, victories: items })}
|
||||||
|
placeholder="Например: 1 место, Чемпионат Беларуси 2024"
|
||||||
|
onLinkValidate={(key, error) => {
|
||||||
|
setLinkErrors((prev) => {
|
||||||
|
if (error) return { ...prev, [key]: error };
|
||||||
|
const n = { ...prev }; delete n[key]; return n;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onUploadComplete={() => setTimeout(saveIfDirty, 100)}
|
||||||
|
/>
|
||||||
|
<VictoryListField
|
||||||
|
label="Образование"
|
||||||
|
items={data.education}
|
||||||
|
onChange={(items) => setData({ ...data, education: items })}
|
||||||
|
placeholder="Например: Сертификат IPSF"
|
||||||
|
onLinkValidate={(key, error) => {
|
||||||
|
setLinkErrors((prev) => {
|
||||||
|
if (error) return { ...prev, [key]: error };
|
||||||
|
const n = { ...prev }; delete n[key]; return n;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onUploadComplete={() => setTimeout(saveIfDirty, 100)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
343
src/app/admin/team/page.tsx
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
GripVertical,
|
||||||
|
Check,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import type { TeamMember } from "@/types/content";
|
||||||
|
|
||||||
|
type Member = TeamMember & { id: number };
|
||||||
|
|
||||||
|
export default function TeamEditorPage() {
|
||||||
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
|
const [insertAt, setInsertAt] = useState<number | null>(null);
|
||||||
|
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||||
|
const [dragSize, setDragSize] = useState({ w: 0, h: 0 });
|
||||||
|
const [grabOffset, setGrabOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch("/api/admin/team")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setMembers)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveOrder = useCallback(async (updated: Member[]) => {
|
||||||
|
setMembers(updated);
|
||||||
|
setSaving(true);
|
||||||
|
await adminFetch("/api/admin/team/reorder", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startDrag = useCallback(
|
||||||
|
(clientX: number, clientY: number, index: number) => {
|
||||||
|
const el = itemRefs.current[index];
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
setDragIndex(index);
|
||||||
|
setInsertAt(index);
|
||||||
|
setMousePos({ x: clientX, y: clientY });
|
||||||
|
setDragSize({ w: rect.width, h: rect.height });
|
||||||
|
setGrabOffset({ x: clientX - rect.left, y: clientY - rect.top });
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGripMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startDrag(e.clientX, e.clientY, index);
|
||||||
|
},
|
||||||
|
[startDrag]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, index: number) => {
|
||||||
|
const tag = (e.target as HTMLElement).closest("input, textarea, select, button, a, [role='switch']");
|
||||||
|
if (tag) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const x = e.clientX;
|
||||||
|
const y = e.clientY;
|
||||||
|
const pendingIndex = index;
|
||||||
|
let moved = false;
|
||||||
|
|
||||||
|
function onMove(ev: MouseEvent) {
|
||||||
|
const dx = ev.clientX - x;
|
||||||
|
const dy = ev.clientY - y;
|
||||||
|
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
|
||||||
|
moved = true;
|
||||||
|
cleanup();
|
||||||
|
startDrag(ev.clientX, ev.clientY, pendingIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
cleanup();
|
||||||
|
if (!moved) {
|
||||||
|
window.location.href = `/admin/team/${members[pendingIndex].id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function cleanup() {
|
||||||
|
window.removeEventListener("mousemove", onMove);
|
||||||
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
},
|
||||||
|
[startDrag, members]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dragIndex === null) return;
|
||||||
|
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
setMousePos({ x: e.clientX, y: e.clientY });
|
||||||
|
|
||||||
|
let newInsert = members.length;
|
||||||
|
for (let i = 0; i < members.length; i++) {
|
||||||
|
if (i === dragIndex) continue;
|
||||||
|
const el = itemRefs.current[i];
|
||||||
|
if (!el) continue;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const midY = rect.top + rect.height / 2;
|
||||||
|
if (e.clientY < midY) {
|
||||||
|
newInsert = i > dragIndex! ? i : i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInsertAt(newInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
setDragIndex((prevDrag) => {
|
||||||
|
setInsertAt((prevInsert) => {
|
||||||
|
if (prevDrag !== null && prevInsert !== null) {
|
||||||
|
let targetIndex = prevInsert;
|
||||||
|
if (prevDrag < targetIndex) targetIndex -= 1;
|
||||||
|
if (prevDrag !== targetIndex) {
|
||||||
|
const updated = [...members];
|
||||||
|
const [moved] = updated.splice(prevDrag, 1);
|
||||||
|
updated.splice(targetIndex, 0, moved);
|
||||||
|
saveOrder(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
|
window.addEventListener("mouseup", onMouseUp);
|
||||||
|
return () => {
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
}, [dragIndex, members, saveOrder]);
|
||||||
|
|
||||||
|
async function deleteMember(id: number) {
|
||||||
|
if (!confirm("Удалить этого участника?")) return;
|
||||||
|
await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
|
||||||
|
setMembers((prev) => prev.filter((m) => m.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-neutral-400">
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const draggedMember = dragIndex !== null ? members[dragIndex] : null;
|
||||||
|
|
||||||
|
// Build the visual order: remove dragged item, insert placeholder at insertAt
|
||||||
|
function renderList() {
|
||||||
|
if (dragIndex === null || insertAt === null) {
|
||||||
|
// Normal render — no drag
|
||||||
|
return members.map((member, i) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
|
onMouseDown={(e) => handleCardMouseDown(e, i)}
|
||||||
|
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||||
|
>
|
||||||
|
<GripVertical size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
|
||||||
|
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="48px" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-white truncate">{member.name}</p>
|
||||||
|
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// During drag: build list without the dragged item, with placeholder inserted
|
||||||
|
const elements: React.ReactNode[] = [];
|
||||||
|
let visualIndex = 0;
|
||||||
|
|
||||||
|
// Determine where to insert placeholder relative to non-dragged items
|
||||||
|
let placeholderPos = insertAt;
|
||||||
|
if (insertAt > dragIndex) placeholderPos = insertAt - 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < members.length; i++) {
|
||||||
|
if (i === dragIndex) {
|
||||||
|
// Keep a hidden ref so midpoint detection still works
|
||||||
|
elements.push(
|
||||||
|
<div key={`hidden-${members[i].id}`} ref={(el) => { itemRefs.current[i] = el; }} className="hidden" />
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visualIndex === placeholderPos) {
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key="placeholder"
|
||||||
|
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-2"
|
||||||
|
style={{ height: dragSize.h }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = members[i];
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
|
onMouseDown={(e) => handleCardMouseDown(e, i)}
|
||||||
|
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||||
|
>
|
||||||
|
<GripVertical size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
|
||||||
|
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="48px" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-white truncate">{member.name}</p>
|
||||||
|
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
visualIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder at the end
|
||||||
|
if (visualIndex === placeholderPos) {
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key="placeholder"
|
||||||
|
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-2"
|
||||||
|
style={{ height: dragSize.h }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h1 className="text-2xl font-bold">Команда</h1>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{(saving || saved) && (
|
||||||
|
<span className="text-sm text-neutral-400 flex items-center gap-1">
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check size={14} className="text-green-400" />
|
||||||
|
)}
|
||||||
|
{saving ? "Сохранение..." : "Сохранено!"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href="/admin/team/new"
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Добавить
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{renderList()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating card following cursor */}
|
||||||
|
{dragIndex !== null &&
|
||||||
|
draggedMember &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[9999] pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: mousePos.x - grabOffset.x,
|
||||||
|
top: mousePos.y - grabOffset.y,
|
||||||
|
width: dragSize.w,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 rounded-lg border-2 border-rose-500 bg-neutral-900 p-3 shadow-2xl shadow-rose-500/20">
|
||||||
|
<div className="text-rose-400">
|
||||||
|
<GripVertical size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
|
||||||
|
<Image
|
||||||
|
src={draggedMember.image}
|
||||||
|
alt={draggedMember.name}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="48px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-white truncate">{draggedMember.name}</p>
|
||||||
|
<p className="text-sm text-neutral-400 truncate">{draggedMember.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/app/api/admin/bookings/search/route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getDb } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const q = request.nextUrl.searchParams.get("q")?.trim();
|
||||||
|
if (!q || q.length < 2) {
|
||||||
|
return NextResponse.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const like = `%${q}%`;
|
||||||
|
|
||||||
|
const groupRows = db.prepare(
|
||||||
|
"SELECT id, name, phone, instagram, telegram, status, notes, created_at, group_info FROM group_bookings WHERE name LIKE ? OR phone LIKE ? ORDER BY created_at DESC LIMIT 20"
|
||||||
|
).all(like, like) as { id: number; name: string; phone: string; instagram: string | null; telegram: string | null; status: string; notes: string | null; created_at: string; group_info: string | null }[];
|
||||||
|
|
||||||
|
const mcRows = db.prepare(
|
||||||
|
"SELECT id, name, phone, instagram, telegram, status, notes, created_at, master_class_title FROM mc_registrations WHERE name LIKE ? OR phone LIKE ? ORDER BY created_at DESC LIMIT 20"
|
||||||
|
).all(like, like) as { id: number; name: string; phone: string | null; instagram: string; telegram: string | null; status: string; notes: string | null; created_at: string; master_class_title: string }[];
|
||||||
|
|
||||||
|
const odRows = db.prepare(
|
||||||
|
"SELECT id, name, phone, instagram, telegram, status, notes, created_at FROM open_day_bookings WHERE name LIKE ? OR phone LIKE ? ORDER BY created_at DESC LIMIT 20"
|
||||||
|
).all(like, like) as { id: number; name: string; phone: string; instagram: string | null; telegram: string | null; status: string; notes: string | null; created_at: string }[];
|
||||||
|
|
||||||
|
const results = [
|
||||||
|
...groupRows.map((r) => ({
|
||||||
|
type: "class" as const,
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
phone: r.phone,
|
||||||
|
instagram: r.instagram ?? undefined,
|
||||||
|
telegram: r.telegram ?? undefined,
|
||||||
|
status: r.status || "new",
|
||||||
|
notes: r.notes ?? undefined,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
groupLabel: r.group_info ?? undefined,
|
||||||
|
})),
|
||||||
|
...mcRows.map((r) => ({
|
||||||
|
type: "mc" as const,
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
phone: r.phone ?? undefined,
|
||||||
|
instagram: r.instagram ?? undefined,
|
||||||
|
telegram: r.telegram ?? undefined,
|
||||||
|
status: r.status || "new",
|
||||||
|
notes: r.notes ?? undefined,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
groupLabel: r.master_class_title,
|
||||||
|
})),
|
||||||
|
...odRows.map((r) => ({
|
||||||
|
type: "open-day" as const,
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
phone: r.phone,
|
||||||
|
instagram: r.instagram ?? undefined,
|
||||||
|
telegram: r.telegram ?? undefined,
|
||||||
|
status: r.status || "new",
|
||||||
|
notes: r.notes ?? undefined,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
return NextResponse.json(results.slice(0, 50));
|
||||||
|
}
|
||||||
75
src/app/api/admin/group-bookings/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getGroupBookings, addGroupBooking, toggleGroupBookingNotification, deleteGroupBooking, setGroupBookingStatus, updateBookingNotes } from "@/lib/db";
|
||||||
|
import type { BookingStatus } from "@/lib/db";
|
||||||
|
import { sanitizeText } from "@/lib/validation";
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
if (body.action === "set-notes") {
|
||||||
|
const { id, notes } = body;
|
||||||
|
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
updateBookingNotes("group_bookings", id, sanitizeText(notes, 1000) ?? "");
|
||||||
|
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 POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, phone, instagram, telegram } = body;
|
||||||
|
if (!name?.trim() || !phone?.trim()) {
|
||||||
|
return NextResponse.json({ error: "name and phone are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const id = addGroupBooking(name.trim(), phone.trim(), undefined, instagram?.trim() || undefined, telegram?.trim() || undefined);
|
||||||
|
return NextResponse.json({ ok: true, id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/group-bookings] POST 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 });
|
||||||
|
}
|
||||||
89
src/app/api/admin/mc-registrations/route.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration, setMcRegistrationStatus, updateBookingNotes } from "@/lib/db";
|
||||||
|
import { sanitizeText } from "@/lib/validation";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const title = request.nextUrl.searchParams.get("title");
|
||||||
|
if (title) {
|
||||||
|
return NextResponse.json(getMcRegistrations(title));
|
||||||
|
}
|
||||||
|
// No title = return all registrations
|
||||||
|
return NextResponse.json(getAllMcRegistrations());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { masterClassTitle, name, instagram, telegram } = body;
|
||||||
|
if (!masterClassTitle || !name || !instagram) {
|
||||||
|
return NextResponse.json({ error: "masterClassTitle, name, instagram are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const id = addMcRegistration(masterClassTitle.trim(), name.trim(), instagram.trim(), telegram?.trim() || undefined);
|
||||||
|
return NextResponse.json({ ok: true, id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/mc-registrations] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Set booking status
|
||||||
|
if (body.action === "set-status") {
|
||||||
|
const { id, status } = body;
|
||||||
|
if (!id || !status) return NextResponse.json({ error: "id, status required" }, { status: 400 });
|
||||||
|
if (!["new", "contacted", "confirmed", "declined"].includes(status)) {
|
||||||
|
return NextResponse.json({ error: "Invalid status" }, { status: 400 });
|
||||||
|
}
|
||||||
|
setMcRegistrationStatus(id, status);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set notes
|
||||||
|
if (body.action === "set-notes") {
|
||||||
|
const { id, notes } = body;
|
||||||
|
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
updateBookingNotes("mc_registrations", id, sanitizeText(notes, 1000) ?? "");
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle notification status
|
||||||
|
if (body.action === "toggle-notify") {
|
||||||
|
const { id, field, value } = body;
|
||||||
|
if (!id || !field || typeof value !== "boolean") {
|
||||||
|
return NextResponse.json({ error: "id, field, value are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (field !== "notified_confirm" && field !== "notified_reminder") {
|
||||||
|
return NextResponse.json({ error: "Invalid field" }, { status: 400 });
|
||||||
|
}
|
||||||
|
toggleMcNotification(id, field, value);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular update
|
||||||
|
const { id, name, instagram, telegram } = body;
|
||||||
|
if (!id || !name || !instagram) {
|
||||||
|
return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
updateMcRegistration(id, name.trim(), instagram.trim(), telegram?.trim() || undefined);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/mc-registrations] 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 });
|
||||||
|
}
|
||||||
|
deleteMcRegistration(id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
62
src/app/api/admin/open-day/bookings/route.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
getOpenDayBookings,
|
||||||
|
toggleOpenDayNotification,
|
||||||
|
deleteOpenDayBooking,
|
||||||
|
setOpenDayBookingStatus,
|
||||||
|
updateBookingNotes,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { sanitizeText } from "@/lib/validation";
|
||||||
|
|
||||||
|
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 === "set-status") {
|
||||||
|
const { id, status } = body;
|
||||||
|
if (!id || !status) return NextResponse.json({ error: "id, status required" }, { status: 400 });
|
||||||
|
if (!["new", "contacted", "confirmed", "declined"].includes(status)) {
|
||||||
|
return NextResponse.json({ error: "Invalid status" }, { status: 400 });
|
||||||
|
}
|
||||||
|
setOpenDayBookingStatus(id, status);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
if (body.action === "set-notes") {
|
||||||
|
const { id, notes } = body;
|
||||||
|
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
updateBookingNotes("open_day_bookings", id, sanitizeText(notes, 1000) ?? "");
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
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
@@ -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
@@ -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
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/admin/sections/[key]/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getSection, setSection, SECTION_KEYS } from "@/lib/db";
|
||||||
|
import { siteContent } from "@/data/content";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { invalidateContentCache } from "@/lib/content";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ key: string }> };
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
|
const { key } = await params;
|
||||||
|
if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) {
|
||||||
|
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = getSection(key);
|
||||||
|
if (!data) {
|
||||||
|
// Auto-seed from fallback content if section doesn't exist yet
|
||||||
|
const fallback = (siteContent as unknown as Record<string, unknown>)[key];
|
||||||
|
if (fallback) {
|
||||||
|
setSection(key, fallback);
|
||||||
|
data = fallback;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: "Section not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=60" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: Params) {
|
||||||
|
const { key } = await params;
|
||||||
|
if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) {
|
||||||
|
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await request.json();
|
||||||
|
setSection(key, data);
|
||||||
|
invalidateContentCache();
|
||||||
|
revalidatePath("/");
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
46
src/app/api/admin/team/[id]/route.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getTeamMember, updateTeamMember, deleteTeamMember } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const { id } = await params;
|
||||||
|
const numId = parseId(id);
|
||||||
|
if (!numId) {
|
||||||
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const member = getTeamMember(numId);
|
||||||
|
if (!member) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
const numId = parseId(id);
|
||||||
|
if (!numId) {
|
||||||
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const data = await request.json();
|
||||||
|
updateTeamMember(numId, data);
|
||||||
|
revalidatePath("/");
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
const numId = parseId(id);
|
||||||
|
if (!numId) {
|
||||||
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
deleteTeamMember(numId);
|
||||||
|
revalidatePath("/");
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
16
src/app/api/admin/team/reorder/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { reorderTeamMembers } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
const { ids } = await request.json() as { ids: number[] };
|
||||||
|
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100 || !ids.every((id) => Number.isInteger(id) && id > 0)) {
|
||||||
|
return NextResponse.json({ error: "ids must be a non-empty array of positive integers (max 100)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
reorderTeamMembers(ids);
|
||||||
|
revalidatePath("/");
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
35
src/app/api/admin/team/route.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getTeamMembers, createTeamMember } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import type { RichListItem } from "@/types/content";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const members = getTeamMembers();
|
||||||
|
return NextResponse.json(members, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=60" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const data = await request.json() as {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
image: string;
|
||||||
|
instagram?: string;
|
||||||
|
description?: string;
|
||||||
|
victories?: RichListItem[];
|
||||||
|
education?: RichListItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.name || !data.role || !data.image) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "name, role, and image are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = createTeamMember(data);
|
||||||
|
revalidatePath("/");
|
||||||
|
|
||||||
|
return NextResponse.json({ id }, { status: 201 });
|
||||||
|
}
|
||||||
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, no-cache" },
|
||||||
|
});
|
||||||
|
}
|
||||||
70
src/app/api/admin/upload/route.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { writeFile, mkdir } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
|
||||||
|
const VIDEO_TYPES = ["video/mp4", "video/webm"];
|
||||||
|
const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"];
|
||||||
|
const VIDEO_EXTENSIONS = [".mp4", ".webm"];
|
||||||
|
const IMAGE_FOLDERS = ["team", "master-classes", "news", "classes"];
|
||||||
|
const VIDEO_FOLDERS = ["hero"];
|
||||||
|
const ALL_FOLDERS = [...IMAGE_FOLDERS, ...VIDEO_FOLDERS];
|
||||||
|
const IMAGE_MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
const VIDEO_MAX_SIZE = 50 * 1024 * 1024; // 50MB
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
const rawFolder = (formData.get("folder") as string) || "team";
|
||||||
|
const folder = ALL_FOLDERS.includes(rawFolder) ? rawFolder : "team";
|
||||||
|
const isVideoFolder = VIDEO_FOLDERS.includes(folder);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedTypes = isVideoFolder ? VIDEO_TYPES : IMAGE_TYPES;
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
const msg = isVideoFolder
|
||||||
|
? "Only MP4 and WebM videos are allowed"
|
||||||
|
: "Only JPEG, PNG, WebP, and AVIF are allowed";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize = isVideoFolder ? VIDEO_MAX_SIZE : IMAGE_MAX_SIZE;
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
const label = isVideoFolder ? "50MB" : "5MB";
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `File too large (max ${label})` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and sanitize filename
|
||||||
|
const ext = path.extname(file.name).toLowerCase() || (isVideoFolder ? ".mp4" : ".webp");
|
||||||
|
const allowedExts = isVideoFolder ? VIDEO_EXTENSIONS : IMAGE_EXTENSIONS;
|
||||||
|
if (!allowedExts.includes(ext)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid file extension" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const baseName = file.name
|
||||||
|
.replace(ext, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9а-яё-]/gi, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.slice(0, 50);
|
||||||
|
const fileName = `${baseName}-${Date.now()}${ext}`;
|
||||||
|
|
||||||
|
const subDir = isVideoFolder ? path.join("video") : path.join("images", folder);
|
||||||
|
const dir = path.join(process.cwd(), "public", subDir);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const filePath = path.join(dir, fileName);
|
||||||
|
await writeFile(filePath, buffer);
|
||||||
|
|
||||||
|
const publicPath = `/${subDir.replace(/\\/g, "/")}/${fileName}`;
|
||||||
|
return NextResponse.json({ path: publicPath });
|
||||||
|
}
|
||||||
28
src/app/api/admin/validate-instagram/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const username = request.nextUrl.searchParams.get("username")?.trim();
|
||||||
|
if (!username) {
|
||||||
|
return NextResponse.json({ valid: false, error: "No username" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://www.instagram.com/${username}/`, {
|
||||||
|
method: "HEAD",
|
||||||
|
redirect: "follow",
|
||||||
|
headers: {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instagram returns 200 for existing profiles, 404 for non-existing
|
||||||
|
const valid = res.ok;
|
||||||
|
return NextResponse.json({ valid });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/validate-instagram] error:", err);
|
||||||
|
// Network error or timeout — don't block the user
|
||||||
|
return NextResponse.json({ valid: true, uncertain: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { verifyPassword, signToken, generateCsrfToken, COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const body = await request.json() as { password?: string };
|
||||||
|
|
||||||
|
if (!body.password || !verifyPassword(body.password)) {
|
||||||
|
return NextResponse.json({ error: "Неверный пароль" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = signToken();
|
||||||
|
const csrfToken = generateCsrfToken();
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
|
||||||
|
response.cookies.set(COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
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;
|
||||||
|
}
|
||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/api/logout/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
response.cookies.set(COOKIE_NAME, "", {
|
||||||
|
httpOnly: true,
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
response.cookies.set(CSRF_COOKIE_NAME, "", {
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
59
src/app/api/master-class-register/route.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { addMcRegistration, getMcRegistrations, getSection } from "@/lib/db";
|
||||||
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
|
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||||
|
import type { MasterClassItem } from "@/types/content";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 5, 60_000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Слишком много запросов. Попробуйте через минуту." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { masterClassTitle, name, phone, instagram, telegram } = body;
|
||||||
|
|
||||||
|
const cleanTitle = sanitizeText(masterClassTitle, 200);
|
||||||
|
if (!cleanTitle) {
|
||||||
|
return NextResponse.json({ error: "masterClassTitle is 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if MC is full — if so, booking goes to waiting list
|
||||||
|
const mcSection = getSection("masterClasses") as { items?: MasterClassItem[] } | null;
|
||||||
|
const mcItem = mcSection?.items?.find((mc) => mc.title === cleanTitle);
|
||||||
|
let isWaiting = false;
|
||||||
|
if (mcItem?.maxParticipants && mcItem.maxParticipants > 0) {
|
||||||
|
const currentRegs = getMcRegistrations(cleanTitle);
|
||||||
|
const confirmedCount = currentRegs.filter((r) => r.status === "confirmed").length;
|
||||||
|
isWaiting = confirmedCount >= mcItem.maxParticipants;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = addMcRegistration(
|
||||||
|
cleanTitle,
|
||||||
|
cleanName,
|
||||||
|
sanitizeHandle(instagram) ?? "",
|
||||||
|
sanitizeHandle(telegram),
|
||||||
|
cleanPhone,
|
||||||
|
isWaiting ? "Лист ожидания" : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, id, isWaiting });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[master-class-register] POST error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/api/open-day-register/route.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
addOpenDayBooking,
|
||||||
|
getPersonOpenDayBookings,
|
||||||
|
getOpenDayEvent,
|
||||||
|
getOpenDayClassById,
|
||||||
|
} 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if class is full (event-level max, total bookings) — if so, booking goes to waiting list
|
||||||
|
const cls = getOpenDayClassById(classId);
|
||||||
|
const event = getOpenDayEvent(eventId);
|
||||||
|
const maxP = event?.maxParticipants ?? 0;
|
||||||
|
const isWaiting = maxP > 0 && cls ? cls.bookingCount >= maxP : false;
|
||||||
|
|
||||||
|
const id = addOpenDayBooking(classId, eventId, {
|
||||||
|
name: cleanName,
|
||||||
|
phone: cleanPhone,
|
||||||
|
instagram: sanitizeHandle(instagram),
|
||||||
|
telegram: sanitizeHandle(telegram),
|
||||||
|
notes: isWaiting ? "Лист ожидания" : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return total bookings for this person (for discount calculation)
|
||||||
|
const totalBookings = getPersonOpenDayBookings(eventId, cleanPhone);
|
||||||
|
const pricePerClass = event && totalBookings >= event.discountThreshold
|
||||||
|
? event.discountPrice
|
||||||
|
: event?.pricePerClass ?? 30;
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, id, totalBookings, pricePerClass, isWaiting });
|
||||||
|
} 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,15 @@
|
|||||||
--color-gold: #c9a96e;
|
--color-gold: #c9a96e;
|
||||||
--color-gold-light: #d4b87a;
|
--color-gold-light: #d4b87a;
|
||||||
--color-gold-dark: #a08050;
|
--color-gold-dark: #a08050;
|
||||||
|
--color-surface-deep: #050505;
|
||||||
|
--color-surface-dark: #0a0a0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Base ===== */
|
/* ===== Base ===== */
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -46,5 +49,76 @@ body {
|
|||||||
/* ===== Focus ===== */
|
/* ===== Focus ===== */
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
@apply outline-2 outline-offset-2 outline-[#c9a96e];
|
@apply outline-2 outline-offset-2 outline-gold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Scrollbar hide utility ===== */
|
||||||
|
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Admin dark scrollbar ===== */
|
||||||
|
|
||||||
|
.admin-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Hide number input spinners ===== */
|
||||||
|
|
||||||
|
input[type="number"]::-webkit-inner-spin-button,
|
||||||
|
input[type="number"]::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Global page scrollbar ===== */
|
||||||
|
|
||||||
|
html {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(201, 169, 110, 0.3) var(--color-surface-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-surface-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(201, 169, 110, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(201, 169, 110, 0.5);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter, Oswald } from "next/font/google";
|
import { Inter, Oswald } from "next/font/google";
|
||||||
import { Header } from "@/components/layout/Header";
|
import { getContent } from "@/lib/content";
|
||||||
import { Footer } from "@/components/layout/Footer";
|
|
||||||
import { siteContent } from "@/data/content";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -15,30 +13,31 @@ const oswald = Oswald({
|
|||||||
subsets: ["latin", "cyrillic"],
|
subsets: ["latin", "cyrillic"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export function generateMetadata(): Metadata {
|
||||||
title: siteContent.meta.title,
|
const { meta } = getContent();
|
||||||
description: siteContent.meta.description,
|
return {
|
||||||
openGraph: {
|
title: meta.title,
|
||||||
title: "BLACK HEART DANCE HOUSE",
|
description: meta.description,
|
||||||
description: siteContent.meta.description,
|
openGraph: {
|
||||||
locale: "ru_RU",
|
title: meta.title,
|
||||||
type: "website",
|
description: meta.description,
|
||||||
},
|
locale: "ru_RU",
|
||||||
};
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="ru" className="dark">
|
<html lang="ru" className="dark">
|
||||||
<body
|
<body
|
||||||
className={`${inter.variable} ${oswald.variable} bg-[#050505] text-neutral-50 font-sans antialiased`}
|
className={`${inter.variable} ${oswald.variable} surface-base font-sans antialiased`}
|
||||||
>
|
>
|
||||||
<Header />
|
{children}
|
||||||
<main>{children}</main>
|
|
||||||
<Footer />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Header } from "@/components/layout/Header";
|
||||||
|
import { Footer } from "@/components/layout/Footer";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 text-center">
|
<>
|
||||||
<h1 className="font-display text-6xl font-bold">404</h1>
|
<Header />
|
||||||
<p className="body-text mt-4 text-lg">Страница не найдена</p>
|
<main>
|
||||||
<div className="mt-8">
|
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 text-center">
|
||||||
<Button href="/">На главную</Button>
|
<h1 className="font-display text-6xl font-bold">404</h1>
|
||||||
</div>
|
<p className="body-text mt-4 text-lg">Страница не найдена</p>
|
||||||
</div>
|
<div className="mt-8">
|
||||||
|
<Button href="/">На главную</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,60 @@ import { Hero } from "@/components/sections/Hero";
|
|||||||
import { Team } from "@/components/sections/Team";
|
import { Team } from "@/components/sections/Team";
|
||||||
import { About } from "@/components/sections/About";
|
import { About } from "@/components/sections/About";
|
||||||
import { Classes } from "@/components/sections/Classes";
|
import { Classes } from "@/components/sections/Classes";
|
||||||
|
import { MasterClasses } from "@/components/sections/MasterClasses";
|
||||||
|
import { Schedule } from "@/components/sections/Schedule";
|
||||||
import { Pricing } from "@/components/sections/Pricing";
|
import { Pricing } from "@/components/sections/Pricing";
|
||||||
|
import { News } from "@/components/sections/News";
|
||||||
import { FAQ } from "@/components/sections/FAQ";
|
import { FAQ } from "@/components/sections/FAQ";
|
||||||
import { Contact } from "@/components/sections/Contact";
|
import { Contact } from "@/components/sections/Contact";
|
||||||
import { BackToTop } from "@/components/ui/BackToTop";
|
import { BackToTop } from "@/components/ui/BackToTop";
|
||||||
|
import { FloatingContact } from "@/components/ui/FloatingContact";
|
||||||
|
import { Header } from "@/components/layout/Header";
|
||||||
|
import { Footer } from "@/components/layout/Footer";
|
||||||
|
import { ClientShell } from "@/components/layout/ClientShell";
|
||||||
|
import { getContent } from "@/lib/content";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { OpenDay } from "@/components/sections/OpenDay";
|
||||||
|
import { getActiveOpenDay } from "@/lib/openDay";
|
||||||
|
import { getAllMcRegistrations } from "@/lib/db";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const content = getContent();
|
||||||
|
const openDayData = getActiveOpenDay();
|
||||||
|
// Count MC registrations per title for capacity check
|
||||||
|
const allMcRegs = getAllMcRegistrations();
|
||||||
|
const mcRegCounts: Record<string, number> = {};
|
||||||
|
for (const reg of allMcRegs) mcRegCounts[reg.masterClassTitle] = (mcRegCounts[reg.masterClassTitle] || 0) + 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Hero />
|
<ClientShell>
|
||||||
<About />
|
<Header />
|
||||||
<Team />
|
<main>
|
||||||
<Classes />
|
<Hero data={content.hero} />
|
||||||
<Pricing />
|
<About
|
||||||
<FAQ />
|
data={content.about}
|
||||||
<Contact />
|
stats={{
|
||||||
<BackToTop />
|
trainers: content.team.members.length,
|
||||||
|
classes: content.classes.items.length,
|
||||||
|
locations: content.schedule.locations.length,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Classes data={content.classes} />
|
||||||
|
<Team data={content.team} schedule={content.schedule.locations} />
|
||||||
|
{openDayData && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team.members} />}
|
||||||
|
<Schedule data={content.schedule} classItems={content.classes.items} teamMembers={content.team.members} />
|
||||||
|
<Pricing data={content.pricing} />
|
||||||
|
<MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} />
|
||||||
|
<News data={content.news} />
|
||||||
|
<FAQ data={content.faq} />
|
||||||
|
<Contact data={content.contact} />
|
||||||
|
<BackToTop />
|
||||||
|
<FloatingContact />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</ClientShell>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ===== */
|
||||||
@@ -283,6 +284,63 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Team Card Glitter ===== */
|
||||||
|
|
||||||
|
@keyframes glitter-move {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 0%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 200%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-glitter {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated gold border glow */
|
||||||
|
.team-card-glitter::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
transparent 20%,
|
||||||
|
rgba(201, 169, 110, 0.6) 30%,
|
||||||
|
rgba(212, 184, 122, 1) 35%,
|
||||||
|
transparent 45%,
|
||||||
|
transparent 55%,
|
||||||
|
rgba(201, 169, 110, 0.5) 65%,
|
||||||
|
rgba(212, 184, 122, 0.9) 70%,
|
||||||
|
transparent 80%
|
||||||
|
);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: glitter-move 3s linear infinite;
|
||||||
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
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 ===== */
|
||||||
|
|
||||||
.section-divider {
|
.section-divider {
|
||||||
@@ -331,4 +389,8 @@
|
|||||||
.glow-hover:hover {
|
.glow-hover:hover {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.team-card-glitter::before {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||