Compare commits

...

33 Commits

Author SHA1 Message Date
1bfd502930 feat: booking confirm modal with cascading Hall → Trainer → Group, linear workflow
- Add confirmed_date, confirmed_group, confirmed_comment to group_bookings (migration #10)
- Linear booking flow: Новая → Связались → Подтвердить (modal) / Отказ
- Confirm modal with cascading selects: Hall → Trainer → Group → Date → Comment
- Groups merged by groupId — shows all days/times (e.g. "СР 20:00, ПТ 16:15")
- Auto-prefill hall/trainer/group from booking's groupInfo via fuzzy scoring
- Proper SHORT_DAYS constant for weekday abbreviations
- Filter chips with status counts, declined sorted to bottom
- "Вернуть" on confirmed/declined returns to "Связались" (not "Новая")

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:03:48 +03:00
8d1e3fb596 fix: reopen booking returns to "Связались" instead of "Новая"
Admin already contacted the person, so reopened bookings skip "Новая" state.
Renamed button from "Сбросить" to "Вернуть".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:25:42 +03:00
0ec2361a16 feat: add linear booking workflow — Новая → Связались → Подтверждено/Отказ
- Add status + confirmed_date columns to group_bookings (migration #10)
- Linear flow: Новая shows "Связались →", Связались shows date picker + "Отказ"
- Date picker for confirmation allows only today and future dates
- Confirmed bookings show the scheduled date
- Filter chips: Все / Новая / Связались / Подтверждено / Отказ with counts
- Declined bookings sorted to bottom of list
- "Сбросить" button on confirmed/declined to restart flow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:14:51 +03:00
e4a9b71bfe feat: upgrade reminders tab — group by event, status tags, amber "Нет ответа"
- Group reminders by event within each day (e.g. "Master class · 16:00")
- Stats (придёт/не придёт/нет ответа/не спрошены) shown per event, not per day
- People separated by status with colored tag labels for easy scanning
- "Нет ответа" now amber when active (was neutral gray, confused with unselected)
- Cancelled people faded (opacity-50)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:32:16 +03:00
e617660467 fix: clean up admin bookings — add all contact fields, remove redundant NotifyToggle/filters
- Add phone to MC registrations display, instagram/telegram to group bookings
- Remove NotifyToggle from all 3 tabs (handled by Reminders tab)
- Remove FilterChips (Новые/Без напоминания) — redundant with Reminders tab
- Remove unused urgencyMap, mcItems fetch, MasterClassSlot/MasterClassItem types
- Fix delete button layout (pinned right, doesn't wrap)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:04:47 +03:00
3458f88367 fix: remove floating booking button overlapping mobile menu, center Open Day heading
- Remove floating "Записаться" button that covered hamburger menu on mobile scroll
- Booking still accessible via mobile menu dropdown and Hero CTA
- Center Open Day section heading to match all other sections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:35:29 +03:00
9e0aa5b5dc fix: LOW priority — GPU hints, CSRF cleanup, redundant query removal, mobile perf
- Add will-change to .hero-glow-orb (filter, transform) and .team-card-glitter::before (background-position)
- Clear CSRF cookie on logout alongside auth cookie
- Add max array length (100) validation on team reorder endpoint
- Remove redundant isOpenDayClassBookedByPhone pre-check (DB UNIQUE constraint handles it)
- Extract Schedule grid layout calculation into useMemo
- Reduce HeroLogo sparkle animations on mobile (15 → 8 via hidden sm:block)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:28:35 +03:00
5cd23473c8 fix: MEDIUM — Cache-Control headers on admin GETs, Open Day past date validation
- Add Cache-Control headers to admin GET endpoints (sections 60s, team 60s, bookings 30s, unread 15s, open-day 60s)
- Validate Open Day date is not in the past on both create (POST) and update (PUT)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:19:39 +03:00
b1adbbfe3d fix: MEDIUM priority — shared validation, content caching, Schedule useReducer, stable keys
- Extract shared sanitization to src/lib/validation.ts, apply to all 3 registration routes (#2)
- Replace key={index} with stable keys in About and News (#4)
- Add 5-min in-memory content cache in content.ts, invalidate on admin section save (#6)
- Refactor Schedule from 8 useState calls to useReducer — single dispatch, fewer re-renders (#8)
- Remove Hero scroll indicator, add auto-scroll to next section on wheel/swipe

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:17:24 +03:00
e63b902081 feat: remove scroll indicator, add auto-scroll from hero to next section
- Remove SCROLL chevron button from hero (not needed)
- Add wheel/swipe listener that smoothly scrolls to the first section below hero
- Works on desktop (wheel) and mobile (touch swipe)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:08:30 +03:00
66dce3f8f5 fix: HIGH priority — scroll debounce, timing-safe auth, a11y, error logging, cleanup dead modals
- Header: throttle scroll handler via requestAnimationFrame (was firing 60+/sec)
- Auth: use crypto.timingSafeEqual for password and token signature comparison
- A11y: add role="dialog", aria-modal, aria-label to all modals (SignupModal, NewsModal, TeamProfile lightbox)
- A11y: add aria-label to close buttons, menu toggle (with aria-expanded), floating CTA
- A11y: add aria-label to MC Instagram buttons
- Error logging: add console.error with route names to all API catch blocks (admin + public)
- Fix open-day-register error leak (was returning raw DB error to client)
- Fix MasterClasses key={index} → key={item.title}
- Delete 3 unused modal components (BookingModal, MasterClassSignupModal, OpenDaySignupModal) — replaced by unified SignupModal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:01:21 +03:00
127990e532 fix: critical perf & security — rate limiting, DB indexes, N+1 query, image lazy loading
- Add in-memory rate limiter (src/lib/rateLimit.ts) to public registration endpoints
- Add DB migration #9 with 8 performance indexes on booking/registration tables
- Fix N+1 query in getUpcomingReminders() — single IN() query instead of per-title
- Add loading="lazy" to all non-hero images (MasterClasses, News, Classes, Team)
- Add sizes attribute to Classes images for better responsive loading

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:55:49 +03:00
4e766d6957 feat: add reminders tab with status tracking (coming/pending/cancelled)
Auto-surfaces bookings for today and tomorrow. Admin sets status per
person: coming, no answer, or cancelled. Summary stats per day.
DB migration 8 adds reminder_status column to all booking tables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:07:00 +03:00
b94ee69033 feat: add booking management, Open Day, unified signup modal
- MC registrations: notification toggles (confirm/remind) with urgency
- Group bookings: save to DB from BookingModal, admin CRUD at /admin/bookings
- Open Day: full event system with schedule grid (halls × time), per-class
  booking, discount pricing (30 BYN / 20 BYN from 3+), auto-cancel threshold
- Unified SignupModal replaces 3 separate forms — consistent fields
  (name, phone, instagram, telegram), Instagram DM fallback on network error
- Centralized /admin/bookings page with 3 tabs (classes, MC, Open Day),
  collapsible sections, notification toggles, filter chips
- Unread booking badge on sidebar + dashboard widget with per-type breakdown
- Pricing: contact hint (Instagram/Telegram/phone) on price & rental tabs,
  admin toggle to show/hide
- DB migrations 5-7: group_bookings table, open_day tables, unified fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:58:04 +03:00
7497ede2fd fix: auto-issue CSRF cookie for existing sessions
Sessions from before CSRF was added lack the bh-csrf-token cookie,
causing 403 on first save. Middleware now auto-generates the cookie
if the user is authenticated but missing it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:57:49 +03:00
6cbdba2197 feat: add CSRF protection for admin API routes
Double-submit cookie pattern: login sets bh-csrf-token cookie,
proxy.ts validates X-CSRF-Token header on POST/PUT/DELETE to /api/admin/*.
New adminFetch() helper in src/lib/csrf.ts auto-includes the header.
All admin pages migrated from fetch() to adminFetch().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:53:02 +03:00
3ac6a4d840 fix: security hardening, UI fixes, and validation improvements
- Fix header nav overflow by switching to lg: breakpoint with tighter gaps
- Fix file upload path traversal by whitelisting allowed folders and extensions
- Fix BookingModal using hardcoded content instead of DB-backed data
- Add input length validation on public master-class registration API
- Add ID validation on team member and reorder API routes
- Fix BookingModal useCallback missing groupInfo/contact dependencies
- Improve admin news date field to use native date picker
- Add missing Мастер-классы and Новости cards to admin dashboard

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:37:29 +03:00
26cb9a9772 feat: redesign news & master classes sections, migrate middleware to proxy
- News: magazine layout with featured hero article + compact list, click-to-open modal
- Master classes: fashion lookbook portrait cards with full-bleed images and overlay content
- Rename middleware.ts to proxy.ts (Next.js 16 convention)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:49:13 +03:00
4a1a2d7512 docs: update CLAUDE.md to reflect Next.js 16 upgrade
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:25:46 +03:00
b9800c1cc2 feat: add news section with admin editor and public display
- NewsItem type with title, text, date, optional image and link
- Admin page at /admin/news with image upload and auto-date
- Public section between Pricing and FAQ, hidden when empty
- Nav link auto-hides when no news items exist

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:19:03 +03:00
f29dbe0c9f fix: use groupId for trainer bio schedule groups
Group extraction now uses groupId (from admin panel) instead of
type+time heuristic, with mergeSlotsByDay for proper day/time display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:54:15 +03:00
340a1d2f7f feat: multi-popular toggle for pricing, BYN price field for master classes
- Replace single popular dropdown with per-item toggle switch in pricing admin
- Add PriceField component to master classes admin (strips/adds BYN suffix)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 20:03:41 +03:00
f5e80c792a feat: add groupId support and redesign schedule GroupView hierarchy
- Add groupId field to ScheduleClass for admin-defined group identity
- Add versioned DB migration system (replaces initTables) to prevent data loss
- Redesign GroupView: Trainer → Class Type → Group → Datetimes hierarchy
- Group datetimes by day, merge days with identical time sets
- Auto-assign groupIds to legacy schedule entries in admin
- Add mc_registrations CRUD to db.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:55:34 +03:00
84b0bc4d60 feat: add master classes section with registration system
- New master classes section on landing page with upcoming events grid
- Admin CRUD for master classes (image, slots, trainer, style, cost, location)
- User signup modal (name + Instagram required, Telegram optional)
- Admin registration management: view, add, edit, delete with quick-contact links
- Customizable success message for signup confirmation
- Auto-filter past events, Russian date formatting, duration auto-calculation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 18:29:06 +03:00
6981376171 feat: add Записаться button to group cards with pre-filled Instagram DM
- BookingModal now accepts optional groupInfo for pre-filled message
- Trainer profile: each group card has Записаться button
- Schedule group view: each group card has Записаться button
- Message includes class type, trainer, days, and time

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:45:27 +03:00
4f92057411 feat: show trainer's groups on profile from schedule data
- Extract classes from schedule matching trainer name
- Group by type+time+location, combine days (e.g. ПН, СР)
- Display as horizontal scroll cards with time, location, level
- Show recruiting badge and address (without city prefix)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:35:17 +03:00
ce033074cd feat: horizontal drag-scroll for all bio sections, fix tab resize
- Replace wrapping grid with horizontal ScrollRow (drag to scroll)
- Apply to victories, education, and experience sections
- Grid overlay for victory tabs so height stays stable across tabs
- Fixed-width cards (w-44/w-48) with items-stretch for uniform height
- Remove scrollbar, add grab cursor for drag interaction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:19:12 +03:00
d4751975d2 feat: upgrade bio panel design, clickable carousel cards, Escape navigation
- Widen layout (max-w-5xl), larger photo column
- Fix place badge: clean pill instead of clipped diamond
- Increase victory card text sizes for readability
- Cards fill available width instead of fixed size
- Move back button above photo
- Add Escape key: closes lightbox or returns to gallery
- Clicking inactive carousel photos scrolls to them

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 15:40:06 +03:00
1b391cdde6 feat: split victories into tabbed sections (place/nomination/judge)
Add type field to VictoryItem, tabbed UI in trainer profile,
and type dropdown in admin form.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:31:00 +03:00
d3bb43af80 feat: replace free-text place input with dropdown select in victories
Predefined options: 1-6 место, Финалист, Полуфиналист, Лауреат,
Номинант, Участник, Победитель, Гран-при, Best Show, Vice Champion,
Champion — with emoji indicators for top places and nominations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:58:27 +03:00
5030edd0d6 fix: compact victory cards, fix date picker overflow, improve city autocomplete
- Merge place/category/competition into single row for compact layout
- Inline date range picker (no wrapper div causing overflow)
- Remove restrictive Nominatim filter — show all location results
- Reduce padding/gaps across all bio fields for denser layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:47:52 +03:00
627781027b feat: upgrade team admin with click-to-edit, Instagram validation, date picker, city autocomplete
- Team list: click card to open editor (remove pencil button), keep drag-to-reorder
- Instagram field: username-only input with @ prefix, async account validation via HEAD request
- Victory dates: date range picker replacing text input, auto-formats to DD.MM.YYYY / DD-DD.MM.YYYY
- Victory location: city autocomplete via Nominatim API with suggestions dropdown
- Links: real-time URL validation with error indicators on all link fields
- Save button blocked when any validation errors exist

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:34:55 +03:00
4918184852 feat: structured victories/education with photos, links, and editorial profile layout
- Add VictoryItem type (place, category, competition, location, date, image, link)
- Add RichListItem type for education with image/link support
- Backward-compatible DB parsing for old string[] formats
- Admin forms with structured fields and image upload per item
- Victory/education cards with photo overlay and lightbox
- Remove max-width constraint from trainer profile for full-width layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:34:30 +03:00
69 changed files with 7285 additions and 705 deletions

134
CLAUDE.md
View File

@@ -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,68 +17,147 @@ Content language: Russian
- Function declarations for components (not arrow functions) - Function declarations for components (not arrow functions)
- PascalCase for component files, camelCase for utils - PascalCase for component files, camelCase for utils
- `@/` path alias for imports - `@/` path alias for imports
- Semantic CSS classes via `@apply`: `surface-base`, `surface-muted`, `heading-text`, `body-text`, `nav-link`, `card`, `contact-item`, `contact-icon`, `theme-border`
- Only Header + ThemeToggle are client components (minimal JS shipped)
- `next/image` with `unoptimized` for PNGs that need transparency preserved - `next/image` with `unoptimized` for PNGs that need transparency preserved
- Header nav uses `lg:` breakpoint (1024px) for desktop/mobile switch (9 nav links + CTA need the space)
## Project Structure ## Project Structure
``` ```
src/ src/
├── app/ ├── app/
│ ├── layout.tsx # Root layout, fonts, metadata │ ├── layout.tsx # Root layout, fonts, metadata
│ ├── page.tsx # Landing: Hero → Team → About → Classes → Contact │ ├── page.tsx # Landing: Hero → [OpenDay] → About → Team → Classes → MasterClasses → Schedule → Pricing → News → FAQ → Contact
│ ├── globals.css # Tailwind imports │ ├── globals.css # Tailwind imports
│ ├── styles/ │ ├── styles/
│ │ ├── theme.css # Theme variables, semantic classes │ │ ├── theme.css # Theme variables, semantic classes
│ │ └── animations.css # Keyframes, scroll reveal, modal animations │ │ └── animations.css # Keyframes, scroll reveal, modal animations
│ ├── icon.png # Favicon │ ├── admin/
└── apple-icon.png │ ├── page.tsx # Dashboard with 13 section cards
│ │ ├── login/ # Password auth
│ │ ├── layout.tsx # Sidebar nav shell (14 items)
│ │ ├── _components/ # SectionEditor, FormField, ArrayEditor, NotifyToggle
│ │ ├── meta/ # SEO editor
│ │ ├── hero/ # Hero editor
│ │ ├── about/ # About editor
│ │ ├── team/ # Team list + [id] editor
│ │ ├── classes/ # Classes editor with icon picker
│ │ ├── master-classes/ # MC editor with registrations + notification toggles
│ │ ├── open-day/ # Open Day event editor (settings + grid + bookings)
│ │ ├── schedule/ # Schedule editor
│ │ ├── bookings/ # Group booking management with notification toggles
│ │ ├── pricing/ # Pricing editor
│ │ ├── faq/ # FAQ editor
│ │ ├── news/ # News editor
│ │ └── contact/ # Contact editor
│ └── api/
│ ├── auth/login/ # POST login
│ ├── logout/ # POST logout
│ ├── admin/
│ │ ├── sections/[key]/ # GET/PUT section data
│ │ ├── team/ # CRUD team members
│ │ ├── team/[id]/ # GET/PUT/DELETE single member
│ │ ├── team/reorder/ # PUT reorder
│ │ ├── upload/ # POST file upload (whitelisted folders)
│ │ ├── mc-registrations/ # CRUD registrations + notification toggle
│ │ ├── group-bookings/ # CRUD group bookings + notification toggle
│ │ ├── open-day/ # CRUD events
│ │ ├── open-day/classes/ # CRUD event classes
│ │ ├── open-day/bookings/ # CRUD event bookings + notification toggle
│ │ └── validate-instagram/ # GET check username
│ ├── master-class-register/ # POST public MC signup
│ ├── group-booking/ # POST public group booking
│ └── open-day-register/ # POST public Open Day booking
├── components/ ├── components/
│ ├── layout/ │ ├── layout/
│ │ ├── Header.tsx # Sticky nav, mobile menu, theme toggle ("use client") │ │ ├── Header.tsx # Sticky nav, mobile menu, booking modal ("use client")
│ │ └── Footer.tsx │ │ └── Footer.tsx
│ ├── sections/ │ ├── sections/
│ │ ├── Hero.tsx │ │ ├── Hero.tsx # Hero with animated logo, floating hearts
│ │ ├── Team.tsx # "use client" — clickable cards + modal │ │ ├── About.tsx # About with stats (trainers, classes, locations)
│ │ ├── About.tsx │ │ ├── Team.tsx # Carousel + profile view
│ │ ├── Classes.tsx │ │ ├── Classes.tsx # Showcase layout with icon selector
│ │ ── Contact.tsx │ │ ── MasterClasses.tsx # Cards with signup modal
│ │ ├── OpenDay.tsx # Open Day schedule grid + booking (conditional)
│ │ ├── Schedule.tsx # Day/group views with filters
│ │ ├── Pricing.tsx # Tabs: prices, rental, rules
│ │ ├── News.tsx # Featured + compact articles
│ │ ├── FAQ.tsx # Accordion with show more
│ │ └── Contact.tsx # Info + Yandex Maps iframe
│ └── ui/ │ └── ui/
│ ├── Button.tsx │ ├── Button.tsx
│ ├── SectionHeading.tsx │ ├── SectionHeading.tsx
│ ├── SocialLinks.tsx │ ├── BookingModal.tsx # Booking form → Instagram DM + DB save
│ ├── ThemeToggle.tsx │ ├── MasterClassSignupModal.tsx # MC registration form → API
│ ├── OpenDaySignupModal.tsx # Open Day class booking → API
│ ├── NewsModal.tsx # News detail popup
│ ├── Reveal.tsx # Intersection Observer scroll reveal │ ├── Reveal.tsx # Intersection Observer scroll reveal
── TeamMemberModal.tsx # "use client" — member popup ── BackToTop.tsx
│ └── ...
├── data/ ├── data/
│ └── content.ts # ALL Russian text, structured for future CMS │ └── content.ts # Fallback Russian text (DB takes priority)
├── lib/ ├── lib/
── constants.ts # BRAND constants, NAV_LINKS ── constants.ts # BRAND constants, NAV_LINKS
│ ├── config.ts # UI_CONFIG (thresholds, counts)
│ ├── db.ts # SQLite DB, 6 migrations, CRUD for all tables
│ ├── auth.ts # Token signing (Node.js)
│ ├── auth-edge.ts # Token verification (Edge/Web Crypto)
│ ├── content.ts # getContent() — DB with fallback
│ └── openDay.ts # getActiveOpenDay() — server-side Open Day loader
├── proxy.ts # Middleware: auth guard for /admin/*
└── types/ └── types/
├── index.ts ├── index.ts
├── content.ts # SiteContent, TeamMember, ClassItem, ContactInfo ├── content.ts # SiteContent, TeamMember, ClassItem, MasterClassItem, etc.
└── navigation.ts └── navigation.ts
``` ```
## Brand / Styling ## Brand / Styling
- **Accent**: rose/red (`#e11d48`) - **Accent**: gold (`#c9a96e` / `hsl(37, 42%, 61%)`)
- **Dark mode**: bg `#0a0a0a`, surface `#171717` - **Background**: `#050505` `#0a0a0a` (dark only)
- **Light mode**: bg `#fafafa`, surface `#ffffff` - **Surface**: `#171717` dark cards
- Logo: transparent PNG, uses `dark:invert` + `unoptimized` - Logo: transparent PNG heart with gold glow, uses `unoptimized`
## Content Data ## Content Data
- All text lives in `src/data/content.ts` (type-safe, one file to edit) - Primary source: SQLite database (`db/blackheart.db`)
- 13 team members with photos, Instagram links, and personal descriptions - Fallback: `src/data/content.ts` (auto-seeds DB on first access)
- Admin panel edits go to DB, site reads from DB via `getContent()`
- 12 team members with photos, Instagram links, bios, victories, education
- 6 class types (Exotic Pole Dance, Pole Dance, Body Plastic, etc.) - 6 class types (Exotic Pole Dance, Pole Dance, Body Plastic, etc.)
- Master classes with date/time slots and public registration
- 2 addresses in Minsk, Yandex Maps embed with markers - 2 addresses in Minsk, Yandex Maps embed with markers
- Contact: phone, Instagram - Contact: phone, Instagram (no email)
## Admin Panel
- Password-based auth with HMAC-SHA256 signed JWT (24h TTL)
- Cookie: `bh-admin-token` (httpOnly, secure in prod)
- Auto-save with 800ms debounce on all section editors
- Team members: drag-reorder, photo upload, rich bio (experience, victories, education)
- Master classes: slots, registration viewer with notification tracking (confirm + reminder), trainer/style autocomplete
- Group bookings: saved to DB from BookingModal, admin page at `/admin/bookings` with notification toggles
- Open Day: event settings (date, pricing, discount rules, min bookings), schedule grid (halls × time slots), per-class booking with auto-cancel threshold, public section after Hero
- Shared `NotifyToggle` component (`src/app/admin/_components/NotifyToggle.tsx`) used across MC registrations, group bookings, and Open Day bookings
- File upload: whitelisted folders (`team`, `master-classes`, `news`, `classes`), max 5MB, image types only
## Security Notes
- **CSRF protection**: Double-submit cookie pattern. Login sets `bh-csrf-token` cookie (JS-readable). All admin fetch calls use `adminFetch()` from `src/lib/csrf.ts` which sends the token as `X-CSRF-Token` header. Middleware (`proxy.ts`) validates header matches cookie on POST/PUT/DELETE to `/api/admin/*`. **Always use `adminFetch()` instead of `fetch()` for admin API calls.**
- File upload validates: MIME type, file extension, whitelisted folder (no path traversal)
- API routes validate: input types, string lengths, numeric IDs
- Public MC registration: length-limited but **no rate limiting yet** (add before production)
## Upcoming Features
- **Rate limiting** on public endpoints (`/api/master-class-register`, `/api/group-booking`, `/api/open-day-register`)
- **DB backup mechanism** — automated/manual backup of `db/blackheart.db` with rotation
## AST Index ## AST Index
- **Always use the AST index** at `memory/ast-index.md` when searching for components, props, hooks, types, or styles - **Always use the AST index** at `memory/ast-index.md` when searching for components, props, hooks, types, or styles
- Contains: component tree, all exports, props, hooks, client/server status, CSS classes, keyframes - Contains: component tree, all exports, props, hooks, client/server status, CSS classes, keyframes
- Covers all 31 TS/TSX files + 4 CSS files
- Update the index when adding/removing/renaming files or exports - Update the index when adding/removing/renaming files or exports
## Database Migrations
- **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
## Git ## Git
- Remote: Gitea at `git.dolgolyov-family.by` - Remote: Gitea at `git.dolgolyov-family.by`
- User: diana.dolgolyova - User: diana.dolgolyova

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -1,5 +1,7 @@
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { Plus, X } from "lucide-react"; import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { RichListItem, VictoryItem } from "@/types/content";
interface InputFieldProps { interface InputFieldProps {
label: string; label: string;
@@ -104,12 +106,10 @@ export function SelectField({
const filtered = search const filtered = search
? options.filter((o) => { ? options.filter((o) => {
const q = search.toLowerCase(); const q = search.toLowerCase();
// Match any word that starts with the search query
return o.label.toLowerCase().split(/\s+/).some((word) => word.startsWith(q)); return o.label.toLowerCase().split(/\s+/).some((word) => word.startsWith(q));
}) })
: options; : options;
// Close on outside click
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
function handle(e: MouseEvent) { function handle(e: MouseEvent) {
@@ -188,7 +188,6 @@ interface TimeRangeFieldProps {
} }
export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFieldProps) { export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFieldProps) {
// Parse "HH:MMHH:MM" into start and end
const parts = value.split(""); const parts = value.split("");
const start = parts[0]?.trim() || ""; const start = parts[0]?.trim() || "";
const end = parts[1]?.trim() || ""; const end = parts[1]?.trim() || "";
@@ -204,7 +203,6 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
} }
function handleStartChange(newStart: string) { function handleStartChange(newStart: string) {
// Reset end if start >= end
if (newStart && end && newStart >= end) { if (newStart && end && newStart >= end) {
update(newStart, ""); update(newStart, "");
} else { } else {
@@ -213,7 +211,6 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
} }
function handleEndChange(newEnd: string) { function handleEndChange(newEnd: string) {
// Ignore if end <= start
if (start && newEnd && newEnd <= start) return; if (start && newEnd && newEnd <= start) return;
update(start, newEnd); update(start, newEnd);
} }
@@ -339,3 +336,428 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp
</div> </div>
); );
} }
interface VictoryListFieldProps {
label: string;
items: RichListItem[];
onChange: (items: RichListItem[]) => void;
placeholder?: string;
onLinkValidate?: (key: string, error: string | null) => void;
}
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate }: 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)));
}
} 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">
<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(); } }}
placeholder={placeholder || "Добавить..."}
className="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 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>
</div>
);
}
// --- Date Range Picker ---
// Parses Russian date formats: "22.02.2025", "22-23.02.2025", "22.02-01.03.2025"
function parseDateRange(value: string): { start: string; end: string } {
if (!value) return { start: "", end: "" };
// "22-23.02.2025" → same month range
const sameMonth = value.match(/^(\d{1,2})-(\d{1,2})\.(\d{2})\.(\d{4})$/);
if (sameMonth) {
const [, d1, d2, m, y] = sameMonth;
return {
start: `${y}-${m}-${d1.padStart(2, "0")}`,
end: `${y}-${m}-${d2.padStart(2, "0")}`,
};
}
// "22.02-01.03.2025" → cross-month range
const crossMonth = value.match(/^(\d{1,2})\.(\d{2})-(\d{1,2})\.(\d{2})\.(\d{4})$/);
if (crossMonth) {
const [, d1, m1, d2, m2, y] = crossMonth;
return {
start: `${y}-${m1}-${d1.padStart(2, "0")}`,
end: `${y}-${m2}-${d2.padStart(2, "0")}`,
};
}
// "22.02.2025" → single date
const single = value.match(/^(\d{1,2})\.(\d{2})\.(\d{4})$/);
if (single) {
const [, d, m, y] = single;
const iso = `${y}-${m}-${d.padStart(2, "0")}`;
return { start: iso, end: "" };
}
return { start: "", end: "" };
}
function formatDateRange(start: string, end: string): string {
if (!start) return "";
const [sy, sm, sd] = start.split("-");
if (!end) return `${sd}.${sm}.${sy}`;
const [ey, em, ed] = end.split("-");
if (sm === em && sy === ey) return `${sd}-${ed}.${sm}.${sy}`;
return `${sd}.${sm}-${ed}.${em}.${ey}`;
}
interface DateRangeFieldProps {
value: string;
onChange: (value: string) => void;
}
export function DateRangeField({ value, onChange }: DateRangeFieldProps) {
const { start, end } = parseDateRange(value);
function handleChange(s: string, e: string) {
onChange(formatDateRange(s, e));
}
return (
<div className="flex items-center gap-1">
<Calendar size={11} className="text-neutral-500 shrink-0" />
<input
type="date"
value={start}
onChange={(e) => handleChange(e.target.value, end)}
className="w-[130px] rounded-md border border-white/10 bg-neutral-800 px-1.5 py-1.5 text-xs text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
/>
<span className="text-neutral-500 text-xs"></span>
<input
type="date"
value={end}
min={start}
onChange={(e) => handleChange(start, e.target.value)}
className="w-[130px] rounded-md border border-white/10 bg-neutral-800 px-1.5 py-1.5 text-xs text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
/>
</div>
);
}
// --- City Autocomplete Field ---
interface CityFieldProps {
value: string;
onChange: (value: string) => void;
error?: string;
onSearch?: (query: string) => void;
suggestions?: string[];
onSelectSuggestion?: (value: string) => void;
}
export function CityField({ value, onChange, error, onSearch, suggestions, onSelectSuggestion }: CityFieldProps) {
const [focused, setFocused] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!focused) return;
function handle(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setFocused(false);
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [focused]);
return (
<div ref={containerRef} className="relative flex-1">
<div className="relative">
<MapPin size={11} className="absolute left-2 top-1/2 -translate-y-1/2 text-neutral-500 pointer-events-none" />
<input
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
onSearch?.(e.target.value);
}}
onFocus={() => setFocused(true)}
placeholder="Город, страна"
className={`w-full rounded-md border bg-neutral-800 pl-6 pr-3 py-1.5 text-sm text-white placeholder-neutral-600 outline-none transition-colors ${
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
}`}
/>
{error && <AlertCircle size={12} className="absolute right-2 top-1/2 -translate-y-1/2 text-red-400" />}
</div>
{error && <p className="mt-0.5 text-[10px] text-red-400">{error}</p>}
{focused && suggestions && suggestions.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
{suggestions.map((s) => (
<button
key={s}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onSelectSuggestion?.(s);
setFocused(false);
}}
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-white/5 transition-colors"
>
{s}
</button>
))}
</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>
);
}
interface VictoryItemListFieldProps {
label: string;
items: VictoryItem[];
onChange: (items: VictoryItem[]) => void;
cityErrors?: Record<number, string>;
citySuggestions?: { index: number; items: string[] } | null;
onCitySearch?: (index: number, query: string) => void;
onCitySelect?: (index: number, value: string) => void;
onLinkValidate?: (key: string, error: string | null) => void;
}
export function VictoryItemListField({ label, items, onChange, cityErrors, citySuggestions, onCitySearch, onCitySelect, onLinkValidate }: VictoryItemListFieldProps) {
function add() {
onChange([...items, { type: "place", place: "", category: "", competition: "" }]);
}
function remove(index: number) {
onChange(items.filter((_, i) => i !== index));
}
function update(index: number, field: keyof VictoryItem, value: string) {
onChange(items.map((item, i) => (i === index ? { ...item, [field]: value || undefined } : item)));
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="space-y-3">
{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">
<div className="flex gap-1.5">
<select
value={item.type || "place"}
onChange={(e) => update(i, "type", e.target.value)}
className="w-32 shrink-0 rounded-md border border-white/10 bg-neutral-800 px-2 py-1.5 text-sm text-white outline-none focus:border-gold transition-colors"
>
<option value="place">Место</option>
<option value="nomination">Номинация</option>
<option value="judge">Судейство</option>
</select>
<input
type="text"
value={item.place || ""}
onChange={(e) => update(i, "place", e.target.value)}
placeholder="1 место, финалист..."
className="w-28 shrink-0 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
/>
<input
type="text"
value={item.category || ""}
onChange={(e) => update(i, "category", e.target.value)}
placeholder="Категория"
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
/>
<input
type="text"
value={item.competition || ""}
onChange={(e) => update(i, "competition", e.target.value)}
placeholder="Чемпионат"
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 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 gap-1.5">
<CityField
value={item.location || ""}
onChange={(v) => update(i, "location", v)}
error={cityErrors?.[i]}
onSearch={(q) => onCitySearch?.(i, q)}
suggestions={citySuggestions?.index === i ? citySuggestions.items : undefined}
onSelectSuggestion={(v) => onCitySelect?.(i, v)}
/>
<DateRangeField
value={item.date || ""}
onChange={(v) => update(i, "date", v)}
/>
</div>
<ValidatedLinkField
value={item.link || ""}
onChange={(v) => update(i, "link", v)}
validationKey={`victory-${i}`}
onValidate={onLinkValidate}
/>
</div>
))}
<button
type="button"
onClick={add}
className="flex items-center gap-2 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
>
<Plus size={14} />
Добавить достижение
</button>
</div>
</div>
);
}

View File

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

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { Loader2, Check, AlertCircle } from "lucide-react"; import { Loader2, Check, AlertCircle } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
interface SectionEditorProps<T> { interface SectionEditorProps<T> {
sectionKey: string; sectionKey: string;
@@ -24,7 +25,7 @@ export function SectionEditor<T>({
const initialLoadRef = useRef(true); const initialLoadRef = useRef(true);
useEffect(() => { useEffect(() => {
fetch(`/api/admin/sections/${sectionKey}`) adminFetch(`/api/admin/sections/${sectionKey}`)
.then((r) => { .then((r) => {
if (!r.ok) throw new Error("Failed to load"); if (!r.ok) throw new Error("Failed to load");
return r.json(); return r.json();
@@ -39,7 +40,7 @@ export function SectionEditor<T>({
setError(""); setError("");
try { try {
const res = await fetch(`/api/admin/sections/${sectionKey}`, { const res = await adminFetch(`/api/admin/sections/${sectionKey}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(dataToSave), body: JSON.stringify(dataToSave),

View File

@@ -0,0 +1,978 @@
"use client";
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { Loader2, Trash2, Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
// --- Types ---
interface GroupBooking {
id: number;
name: string;
phone: string;
groupInfo?: string;
instagram?: string;
telegram?: string;
notifiedConfirm: boolean;
notifiedReminder: boolean;
status: BookingStatus;
confirmedDate?: string;
confirmedGroup?: string;
confirmedComment?: string;
createdAt: string;
}
interface McRegistration {
id: number;
masterClassTitle: string;
name: string;
phone?: string;
instagram: string;
telegram?: string;
notifiedConfirm: boolean;
notifiedReminder: boolean;
createdAt: string;
}
interface OpenDayBooking {
id: number;
classId: number;
eventId: number;
name: string;
phone: string;
instagram?: string;
telegram?: string;
notifiedConfirm: boolean;
notifiedReminder: boolean;
createdAt: string;
classStyle?: string;
classTrainer?: string;
classTime?: string;
classHall?: string;
}
const SHORT_DAYS: Record<string, string> = {
"Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ",
"Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС",
};
type Tab = "reminders" | "classes" | "master-classes" | "open-day";
type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
type BookingFilter = "all" | BookingStatus;
const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [
{ key: "new", label: "Новая", color: "text-gold", bg: "bg-gold/10", border: "border-gold/30" },
{ key: "contacted", label: "Связались", color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" },
{ key: "confirmed", label: "Подтверждено", color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/30" },
{ key: "declined", label: "Отказ", color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
];
// --- Confirm Booking Modal ---
function ConfirmModal({
open,
bookingName,
groupInfo,
allClasses,
onConfirm,
onClose,
}: {
open: boolean;
bookingName: string;
groupInfo?: string;
allClasses: ScheduleClassInfo[];
onConfirm: (data: { group: string; date: string; comment?: string }) => void;
onClose: () => void;
}) {
const [hall, setHall] = useState("");
const [trainer, setTrainer] = useState("");
const [group, setGroup] = useState("");
const [date, setDate] = useState("");
const [comment, setComment] = useState("");
useEffect(() => {
if (!open) return;
setDate(""); setComment("");
// Try to match groupInfo against schedule to pre-fill
if (groupInfo && allClasses.length > 0) {
const info = groupInfo.toLowerCase();
// Score each class against groupInfo, pick best match
let bestMatch: ScheduleClassInfo | null = null;
let bestScore = 0;
for (const c of allClasses) {
let score = 0;
if (info.includes(c.type.toLowerCase())) score += 3;
if (info.includes(c.trainer.toLowerCase())) score += 3;
if (info.includes(c.time)) score += 2;
const dayShort = (SHORT_DAYS[c.day] || c.day.slice(0, 2)).toLowerCase();
if (info.includes(dayShort)) score += 1;
const hallWords = c.hall.toLowerCase().split(/[\s/,]+/);
if (hallWords.some((w) => w.length > 2 && info.includes(w))) score += 2;
if (score > bestScore) { bestScore = score; bestMatch = c; }
}
const match = bestScore >= 4 ? bestMatch : null;
if (match) {
setHall(match.hall);
setTrainer(match.trainer);
setGroup(match.groupId || `${match.type}|${match.time}|${match.address}`);
return;
}
}
setHall(""); setTrainer(""); setGroup("");
}, [open, groupInfo, allClasses]);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Cascading options
const halls = useMemo(() => [...new Set(allClasses.map((c) => c.hall))], [allClasses]);
const trainers = useMemo(() => {
if (!hall) return [];
return [...new Set(allClasses.filter((c) => c.hall === hall).map((c) => c.trainer))].sort();
}, [allClasses, hall]);
const groups = useMemo(() => {
if (!hall || !trainer) return [];
const filtered = allClasses.filter((c) => c.hall === hall && c.trainer === trainer);
// Group by groupId — merge days for the same group
const byId = new Map<string, { type: string; slots: { day: string; time: string }[]; id: string }>();
for (const c of filtered) {
const id = c.groupId || `${c.type}|${c.time}|${c.address}`;
const existing = byId.get(id);
if (existing) {
if (!existing.slots.some((s) => s.day === c.day)) existing.slots.push({ day: c.day, time: c.time });
} else {
byId.set(id, { type: c.type, slots: [{ day: c.day, time: c.time }], id });
}
}
return [...byId.values()].map((g) => {
const sameTime = g.slots.every((s) => s.time === g.slots[0].time);
const label = sameTime
? `${g.type}, ${g.slots.map((s) => SHORT_DAYS[s.day] || s.day.slice(0, 2)).join("/")} ${g.slots[0].time}`
: `${g.type}, ${g.slots.map((s) => `${SHORT_DAYS[s.day] || s.day.slice(0, 2)} ${s.time}`).join(", ")}`;
return { label, value: g.id };
}).sort((a, b) => a.label.localeCompare(b.label));
}, [allClasses, hall, trainer]);
// Reset downstream on upstream change (skip during initial pre-fill)
const initRef = useRef(false);
useEffect(() => {
if (initRef.current) { setTrainer(""); setGroup(""); }
initRef.current = true;
}, [hall]);
useEffect(() => {
if (initRef.current && trainer === "") setGroup("");
}, [trainer]);
// Reset init flag when modal closes
useEffect(() => { if (!open) initRef.current = false; }, [open]);
if (!open) return null;
const today = new Date().toISOString().split("T")[0];
const selectClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 [color-scheme:dark] disabled:opacity-30 disabled:cursor-not-allowed";
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" onClick={onClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose} aria-label="Закрыть" className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
<X size={16} />
</button>
<h3 className="text-base font-bold text-white">Подтвердить запись</h3>
<p className="mt-1 text-xs text-neutral-400">{bookingName}</p>
<div className="mt-4 space-y-3">
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Зал</label>
<select value={hall} onChange={(e) => setHall(e.target.value)} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите зал</option>
{halls.map((h) => <option key={h} value={h} className="bg-neutral-900">{h}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Тренер</label>
<select value={trainer} onChange={(e) => setTrainer(e.target.value)} disabled={!hall} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите тренера</option>
{trainers.map((t) => <option key={t} value={t} className="bg-neutral-900">{t}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Группа</label>
<select value={group} onChange={(e) => setGroup(e.target.value)} disabled={!trainer} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите группу</option>
{groups.map((g) => <option key={g.value} value={g.value} className="bg-neutral-900">{g.label}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Дата занятия</label>
<input
type="date"
value={date}
min={today}
disabled={!group}
onChange={(e) => setDate(e.target.value)}
className={selectClass}
/>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
<input
type="text"
value={comment}
disabled={!group}
onChange={(e) => setComment(e.target.value)}
placeholder="Первое занятие, пробный"
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40 disabled:opacity-30 disabled:cursor-not-allowed"
/>
</div>
</div>
<button
onClick={() => {
if (group && date) {
onConfirm({ group, date, comment: comment.trim() || undefined });
}
}}
disabled={!group || !date}
className="mt-5 w-full rounded-lg bg-emerald-600 py-2.5 text-sm font-semibold text-white transition-all hover:bg-emerald-500 disabled:opacity-30 disabled:cursor-not-allowed"
>
Подтвердить
</button>
</div>
</div>,
document.body
);
}
// --- Group Bookings Tab ---
interface ScheduleClassInfo { type: string; trainer: string; time: string; day: string; hall: string; address: string; groupId?: string }
interface ScheduleLocation { name: string; address: string; days: { day: string; classes: { time: string; trainer: string; type: string; groupId?: string }[] }[] }
function GroupBookingsTab() {
const [bookings, setBookings] = useState<GroupBooking[]>([]);
const [allClasses, setAllClasses] = useState<ScheduleClassInfo[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<BookingFilter>("all");
useEffect(() => {
Promise.all([
adminFetch("/api/admin/group-bookings").then((r) => r.json()),
adminFetch("/api/admin/sections/schedule").then((r) => r.json()),
])
.then(([bookingData, scheduleData]: [GroupBooking[], { locations?: ScheduleLocation[] }]) => {
setBookings(bookingData);
const classes: ScheduleClassInfo[] = [];
for (const loc of scheduleData.locations || []) {
const shortAddr = loc.address?.split(",")[0] || loc.name;
for (const day of loc.days) {
for (const cls of day.classes) {
classes.push({
type: cls.type,
trainer: cls.trainer,
time: cls.time,
day: day.day,
hall: loc.name,
address: shortAddr,
groupId: cls.groupId,
});
}
}
}
setAllClasses(classes);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const counts = useMemo(() => {
const c: Record<string, number> = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
for (const b of bookings) c[b.status] = (c[b.status] || 0) + 1;
return c;
}, [bookings]);
const filtered = useMemo(() => {
const list = filter === "all" ? bookings : bookings.filter((b) => b.status === filter);
const order: Record<string, number> = { new: 0, contacted: 1, confirmed: 2, declined: 3 };
return [...list].sort((a, b) => (order[a.status] ?? 0) - (order[b.status] ?? 0));
}, [bookings, filter]);
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const confirmingBooking = bookings.find((b) => b.id === confirmingId);
async function handleStatus(id: number, status: BookingStatus, confirmation?: { group: string; date: string; comment?: string }) {
setBookings((prev) => prev.map((b) => b.id === id ? {
...b, status,
confirmedDate: confirmation?.date,
confirmedGroup: confirmation?.group,
confirmedComment: confirmation?.comment,
} : b));
await adminFetch("/api/admin/group-bookings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-status", id, status, confirmation }),
});
}
async function handleDelete(id: number) {
await adminFetch(`/api/admin/group-bookings?id=${id}`, { method: "DELETE" });
setBookings((prev) => prev.filter((b) => b.id !== id));
}
if (loading) return <LoadingSpinner />;
return (
<div>
{/* Filter tabs */}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setFilter("all")}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
}`}
>
Все <span className="text-neutral-500 ml-1">{bookings.length}</span>
</button>
{BOOKING_STATUSES.map((s) => (
<button
key={s.key}
onClick={() => setFilter(s.key)}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
}`}
>
{s.label}
{counts[s.key] > 0 && <span className="ml-1.5">{counts[s.key]}</span>}
</button>
))}
</div>
{/* Bookings list */}
<div className="mt-3 space-y-2">
{filtered.length === 0 && <EmptyState total={bookings.length} />}
{filtered.map((b) => {
const statusConf = BOOKING_STATUSES.find((s) => s.key === b.status) || BOOKING_STATUSES[0];
return (
<div
key={b.id}
className={`rounded-xl border p-4 transition-colors ${
b.status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
: b.status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
: b.status === "new" ? "border-gold/20 bg-gold/[0.03]"
: "border-white/10 bg-neutral-900"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
<span className="font-medium text-white">{b.name}</span>
<a href={`tel:${b.phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
<Phone size={10} />{b.phone}
</a>
{b.instagram && (
<a href={`https://ig.me/m/${b.instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
<Instagram size={10} />{b.instagram}
</a>
)}
{b.telegram && (
<a href={`https://t.me/${b.telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
<Send size={10} />{b.telegram}
</a>
)}
{b.groupInfo && (
<span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-neutral-600 text-xs">{fmtDate(b.createdAt)}</span>
<DeleteBtn onClick={() => handleDelete(b.id)} />
</div>
</div>
{/* Linear status flow */}
<div className="flex items-center gap-2 mt-2 flex-wrap">
{/* Current status badge */}
<span className={`text-[10px] font-medium ${statusConf.bg} ${statusConf.color} border ${statusConf.border} rounded-full px-2.5 py-0.5`}>
{statusConf.label}
</span>
{b.status === "confirmed" && (
<span className="text-[10px] text-emerald-400/70">
{b.confirmedGroup}
{b.confirmedDate && ` · ${new Date(b.confirmedDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`}
{b.confirmedComment && ` · ${b.confirmedComment}`}
</span>
)}
{/* Action buttons based on current state */}
<div className="flex gap-1 ml-auto">
{b.status === "new" && (
<button
onClick={() => handleStatus(b.id, "contacted")}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20 transition-all"
>
Связались
</button>
)}
{b.status === "contacted" && (
<>
<button
onClick={() => setConfirmingId(b.id)}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20 transition-all"
>
Подтвердить
</button>
<button
onClick={() => handleStatus(b.id, "declined")}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-all"
>
Отказ
</button>
</>
)}
{(b.status === "confirmed" || b.status === "declined") && (
<button
onClick={() => handleStatus(b.id, "contacted")}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300 transition-all"
>
Вернуть
</button>
)}
</div>
</div>
</div>
);
})}
</div>
<ConfirmModal
open={confirmingId !== null}
bookingName={confirmingBooking?.name ?? ""}
groupInfo={confirmingBooking?.groupInfo}
allClasses={allClasses}
onClose={() => setConfirmingId(null)}
onConfirm={(data) => {
if (confirmingId) handleStatus(confirmingId, "confirmed", data);
setConfirmingId(null);
}}
/>
</div>
);
}
// --- MC Registrations Tab ---
function McRegistrationsTab() {
const [regs, setRegs] = useState<McRegistration[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
adminFetch("/api/admin/mc-registrations")
.then((r) => r.json())
.then((data: McRegistration[]) => setRegs(data))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
// Group by MC title
const grouped = useMemo(() => {
const map: Record<string, McRegistration[]> = {};
for (const r of regs) {
if (!map[r.masterClassTitle]) map[r.masterClassTitle] = [];
map[r.masterClassTitle].push(r);
}
return map;
}, [regs]);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
function toggleExpand(key: string) {
setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));
}
async function handleDelete(id: number) {
await adminFetch(`/api/admin/mc-registrations?id=${id}`, { method: "DELETE" });
setRegs((prev) => prev.filter((r) => r.id !== id));
}
if (loading) return <LoadingSpinner />;
return (
<div className="space-y-2">
{Object.keys(grouped).length === 0 && <EmptyState total={regs.length} />}
{Object.entries(grouped).map(([title, items]) => {
const isOpen = expanded[title] ?? false;
return (
<div key={title} className="rounded-xl border border-white/10 overflow-hidden">
<button
onClick={() => toggleExpand(title)}
className="w-full flex items-center gap-3 px-4 py-3 bg-neutral-900 hover:bg-neutral-800/80 transition-colors text-left"
>
{isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
<span className="font-medium text-white text-sm truncate">{title}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">{items.length}</span>
</button>
{isOpen && (
<div className="px-4 pb-3 pt-1 space-y-1.5">
{items.map((r) => (
<div
key={r.id}
className="rounded-lg border border-white/5 bg-neutral-800/30 p-3"
>
<div className="flex items-center gap-2 flex-wrap text-sm">
<span className="font-medium text-white">{r.name}</span>
{r.phone && (
<a href={`tel:${r.phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
<Phone size={10} />{r.phone}
</a>
)}
{r.instagram && (
<a
href={`https://ig.me/m/${r.instagram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs"
>
<Instagram size={10} />{r.instagram}
</a>
)}
{r.telegram && (
<a
href={`https://t.me/${r.telegram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs"
>
<Send size={10} />{r.telegram}
</a>
)}
<span className="text-neutral-600 text-xs ml-auto">{fmtDate(r.createdAt)}</span>
<DeleteBtn onClick={() => handleDelete(r.id)} />
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
);
}
// --- Open Day Bookings Tab ---
function OpenDayBookingsTab() {
const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
adminFetch("/api/admin/open-day")
.then((r) => r.json())
.then((events: { id: number; date: string }[]) => {
if (events.length === 0) {
setLoading(false);
return;
}
const ev = events[0];
return adminFetch(`/api/admin/open-day/bookings?eventId=${ev.id}`)
.then((r) => r.json())
.then((data: OpenDayBooking[]) => setBookings(data));
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
// Group by class — sorted by hall then time
const grouped = useMemo(() => {
const map: Record<string, { hall: string; time: string; style: string; trainer: string; items: OpenDayBooking[] }> = {};
for (const b of bookings) {
const key = `${b.classHall}|${b.classTime}|${b.classStyle}`;
if (!map[key]) map[key] = { hall: b.classHall || "—", time: b.classTime || "—", style: b.classStyle || "—", trainer: b.classTrainer || "—", items: [] };
map[key].items.push(b);
}
// Sort by hall, then time
return Object.entries(map).sort(([, a], [, b]) => {
const hallCmp = a.hall.localeCompare(b.hall);
return hallCmp !== 0 ? hallCmp : a.time.localeCompare(b.time);
});
}, [bookings]);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
function toggleExpand(key: string) {
setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));
}
async function handleDelete(id: number) {
await adminFetch(`/api/admin/open-day/bookings?id=${id}`, { method: "DELETE" });
setBookings((prev) => prev.filter((b) => b.id !== id));
}
if (loading) return <LoadingSpinner />;
return (
<div className="space-y-2">
{grouped.length === 0 && <EmptyState total={bookings.length} />}
{grouped.map(([key, group]) => {
const isOpen = expanded[key] ?? false;
return (
<div key={key} className="rounded-xl border border-white/10 overflow-hidden">
<button
onClick={() => toggleExpand(key)}
className="w-full flex items-center gap-3 px-4 py-3 bg-neutral-900 hover:bg-neutral-800/80 transition-colors text-left"
>
{isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
<span className="text-gold text-xs font-medium shrink-0">{group.time}</span>
<span className="font-medium text-white text-sm truncate">{group.style}</span>
<span className="text-xs text-neutral-500 truncate hidden sm:inline">· {group.trainer}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0 ml-auto">{group.hall}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">{group.items.length} чел.</span>
</button>
{isOpen && (
<div className="px-4 pb-3 pt-1 space-y-1.5">
{group.items.map((b) => (
<div
key={b.id}
className="rounded-lg border border-white/5 bg-neutral-800/30 p-3"
>
<div className="flex items-center gap-2 flex-wrap text-sm">
<span className="font-medium text-white">{b.name}</span>
<a href={`tel:${b.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
<Phone size={10} />{b.phone}
</a>
{b.instagram && (
<a
href={`https://ig.me/m/${b.instagram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs"
>
<Instagram size={10} />{b.instagram}
</a>
)}
{b.telegram && (
<a
href={`https://t.me/${b.telegram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs"
>
<Send size={10} />{b.telegram}
</a>
)}
<span className="text-neutral-600 text-xs ml-auto">{fmtDate(b.createdAt)}</span>
<DeleteBtn onClick={() => handleDelete(b.id)} />
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
);
}
// --- Reminders Tab ---
interface ReminderItem {
id: number;
type: "class" | "master-class" | "open-day";
table: "mc_registrations" | "group_bookings" | "open_day_bookings";
name: string;
phone?: string;
instagram?: string;
telegram?: string;
reminderStatus?: string;
eventLabel: string;
eventDate: string;
}
type ReminderStatus = "pending" | "coming" | "cancelled";
const STATUS_CONFIG: Record<ReminderStatus, { label: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }> = {
pending: { label: "Нет ответа", icon: Clock, color: "text-amber-400", bg: "bg-amber-500/10", border: "border-amber-500/20" },
coming: { label: "Придёт", icon: CheckCircle2, color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
cancelled: { label: "Не придёт", icon: XCircle, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" },
};
const TYPE_CONFIG = {
"master-class": { label: "МК", icon: Star, color: "text-purple-400" },
"open-day": { label: "Open Day", icon: DoorOpen, color: "text-gold" },
"class": { label: "Занятие", icon: Calendar, color: "text-blue-400" },
};
function RemindersTab() {
const [items, setItems] = useState<ReminderItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
adminFetch("/api/admin/reminders")
.then((r) => r.json())
.then((data: ReminderItem[]) => setItems(data))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
async function setStatus(item: ReminderItem, status: ReminderStatus | null) {
setItems((prev) => prev.map((i) => i.id === item.id && i.table === item.table ? { ...i, reminderStatus: status ?? undefined } : i));
await adminFetch("/api/admin/reminders", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ table: item.table, id: item.id, status }),
});
}
if (loading) return <LoadingSpinner />;
const today = new Date().toISOString().split("T")[0];
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split("T")[0];
const todayItems = items.filter((i) => i.eventDate === today);
const tomorrowItems = items.filter((i) => i.eventDate === tomorrow);
// Stats
function countByStatus(list: ReminderItem[]) {
const coming = list.filter((i) => i.reminderStatus === "coming").length;
const cancelled = list.filter((i) => i.reminderStatus === "cancelled").length;
const pending = list.filter((i) => i.reminderStatus === "pending").length;
const notAsked = list.filter((i) => !i.reminderStatus).length;
return { coming, cancelled, pending, notAsked, total: list.length };
}
if (items.length === 0) {
return (
<div className="py-12 text-center">
<Bell size={32} className="mx-auto text-neutral-600 mb-3" />
<p className="text-neutral-400">Нет напоминаний все на контроле</p>
<p className="text-xs text-neutral-600 mt-1">Здесь появятся записи на сегодня и завтра</p>
</div>
);
}
// Group items by event within each day
function groupByEvent(dayItems: ReminderItem[]) {
const map: Record<string, { type: ReminderItem["type"]; label: string; items: ReminderItem[] }> = {};
for (const item of dayItems) {
const key = `${item.type}|${item.eventLabel}`;
if (!map[key]) map[key] = { type: item.type, label: item.eventLabel, items: [] };
map[key].items.push(item);
}
return Object.values(map);
}
const STATUS_SECTIONS = [
{ key: "not-asked", label: "Не спрошены", color: "text-gold", bg: "bg-gold/10", match: (i: ReminderItem) => !i.reminderStatus },
{ key: "pending", label: "Нет ответа", color: "text-amber-400", bg: "bg-amber-500/10", match: (i: ReminderItem) => i.reminderStatus === "pending" },
{ key: "coming", label: "Придёт", color: "text-emerald-400", bg: "bg-emerald-500/10", match: (i: ReminderItem) => i.reminderStatus === "coming" },
{ key: "cancelled", label: "Не придёт", color: "text-red-400", bg: "bg-red-500/10", match: (i: ReminderItem) => i.reminderStatus === "cancelled" },
];
function renderPerson(item: ReminderItem) {
const currentStatus = item.reminderStatus as ReminderStatus | undefined;
return (
<div
key={`${item.table}-${item.id}`}
className={`rounded-lg border p-3 transition-colors ${
!currentStatus ? "border-gold/20 bg-gold/[0.03]"
: currentStatus === "coming" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
: currentStatus === "cancelled" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
: currentStatus === "pending" ? "border-amber-500/15 bg-amber-500/[0.02]"
: "border-white/5 bg-neutral-800/30"
}`}
>
<div className="flex items-center gap-2 flex-wrap text-sm">
<span className="font-medium text-white">{item.name}</span>
{item.phone && (
<a href={`tel:${item.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
<Phone size={10} />{item.phone}
</a>
)}
{item.instagram && (
<a href={`https://ig.me/m/${item.instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
<Instagram size={10} />{item.instagram}
</a>
)}
{item.telegram && (
<a href={`https://t.me/${item.telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
<Send size={10} />{item.telegram}
</a>
)}
<div className="flex gap-1 ml-auto">
{(["coming", "pending", "cancelled"] as ReminderStatus[]).map((st) => {
const conf = STATUS_CONFIG[st];
const Icon = conf.icon;
const active = currentStatus === st;
return (
<button
key={st}
onClick={() => setStatus(item, active ? null : st)}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium transition-all ${
active
? `${conf.bg} ${conf.color} border ${conf.border}`
: "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300"
}`}
>
<Icon size={10} />
{conf.label}
</button>
);
})}
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{[
{ label: "Сегодня", date: today, items: todayItems },
{ label: "Завтра", date: tomorrow, items: tomorrowItems },
]
.filter((g) => g.items.length > 0)
.map((group) => {
const eventGroups = groupByEvent(group.items);
return (
<div key={group.date}>
<div className="flex items-center gap-3 mb-3">
<h3 className="text-sm font-bold text-white">{group.label}</h3>
<span className="text-[10px] text-neutral-500">
{new Date(group.date + "T12:00").toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" })}
</span>
</div>
<div className="space-y-3">
{eventGroups.map((eg) => {
const typeConf = TYPE_CONFIG[eg.type];
const TypeIcon = typeConf.icon;
const egStats = countByStatus(eg.items);
return (
<div key={eg.label} className="rounded-xl border border-white/10 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-900">
<TypeIcon size={13} className={typeConf.color} />
<span className="text-sm font-medium text-white">{eg.label}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{eg.items.length} чел.</span>
<div className="flex gap-2 ml-auto text-[10px]">
{egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>}
{egStats.cancelled > 0 && <span className="text-red-400">{egStats.cancelled} не придёт</span>}
{egStats.pending > 0 && <span className="text-amber-400">{egStats.pending} нет ответа</span>}
{egStats.notAsked > 0 && <span className="text-gold">{egStats.notAsked} не спрошены</span>}
</div>
</div>
<div className="px-4 pb-3 pt-1">
{STATUS_SECTIONS
.map((sec) => ({ ...sec, items: eg.items.filter(sec.match) }))
.filter((sec) => sec.items.length > 0)
.map((sec) => (
<div key={sec.key} className="mt-2 first:mt-0">
<span className={`text-[10px] font-medium ${sec.color} ${sec.bg} rounded-full px-2 py-0.5`}>
{sec.label} · {sec.items.length}
</span>
<div className="mt-1.5 space-y-1.5">
{sec.items.map(renderPerson)}
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
// --- Shared helpers ---
function LoadingSpinner() {
return (
<div className="flex items-center gap-2 py-8 text-neutral-500 justify-center">
<Loader2 size={16} className="animate-spin" />
Загрузка...
</div>
);
}
function EmptyState({ total }: { total: number }) {
return (
<p className="text-sm text-neutral-500 py-8 text-center">
{total === 0 ? "Пока нет записей" : "Нет записей по фильтру"}
</p>
);
}
function DeleteBtn({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
title="Удалить"
>
<Trash2 size={14} />
</button>
);
}
function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString("ru-RU");
}
// --- Main Page ---
const TABS: { key: Tab; label: string }[] = [
{ key: "reminders", label: "Напоминания" },
{ key: "classes", label: "Занятия" },
{ key: "master-classes", label: "Мастер-классы" },
{ key: "open-day", label: "День открытых дверей" },
];
export default function BookingsPage() {
const [tab, setTab] = useState<Tab>("reminders");
return (
<div>
<h1 className="text-2xl font-bold">Записи</h1>
<p className="mt-1 text-neutral-400 text-sm">
Все заявки и записи в одном месте
</p>
{/* Tabs */}
<div className="mt-5 flex border-b border-white/10">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
tab === t.key
? "text-gold"
: "text-neutral-400 hover:text-white"
}`}
>
{t.label}
{tab === t.key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gold rounded-full" />
)}
</button>
))}
</div>
{/* Tab content */}
<div className="mt-4">
{tab === "reminders" && <RemindersTab />}
{tab === "classes" && <GroupBookingsTab />}
{tab === "master-classes" && <McRegistrationsTab />}
{tab === "open-day" && <OpenDayBookingsTab />}
</div>
</div>
);
}

View File

@@ -1,23 +1,28 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { adminFetch } from "@/lib/csrf";
import { import {
LayoutDashboard, LayoutDashboard,
Sparkles, Sparkles,
Users, Users,
BookOpen, BookOpen,
Star,
Calendar, Calendar,
DollarSign, DollarSign,
HelpCircle, HelpCircle,
Phone, Phone,
FileText, FileText,
Globe, Globe,
Newspaper,
LogOut, LogOut,
Menu, Menu,
X, X,
ChevronLeft, ChevronLeft,
ClipboardList,
DoorOpen,
} from "lucide-react"; } from "lucide-react";
const NAV_ITEMS = [ const NAV_ITEMS = [
@@ -27,9 +32,13 @@ const NAV_ITEMS = [
{ href: "/admin/about", label: "О студии", icon: FileText }, { href: "/admin/about", label: "О студии", icon: FileText },
{ href: "/admin/team", label: "Команда", icon: Users }, { href: "/admin/team", label: "Команда", icon: Users },
{ href: "/admin/classes", label: "Направления", icon: BookOpen }, { href: "/admin/classes", label: "Направления", icon: BookOpen },
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen },
{ href: "/admin/schedule", label: "Расписание", icon: Calendar }, { href: "/admin/schedule", label: "Расписание", icon: Calendar },
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
{ href: "/admin/pricing", label: "Цены", icon: DollarSign }, { href: "/admin/pricing", label: "Цены", icon: DollarSign },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle }, { href: "/admin/faq", label: "FAQ", icon: HelpCircle },
{ href: "/admin/news", label: "Новости", icon: Newspaper },
{ href: "/admin/contact", label: "Контакты", icon: Phone }, { href: "/admin/contact", label: "Контакты", icon: Phone },
]; ];
@@ -41,12 +50,27 @@ export default function AdminLayout({
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [unreadTotal, setUnreadTotal] = useState(0);
// Don't render admin shell on login page // Don't render admin shell on login page
if (pathname === "/admin/login") { if (pathname === "/admin/login") {
return <>{children}</>; return <>{children}</>;
} }
// Fetch unread counts — poll every 30s
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
function fetchCounts() {
adminFetch("/api/admin/unread-counts")
.then((r) => r.json())
.then((data: { total: number }) => setUnreadTotal(data.total))
.catch(() => {});
}
fetchCounts();
const interval = setInterval(fetchCounts, 30000);
return () => clearInterval(interval);
}, []);
async function handleLogout() { async function handleLogout() {
await fetch("/api/logout", { method: "POST" }); await fetch("/api/logout", { method: "POST" });
router.push("/admin/login"); router.push("/admin/login");
@@ -102,6 +126,11 @@ export default function AdminLayout({
> >
<Icon size={18} /> <Icon size={18} />
{item.label} {item.label}
{item.href === "/admin/bookings" && unreadTotal > 0 && (
<span className="ml-auto rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
{unreadTotal > 99 ? "99+" : unreadTotal}
</span>
)}
</Link> </Link>
); );
})} })}

View File

@@ -0,0 +1,628 @@
"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 { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
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 ?? "0"}
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>
);
}
interface MasterClassesData {
title: string;
successMessage?: string;
items: MasterClassItem[];
}
// --- Autocomplete Multi-Select ---
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(", ").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>
{/* Selected chips + input */}
<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"
}`}
>
{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>
{/* Dropdown */}
{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>
);
}
// --- 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 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);
return (
<div key={i} 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 border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
/>
<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 border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
/>
{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>
);
})}
<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>
);
}
// --- Image Upload ---
function ImageUploadField({
value,
onChange,
}: {
value: string;
onChange: (path: string) => void;
}) {
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(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", "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="flex items-center gap-2">
<div className="flex items-center gap-1.5 rounded-lg bg-neutral-700/50 px-3 py-2 text-sm text-neutral-300">
<ImageIcon size={14} className="text-gold" />
<span className="max-w-[200px] truncate">
{value.split("/").pop()}
</span>
</div>
<button
type="button"
onClick={() => onChange("")}
className="rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
>
<X size={14} />
</button>
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
{uploading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Upload size={14} />
)}
Заменить
<input
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
</label>
</div>
) : (
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-3 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
{uploading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Upload size={16} />
)}
{uploading ? "Загрузка..." : "Загрузить изображение"}
<input
ref={inputRef}
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>
);
}
// --- Main page ---
export default function MasterClassesEditorPage() {
const [trainers, setTrainers] = useState<string[]>([]);
const [styles, setStyles] = useState<string[]>([]);
const [locations, setLocations] = useState<{ name: string; address: string }[]>([]);
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) => (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<InputField
label="Текст после записи (success popup)"
value={data.successMessage || ""}
onChange={(v) => update({ ...data, successMessage: v || undefined })}
placeholder="Вы записаны! Мы свяжемся с вами"
/>
<ArrayEditor
label="Мастер-классы"
items={data.items}
onChange={(items) => update({ ...data, items })}
renderItem={(item, _i, updateItem) => (
<div className="space-y-3">
<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="Мастер-класс от Анны Тарыбы"
/>
<ImageUploadField
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 })
}
/>
</div>
)}
createItem={() => ({
title: "",
image: "",
slots: [],
trainer: "",
cost: "",
style: "",
})}
addLabel="Добавить мастер-класс"
/>
</>
)}
</SectionEditor>
);
}

166
src/app/admin/news/page.tsx Normal file
View File

@@ -0,0 +1,166 @@
"use client";
import { useState, useRef } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { Upload, Loader2, ImageIcon, X } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { NewsItem } from "@/types/content";
interface NewsData {
title: string;
items: NewsItem[];
}
function ImageUploadField({
value,
onChange,
}: {
value: string;
onChange: (path: string) => void;
}) {
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(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) 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="flex items-center gap-2">
<div className="flex items-center gap-1.5 rounded-lg bg-neutral-700/50 px-3 py-2 text-sm text-neutral-300">
<ImageIcon size={14} className="text-gold" />
<span className="max-w-[200px] truncate">
{value.split("/").pop()}
</span>
</div>
<button
type="button"
onClick={() => onChange("")}
className="rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
>
<X size={14} />
</button>
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
{uploading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Upload size={14} />
)}
Заменить
<input
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
</label>
</div>
) : (
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-3 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
{uploading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Upload size={16} />
)}
{uploading ? "Загрузка..." : "Загрузить изображение"}
<input
ref={inputRef}
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 })}
renderItem={(item, _i, updateItem) => (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<InputField
label="Заголовок"
value={item.title}
onChange={(v) => updateItem({ ...item, title: v })}
/>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Дата</label>
<input
type="date"
value={item.date}
onChange={(e) => updateItem({ ...item, 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>
<TextareaField
label="Текст"
value={item.text}
onChange={(v) => updateItem({ ...item, text: v })}
/>
<div className="grid gap-3 sm:grid-cols-2">
<ImageUploadField
value={item.image || ""}
onChange={(v) => updateItem({ ...item, image: v || undefined })}
/>
<InputField
label="Ссылка (необязательно)"
value={item.link || ""}
onChange={(v) => updateItem({ ...item, link: v || undefined })}
placeholder="https://instagram.com/p/..."
/>
</div>
</div>
)}
createItem={(): NewsItem => ({
title: "",
text: "",
date: new Date().toISOString().slice(0, 10),
})}
addLabel="Добавить новость"
/>
</>
)}
</SectionEditor>
);
}

View File

@@ -0,0 +1,711 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import {
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, ChevronDown, ChevronUp,
Phone, Instagram, Send,
} from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { NotifyToggle } from "../_components/NotifyToggle";
// --- Types ---
interface OpenDayEvent {
id: number;
date: string;
title: string;
description?: string;
pricePerClass: number;
discountPrice: number;
discountThreshold: number;
minBookings: number;
active: boolean;
}
interface OpenDayClass {
id: number;
eventId: number;
hall: string;
startTime: string;
endTime: string;
trainer: string;
style: string;
cancelled: boolean;
sortOrder: number;
bookingCount: number;
}
interface OpenDayBooking {
id: number;
classId: number;
eventId: number;
name: string;
phone: string;
instagram?: string;
telegram?: string;
notifiedConfirm: boolean;
notifiedReminder: boolean;
createdAt: string;
classStyle?: string;
classTrainer?: string;
classTime?: string;
classHall?: string;
}
// --- Helpers ---
function generateTimeSlots(startHour: number, endHour: number): string[] {
const slots: string[] = [];
for (let h = startHour; h < endHour; h++) {
slots.push(`${h.toString().padStart(2, "0")}:00`);
}
return slots;
}
function addHour(time: string): string {
const [h, m] = time.split(":").map(Number);
return `${(h + 1).toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
}
// --- Event Settings ---
function EventSettings({
event,
onChange,
}: {
event: OpenDayEvent;
onChange: (patch: Partial<OpenDayEvent>) => void;
}) {
return (
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-4">
<h2 className="text-lg font-bold flex items-center gap-2">
<Calendar size={18} className="text-gold" />
Настройки мероприятия
</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Название</label>
<input
type="text"
value={event.title}
onChange={(e) => onChange({ title: e.target.value })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Дата</label>
<input
type="date"
value={event.date}
onChange={(e) => onChange({ date: e.target.value })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
/>
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Описание</label>
<textarea
value={event.description || ""}
onChange={(e) => onChange({ description: e.target.value || undefined })}
rows={2}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-none"
placeholder="Описание мероприятия..."
/>
</div>
<div className="grid gap-4 sm:grid-cols-4">
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label>
<input
type="number"
value={event.pricePerClass}
onChange={(e) => onChange({ pricePerClass: parseInt(e.target.value) || 0 })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Скидка (BYN)</label>
<input
type="number"
value={event.discountPrice}
onChange={(e) => onChange({ discountPrice: parseInt(e.target.value) || 0 })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">От N занятий</label>
<input
type="number"
value={event.discountThreshold}
onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 1 })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Мин. записей</label>
<input
type="number"
value={event.minBookings}
onChange={(e) => onChange({ minBookings: parseInt(e.target.value) || 1 })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
/>
</div>
</div>
<div className="flex items-center gap-3 pt-1">
<button
type="button"
onClick={() => onChange({ active: !event.active })}
className={`relative flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${
event.active
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30"
: "bg-neutral-800 text-neutral-400 border border-white/10"
}`}
>
{event.active ? <CheckCircle2 size={14} /> : <Ban size={14} />}
{event.active ? "Опубликовано" : "Черновик"}
</button>
<span className="text-xs text-neutral-500">
{event.pricePerClass} BYN / занятие, от {event.discountThreshold} {event.discountPrice} BYN
</span>
</div>
</div>
);
}
// --- Class Grid Cell ---
function ClassCell({
cls,
minBookings,
trainers,
styles,
onUpdate,
onDelete,
onCancel,
}: {
cls: OpenDayClass;
minBookings: number;
trainers: string[];
styles: string[];
onUpdate: (id: number, data: Partial<OpenDayClass>) => void;
onDelete: (id: number) => void;
onCancel: (id: number) => void;
}) {
const [editing, setEditing] = useState(false);
const [trainer, setTrainer] = useState(cls.trainer);
const [style, setStyle] = useState(cls.style);
const atRisk = cls.bookingCount < minBookings && !cls.cancelled;
function save() {
if (trainer.trim() && style.trim()) {
onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim() });
setEditing(false);
}
}
if (editing) {
return (
<div className="p-2 space-y-1.5">
<select
value={trainer}
onChange={(e) => setTrainer(e.target.value)}
className="w-full rounded-md border border-white/10 bg-neutral-800 px-2 py-1 text-xs text-white outline-none focus:border-gold"
>
<option value="">Тренер...</option>
{trainers.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<select
value={style}
onChange={(e) => setStyle(e.target.value)}
className="w-full rounded-md border border-white/10 bg-neutral-800 px-2 py-1 text-xs text-white outline-none focus:border-gold"
>
<option value="">Стиль...</option>
{styles.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<div className="flex gap-1 justify-end">
<button onClick={() => setEditing(false)} className="text-[10px] text-neutral-500 hover:text-white px-1">
Отмена
</button>
<button onClick={save} className="text-[10px] text-gold hover:text-gold-light px-1 font-medium">
OK
</button>
</div>
</div>
);
}
return (
<div
className={`group relative p-2 rounded-lg cursor-pointer transition-all ${
cls.cancelled
? "bg-neutral-800/30 opacity-50"
: atRisk
? "bg-red-500/5 border border-red-500/20"
: "bg-gold/5 border border-gold/15 hover:border-gold/30"
}`}
onClick={() => setEditing(true)}
>
<div className="text-xs font-medium text-white truncate">{cls.style}</div>
<div className="text-[10px] text-neutral-400 truncate">{cls.trainer}</div>
<div className="flex items-center gap-1 mt-1">
<span className={`text-[10px] font-medium ${
cls.cancelled
? "text-neutral-500 line-through"
: atRisk
? "text-red-400"
: "text-emerald-400"
}`}>
{cls.bookingCount} чел.
</span>
{atRisk && !cls.cancelled && (
<span className="text-[9px] text-red-400">мин. {minBookings}</span>
)}
{cls.cancelled && <span className="text-[9px] text-neutral-500">отменено</span>}
</div>
{/* Actions */}
<div className="absolute top-1 right-1 hidden group-hover:flex gap-0.5">
<button
onClick={(e) => { e.stopPropagation(); onCancel(cls.id); }}
className="rounded p-0.5 text-neutral-500 hover:text-yellow-400"
title={cls.cancelled ? "Восстановить" : "Отменить"}
>
<Ban size={10} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete(cls.id); }}
className="rounded p-0.5 text-neutral-500 hover:text-red-400"
title="Удалить"
>
<Trash2 size={10} />
</button>
</div>
</div>
);
}
// --- Schedule Grid ---
function ScheduleGrid({
eventId,
minBookings,
halls,
classes,
trainers,
styles,
onClassesChange,
}: {
eventId: number;
minBookings: number;
halls: string[];
classes: OpenDayClass[];
trainers: string[];
styles: string[];
onClassesChange: () => void;
}) {
const timeSlots = generateTimeSlots(10, 22);
// Build lookup: hall -> time -> class
const grid = useMemo(() => {
const map: Record<string, Record<string, OpenDayClass>> = {};
for (const hall of halls) map[hall] = {};
for (const cls of classes) {
if (!map[cls.hall]) map[cls.hall] = {};
map[cls.hall][cls.startTime] = cls;
}
return map;
}, [classes, halls]);
async function addClass(hall: string, startTime: string) {
const endTime = addHour(startTime);
await adminFetch("/api/admin/open-day/classes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventId, hall, startTime, endTime, trainer: "—", style: "—" }),
});
onClassesChange();
}
async function updateClass(id: number, data: Partial<OpenDayClass>) {
await adminFetch("/api/admin/open-day/classes", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, ...data }),
});
onClassesChange();
}
async function deleteClass(id: number) {
await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
onClassesChange();
}
async function cancelClass(id: number) {
const cls = classes.find((c) => c.id === id);
if (!cls) return;
await updateClass(id, { cancelled: !cls.cancelled });
}
return (
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-3">
<h2 className="text-lg font-bold">Расписание</h2>
{halls.length === 0 ? (
<p className="text-sm text-neutral-500">Нет залов в расписании. Добавьте локации в разделе «Расписание».</p>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[500px]">
<thead>
<tr>
<th className="text-left text-xs text-neutral-500 font-medium pb-2 w-16">Время</th>
{halls.map((hall) => (
<th key={hall} className="text-left text-xs text-neutral-400 font-medium pb-2 px-1">
{hall}
</th>
))}
</tr>
</thead>
<tbody>
{timeSlots.map((time) => (
<tr key={time} className="border-t border-white/5">
<td className="text-xs text-neutral-500 py-1 pr-2 align-top pt-2">{time}</td>
{halls.map((hall) => {
const cls = grid[hall]?.[time];
return (
<td key={hall} className="py-1 px-1 align-top">
{cls ? (
<ClassCell
cls={cls}
minBookings={minBookings}
trainers={trainers}
styles={styles}
onUpdate={updateClass}
onDelete={deleteClass}
onCancel={cancelClass}
/>
) : (
<button
onClick={() => addClass(hall, time)}
className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors"
>
<Plus size={12} className="mx-auto" />
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// --- Bookings Table ---
function BookingsSection({
eventId,
eventDate,
}: {
eventId: number;
eventDate: string;
}) {
const [open, setOpen] = useState(false);
const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
const [loading, setLoading] = useState(false);
const reminderUrgent = useMemo(() => {
if (!eventDate) return false;
const now = Date.now();
const twoDays = 2 * 24 * 60 * 60 * 1000;
const eventTime = new Date(eventDate + "T10:00").getTime();
const diff = eventTime - now;
return diff >= 0 && diff <= twoDays;
}, [eventDate]);
function load() {
setLoading(true);
adminFetch(`/api/admin/open-day/bookings?eventId=${eventId}`)
.then((r) => r.json())
.then((data: OpenDayBooking[]) => setBookings(data))
.catch(() => {})
.finally(() => setLoading(false));
}
function toggle() {
if (!open) load();
setOpen(!open);
}
async function handleToggle(id: number, field: "notified_confirm" | "notified_reminder") {
const b = bookings.find((x) => x.id === id);
if (!b) return;
const key = field === "notified_confirm" ? "notifiedConfirm" : "notifiedReminder";
const newValue = !b[key];
setBookings((prev) => prev.map((x) => x.id === id ? { ...x, [key]: newValue } : x));
await adminFetch("/api/admin/open-day/bookings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "toggle-notify", id, field, value: newValue }),
});
}
async function handleDelete(id: number) {
await adminFetch(`/api/admin/open-day/bookings?id=${id}`, { method: "DELETE" });
setBookings((prev) => prev.filter((x) => x.id !== id));
}
const newCount = bookings.filter((b) => !b.notifiedConfirm).length;
return (
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5">
<button
onClick={toggle}
className="flex items-center gap-2 text-lg font-bold hover:text-gold transition-colors"
>
{open ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
Записи
{bookings.length > 0 && (
<span className="text-sm font-normal text-neutral-400">({bookings.length})</span>
)}
{newCount > 0 && (
<span className="rounded-full bg-red-500/20 text-red-400 px-2 py-0.5 text-[10px] font-medium">
{newCount} новых
</span>
)}
</button>
{open && (
<div className="mt-3 space-y-2">
{loading && (
<div className="flex items-center gap-2 text-neutral-500 text-sm py-4 justify-center">
<Loader2 size={14} className="animate-spin" />
Загрузка...
</div>
)}
{!loading && bookings.length === 0 && (
<p className="text-sm text-neutral-500 text-center py-4">Пока нет записей</p>
)}
{bookings.map((b) => (
<div
key={b.id}
className={`rounded-lg p-3 space-y-1.5 ${
!b.notifiedConfirm ? "bg-gold/[0.03] border border-gold/20" : "bg-neutral-800/50 border border-white/5"
}`}
>
<div className="flex items-center gap-2 flex-wrap text-sm">
<span className="font-medium text-white">{b.name}</span>
<a
href={`tel:${b.phone}`}
className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs"
>
<Phone size={10} />
{b.phone}
</a>
{b.instagram && (
<a
href={`https://ig.me/m/${b.instagram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs"
>
<Instagram size={10} />
{b.instagram}
</a>
)}
{b.telegram && (
<a
href={`https://t.me/${b.telegram.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs"
>
<Send size={10} />
{b.telegram}
</a>
)}
<span className="text-[10px] text-neutral-500 ml-auto">
{b.classHall} {b.classTime} · {b.classStyle}
</span>
<button
onClick={() => handleDelete(b.id)}
className="rounded p-1 text-neutral-500 hover:text-red-400"
>
<Trash2 size={12} />
</button>
</div>
<NotifyToggle
confirmed={b.notifiedConfirm}
reminded={b.notifiedReminder}
reminderUrgent={reminderUrgent && !b.notifiedReminder}
onToggleConfirm={() => handleToggle(b.id, "notified_confirm")}
onToggleReminder={() => handleToggle(b.id, "notified_reminder")}
/>
</div>
))}
</div>
)}
</div>
);
}
// --- Main Page ---
export default function OpenDayAdminPage() {
const [event, setEvent] = useState<OpenDayEvent | null>(null);
const [classes, setClasses] = useState<OpenDayClass[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [trainers, setTrainers] = useState<string[]>([]);
const [styles, setStyles] = useState<string[]>([]);
const [halls, setHalls] = useState<string[]>([]);
const saveTimerRef = { current: null as ReturnType<typeof setTimeout> | null };
// Load data
useEffect(() => {
Promise.all([
adminFetch("/api/admin/open-day").then((r) => r.json()),
adminFetch("/api/admin/team").then((r) => r.json()),
adminFetch("/api/admin/sections/classes").then((r) => r.json()),
adminFetch("/api/admin/sections/schedule").then((r) => r.json()),
])
.then(([events, members, classesData, scheduleData]: [OpenDayEvent[], { name: string }[], { items: { name: string }[] }, { locations: { name: string }[] }]) => {
if (events.length > 0) {
setEvent(events[0]);
loadClasses(events[0].id);
}
setTrainers(members.map((m) => m.name));
setStyles(classesData.items.map((c) => c.name));
setHalls(scheduleData.locations.map((l) => l.name));
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
function loadClasses(eventId: number) {
adminFetch(`/api/admin/open-day/classes?eventId=${eventId}`)
.then((r) => r.json())
.then((data: OpenDayClass[]) => setClasses(data))
.catch(() => {});
}
// Auto-save event changes
const saveEvent = useCallback(
(updated: OpenDayEvent) => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
setSaving(true);
await adminFetch("/api/admin/open-day", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updated),
});
setSaving(false);
}, 800);
},
[]
);
function handleEventChange(patch: Partial<OpenDayEvent>) {
if (!event) return;
const updated = { ...event, ...patch };
setEvent(updated);
saveEvent(updated);
}
async function createEvent() {
const today = new Date().toISOString().split("T")[0];
const res = await adminFetch("/api/admin/open-day", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ date: today }),
});
const { id } = await res.json();
setEvent({
id,
date: today,
title: "День открытых дверей",
pricePerClass: 30,
discountPrice: 20,
discountThreshold: 3,
minBookings: 4,
active: true,
});
}
async function deleteEvent() {
if (!event) return;
await adminFetch(`/api/admin/open-day?id=${event.id}`, { method: "DELETE" });
setEvent(null);
setClasses([]);
}
if (loading) {
return (
<div className="flex items-center gap-2 py-12 text-neutral-500 justify-center">
<Loader2 size={18} className="animate-spin" />
Загрузка...
</div>
);
}
if (!event) {
return (
<div className="text-center py-12">
<h1 className="text-2xl font-bold">День открытых дверей</h1>
<p className="mt-2 text-neutral-400">Создайте мероприятие, чтобы начать</p>
<button
onClick={createEvent}
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-gold px-6 py-3 text-sm font-semibold text-black hover:bg-gold-light transition-colors"
>
<Plus size={16} />
Создать мероприятие
</button>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">День открытых дверей</h1>
{saving && <span className="text-xs text-neutral-500">Сохранение...</span>}
</div>
<button
onClick={deleteEvent}
className="flex items-center gap-1.5 rounded-lg border border-red-500/20 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 size={12} />
Удалить
</button>
</div>
<EventSettings event={event} onChange={handleEventChange} />
<ScheduleGrid
eventId={event.id}
minBookings={event.minBookings}
halls={halls}
classes={classes}
trainers={trainers}
styles={styles}
onClassesChange={() => loadClasses(event.id)}
/>
<BookingsSection eventId={event.id} eventDate={event.date} />
</div>
);
}

View File

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

View File

@@ -19,6 +19,7 @@ interface PricingData {
rentalTitle: string; rentalTitle: string;
rentalItems: { name: string; price: string; note?: string }[]; rentalItems: { name: string; price: string; note?: string }[];
rules: string[]; rules: string[];
showContactHint?: boolean;
} }
function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) { function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
@@ -63,31 +64,34 @@ export default function PricingEditorPage() {
onChange={(v) => update({ ...data, subtitle: v })} onChange={(v) => update({ ...data, subtitle: v })}
/> />
{/* Popular & Featured selectors */} <label className="inline-flex items-center gap-2 cursor-pointer select-none">
<button
type="button"
role="switch"
aria-checked={data.showContactHint !== false}
onClick={() => update({ ...data, showContactHint: data.showContactHint === false })}
className={`relative h-5 w-9 rounded-full transition-colors ${
data.showContactHint !== false ? "bg-gold" : "bg-neutral-600"
}`}
>
<span
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
data.showContactHint !== false ? "translate-x-4" : ""
}`}
/>
</button>
<span className="text-sm text-neutral-400">Показывать контакты для записи (Instagram, Telegram, телефон)</span>
</label>
{/* Featured selector */}
{(() => { {(() => {
const itemOptions = data.items const itemOptions = data.items
.map((it, idx) => ({ value: String(idx), label: it.name })) .map((it, idx) => ({ value: String(idx), label: it.name }))
.filter((o) => o.label.trim() !== ""); .filter((o) => o.label.trim() !== "");
const noneOption = { value: "", label: "— Нет —" }; const noneOption = { value: "", label: "— Нет —" };
const popularIdx = data.items.findIndex((it) => it.popular);
const featuredIdx = data.items.findIndex((it) => it.featured); const featuredIdx = data.items.findIndex((it) => it.featured);
return ( return (
<div className="grid gap-3 sm:grid-cols-2">
<SelectField
label="Популярный абонемент"
value={popularIdx >= 0 ? String(popularIdx) : ""}
onChange={(v) => {
const items = data.items.map((it, idx) => ({
...it,
popular: v ? idx === Number(v) : false,
}));
update({ ...data, items });
}}
options={[noneOption, ...itemOptions]}
placeholder="Выберите..."
/>
<SelectField <SelectField
label="Выделенный абонемент (безлимит)" label="Выделенный абонемент (безлимит)"
value={featuredIdx >= 0 ? String(featuredIdx) : ""} value={featuredIdx >= 0 ? String(featuredIdx) : ""}
@@ -101,7 +105,6 @@ export default function PricingEditorPage() {
options={[noneOption, ...itemOptions]} options={[noneOption, ...itemOptions]}
placeholder="Выберите..." placeholder="Выберите..."
/> />
</div>
); );
})()} })()}
@@ -110,6 +113,7 @@ export default function PricingEditorPage() {
items={data.items} items={data.items}
onChange={(items) => update({ ...data, items })} onChange={(items) => update({ ...data, items })}
renderItem={(item, _i, updateItem) => ( renderItem={(item, _i, updateItem) => (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
<InputField <InputField
label="Название" label="Название"
@@ -127,6 +131,25 @@ export default function PricingEditorPage() {
onChange={(v) => updateItem({ ...item, note: v })} onChange={(v) => updateItem({ ...item, note: v })}
/> />
</div> </div>
<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: "" })} createItem={() => ({ name: "", price: "", note: "" })}
addLabel="Добавить абонемент" addLabel="Добавить абонемент"

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField"; import { InputField, SelectField, TimeRangeField, ToggleField } from "../_components/FormField";
import { Plus, X, Trash2 } from "lucide-react"; import { Plus, X, Trash2 } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content"; import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
interface ScheduleData { interface ScheduleData {
@@ -211,12 +212,16 @@ function ClassBlock({
} }
// ---------- Edit Modal ---------- // ---------- Edit Modal ----------
/** Check if two classes are the same "group" (same trainer + type + time) */ /** Same group = matching groupId, or fallback to trainer + type for legacy data */
/** Same group = same trainer + type */
function isSameGroup(a: ScheduleClass, b: ScheduleClass): boolean { function isSameGroup(a: ScheduleClass, b: ScheduleClass): boolean {
if (a.groupId && b.groupId) return a.groupId === b.groupId;
return a.trainer === b.trainer && a.type === b.type; return a.trainer === b.trainer && a.type === b.type;
} }
function generateGroupId(): string {
return `g_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
}
function ClassModal({ function ClassModal({
cls, cls,
trainers, trainers,
@@ -565,6 +570,83 @@ function CalendarGrid({
cls: ScheduleClass; cls: ScheduleClass;
} | null>(null); } | null>(null);
// Auto-assign groupId to legacy classes that don't have one
useEffect(() => {
const needsMigration = location.days.some((d) =>
d.classes.some((c) => !c.groupId)
);
if (!needsMigration) return;
// Collect all legacy classes per trainer+type key
const buckets = new Map<string, { dayIdx: number; clsIdx: number; time: string }[]>();
location.days.forEach((d, di) => {
d.classes.forEach((c, ci) => {
if (c.groupId) return;
const key = `${c.trainer}|${c.type}`;
const bucket = buckets.get(key);
if (bucket) bucket.push({ dayIdx: di, clsIdx: ci, time: c.time });
else buckets.set(key, [{ dayIdx: di, clsIdx: ci, time: c.time }]);
});
});
// For each bucket, figure out how many distinct groups there are.
// If any day has N entries with same trainer+type, there are at least N groups.
// Assign groups by matching closest times across days.
const assignedIds = new Map<string, string>(); // "dayIdx:clsIdx" -> groupId
for (const entries of buckets.values()) {
// Count max entries per day
const perDay = new Map<number, typeof entries>();
for (const e of entries) {
const arr = perDay.get(e.dayIdx);
if (arr) arr.push(e);
else perDay.set(e.dayIdx, [e]);
}
const maxPerDay = Math.max(...[...perDay.values()].map((a) => a.length));
if (maxPerDay <= 1) {
// Simple: one group
const gid = generateGroupId();
for (const e of entries) assignedIds.set(`${e.dayIdx}:${e.clsIdx}`, gid);
} else {
// Find the day with most entries — those define the seed groups
const busiestDay = [...perDay.entries()].sort((a, b) => b[1].length - a[1].length)[0];
const seeds = busiestDay[1].map((e) => ({
gid: generateGroupId(),
time: timeToMinutes(e.time.split("")[0]?.trim() || ""),
entry: e,
}));
// Assign seeds
for (const s of seeds) assignedIds.set(`${s.entry.dayIdx}:${s.entry.clsIdx}`, s.gid);
// Assign remaining entries to closest seed by start time
for (const e of entries) {
const k = `${e.dayIdx}:${e.clsIdx}`;
if (assignedIds.has(k)) continue;
const eMin = timeToMinutes(e.time.split("")[0]?.trim() || "");
let bestSeed = seeds[0];
let bestDiff = Infinity;
for (const s of seeds) {
const diff = Math.abs(eMin - s.time);
if (diff < bestDiff) { bestDiff = diff; bestSeed = s; }
}
assignedIds.set(k, bestSeed.gid);
}
}
}
// Apply groupIds
const migratedDays = location.days.map((d, di) => ({
...d,
classes: d.classes.map((c, ci) => {
if (c.groupId) return c;
return { ...c, groupId: assignedIds.get(`${di}:${ci}`) ?? generateGroupId() };
}),
}));
onChange({ ...location, days: migratedDays });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Compute group-based colors for calendar blocks // Compute group-based colors for calendar blocks
const sortedDaysForColors = sortDaysByWeekday(location.days); const sortedDaysForColors = sortDaysByWeekday(location.days);
const groupColors = useMemo( const groupColors = useMemo(
@@ -749,6 +831,7 @@ function CalendarGrid({
time: `${startTime}${endTime}`, time: `${startTime}${endTime}`,
trainer: "", trainer: "",
type: "", type: "",
groupId: generateGroupId(),
}, },
}); });
} }
@@ -1031,21 +1114,21 @@ export default function ScheduleEditorPage() {
const [classTypes, setClassTypes] = useState<string[]>([]); const [classTypes, setClassTypes] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
fetch("/api/admin/team") adminFetch("/api/admin/team")
.then((r) => r.json()) .then((r) => r.json())
.then((members: { name: string }[]) => { .then((members: { name: string }[]) => {
setTrainers(members.map((m) => m.name)); setTrainers(members.map((m) => m.name));
}) })
.catch(() => {}); .catch(() => {});
fetch("/api/admin/sections/contact") adminFetch("/api/admin/sections/contact")
.then((r) => r.json()) .then((r) => r.json())
.then((contact: { addresses?: string[] }) => { .then((contact: { addresses?: string[] }) => {
setAddresses(contact.addresses ?? []); setAddresses(contact.addresses ?? []);
}) })
.catch(() => {}); .catch(() => {});
fetch("/api/admin/sections/classes") adminFetch("/api/admin/sections/classes")
.then((r) => r.json()) .then((r) => r.json())
.then((classes: { items?: { name: string }[] }) => { .then((classes: { items?: { name: string }[] }) => {
setClassTypes((classes.items ?? []).map((c) => c.name)); setClassTypes((classes.items ?? []).map((c) => c.name));

View File

@@ -1,10 +1,19 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter, useParams } from "next/navigation"; import { useRouter, useParams } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import { Save, Loader2, Check, ArrowLeft, Upload } from "lucide-react"; import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
import { InputField, TextareaField, ListField } from "../../_components/FormField"; import { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField } from "../../_components/FormField";
import { adminFetch } from "@/lib/csrf";
import type { RichListItem, VictoryItem } 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 { interface MemberForm {
name: string; name: string;
@@ -13,8 +22,8 @@ interface MemberForm {
instagram: string; instagram: string;
description: string; description: string;
experience: string[]; experience: string[];
victories: string[]; victories: VictoryItem[];
education: string[]; education: RichListItem[];
} }
export default function TeamMemberEditorPage() { export default function TeamMemberEditorPage() {
@@ -37,43 +46,113 @@ export default function TeamMemberEditorPage() {
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
// 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>>({});
function validateUrl(url: string): boolean {
if (!url) return true;
try { new URL(url); return true; } catch { return false; }
}
// City validation for victories
const [cityErrors, setCityErrors] = useState<Record<number, string>>({});
const [citySuggestions, setCitySuggestions] = useState<{ index: number; items: string[] } | null>(null);
const cityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchCity = useCallback((index: number, query: string) => {
if (cityTimerRef.current) clearTimeout(cityTimerRef.current);
if (!query || query.length < 2) { setCitySuggestions(null); return; }
cityTimerRef.current = setTimeout(async () => {
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&accept-language=ru`,
{ headers: { "User-Agent": "BlackheartAdmin/1.0" } }
);
const results = await res.json();
const cities = results
.map((r: Record<string, unknown>) => {
const addr = r.address as Record<string, string> | undefined;
const city = addr?.city || addr?.town || addr?.village || addr?.state || (r.name as string);
const country = addr?.country || "";
return country ? `${city}, ${country}` : city;
})
.filter((v: string, i: number, a: string[]) => a.indexOf(v) === i)
.slice(0, 6);
setCitySuggestions(cities.length > 0 ? { index, items: cities } : null);
setCityErrors((prev) => { const n = { ...prev }; delete n[index]; return n; });
} catch {
setCitySuggestions(null);
}
}, 500);
}, []);
useEffect(() => { useEffect(() => {
if (isNew) return; if (isNew) return;
fetch(`/api/admin/team/${id}`) adminFetch(`/api/admin/team/${id}`)
.then((r) => r.json()) .then((r) => r.json())
.then((member) => .then((member) => {
const username = extractUsername(member.instagram || "");
setData({ setData({
name: member.name, name: member.name,
role: member.role, role: member.role,
image: member.image, image: member.image,
instagram: member.instagram || "", instagram: username,
description: member.description || "", description: member.description || "",
experience: member.experience || [], experience: member.experience || [],
victories: member.victories || [], victories: member.victories || [],
education: member.education || [], education: member.education || [],
});
if (username) setIgStatus("valid"); // existing data is trusted
}) })
)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id, isNew]); }, [id, isNew]);
const hasErrors = igStatus === "invalid" || Object.keys(linkErrors).length > 0 || Object.keys(cityErrors).length > 0;
async function handleSave() { async function handleSave() {
if (hasErrors) return;
setSaving(true); setSaving(true);
setSaved(false); setSaved(false);
// Build instagram as full URL for storage if username is provided
const payload = {
...data,
instagram: data.instagram ? `https://instagram.com/${data.instagram}` : "",
};
if (isNew) { if (isNew) {
const res = await fetch("/api/admin/team", { const res = await adminFetch("/api/admin/team", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(payload),
}); });
if (res.ok) { if (res.ok) {
router.push("/admin/team"); router.push("/admin/team");
} }
} else { } else {
const res = await fetch(`/api/admin/team/${id}`, { const res = await adminFetch(`/api/admin/team/${id}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(payload),
}); });
if (res.ok) { if (res.ok) {
setSaved(true); setSaved(true);
@@ -93,7 +172,7 @@ export default function TeamMemberEditorPage() {
formData.append("folder", "team"); formData.append("folder", "team");
try { try {
const res = await fetch("/api/admin/upload", { const res = await adminFetch("/api/admin/upload", {
method: "POST", method: "POST",
body: formData, body: formData,
}); });
@@ -133,7 +212,7 @@ export default function TeamMemberEditorPage() {
</div> </div>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving || !data.name || !data.role} 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" 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 ? ( {saving ? (
@@ -188,13 +267,40 @@ export default function TeamMemberEditorPage() {
value={data.role} value={data.role}
onChange={(v) => setData({ ...data, role: v })} onChange={(v) => setData({ ...data, role: v })}
/> />
<InputField <div>
label="Instagram" <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} value={data.instagram}
onChange={(v) => setData({ ...data, instagram: v })} onChange={(e) => {
type="url" const username = extractUsername(e.target.value);
placeholder="https://instagram.com/..." 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 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>
<TextareaField <TextareaField
label="Описание" label="Описание"
value={data.description} value={data.description}
@@ -211,17 +317,37 @@ export default function TeamMemberEditorPage() {
onChange={(items) => setData({ ...data, experience: items })} onChange={(items) => setData({ ...data, experience: items })}
placeholder="Например: 10 лет в танцах" placeholder="Например: 10 лет в танцах"
/> />
<ListField <VictoryItemListField
label="Достижения" label="Достижения"
items={data.victories} items={data.victories}
onChange={(items) => setData({ ...data, victories: items })} onChange={(items) => setData({ ...data, victories: items })}
placeholder="Например: 1 место — чемпионат..." cityErrors={cityErrors}
citySuggestions={citySuggestions}
onCitySearch={searchCity}
onCitySelect={(i, v) => {
const updated = data.victories.map((item, idx) => idx === i ? { ...item, location: v } : item);
setData({ ...data, victories: updated });
setCitySuggestions(null);
setCityErrors((prev) => { const n = { ...prev }; delete n[i]; return n; });
}}
onLinkValidate={(key, error) => {
setLinkErrors((prev) => {
if (error) return { ...prev, [key]: error };
const n = { ...prev }; delete n[key]; return n;
});
}}
/> />
<ListField <VictoryListField
label="Образование" label="Образование"
items={data.education} items={data.education}
onChange={(items) => setData({ ...data, education: items })} onChange={(items) => setData({ ...data, education: items })}
placeholder="Например: Сертификат IPSF" placeholder="Например: Сертификат IPSF"
onLinkValidate={(key, error) => {
setLinkErrors((prev) => {
if (error) return { ...prev, [key]: error };
const n = { ...prev }; delete n[key]; return n;
});
}}
/> />
</div> </div>
</div> </div>

View File

@@ -9,9 +9,9 @@ import {
Plus, Plus,
Trash2, Trash2,
GripVertical, GripVertical,
Pencil,
Check, Check,
} from "lucide-react"; } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { TeamMember } from "@/types/content"; import type { TeamMember } from "@/types/content";
type Member = TeamMember & { id: number }; type Member = TeamMember & { id: number };
@@ -30,7 +30,7 @@ export default function TeamEditorPage() {
const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => { useEffect(() => {
fetch("/api/admin/team") adminFetch("/api/admin/team")
.then((r) => r.json()) .then((r) => r.json())
.then(setMembers) .then(setMembers)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -39,7 +39,7 @@ export default function TeamEditorPage() {
const saveOrder = useCallback(async (updated: Member[]) => { const saveOrder = useCallback(async (updated: Member[]) => {
setMembers(updated); setMembers(updated);
setSaving(true); setSaving(true);
await fetch("/api/admin/team/reorder", { await adminFetch("/api/admin/team/reorder", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: updated.map((m) => m.id) }), body: JSON.stringify({ ids: updated.map((m) => m.id) }),
@@ -80,16 +80,23 @@ export default function TeamEditorPage() {
const x = e.clientX; const x = e.clientX;
const y = e.clientY; const y = e.clientY;
const pendingIndex = index; const pendingIndex = index;
let moved = false;
function onMove(ev: MouseEvent) { function onMove(ev: MouseEvent) {
const dx = ev.clientX - x; const dx = ev.clientX - x;
const dy = ev.clientY - y; const dy = ev.clientY - y;
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) { if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
moved = true;
cleanup(); cleanup();
startDrag(ev.clientX, ev.clientY, pendingIndex); startDrag(ev.clientX, ev.clientY, pendingIndex);
} }
} }
function onUp() { cleanup(); } function onUp() {
cleanup();
if (!moved) {
window.location.href = `/admin/team/${members[pendingIndex].id}`;
}
}
function cleanup() { function cleanup() {
window.removeEventListener("mousemove", onMove); window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp); window.removeEventListener("mouseup", onUp);
@@ -97,7 +104,7 @@ export default function TeamEditorPage() {
window.addEventListener("mousemove", onMove); window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp); window.addEventListener("mouseup", onUp);
}, },
[startDrag] [startDrag, members]
); );
useEffect(() => { useEffect(() => {
@@ -153,7 +160,7 @@ export default function TeamEditorPage() {
async function deleteMember(id: number) { async function deleteMember(id: number) {
if (!confirm("Удалить этого участника?")) return; if (!confirm("Удалить этого участника?")) return;
await fetch(`/api/admin/team/${id}`, { method: "DELETE" }); await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
setMembers((prev) => prev.filter((m) => m.id !== id)); setMembers((prev) => prev.filter((m) => m.id !== id));
} }
@@ -177,7 +184,7 @@ export default function TeamEditorPage() {
key={member.id} key={member.id}
ref={(el) => { itemRefs.current[i] = el; }} ref={(el) => { itemRefs.current[i] = el; }}
onMouseDown={(e) => handleCardMouseDown(e, i)} 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" 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 <div
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none" className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
@@ -192,15 +199,10 @@ export default function TeamEditorPage() {
<p className="font-medium text-white truncate">{member.name}</p> <p className="font-medium text-white truncate">{member.name}</p>
<p className="text-sm text-neutral-400 truncate">{member.role}</p> <p className="text-sm text-neutral-400 truncate">{member.role}</p>
</div> </div>
<div className="flex items-center gap-1"> <button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
<Link href={`/admin/team/${member.id}`} className="rounded p-2 text-neutral-400 hover:text-white transition-colors">
<Pencil size={16} />
</Link>
<button onClick={() => deleteMember(member.id)} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
</div> </div>
</div>
)); ));
} }
@@ -237,7 +239,7 @@ export default function TeamEditorPage() {
key={member.id} key={member.id}
ref={(el) => { itemRefs.current[i] = el; }} ref={(el) => { itemRefs.current[i] = el; }}
onMouseDown={(e) => handleCardMouseDown(e, i)} 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" 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 <div
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none" className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
@@ -252,15 +254,10 @@ export default function TeamEditorPage() {
<p className="font-medium text-white truncate">{member.name}</p> <p className="font-medium text-white truncate">{member.name}</p>
<p className="text-sm text-neutral-400 truncate">{member.role}</p> <p className="text-sm text-neutral-400 truncate">{member.role}</p>
</div> </div>
<div className="flex items-center gap-1"> <button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
<Link href={`/admin/team/${member.id}`} className="rounded p-2 text-neutral-400 hover:text-white transition-colors">
<Pencil size={16} />
</Link>
<button onClick={() => deleteMember(member.id)} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
</div> </div>
</div>
); );
visualIndex++; visualIndex++;
} }

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { getGroupBookings, toggleGroupBookingNotification, deleteGroupBooking, setGroupBookingStatus } from "@/lib/db";
import type { BookingStatus } from "@/lib/db";
export async function GET() {
const bookings = getGroupBookings();
return NextResponse.json(bookings, {
headers: { "Cache-Control": "private, max-age=30" },
});
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
if (body.action === "toggle-notify") {
const { id, field, value } = body;
if (!id || !field || typeof value !== "boolean") {
return NextResponse.json({ error: "id, field, value are required" }, { status: 400 });
}
if (field !== "notified_confirm" && field !== "notified_reminder") {
return NextResponse.json({ error: "Invalid field" }, { status: 400 });
}
toggleGroupBookingNotification(id, field, value);
return NextResponse.json({ ok: true });
}
if (body.action === "set-status") {
const { id, status, confirmation } = body;
const valid: BookingStatus[] = ["new", "contacted", "confirmed", "declined"];
if (!id || !valid.includes(status)) {
return NextResponse.json({ error: "id and valid status are required" }, { status: 400 });
}
setGroupBookingStatus(id, status, confirmation);
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch (err) {
console.error("[admin/group-bookings] error:", err);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const idStr = request.nextUrl.searchParams.get("id");
if (!idStr) {
return NextResponse.json({ error: "id parameter is required" }, { status: 400 });
}
const id = parseInt(idStr, 10);
if (isNaN(id)) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
}
deleteGroupBooking(id);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration } from "@/lib/db";
export async function GET(request: NextRequest) {
const title = request.nextUrl.searchParams.get("title");
if (title) {
return NextResponse.json(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();
// 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 });
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import {
getOpenDayBookings,
toggleOpenDayNotification,
deleteOpenDayBooking,
} from "@/lib/db";
export async function GET(request: NextRequest) {
const eventIdStr = request.nextUrl.searchParams.get("eventId");
if (!eventIdStr) return NextResponse.json({ error: "eventId is required" }, { status: 400 });
const eventId = parseInt(eventIdStr, 10);
if (isNaN(eventId)) return NextResponse.json({ error: "Invalid eventId" }, { status: 400 });
return NextResponse.json(getOpenDayBookings(eventId));
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
if (body.action === "toggle-notify") {
const { id, field, value } = body;
if (!id || !field || typeof value !== "boolean") {
return NextResponse.json({ error: "id, field, value required" }, { status: 400 });
}
if (field !== "notified_confirm" && field !== "notified_reminder") {
return NextResponse.json({ error: "Invalid field" }, { status: 400 });
}
toggleOpenDayNotification(id, field, value);
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch (err) {
console.error("[admin/open-day/bookings] error:", err);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const idStr = request.nextUrl.searchParams.get("id");
if (!idStr) return NextResponse.json({ error: "id is required" }, { status: 400 });
const id = parseInt(idStr, 10);
if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
deleteOpenDayBooking(id);
return NextResponse.json({ ok: true });
}

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

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

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

View File

@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSection, setSection, SECTION_KEYS } from "@/lib/db"; import { getSection, setSection, SECTION_KEYS } from "@/lib/db";
import { siteContent } from "@/data/content";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { invalidateContentCache } from "@/lib/content";
type Params = { params: Promise<{ key: string }> }; type Params = { params: Promise<{ key: string }> };
@@ -10,12 +12,21 @@ export async function GET(_request: NextRequest, { params }: Params) {
return NextResponse.json({ error: "Invalid section key" }, { status: 400 }); return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
} }
const data = getSection(key); let data = getSection(key);
if (!data) { 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({ error: "Section not found" }, { status: 404 });
} }
}
return NextResponse.json(data); return NextResponse.json(data, {
headers: { "Cache-Control": "private, max-age=60" },
});
} }
export async function PUT(request: NextRequest, { params }: Params) { export async function PUT(request: NextRequest, { params }: Params) {
@@ -26,6 +37,7 @@ export async function PUT(request: NextRequest, { params }: Params) {
const data = await request.json(); const data = await request.json();
setSection(key, data); setSection(key, data);
invalidateContentCache();
revalidatePath("/"); revalidatePath("/");
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });

View File

@@ -4,9 +4,18 @@ import { revalidatePath } from "next/cache";
type Params = { params: Promise<{ id: string }> }; type Params = { params: Promise<{ id: string }> };
function parseId(raw: string): number | null {
const n = Number(raw);
return Number.isInteger(n) && n > 0 ? n : null;
}
export async function GET(_request: NextRequest, { params }: Params) { export async function GET(_request: NextRequest, { params }: Params) {
const { id } = await params; const { id } = await params;
const member = getTeamMember(Number(id)); const numId = parseId(id);
if (!numId) {
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
}
const member = getTeamMember(numId);
if (!member) { if (!member) {
return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json({ error: "Not found" }, { status: 404 });
} }
@@ -15,15 +24,23 @@ export async function GET(_request: NextRequest, { params }: Params) {
export async function PUT(request: NextRequest, { params }: Params) { export async function PUT(request: NextRequest, { params }: Params) {
const { id } = await params; const { id } = await params;
const numId = parseId(id);
if (!numId) {
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
}
const data = await request.json(); const data = await request.json();
updateTeamMember(Number(id), data); updateTeamMember(numId, data);
revalidatePath("/"); revalidatePath("/");
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
export async function DELETE(_request: NextRequest, { params }: Params) { export async function DELETE(_request: NextRequest, { params }: Params) {
const { id } = await params; const { id } = await params;
deleteTeamMember(Number(id)); const numId = parseId(id);
if (!numId) {
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
}
deleteTeamMember(numId);
revalidatePath("/"); revalidatePath("/");
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }

View File

@@ -5,8 +5,8 @@ import { revalidatePath } from "next/cache";
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
const { ids } = await request.json() as { ids: number[] }; const { ids } = await request.json() as { ids: number[] };
if (!Array.isArray(ids) || ids.length === 0) { if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100 || !ids.every((id) => Number.isInteger(id) && id > 0)) {
return NextResponse.json({ error: "ids array required" }, { status: 400 }); return NextResponse.json({ error: "ids must be a non-empty array of positive integers (max 100)" }, { status: 400 });
} }
reorderTeamMembers(ids); reorderTeamMembers(ids);

View File

@@ -1,10 +1,13 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getTeamMembers, createTeamMember } from "@/lib/db"; import { getTeamMembers, createTeamMember } from "@/lib/db";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import type { RichListItem, VictoryItem } from "@/types/content";
export async function GET() { export async function GET() {
const members = getTeamMembers(); const members = getTeamMembers();
return NextResponse.json(members); return NextResponse.json(members, {
headers: { "Cache-Control": "private, max-age=60" },
});
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -15,8 +18,8 @@ export async function POST(request: NextRequest) {
instagram?: string; instagram?: string;
description?: string; description?: string;
experience?: string[]; experience?: string[];
victories?: string[]; victories?: VictoryItem[];
education?: string[]; education?: RichListItem[];
}; };
if (!data.name || !data.role || !data.image) { if (!data.name || !data.role || !data.image) {

View File

@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { getUnreadBookingCounts } from "@/lib/db";
export async function GET() {
return NextResponse.json(getUnreadBookingCounts(), {
headers: { "Cache-Control": "private, max-age=15" },
});
}

View File

@@ -3,12 +3,15 @@ import { writeFile, mkdir } from "fs/promises";
import path from "path"; import path from "path";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"]; const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"];
const ALLOWED_FOLDERS = ["team", "master-classes", "news", "classes"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB const MAX_SIZE = 5 * 1024 * 1024; // 5MB
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get("file") as File | null; const file = formData.get("file") as File | null;
const folder = (formData.get("folder") as string) || "team"; const rawFolder = (formData.get("folder") as string) || "team";
const folder = ALLOWED_FOLDERS.includes(rawFolder) ? rawFolder : "team";
if (!file) { if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 }); return NextResponse.json({ error: "No file provided" }, { status: 400 });
@@ -28,8 +31,14 @@ export async function POST(request: NextRequest) {
); );
} }
// Sanitize filename // Validate and sanitize filename
const ext = path.extname(file.name) || ".webp"; const ext = path.extname(file.name).toLowerCase() || ".webp";
if (!ALLOWED_EXTENSIONS.includes(ext)) {
return NextResponse.json(
{ error: "Invalid file extension" },
{ status: 400 }
);
}
const baseName = file.name const baseName = file.name
.replace(ext, "") .replace(ext, "")
.toLowerCase() .toLowerCase()

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

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { verifyPassword, signToken, COOKIE_NAME } from "@/lib/auth"; import { verifyPassword, signToken, generateCsrfToken, COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const body = await request.json() as { password?: string }; const body = await request.json() as { password?: string };
@@ -9,6 +9,7 @@ export async function POST(request: NextRequest) {
} }
const token = signToken(); const token = signToken();
const csrfToken = generateCsrfToken();
const response = NextResponse.json({ ok: true }); const response = NextResponse.json({ ok: true });
response.cookies.set(COOKIE_NAME, token, { response.cookies.set(COOKIE_NAME, token, {
@@ -16,7 +17,15 @@ export async function POST(request: NextRequest) {
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
maxAge: 60 * 60 * 24, // 24 hours maxAge: 60 * 60 * 24,
});
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
httpOnly: false, // JS must read this to send as header
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24,
}); });
return response; return response;

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

View File

@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { COOKIE_NAME } from "@/lib/auth"; import { COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
export async function POST() { export async function POST() {
const response = NextResponse.json({ ok: true }); const response = NextResponse.json({ ok: true });
@@ -8,5 +8,9 @@ export async function POST() {
path: "/", path: "/",
maxAge: 0, maxAge: 0,
}); });
response.cookies.set(CSRF_COOKIE_NAME, "", {
path: "/",
maxAge: 0,
});
return response; return response;
} }

View File

@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { addMcRegistration } from "@/lib/db";
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
export async function POST(request: Request) {
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 });
}
const id = addMcRegistration(
cleanTitle,
cleanName,
sanitizeHandle(instagram) ?? "",
sanitizeHandle(telegram),
cleanPhone
);
return NextResponse.json({ ok: true, id });
} catch (err) {
console.error("[master-class-register] POST error:", err);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import {
addOpenDayBooking,
getPersonOpenDayBookings,
getOpenDayEvent,
} from "@/lib/db";
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
import { sanitizeName, sanitizePhone, sanitizeHandle } from "@/lib/validation";
export async function POST(request: NextRequest) {
const ip = getClientIp(request);
if (!checkRateLimit(ip, 10, 60_000)) {
return NextResponse.json(
{ error: "Слишком много запросов. Попробуйте через минуту." },
{ status: 429 }
);
}
try {
const body = await request.json();
const { classId, eventId, name, phone, instagram, telegram } = body;
if (!classId || !eventId) {
return NextResponse.json({ error: "classId and eventId are required" }, { status: 400 });
}
const cleanName = sanitizeName(name);
if (!cleanName) {
return NextResponse.json({ error: "Имя обязательно" }, { status: 400 });
}
const cleanPhone = sanitizePhone(phone);
if (!cleanPhone) {
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
}
const id = addOpenDayBooking(classId, eventId, {
name: cleanName,
phone: cleanPhone,
instagram: sanitizeHandle(instagram),
telegram: sanitizeHandle(telegram),
});
// Return total bookings for this person (for discount calculation)
const totalBookings = getPersonOpenDayBookings(eventId, cleanPhone);
const event = getOpenDayEvent(eventId);
const pricePerClass = event && totalBookings >= event.discountThreshold
? event.discountPrice
: event?.pricePerClass ?? 30;
return NextResponse.json({ ok: true, id, totalBookings, pricePerClass });
} catch (e) {
const msg = e instanceof Error ? e.message : "Internal error";
if (msg.includes("UNIQUE")) {
return NextResponse.json({ error: "Вы уже записаны на это занятие" }, { status: 409 });
}
console.error("[open-day-register] POST error:", e);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}

View File

@@ -2,23 +2,29 @@ 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 { 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 { Header } from "@/components/layout/Header"; import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import { getContent } from "@/lib/content"; import { getContent } from "@/lib/content";
import { OpenDay } from "@/components/sections/OpenDay";
import { getActiveOpenDay } from "@/lib/openDay";
export default function HomePage() { export default function HomePage() {
const content = getContent(); const content = getContent();
const openDayData = getActiveOpenDay();
return ( return (
<> <>
<Header /> <Header />
<main> <main>
<Hero data={content.hero} /> <Hero data={content.hero} />
{openDayData && <OpenDay data={openDayData} />}
<About <About
data={content.about} data={content.about}
stats={{ stats={{
@@ -27,10 +33,12 @@ export default function HomePage() {
locations: content.schedule.locations.length, locations: content.schedule.locations.length,
}} }}
/> />
<Team data={content.team} /> <Team data={content.team} schedule={content.schedule.locations} />
<Classes data={content.classes} /> <Classes data={content.classes} />
<MasterClasses data={content.masterClasses} />
<Schedule data={content.schedule} classItems={content.classes.items} /> <Schedule data={content.schedule} classItems={content.classes.items} />
<Pricing data={content.pricing} /> <Pricing data={content.pricing} />
<News data={content.news} />
<FAQ data={content.faq} /> <FAQ data={content.faq} />
<Contact data={content.contact} /> <Contact data={content.contact} />
<BackToTop /> <BackToTop />

View File

@@ -128,6 +128,7 @@
filter: blur(80px); filter: blur(80px);
animation: pulse-glow 6s ease-in-out infinite; animation: pulse-glow 6s ease-in-out infinite;
pointer-events: none; pointer-events: none;
will-change: filter, transform;
} }
/* ===== Gradient Text ===== */ /* ===== Gradient Text ===== */
@@ -322,6 +323,22 @@
mask-composite: exclude; mask-composite: exclude;
pointer-events: none; pointer-events: none;
z-index: 1; z-index: 1;
will-change: background-position;
}
/* ===== Notification Pulse ===== */
@keyframes pulse-urgent {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
}
50% {
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
}
}
.pulse-urgent {
animation: pulse-urgent 1.5s ease-in-out infinite;
} }
/* ===== Section Divider ===== */ /* ===== Section Divider ===== */

View File

@@ -6,7 +6,7 @@ import { useState, useEffect } from "react";
import { BRAND, NAV_LINKS } from "@/lib/constants"; import { BRAND, NAV_LINKS } from "@/lib/constants";
import { UI_CONFIG } from "@/lib/config"; import { UI_CONFIG } from "@/lib/config";
import { HeroLogo } from "@/components/ui/HeroLogo"; import { HeroLogo } from "@/components/ui/HeroLogo";
import { BookingModal } from "@/components/ui/BookingModal"; import { SignupModal } from "@/components/ui/SignupModal";
export function Header() { export function Header() {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@@ -15,8 +15,15 @@ export function Header() {
const [bookingOpen, setBookingOpen] = useState(false); const [bookingOpen, setBookingOpen] = useState(false);
useEffect(() => { useEffect(() => {
let ticking = false;
function handleScroll() { function handleScroll() {
if (!ticking) {
ticking = true;
requestAnimationFrame(() => {
setScrolled(window.scrollY > UI_CONFIG.scrollThresholds.header); setScrolled(window.scrollY > UI_CONFIG.scrollThresholds.header);
ticking = false;
});
}
} }
window.addEventListener("scroll", handleScroll, { passive: true }); window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll);
@@ -31,8 +38,14 @@ export function Header() {
return () => window.removeEventListener("open-booking", onOpenBooking); return () => window.removeEventListener("open-booking", onOpenBooking);
}, []); }, []);
// Filter out nav links whose target section doesn't exist on the page
const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS);
useEffect(() => { useEffect(() => {
const sectionIds = NAV_LINKS.map((l) => l.href.replace("#", "")); setVisibleLinks(NAV_LINKS.filter((l) => document.getElementById(l.href.replace("#", ""))));
}, []);
useEffect(() => {
const sectionIds = visibleLinks.map((l) => l.href.replace("#", ""));
const observers: IntersectionObserver[] = []; const observers: IntersectionObserver[] = [];
// Observe hero — when visible, clear active section // Observe hero — when visible, clear active section
@@ -65,7 +78,7 @@ export function Header() {
}); });
return () => observers.forEach((o) => o.disconnect()); return () => observers.forEach((o) => o.disconnect());
}, []); }, [visibleLinks]);
return ( return (
<header <header
@@ -94,14 +107,14 @@ export function Header() {
</span> </span>
</Link> </Link>
<nav className="hidden items-center gap-8 md:flex"> <nav className="hidden items-center gap-3 lg:gap-5 xl:gap-6 lg:flex">
{NAV_LINKS.map((link) => { {visibleLinks.map((link) => {
const isActive = activeSection === link.href.replace("#", ""); const isActive = activeSection === link.href.replace("#", "");
return ( return (
<a <a
key={link.href} key={link.href}
href={link.href} href={link.href}
className={`relative py-1 text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${ className={`relative whitespace-nowrap py-1 text-xs lg:text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${
isActive isActive
? "text-gold-light after:w-full" ? "text-gold-light after:w-full"
: "text-neutral-400 after:w-0 hover:text-white hover:after:w-full" : "text-neutral-400 after:w-0 hover:text-white hover:after:w-full"
@@ -119,10 +132,11 @@ export function Header() {
</button> </button>
</nav> </nav>
<div className="flex items-center gap-2 md:hidden"> <div className="flex items-center gap-2 lg:hidden">
<button <button
onClick={() => setMenuOpen(!menuOpen)} onClick={() => setMenuOpen(!menuOpen)}
aria-label="Меню" aria-label={menuOpen ? "Закрыть меню" : "Открыть меню"}
aria-expanded={menuOpen}
className="rounded-lg p-2 text-neutral-400 transition-colors hover:text-white" className="rounded-lg p-2 text-neutral-400 transition-colors hover:text-white"
> >
{menuOpen ? <X size={24} /> : <Menu size={24} />} {menuOpen ? <X size={24} /> : <Menu size={24} />}
@@ -132,12 +146,12 @@ export function Header() {
{/* Mobile menu */} {/* Mobile menu */}
<div <div
className={`overflow-hidden transition-all duration-300 md:hidden ${ className={`overflow-hidden transition-all duration-300 lg:hidden ${
menuOpen ? "max-h-80 opacity-100" : "max-h-0 opacity-0" menuOpen ? "max-h-80 opacity-100" : "max-h-0 opacity-0"
}`} }`}
> >
<nav className="border-t border-white/[0.06] bg-black/40 px-6 py-4 backdrop-blur-xl sm:px-8"> <nav className="border-t border-white/[0.06] bg-black/40 px-6 py-4 backdrop-blur-xl sm:px-8">
{NAV_LINKS.map((link) => { {visibleLinks.map((link) => {
const isActive = activeSection === link.href.replace("#", ""); const isActive = activeSection === link.href.replace("#", "");
return ( return (
<a <a
@@ -166,17 +180,8 @@ export function Header() {
</nav> </nav>
</div> </div>
{/* Floating booking button — visible on scroll, mobile */}
<button
onClick={() => setBookingOpen(true)}
className={`fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-full bg-gold px-5 py-3 text-sm font-semibold text-black shadow-lg shadow-gold/25 transition-all duration-500 hover:bg-gold-light hover:shadow-xl hover:shadow-gold/30 cursor-pointer md:hidden ${
scrolled ? "translate-y-0 opacity-100" : "translate-y-16 opacity-0 pointer-events-none"
}`}
>
Записаться
</button>
<BookingModal open={bookingOpen} onClose={() => setBookingOpen(false)} /> <SignupModal open={bookingOpen} onClose={() => setBookingOpen(false)} endpoint="/api/group-booking" />
</header> </header>
); );
} }

View File

@@ -30,8 +30,8 @@ export function About({ data: about, stats }: AboutProps) {
</Reveal> </Reveal>
<div className="mt-14 mx-auto max-w-2xl space-y-8 text-center"> <div className="mt-14 mx-auto max-w-2xl space-y-8 text-center">
{about.paragraphs.map((text, i) => ( {about.paragraphs.map((text) => (
<Reveal key={i}> <Reveal key={text}>
<p className="text-xl leading-relaxed text-neutral-600 dark:text-neutral-300 sm:text-2xl"> <p className="text-xl leading-relaxed text-neutral-600 dark:text-neutral-300 sm:text-2xl">
{text} {text}
</p> </p>

View File

@@ -53,6 +53,8 @@ export function Classes({ data: classes }: ClassesProps) {
src={item.images[0]} src={item.images[0]}
alt={item.name} alt={item.name}
fill fill
loading="lazy"
sizes="(min-width: 1024px) 60vw, 100vw"
className="object-cover" className="object-cover"
/> />
{/* Gradient overlay */} {/* Gradient overlay */}

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { useEffect, useRef, useCallback } from "react";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { FloatingHearts } from "@/components/ui/FloatingHearts"; import { FloatingHearts } from "@/components/ui/FloatingHearts";
import { HeroLogo } from "@/components/ui/HeroLogo"; import { HeroLogo } from "@/components/ui/HeroLogo";
import { ChevronDown } from "lucide-react";
import type { SiteContent } from "@/types/content"; import type { SiteContent } from "@/types/content";
interface HeroProps { interface HeroProps {
@@ -11,9 +11,65 @@ interface HeroProps {
} }
export function Hero({ data: hero }: HeroProps) { export function Hero({ data: hero }: HeroProps) {
const sectionRef = useRef<HTMLElement>(null);
const scrolledRef = useRef(false);
const scrollToNext = useCallback(() => {
const hero = sectionRef.current;
if (!hero) return;
// Find the next sibling section
let next = hero.nextElementSibling;
while (next && next.tagName !== "SECTION") {
next = next.nextElementSibling;
}
next?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
const hero = sectionRef.current;
if (!hero) return;
function handleWheel(e: WheelEvent) {
// Only trigger when scrolling down and still inside hero
if (e.deltaY <= 0 || scrolledRef.current) return;
if (window.scrollY > 10) return; // already scrolled past hero top
scrolledRef.current = true;
scrollToNext();
// Reset after animation completes
setTimeout(() => { scrolledRef.current = false; }, 1000);
}
function handleTouchStart(e: TouchEvent) {
(hero as HTMLElement).dataset.touchY = String(e.touches[0].clientY);
}
function handleTouchEnd(e: TouchEvent) {
const startY = Number((hero as HTMLElement).dataset.touchY);
const endY = e.changedTouches[0].clientY;
const diff = startY - endY;
// Swipe down (finger moves up) with enough distance
if (diff > 50 && !scrolledRef.current && window.scrollY < 10) {
scrolledRef.current = true;
scrollToNext();
setTimeout(() => { scrolledRef.current = false; }, 1000);
}
}
hero.addEventListener("wheel", handleWheel, { passive: true });
hero.addEventListener("touchstart", handleTouchStart, { passive: true });
hero.addEventListener("touchend", handleTouchEnd, { passive: true });
return () => {
hero.removeEventListener("wheel", handleWheel);
hero.removeEventListener("touchstart", handleTouchStart);
hero.removeEventListener("touchend", handleTouchEnd);
};
}, [scrollToNext]);
return ( return (
<section className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]"> <section ref={sectionRef} className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
{/* Animated gradient background */} {/* Animated gradient background */}
<div className="hero-bg-gradient absolute inset-0" /> <div className="hero-bg-gradient absolute inset-0" />
@@ -72,16 +128,6 @@ export function Hero({ data: hero }: HeroProps) {
</div> </div>
</div> </div>
{/* Scroll indicator */}
<div className="hero-cta absolute bottom-8 left-1/2 -translate-x-1/2">
<a
href="#about"
className="flex flex-col items-center gap-1 text-neutral-600 transition-colors hover:text-gold-light"
>
<span className="text-xs uppercase tracking-widest">Scroll</span>
<ChevronDown size={20} className="animate-bounce" />
</a>
</div>
</section> </section>
); );
} }

View File

@@ -0,0 +1,251 @@
"use client";
import { useState, useMemo } from "react";
import Image from "next/image";
import { Calendar, Clock, User, MapPin, Instagram } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { SignupModal } from "@/components/ui/SignupModal";
import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
interface MasterClassesProps {
data: SiteContent["masterClasses"];
}
const MONTHS_RU = [
"января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря",
];
const WEEKDAYS_RU = [
"воскресенье", "понедельник", "вторник", "среда",
"четверг", "пятница", "суббота",
];
function parseDate(iso: string) {
return new Date(iso + "T00:00:00");
}
function formatSlots(slots: MasterClassSlot[]): string {
if (slots.length === 0) return "";
const sorted = [...slots].sort(
(a, b) => parseDate(a.date).getTime() - parseDate(b.date).getTime()
);
const dates = sorted.map((s) => parseDate(s.date)).filter((d) => !isNaN(d.getTime()));
if (dates.length === 0) return "";
const timePart = sorted[0].startTime
? `, ${sorted[0].startTime}${sorted[0].endTime}`
: "";
if (dates.length === 1) {
const d = dates[0];
return `${d.getDate()} ${MONTHS_RU[d.getMonth()]} (${WEEKDAYS_RU[d.getDay()]})${timePart}`;
}
const sameMonth = dates.every((d) => d.getMonth() === dates[0].getMonth());
const sameWeekday = dates.every((d) => d.getDay() === dates[0].getDay());
if (sameMonth) {
const days = dates.map((d) => d.getDate()).join(" и ");
const weekdayHint = sameWeekday ? ` (${WEEKDAYS_RU[dates[0].getDay()]})` : "";
return `${days} ${MONTHS_RU[dates[0].getMonth()]}${weekdayHint}${timePart}`;
}
const parts = dates.map((d) => `${d.getDate()} ${MONTHS_RU[d.getMonth()]}`);
return parts.join(", ") + timePart;
}
function calcDuration(slot: MasterClassSlot): string {
if (!slot.startTime || !slot.endTime) return "";
const [sh, sm] = slot.startTime.split(":").map(Number);
const [eh, em] = slot.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 isUpcoming(item: MasterClassItem): boolean {
const today = new Date();
today.setHours(0, 0, 0, 0);
const lastDate = (item.slots ?? [])
.map((s) => parseDate(s.date))
.reduce((a, b) => (a > b ? a : b), new Date(0));
return lastDate >= today;
}
function MasterClassCard({
item,
onSignup,
}: {
item: MasterClassItem;
onSignup: () => void;
}) {
const duration = item.slots[0] ? calcDuration(item.slots[0]) : "";
const slotsDisplay = formatSlots(item.slots);
return (
<div className="group relative flex flex-col overflow-hidden rounded-2xl bg-black">
{/* Full-bleed image */}
{item.image && (
<div className="relative aspect-[3/4] sm:aspect-[2/3] w-full overflow-hidden">
<Image
src={item.image}
alt={item.title}
fill
loading="lazy"
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className="object-cover transition-transform duration-700 group-hover:scale-110"
/>
{/* Dark overlay that intensifies on hover */}
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/20 to-transparent opacity-80 transition-opacity duration-500 group-hover:opacity-90" />
</div>
)}
{/* Content overlay at bottom */}
<div className="absolute inset-x-0 bottom-0 flex flex-col p-5 sm:p-6">
{/* Tags row */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<span className="inline-flex items-center gap-1 rounded-full border border-gold/40 bg-black/40 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-gold backdrop-blur-md">
{item.style}
</span>
{duration && (
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-0.5 text-[11px] text-white/60 backdrop-blur-md">
<Clock size={10} />
{duration}
</span>
)}
</div>
{/* Title */}
<h3 className="text-xl sm:text-2xl font-bold text-white leading-tight tracking-tight">
{item.title}
</h3>
{/* Trainer */}
<div className="mt-2 flex items-center gap-2 text-sm text-white/50">
<User size={13} className="shrink-0" />
<span>{item.trainer}</span>
</div>
{/* Divider */}
<div className="mt-4 mb-4 h-px bg-gradient-to-r from-gold/40 via-gold/20 to-transparent" />
{/* Date + Location */}
<div className="flex flex-col gap-1.5 text-sm text-white/60 mb-4">
<div className="flex items-center gap-2">
<Calendar size={13} className="shrink-0 text-gold/70" />
<span>{slotsDisplay}</span>
</div>
{item.location && (
<div className="flex items-center gap-2">
<MapPin size={13} className="shrink-0 text-gold/70" />
<span>{item.location}</span>
</div>
)}
</div>
{/* Price + Actions */}
<div className="flex items-center gap-3">
<button
onClick={onSignup}
className="flex-1 rounded-xl bg-gold py-3 text-sm font-bold text-black uppercase tracking-wide transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/25 cursor-pointer"
>
Записаться
</button>
{item.instagramUrl && (
<button
onClick={() =>
window.open(item.instagramUrl, "_blank", "noopener,noreferrer")
}
aria-label={`Instagram ${item.trainer}`}
className="flex h-[46px] w-[46px] items-center justify-center rounded-xl border border-white/10 text-white/40 transition-all hover:border-gold/30 hover:text-gold cursor-pointer"
>
<Instagram size={18} />
</button>
)}
</div>
{/* Price floating tag */}
<div className="absolute top-0 right-0 -translate-y-full mr-5 sm:mr-6 mb-2">
<span className="inline-block rounded-full bg-white/10 px-3 py-1 text-sm font-bold text-white backdrop-blur-md">
{item.cost}
</span>
</div>
</div>
</div>
);
}
export function MasterClasses({ data }: MasterClassesProps) {
const [signupTitle, setSignupTitle] = useState<string | null>(null);
const upcoming = useMemo(() => {
return data.items
.filter(isUpcoming)
.sort((a, b) => {
const aFirst = parseDate(a.slots[0]?.date ?? "");
const bFirst = parseDate(b.slots[0]?.date ?? "");
return aFirst.getTime() - bFirst.getTime();
});
}, [data.items]);
return (
<section
id="master-classes"
className="section-glow relative section-padding overflow-hidden"
>
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container">
<Reveal>
<SectionHeading centered>{data.title}</SectionHeading>
</Reveal>
{upcoming.length === 0 ? (
<Reveal>
<div className="mt-10 py-12 text-center">
<p className="text-sm text-neutral-500 dark:text-white/40">
Следите за анонсами мастер-классов в нашем{" "}
<a
href="https://instagram.com/blackheartdancehouse/"
target="_blank"
rel="noopener noreferrer"
className="text-gold hover:text-gold-light underline underline-offset-2 transition-colors"
>
Instagram
</a>
</p>
</div>
</Reveal>
) : (
<Reveal>
<div className="mx-auto mt-10 grid max-w-5xl grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{upcoming.map((item) => (
<MasterClassCard
key={item.title}
item={item}
onSignup={() => setSignupTitle(item.title)}
/>
))}
</div>
</Reveal>
)}
</div>
<SignupModal
open={signupTitle !== null}
onClose={() => setSignupTitle(null)}
subtitle={signupTitle ?? ""}
endpoint="/api/master-class-register"
extraBody={{ masterClassTitle: signupTitle }}
successMessage={data.successMessage}
/>
</section>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { Calendar, ExternalLink } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { NewsModal } from "@/components/ui/NewsModal";
import type { SiteContent, NewsItem } from "@/types/content";
interface NewsProps {
data: SiteContent["news"];
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
});
} catch {
return iso;
}
}
function FeaturedArticle({
item,
onClick,
}: {
item: NewsItem;
onClick: () => void;
}) {
return (
<article
className="group relative overflow-hidden rounded-3xl cursor-pointer"
onClick={onClick}
>
{item.image && (
<div className="relative aspect-[21/9] sm:aspect-[2/1] overflow-hidden">
<Image
src={item.image}
alt={item.title}
fill
loading="lazy"
sizes="(min-width: 768px) 80vw, 100vw"
className="object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
</div>
)}
<div
className={`${item.image ? "absolute bottom-0 left-0 right-0 p-6 sm:p-8" : "p-6 sm:p-8 bg-neutral-900 rounded-3xl"}`}
>
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1 text-xs font-medium text-white/80 backdrop-blur-sm">
<Calendar size={12} />
{formatDate(item.date)}
</span>
<h3 className="mt-3 text-xl sm:text-2xl font-bold text-white leading-tight">
{item.title}
</h3>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-white/70 line-clamp-3">
{item.text}
</p>
</div>
</article>
);
}
function CompactArticle({
item,
onClick,
}: {
item: NewsItem;
onClick: () => void;
}) {
return (
<article
className="group flex gap-4 items-start py-5 border-b border-neutral-200/60 last:border-0 dark:border-white/[0.06] cursor-pointer"
onClick={onClick}
>
{item.image && (
<div className="relative w-24 h-24 sm:w-28 sm:h-28 shrink-0 overflow-hidden rounded-xl">
<Image
src={item.image}
alt={item.title}
fill
loading="lazy"
sizes="112px"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
)}
<div className="flex-1 min-w-0">
<span className="text-xs text-neutral-400 dark:text-white/30">
{formatDate(item.date)}
</span>
<h3 className="mt-1 text-sm sm:text-base font-bold text-neutral-900 dark:text-white leading-snug line-clamp-2 group-hover:text-gold transition-colors">
{item.title}
</h3>
<p className="mt-1 text-sm leading-relaxed text-neutral-500 dark:text-neutral-400 line-clamp-2">
{item.text}
</p>
</div>
</article>
);
}
export function News({ data }: NewsProps) {
const [selected, setSelected] = useState<NewsItem | null>(null);
if (!data.items || data.items.length === 0) return null;
const [featured, ...rest] = data.items;
return (
<section id="news" className="section-glow relative section-padding">
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container">
<Reveal>
<SectionHeading centered>{data.title}</SectionHeading>
</Reveal>
<div className="mx-auto mt-10 max-w-4xl space-y-6">
<Reveal>
<FeaturedArticle
item={featured}
onClick={() => setSelected(featured)}
/>
</Reveal>
{rest.length > 0 && (
<Reveal>
<div className="rounded-2xl bg-neutral-50/80 px-5 sm:px-6 dark:bg-white/[0.02]">
{rest.map((item) => (
<CompactArticle
key={item.title}
item={item}
onClick={() => setSelected(item)}
/>
))}
</div>
</Reveal>
)}
</div>
</div>
<NewsModal item={selected} onClose={() => setSelected(null)} />
</section>
);
}

View File

@@ -0,0 +1,184 @@
"use client";
import { useState, useMemo } from "react";
import { Calendar, Users, Sparkles } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { SignupModal } from "@/components/ui/SignupModal";
import type { OpenDayEvent, OpenDayClass } from "@/lib/openDay";
interface OpenDayProps {
data: {
event: OpenDayEvent;
classes: OpenDayClass[];
};
}
function formatDateRu(dateStr: string): string {
const d = new Date(dateStr + "T12:00:00");
return d.toLocaleDateString("ru-RU", {
weekday: "long",
day: "numeric",
month: "long",
});
}
export function OpenDay({ data }: OpenDayProps) {
const { event, classes } = data;
const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
// Group classes by hall
const hallGroups = useMemo(() => {
const groups: Record<string, OpenDayClass[]> = {};
for (const cls of classes) {
if (!groups[cls.hall]) groups[cls.hall] = [];
groups[cls.hall].push(cls);
}
// Sort each hall's classes by time
for (const hall in groups) {
groups[hall].sort((a, b) => a.startTime.localeCompare(b.startTime));
}
return groups;
}, [classes]);
const halls = Object.keys(hallGroups);
if (classes.length === 0) return null;
return (
<section id="open-day" className="py-10 sm:py-14">
<div className="mx-auto max-w-6xl px-4">
<Reveal>
<SectionHeading centered>{event.title}</SectionHeading>
</Reveal>
<Reveal>
<div className="mt-4 text-center">
<div className="inline-flex items-center gap-2 rounded-full bg-gold/10 border border-gold/20 px-5 py-2.5 text-sm font-medium text-gold">
<Calendar size={16} />
{formatDateRu(event.date)}
</div>
</div>
</Reveal>
{/* Pricing info */}
<Reveal>
<div className="mt-6 text-center space-y-1">
<p className="text-lg font-semibold text-white">
{event.pricePerClass} BYN <span className="text-neutral-400 font-normal text-sm">за занятие</span>
</p>
<p className="text-sm text-gold">
<Sparkles size={12} className="inline mr-1" />
От {event.discountThreshold} занятий {event.discountPrice} BYN за каждое!
</p>
</div>
</Reveal>
{event.description && (
<Reveal>
<p className="mt-4 text-center text-sm text-neutral-400 max-w-2xl mx-auto">
{event.description}
</p>
</Reveal>
)}
{/* Schedule Grid */}
<div className="mt-8">
{halls.length === 1 ? (
// Single hall — simple list
<Reveal>
<div className="max-w-lg mx-auto space-y-3">
<h3 className="text-sm font-medium text-neutral-400 text-center">{halls[0]}</h3>
{hallGroups[halls[0]].map((cls) => (
<ClassCard
key={cls.id}
cls={cls}
onSignup={setSignup}
/>
))}
</div>
</Reveal>
) : (
// Multiple halls — columns
<div className={`grid gap-6 ${halls.length === 2 ? "sm:grid-cols-2" : "sm:grid-cols-2 lg:grid-cols-3"}`}>
{halls.map((hall) => (
<Reveal key={hall}>
<div>
<h3 className="text-sm font-medium text-neutral-400 mb-3 text-center">{hall}</h3>
<div className="space-y-3">
{hallGroups[hall].map((cls) => (
<ClassCard
key={cls.id}
cls={cls}
onSignup={setSignup}
/>
))}
</div>
</div>
</Reveal>
))}
</div>
)}
</div>
</div>
{signup && (
<SignupModal
open
onClose={() => setSignup(null)}
subtitle={signup.label}
endpoint="/api/open-day-register"
extraBody={{ classId: signup.classId, eventId: event.id }}
/>
)}
</section>
);
}
function ClassCard({
cls,
onSignup,
}: {
cls: OpenDayClass;
onSignup: (info: { classId: number; label: string }) => void;
}) {
const label = `${cls.style} · ${cls.trainer} · ${cls.startTime}${cls.endTime}`;
if (cls.cancelled) {
return (
<div className="rounded-xl border border-white/5 bg-neutral-900/30 p-4 opacity-50">
<div className="flex items-center justify-between">
<div>
<span className="text-xs text-neutral-500">{cls.startTime}{cls.endTime}</span>
<p className="text-sm text-neutral-500 line-through">{cls.style}</p>
<p className="text-xs text-neutral-600">{cls.trainer}</p>
</div>
<span className="text-xs text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">
Отменено
</span>
</div>
</div>
);
}
return (
<div className="rounded-xl border border-white/10 bg-neutral-900 p-4 transition-all hover:border-gold/20">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<span className="text-xs text-gold font-medium">{cls.startTime}{cls.endTime}</span>
<p className="text-sm font-medium text-white mt-0.5">{cls.style}</p>
<p className="text-xs text-neutral-400 flex items-center gap-1 mt-0.5">
<Users size={10} />
{cls.trainer}
</p>
</div>
<button
onClick={() => onSignup({ classId: cls.id, label })}
className="shrink-0 rounded-full bg-gold/10 border border-gold/20 px-4 py-2 text-xs font-medium text-gold hover:bg-gold/20 transition-colors cursor-pointer"
>
Записаться
</button>
</div>
</div>
);
}

View File

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

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useMemo, useCallback } from "react"; import { useReducer, useMemo, useCallback } from "react";
import { SignupModal } from "@/components/ui/SignupModal";
import { CalendarDays, Users, LayoutGrid } from "lucide-react"; import { CalendarDays, Users, LayoutGrid } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
@@ -15,19 +16,74 @@ import type { SiteContent } from "@/types/content";
type ViewMode = "days" | "groups"; type ViewMode = "days" | "groups";
type LocationMode = "all" | number; type LocationMode = "all" | number;
interface ScheduleState {
locationMode: LocationMode;
viewMode: ViewMode;
filterTrainer: string | null;
filterType: string | null;
filterStatus: StatusFilter;
filterTime: TimeFilter;
filterDaySet: Set<string>;
bookingGroup: string | null;
}
type ScheduleAction =
| { type: "SET_LOCATION"; mode: LocationMode }
| { type: "SET_VIEW"; mode: ViewMode }
| { type: "SET_TRAINER"; value: string | null }
| { type: "SET_TYPE"; value: string | null }
| { type: "SET_STATUS"; value: StatusFilter }
| { type: "SET_TIME"; value: TimeFilter }
| { type: "TOGGLE_DAY"; day: string }
| { type: "SET_BOOKING"; value: string | null }
| { type: "CLEAR_FILTERS" };
const initialState: ScheduleState = {
locationMode: "all",
viewMode: "days",
filterTrainer: null,
filterType: null,
filterStatus: "all",
filterTime: "all",
filterDaySet: new Set(),
bookingGroup: null,
};
function scheduleReducer(state: ScheduleState, action: ScheduleAction): ScheduleState {
switch (action.type) {
case "SET_LOCATION":
return { ...initialState, viewMode: state.viewMode, locationMode: action.mode };
case "SET_VIEW":
return { ...state, viewMode: action.mode };
case "SET_TRAINER":
return { ...state, filterTrainer: action.value };
case "SET_TYPE":
return { ...state, filterType: action.value };
case "SET_STATUS":
return { ...state, filterStatus: action.value };
case "SET_TIME":
return { ...state, filterTime: action.value };
case "TOGGLE_DAY": {
const next = new Set(state.filterDaySet);
if (next.has(action.day)) next.delete(action.day);
else next.add(action.day);
return { ...state, filterDaySet: next };
}
case "SET_BOOKING":
return { ...state, bookingGroup: action.value };
case "CLEAR_FILTERS":
return { ...state, filterTrainer: null, filterType: null, filterStatus: "all", filterTime: "all", filterDaySet: new Set() };
}
}
interface ScheduleProps { interface ScheduleProps {
data: SiteContent["schedule"]; data: SiteContent["schedule"];
classItems?: { name: string; color?: string }[]; classItems?: { name: string; color?: string }[];
} }
export function Schedule({ data: schedule, classItems }: ScheduleProps) { export function Schedule({ data: schedule, classItems }: ScheduleProps) {
const [locationMode, setLocationMode] = useState<LocationMode>("all"); const [state, dispatch] = useReducer(scheduleReducer, initialState);
const [viewMode, setViewMode] = useState<ViewMode>("days"); const { locationMode, viewMode, filterTrainer, filterType, filterStatus, filterTime, filterDaySet, bookingGroup } = state;
const [filterTrainer, setFilterTrainer] = useState<string | null>(null);
const [filterType, setFilterType] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<StatusFilter>("all");
const [filterTime, setFilterTime] = useState<TimeFilter>("all");
const [filterDaySet, setFilterDaySet] = useState<Set<string>>(new Set());
const isAllMode = locationMode === "all"; const isAllMode = locationMode === "all";
@@ -36,13 +92,18 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}, []); }, []);
const setFilterTrainer = useCallback((value: string | null) => dispatch({ type: "SET_TRAINER", value }), []);
const setFilterType = useCallback((value: string | null) => dispatch({ type: "SET_TYPE", value }), []);
const setFilterStatus = useCallback((value: StatusFilter) => dispatch({ type: "SET_STATUS", value }), []);
const setFilterTime = useCallback((value: TimeFilter) => dispatch({ type: "SET_TIME", value }), []);
const setFilterTrainerFromCard = useCallback((trainer: string | null) => { const setFilterTrainerFromCard = useCallback((trainer: string | null) => {
setFilterTrainer(trainer); dispatch({ type: "SET_TRAINER", value: trainer });
if (trainer) scrollToSchedule(); if (trainer) scrollToSchedule();
}, [scrollToSchedule]); }, [scrollToSchedule]);
const setFilterTypeFromCard = useCallback((type: string | null) => { const setFilterTypeFromCard = useCallback((type: string | null) => {
setFilterType(type); dispatch({ type: "SET_TYPE", value: type });
if (type) scrollToSchedule(); if (type) scrollToSchedule();
}, [scrollToSchedule]); }, [scrollToSchedule]);
@@ -144,11 +205,7 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all" || filterTime !== "all" || filterDaySet.size > 0); const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all" || filterTime !== "all" || filterDaySet.size > 0);
function clearFilters() { function clearFilters() {
setFilterTrainer(null); dispatch({ type: "CLEAR_FILTERS" });
setFilterType(null);
setFilterStatus("all");
setFilterTime("all");
setFilterDaySet(new Set());
} }
// Available days for the day filter // Available days for the day filter
@@ -158,19 +215,27 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
); );
function toggleDay(day: string) { function toggleDay(day: string) {
setFilterDaySet((prev) => { dispatch({ type: "TOGGLE_DAY", day });
const next = new Set(prev);
if (next.has(day)) next.delete(day);
else next.add(day);
return next;
});
} }
function switchLocation(mode: LocationMode) { function switchLocation(mode: LocationMode) {
setLocationMode(mode); dispatch({ type: "SET_LOCATION", mode });
clearFilters();
} }
const gridLayout = useMemo(() => {
const len = filteredDays.length;
const cls = len >= 7 ? "sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7"
: len >= 6 ? "sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"
: len >= 4 ? "sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5"
: len === 3 ? "sm:grid-cols-2 lg:grid-cols-3"
: len === 2 ? "sm:grid-cols-2"
: "justify-items-center";
const style = len === 1 ? undefined
: len <= 3 && len > 0 ? { maxWidth: len * 340 + (len - 1) * 12, marginInline: "auto" as const }
: undefined;
return { cls, style };
}, [filteredDays.length]);
const activeTabClass = "bg-gold text-black shadow-[0_0_20px_rgba(201,169,110,0.3)]"; const activeTabClass = "bg-gold text-black shadow-[0_0_20px_rgba(201,169,110,0.3)]";
const inactiveTabClass = "border border-neutral-300 text-neutral-500 hover:border-neutral-400 hover:text-neutral-700 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20"; const inactiveTabClass = "border border-neutral-300 text-neutral-500 hover:border-neutral-400 hover:text-neutral-700 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20";
@@ -230,7 +295,7 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
<div className="mt-4 flex justify-center"> <div className="mt-4 flex justify-center">
<div className="inline-flex rounded-xl border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.08] dark:bg-white/[0.04]"> <div className="inline-flex rounded-xl border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.08] dark:bg-white/[0.04]">
<button <button
onClick={() => setViewMode("days")} onClick={() => dispatch({ type: "SET_VIEW", mode: "days" })}
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${ className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
viewMode === "days" viewMode === "days"
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white" ? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
@@ -241,7 +306,7 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
По дням По дням
</button> </button>
<button <button
onClick={() => setViewMode("groups")} onClick={() => dispatch({ type: "SET_VIEW", mode: "groups" })}
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${ className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
viewMode === "groups" viewMode === "groups"
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white" ? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
@@ -298,8 +363,8 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
{/* Desktop: grid layout */} {/* Desktop: grid layout */}
<Reveal> <Reveal>
<div <div
className={`mt-8 hidden sm:grid grid-cols-1 gap-3 px-4 sm:px-6 lg:px-8 xl:px-6 ${filteredDays.length >= 7 ? "sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7" : filteredDays.length >= 6 ? "sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6" : filteredDays.length >= 4 ? "sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5" : filteredDays.length === 3 ? "sm:grid-cols-2 lg:grid-cols-3" : filteredDays.length === 2 ? "sm:grid-cols-2" : "justify-items-center"}`} className={`mt-8 hidden sm:grid grid-cols-1 gap-3 px-4 sm:px-6 lg:px-8 xl:px-6 ${gridLayout.cls}`}
style={filteredDays.length === 1 ? undefined : filteredDays.length <= 3 && filteredDays.length > 0 ? { maxWidth: filteredDays.length * 340 + (filteredDays.length - 1) * 12, marginInline: "auto" } : undefined} style={gridLayout.style}
> >
{filteredDays.map((day) => ( {filteredDays.map((day) => (
<div <div
@@ -329,9 +394,17 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
filterTrainer={filterTrainer} filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainerFromCard} setFilterTrainer={setFilterTrainerFromCard}
showLocation={isAllMode} showLocation={isAllMode}
onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })}
/> />
</Reveal> </Reveal>
)} )}
<SignupModal
open={bookingGroup !== null}
onClose={() => dispatch({ type: "SET_BOOKING", value: null })}
subtitle={bookingGroup ?? undefined}
endpoint="/api/group-booking"
extraBody={{ groupInfo: bookingGroup }}
/>
</section> </section>
); );
} }

View File

@@ -6,13 +6,14 @@ import { Reveal } from "@/components/ui/Reveal";
import { TeamCarousel } from "@/components/sections/team/TeamCarousel"; import { TeamCarousel } from "@/components/sections/team/TeamCarousel";
import { TeamMemberInfo } from "@/components/sections/team/TeamMemberInfo"; import { TeamMemberInfo } from "@/components/sections/team/TeamMemberInfo";
import { TeamProfile } from "@/components/sections/team/TeamProfile"; import { TeamProfile } from "@/components/sections/team/TeamProfile";
import type { SiteContent } from "@/types/content"; import type { SiteContent, ScheduleLocation } from "@/types/content";
interface TeamProps { interface TeamProps {
data: SiteContent["team"]; data: SiteContent["team"];
schedule?: ScheduleLocation[];
} }
export function Team({ data: team }: TeamProps) { export function Team({ data: team, schedule }: TeamProps) {
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [showProfile, setShowProfile] = useState(false); const [showProfile, setShowProfile] = useState(false);
@@ -36,9 +37,10 @@ export function Team({ data: team }: TeamProps) {
<Reveal> <Reveal>
<SectionHeading centered>{team.title}</SectionHeading> <SectionHeading centered>{team.title}</SectionHeading>
</Reveal> </Reveal>
</div>
<Reveal> <Reveal>
<div className="mt-10"> <div className="mt-10 px-4 sm:px-6">
{!showProfile ? ( {!showProfile ? (
<> <>
<TeamCarousel <TeamCarousel
@@ -47,22 +49,24 @@ export function Team({ data: team }: TeamProps) {
onActiveChange={setActiveIndex} onActiveChange={setActiveIndex}
/> />
<div className="mx-auto max-w-6xl">
<TeamMemberInfo <TeamMemberInfo
members={team.members} members={team.members}
activeIndex={activeIndex} activeIndex={activeIndex}
onSelect={setActiveIndex} onSelect={setActiveIndex}
onOpenBio={() => setShowProfile(true)} onOpenBio={() => setShowProfile(true)}
/> />
</div>
</> </>
) : ( ) : (
<TeamProfile <TeamProfile
member={team.members[activeIndex]} member={team.members[activeIndex]}
onBack={() => setShowProfile(false)} onBack={() => setShowProfile(false)}
schedule={schedule}
/> />
)} )}
</div> </div>
</Reveal> </Reveal>
</div>
</section> </section>
); );
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { User, Clock, CalendarDays, MapPin } from "lucide-react"; import { User, MapPin } from "lucide-react";
import { shortAddress } from "./constants"; import { shortAddress } from "./constants";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants"; import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
@@ -20,9 +20,12 @@ function buildGroups(days: ScheduleDayMerged[]): ScheduleGroup[] {
for (const day of days) { for (const day of days) {
for (const cls of day.classes as ScheduleClassWithLocation[]) { for (const cls of day.classes as ScheduleClassWithLocation[]) {
// Include location in key so same trainer+type at different locations = separate groups // Use groupId if available, otherwise fall back to trainer+type+location
const locPart = cls.locationName ?? ""; const locPart = cls.locationName ?? "";
const key = `${cls.trainer}||${cls.type}||${locPart}`; const key = cls.groupId
? `${cls.groupId}||${locPart}`
: `${cls.trainer}||${cls.type}||${locPart}`;
const existing = map.get(key); const existing = map.get(key);
if (existing) { if (existing) {
existing.slots.push({ day: day.day, dayShort: day.dayShort, time: cls.time }); existing.slots.push({ day: day.day, dayShort: day.dayShort, time: cls.time });
@@ -47,6 +50,70 @@ function buildGroups(days: ScheduleDayMerged[]): ScheduleGroup[] {
return Array.from(map.values()); return Array.from(map.values());
} }
/** Group slots by day, then merge days that share identical time sets */
function mergeSlotsByDay(slots: { day: string; dayShort: string; time: string }[]): { days: string[]; times: string[] }[] {
// Step 1: collect times per day
const dayMap = new Map<string, { dayShort: string; times: string[] }>();
const dayOrder: string[] = [];
for (const s of slots) {
const existing = dayMap.get(s.day);
if (existing) {
if (!existing.times.includes(s.time)) existing.times.push(s.time);
} else {
dayMap.set(s.day, { dayShort: s.dayShort, times: [s.time] });
dayOrder.push(s.day);
}
}
// Sort times within each day
for (const entry of dayMap.values()) entry.times.sort();
// Step 2: merge days with identical time sets
const result: { days: string[]; times: string[] }[] = [];
const used = new Set<string>();
for (const day of dayOrder) {
if (used.has(day)) continue;
const entry = dayMap.get(day)!;
const timeKey = entry.times.join("|");
const days = [entry.dayShort];
used.add(day);
for (const other of dayOrder) {
if (used.has(other)) continue;
const o = dayMap.get(other)!;
if (o.times.join("|") === timeKey) { days.push(o.dayShort); used.add(other); }
}
result.push({ days, times: entry.times });
}
return result;
}
/** Group schedule groups by trainer for compact display */
function groupByTrainer(groups: ScheduleGroup[]): Map<string, ScheduleGroup[]> {
const map = new Map<string, ScheduleGroup[]>();
for (const g of groups) {
const existing = map.get(g.trainer);
if (existing) existing.push(g);
else map.set(g.trainer, [g]);
}
return map;
}
/** Within a trainer's groups, cluster by class type preserving order */
function groupByType(groups: ScheduleGroup[]): { type: string; groups: ScheduleGroup[] }[] {
const result: { type: string; groups: ScheduleGroup[] }[] = [];
const map = new Map<string, ScheduleGroup[]>();
for (const g of groups) {
const existing = map.get(g.type);
if (existing) {
existing.push(g);
} else {
const arr = [g];
map.set(g.type, arr);
result.push({ type: g.type, groups: arr });
}
}
return result;
}
interface GroupViewProps { interface GroupViewProps {
typeDots: Record<string, string>; typeDots: Record<string, string>;
filteredDays: ScheduleDayMerged[]; filteredDays: ScheduleDayMerged[];
@@ -55,6 +122,7 @@ interface GroupViewProps {
filterTrainer: string | null; filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void; setFilterTrainer: (trainer: string | null) => void;
showLocation?: boolean; showLocation?: boolean;
onBook?: (groupInfo: string) => void;
} }
export function GroupView({ export function GroupView({
@@ -65,8 +133,10 @@ export function GroupView({
filterTrainer, filterTrainer,
setFilterTrainer, setFilterTrainer,
showLocation, showLocation,
onBook,
}: GroupViewProps) { }: GroupViewProps) {
const groups = buildGroups(filteredDays); const groups = buildGroups(filteredDays);
const byTrainer = groupByTrainer(groups);
if (groups.length === 0) { if (groups.length === 0) {
return ( return (
@@ -77,106 +147,120 @@ export function GroupView({
} }
return ( return (
<div className="mt-8 grid grid-cols-1 gap-3 px-4 sm:grid-cols-2 lg:grid-cols-3 sm:px-6 lg:px-8 xl:px-6"> <div className="mt-8 space-y-3 px-4 sm:px-6 lg:px-8 xl:px-6 max-w-4xl mx-auto">
{groups.map((group) => { {Array.from(byTrainer.entries()).map(([trainer, trainerGroups]) => {
const dotColor = typeDots[group.type] ?? "bg-white/30"; const byType = groupByType(trainerGroups);
const totalGroups = trainerGroups.length;
return ( return (
<div <div
key={`${group.trainer}||${group.type}||${group.location ?? ""}`} key={trainer}
className={`rounded-2xl border overflow-hidden transition-colors ${ className="rounded-xl border border-neutral-200 bg-white overflow-hidden dark:border-white/[0.06] dark:bg-[#0a0a0a]"
group.hasSlots
? "border-emerald-500/25 bg-white dark:border-emerald-500/15 dark:bg-[#0a0a0a]"
: group.recruiting
? "border-sky-500/25 bg-white dark:border-sky-500/15 dark:bg-[#0a0a0a]"
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a]"
}`}
> >
{/* Header */} {/* Trainer header */}
<div className={`px-5 py-4 border-b ${
group.hasSlots
? "border-emerald-500/15 bg-emerald-500/5 dark:border-emerald-500/10 dark:bg-emerald-500/[0.03]"
: group.recruiting
? "border-sky-500/15 bg-sky-500/5 dark:border-sky-500/10 dark:bg-sky-500/[0.03]"
: "border-neutral-100 bg-neutral-50 dark:border-white/[0.04] dark:bg-white/[0.02]"
}`}>
{/* Type + badges */}
<div className="flex items-center gap-2 flex-wrap">
<button <button
onClick={() => setFilterType(filterType === group.type ? null : group.type)} onClick={() => setFilterTrainer(filterTrainer === trainer ? null : trainer)}
className={`flex items-center gap-2 active:opacity-60 ${ className={`flex items-center gap-2 w-full px-4 py-2.5 text-left transition-colors cursor-pointer ${
filterType === group.type ? "opacity-100" : "" filterTrainer === trainer
? "bg-gold/10 dark:bg-gold/5"
: "bg-neutral-50 dark:bg-white/[0.02]"
}`} }`}
> >
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`} /> <User size={14} className={filterTrainer === trainer ? "text-gold" : "text-neutral-400 dark:text-white/40"} />
<span className="text-base font-semibold text-neutral-900 dark:text-white/90"> <span className={`text-sm font-semibold ${
{group.type} filterTrainer === trainer ? "text-gold" : "text-neutral-800 dark:text-white/80"
}`}>
{trainer}
</span>
<span className="ml-auto text-[10px] text-neutral-400 dark:text-white/25">
{totalGroups === 1 ? "1 группа" : `${totalGroups} групп${totalGroups < 5 ? "ы" : ""}`}
</span> </span>
</button> </button>
{/* Type → Groups */}
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
{byType.map(({ type, groups: typeGroups }) => {
const dotColor = typeDots[type] ?? "bg-white/30";
return (
<div key={type} className="px-4 py-2.5">
{/* Class type row */}
<button
onClick={() => setFilterType(filterType === type ? null : type)}
className="flex items-center gap-1.5 cursor-pointer"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${dotColor}`} />
<span className="text-sm font-medium text-neutral-800 dark:text-white/80">
{type}
</span>
</button>
{/* Group rows under this type */}
<div className="mt-1.5 space-y-1 pl-3.5">
{typeGroups.map((group, gi) => {
const merged = mergeSlotsByDay(group.slots);
return (
<div
key={gi}
className="flex items-center gap-2 flex-wrap"
>
{/* Datetimes */}
<div className="flex items-center gap-0.5 flex-wrap">
{merged.map((m, i) => (
<span key={i} className="inline-flex items-center gap-1 text-xs">
{i > 0 && <span className="text-neutral-300 dark:text-white/15 mx-0.5">·</span>}
<span className="rounded bg-gold/10 px-1.5 py-0.5 text-[10px] font-bold text-gold-dark dark:text-gold">
{m.days.join(", ")}
</span>
<span className="font-medium tabular-nums text-neutral-500 dark:text-white/45">
{m.times.join(", ")}
</span>
</span>
))}
</div>
{/* Badges */}
{group.level && ( {group.level && (
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-0.5 text-[10px] font-semibold text-rose-600 dark:text-rose-400"> <span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-px text-[10px] font-semibold text-rose-600 dark:text-rose-400">
{group.level} {group.level}
</span> </span>
)} )}
{group.hasSlots && ( {group.hasSlots && (
<span className="rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400"> <span className="rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2 py-px text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
есть места есть места
</span> </span>
)} )}
{group.recruiting && ( {group.recruiting && (
<span className="rounded-full bg-sky-500/15 border border-sky-500/25 px-2 py-0.5 text-[10px] font-semibold text-sky-600 dark:text-sky-400"> <span className="rounded-full bg-sky-500/15 border border-sky-500/25 px-2 py-px text-[10px] font-semibold text-sky-600 dark:text-sky-400">
набор набор
</span> </span>
)} )}
</div>
{/* Trainer */} {/* Location */}
<button
onClick={() => setFilterTrainer(filterTrainer === group.trainer ? null : group.trainer)}
className={`mt-2 flex items-center gap-1.5 text-sm active:opacity-60 ${
filterTrainer === group.trainer
? "text-gold underline underline-offset-2"
: "text-neutral-500 dark:text-white/50"
}`}
>
<User size={13} className="shrink-0" />
{group.trainer}
</button>
{/* Location badge — only in "all" mode */}
{showLocation && group.location && ( {showLocation && group.location && (
<div className="mt-2 flex items-center gap-1 text-[11px] text-neutral-400 dark:text-white/30"> <span className="flex items-center gap-1 text-[10px] text-neutral-400 dark:text-white/25">
<MapPin size={10} className="shrink-0" /> <MapPin size={9} />
{group.location} {group.location}
{group.locationAddress && (
<span className="text-neutral-300 dark:text-white/15"> · {shortAddress(group.locationAddress)}</span>
)}
</div>
)}
</div>
{/* Schedule slots */}
<div className="px-5 py-3.5">
<div className="flex items-center gap-1.5 mb-2.5 text-[11px] font-medium text-neutral-400 dark:text-white/25 uppercase tracking-wider">
<CalendarDays size={12} />
Расписание
</div>
<div className="space-y-1.5">
{group.slots.map((slot, i) => (
<div
key={i}
className="flex items-center gap-3 rounded-lg bg-neutral-50 px-3 py-2 dark:bg-white/[0.03]"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-gold/10 text-[10px] font-bold text-gold-dark dark:text-gold-light">
{slot.dayShort}
</span> </span>
<div className="flex items-center gap-1.5 text-sm text-neutral-600 dark:text-white/60"> )}
<Clock size={12} className="shrink-0 text-neutral-400 dark:text-white/30" />
<span className="font-medium tabular-nums">{slot.time}</span> {/* Book button */}
{onBook && (
<button
onClick={() => onBook(`${group.type}, ${group.trainer}, ${group.slots.map(s => s.dayShort).join("/")} ${group.slots[0]?.time ?? ""}`)}
className="ml-auto rounded-lg bg-gold/10 border border-gold/20 px-3 py-1 text-[11px] font-semibold text-gold hover:bg-gold/20 transition-colors cursor-pointer shrink-0"
>
Записаться
</button>
)}
</div>
);
})}
</div> </div>
</div> </div>
))} );
</div> })}
</div> </div>
</div> </div>
); );

View File

@@ -87,6 +87,7 @@ export interface ScheduleClassWithLocation {
level?: string; level?: string;
hasSlots?: boolean; hasSlots?: boolean;
recruiting?: boolean; recruiting?: boolean;
groupId?: string;
locationName?: string; locationName?: string;
locationAddress?: string; locationAddress?: string;
} }

View File

@@ -49,6 +49,7 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
const total = members.length; const total = members.length;
const [dragOffset, setDragOffset] = useState(0); const [dragOffset, setDragOffset] = useState(0);
const isDraggingRef = useRef(false); const isDraggingRef = useRef(false);
const wasDragRef = useRef(false);
const pausedUntilRef = useRef(0); const pausedUntilRef = useRef(0);
const dragStartRef = useRef<{ x: number; startIndex: number } | null>(null); const dragStartRef = useRef<{ x: number; startIndex: number } | null>(null);
@@ -76,6 +77,7 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId); (e.target as HTMLElement).setPointerCapture(e.pointerId);
isDraggingRef.current = true; isDraggingRef.current = true;
wasDragRef.current = false;
dragStartRef.current = { x: e.clientX, startIndex: activeIndex }; dragStartRef.current = { x: e.clientX, startIndex: activeIndex };
setDragOffset(0); setDragOffset(0);
}, },
@@ -86,6 +88,7 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
if (!dragStartRef.current) return; if (!dragStartRef.current) return;
const dx = e.clientX - dragStartRef.current.x; const dx = e.clientX - dragStartRef.current.x;
if (Math.abs(dx) > 10) wasDragRef.current = true;
setDragOffset(dx); setDragOffset(dx);
}, },
[] []
@@ -194,7 +197,13 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
return ( return (
<div <div
key={m.name} key={m.name}
className={`absolute bottom-0 overflow-hidden rounded-2xl border pointer-events-none ${style.isCenter ? "team-card-glitter" : ""}`} onClick={() => {
if (!style.isCenter && !wasDragRef.current) {
onActiveChange(i);
pausedUntilRef.current = Date.now() + PAUSE_MS;
}
}}
className={`absolute bottom-0 overflow-hidden rounded-2xl border ${style.isCenter ? "team-card-glitter" : "cursor-pointer"} pointer-events-auto`}
style={{ style={{
width: style.width, width: style.width,
height: style.height, height: style.height,
@@ -213,6 +222,7 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
src={m.image} src={m.image}
alt={m.name} alt={m.name}
fill fill
loading="lazy"
sizes="280px" sizes="280px"
className="object-cover" className="object-cover"
draggable={false} draggable={false}

View File

@@ -1,109 +1,478 @@
import { useState, useEffect, useRef, useCallback } from "react";
import Image from "next/image"; import Image from "next/image";
import { ArrowLeft, Instagram, Trophy, Award, GraduationCap } from "lucide-react"; import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Award, Scale, Clock, MapPin } from "lucide-react";
import type { TeamMember } from "@/types/content"; import type { TeamMember, RichListItem, VictoryItem, ScheduleLocation } from "@/types/content";
import { SignupModal } from "@/components/ui/SignupModal";
interface TeamProfileProps { interface TeamProfileProps {
member: TeamMember; member: TeamMember;
onBack: () => void; onBack: () => void;
schedule?: ScheduleLocation[];
} }
const BIO_SECTIONS = [ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
{ key: "experience" as const, label: "Опыт", icon: Trophy }, const [lightbox, setLightbox] = useState<string | null>(null);
{ key: "victories" as const, label: "Достижения", icon: Award }, const [bookingGroup, setBookingGroup] = useState<string | null>(null);
{ key: "education" as const, label: "Образование", icon: GraduationCap },
];
export function TeamProfile({ member, onBack }: TeamProfileProps) { useEffect(() => {
const hasBio = BIO_SECTIONS.some( function handleKeyDown(e: KeyboardEvent) {
(s) => member[s.key] && member[s.key]!.length > 0 if (e.key === "Escape") {
); if (lightbox) setLightbox(null);
else onBack();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [lightbox, onBack]);
const places = member.victories?.filter(v => !v.type || v.type === 'place') ?? [];
const nominations = member.victories?.filter(v => v.type === 'nomination') ?? [];
const judging = member.victories?.filter(v => v.type === 'judge') ?? [];
const victoryTabs = [
...(places.length > 0 ? [{ key: 'place' as const, label: 'Достижения', icon: Trophy, items: places }] : []),
...(nominations.length > 0 ? [{ key: 'nomination' as const, label: 'Номинации', icon: Award, items: nominations }] : []),
...(judging.length > 0 ? [{ key: 'judge' as const, label: 'Судейство', icon: Scale, items: judging }] : []),
];
const hasVictories = victoryTabs.length > 0;
const [activeTab, setActiveTab] = useState(victoryTabs[0]?.key ?? 'place');
const hasExperience = member.experience && member.experience.length > 0;
const hasEducation = member.education && member.education.length > 0;
// Extract trainer's groups from schedule using groupId
const groupMap = new Map<string, { type: string; location: string; address: string; slots: { day: string; dayShort: string; time: string }[]; level?: string; recruiting?: boolean }>();
schedule?.forEach(location => {
location.days.forEach(day => {
day.classes
.filter(c => c.trainer === member.name)
.forEach(c => {
const key = c.groupId
? `${c.groupId}||${location.name}`
: `${c.trainer}||${c.type}||${location.name}`;
const existing = groupMap.get(key);
if (existing) {
existing.slots.push({ day: day.day, dayShort: day.dayShort, time: c.time });
if (c.level && !existing.level) existing.level = c.level;
if (c.recruiting) existing.recruiting = true;
} else {
groupMap.set(key, {
type: c.type,
location: location.name,
address: location.address,
slots: [{ day: day.day, dayShort: day.dayShort, time: c.time }],
level: c.level,
recruiting: c.recruiting,
});
}
});
});
});
const uniqueGroups = Array.from(groupMap.values()).map(g => {
// Merge slots by day, then merge days with identical time sets
const dayMap = new Map<string, { dayShort: string; times: string[] }>();
const dayOrder: string[] = [];
for (const s of g.slots) {
const existing = dayMap.get(s.day);
if (existing) {
if (!existing.times.includes(s.time)) existing.times.push(s.time);
} else {
dayMap.set(s.day, { dayShort: s.dayShort, times: [s.time] });
dayOrder.push(s.day);
}
}
for (const entry of dayMap.values()) entry.times.sort();
const merged: { days: string[]; times: string[] }[] = [];
const used = new Set<string>();
for (const day of dayOrder) {
if (used.has(day)) continue;
const entry = dayMap.get(day)!;
const timeKey = entry.times.join("|");
const days = [entry.dayShort];
used.add(day);
for (const other of dayOrder) {
if (used.has(other)) continue;
const o = dayMap.get(other)!;
if (o.times.join("|") === timeKey) { days.push(o.dayShort); used.add(other); }
}
merged.push({ days, times: entry.times });
}
return { ...g, merged };
});
const hasGroups = uniqueGroups.length > 0;
const hasBio = hasVictories || hasExperience || hasEducation || hasGroups;
return ( return (
<div <div
className="mx-auto max-w-3xl" className="w-full"
style={{ animation: "team-info-in 0.6s cubic-bezier(0.16, 1, 0.3, 1)" }} style={{ animation: "team-info-in 0.6s cubic-bezier(0.16, 1, 0.3, 1)" }}
> >
{/* Back button */} {/* Magazine editorial layout */}
<div className="relative mx-auto max-w-5xl flex flex-col sm:flex-row sm:items-start">
{/* Photo — left column, sticky */}
<div className="relative shrink-0 w-full sm:w-[380px] lg:w-[420px] sm:sticky sm:top-8">
<button <button
onClick={onBack} onClick={onBack}
className="mb-6 inline-flex items-center gap-1.5 text-sm text-white/40 transition-colors hover:text-gold-light cursor-pointer" className="mb-3 inline-flex items-center gap-1.5 rounded-full bg-white/[0.06] px-3 py-1.5 text-sm text-white/50 transition-colors hover:text-white hover:bg-white/[0.1] cursor-pointer"
> >
<ArrowLeft size={16} /> <ArrowLeft size={14} />
Назад Назад
</button> </button>
<div className="relative aspect-[3/4] overflow-hidden rounded-2xl border border-white/[0.06]">
{/* Main: photo + info */}
<div className="flex flex-col items-center gap-8 sm:flex-row sm:items-start">
{/* Photo */}
<div className="relative w-full max-w-[260px] shrink-0 aspect-[3/4] overflow-hidden rounded-2xl border border-white/[0.06]">
<Image <Image
src={member.image} src={member.image}
alt={member.name} alt={member.name}
fill fill
sizes="260px" sizes="(min-width: 1024px) 380px, (min-width: 640px) 340px, 100vw"
className="object-cover" className="object-cover"
/> />
</div> {/* Top gradient for name */}
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-transparent to-transparent" />
{/* Bottom gradient for mobile bio peek */}
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent sm:hidden" />
{/* Info */} {/* Name + role overlay at top */}
<div className="text-center sm:text-left"> <div className="absolute top-0 left-0 right-0 p-6 sm:p-8">
<h3 className="text-2xl font-bold text-white">{member.name}</h3> <h3
<p className="mt-1 text-sm font-medium text-gold-light"> className="text-3xl sm:text-4xl font-bold text-white leading-tight"
style={{ textShadow: "0 2px 24px rgba(0,0,0,0.6)" }}
>
{member.name}
</h3>
<p
className="mt-1.5 text-sm sm:text-base font-medium text-gold-light"
style={{ textShadow: "0 1px 12px rgba(0,0,0,0.5)" }}
>
{member.role} {member.role}
</p> </p>
{member.instagram && ( {member.instagram && (
<a <a
href={member.instagram} href={member.instagram}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1.5 text-sm text-white/40 transition-colors hover:text-gold-light" className="mt-3 inline-flex items-center gap-1.5 text-sm text-white/70 transition-colors hover:text-gold-light"
style={{ textShadow: "0 1px 8px rgba(0,0,0,0.5)" }}
> >
<Instagram size={14} /> <Instagram size={14} />
{member.instagram.split("/").filter(Boolean).pop()} {member.instagram.split("/").filter(Boolean).pop()}
</a> </a>
)} )}
</div>
</div>
</div>
{/* Bio panel — overlaps photo edge on desktop */}
<div className="relative sm:-ml-12 sm:mt-8 mt-0 flex-1 min-w-0 z-10">
<div className="relative rounded-2xl border border-white/[0.08] overflow-hidden shadow-2xl shadow-black/40">
{/* Ambient photo background */}
<div className="absolute inset-0">
<Image
src={member.image}
alt=""
fill
sizes="600px"
className="object-cover scale-150 blur-sm grayscale opacity-70 brightness-[0.6] contrast-[1.3]"
/>
<div className="absolute inset-0 bg-black/20 mix-blend-multiply" />
<div className="absolute inset-0 bg-gold/10 mix-blend-color" />
</div>
<div className="relative p-5 sm:p-6">
{/* Victory tabs */}
{hasVictories && (
<div>
<div className="flex flex-wrap gap-2">
{victoryTabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`inline-flex items-center gap-1.5 rounded-full border px-4 py-1.5 text-sm font-medium transition-colors cursor-pointer ${
activeTab === tab.key
? "border-gold/30 bg-gold/10 text-gold"
: "border-white/[0.08] bg-white/[0.03] text-white/40 hover:text-white/60"
}`}
>
<tab.icon size={14} />
{tab.label}
<span className={`ml-0.5 text-xs ${activeTab === tab.key ? "text-gold/60" : "text-white/20"}`}>
{tab.items.length}
</span>
</button>
))}
</div>
<div className="grid mt-4" style={{ gridTemplateColumns: "1fr", gridTemplateRows: "1fr" }}>
{victoryTabs.map(tab => (
<div key={tab.key} className={`col-start-1 row-start-1 ${activeTab === tab.key ? "" : "invisible"}`}>
<ScrollRow>
{tab.items.map((item, i) => (
<VictoryCard key={i} victory={item} />
))}
</ScrollRow>
</div>
))}
</div>
</div>
)}
{/* Groups */}
{hasGroups && (
<div className={hasVictories ? "mt-8" : ""}>
<span className="inline-flex items-center gap-1.5 rounded-full border border-gold/20 bg-gold/5 px-4 py-1.5 text-sm font-medium text-gold">
<Clock size={14} />
Группы
</span>
<ScrollRow>
{uniqueGroups.map((g, i) => (
<div key={i} className="w-48 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3 space-y-1.5">
<p className="text-xs font-semibold uppercase tracking-wider text-white/80">{g.type}</p>
<div className="space-y-0.5">
{g.merged.map((m, mi) => (
<div key={mi} className="flex items-center gap-1.5 text-xs text-white/50">
<Clock size={11} className="shrink-0" />
<span className="font-medium text-white/70">{m.days.join(", ")}</span>
<span>{m.times.join(", ")}</span>
</div>
))}
</div>
<div className="flex items-start gap-1.5 text-xs text-white/40">
<MapPin size={11} className="mt-0.5 shrink-0" />
<span>{g.location} · {g.address.replace(/^г\.\s*\S+,\s*/, "")}</span>
</div>
{g.level && (
<p className="text-[10px] text-gold/60">{g.level}</p>
)}
{g.recruiting && (
<span className="inline-block rounded-full bg-green-500/15 border border-green-500/30 px-2 py-0.5 text-[10px] text-green-400">
Набор открыт
</span>
)}
<button
onClick={() => setBookingGroup(`${g.type}, ${g.merged.map(m => m.days.join("/")).join(", ")} ${g.merged[0]?.times[0] ?? ""}`)}
className="w-full mt-1 rounded-lg bg-gold/15 border border-gold/25 py-1.5 text-[11px] font-semibold text-gold hover:bg-gold/25 transition-colors cursor-pointer"
>
Записаться
</button>
</div>
))}
</ScrollRow>
</div>
)}
{/* Education */}
{hasEducation && (
<div className={hasVictories || hasGroups ? "mt-8" : ""}>
<span className="inline-flex items-center gap-1.5 rounded-full border border-gold/20 bg-gold/5 px-4 py-1.5 text-sm font-medium text-gold">
<GraduationCap size={14} />
Образование
</span>
<ScrollRow>
{member.education!.map((item, i) => (
<RichCard key={i} item={item} onImageClick={setLightbox} />
))}
</ScrollRow>
</div>
)}
{/* Experience */}
{hasExperience && (
<div className={hasVictories || hasGroups || hasEducation ? "mt-8" : ""}>
<span className="inline-flex items-center gap-1.5 rounded-full border border-gold/20 bg-gold/5 px-4 py-1.5 text-sm font-medium text-gold">
<Trophy size={15} />
Опыт
</span>
<ScrollRow>
{member.experience!.map((item, i) => (
<div key={i} className="w-48 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3">
<p className="text-sm text-white/60">{item}</p>
</div>
))}
</ScrollRow>
</div>
)}
{/* Description */}
{member.description && ( {member.description && (
<p className="mt-4 text-sm leading-relaxed text-white/55"> <p className={`text-sm leading-relaxed text-white/45 ${hasBio ? "mt-8 border-t border-white/[0.06] pt-6" : ""}`}>
{member.description} {member.description}
</p> </p>
)} )}
{/* Empty state */}
{!hasBio && !member.description && (
<p className="text-sm text-white/30 italic">
Информация скоро появится
</p>
)}
</div> </div>
</div> </div>
{/* Bio sections */}
{hasBio && (
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-3">
{BIO_SECTIONS.map((section) => {
const items = member[section.key];
if (!items || items.length === 0) return null;
const Icon = section.icon;
return (
<div key={section.key}>
<div className="flex items-center gap-2 text-gold">
<Icon size={16} />
<span className="text-xs font-semibold uppercase tracking-wider">
{section.label}
</span>
</div> </div>
<ul className="mt-3 space-y-2"> </div>
{items.map((item, i) => (
<li {/* Image lightbox */}
key={i} {lightbox && (
className="flex items-start gap-2 text-sm text-white/60" <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-label="Просмотр изображения"
onClick={() => setLightbox(null)}
> >
<span className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-gold/50" /> <button
{item} onClick={() => setLightbox(null)}
</li> aria-label="Закрыть"
))} className="absolute top-4 right-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20 transition-colors"
</ul> >
<X size={20} />
</button>
<div className="relative max-h-[85vh] max-w-[90vw]">
<Image
src={lightbox}
alt="Достижение"
width={900}
height={900}
className="rounded-lg object-contain max-h-[85vh]"
/>
</div> </div>
);
})}
</div> </div>
)} )}
<SignupModal
open={bookingGroup !== null}
onClose={() => setBookingGroup(null)}
subtitle={bookingGroup ?? undefined}
endpoint="/api/group-booking"
extraBody={{ groupInfo: bookingGroup }}
/>
</div>
);
}
function ScrollRow({ children }: { children: React.ReactNode }) {
const scrollRef = useRef<HTMLDivElement>(null);
const dragState = useRef<{ startX: number; scrollLeft: number } | null>(null);
const wasDragged = useRef(false);
const onPointerDown = useCallback((e: React.PointerEvent) => {
const el = scrollRef.current;
if (!el) return;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragState.current = { startX: e.clientX, scrollLeft: el.scrollLeft };
wasDragged.current = false;
}, []);
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current || !scrollRef.current) return;
const dx = e.clientX - dragState.current.startX;
if (Math.abs(dx) > 4) wasDragged.current = true;
scrollRef.current.scrollLeft = dragState.current.scrollLeft - dx;
}, []);
const onPointerUp = useCallback(() => {
dragState.current = null;
}, []);
return (
<div className="relative mt-4">
<div
ref={scrollRef}
className="flex items-stretch gap-3 overflow-x-auto pb-2 pt-4 cursor-grab active:cursor-grabbing select-none"
style={{ scrollbarWidth: "none", msOverflowStyle: "none", WebkitOverflowScrolling: "touch" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onLostPointerCapture={onPointerUp}
>
{children}
</div>
</div>
);
}
function VictoryCard({ victory }: { victory: VictoryItem }) {
const hasLink = !!victory.link;
return (
<div className="group w-44 shrink-0 rounded-xl border border-white/[0.08] overflow-visible bg-white/[0.03] relative">
<div className="absolute top-0 left-0 w-1 h-full bg-gold/40 rounded-l-xl" />
{victory.place && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
<span className="inline-block rounded-full border border-gold/40 bg-gold/20 px-3 py-0.5 text-xs font-bold uppercase tracking-wider text-gold whitespace-nowrap backdrop-blur-sm">
{victory.place}
</span>
</div>
)}
<div className={`pl-4 pr-3 pb-3 space-y-1 ${victory.place ? "pt-6" : "py-3"}`}>
{victory.category && (
<p className="text-xs font-semibold uppercase tracking-wider text-white/80">{victory.category}</p>
)}
<p className="text-sm text-white/50">{victory.competition}</p>
{hasLink && (
<a
href={victory.link}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-gold/70 hover:text-gold transition-colors"
>
<ExternalLink size={10} />
Подробнее
</a>
)}
</div>
</div>
);
}
function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (src: string) => void }) {
const hasImage = !!item.image;
const hasLink = !!item.link;
if (hasImage) {
return (
<div className="group w-48 shrink-0 flex rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03]">
<button
onClick={() => onImageClick(item.image!)}
className="relative w-14 shrink-0 overflow-hidden cursor-pointer"
>
<Image
src={item.image!}
alt={item.text}
fill
sizes="56px"
className="object-cover transition-transform group-hover:scale-105"
/>
</button>
<div className="flex-1 min-w-0 p-2.5">
<p className="text-xs text-white/70">{item.text}</p>
{hasLink && (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-gold/70 hover:text-gold transition-colors"
>
<ExternalLink size={11} />
Подробнее
</a>
)}
</div>
</div>
);
}
return (
<div className="group w-48 shrink-0 rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03]">
<div className="p-3">
<p className="text-sm text-white/60">{item.text}</p>
{hasLink && (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="mt-1.5 inline-flex items-center gap-1 text-xs text-gold/70 hover:text-gold transition-colors"
>
<ExternalLink size={11} />
Подробнее
</a>
)}
</div>
</div> </div>
); );
} }

View File

@@ -1,200 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { X, Instagram, Send, CheckCircle, Phone } from "lucide-react";
import { siteContent } from "@/data/content";
interface BookingModalProps {
open: boolean;
onClose: () => void;
}
export function BookingModal({ open, onClose }: BookingModalProps) {
const { contact } = siteContent;
const [name, setName] = useState("");
const [phone, setPhone] = useState("+375 ");
// Format phone: +375 (XX) XXX-XX-XX
function handlePhoneChange(raw: string) {
// Strip everything except digits
let digits = raw.replace(/\D/g, "");
// Ensure starts with 375
if (!digits.startsWith("375")) {
digits = "375" + digits.replace(/^375?/, "");
}
// Limit to 12 digits (375 + 9 digits)
digits = digits.slice(0, 12);
// Format
let formatted = "+375";
const rest = digits.slice(3);
if (rest.length > 0) formatted += " (" + rest.slice(0, 2);
if (rest.length >= 2) formatted += ") ";
if (rest.length > 2) formatted += rest.slice(2, 5);
if (rest.length > 5) formatted += "-" + rest.slice(5, 7);
if (rest.length > 7) formatted += "-" + rest.slice(7, 9);
setPhone(formatted);
}
const [submitted, setSubmitted] = useState(false);
// Close on Escape
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
// Lock body scroll
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
// Build Instagram DM message with pre-filled text
const message = `Здравствуйте! Меня зовут ${name}, хочу записаться на занятие. Мой телефон: ${phone}`;
const instagramUrl = `https://ig.me/m/blackheartdancehouse?text=${encodeURIComponent(message)}`;
window.open(instagramUrl, "_blank");
setSubmitted(true);
},
[name, phone]
);
const handleClose = useCallback(() => {
onClose();
// Reset after animation
setTimeout(() => {
setName("");
setPhone("+375 ");
setSubmitted(false);
}, 300);
}, [onClose]);
if (!open) return null;
return createPortal(
<div
className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={handleClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
{/* Modal */}
<div
className="modal-content relative w-full max-w-md rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 sm:p-8 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Close button */}
<button
onClick={handleClose}
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-white cursor-pointer"
>
<X size={18} />
</button>
{submitted ? (
/* Success state */
<div className="py-4 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10">
<CheckCircle size={28} className="text-emerald-500" />
</div>
<h3 className="text-lg font-bold text-white">Отлично!</h3>
<p className="mt-2 text-sm text-neutral-400">
Сообщение отправлено в Instagram. Мы свяжемся с вами в ближайшее время!
</p>
<button
onClick={handleClose}
className="mt-6 rounded-full bg-gold px-6 py-2.5 text-sm font-semibold text-black transition-all hover:bg-gold-light cursor-pointer"
>
Закрыть
</button>
</div>
) : (
<>
{/* Header */}
<div className="mb-6">
<h3 className="text-xl font-bold text-white">Записаться</h3>
<p className="mt-1 text-sm text-neutral-400">
Оставьте данные и мы свяжемся с вами, или напишите нам напрямую
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ваше имя"
required
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
</div>
<div>
<input
type="tel"
value={phone}
onChange={(e) => handlePhoneChange(e.target.value)}
placeholder="+375 (__) ___-__-__"
required
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>
</div>
<button
type="submit"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gold py-3 text-sm font-semibold text-black transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/20 cursor-pointer"
>
<Send size={15} />
Отправить в Instagram
</button>
</form>
{/* Divider */}
<div className="my-5 flex items-center gap-3">
<span className="h-px flex-1 bg-white/[0.06]" />
<span className="text-xs text-neutral-500">или напрямую</span>
<span className="h-px flex-1 bg-white/[0.06]" />
</div>
{/* Direct links */}
<div className="flex gap-2">
<a
href={contact.instagram}
target="_blank"
rel="noopener noreferrer"
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.03] py-3 text-sm font-medium text-neutral-300 transition-all hover:border-gold/30 hover:text-gold-light cursor-pointer"
>
<Instagram size={16} />
Instagram
</a>
<a
href={`tel:${contact.phone.replace(/\s/g, "")}`}
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.03] py-3 text-sm font-medium text-neutral-300 transition-all hover:border-gold/30 hover:text-gold-light cursor-pointer"
>
<Phone size={16} />
Позвонить
</a>
</div>
</>
)}
</div>
</div>,
document.body
);
}

View File

@@ -96,10 +96,10 @@ export function HeroLogo({ className = "", size = 220 }: HeroLogoProps) {
d={FULL_PATH} d={FULL_PATH}
/> />
{/* Glitter sparkles on heart surface */} {/* Glitter sparkles on heart surface — odd-indexed hidden on mobile via CSS class */}
<g clipPath="url(#heart-clip)" filter="url(#sparkle-glow)"> <g clipPath="url(#heart-clip)" filter="url(#sparkle-glow)">
{SPARKLES.map((s, i) => ( {SPARKLES.map((s, i) => (
<circle key={`sparkle-${i}`} cx={s.x} cy={s.y} r="1.8" fill="#d4b87a"> <circle key={`sparkle-${i}`} cx={s.x} cy={s.y} r="1.8" fill="#d4b87a" className={i % 2 ? "hidden sm:block" : ""}>
<animate <animate
attributeName="opacity" attributeName="opacity"
values="0;0;0.9;1;0.9;0;0" values="0;0;0.9;1;0.9;0;0"

View File

@@ -0,0 +1,114 @@
"use client";
import { useEffect } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import { X, Calendar, ExternalLink } from "lucide-react";
import type { NewsItem } from "@/types/content";
interface NewsModalProps {
item: NewsItem | null;
onClose: () => void;
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
});
} catch {
return iso;
}
}
export function NewsModal({ item, onClose }: NewsModalProps) {
useEffect(() => {
if (!item) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [item, onClose]);
useEffect(() => {
if (item) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [item]);
if (!item) return null;
return createPortal(
<div
className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-label={item.title}
onClick={onClose}
>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div
className="modal-content relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl border border-white/[0.08] bg-[#0a0a0a] shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
aria-label="Закрыть"
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/50 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-white/[0.1] hover:text-white cursor-pointer"
>
<X size={18} />
</button>
{item.image && (
<div className="relative aspect-[2/1] w-full overflow-hidden rounded-t-2xl">
<Image
src={item.image}
alt={item.title}
fill
sizes="(min-width: 768px) 672px, 100vw"
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#0a0a0a] via-transparent to-transparent" />
</div>
)}
<div className={`p-6 sm:p-8 ${item.image ? "-mt-12 relative" : ""}`}>
<span className="inline-flex items-center gap-1.5 text-xs text-neutral-400">
<Calendar size={12} />
{formatDate(item.date)}
</span>
<h2 className="mt-2 text-xl sm:text-2xl font-bold text-white leading-tight">
{item.title}
</h2>
<p className="mt-4 text-sm sm:text-base leading-relaxed text-neutral-300 whitespace-pre-line">
{item.text}
</p>
{item.link && (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-gold px-5 py-2.5 text-sm font-semibold text-black transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/20"
>
Подробнее
<ExternalLink size={14} />
</a>
)}
</div>
</div>
</div>,
document.body
);
}

View File

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

View File

@@ -307,6 +307,10 @@ export const siteContent: SiteContent = {
"В случае болезни, подтверждённой больничным листом, возможно продление срока действия абонемента.", "В случае болезни, подтверждённой больничным листом, возможно продление срока действия абонемента.",
], ],
}, },
masterClasses: {
title: "Мастер-классы",
items: [],
},
schedule: { schedule: {
title: "Расписание", title: "Расписание",
locations: [ locations: [
@@ -438,6 +442,10 @@ export const siteContent: SiteContent = {
}, },
], ],
}, },
news: {
title: "Новости",
items: [],
},
contact: { contact: {
title: "Контакты", title: "Контакты",
addresses: [ addresses: [

View File

@@ -41,6 +41,7 @@ const sectionData: Record<string, unknown> = {
hero: siteContent.hero, hero: siteContent.hero,
about: siteContent.about, about: siteContent.about,
classes: siteContent.classes, classes: siteContent.classes,
masterClasses: siteContent.masterClasses,
faq: siteContent.faq, faq: siteContent.faq,
pricing: siteContent.pricing, pricing: siteContent.pricing,
schedule: siteContent.schedule, schedule: siteContent.schedule,

View File

@@ -17,7 +17,13 @@ function getAdminPassword(): string {
} }
export function verifyPassword(password: string): boolean { export function verifyPassword(password: string): boolean {
return password === getAdminPassword(); const expected = getAdminPassword();
if (password.length !== expected.length) return false;
const a = Buffer.from(password);
const b = Buffer.from(expected);
// Pad to equal length for timingSafeEqual
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
} }
export function signToken(): string { export function signToken(): string {
@@ -51,7 +57,7 @@ function verifyTokenNode(token: string): boolean {
.update(data) .update(data)
.digest("base64url"); .digest("base64url");
if (sig !== expectedSig) return false; if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) return false;
const payload = JSON.parse( const payload = JSON.parse(
Buffer.from(data, "base64url").toString() Buffer.from(data, "base64url").toString()
@@ -63,4 +69,10 @@ function verifyTokenNode(token: string): boolean {
} }
} }
export const CSRF_COOKIE_NAME = "bh-csrf-token";
export function generateCsrfToken(): string {
return crypto.randomBytes(32).toString("base64url");
}
export { COOKIE_NAME }; export { COOKIE_NAME };

View File

@@ -11,8 +11,10 @@ export const NAV_LINKS: NavLink[] = [
{ label: "О нас", href: "#about" }, { label: "О нас", href: "#about" },
{ label: "Команда", href: "#team" }, { label: "Команда", href: "#team" },
{ label: "Направления", href: "#classes" }, { label: "Направления", href: "#classes" },
{ label: "Мастер-классы", href: "#master-classes" },
{ label: "Расписание", href: "#schedule" }, { label: "Расписание", href: "#schedule" },
{ label: "Стоимость", href: "#pricing" }, { label: "Стоимость", href: "#pricing" },
{ label: "FAQ", href: "#faq" }, { label: "FAQ", href: "#faq" },
{ label: "Новости", href: "#news" },
{ label: "Контакты", href: "#contact" }, { label: "Контакты", href: "#contact" },
]; ];

View File

@@ -2,12 +2,28 @@ import { getSiteContent } from "@/lib/db";
import { siteContent as fallback } from "@/data/content"; import { siteContent as fallback } from "@/data/content";
import type { SiteContent } from "@/types/content"; import type { SiteContent } from "@/types/content";
let cached: { data: SiteContent; expiresAt: number } | null = null;
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export function getContent(): SiteContent { export function getContent(): SiteContent {
const now = Date.now();
if (cached && now < cached.expiresAt) {
return cached.data;
}
try { try {
const content = getSiteContent(); const content = getSiteContent();
if (content) return content; if (content) {
cached = { data: content, expiresAt: now + CACHE_TTL };
return content;
}
return fallback; return fallback;
} catch { } catch {
return fallback; return fallback;
} }
} }
/** Invalidate the content cache (call after admin edits). */
export function invalidateContentCache() {
cached = null;
}

17
src/lib/csrf.ts Normal file
View File

@@ -0,0 +1,17 @@
const CSRF_COOKIE_NAME = "bh-csrf-token";
function getCsrfToken(): string {
const match = document.cookie
.split("; ")
.find((c) => c.startsWith(`${CSRF_COOKIE_NAME}=`));
return match ? match.split("=")[1] : "";
}
/** Wrapper around fetch that auto-includes the CSRF token header for admin API calls */
export function adminFetch(url: string, init?: RequestInit): Promise<Response> {
const headers = new Headers(init?.headers);
if (!headers.has("x-csrf-token")) {
headers.set("x-csrf-token", getCsrfToken());
}
return fetch(url, { ...init, headers });
}

File diff suppressed because it is too large Load Diff

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

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

62
src/lib/rateLimit.ts Normal file
View File

@@ -0,0 +1,62 @@
/**
* Simple in-memory rate limiter for public API endpoints.
* Limits requests per IP within a sliding time window.
*/
interface RateLimitEntry {
count: number;
resetAt: number;
}
const store = new Map<string, RateLimitEntry>();
// Periodically clean up expired entries (every 5 minutes)
let cleanupScheduled = false;
function scheduleCleanup() {
if (cleanupScheduled) return;
cleanupScheduled = true;
setInterval(() => {
const now = Date.now();
for (const [key, entry] of store) {
if (now > entry.resetAt) store.delete(key);
}
}, 5 * 60 * 1000);
}
/**
* Check if a request is within the rate limit.
* @param ip - Client IP address
* @param limit - Max requests per window (default: 10)
* @param windowMs - Time window in ms (default: 60_000 = 1 minute)
* @returns true if allowed, false if rate limited
*/
export function checkRateLimit(
ip: string,
limit: number = 10,
windowMs: number = 60_000
): boolean {
scheduleCleanup();
const now = Date.now();
const entry = store.get(ip);
if (!entry || now > entry.resetAt) {
store.set(ip, { count: 1, resetAt: now + windowMs });
return true;
}
if (entry.count >= limit) return false;
entry.count++;
return true;
}
/**
* Extract client IP from request headers.
*/
export function getClientIp(request: Request): string {
return (
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"unknown"
);
}

27
src/lib/validation.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* Shared input sanitization for public registration endpoints.
*/
export function sanitizeName(name: unknown): string | null {
if (!name || typeof name !== "string") return null;
const clean = name.trim().slice(0, 100);
return clean || null;
}
export function sanitizePhone(phone: unknown): string | null {
if (!phone || typeof phone !== "string") return null;
const clean = phone.replace(/\D/g, "").slice(0, 15);
return clean.length >= 9 ? clean : null;
}
export function sanitizeHandle(value: unknown): string | undefined {
if (!value || typeof value !== "string") return undefined;
const clean = value.trim().slice(0, 100);
return clean || undefined;
}
export function sanitizeText(value: unknown, maxLength: number = 200): string | undefined {
if (!value || typeof value !== "string") return undefined;
const clean = value.trim().slice(0, maxLength);
return clean || undefined;
}

View File

@@ -1,28 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyToken, COOKIE_NAME } from "@/lib/auth-edge";
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow login page and login API
if (pathname === "/admin/login" || pathname === "/api/auth/login") {
return NextResponse.next();
}
// Protect /admin/* and /api/admin/*
const token = request.cookies.get(COOKIE_NAME)?.value;
const valid = token ? await verifyToken(token) : false;
if (!valid) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.redirect(new URL("/admin/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/admin/:path*", "/api/admin/:path*"],
};

65
src/proxy.ts Normal file
View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyToken, COOKIE_NAME } from "@/lib/auth-edge";
const CSRF_COOKIE_NAME = "bh-csrf-token";
const CSRF_HEADER_NAME = "x-csrf-token";
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
function generateCsrfToken(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
let binary = "";
for (const b of array) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow login page and login API
if (pathname === "/admin/login" || pathname === "/api/auth/login") {
return NextResponse.next();
}
// Protect /admin/* and /api/admin/*
const token = request.cookies.get(COOKIE_NAME)?.value;
const valid = token ? await verifyToken(token) : false;
if (!valid) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.redirect(new URL("/admin/login", request.url));
}
// Auto-issue CSRF cookie if missing (e.g. session from before CSRF was added)
const hasCsrf = request.cookies.has(CSRF_COOKIE_NAME);
if (!hasCsrf) {
const csrfToken = generateCsrfToken();
const response = NextResponse.next();
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
httpOnly: false,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24,
});
return response;
}
// CSRF check on state-changing API requests
if (pathname.startsWith("/api/admin/") && STATE_CHANGING_METHODS.has(request.method)) {
const csrfCookie = request.cookies.get(CSRF_COOKIE_NAME)?.value;
const csrfHeader = request.headers.get(CSRF_HEADER_NAME);
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
return NextResponse.json({ error: "CSRF token mismatch" }, { status: 403 });
}
}
return NextResponse.next();
}
export const config = {
matcher: ["/admin/:path*", "/api/admin/:path*"],
};

View File

@@ -7,6 +7,23 @@ export interface ClassItem {
color?: string; color?: string;
} }
export interface RichListItem {
text: string;
image?: string;
link?: string;
}
export interface VictoryItem {
type?: 'place' | 'nomination' | 'judge';
place: string;
category: string;
competition: string;
location?: string;
date?: string;
image?: string;
link?: string;
}
export interface TeamMember { export interface TeamMember {
name: string; name: string;
role: string; role: string;
@@ -14,8 +31,8 @@ export interface TeamMember {
instagram?: string; instagram?: string;
description?: string; description?: string;
experience?: string[]; experience?: string[];
victories?: string[]; victories?: VictoryItem[];
education?: string[]; education?: RichListItem[];
} }
export interface FAQItem { export interface FAQItem {
@@ -38,6 +55,7 @@ export interface ScheduleClass {
level?: string; level?: string;
hasSlots?: boolean; hasSlots?: boolean;
recruiting?: boolean; recruiting?: boolean;
groupId?: string;
} }
export interface ScheduleDay { export interface ScheduleDay {
@@ -52,6 +70,32 @@ export interface ScheduleLocation {
days: ScheduleDay[]; days: ScheduleDay[];
} }
export interface MasterClassSlot {
date: string; // ISO "2026-03-13"
startTime: string; // "19:00"
endTime: string; // "21:00"
}
export interface MasterClassItem {
title: string;
image: string;
slots: MasterClassSlot[];
trainer: string;
cost: string;
style: string;
location?: string;
description?: string;
instagramUrl?: string;
}
export interface NewsItem {
title: string;
text: string;
date: string;
image?: string;
link?: string;
}
export interface ContactInfo { export interface ContactInfo {
title: string; title: string;
addresses: string[]; addresses: string[];
@@ -95,10 +139,20 @@ export interface SiteContent {
rentalTitle: string; rentalTitle: string;
rentalItems: PricingItem[]; rentalItems: PricingItem[];
rules: string[]; rules: string[];
showContactHint?: boolean;
};
masterClasses: {
title: string;
successMessage?: string;
items: MasterClassItem[];
}; };
schedule: { schedule: {
title: string; title: string;
locations: ScheduleLocation[]; locations: ScheduleLocation[];
}; };
news: {
title: string;
items: NewsItem[];
};
contact: ContactInfo; contact: ContactInfo;
} }

View File

@@ -1,2 +1,2 @@
export type { NavLink } from "./navigation"; export type { NavLink } from "./navigation";
export type { ClassItem, TeamMember, FAQItem, PricingItem, ContactInfo, SiteContent, ScheduleClass, ScheduleDay, ScheduleLocation } from "./content"; export type { ClassItem, TeamMember, FAQItem, PricingItem, MasterClassItem, MasterClassSlot, ContactInfo, SiteContent, ScheduleClass, ScheduleDay, ScheduleLocation } from "./content";