Compare commits

...

77 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
921d10800b feat: add trainer bio (experience, victories, education) across all layers
- Extend TeamMember type with experience/victories/education string arrays
- Add DB columns with auto-migration for existing databases
- Update API POST route to accept bio fields
- Add ListField component for editing string arrays in admin
- Add bio section (Опыт/Достижения/Образование) to team member admin form
- Create TeamProfile component with full profile view (photo + bio sections)
- Add "Подробнее" button to TeamMemberInfo that toggles to profile view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:09:28 +03:00
ed90cd5924 feat: smooth scroll to schedule section when filtering from cards
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:38:17 +03:00
03f0524ba3 fix: clickable trainers in day cards, remove trainer dropdown, fix layout shift
- Remove trainer dropdown from filter bar — filter by clicking names in schedule cards
- Make trainer/type clickable in DayCard with gold highlight on active filter
- Fix showcase layout shift: track max detail height to prevent section from shrinking
- Remove key-based grid remount that re-triggered Reveal animations on filter change

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:52:56 +03:00
46ad10e8a0 feat: upgrade schedule with cross-location views, day/time filters, and clickable trainers
- Add "Все студии" tab merging all locations by weekday with location sub-headers
- Location tabs show hall name + address subtitle for clarity
- Add day multi-select and time-of-day preset filters (Утро/День/Вечер) behind collapsible "Когда" button
- Make trainer and type names clickable in day cards for inline filtering
- Add group view clustering classes by trainer+type+location
- Remove trainer dropdown from filter bar — filter by clicking names in schedule
- Add searchable icon picker and lucide-react icon rendering for classes admin/section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:25:11 +03:00
8ff7713cf2 fix: remove photo filter from classes section images
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:50:23 +03:00
604a52e04c feat: upgrade pricing admin with popular/featured selects and price input with BYN badge
Replace per-item toggles with top-level dropdown selects for popular and featured items.
Add PriceField component with inline gold BYN suffix badge.
Public Pricing component now uses dynamic popular/featured flags from data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:47:41 +03:00
8ef5fc975c fix: gold checkbox + button layout in schedule modal
- Custom gold checkbox for 'Одинаковое время'
- Delete button moved to right with text label
- Save button on left

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:32:10 +03:00
5fe2500dbe feat: searchable select dropdowns + updated levels
- Replace native select with custom searchable dropdown
- Search matches word starts (type 'а' finds 'Анна' or 'Мария Андреева')
- Search input shows for lists with 4+ options
- Updated levels: Начинающий/Без опыта, Продвинутый

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:25:32 +03:00
21f3887bc9 feat: schedule modal validation + time range guard
- Validate trainer, type, and time before saving
- Show overlap warnings for conflicting classes
- Reset end time when start time exceeds it
- Block setting end time earlier than start
- Remove day change hints from modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:14:33 +03:00
9d0b4b0fba fix: sticky sidebar nav in admin panel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:06:01 +03:00
fc523b2045 feat: same-time checkbox for multi-day class groups
- "Одинаковое время" checkbox (default on) syncs time across all days
- Uncheck to set per-day times (e.g., Mon 12:00, Fri 18:00)
- Checkbox only appears when 2+ days selected
- Single day: just shows one time field, no checkbox
- Unified day selector for both new and edit modes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:52:59 +03:00
bfa59a8d18 feat: flexible group management in schedule editor
- Group = trainer + type (time-independent)
- Edit modal shows per-day time fields (Mon 12:00, Fri 18:00)
- Calendar blocks colored by group, not class type
- Color picker for site dots moved to classes editor
- New class: single time + multi-day selector
- Edit class: per-day times, add/remove days from group
- Delete removes group from all days

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:31:05 +03:00
b5262b4adc feat: group-based day management in schedule editor
- Auto-detect class groups (same trainer + type + time across days)
- Edit modal shows all group days pre-selected (e.g., ВТ/ПТ both lit)
- Toggle days to add/remove class from specific days
- Delete removes class from all days in the group
- New class modal lets you pick multiple days at once
- Visual hints: green +day / red −day for pending changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:51:20 +03:00
5c23b622f9 feat: unique color picker for class types in schedule editor
- Add clickable color picker in schedule legend (16 distinct colors)
- Two-pass smart assignment: explicit colors first, then unused palette slots
- Hide already-used colors from the picker (both explicit and fallback)
- Colors saved to classes section and flow to public site schedule dots
- Expanded palette: rose, orange, amber, yellow, lime, emerald, teal, cyan,
  sky, blue, indigo, violet, purple, fuchsia, pink, red

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:16:26 +03:00
85c61cfacd fix: remove card-body drag from ArrayEditor form cards
Card-body drag was causing accidental drags in complex form cards
(classes, FAQ, pricing). Keep grip-icon-only drag for ArrayEditor,
card-body drag remains on team page (simple cards).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:10:42 +03:00
27ef3bd694 fix: setState-during-render error + hover highlight on cards
Defer onChange call in ArrayEditor drag drop to queueMicrotask to
avoid calling parent setState inside React updater. Add hover
highlight on draggable cards for better visual feedback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:07:16 +03:00
b145d5416a feat: improved drag-and-drop UX + long-press card drag + no text selection
- Drag from grip icon (instant) or card body (8px movement threshold)
- Floating clone + placeholder at drop position
- Disable text selection during drag
- Auto-resize textareas, hidden scrollbar/resize handle
- Dark admin scrollbar styles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:02:01 +03:00
e6c7bcf7f4 fix: auto-resize textareas + dark scrollbar in admin editors
Textareas auto-grow with content and on window resize, no scrollbar
or resize handle needed. Added dark admin scrollbar styles for cases
where overflow is needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:48:12 +03:00
ed5a164d59 feat: drag-and-drop reordering + auto-save for admin editors
Replace arrow buttons with mouse-based drag-and-drop in ArrayEditor
and team page. Dragged card follows cursor with floating clone, empty
placeholder shows at drop position. SectionEditor now auto-saves with
800ms debounce instead of manual save button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:40:33 +03:00
27c1348f89 feat: admin panel with SQLite, auth, and calendar-style schedule editor
Complete admin panel for content management:
- SQLite database with better-sqlite3, seed script from content.ts
- Simple password auth with HMAC-signed cookies (Edge + Node compatible)
- 9 section editors: meta, hero, about, team, classes, schedule, pricing, FAQ, contact
- Team CRUD with image upload and drag reorder
- Schedule editor with Google Calendar-style visual timeline (colored blocks, overlap detection, click-to-add)
- All public components refactored to accept data props from DB (with fallback to static content)
- Middleware protecting /admin/* and /api/admin/* routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:59:12 +03:00
d5afaf92ba refactor: centralize gold tokens, extract sub-components, clean up unused code
- Replace hardcoded hex colors with gold/gold-light/gold-dark Tailwind tokens
- Extract Schedule into DayCard, ScheduleFilters, MobileSchedule sub-components
- Extract Team into TeamCarousel, TeamMemberInfo sub-components
- Add UI_CONFIG for centralized magic numbers (timings, thresholds)
- Add reusable IconBadge component, simplify Contact section
- Convert Pricing clickable divs to semantic buttons for a11y
- Remove unused SocialLinks, btn-outline, btn-ghost, nav-link CSS classes
- Fix React setState-during-render error in TeamCarousel (deferred update pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:57:39 +03:00
08e4af1d55 feat: dark map theme (invert filter) + vertically center contact grid
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:45:04 +03:00
7ff850f21a feat: clickable price cards open booking modal + smooth showcase transitions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:33:37 +03:00
e5fae578ab fix: booking modal positioning — use portal to render in document.body
Also make header full-width (remove max-w-6xl constraint).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:18:25 +03:00
a4dc8173fc feat: global booking button — header nav, mobile menu, floating CTA
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:22:12 +03:00
13b68484e1 feat: phone input mask with +375 prefix and auto-formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:18:07 +03:00
0c8c45dcd9 feat: booking modal with form + Instagram DM + phone link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:16:21 +03:00
d5b1873f83 fix: schedule grid columns for Машерова — use literal Tailwind classes
Dynamic class concatenation ("xl:grid-cols-" + n) isn't detected by
Tailwind's scanner. Use explicit literal class names for 5/6/7 columns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:08:38 +03:00
e42c3c7a51 feat: FAQ section redesign — compact cards, expand/collapse, bigger text
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:05:56 +03:00
8fbbc94024 feat: redesign pricing section with card grid, featured unlimited plan
- Subscription cards in responsive grid (1/2/3 cols)
- Popular badge with sparkle floating above first card
- Unlimited plan as featured card with glitter border and crown icon
- Rental items as individual rounded cards
- Rules in numbered card layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:58:43 +03:00
f2b840416d feat: glitter border on team/class cards, mobile-friendly class selector
- Animated gold border glitter effect on active team card and class image
- Classes selector: 2-column grid on mobile (no scroll), list on desktop
- Selector appears above detail on mobile (flex-col-reverse)
- Compact selector items on mobile (icon + name only)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:55:09 +03:00
303c52653c feat: tap-to-filter on mobile schedule, replace filter bar
On mobile, tapping a trainer name or class type in the agenda list
filters to show all their sessions across the week. Active filter
shown as a banner with clear button. Desktop filters unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:10:58 +03:00
22a59ae9af feat: mobile-optimized schedule with compact agenda list view
Mobile shows all days as a vertical timeline with compact class rows
(time + trainer + type on each line). Desktop keeps the card grid.
Filters are horizontally scrollable on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:07:18 +03:00
8d2f482e99 feat: add schedule section with location tabs, filters, and status badges
Two locations (Притыцкого 62/М, Машерова 17/4) with day-grid layout,
class type/trainer/status filters, and badges for availability and recruiting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:47:59 +03:00
233c117afa feat: team spotlight carousel with drag, auto-rotate, and progress dots
- Spotlight stage effect with center card lit up, side cards dimmed
- Continuous drag navigation with pointer events
- Auto-rotation every 4.5s, pauses on interaction
- Interpolated card positions (size, opacity, brightness, grayscale)
- Progress dots navigation
- Fix About stats: 13 → 16 trainers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:00:39 +03:00
fed99f27b5 fix: update team count from 13 to 16 in About stats
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:19:23 +03:00
26e78edf5c feat: about stats cards, pricing popular badge, back-to-top button
- About: add 3 stat cards (13 trainers, 6 styles, 2 locations)
- Pricing: highlight first plan with gold "Популярный" badge
- BackToTop: floating gold button appears after 600px scroll

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:25:53 +03:00
3ff69d6945 feat: FAQ two-column layout, team swipe & counter, showcase improvements
- FAQ: split into 2 columns on desktop to reduce section height
- Team: remove description line-clamp, show full bio
- ShowcaseLayout: add swipe navigation on mobile, optional counter
- Counter shows current/total with gold accent styling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:22:47 +03:00
0b2d3310af feat: highlight active section in header nav on scroll
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:15:22 +03:00
96081ccfe3 fix: remove white border line from header on scroll
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:01:59 +03:00
c28c9a05a8 feat: glitter sparkles on heart, metallic gold text across all headings
- Add animated gold sparkle dots on heart surface with glow filter
- Brighten heart metal fill for better visibility against dark bg
- Update headline gradient to dark metallic gold (no white tones)
- Apply gradient-text to all section headings for consistency
- Warm gold subtitle color in hero

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:55:31 +03:00
04963fb0de feat: smooth circular gold border animation on heart logo
Split heart SVG into 3 sub-paths with independent staggered
stroke-dashoffset animations for continuous gold glint effect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:40:47 +03:00
0ed0a91161 feat: showcase layout, photo filter, team specializations, scroll UX
- Replace modals with ShowcaseLayout for Team and Classes sections
- Add warm photo filter matching dark/gold color scheme
- Replace generic "Тренер" with actual specializations per member
- Fix heart logo color animation loop (seamless repeat)
- Style scrollbar with gold theme, pause auto-rotation on hover
- Auto-scroll only when active item is out of view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:23:11 +03:00
a75922c730 feat: Instagram data sync, gold accent, SVG logo, FAQ & Pricing sections
- Sync all content from Instagram: fix addresses, trainer names, add 5 new
  trainers, remove 2 inactive, update class descriptions
- Add FAQ section (11 Q&A items) and Pricing section (tabs: subscriptions,
  rental, rules)
- Redesign with editorial magazine feel: centered headings, generous spacing,
  section glow effects, glassmorphism cards
- Migrate entire accent palette from rose to warm gold (#c9a96e)
- Replace low-res PNG logo with vector SVG traced via potrace — crisp at any
  size, animated gradient (black↔gold), heartbeat pulse animation
- Make header brand name gold

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:45:50 +03:00
105 changed files with 15144 additions and 815 deletions

3
.gitignore vendored
View File

@@ -36,6 +36,9 @@ yarn-error.log*
# vercel
.vercel
# database
/db/
# claude
.claude/

140
CLAUDE.md
View File

@@ -6,9 +6,10 @@ Instagram: @blackheartdancehouse
Content language: Russian
## Tech Stack
- **Next.js 15** (App Router, TypeScript)
- **Tailwind CSS v4** (light + dark mode, class-based toggle)
- **Next.js 16** (App Router, TypeScript, Turbopack)
- **Tailwind CSS v4** (dark mode only, gold/black theme)
- **lucide-react** for icons
- **better-sqlite3** for SQLite database
- **Fonts**: Inter (body) + Oswald (headings) via `next/font`
- **Hosting**: Vercel (planned)
@@ -16,61 +17,146 @@ Content language: Russian
- Function declarations for components (not arrow functions)
- PascalCase for component files, camelCase for utils
- `@/` 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
- Header nav uses `lg:` breakpoint (1024px) for desktop/mobile switch (9 nav links + CTA need the space)
## Project Structure
```
src/
├── app/
│ ├── 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
│ ├── styles/
│ │ ├── theme.css # Theme variables, semantic classes
│ │ └── animations.css # Keyframes, scroll reveal, modal animations
│ ├── icon.png # Favicon
└── apple-icon.png
│ ├── admin/
│ ├── 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/
│ ├── layout/
│ │ ├── Header.tsx # Sticky nav, mobile menu, theme toggle ("use client")
│ │ ├── Header.tsx # Sticky nav, mobile menu, booking modal ("use client")
│ │ └── Footer.tsx
│ ├── sections/
│ │ ├── Hero.tsx
│ │ ├── Team.tsx # "use client" — clickable cards + modal
│ │ ├── About.tsx
│ │ ├── Classes.tsx
│ │ ── Contact.tsx
│ │ ├── Hero.tsx # Hero with animated logo, floating hearts
│ │ ├── About.tsx # About with stats (trainers, classes, locations)
│ │ ├── Team.tsx # Carousel + profile view
│ │ ├── Classes.tsx # Showcase layout with icon selector
│ │ ── MasterClasses.tsx # Cards with signup modal
│ │ ├── OpenDay.tsx # Open Day schedule grid + booking (conditional)
│ │ ├── Schedule.tsx # Day/group views with filters
│ │ ├── Pricing.tsx # Tabs: prices, rental, rules
│ │ ├── News.tsx # Featured + compact articles
│ │ ├── FAQ.tsx # Accordion with show more
│ │ └── Contact.tsx # Info + Yandex Maps iframe
│ └── ui/
│ ├── Button.tsx
│ ├── SectionHeading.tsx
│ ├── SocialLinks.tsx
│ ├── ThemeToggle.tsx
│ ├── Reveal.tsx # Intersection Observer scroll reveal
── TeamMemberModal.tsx # "use client" — member popup
│ ├── BookingModal.tsx # Booking form → Instagram DM + DB save
│ ├── MasterClassSignupModal.tsx # MC registration form → API
│ ├── OpenDaySignupModal.tsx # Open Day class booking → API
── NewsModal.tsx # News detail popup
│ ├── Reveal.tsx # Intersection Observer scroll reveal
│ ├── BackToTop.tsx
│ └── ...
├── data/
│ └── content.ts # ALL Russian text, structured for future CMS
│ └── content.ts # Fallback Russian text (DB takes priority)
├── 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/
├── index.ts
├── content.ts # SiteContent, TeamMember, ClassItem, ContactInfo
├── content.ts # SiteContent, TeamMember, ClassItem, MasterClassItem, etc.
└── navigation.ts
```
## Brand / Styling
- **Accent**: rose/red (`#e11d48`)
- **Dark mode**: bg `#0a0a0a`, surface `#171717`
- **Light mode**: bg `#fafafa`, surface `#ffffff`
- Logo: transparent PNG, uses `dark:invert` + `unoptimized`
- **Accent**: gold (`#c9a96e` / `hsl(37, 42%, 61%)`)
- **Background**: `#050505` `#0a0a0a` (dark only)
- **Surface**: `#171717` dark cards
- Logo: transparent PNG heart with gold glow, uses `unoptimized`
## Content Data
- All text lives in `src/data/content.ts` (type-safe, one file to edit)
- 13 team members with photos, Instagram links, and personal descriptions
- Primary source: SQLite database (`db/blackheart.db`)
- 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.)
- Master classes with date/time slots and public registration
- 2 addresses in Minsk, Yandex Maps embed with markers
- Contact: phone, Instagram
- Contact: phone, Instagram (no email)
## Admin Panel
- Password-based auth with HMAC-SHA256 signed JWT (24h TTL)
- Cookie: `bh-admin-token` (httpOnly, secure in prod)
- Auto-save with 800ms debounce on all section editors
- Team members: drag-reorder, photo upload, rich bio (experience, victories, education)
- Master classes: slots, registration viewer with notification tracking (confirm + reminder), trainer/style autocomplete
- Group bookings: saved to DB from BookingModal, admin page at `/admin/bookings` with notification toggles
- Open Day: event settings (date, pricing, discount rules, min bookings), schedule grid (halls × time slots), per-class booking with auto-cancel threshold, public section after Hero
- Shared `NotifyToggle` component (`src/app/admin/_components/NotifyToggle.tsx`) used across MC registrations, group bookings, and Open Day bookings
- File upload: whitelisted folders (`team`, `master-classes`, `news`, `classes`), max 5MB, image types only
## Security Notes
- **CSRF protection**: Double-submit cookie pattern. Login sets `bh-csrf-token` cookie (JS-readable). All admin fetch calls use `adminFetch()` from `src/lib/csrf.ts` which sends the token as `X-CSRF-Token` header. Middleware (`proxy.ts`) validates header matches cookie on POST/PUT/DELETE to `/api/admin/*`. **Always use `adminFetch()` instead of `fetch()` for admin API calls.**
- File upload validates: MIME type, file extension, whitelisted folder (no path traversal)
- API routes validate: input types, string lengths, numeric IDs
- Public MC registration: length-limited but **no rate limiting yet** (add before production)
## Upcoming Features
- **Rate limiting** on public endpoints (`/api/master-class-register`, `/api/group-booking`, `/api/open-day-register`)
- **DB backup mechanism** — automated/manual backup of `db/blackheart.db` with rotation
## AST Index
- **Always use the AST index** at `memory/ast-index.md` when searching for components, props, hooks, types, or styles
- Contains: component tree, all exports, props, hooks, client/server status, CSS classes, keyframes
- Update the index when adding/removing/renaming files or exports
## Database Migrations
- **Never drop/recreate the database** — admin data (photos, edits, registrations) lives there
- Schema changes go through versioned migrations in `src/lib/db.ts` (`migrations` array)
- Add a new entry with the next version number; never modify existing migrations
- Migrations run automatically on server start via `runMigrations()` and are tracked in the `_migrations` table
- Use `CREATE TABLE IF NOT EXISTS` and column-existence checks (`PRAGMA table_info`) for safety
## Git
- Remote: Gitea at `git.dolgolyov-family.by`

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
serverExternalPackages: ["better-sqlite3"],
};
export default nextConfig;

892
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,11 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"seed": "tsx src/data/seed.ts"
},
"dependencies": {
"better-sqlite3": "^12.6.2",
"lucide-react": "^0.576.0",
"next": "16.1.6",
"react": "19.2.3",
@@ -16,6 +18,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@@ -24,6 +27,7 @@
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
}
}

3
public/images/logo.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 192" fill="currentColor" fill-rule="evenodd">
<path d="M118.02,188.43 C118.04,184.10 120.51,173.30 122.96,166.79 C126.11,158.42 133.55,147.62 144.55,135.42 C165.53,112.15 170.96,101.38 170.99,82.98 C171.00,72.35 168.51,62.96 162.47,50.94 C160.00,46.02 157.78,42.00 157.53,42.00 C157.29,42.00 158.24,45.04 159.64,48.75 C163.04,57.78 165.96,71.24 165.96,78.00 C165.97,85.89 163.51,95.22 159.27,103.40 C156.31,109.09 152.52,113.57 140.17,126.00 C131.69,134.53 123.42,143.44 121.79,145.81 C116.23,153.88 110.87,167.99 109.81,177.28 C109.51,179.99 109.37,179.90 105.02,174.28 C102.55,171.10 98.74,166.54 96.55,164.15 L92.56,159.80 L95.53,157.70 C100.61,154.12 105.90,148.12 108.76,142.70 C111.22,138.02 111.50,136.50 111.47,127.50 C111.45,118.43 111.02,116.15 106.86,103.00 C101.17,85.06 99.60,76.75 100.25,68.17 C100.75,61.51 104.60,48.83 107.29,45.00 C108.57,43.16 108.76,43.69 109.31,50.84 C110.42,65.22 115.99,75.08 126.37,81.04 C133.31,85.02 133.82,84.76 128.92,79.75 C124.20,74.93 119.44,65.68 118.16,58.84 C116.46,49.75 119.09,39.24 125.73,28.59 L128.79,23.69 L130.02,29.59 C130.69,32.84 133.23,39.92 135.65,45.33 C143.15,62.02 144.36,69.90 141.53,83.29 C140.04,90.28 134.00,104.04 127.66,114.86 C125.68,118.24 124.39,120.97 124.78,120.93 C125.18,120.90 128.41,117.53 131.97,113.46 C145.06,98.47 150.50,85.84 150.46,70.50 C150.43,59.80 149.70,57.36 141.46,40.79 C137.98,33.80 134.83,26.02 134.45,23.49 C133.85,19.50 134.07,18.56 136.10,16.40 C139.50,12.77 147.93,7.39 153.86,5.06 L159.00,3.03 L159.00,9.32 C159.00,18.37 162.11,24.01 172.06,33.00 C176.46,36.97 180.72,41.50 181.53,43.06 C183.67,47.20 183.39,56.94 180.95,63.13 C178.14,70.25 180.87,67.95 184.97,59.74 C190.78,48.12 188.70,39.73 177.15,28.15 C173.28,24.27 169.43,20.06 168.61,18.80 C166.51,15.58 164.79,7.21 165.54,3.83 C166.16,1.00 166.17,1.00 174.33,1.01 C178.82,1.02 184.15,1.29 186.17,1.63 C189.69,2.21 189.81,2.37 189.29,5.62 C188.42,10.94 190.83,20.71 195.10,29.20 C197.26,33.49 199.19,37.00 199.40,37.00 C199.62,37.00 198.67,33.51 197.31,29.25 C195.35,23.12 194.91,19.90 195.17,13.83 C195.36,9.61 195.85,5.81 196.28,5.39 C197.35,4.31 205.52,8.43 211.67,13.15 C218.45,18.35 221.00,23.72 220.99,32.74 C220.98,36.46 220.28,41.98 219.42,45.00 C218.57,48.02 217.63,51.40 217.34,52.50 C216.30,56.51 222.34,45.32 224.52,39.20 C225.76,35.73 227.01,32.66 227.29,32.38 C227.57,32.09 228.79,34.48 229.99,37.68 C238.21,59.57 232.78,83.80 215.76,101.15 C209.43,107.60 207.42,108.54 209.17,104.25 C210.91,100.00 210.37,85.54 208.08,74.64 C206.93,69.22 205.95,62.24 205.90,59.14 C205.80,53.85 205.74,53.72 204.93,57.00 C204.45,58.92 204.13,68.15 204.22,77.50 C204.37,93.11 204.20,94.91 202.15,99.45 C198.99,106.51 192.06,115.46 190.76,114.16 C188.49,111.89 189.93,84.88 192.72,77.19 C194.09,73.45 189.30,79.05 186.68,84.26 C182.02,93.55 180.69,101.03 181.41,113.97 L182.05,125.50 L169.94,135.00 C153.90,147.58 132.06,170.01 124.13,182.05 C118.83,190.09 118.00,190.96 118.02,188.43 Z M83.09,150.59 C78.00,145.44 77.78,144.99 78.44,141.34 C78.82,139.23 81.24,133.00 83.81,127.50 C88.47,117.57 88.50,117.43 88.49,107.00 C88.47,99.38 87.96,94.99 86.59,91.00 C84.28,84.21 77.06,69.61 76.36,70.30 C76.08,70.58 76.56,72.19 77.43,73.87 C79.91,78.65 82.99,92.88 82.99,99.57 C83.00,108.39 80.69,114.86 73.96,124.82 C70.68,129.67 68.00,134.17 68.00,134.82 C68.00,135.47 67.62,136.00 67.16,136.00 C66.07,136.00 57.00,128.93 57.00,128.07 C57.00,127.71 59.03,123.47 61.50,118.66 C66.60,108.75 67.24,103.18 64.48,92.59 C62.01,83.09 61.32,83.22 61.40,93.17 C61.45,100.19 61.02,103.39 59.69,106.10 C57.49,110.57 48.29,121.00 46.56,121.00 C44.40,121.00 39.79,109.24 39.24,102.34 C38.56,93.90 40.48,89.09 48.68,78.77 C62.32,61.60 65.53,49.22 60.98,31.41 C58.70,22.51 54.61,13.20 50.08,6.62 C47.54,2.92 47.30,2.10 48.60,1.60 C50.50,0.87 66.31,0.80 68.17,1.51 C69.18,1.90 69.42,4.19 69.15,10.95 C68.88,18.05 69.27,21.45 71.06,27.48 C72.30,31.66 73.77,35.36 74.33,35.70 C74.97,36.10 75.06,35.62 74.57,34.41 C74.15,33.36 73.57,28.88 73.27,24.45 C72.70,15.76 74.85,5.38 77.44,4.39 C79.59,3.56 92.09,10.37 97.73,15.45 C100.57,18.00 103.83,21.61 104.99,23.48 L107.09,26.88 L103.66,31.69 C94.93,43.97 91.54,55.17 91.64,71.50 C91.72,84.83 92.69,89.79 99.08,109.50 C105.86,130.41 104.79,139.90 94.39,151.01 C91.83,153.75 89.44,156.00 89.08,156.00 C88.72,156.00 86.03,153.57 83.09,150.59 Z M29.50,109.90 C26.20,107.67 21.64,104.05 19.38,101.84 L15.26,97.83 L17.24,92.67 C19.86,85.83 19.20,74.50 15.57,64.04 C12.16,54.24 10.98,53.26 12.75,61.71 C14.48,69.97 13.94,81.02 11.53,86.50 L9.77,90.50 L6.92,84.50 C2.82,75.84 1.00,67.75 1.00,58.18 C1.00,42.04 6.09,29.69 17.39,18.40 C23.48,12.31 32.07,6.09 30.82,8.67 C30.59,9.13 28.88,12.62 27.02,16.44 C21.43,27.90 22.74,38.16 31.08,48.28 C35.14,53.21 36.55,53.01 33.93,47.87 C31.25,42.61 30.40,34.47 31.90,28.31 C33.17,23.08 42.81,3.00 44.05,3.00 C44.47,3.00 46.18,6.79 47.86,11.42 C58.07,39.63 56.90,53.23 42.67,72.14 C31.96,86.38 29.60,96.21 33.92,108.52 C34.98,111.54 35.77,113.99 35.67,113.97 C35.58,113.96 32.80,112.12 29.50,109.90 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

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

@@ -0,0 +1,254 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import { Plus, Trash2, GripVertical } from "lucide-react";
interface ArrayEditorProps<T> {
items: T[];
onChange: (items: T[]) => void;
renderItem: (item: T, index: number, update: (item: T) => void) => React.ReactNode;
createItem: () => T;
label?: string;
addLabel?: string;
}
export function ArrayEditor<T>({
items,
onChange,
renderItem,
createItem,
label,
addLabel = "Добавить",
}: ArrayEditorProps<T>) {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [insertAt, setInsertAt] = useState<number | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [dragSize, setDragSize] = useState({ w: 0, h: 0 });
const [grabOffset, setGrabOffset] = useState({ x: 0, y: 0 });
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
function updateItem(index: number, item: T) {
const updated = [...items];
updated[index] = item;
onChange(updated);
}
function removeItem(index: number) {
onChange(items.filter((_, i) => i !== index));
}
const startDrag = useCallback(
(clientX: number, clientY: number, index: number) => {
const el = itemRefs.current[index];
if (!el) return;
const rect = el.getBoundingClientRect();
setDragIndex(index);
setInsertAt(index);
setMousePos({ x: clientX, y: clientY });
setDragSize({ w: rect.width, h: rect.height });
setGrabOffset({ x: clientX - rect.left, y: clientY - rect.top });
},
[]
);
const handleGripMouseDown = useCallback(
(e: React.MouseEvent, index: number) => {
e.preventDefault();
startDrag(e.clientX, e.clientY, index);
},
[startDrag]
);
useEffect(() => {
if (dragIndex === null) return;
document.body.style.userSelect = "none";
function onMouseMove(e: MouseEvent) {
setMousePos({ x: e.clientX, y: e.clientY });
let newInsert = items.length;
for (let i = 0; i < items.length; i++) {
if (i === dragIndex) continue;
const el = itemRefs.current[i];
if (!el) continue;
const rect = el.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
newInsert = i;
break;
}
}
setInsertAt(newInsert);
}
function onMouseUp() {
// Read current values from state updaters but defer onChange to avoid
// calling parent setState during React's render/updater cycle
let capturedDrag: number | null = null;
let capturedInsert: number | null = null;
setDragIndex((prev) => { capturedDrag = prev; return null; });
setInsertAt((prev) => { capturedInsert = prev; return null; });
// Defer the reorder to next microtask so React finishes its batch first
queueMicrotask(() => {
if (capturedDrag !== null && capturedInsert !== null) {
let targetIndex = capturedInsert;
if (capturedDrag < targetIndex) targetIndex -= 1;
if (capturedDrag !== targetIndex) {
const updated = [...items];
const [moved] = updated.splice(capturedDrag, 1);
updated.splice(targetIndex, 0, moved);
onChange(updated);
}
}
});
}
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [dragIndex, items, onChange]);
function renderList() {
if (dragIndex === null || insertAt === null) {
return items.map((item, i) => (
<div
key={i}
ref={(el) => { itemRefs.current[i] = el; }}
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-colors"
>
<div className="flex items-start justify-between gap-2 mb-3">
<div
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
onMouseDown={(e) => handleGripMouseDown(e, i)}
>
<GripVertical size={16} />
</div>
<button
type="button"
onClick={() => removeItem(i)}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
>
<Trash2 size={16} />
</button>
</div>
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
));
}
const elements: React.ReactNode[] = [];
let visualIndex = 0;
let placeholderPos = insertAt;
if (insertAt > dragIndex) placeholderPos = insertAt - 1;
for (let i = 0; i < items.length; i++) {
if (i === dragIndex) {
elements.push(
<div key={`hidden-${i}`} ref={(el) => { itemRefs.current[i] = el; }} className="hidden" />
);
continue;
}
if (visualIndex === placeholderPos) {
elements.push(
<div
key="placeholder"
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3"
style={{ height: dragSize.h }}
/>
);
}
const item = items[i];
elements.push(
<div
key={i}
ref={(el) => { itemRefs.current[i] = el; }}
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-colors"
>
<div className="flex items-start justify-between gap-2 mb-3">
<div
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
onMouseDown={(e) => handleGripMouseDown(e, i)}
>
<GripVertical size={16} />
</div>
<button
type="button"
onClick={() => removeItem(i)}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
>
<Trash2 size={16} />
</button>
</div>
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
);
visualIndex++;
}
if (visualIndex === placeholderPos) {
elements.push(
<div
key="placeholder"
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3"
style={{ height: dragSize.h }}
/>
);
}
return elements;
}
return (
<div>
{label && (
<h3 className="text-sm font-medium text-neutral-300 mb-3">{label}</h3>
)}
<div>
{renderList()}
</div>
<button
type="button"
onClick={() => onChange([...items, createItem()])}
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
>
<Plus size={16} />
{addLabel}
</button>
{/* Floating clone following cursor */}
{mounted && dragIndex !== null &&
createPortal(
<div
className="fixed z-[9999] pointer-events-none"
style={{
left: mousePos.x - grabOffset.x,
top: mousePos.y - grabOffset.y,
width: dragSize.w,
height: dragSize.h,
}}
>
<div className="h-full rounded-lg border-2 border-rose-500 bg-neutral-900/95 shadow-2xl shadow-rose-500/20 flex items-center gap-3 px-4">
<GripVertical size={16} className="text-rose-400 shrink-0" />
<span className="text-sm text-neutral-300">Перемещение элемента...</span>
</div>
</div>,
document.body
)}
</div>
);
}

View File

@@ -0,0 +1,763 @@
import { useRef, useEffect, useState } from "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 {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
type?: "text" | "url" | "tel";
}
export function InputField({
label,
value,
onChange,
placeholder,
type = "text",
}: InputFieldProps) {
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="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>
);
}
interface TextareaFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
}
export function TextareaField({
label,
value,
onChange,
placeholder,
rows = 3,
}: TextareaFieldProps) {
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}, [value]);
useEffect(() => {
function onResize() {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<textarea
ref={ref}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
className="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 overflow-hidden"
/>
</div>
);
}
interface SelectFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
}
export function SelectField({
label,
value,
onChange,
options,
placeholder,
}: SelectFieldProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selectedLabel = options.find((o) => o.value === value)?.label || "";
const filtered = search
? options.filter((o) => {
const q = search.toLowerCase();
return o.label.toLowerCase().split(/\s+/).some((word) => word.startsWith(q));
})
: options;
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [open]);
return (
<div ref={containerRef} className="relative">
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<button
type="button"
onClick={() => {
setOpen(!open);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}}
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-left outline-none transition-colors ${
open ? "border-gold" : "border-white/10"
} ${value ? "text-white" : "text-neutral-500"}`}
>
{selectedLabel || placeholder || "Выберите..."}
</button>
{open && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
{options.length > 3 && (
<div className="p-1.5">
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск..."
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
/>
</div>
)}
<div className="max-h-48 overflow-y-auto">
{filtered.length === 0 && (
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
)}
{filtered.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
onChange(opt.value);
setOpen(false);
setSearch("");
}}
className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-white/5 ${
opt.value === value ? "text-gold bg-gold/5" : "text-white"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
)}
</div>
);
}
interface TimeRangeFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
}
export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFieldProps) {
const parts = value.split("");
const start = parts[0]?.trim() || "";
const end = parts[1]?.trim() || "";
function update(s: string, e: string) {
if (s && e) {
onChange(`${s}${e}`);
} else if (s) {
onChange(s);
} else {
onChange("");
}
}
function handleStartChange(newStart: string) {
if (newStart && end && newStart >= end) {
update(newStart, "");
} else {
update(newStart, end);
}
}
function handleEndChange(newEnd: string) {
if (start && newEnd && newEnd <= start) return;
update(start, newEnd);
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="flex items-center gap-2">
<input
type="time"
value={start}
onChange={(e) => handleStartChange(e.target.value)}
onBlur={onBlur}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
/>
<span className="text-neutral-500"></span>
<input
type="time"
value={end}
onChange={(e) => handleEndChange(e.target.value)}
onBlur={onBlur}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
/>
</div>
</div>
);
}
interface ToggleFieldProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
export function ToggleField({ label, checked, onChange }: ToggleFieldProps) {
return (
<label className="flex items-center gap-2 cursor-pointer">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 rounded-full transition-colors ${
checked ? "bg-gold" : "bg-neutral-700"
}`}
>
<span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
checked ? "translate-x-5" : ""
}`}
/>
</button>
<span className="text-sm text-neutral-300">{label}</span>
</label>
);
}
interface ListFieldProps {
label: string;
items: string[];
onChange: (items: string[]) => void;
placeholder?: string;
}
export function ListField({ label, items, onChange, placeholder }: ListFieldProps) {
const [draft, setDraft] = useState("");
function add() {
const val = draft.trim();
if (!val) return;
onChange([...items, val]);
setDraft("");
}
function remove(index: number) {
onChange(items.filter((_, i) => i !== index));
}
function update(index: number, value: string) {
onChange(items.map((item, i) => (i === index ? value : item)));
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="space-y-2">
{items.map((item, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
value={item}
onChange={(e) => update(i, e.target.value)}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-4 py-2 text-sm text-white outline-none focus:border-gold transition-colors"
/>
<button
type="button"
onClick={() => remove(i)}
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
>
<X size={14} />
</button>
</div>
))}
<div className="flex items-center gap-2">
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
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>
);
}
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

@@ -0,0 +1,117 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { Loader2, Check, AlertCircle } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
interface SectionEditorProps<T> {
sectionKey: string;
title: string;
children: (data: T, update: (data: T) => void) => React.ReactNode;
}
const DEBOUNCE_MS = 800;
export function SectionEditor<T>({
sectionKey,
title,
children,
}: SectionEditorProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [error, setError] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialLoadRef = useRef(true);
useEffect(() => {
adminFetch(`/api/admin/sections/${sectionKey}`)
.then((r) => {
if (!r.ok) throw new Error("Failed to load");
return r.json();
})
.then(setData)
.catch(() => setError("Не удалось загрузить данные"))
.finally(() => setLoading(false));
}, [sectionKey]);
const save = useCallback(async (dataToSave: T) => {
setStatus("saving");
setError("");
try {
const res = await adminFetch(`/api/admin/sections/${sectionKey}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(dataToSave),
});
if (!res.ok) throw new Error("Failed to save");
setStatus("saved");
setTimeout(() => setStatus((s) => (s === "saved" ? "idle" : s)), 2000);
} catch {
setStatus("error");
setError("Ошибка сохранения");
}
}, [sectionKey]);
// Auto-save with debounce whenever data changes (skip initial load)
useEffect(() => {
if (!data) return;
if (initialLoadRef.current) {
initialLoadRef.current = false;
return;
}
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
save(data);
}, DEBOUNCE_MS);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [data, save]);
if (loading) {
return (
<div className="flex items-center gap-2 text-neutral-400">
<Loader2 size={18} className="animate-spin" />
Загрузка...
</div>
);
}
if (!data) {
return <p className="text-red-400">{error || "Данные не найдены"}</p>;
}
return (
<div>
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{title}</h1>
<div className="flex items-center gap-2 text-sm text-neutral-400">
{status === "saving" && (
<>
<Loader2 size={14} className="animate-spin" />
<span>Сохранение...</span>
</>
)}
{status === "saved" && (
<>
<Check size={14} className="text-emerald-400" />
<span className="text-emerald-400">Сохранено</span>
</>
)}
{status === "error" && (
<>
<AlertCircle size={14} className="text-red-400" />
<span className="text-red-400">{error}</span>
</>
)}
</div>
</div>
<div className="mt-6 space-y-6">{children(data, setData)}</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
interface AboutData {
title: string;
paragraphs: string[];
}
export default function AboutEditorPage() {
return (
<SectionEditor<AboutData> sectionKey="about" title="О студии">
{(data, update) => (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<ArrayEditor
label="Параграфы"
items={data.paragraphs}
onChange={(paragraphs) => update({ ...data, paragraphs })}
renderItem={(text, _i, updateItem) => (
<TextareaField
label={`Параграф`}
value={text}
onChange={updateItem}
rows={3}
/>
)}
createItem={() => ""}
addLabel="Добавить параграф"
/>
</>
)}
</SectionEditor>
);
}

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

@@ -0,0 +1,239 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { icons, type LucideIcon } from "lucide-react";
// PascalCase "HeartPulse" → kebab "heart-pulse"
function toKebab(name: string) {
return name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
}
// All icons as { key: kebab-name, Icon: component, label: PascalCase }
const ALL_ICONS = Object.entries(icons).map(([name, Icon]) => ({
key: toKebab(name),
Icon: Icon as LucideIcon,
label: name,
}));
const ICON_BY_KEY = Object.fromEntries(ALL_ICONS.map((i) => [i.key, i]));
function IconPicker({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selected = ICON_BY_KEY[value];
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [open]);
const filtered = useMemo(() => {
if (!search) return ALL_ICONS.slice(0, 60);
const q = search.toLowerCase();
return ALL_ICONS.filter((i) => i.label.toLowerCase().includes(q)).slice(0, 60);
}, [search]);
const SelectedIcon = selected?.Icon;
return (
<div ref={ref} className="relative">
<label className="block text-sm text-neutral-400 mb-1.5">Иконка</label>
<button
type="button"
onClick={() => {
setOpen(!open);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}}
className={`w-full flex items-center gap-2.5 rounded-lg border bg-neutral-800 px-4 py-2.5 text-left text-white outline-none transition-colors ${
open ? "border-gold" : "border-white/10"
}`}
>
{SelectedIcon ? (
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-gold/20 text-gold-light">
<SelectedIcon size={16} />
</span>
) : (
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-white/10 text-neutral-500">?</span>
)}
<span className="text-sm">{selected?.label || value}</span>
</button>
{open && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
<div className="p-2 pb-0">
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск иконки... (flame, heart, star...)"
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
/>
</div>
<div className="p-2 max-h-56 overflow-y-auto">
{filtered.length === 0 ? (
<div className="py-3 text-center text-sm text-neutral-500">Ничего не найдено</div>
) : (
<div className="grid grid-cols-6 gap-1">
{filtered.map(({ key, Icon, label }) => (
<button
key={key}
type="button"
title={label}
onClick={() => {
onChange(key);
setOpen(false);
setSearch("");
}}
className={`flex flex-col items-center gap-0.5 rounded-lg p-2 transition-colors ${
key === value
? "bg-gold/20 text-gold-light"
: "text-neutral-400 hover:bg-white/5 hover:text-white"
}`}
>
<Icon size={20} />
<span className="text-[10px] leading-tight truncate w-full text-center">{label}</span>
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}
const COLOR_SWATCHES: { value: string; bg: string }[] = [
{ value: "rose", bg: "bg-rose-500" },
{ value: "orange", bg: "bg-orange-500" },
{ value: "amber", bg: "bg-amber-500" },
{ value: "yellow", bg: "bg-yellow-400" },
{ value: "lime", bg: "bg-lime-500" },
{ value: "emerald", bg: "bg-emerald-500" },
{ value: "teal", bg: "bg-teal-500" },
{ value: "cyan", bg: "bg-cyan-500" },
{ value: "sky", bg: "bg-sky-500" },
{ value: "blue", bg: "bg-blue-500" },
{ value: "indigo", bg: "bg-indigo-500" },
{ value: "violet", bg: "bg-violet-500" },
{ value: "purple", bg: "bg-purple-500" },
{ value: "fuchsia", bg: "bg-fuchsia-500" },
{ value: "pink", bg: "bg-pink-500" },
{ value: "red", bg: "bg-red-500" },
];
interface ClassesData {
title: string;
items: {
name: string;
description: string;
icon: string;
detailedDescription?: string;
images?: string[];
color?: string;
}[];
}
export default function ClassesEditorPage() {
return (
<SectionEditor<ClassesData> sectionKey="classes" title="Направления">
{(data, update) => (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<ArrayEditor
label="Направления"
items={data.items}
onChange={(items) => update({ ...data, items })}
renderItem={(item, _i, updateItem) => (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<InputField
label="Название"
value={item.name}
onChange={(v) => updateItem({ ...item, name: v })}
/>
<IconPicker
value={item.icon}
onChange={(v) => updateItem({ ...item, icon: v })}
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">
Цвет в расписании
</label>
<div className="flex flex-wrap gap-1.5">
{COLOR_SWATCHES.map((c) => {
const isUsed = data.items.some(
(other) => other !== item && other.color === c.value
);
if (isUsed) return null;
return (
<button
key={c.value}
type="button"
onClick={() => updateItem({ ...item, color: c.value })}
className={`h-6 w-6 rounded-full ${c.bg} transition-all ${
item.color === c.value
? "ring-2 ring-white ring-offset-1 ring-offset-neutral-900 scale-110"
: "opacity-50 hover:opacity-100"
}`}
/>
);
})}
</div>
</div>
<TextareaField
label="Краткое описание"
value={item.description}
onChange={(v) => updateItem({ ...item, description: v })}
rows={2}
/>
<TextareaField
label="Подробное описание"
value={item.detailedDescription || ""}
onChange={(v) =>
updateItem({ ...item, detailedDescription: v })
}
rows={4}
/>
</div>
)}
createItem={() => ({
name: "",
description: "",
icon: "sparkles",
detailedDescription: "",
images: [],
})}
addLabel="Добавить направление"
/>
</>
)}
</SectionEditor>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import type { ContactInfo } from "@/types/content";
export default function ContactEditorPage() {
return (
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты">
{(data, update) => (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<InputField
label="Телефон"
value={data.phone}
onChange={(v) => update({ ...data, phone: v })}
type="tel"
/>
<InputField
label="Instagram"
value={data.instagram}
onChange={(v) => update({ ...data, instagram: v })}
type="url"
/>
<InputField
label="Часы работы"
value={data.workingHours}
onChange={(v) => update({ ...data, workingHours: v })}
/>
<ArrayEditor
label="Адреса"
items={data.addresses}
onChange={(addresses) => update({ ...data, addresses })}
renderItem={(addr, _i, updateItem) => (
<InputField label="Адрес" value={addr} onChange={updateItem} />
)}
createItem={() => ""}
addLabel="Добавить адрес"
/>
<TextareaField
label="URL карты (Yandex Maps iframe)"
value={data.mapEmbedUrl}
onChange={(v) => update({ ...data, mapEmbedUrl: v })}
rows={2}
/>
</>
)}
</SectionEditor>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
interface FAQData {
title: string;
items: { question: string; answer: string }[];
}
export default function FAQEditorPage() {
return (
<SectionEditor<FAQData> sectionKey="faq" title="FAQ">
{(data, update) => (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<ArrayEditor
label="Вопросы и ответы"
items={data.items}
onChange={(items) => update({ ...data, items })}
renderItem={(item, _i, updateItem) => (
<div className="space-y-3">
<InputField
label="Вопрос"
value={item.question}
onChange={(v) => updateItem({ ...item, question: v })}
/>
<TextareaField
label="Ответ"
value={item.answer}
onChange={(v) => updateItem({ ...item, answer: v })}
rows={3}
/>
</div>
)}
createItem={() => ({ question: "", answer: "" })}
addLabel="Добавить вопрос"
/>
</>
)}
</SectionEditor>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField } from "../_components/FormField";
interface HeroData {
headline: string;
subheadline: string;
ctaText: string;
ctaHref: string;
}
export default function HeroEditorPage() {
return (
<SectionEditor<HeroData> sectionKey="hero" title="Главный экран">
{(data, update) => (
<>
<InputField
label="Заголовок"
value={data.headline}
onChange={(v) => update({ ...data, headline: v })}
/>
<InputField
label="Подзаголовок"
value={data.subheadline}
onChange={(v) => update({ ...data, subheadline: v })}
/>
<InputField
label="Текст кнопки"
value={data.ctaText}
onChange={(v) => update({ ...data, ctaText: v })}
/>
<InputField
label="Ссылка кнопки"
value={data.ctaHref}
onChange={(v) => update({ ...data, ctaHref: v })}
type="url"
/>
</>
)}
</SectionEditor>
);
}

175
src/app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,175 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { adminFetch } from "@/lib/csrf";
import {
LayoutDashboard,
Sparkles,
Users,
BookOpen,
Star,
Calendar,
DollarSign,
HelpCircle,
Phone,
FileText,
Globe,
Newspaper,
LogOut,
Menu,
X,
ChevronLeft,
ClipboardList,
DoorOpen,
} from "lucide-react";
const NAV_ITEMS = [
{ href: "/admin", label: "Дашборд", icon: LayoutDashboard },
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe },
{ href: "/admin/hero", label: "Главный экран", icon: Sparkles },
{ href: "/admin/about", label: "О студии", icon: FileText },
{ href: "/admin/team", label: "Команда", icon: Users },
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen },
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
{ href: "/admin/news", label: "Новости", icon: Newspaper },
{ href: "/admin/contact", label: "Контакты", icon: Phone },
];
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [unreadTotal, setUnreadTotal] = useState(0);
// Don't render admin shell on login page
if (pathname === "/admin/login") {
return <>{children}</>;
}
// Fetch unread counts — poll every 30s
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
function fetchCounts() {
adminFetch("/api/admin/unread-counts")
.then((r) => r.json())
.then((data: { total: number }) => setUnreadTotal(data.total))
.catch(() => {});
}
fetchCounts();
const interval = setInterval(fetchCounts, 30000);
return () => clearInterval(interval);
}, []);
async function handleLogout() {
await fetch("/api/logout", { method: "POST" });
router.push("/admin/login");
}
function isActive(href: string) {
if (href === "/admin") return pathname === "/admin";
return pathname.startsWith(href);
}
return (
<div className="flex min-h-screen bg-neutral-950 text-white">
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/60 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-white/10 bg-neutral-900 transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<Link href="/admin" className="text-lg font-bold">
BLACK HEART
</Link>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden text-neutral-400 hover:text-white"
>
<X size={20} />
</button>
</div>
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors ${
active
? "bg-gold/10 text-gold font-medium"
: "text-neutral-400 hover:text-white hover:bg-white/5"
}`}
>
<Icon size={18} />
{item.label}
{item.href === "/admin/bookings" && unreadTotal > 0 && (
<span className="ml-auto rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
{unreadTotal > 99 ? "99+" : unreadTotal}
</span>
)}
</Link>
);
})}
</nav>
<div className="border-t border-white/10 p-3 space-y-1">
<Link
href="/"
target="_blank"
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-white hover:bg-white/5 transition-colors"
>
<ChevronLeft size={18} />
Открыть сайт
</Link>
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-red-400 hover:bg-white/5 transition-colors"
>
<LogOut size={18} />
Выйти
</button>
</div>
</aside>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Top bar (mobile) */}
<header className="flex items-center gap-3 border-b border-white/10 px-4 py-3 lg:hidden">
<button
onClick={() => setSidebarOpen(true)}
className="text-neutral-400 hover:text-white"
>
<Menu size={24} />
</button>
<span className="font-bold">BLACK HEART</span>
</header>
<main className="flex-1 p-4 sm:p-6 lg:p-8">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function AdminLoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (res.ok) {
router.push("/admin");
} else {
setError("Неверный пароль");
}
} catch {
setError("Ошибка соединения");
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-950 px-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-6 rounded-2xl border border-white/10 bg-neutral-900 p-8"
>
<div className="text-center">
<h1 className="text-2xl font-bold text-white">BLACK HEART</h1>
<p className="mt-1 text-sm text-neutral-400">Панель управления</p>
</div>
<div>
<label htmlFor="password" className="block text-sm text-neutral-400 mb-2">
Пароль
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-3 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
placeholder="Введите пароль"
autoFocus
/>
</div>
{error && (
<p className="text-sm text-red-400 text-center">{error}</p>
)}
<button
type="submit"
disabled={loading || !password}
className="w-full rounded-lg bg-gold px-4 py-3 font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
>
{loading ? "Вход..." : "Войти"}
</button>
</form>
</div>
);
}

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

View File

@@ -0,0 +1,31 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
interface MetaData {
title: string;
description: string;
}
export default function MetaEditorPage() {
return (
<SectionEditor<MetaData> sectionKey="meta" title="SEO / Мета">
{(data, update) => (
<>
<InputField
label="Заголовок сайта (title)"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<TextareaField
label="Описание (description)"
value={data.description}
onChange={(v) => update({ ...data, description: v })}
rows={3}
/>
</>
)}
</SectionEditor>
);
}

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

141
src/app/admin/page.tsx Normal file
View File

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

View File

@@ -0,0 +1,205 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, SelectField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
interface PricingItem {
name: string;
price: string;
note?: string;
popular?: boolean;
featured?: boolean;
}
interface PricingData {
title: string;
subtitle: string;
items: PricingItem[];
rentalTitle: string;
rentalItems: { name: string; price: string; note?: string }[];
rules: string[];
showContactHint?: boolean;
}
function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
// Strip "BYN" suffix for editing, add back on save
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="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>
);
}
export default function PricingEditorPage() {
return (
<SectionEditor<PricingData> sectionKey="pricing" title="Цены">
{(data, update) => (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<InputField
label="Подзаголовок"
value={data.subtitle}
onChange={(v) => update({ ...data, subtitle: v })}
/>
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
<button
type="button"
role="switch"
aria-checked={data.showContactHint !== false}
onClick={() => update({ ...data, showContactHint: data.showContactHint === false })}
className={`relative h-5 w-9 rounded-full transition-colors ${
data.showContactHint !== false ? "bg-gold" : "bg-neutral-600"
}`}
>
<span
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
data.showContactHint !== false ? "translate-x-4" : ""
}`}
/>
</button>
<span className="text-sm text-neutral-400">Показывать контакты для записи (Instagram, Telegram, телефон)</span>
</label>
{/* Featured selector */}
{(() => {
const itemOptions = data.items
.map((it, idx) => ({ value: String(idx), label: it.name }))
.filter((o) => o.label.trim() !== "");
const noneOption = { value: "", label: "— Нет —" };
const featuredIdx = data.items.findIndex((it) => it.featured);
return (
<SelectField
label="Выделенный абонемент (безлимит)"
value={featuredIdx >= 0 ? String(featuredIdx) : ""}
onChange={(v) => {
const items = data.items.map((it, idx) => ({
...it,
featured: v ? idx === Number(v) : false,
}));
update({ ...data, items });
}}
options={[noneOption, ...itemOptions]}
placeholder="Выберите..."
/>
);
})()}
<ArrayEditor
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-3">
<InputField
label="Название"
value={item.name}
onChange={(v) => updateItem({ ...item, name: v })}
/>
<PriceField
label="Цена"
value={item.price}
onChange={(v) => updateItem({ ...item, price: v })}
/>
<InputField
label="Примечание"
value={item.note || ""}
onChange={(v) => updateItem({ ...item, note: v })}
/>
</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: "" })}
addLabel="Добавить абонемент"
/>
<InputField
label="Заголовок аренды"
value={data.rentalTitle}
onChange={(v) => update({ ...data, rentalTitle: v })}
/>
<ArrayEditor
label="Аренда"
items={data.rentalItems}
onChange={(rentalItems) => update({ ...data, rentalItems })}
renderItem={(item, _i, updateItem) => (
<div className="grid gap-3 sm:grid-cols-3">
<InputField
label="Название"
value={item.name}
onChange={(v) => updateItem({ ...item, name: v })}
/>
<PriceField
label="Цена"
value={item.price}
onChange={(v) => updateItem({ ...item, price: v })}
/>
<InputField
label="Примечание"
value={item.note || ""}
onChange={(v) => updateItem({ ...item, note: v })}
/>
</div>
)}
createItem={() => ({ name: "", price: "", note: "" })}
addLabel="Добавить вариант аренды"
/>
<ArrayEditor
label="Правила"
items={data.rules}
onChange={(rules) => update({ ...data, rules })}
renderItem={(rule, _i, updateItem) => (
<InputField label="Правило" value={rule} onChange={updateItem} />
)}
createItem={() => ""}
addLabel="Добавить правило"
/>
</>
)}
</SectionEditor>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,358 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter, useParams } from "next/navigation";
import Image from "next/image";
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
import { InputField, TextareaField, 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 {
name: string;
role: string;
image: string;
instagram: string;
description: string;
experience: string[];
victories: VictoryItem[];
education: RichListItem[];
}
export default function TeamMemberEditorPage() {
const router = useRouter();
const { id } = useParams<{ id: string }>();
const isNew = id === "new";
const [data, setData] = useState<MemberForm>({
name: "",
role: "",
image: "/images/team/placeholder.webp",
instagram: "",
description: "",
experience: [],
victories: [],
education: [],
});
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = 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(() => {
if (isNew) return;
adminFetch(`/api/admin/team/${id}`)
.then((r) => r.json())
.then((member) => {
const username = extractUsername(member.instagram || "");
setData({
name: member.name,
role: member.role,
image: member.image,
instagram: username,
description: member.description || "",
experience: member.experience || [],
victories: member.victories || [],
education: member.education || [],
});
if (username) setIgStatus("valid"); // existing data is trusted
})
.finally(() => setLoading(false));
}, [id, isNew]);
const hasErrors = igStatus === "invalid" || Object.keys(linkErrors).length > 0 || Object.keys(cityErrors).length > 0;
async function handleSave() {
if (hasErrors) return;
setSaving(true);
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) {
const res = await adminFetch("/api/admin/team", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) {
router.push("/admin/team");
}
} else {
const res = await adminFetch(`/api/admin/team/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) {
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
}
setSaving(false);
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append("file", file);
formData.append("folder", "team");
try {
const res = await adminFetch("/api/admin/upload", {
method: "POST",
body: formData,
});
const result = await res.json();
if (result.path) {
setData((prev) => ({ ...prev, image: result.path }));
}
} catch {
// Upload failed silently
} finally {
setUploading(false);
}
}
if (loading) {
return (
<div className="flex items-center gap-2 text-neutral-400">
<Loader2 size={18} className="animate-spin" />
Загрузка...
</div>
);
}
return (
<div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/admin/team")}
className="rounded-lg p-2 text-neutral-400 hover:text-white transition-colors"
>
<ArrowLeft size={20} />
</button>
<h1 className="text-2xl font-bold">
{isNew ? "Новый участник" : data.name}
</h1>
</div>
<button
onClick={handleSave}
disabled={saving || !data.name || !data.role || hasErrors || igStatus === "checking"}
className="flex items-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : saved ? (
<Check size={16} />
) : (
<Save size={16} />
)}
{saving ? "Сохранение..." : saved ? "Сохранено!" : "Сохранить"}
</button>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-[240px_1fr]">
{/* Photo */}
<div>
<p className="text-sm text-neutral-400 mb-2">Фото</p>
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-xl border border-white/10">
<Image
src={data.image}
alt={data.name || "Фото"}
fill
className="object-cover"
sizes="240px"
/>
</div>
<label className="mt-3 flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
{uploading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Upload size={16} />
)}
{uploading ? "Загрузка..." : "Загрузить фото"}
<input
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
</label>
</div>
{/* Fields */}
<div className="space-y-4">
<InputField
label="Имя"
value={data.name}
onChange={(v) => setData({ ...data, name: v })}
/>
<InputField
label="Роль / Специализация"
value={data.role}
onChange={(v) => setData({ ...data, role: v })}
/>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Instagram</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-500 text-sm select-none">@</span>
<input
type="text"
value={data.instagram}
onChange={(e) => {
const username = extractUsername(e.target.value);
setData({ ...data, instagram: username });
validateInstagram(username);
}}
placeholder="username"
className={`w-full rounded-lg border bg-neutral-800 pl-8 pr-10 py-2.5 text-white placeholder-neutral-500 outline-none 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
label="Описание"
value={data.description}
onChange={(v) => setData({ ...data, description: v })}
rows={6}
/>
<div className="border-t border-white/5 pt-4 mt-4">
<p className="text-sm font-medium text-neutral-300 mb-4">Биография</p>
<div className="space-y-4">
<ListField
label="Опыт"
items={data.experience}
onChange={(items) => setData({ ...data, experience: items })}
placeholder="Например: 10 лет в танцах"
/>
<VictoryItemListField
label="Достижения"
items={data.victories}
onChange={(items) => setData({ ...data, victories: items })}
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;
});
}}
/>
<VictoryListField
label="Образование"
items={data.education}
onChange={(items) => setData({ ...data, education: items })}
placeholder="Например: Сертификат IPSF"
onLinkValidate={(key, error) => {
setLinkErrors((prev) => {
if (error) return { ...prev, [key]: error };
const n = { ...prev }; delete n[key]; return n;
});
}}
/>
</div>
</div>
</div>
</div>
</div>
);
}

343
src/app/admin/team/page.tsx Normal file
View File

@@ -0,0 +1,343 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import Link from "next/link";
import {
Loader2,
Plus,
Trash2,
GripVertical,
Check,
} from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { TeamMember } from "@/types/content";
type Member = TeamMember & { id: number };
export default function TeamEditorPage() {
const [members, setMembers] = useState<Member[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [insertAt, setInsertAt] = useState<number | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [dragSize, setDragSize] = useState({ w: 0, h: 0 });
const [grabOffset, setGrabOffset] = useState({ x: 0, y: 0 });
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
adminFetch("/api/admin/team")
.then((r) => r.json())
.then(setMembers)
.finally(() => setLoading(false));
}, []);
const saveOrder = useCallback(async (updated: Member[]) => {
setMembers(updated);
setSaving(true);
await adminFetch("/api/admin/team/reorder", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
});
setSaving(false);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}, []);
const startDrag = useCallback(
(clientX: number, clientY: number, index: number) => {
const el = itemRefs.current[index];
if (!el) return;
const rect = el.getBoundingClientRect();
setDragIndex(index);
setInsertAt(index);
setMousePos({ x: clientX, y: clientY });
setDragSize({ w: rect.width, h: rect.height });
setGrabOffset({ x: clientX - rect.left, y: clientY - rect.top });
},
[]
);
const handleGripMouseDown = useCallback(
(e: React.MouseEvent, index: number) => {
e.preventDefault();
startDrag(e.clientX, e.clientY, index);
},
[startDrag]
);
const handleCardMouseDown = useCallback(
(e: React.MouseEvent, index: number) => {
const tag = (e.target as HTMLElement).closest("input, textarea, select, button, a, [role='switch']");
if (tag) return;
e.preventDefault();
const x = e.clientX;
const y = e.clientY;
const pendingIndex = index;
let moved = false;
function onMove(ev: MouseEvent) {
const dx = ev.clientX - x;
const dy = ev.clientY - y;
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
moved = true;
cleanup();
startDrag(ev.clientX, ev.clientY, pendingIndex);
}
}
function onUp() {
cleanup();
if (!moved) {
window.location.href = `/admin/team/${members[pendingIndex].id}`;
}
}
function cleanup() {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
}
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[startDrag, members]
);
useEffect(() => {
if (dragIndex === null) return;
document.body.style.userSelect = "none";
function onMouseMove(e: MouseEvent) {
setMousePos({ x: e.clientX, y: e.clientY });
let newInsert = members.length;
for (let i = 0; i < members.length; i++) {
if (i === dragIndex) continue;
const el = itemRefs.current[i];
if (!el) continue;
const rect = el.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
newInsert = i > dragIndex! ? i : i;
break;
}
}
setInsertAt(newInsert);
}
function onMouseUp() {
setDragIndex((prevDrag) => {
setInsertAt((prevInsert) => {
if (prevDrag !== null && prevInsert !== null) {
let targetIndex = prevInsert;
if (prevDrag < targetIndex) targetIndex -= 1;
if (prevDrag !== targetIndex) {
const updated = [...members];
const [moved] = updated.splice(prevDrag, 1);
updated.splice(targetIndex, 0, moved);
saveOrder(updated);
}
}
return null;
});
return null;
});
}
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [dragIndex, members, saveOrder]);
async function deleteMember(id: number) {
if (!confirm("Удалить этого участника?")) return;
await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
setMembers((prev) => prev.filter((m) => m.id !== id));
}
if (loading) {
return (
<div className="flex items-center gap-2 text-neutral-400">
<Loader2 size={18} className="animate-spin" />
Загрузка...
</div>
);
}
const draggedMember = dragIndex !== null ? members[dragIndex] : null;
// Build the visual order: remove dragged item, insert placeholder at insertAt
function renderList() {
if (dragIndex === null || insertAt === null) {
// Normal render — no drag
return members.map((member, i) => (
<div
key={member.id}
ref={(el) => { itemRefs.current[i] = el; }}
onMouseDown={(e) => handleCardMouseDown(e, i)}
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors cursor-pointer"
>
<div
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
onMouseDown={(e) => handleGripMouseDown(e, i)}
>
<GripVertical size={18} />
</div>
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="48px" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-white truncate">{member.name}</p>
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
</div>
<button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
<Trash2 size={16} />
</button>
</div>
));
}
// During drag: build list without the dragged item, with placeholder inserted
const elements: React.ReactNode[] = [];
let visualIndex = 0;
// Determine where to insert placeholder relative to non-dragged items
let placeholderPos = insertAt;
if (insertAt > dragIndex) placeholderPos = insertAt - 1;
for (let i = 0; i < members.length; i++) {
if (i === dragIndex) {
// Keep a hidden ref so midpoint detection still works
elements.push(
<div key={`hidden-${members[i].id}`} ref={(el) => { itemRefs.current[i] = el; }} className="hidden" />
);
continue;
}
if (visualIndex === placeholderPos) {
elements.push(
<div
key="placeholder"
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-2"
style={{ height: dragSize.h }}
/>
);
}
const member = members[i];
elements.push(
<div
key={member.id}
ref={(el) => { itemRefs.current[i] = el; }}
onMouseDown={(e) => handleCardMouseDown(e, i)}
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors cursor-pointer"
>
<div
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
onMouseDown={(e) => handleGripMouseDown(e, i)}
>
<GripVertical size={18} />
</div>
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="48px" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-white truncate">{member.name}</p>
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
</div>
<button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
<Trash2 size={16} />
</button>
</div>
);
visualIndex++;
}
// Placeholder at the end
if (visualIndex === placeholderPos) {
elements.push(
<div
key="placeholder"
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-2"
style={{ height: dragSize.h }}
/>
);
}
return elements;
}
return (
<div>
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Команда</h1>
<div className="flex items-center gap-3">
{(saving || saved) && (
<span className="text-sm text-neutral-400 flex items-center gap-1">
{saving ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Check size={14} className="text-green-400" />
)}
{saving ? "Сохранение..." : "Сохранено!"}
</span>
)}
<Link
href="/admin/team/new"
className="flex items-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black hover:opacity-90 transition-opacity"
>
<Plus size={16} />
Добавить
</Link>
</div>
</div>
<div className="mt-6">
{renderList()}
</div>
{/* Floating card following cursor */}
{dragIndex !== null &&
draggedMember &&
createPortal(
<div
className="fixed z-[9999] pointer-events-none"
style={{
left: mousePos.x - grabOffset.x,
top: mousePos.y - grabOffset.y,
width: dragSize.w,
}}
>
<div className="flex items-center gap-4 rounded-lg border-2 border-rose-500 bg-neutral-900 p-3 shadow-2xl shadow-rose-500/20">
<div className="text-rose-400">
<GripVertical size={18} />
</div>
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
<Image
src={draggedMember.image}
alt={draggedMember.name}
fill
className="object-cover"
sizes="48px"
/>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-white truncate">{draggedMember.name}</p>
<p className="text-sm text-neutral-400 truncate">{draggedMember.role}</p>
</div>
</div>
</div>,
document.body
)}
</div>
);
}

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

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { getSection, setSection, SECTION_KEYS } from "@/lib/db";
import { siteContent } from "@/data/content";
import { revalidatePath } from "next/cache";
import { invalidateContentCache } from "@/lib/content";
type Params = { params: Promise<{ key: string }> };
export async function GET(_request: NextRequest, { params }: Params) {
const { key } = await params;
if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) {
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
}
let data = getSection(key);
if (!data) {
// Auto-seed from fallback content if section doesn't exist yet
const fallback = (siteContent as unknown as Record<string, unknown>)[key];
if (fallback) {
setSection(key, fallback);
data = fallback;
} else {
return NextResponse.json({ error: "Section not found" }, { status: 404 });
}
}
return NextResponse.json(data, {
headers: { "Cache-Control": "private, max-age=60" },
});
}
export async function PUT(request: NextRequest, { params }: Params) {
const { key } = await params;
if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) {
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
}
const data = await request.json();
setSection(key, data);
invalidateContentCache();
revalidatePath("/");
return NextResponse.json({ ok: true });
}

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { getTeamMembers, createTeamMember } from "@/lib/db";
import { revalidatePath } from "next/cache";
import type { RichListItem, VictoryItem } from "@/types/content";
export async function GET() {
const members = getTeamMembers();
return NextResponse.json(members, {
headers: { "Cache-Control": "private, max-age=60" },
});
}
export async function POST(request: NextRequest) {
const data = await request.json() as {
name: string;
role: string;
image: string;
instagram?: string;
description?: string;
experience?: string[];
victories?: VictoryItem[];
education?: RichListItem[];
};
if (!data.name || !data.role || !data.image) {
return NextResponse.json(
{ error: "name, role, and image are required" },
{ status: 400 }
);
}
const id = createTeamMember(data);
revalidatePath("/");
return NextResponse.json({ id }, { status: 201 });
}

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

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
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
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
const rawFolder = (formData.get("folder") as string) || "team";
const folder = ALLOWED_FOLDERS.includes(rawFolder) ? rawFolder : "team";
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: "Only JPEG, PNG, WebP, and AVIF are allowed" },
{ status: 400 }
);
}
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: "File too large (max 5MB)" },
{ status: 400 }
);
}
// Validate and sanitize filename
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
.replace(ext, "")
.toLowerCase()
.replace(/[^a-z0-9а-яё-]/gi, "-")
.replace(/-+/g, "-")
.slice(0, 50);
const fileName = `${baseName}-${Date.now()}${ext}`;
const dir = path.join(process.cwd(), "public", "images", folder);
await mkdir(dir, { recursive: true });
const buffer = Buffer.from(await file.arrayBuffer());
const filePath = path.join(dir, fileName);
await writeFile(filePath, buffer);
const publicPath = `/images/${folder}/${fileName}`;
return NextResponse.json({ path: publicPath });
}

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

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyPassword, signToken, generateCsrfToken, COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
export async function POST(request: NextRequest) {
const body = await request.json() as { password?: string };
if (!body.password || !verifyPassword(body.password)) {
return NextResponse.json({ error: "Неверный пароль" }, { status: 401 });
}
const token = signToken();
const csrfToken = generateCsrfToken();
const response = NextResponse.json({ ok: true });
response.cookies.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24,
});
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
httpOnly: false, // JS must read this to send as header
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24,
});
return response;
}

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

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

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

@@ -8,6 +8,9 @@
@theme inline {
--font-display: var(--font-oswald);
--font-sans: var(--font-inter);
--color-gold: #c9a96e;
--color-gold-light: #d4b87a;
--color-gold-dark: #a08050;
}
/* ===== Base ===== */
@@ -23,7 +26,7 @@ body {
/* ===== Selection ===== */
::selection {
background-color: rgba(225, 29, 72, 0.3);
background-color: rgba(201, 169, 110, 0.3);
color: inherit;
}
@@ -43,5 +46,40 @@ body {
/* ===== Focus ===== */
:focus-visible {
@apply outline-2 outline-offset-2 outline-rose-500;
@apply outline-2 outline-offset-2 outline-gold;
}
/* ===== Scrollbar hide utility ===== */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* ===== Admin dark scrollbar ===== */
.admin-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
.admin-scrollbar::-webkit-scrollbar {
width: 6px;
}
.admin-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.admin-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.admin-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}

View File

@@ -1,8 +1,6 @@
import type { Metadata } from "next";
import { Inter, Oswald } from "next/font/google";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { siteContent } from "@/data/content";
import { getContent } from "@/lib/content";
import "./globals.css";
const inter = Inter({
@@ -15,30 +13,31 @@ const oswald = Oswald({
subsets: ["latin", "cyrillic"],
});
export const metadata: Metadata = {
title: siteContent.meta.title,
description: siteContent.meta.description,
openGraph: {
title: "BLACK HEART DANCE HOUSE",
description: siteContent.meta.description,
locale: "ru_RU",
type: "website",
},
};
export function generateMetadata(): Metadata {
const { meta } = getContent();
return {
title: meta.title,
description: meta.description,
openGraph: {
title: meta.title,
description: meta.description,
locale: "ru_RU",
type: "website",
},
};
}
export default function RootLayout({
children,
}: Readonly<{
}: {
children: React.ReactNode;
}>) {
}) {
return (
<html lang="ru" className="dark">
<body
className={`${inter.variable} ${oswald.variable} bg-[#050505] text-neutral-50 font-sans antialiased`}
className={`${inter.variable} ${oswald.variable} surface-base font-sans antialiased`}
>
<Header />
<main>{children}</main>
<Footer />
{children}
</body>
</html>
);

View File

@@ -1,13 +1,21 @@
import { Button } from "@/components/ui/Button";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
export default function NotFound() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 text-center">
<h1 className="font-display text-6xl font-bold">404</h1>
<p className="body-text mt-4 text-lg">Страница не найдена</p>
<div className="mt-8">
<Button href="/">На главную</Button>
</div>
</div>
<>
<Header />
<main>
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 text-center">
<h1 className="font-display text-6xl font-bold">404</h1>
<p className="body-text mt-4 text-lg">Страница не найдена</p>
<div className="mt-8">
<Button href="/">На главную</Button>
</div>
</div>
</main>
<Footer />
</>
);
}

View File

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

View File

@@ -53,6 +53,24 @@
}
}
@keyframes heartbeat {
0%, 100% {
transform: scale(1);
}
15% {
transform: scale(1.08);
}
30% {
transform: scale(1);
}
45% {
transform: scale(1.05);
}
60% {
transform: scale(1);
}
}
@keyframes heart-float {
0% {
opacity: 0;
@@ -77,6 +95,10 @@
animation: hero-fade-in-scale 1.2s cubic-bezier(0.16, 1, 0.3, 1) 0.1s forwards;
}
.hero-logo-heartbeat {
animation: heartbeat 2.5s ease-in-out 1.5s infinite;
}
.hero-title {
opacity: 0;
animation: hero-fade-in-up 1s cubic-bezier(0.16, 1, 0.3, 1) 0.5s forwards;
@@ -95,9 +117,9 @@
/* ===== Hero Background ===== */
.hero-bg-gradient {
background: radial-gradient(ellipse 80% 60% at 50% -20%, rgba(225, 29, 72, 0.15), transparent),
radial-gradient(ellipse 60% 40% at 80% 50%, rgba(225, 29, 72, 0.08), transparent),
radial-gradient(ellipse 60% 40% at 20% 80%, rgba(225, 29, 72, 0.06), transparent);
background: radial-gradient(ellipse 80% 60% at 50% -20%, rgba(201, 169, 110, 0.12), transparent),
radial-gradient(ellipse 60% 40% at 80% 50%, rgba(201, 169, 110, 0.06), transparent),
radial-gradient(ellipse 60% 40% at 20% 80%, rgba(201, 169, 110, 0.04), transparent);
}
.hero-glow-orb {
@@ -106,12 +128,21 @@
filter: blur(80px);
animation: pulse-glow 6s ease-in-out infinite;
pointer-events: none;
will-change: filter, transform;
}
/* ===== Gradient Text ===== */
.gradient-text {
background: linear-gradient(135deg, #fff 0%, #e11d48 50%, #fff 100%);
background: linear-gradient(
135deg,
#8a6f3e 0%,
#c9a96e 20%,
#8a6f3e 40%,
#c9a96e 60%,
#6b5530 80%,
#8a6f3e 100%
);
background-size: 200% 200%;
-webkit-background-clip: text;
background-clip: text;
@@ -121,7 +152,7 @@
/* Light mode gradient text */
.gradient-text-light {
background: linear-gradient(135deg, #171717 0%, #e11d48 50%, #171717 100%);
background: linear-gradient(135deg, #171717 0%, #c9a96e 50%, #171717 100%);
background-size: 200% 200%;
-webkit-background-clip: text;
background-clip: text;
@@ -141,7 +172,7 @@
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, rgba(225, 29, 72, 0.3), transparent 40%, transparent 60%, rgba(225, 29, 72, 0.15));
background: linear-gradient(135deg, rgba(201, 169, 110, 0.3), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.15));
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
@@ -151,7 +182,7 @@
.animated-border:hover::before {
opacity: 1;
background: linear-gradient(135deg, rgba(225, 29, 72, 0.6), transparent 40%, transparent 60%, rgba(225, 29, 72, 0.4));
background: linear-gradient(135deg, rgba(201, 169, 110, 0.6), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.4));
}
/* ===== Glow Effect ===== */
@@ -161,7 +192,7 @@
}
.glow-hover:hover {
box-shadow: 0 0 30px rgba(225, 29, 72, 0.1), 0 0 60px rgba(225, 29, 72, 0.05);
box-shadow: 0 0 30px rgba(201, 169, 110, 0.1), 0 0 60px rgba(201, 169, 110, 0.05);
transform: translateY(-4px);
}
@@ -178,6 +209,38 @@
transform: translateY(0);
}
/* ===== Showcase ===== */
@keyframes showcase-detail-enter {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes showcase-image-enter {
from {
opacity: 0;
transform: scale(1.03);
}
to {
opacity: 1;
transform: scale(1);
}
}
.showcase-detail-enter {
animation: showcase-detail-enter 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.showcase-detail-enter img {
animation: showcase-image-enter 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* ===== Modal ===== */
@keyframes modal-fade-in {
@@ -208,11 +271,81 @@
animation: modal-fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
/* ===== Team Info Fade ===== */
@keyframes team-info-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ===== Team Card Glitter ===== */
@keyframes glitter-move {
0% {
background-position: 0% 0%;
}
100% {
background-position: 200% 200%;
}
}
.team-card-glitter {
position: relative;
}
/* Animated gold border glow */
.team-card-glitter::before {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 2px;
background: linear-gradient(
135deg,
transparent 20%,
rgba(201, 169, 110, 0.6) 30%,
rgba(212, 184, 122, 1) 35%,
transparent 45%,
transparent 55%,
rgba(201, 169, 110, 0.5) 65%,
rgba(212, 184, 122, 0.9) 70%,
transparent 80%
);
background-size: 200% 200%;
animation: glitter-move 3s linear infinite;
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
z-index: 1;
will-change: background-position;
}
/* ===== Notification Pulse ===== */
@keyframes pulse-urgent {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
}
50% {
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
}
}
.pulse-urgent {
animation: pulse-urgent 1.5s ease-in-out infinite;
}
/* ===== Section Divider ===== */
.section-divider {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(225, 29, 72, 0.3), transparent);
background: linear-gradient(90deg, transparent, rgba(201, 169, 110, 0.15), transparent);
}
/* ===== Reduced Motion ===== */
@@ -243,11 +376,21 @@
animation: none !important;
}
.hero-glow-orb {
.hero-glow-orb,
.hero-logo-heartbeat {
animation: none !important;
}
.showcase-detail-enter,
.showcase-detail-enter img {
animation: none !important;
}
.glow-hover:hover {
transform: none;
}
.team-card-glitter::before {
animation: none !important;
}
}

View File

@@ -1,57 +1,11 @@
/* ===== Navigation ===== */
.nav-link {
@apply text-sm font-medium transition-all duration-300;
@apply text-neutral-500;
@apply hover:text-neutral-900;
@apply dark:text-neutral-400 dark:hover:text-white;
}
.nav-link-active {
@apply text-rose-600;
@apply dark:text-rose-400;
}
.social-icon {
@apply text-neutral-400 transition-all duration-300;
@apply hover:text-rose-600;
@apply dark:text-neutral-500 dark:hover:text-rose-400;
}
/* ===== Cards ===== */
.card {
@apply rounded-2xl border p-6 transition-all duration-500 cursor-pointer;
@apply border-neutral-200 bg-white;
@apply hover:border-rose-200 hover:shadow-lg;
@apply dark:border-white/[0.06] dark:bg-white/[0.02];
@apply dark:hover:border-rose-500/20 dark:hover:bg-white/[0.04];
@apply dark:hover:shadow-[0_0_30px_rgba(225,29,72,0.08)];
}
/* ===== Buttons ===== */
.btn-primary {
@apply inline-flex items-center justify-center font-semibold rounded-full transition-all duration-300 cursor-pointer;
@apply bg-rose-600 text-white;
@apply hover:bg-rose-500 hover:shadow-[0_0_30px_rgba(225,29,72,0.4)];
@apply dark:bg-rose-600 dark:text-white;
@apply dark:hover:bg-rose-500 dark:hover:shadow-[0_0_30px_rgba(225,29,72,0.4)];
}
.btn-outline {
@apply inline-flex items-center justify-center font-semibold rounded-full transition-all duration-300 cursor-pointer;
@apply border border-rose-600 text-rose-600;
@apply hover:bg-rose-600 hover:text-white;
@apply dark:border-rose-500 dark:text-rose-400;
@apply dark:hover:bg-rose-500 dark:hover:text-white;
}
.btn-ghost {
@apply inline-flex items-center justify-center font-medium rounded-full transition-all duration-300 cursor-pointer;
@apply text-neutral-600;
@apply hover:text-rose-600;
@apply dark:text-neutral-400 dark:hover:text-rose-400;
@apply bg-gold text-black;
@apply hover:bg-gold-light hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
@apply dark:bg-gold dark:text-black;
@apply dark:hover:bg-gold-light dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
}
/* ===== Scrollbar ===== */
@@ -73,14 +27,3 @@
scrollbar-color: rgb(64 64 64) transparent;
}
}
/* ===== Contact ===== */
.contact-item {
@apply flex items-center gap-4;
}
.contact-icon {
@apply shrink-0 text-rose-600;
@apply dark:text-rose-400;
}

View File

@@ -2,12 +2,12 @@
.surface-base {
@apply bg-neutral-50 text-neutral-900;
@apply dark:bg-[#050505] dark:text-neutral-50;
@apply dark:bg-[#050505] dark:text-neutral-100;
}
.surface-muted {
@apply bg-neutral-100;
@apply dark:bg-[#0a0a0a];
@apply dark:bg-[#080808];
}
.surface-glass {
@@ -17,14 +17,14 @@
.surface-card {
@apply bg-white/80 backdrop-blur-sm;
@apply dark:bg-white/[0.03] dark:backdrop-blur-sm;
@apply dark:bg-[#111] dark:backdrop-blur-sm;
}
/* ===== Borders ===== */
.theme-border {
@apply border-neutral-200;
@apply dark:border-white/[0.06];
@apply dark:border-white/[0.08];
}
/* ===== Text ===== */
@@ -36,7 +36,7 @@
.body-text {
@apply text-neutral-600;
@apply dark:text-neutral-400;
@apply dark:text-neutral-300;
}
.muted-text {
@@ -45,16 +45,81 @@
}
.accent-text {
@apply text-rose-600;
@apply dark:text-rose-400;
@apply text-gold-dark;
@apply dark:text-gold-light;
}
/* ===== Layout ===== */
.section-padding {
@apply py-16 sm:py-24;
@apply py-20 sm:py-32;
}
.section-container {
@apply mx-auto max-w-6xl px-6 sm:px-8;
}
/* ===== Section Glow Backgrounds ===== */
.section-glow {
position: relative;
}
.section-glow::before {
content: "";
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 600px;
height: 400px;
background: radial-gradient(ellipse, rgba(201, 169, 110, 0.05), transparent 70%);
pointer-events: none;
}
/* ===== Glass Card ===== */
.glass-card {
@apply rounded-2xl border backdrop-blur-sm transition-all duration-300;
@apply border-neutral-200/80 bg-white/90;
@apply dark:border-white/[0.06] dark:bg-white/[0.04];
}
.glass-card:hover {
@apply dark:border-gold/15 dark:bg-white/[0.06];
}
/* ===== Photo Filter ===== */
.photo-filter {
filter: saturate(0.7) sepia(0.15) brightness(0.95) contrast(1.05);
}
:is(.dark) .photo-filter {
filter: saturate(0.6) sepia(0.2) brightness(0.9) contrast(1.1);
}
/* ===== Custom Scrollbar ===== */
.styled-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(201, 169, 110, 0.25) transparent;
}
.styled-scrollbar::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.styled-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.styled-scrollbar::-webkit-scrollbar-thumb {
background: rgba(201, 169, 110, 0.25);
border-radius: 4px;
}
.styled-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(201, 169, 110, 0.4);
}

View File

@@ -5,7 +5,7 @@ export function Footer() {
const year = new Date().getFullYear();
return (
<footer className="relative border-t border-neutral-200 bg-neutral-100 dark:border-white/[0.06] dark:bg-[#050505]">
<footer className="relative border-t border-neutral-200 bg-neutral-100 dark:border-white/[0.08] dark:bg-[#050505]">
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container flex flex-col items-center gap-4 py-10 sm:flex-row sm:justify-between">
<p className="text-sm text-neutral-500">
@@ -13,7 +13,7 @@ export function Footer() {
</p>
<div className="flex items-center gap-1.5 text-sm text-neutral-500">
<span>Made with</span>
<Heart size={14} className="fill-rose-500 text-rose-500" />
<Heart size={14} className="fill-gold text-gold" />
</div>
</div>
</footer>

View File

@@ -1,73 +1,142 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Menu, X } from "lucide-react";
import { useState, useEffect } from "react";
import { BRAND, NAV_LINKS } from "@/lib/constants";
import { UI_CONFIG } from "@/lib/config";
import { HeroLogo } from "@/components/ui/HeroLogo";
import { SignupModal } from "@/components/ui/SignupModal";
export function Header() {
const [menuOpen, setMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [activeSection, setActiveSection] = useState("");
const [bookingOpen, setBookingOpen] = useState(false);
useEffect(() => {
let ticking = false;
function handleScroll() {
setScrolled(window.scrollY > 20);
if (!ticking) {
ticking = true;
requestAnimationFrame(() => {
setScrolled(window.scrollY > UI_CONFIG.scrollThresholds.header);
ticking = false;
});
}
}
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
// Listen for booking open events from other components
useEffect(() => {
function onOpenBooking() {
setBookingOpen(true);
}
window.addEventListener("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(() => {
setVisibleLinks(NAV_LINKS.filter((l) => document.getElementById(l.href.replace("#", ""))));
}, []);
useEffect(() => {
const sectionIds = visibleLinks.map((l) => l.href.replace("#", ""));
const observers: IntersectionObserver[] = [];
// Observe hero — when visible, clear active section
const hero = document.querySelector("section:first-of-type");
if (hero) {
const heroObserver = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setActiveSection("");
},
{ rootMargin: "-20% 0px -70% 0px" },
);
heroObserver.observe(hero);
observers.push(heroObserver);
}
sectionIds.forEach((id) => {
const el = document.getElementById(id);
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setActiveSection(id);
}
},
{ rootMargin: "-40% 0px -55% 0px" },
);
observer.observe(el);
observers.push(observer);
});
return () => observers.forEach((o) => o.disconnect());
}, [visibleLinks]);
return (
<header
className={`fixed top-0 z-50 w-full transition-all duration-500 ${
scrolled
? "border-b border-white/[0.06] bg-black/40 shadow-none backdrop-blur-xl"
? "bg-black/40 shadow-none backdrop-blur-xl"
: "bg-transparent"
}`}
>
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-6 sm:px-8">
<div className="flex h-16 items-center justify-between px-6 sm:px-10 lg:px-16">
<Link href="/" className="group flex items-center gap-2.5">
<div className="relative flex h-8 w-8 items-center justify-center">
<div
className="absolute inset-0 rounded-full transition-all duration-300 group-hover:scale-125"
style={{
background: "radial-gradient(circle, rgba(225,29,72,0.5) 0%, rgba(225,29,72,0.15) 50%, transparent 70%)",
background: "radial-gradient(circle, rgba(201,169,110,0.5) 0%, rgba(201,169,110,0.15) 50%, transparent 70%)",
}}
/>
<Image
src="/images/logo.png"
alt={BRAND.name}
width={24}
height={24}
unoptimized
className="relative transition-transform duration-300 group-hover:scale-110"
style={{
filter: "drop-shadow(0 0 3px rgba(225,29,72,0.5))",
}}
<HeroLogo
size={24}
className="relative text-black transition-transform duration-300 drop-shadow-[0_0_3px_rgba(201,169,110,0.5)] group-hover:scale-110"
/>
</div>
<span className="font-display text-lg font-bold tracking-tight text-white">
<span className="font-display text-lg font-bold tracking-tight text-gold">
{BRAND.shortName}
</span>
</Link>
<nav className="hidden items-center gap-8 md:flex">
{NAV_LINKS.map((link) => (
<a
key={link.href}
href={link.href}
className="relative py-1 text-sm font-medium text-neutral-400 transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:w-0 after:bg-rose-500 after:transition-all after:duration-300 hover:text-white hover:after:w-full"
>
{link.label}
</a>
))}
<nav className="hidden items-center gap-3 lg:gap-5 xl:gap-6 lg:flex">
{visibleLinks.map((link) => {
const isActive = activeSection === link.href.replace("#", "");
return (
<a
key={link.href}
href={link.href}
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
? "text-gold-light after:w-full"
: "text-neutral-400 after:w-0 hover:text-white hover:after:w-full"
}`}
>
{link.label}
</a>
);
})}
<button
onClick={() => setBookingOpen(true)}
className="rounded-full bg-gold px-4 py-1.5 text-sm font-semibold text-black transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/20 cursor-pointer"
>
Записаться
</button>
</nav>
<div className="flex items-center gap-2 md:hidden">
<div className="flex items-center gap-2 lg:hidden">
<button
onClick={() => setMenuOpen(!menuOpen)}
aria-label="Меню"
aria-label={menuOpen ? "Закрыть меню" : "Открыть меню"}
aria-expanded={menuOpen}
className="rounded-lg p-2 text-neutral-400 transition-colors hover:text-white"
>
{menuOpen ? <X size={24} /> : <Menu size={24} />}
@@ -77,23 +146,42 @@ export function Header() {
{/* Mobile menu */}
<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"
}`}
>
<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) => (
<a
key={link.href}
href={link.href}
onClick={() => setMenuOpen(false)}
className="block py-3 text-base text-neutral-400 transition-colors hover:text-white"
>
{link.label}
</a>
))}
{visibleLinks.map((link) => {
const isActive = activeSection === link.href.replace("#", "");
return (
<a
key={link.href}
href={link.href}
onClick={() => setMenuOpen(false)}
className={`block py-3 text-base transition-colors ${
isActive
? "text-gold-light"
: "text-neutral-400 hover:text-white"
}`}
>
{link.label}
</a>
);
})}
<button
onClick={() => {
setMenuOpen(false);
setBookingOpen(true);
}}
className="mt-2 w-full rounded-full bg-gold py-3 text-sm font-semibold text-black transition-all hover:bg-gold-light cursor-pointer"
>
Записаться
</button>
</nav>
</div>
<SignupModal open={bookingOpen} onClose={() => setBookingOpen(false)} endpoint="/api/group-booking" />
</header>
);
}

View File

@@ -1,32 +1,65 @@
import { siteContent } from "@/data/content";
import { Users, Layers, MapPin } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { Heart } from "lucide-react";
import type { SiteContent } from "@/types/content";
export function About() {
const { about } = siteContent;
interface AboutStats {
trainers: number;
classes: number;
locations: number;
}
interface AboutProps {
data: SiteContent["about"];
stats: AboutStats;
}
export function About({ data: about, stats }: AboutProps) {
const statItems = [
{ icon: <Users size={22} />, value: String(stats.trainers), label: "тренеров" },
{ icon: <Layers size={22} />, value: String(stats.classes), label: "направлений" },
{ icon: <MapPin size={22} />, value: String(stats.locations), label: "зала в Минске" },
];
return (
<section id="about" className="relative section-padding bg-neutral-100 dark:bg-[#0a0a0a]">
<section id="about" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]">
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container">
<Reveal>
<SectionHeading>{about.title}</SectionHeading>
<SectionHeading centered>{about.title}</SectionHeading>
</Reveal>
<div className="mt-10 max-w-3xl space-y-6">
{about.paragraphs.map((text, i) => (
<Reveal key={i}>
<div className="flex gap-4">
<Heart
size={20}
className="mt-1 shrink-0 fill-rose-500/20 text-rose-500 dark:fill-rose-500/10 dark:text-rose-400"
/>
<p className="body-text text-lg leading-relaxed">{text}</p>
</div>
<div className="mt-14 mx-auto max-w-2xl space-y-8 text-center">
{about.paragraphs.map((text) => (
<Reveal key={text}>
<p className="text-xl leading-relaxed text-neutral-600 dark:text-neutral-300 sm:text-2xl">
{text}
</p>
</Reveal>
))}
</div>
{/* Stats */}
<Reveal>
<div className="mx-auto mt-14 grid max-w-3xl grid-cols-3 gap-4 sm:gap-8">
{statItems.map((stat, i) => (
<div
key={i}
className="group flex flex-col items-center gap-3 rounded-2xl border border-neutral-200 bg-white/50 p-6 transition-all duration-300 hover:border-gold/30 sm:p-8 dark:border-white/[0.06] dark:bg-white/[0.02] dark:hover:border-gold/20"
>
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gold/10 text-gold-dark transition-colors group-hover:bg-gold/20 dark:text-gold-light">
{stat.icon}
</div>
<span className="font-display text-3xl font-bold text-neutral-900 sm:text-4xl dark:text-white">
{stat.value}
</span>
<span className="text-sm text-neutral-500 dark:text-neutral-400">
{stat.label}
</span>
</div>
))}
</div>
</Reveal>
</div>
</section>
);

View File

@@ -1,89 +1,117 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { Flame, Sparkles, Wind, Zap, Star, Monitor, ArrowRight } from "lucide-react";
import { siteContent } from "@/data/content";
import { icons } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { ClassModal } from "@/components/ui/ClassModal";
import type { ClassItem } from "@/types";
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
import type { ClassItem, SiteContent } from "@/types";
import { UI_CONFIG } from "@/lib/config";
const iconMap: Record<string, React.ReactNode> = {
flame: <Flame size={20} />,
sparkles: <Sparkles size={20} />,
wind: <Wind size={20} />,
zap: <Zap size={20} />,
star: <Star size={20} />,
monitor: <Monitor size={20} />,
};
// kebab "heart-pulse" → PascalCase "HeartPulse"
function toPascal(kebab: string) {
return kebab.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
}
export function Classes() {
const { classes } = siteContent;
const [selectedClass, setSelectedClass] = useState<ClassItem | null>(null);
function getIcon(key: string) {
const Icon = icons[toPascal(key) as keyof typeof icons];
return Icon ? <Icon size={20} /> : null;
}
interface ClassesProps {
data: SiteContent["classes"];
}
export function Classes({ data: classes }: ClassesProps) {
const { activeIndex, select, setHovering } = useShowcaseRotation({
totalItems: classes.items.length,
autoPlayInterval: UI_CONFIG.showcase.autoPlayInterval,
});
return (
<section id="classes" className="relative section-padding bg-neutral-100 dark:bg-[#0a0a0a]">
<section id="classes" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]">
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container">
<Reveal>
<SectionHeading>{classes.title}</SectionHeading>
<SectionHeading centered>{classes.title}</SectionHeading>
</Reveal>
<div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{classes.items.map((item) => (
<Reveal key={item.name} className="h-full">
<div
className="group relative h-full min-h-[280px] cursor-pointer overflow-hidden rounded-2xl"
onClick={() => setSelectedClass(item)}
>
{/* Background image */}
{item.images && item.images[0] && (
<Image
src={item.images[0]}
alt={item.name}
fill
className="object-cover transition-transform duration-700 ease-out group-hover:scale-105"
/>
)}
<div className="mt-14">
<Reveal>
<ShowcaseLayout<ClassItem>
items={classes.items}
activeIndex={activeIndex}
onSelect={select}
onHoverChange={setHovering}
renderDetail={(item) => (
<div>
{/* Hero image */}
{item.images && item.images[0] && (
<div className="team-card-glitter relative aspect-[16/9] w-full overflow-hidden rounded-2xl">
<Image
src={item.images[0]}
alt={item.name}
fill
loading="lazy"
sizes="(min-width: 1024px) 60vw, 100vw"
className="object-cover"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
{/* Dark gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-black/10 transition-all duration-500 group-hover:from-black/95 group-hover:via-black/50" />
{/* Icon + name overlay */}
<div className="absolute bottom-0 left-0 right-0 p-6">
<div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-gold/20 text-gold-light backdrop-blur-sm">
{getIcon(item.icon)}
</div>
<h3 className="text-2xl font-bold text-white">
{item.name}
</h3>
</div>
</div>
)}
{/* Rose tint on hover */}
<div className="absolute inset-0 bg-rose-900/0 transition-all duration-500 group-hover:bg-rose-900/10" />
{/* Content */}
<div className="relative flex h-full flex-col justify-end p-6">
{/* Icon badge */}
<div className="mb-3 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-white/10 text-white backdrop-blur-sm transition-all duration-300 group-hover:bg-rose-500/20 group-hover:text-rose-300">
{iconMap[item.icon]}
{/* Description */}
{item.detailedDescription && (
<div className="mt-5 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400 whitespace-pre-line">
{item.detailedDescription}
</div>
)}
</div>
)}
renderSelectorItem={(item, _i, isActive) => (
<div className="flex items-center gap-2 px-3 py-2 lg:gap-3 lg:p-3">
{/* Icon */}
<div
className={`flex h-7 w-7 lg:h-9 lg:w-9 shrink-0 items-center justify-center rounded-lg transition-colors ${
isActive
? "bg-gold/20 text-gold-light"
: "bg-neutral-200/50 text-neutral-500 dark:bg-white/[0.06] dark:text-neutral-400"
}`}
>
{getIcon(item.icon)}
</div>
<h3 className="text-xl font-semibold text-white">
{item.name}
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-white/60 line-clamp-2">
{item.description}
</p>
{/* Hover arrow */}
<div className="mt-3 flex items-center gap-1.5 text-sm font-medium text-rose-400 opacity-0 translate-y-2 transition-all duration-300 group-hover:opacity-100 group-hover:translate-y-0">
<span>Подробнее</span>
<ArrowRight size={14} />
<div className="min-w-0">
<p
className={`text-xs lg:text-sm font-semibold truncate transition-colors ${
isActive
? "text-gold"
: "text-neutral-700 dark:text-neutral-300"
}`}
>
{item.name}
</p>
<p className="hidden lg:block text-xs text-neutral-500 dark:text-neutral-500 truncate">
{item.description}
</p>
</div>
</div>
</div>
</Reveal>
))}
)}
/>
</Reveal>
</div>
</div>
<ClassModal
classItem={selectedClass}
onClose={() => setSelectedClass(null)}
/>
</section>
);
}

View File

@@ -1,58 +1,54 @@
import { MapPin, Phone, Clock, Instagram } from "lucide-react";
import { siteContent } from "@/data/content";
import { BRAND } from "@/lib/constants";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { IconBadge } from "@/components/ui/IconBadge";
import type { ContactInfo } from "@/types/content";
export function Contact() {
const { contact } = siteContent;
interface ContactProps {
data: ContactInfo;
}
export function Contact({ data: contact }: ContactProps) {
return (
<section id="contact" className="relative section-padding bg-neutral-50 dark:bg-[#050505]">
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container grid items-start gap-12 lg:grid-cols-2">
<div className="section-container grid items-center gap-12 lg:grid-cols-2">
<Reveal>
<SectionHeading>{contact.title}</SectionHeading>
<div className="mt-10 space-y-5">
{contact.addresses.map((address, i) => (
<div key={i} className="group flex items-center gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-50 text-rose-600 transition-colors group-hover:bg-rose-100 dark:bg-rose-500/10 dark:text-rose-400 dark:group-hover:bg-rose-500/15">
<MapPin size={18} />
</div>
<IconBadge><MapPin size={18} /></IconBadge>
<p className="body-text">{address}</p>
</div>
))}
<div className="group flex items-center gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-50 text-rose-600 transition-colors group-hover:bg-rose-100 dark:bg-rose-500/10 dark:text-rose-400 dark:group-hover:bg-rose-500/15">
<Phone size={18} />
</div>
<IconBadge><Phone size={18} /></IconBadge>
<a
href={`tel:${contact.phone}`}
className="text-neutral-600 transition-colors hover:text-rose-600 dark:text-neutral-400 dark:hover:text-rose-400"
className="text-neutral-600 transition-colors hover:text-gold-dark dark:text-neutral-300 dark:hover:text-gold-light"
>
{contact.phone}
</a>
</div>
<div className="group flex items-center gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-50 text-rose-600 transition-colors group-hover:bg-rose-100 dark:bg-rose-500/10 dark:text-rose-400 dark:group-hover:bg-rose-500/15">
<Clock size={18} />
</div>
<IconBadge><Clock size={18} /></IconBadge>
<p className="body-text">{contact.workingHours}</p>
</div>
<div className="border-t border-neutral-200 pt-5 dark:border-white/[0.06]">
<div className="border-t border-neutral-200 pt-5 dark:border-white/[0.08]">
<div className="group flex items-center gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-50 text-rose-600 transition-colors group-hover:bg-rose-100 dark:bg-rose-500/10 dark:text-rose-400 dark:group-hover:bg-rose-500/15">
<Instagram size={18} />
</div>
<IconBadge><Instagram size={18} /></IconBadge>
<a
href={contact.instagram}
target="_blank"
rel="noopener noreferrer"
className="text-neutral-600 transition-colors hover:text-rose-600 dark:text-neutral-400 dark:hover:text-rose-400"
className="text-neutral-600 transition-colors hover:text-gold-dark dark:text-neutral-300 dark:hover:text-gold-light"
>
{BRAND.instagramHandle}
</a>
@@ -62,7 +58,7 @@ export function Contact() {
</Reveal>
<Reveal>
<div className="overflow-hidden rounded-2xl border border-neutral-200 shadow-sm dark:border-white/[0.06] dark:shadow-[0_0_30px_rgba(225,29,72,0.05)]">
<div className="overflow-hidden rounded-2xl border border-neutral-200 shadow-sm dark:border-white/[0.08] dark:shadow-[0_0_30px_rgba(201,169,110,0.05)]">
<iframe
src={contact.mapEmbedUrl}
width="100%"
@@ -71,6 +67,7 @@ export function Contact() {
allowFullScreen
loading="lazy"
title="Карта"
className="dark:invert dark:hue-rotate-180 dark:brightness-95 dark:contrast-90"
/>
</div>
</Reveal>

View File

@@ -0,0 +1,114 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { UI_CONFIG } from "@/lib/config";
import type { SiteContent } from "@/types/content";
const VISIBLE_COUNT = UI_CONFIG.faq.visibleCount;
interface FAQProps {
data: SiteContent["faq"];
}
export function FAQ({ data: faq }: FAQProps) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [expanded, setExpanded] = useState(false);
function toggle(index: number) {
setOpenIndex(openIndex === index ? null : index);
}
const visibleItems = expanded ? faq.items : faq.items.slice(0, VISIBLE_COUNT);
const hasMore = faq.items.length > VISIBLE_COUNT;
return (
<section id="faq" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]">
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container">
<Reveal>
<SectionHeading centered>{faq.title}</SectionHeading>
</Reveal>
<div className="mx-auto mt-12 max-w-3xl space-y-2.5">
{visibleItems.map((item, idx) => {
const isOpen = openIndex === idx;
return (
<Reveal key={idx}>
<div
className={`rounded-xl border transition-all duration-300 ${
isOpen
? "border-gold/30 bg-gradient-to-br from-gold/[0.06] via-transparent to-gold/[0.03] shadow-md shadow-gold/5"
: "border-neutral-200 bg-white hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]"
}`}
>
<button
onClick={() => toggle(idx)}
className="flex w-full items-center gap-3 px-5 py-4 text-left cursor-pointer"
>
{/* Number badge */}
<span
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-bold transition-colors duration-300 ${
isOpen
? "bg-gold text-black"
: "bg-gold/10 text-gold-dark dark:text-gold-light"
}`}
>
{idx + 1}
</span>
<span className="flex-1 text-sm sm:text-base font-medium text-neutral-900 dark:text-white leading-snug">
{item.question}
</span>
<ChevronDown
size={16}
className={`shrink-0 transition-all duration-300 ${
isOpen ? "text-gold rotate-180" : "text-neutral-400 dark:text-neutral-500"
}`}
/>
</button>
<div
className={`grid transition-all duration-300 ease-out ${
isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
}`}
>
<div className="overflow-hidden">
<div className="px-5 pb-4 pl-14 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400 whitespace-pre-line">
{item.answer}
</div>
</div>
</div>
</div>
</Reveal>
);
})}
{/* Show more / less */}
{hasMore && (
<Reveal>
<div className="pt-2 text-center">
<button
onClick={() => {
setExpanded(!expanded);
if (expanded) setOpenIndex(null);
}}
className="inline-flex items-center gap-1.5 rounded-full border border-neutral-200 bg-white px-5 py-2 text-sm font-medium text-neutral-600 transition-all hover:border-gold/40 hover:text-gold dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-neutral-400 dark:hover:border-gold/30 dark:hover:text-gold cursor-pointer"
>
{expanded ? "Скрыть" : `Ещё ${faq.items.length - VISIBLE_COUNT} вопросов`}
<ChevronDown
size={14}
className={`transition-transform duration-300 ${expanded ? "rotate-180" : ""}`}
/>
</button>
</div>
</Reveal>
)}
</div>
</div>
</section>
);
}

View File

@@ -1,17 +1,75 @@
"use client";
import Image from "next/image";
import { siteContent } from "@/data/content";
import { BRAND } from "@/lib/constants";
import { useEffect, useRef, useCallback } from "react";
import { Button } from "@/components/ui/Button";
import { FloatingHearts } from "@/components/ui/FloatingHearts";
import { ChevronDown } from "lucide-react";
import { HeroLogo } from "@/components/ui/HeroLogo";
import type { SiteContent } from "@/types/content";
export function Hero() {
const { hero } = siteContent;
interface HeroProps {
data: SiteContent["hero"];
}
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 (
<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 */}
<div className="hero-bg-gradient absolute inset-0" />
@@ -24,7 +82,7 @@ export function Hero() {
top: "-10%",
left: "50%",
transform: "translateX(-50%)",
background: "radial-gradient(circle, rgba(225, 29, 72, 0.12), transparent 70%)",
background: "radial-gradient(circle, rgba(201, 169, 110, 0.12), transparent 70%)",
}}
/>
<div
@@ -34,7 +92,7 @@ export function Hero() {
height: "300px",
bottom: "10%",
right: "10%",
background: "radial-gradient(circle, rgba(225, 29, 72, 0.08), transparent 70%)",
background: "radial-gradient(circle, rgba(201, 169, 110, 0.08), transparent 70%)",
animationDelay: "3s",
}}
/>
@@ -44,56 +102,32 @@ export function Hero() {
{/* Content */}
<div className="section-container relative z-10 text-center">
<div className="hero-logo relative mx-auto mb-10 h-[220px] w-[220px]">
{/* Outer ambient glow */}
<div className="absolute -inset-16 rounded-full bg-rose-500/8 blur-[60px]" />
{/* Rose disc — makes black heart visible as silhouette */}
<div
className="absolute inset-2 rounded-full"
style={{
background: "radial-gradient(circle, rgba(225,29,72,0.45) 0%, rgba(225,29,72,0.18) 45%, transparent 70%)",
}}
/>
<Image
src="/images/logo.png"
alt={BRAND.name}
width={220}
height={220}
priority
unoptimized
className="relative"
style={{
filter:
"drop-shadow(0 0 6px rgba(225,29,72,0.5)) drop-shadow(0 0 20px rgba(225,29,72,0.25))",
}}
/>
<div className="hero-logo relative mx-auto mb-10 flex items-center justify-center" style={{ width: 220, height: 181 }}>
{/* Soft ambient glow behind heart */}
<div className="absolute -inset-10 rounded-full blur-[80px]" style={{ background: "radial-gradient(circle, rgba(201,169,110,0.25), transparent 70%)" }} />
<div className="hero-logo-heartbeat relative">
<HeroLogo
size={220}
className="drop-shadow-[0_0_10px_rgba(201,169,110,0.35)] drop-shadow-[0_0_40px_rgba(201,169,110,0.15)]"
/>
</div>
</div>
<h1 className="hero-title font-display text-5xl font-bold tracking-tight sm:text-6xl lg:text-8xl">
<span className="gradient-text">{hero.headline}</span>
</h1>
<p className="hero-subtitle mx-auto mt-6 max-w-lg text-lg text-neutral-400 sm:text-xl">
<p className="hero-subtitle mx-auto mt-6 max-w-lg text-lg text-[#b8a080] sm:text-xl">
{hero.subheadline}
</p>
<div className="hero-cta mt-12">
<Button href={hero.ctaHref} size="lg">
<Button size="lg" onClick={() => window.dispatchEvent(new Event("open-booking"))}>
{hero.ctaText}
</Button>
</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-rose-400"
>
<span className="text-xs uppercase tracking-widest">Scroll</span>
<ChevronDown size={20} className="animate-bounce" />
</a>
</div>
</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

@@ -0,0 +1,227 @@
"use client";
import { useState } from "react";
import { CreditCard, Building2, ScrollText, Crown, Sparkles, Instagram, Send, Phone } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { BRAND } from "@/lib/constants";
import type { SiteContent } from "@/types/content";
type Tab = "prices" | "rental" | "rules";
interface PricingProps {
data: SiteContent["pricing"];
}
function ContactHint() {
return (
<div className="mt-5 flex flex-wrap items-center justify-center gap-3 text-xs text-neutral-500">
<span>Для записи и бронирования:</span>
<a
href={BRAND.instagram}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-white/[0.06] px-3 py-1.5 text-pink-400 hover:text-pink-300 hover:border-pink-400/30 transition-colors"
>
<Instagram size={12} />
Instagram
</a>
<a
href="https://t.me/blackheartdancehouse"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-full border border-white/[0.06] px-3 py-1.5 text-blue-400 hover:text-blue-300 hover:border-blue-400/30 transition-colors"
>
<Send size={12} />
Telegram
</a>
<a
href="tel:+375293897001"
className="inline-flex items-center gap-1 rounded-full border border-white/[0.06] px-3 py-1.5 text-emerald-400 hover:text-emerald-300 hover:border-emerald-400/30 transition-colors"
>
<Phone size={12} />
Позвонить
</a>
</div>
);
}
export function Pricing({ data: pricing }: PricingProps) {
const [activeTab, setActiveTab] = useState<Tab>("prices");
const showHint = pricing.showContactHint !== false; // default true
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "prices", label: "Абонементы", icon: <CreditCard size={16} /> },
{ id: "rental", label: "Аренда зала", icon: <Building2 size={16} /> },
{ id: "rules", label: "Правила", icon: <ScrollText size={16} /> },
];
// Split items: featured (big card) vs regular
const featuredItem = pricing.items.find((item) => item.featured);
const regularItems = pricing.items.filter((item) => !item.featured);
return (
<section id="pricing" className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505]">
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container">
<Reveal>
<SectionHeading centered>{pricing.title}</SectionHeading>
</Reveal>
{/* Tabs */}
<Reveal>
<div className="mt-12 flex flex-wrap justify-center gap-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`inline-flex items-center gap-2 rounded-full px-6 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
activeTab === tab.id
? "bg-gold text-black shadow-lg shadow-gold/25"
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-white/[0.06] dark:text-neutral-300 dark:hover:bg-white/[0.1]"
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
</Reveal>
{/* Prices tab */}
{activeTab === "prices" && (
<Reveal>
<div className="mx-auto mt-10 max-w-4xl">
<p className="mb-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
{pricing.subtitle}
</p>
{/* Cards grid */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{regularItems.map((item, i) => {
const isPopular = item.popular ?? false;
return (
<div
key={i}
className={`group relative rounded-2xl border p-5 transition-all duration-300 ${
isPopular
? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10"
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a]"
}`}
>
{/* Popular badge */}
{isPopular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="inline-flex items-center gap-1 rounded-full bg-gold px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-black shadow-md shadow-gold/30">
<Sparkles size={10} />
Популярный
</span>
</div>
)}
<div className={isPopular ? "mt-1" : ""}>
{/* Name */}
<p className={`text-sm font-medium ${isPopular ? "text-gold-dark dark:text-gold-light" : "text-neutral-700 dark:text-neutral-300"}`}>
{item.name}
</p>
{/* Note */}
{item.note && (
<p className="mt-1 text-xs text-neutral-400 dark:text-neutral-500">
{item.note}
</p>
)}
{/* Price */}
<p className={`mt-3 font-display text-2xl font-bold ${isPopular ? "text-gold" : "text-neutral-900 dark:text-white"}`}>
{item.price}
</p>
</div>
</div>
);
})}
</div>
{/* Featured — big card */}
{featuredItem && (
<div className="mt-6 w-full team-card-glitter rounded-2xl border border-gold/30 bg-gradient-to-r from-gold/10 via-gold/5 to-gold/10 dark:from-gold/[0.06] dark:via-transparent dark:to-gold/[0.06] p-6 sm:p-8">
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
<div className="text-center sm:text-left">
<div className="flex items-center justify-center gap-2 sm:justify-start">
<Crown size={18} className="text-gold" />
<p className="text-lg font-bold text-neutral-900 dark:text-white">
{featuredItem.name}
</p>
</div>
{featuredItem.note && (
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{featuredItem.note}
</p>
)}
</div>
<p className="shrink-0 font-display text-3xl font-bold text-gold">
{featuredItem.price}
</p>
</div>
</div>
)}
{showHint && <ContactHint />}
</div>
</Reveal>
)}
{/* Rental tab */}
{activeTab === "rental" && (
<Reveal>
<div className="mx-auto mt-10 max-w-2xl space-y-3">
{pricing.rentalItems.map((item, i) => (
<div
key={i}
className="flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
>
<div>
<p className="font-medium text-neutral-900 dark:text-white">
{item.name}
</p>
{item.note && (
<p className="mt-0.5 text-sm text-neutral-500 dark:text-neutral-400">
{item.note}
</p>
)}
</div>
<span className="shrink-0 font-display text-xl font-bold text-gold-dark dark:text-gold-light">
{item.price}
</span>
</div>
))}
{showHint && <ContactHint />}
</div>
</Reveal>
)}
{/* Rules tab */}
{activeTab === "rules" && (
<Reveal>
<div className="mx-auto mt-10 max-w-2xl space-y-3">
{pricing.rules.map((rule, i) => (
<div
key={i}
className="flex gap-4 rounded-2xl border border-neutral-200 bg-white px-5 py-4 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
>
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
{i + 1}
</span>
<p className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
{rule}
</p>
</div>
))}
</div>
</Reveal>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,410 @@
"use client";
import { useReducer, useMemo, useCallback } from "react";
import { SignupModal } from "@/components/ui/SignupModal";
import { CalendarDays, Users, LayoutGrid } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { DayCard } from "./schedule/DayCard";
import { ScheduleFilters } from "./schedule/ScheduleFilters";
import { MobileSchedule } from "./schedule/MobileSchedule";
import { GroupView } from "./schedule/GroupView";
import { buildTypeDots, shortAddress, startTimeMinutes, TIME_PRESETS } from "./schedule/constants";
import type { StatusFilter, TimeFilter, ScheduleDayMerged, ScheduleClassWithLocation } from "./schedule/constants";
import type { SiteContent } from "@/types/content";
type ViewMode = "days" | "groups";
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 {
data: SiteContent["schedule"];
classItems?: { name: string; color?: string }[];
}
export function Schedule({ data: schedule, classItems }: ScheduleProps) {
const [state, dispatch] = useReducer(scheduleReducer, initialState);
const { locationMode, viewMode, filterTrainer, filterType, filterStatus, filterTime, filterDaySet, bookingGroup } = state;
const isAllMode = locationMode === "all";
const scrollToSchedule = useCallback(() => {
const el = document.getElementById("schedule");
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) => {
dispatch({ type: "SET_TRAINER", value: trainer });
if (trainer) scrollToSchedule();
}, [scrollToSchedule]);
const setFilterTypeFromCard = useCallback((type: string | null) => {
dispatch({ type: "SET_TYPE", value: type });
if (type) scrollToSchedule();
}, [scrollToSchedule]);
const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]);
// Build days: either from one location or merged from all
const activeDays: ScheduleDayMerged[] = useMemo(() => {
if (locationMode !== "all") {
const loc = schedule.locations[locationMode];
if (!loc) return [];
return loc.days.map((day) => ({
...day,
classes: day.classes.map((cls) => ({ ...cls })),
}));
}
// Merge all locations by weekday
const dayOrder = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"];
const dayMap = new Map<string, ScheduleDayMerged>();
for (const loc of schedule.locations) {
for (const day of loc.days) {
const existing = dayMap.get(day.day);
const taggedClasses: ScheduleClassWithLocation[] = day.classes.map((cls) => ({
...cls,
locationName: loc.name,
locationAddress: loc.address,
}));
if (existing) {
existing.classes = [...existing.classes, ...taggedClasses];
} else {
dayMap.set(day.day, {
day: day.day,
dayShort: day.dayShort,
classes: taggedClasses,
});
}
}
}
// Sort by weekday order
return dayOrder
.filter((d) => dayMap.has(d))
.map((d) => dayMap.get(d)!);
}, [locationMode, schedule.locations]);
const { types, hasAnySlots, hasAnyRecruiting } = useMemo(() => {
const typeSet = new Set<string>();
let slots = false;
let recruiting = false;
for (const day of activeDays) {
for (const cls of day.classes) {
typeSet.add(cls.type);
if (cls.hasSlots) slots = true;
if (cls.recruiting) recruiting = true;
}
}
return {
types: Array.from(typeSet).sort(),
hasAnySlots: slots,
hasAnyRecruiting: recruiting,
};
}, [activeDays]);
// Get the time range for the active time filter
const activeTimeRange = filterTime !== "all"
? TIME_PRESETS.find((p) => p.value === filterTime)?.range
: null;
const filteredDays: ScheduleDayMerged[] = useMemo(() => {
const noFilter = !filterTrainer && !filterType && filterStatus === "all" && filterTime === "all" && filterDaySet.size === 0;
if (noFilter) return activeDays;
// First filter by day names if any selected
const dayFiltered = filterDaySet.size > 0
? activeDays.filter((day) => filterDaySet.has(day.day))
: activeDays;
return dayFiltered
.map((day) => ({
...day,
classes: day.classes.filter(
(cls) =>
(!filterTrainer || cls.trainer === filterTrainer) &&
(!filterType || cls.type === filterType) &&
(filterStatus === "all" ||
(filterStatus === "hasSlots" && cls.hasSlots) ||
(filterStatus === "recruiting" && cls.recruiting)) &&
(!activeTimeRange || (() => {
const m = startTimeMinutes(cls.time);
return m >= activeTimeRange[0] && m < activeTimeRange[1];
})())
),
}))
.filter((day) => day.classes.length > 0);
}, [activeDays, filterTrainer, filterType, filterStatus, filterTime, activeTimeRange, filterDaySet]);
const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all" || filterTime !== "all" || filterDaySet.size > 0);
function clearFilters() {
dispatch({ type: "CLEAR_FILTERS" });
}
// Available days for the day filter
const availableDays = useMemo(() =>
activeDays.map((d) => ({ day: d.day, dayShort: d.dayShort })),
[activeDays]
);
function toggleDay(day: string) {
dispatch({ type: "TOGGLE_DAY", day });
}
function switchLocation(mode: LocationMode) {
dispatch({ type: "SET_LOCATION", mode });
}
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 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";
return (
<section
id="schedule"
className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505] overflow-hidden"
>
<div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container">
<Reveal>
<SectionHeading centered>{schedule.title}</SectionHeading>
</Reveal>
{/* Location tabs */}
<Reveal>
<div className="mt-8 flex justify-center gap-2 flex-wrap">
{/* "All studios" tab */}
<button
onClick={() => switchLocation("all")}
className={`inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
isAllMode ? activeTabClass : inactiveTabClass
}`}
>
<LayoutGrid size={14} />
<span className="hidden sm:inline">Все студии</span>
<span className="sm:hidden">Все</span>
</button>
{/* Per-location tabs */}
{schedule.locations.map((loc, i) => (
<button
key={loc.name}
onClick={() => switchLocation(i)}
className={`inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
locationMode === i ? activeTabClass : inactiveTabClass
}`}
>
<span className="text-center">
<span className="block leading-tight">{loc.name}</span>
{loc.address && (
<span className={`block text-[10px] font-normal leading-tight mt-0.5 ${
locationMode === i ? "text-black/60" : "text-neutral-400 dark:text-white/25"
}`}>
{shortAddress(loc.address)}
</span>
)}
</span>
</button>
))}
</div>
</Reveal>
{/* View mode toggle */}
<Reveal>
<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]">
<button
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 ${
viewMode === "days"
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
: "text-neutral-500 hover:text-neutral-700 dark:text-white/35 dark:hover:text-white/60"
}`}
>
<CalendarDays size={13} />
По дням
</button>
<button
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 ${
viewMode === "groups"
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
: "text-neutral-500 hover:text-neutral-700 dark:text-white/35 dark:hover:text-white/60"
}`}
>
<Users size={13} />
По группам
</button>
</div>
</div>
</Reveal>
{/* Compact filters — desktop only */}
<Reveal>
<ScheduleFilters
typeDots={typeDots}
types={types}
hasAnySlots={hasAnySlots}
hasAnyRecruiting={hasAnyRecruiting}
filterType={filterType}
setFilterType={setFilterType}
filterTrainer={filterTrainer}
filterStatus={filterStatus}
setFilterStatus={setFilterStatus}
filterTime={filterTime}
setFilterTime={setFilterTime}
availableDays={availableDays}
filterDaySet={filterDaySet}
toggleDay={toggleDay}
hasActiveFilter={hasActiveFilter}
clearFilters={clearFilters}
/>
</Reveal>
</div>
{viewMode === "days" ? (
<>
{/* Mobile: compact agenda list with tap-to-filter */}
<Reveal>
<MobileSchedule
typeDots={typeDots}
filteredDays={filteredDays}
filterType={filterType}
setFilterType={setFilterTypeFromCard}
filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainerFromCard}
hasActiveFilter={hasActiveFilter}
clearFilters={clearFilters}
showLocation={isAllMode}
/>
</Reveal>
{/* Desktop: grid layout */}
<Reveal>
<div
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={gridLayout.style}
>
{filteredDays.map((day) => (
<div
key={day.day}
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""}
>
<DayCard day={day} typeDots={typeDots} showLocation={isAllMode} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainerFromCard} filterType={filterType} setFilterType={setFilterTypeFromCard} />
</div>
))}
{filteredDays.length === 0 && (
<div className="col-span-full py-12 text-center text-sm text-neutral-400 dark:text-white/30">
Нет занятий по выбранным фильтрам
</div>
)}
</div>
</Reveal>
</>
) : (
/* Group view: classes clustered by trainer+type */
<Reveal>
<GroupView
typeDots={typeDots}
filteredDays={filteredDays}
filterType={filterType}
setFilterType={setFilterTypeFromCard}
filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainerFromCard}
showLocation={isAllMode}
onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })}
/>
</Reveal>
)}
<SignupModal
open={bookingGroup !== null}
onClose={() => dispatch({ type: "SET_BOOKING", value: null })}
subtitle={bookingGroup ?? undefined}
endpoint="/api/group-booking"
extraBody={{ groupInfo: bookingGroup }}
/>
</section>
);
}

View File

@@ -1,195 +1,72 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import Image from "next/image";
import { Instagram, ChevronLeft, ChevronRight } from "lucide-react";
import { siteContent } from "@/data/content";
import { useState } from "react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { TeamMemberModal } from "@/components/ui/TeamMemberModal";
import type { TeamMember } from "@/types";
import { TeamCarousel } from "@/components/sections/team/TeamCarousel";
import { TeamMemberInfo } from "@/components/sections/team/TeamMemberInfo";
import { TeamProfile } from "@/components/sections/team/TeamProfile";
import type { SiteContent, ScheduleLocation } from "@/types/content";
export function Team() {
const { team } = siteContent;
const [selectedMember, setSelectedMember] = useState<TeamMember | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollTimer = useRef<ReturnType<typeof setTimeout>>(null);
const isDragging = useRef(false);
const dragStartX = useRef(0);
const dragScrollLeft = useRef(0);
const dragMoved = useRef(false);
interface TeamProps {
data: SiteContent["team"];
schedule?: ScheduleLocation[];
}
// Render 3 copies: [clone] [original] [clone]
const tripled = [...team.members, ...team.members, ...team.members];
// On mount, jump to the middle set (no animation)
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
requestAnimationFrame(() => {
const cardWidth = el.scrollWidth / 3;
el.scrollLeft = cardWidth;
});
}, []);
// When scroll settles, check if we need to loop
const handleScroll = useCallback(() => {
if (scrollTimer.current) clearTimeout(scrollTimer.current);
scrollTimer.current = setTimeout(() => {
const el = scrollRef.current;
if (!el) return;
const oneSetWidth = el.scrollWidth / 3;
if (el.scrollLeft < oneSetWidth * 0.3) {
el.style.scrollBehavior = "auto";
el.scrollLeft += oneSetWidth;
el.style.scrollBehavior = "";
}
if (el.scrollLeft > oneSetWidth * 1.7) {
el.style.scrollBehavior = "auto";
el.scrollLeft -= oneSetWidth;
el.style.scrollBehavior = "";
}
}, 100);
}, []);
// Mouse drag handlers
function handleMouseDown(e: React.MouseEvent) {
const el = scrollRef.current;
if (!el) return;
isDragging.current = true;
dragMoved.current = false;
dragStartX.current = e.pageX;
dragScrollLeft.current = el.scrollLeft;
el.style.scrollBehavior = "auto";
el.style.scrollSnapType = "none";
el.style.cursor = "grabbing";
}
function handleMouseMove(e: React.MouseEvent) {
if (!isDragging.current || !scrollRef.current) return;
e.preventDefault();
const dx = e.pageX - dragStartX.current;
if (Math.abs(dx) > 3) dragMoved.current = true;
scrollRef.current.scrollLeft = dragScrollLeft.current - dx;
}
function handleMouseUp() {
if (!isDragging.current || !scrollRef.current) return;
isDragging.current = false;
scrollRef.current.style.scrollBehavior = "";
scrollRef.current.style.scrollSnapType = "";
scrollRef.current.style.cursor = "";
}
function handleCardClick(member: TeamMember) {
// Don't open modal if user was dragging
if (dragMoved.current) return;
setSelectedMember(member);
}
function scroll(direction: "left" | "right") {
if (!scrollRef.current) return;
const amount = scrollRef.current.offsetWidth * 0.7;
scrollRef.current.scrollBy({
left: direction === "left" ? -amount : amount,
behavior: "smooth",
});
}
export function Team({ data: team, schedule }: TeamProps) {
const [activeIndex, setActiveIndex] = useState(0);
const [showProfile, setShowProfile] = useState(false);
return (
<section id="team" className="relative section-padding bg-neutral-50 dark:bg-[#050505]">
<section
id="team"
className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505] overflow-hidden"
>
<div className="section-divider absolute top-0 left-0 right-0" />
{/* Stage spotlight glow */}
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(ellipse 50% 70% at 50% 30%, rgba(201,169,110,0.07) 0%, transparent 70%)",
}}
/>
<div className="section-container">
<Reveal>
<SectionHeading>{team.title}</SectionHeading>
<SectionHeading centered>{team.title}</SectionHeading>
</Reveal>
</div>
{/* Carousel wrapper */}
<Reveal>
<div className="relative mt-10">
{/* Scroll container */}
<div
ref={scrollRef}
onScroll={handleScroll}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
className="flex cursor-grab gap-4 overflow-x-auto px-6 pb-4 sm:px-8 scroll-smooth snap-x snap-mandatory select-none lg:px-[max(2rem,calc((100vw-72rem)/2+2rem))]"
style={{ scrollbarWidth: "none" }}
>
{tripled.map((member, i) => (
<div
key={`${i}-${member.name}`}
className="group relative w-[220px] shrink-0 cursor-pointer snap-start overflow-hidden rounded-2xl sm:w-[260px]"
onClick={() => handleCardClick(member)}
>
{/* Photo */}
<div className="aspect-[3/4] w-full overflow-hidden">
<Image
src={member.image}
alt={member.name}
width={260}
height={347}
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
/>
</div>
<div className="mt-10 px-4 sm:px-6">
{!showProfile ? (
<>
<TeamCarousel
members={team.members}
activeIndex={activeIndex}
onActiveChange={setActiveIndex}
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-80 transition-opacity duration-500 group-hover:opacity-100" />
{/* Rose glow on hover */}
<div className="absolute inset-0 bg-gradient-to-t from-rose-900/20 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
{/* Content */}
<div className="absolute bottom-0 left-0 right-0 p-4 translate-y-1 transition-transform duration-500 group-hover:translate-y-0">
<h3 className="text-base font-semibold text-white sm:text-lg">
{member.name}
</h3>
{member.instagram && (
<span
className="mt-1 inline-flex items-center gap-1.5 text-xs text-white/60 transition-colors hover:text-rose-400 sm:text-sm"
onClick={(e) => e.stopPropagation()}
>
<Instagram size={12} className="shrink-0" />
<a
href={member.instagram}
target="_blank"
rel="noopener noreferrer"
>
{member.instagram.split("/").filter(Boolean).pop()}
</a>
</span>
)}
</div>
<div className="mx-auto max-w-6xl">
<TeamMemberInfo
members={team.members}
activeIndex={activeIndex}
onSelect={setActiveIndex}
onOpenBio={() => setShowProfile(true)}
/>
</div>
))}
</div>
{/* Side navigation arrows */}
<button
onClick={() => scroll("left")}
className="absolute left-2 top-1/2 -translate-y-1/2 hidden h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white/80 backdrop-blur-sm transition-all hover:bg-rose-500/30 hover:text-white sm:flex"
aria-label="Назад"
>
<ChevronLeft size={22} />
</button>
<button
onClick={() => scroll("right")}
className="absolute right-2 top-1/2 -translate-y-1/2 hidden h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white/80 backdrop-blur-sm transition-all hover:bg-rose-500/30 hover:text-white sm:flex"
aria-label="Вперёд"
>
<ChevronRight size={22} />
</button>
</>
) : (
<TeamProfile
member={team.members[activeIndex]}
onBack={() => setShowProfile(false)}
schedule={schedule}
/>
)}
</div>
</Reveal>
<TeamMemberModal
member={selectedMember}
onClose={() => setSelectedMember(null)}
/>
</section>
);
}

View File

@@ -0,0 +1,142 @@
import { Clock, User, MapPin } from "lucide-react";
import { shortAddress } from "./constants";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
interface DayCardProps {
day: ScheduleDayMerged;
typeDots: Record<string, string>;
showLocation?: boolean;
filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void;
filterType: string | null;
setFilterType: (type: string | null) => void;
}
function ClassRow({
cls,
typeDots,
filterTrainer,
setFilterTrainer,
filterType,
setFilterType,
}: {
cls: ScheduleClassWithLocation;
typeDots: Record<string, string>;
filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void;
filterType: string | null;
setFilterType: (type: string | null) => void;
}) {
return (
<div className={`px-5 py-3.5 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-neutral-500 dark:text-white/40">
<Clock size={13} />
<span className="font-semibold">{cls.time}</span>
</div>
{cls.hasSlots && (
<span className="shrink-0 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>
)}
{cls.recruiting && (
<span className="shrink-0 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>
)}
</div>
<button
onClick={() => setFilterTrainer(filterTrainer === cls.trainer ? null : cls.trainer)}
className={`mt-1.5 flex items-center gap-2 text-sm font-medium cursor-pointer active:opacity-60 ${
filterTrainer === cls.trainer
? "text-gold underline underline-offset-2"
: "text-neutral-800 dark:text-white/80"
}`}
>
<User size={13} className="shrink-0 text-neutral-400 dark:text-white/30" />
{cls.trainer}
</button>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<button
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)}
className="flex items-center gap-2 cursor-pointer active:opacity-60"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
<span className={`text-xs ${
filterType === cls.type
? "text-gold underline underline-offset-2"
: "text-neutral-500 dark:text-white/40"
}`}>{cls.type}</span>
</button>
{cls.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">
{cls.level}
</span>
)}
</div>
</div>
);
}
export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterTrainer, filterType, setFilterType }: DayCardProps) {
// Group classes by location when showLocation is true
const locationGroups = showLocation
? Array.from(
day.classes.reduce((map, cls) => {
const loc = cls.locationName ?? "";
if (!map.has(loc)) {
map.set(loc, { address: cls.locationAddress, classes: [] });
}
map.get(loc)!.classes.push(cls);
return map;
}, new Map<string, { address?: string; classes: ScheduleClassWithLocation[] }>())
)
: null;
return (
<div className="rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a] overflow-hidden">
{/* Day header */}
<div className="border-b border-neutral-100 bg-neutral-50 px-5 py-4 dark:border-white/[0.04] dark:bg-white/[0.02]">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gold/10 text-sm font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
{day.dayShort}
</span>
<span className="text-base font-semibold text-neutral-900 dark:text-white/90">
{day.day}
</span>
</div>
</div>
{/* Classes */}
{locationGroups ? (
// Split by location
<div>
{locationGroups.map(([locName, { address, classes }], gi) => (
<div key={locName}>
{/* Location sub-header */}
<div className={`flex items-center gap-1.5 px-5 py-2 bg-neutral-100/60 dark:bg-white/[0.03] ${gi > 0 ? "border-t border-neutral-200 dark:border-white/[0.06]" : ""}`}>
<MapPin size={11} className="shrink-0 text-neutral-400 dark:text-white/25" />
<span className="text-[11px] font-medium text-neutral-400 dark:text-white/30">
{locName}
{address && <span className="text-neutral-300 dark:text-white/15"> · {shortAddress(address)}</span>}
</span>
</div>
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
{classes.map((cls, i) => (
<ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterType={filterType} setFilterType={setFilterType} />
))}
</div>
</div>
))}
</div>
) : (
// Single location — no sub-headers
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
{day.classes.map((cls, i) => (
<ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterType={filterType} setFilterType={setFilterType} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,270 @@
"use client";
import { User, MapPin } from "lucide-react";
import { shortAddress } from "./constants";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
interface ScheduleGroup {
trainer: string;
type: string;
level?: string;
hasSlots: boolean;
recruiting: boolean;
location?: string;
locationAddress?: string;
slots: { day: string; dayShort: string; time: string }[];
}
function buildGroups(days: ScheduleDayMerged[]): ScheduleGroup[] {
const map = new Map<string, ScheduleGroup>();
for (const day of days) {
for (const cls of day.classes as ScheduleClassWithLocation[]) {
// Use groupId if available, otherwise fall back to trainer+type+location
const locPart = cls.locationName ?? "";
const key = cls.groupId
? `${cls.groupId}||${locPart}`
: `${cls.trainer}||${cls.type}||${locPart}`;
const existing = map.get(key);
if (existing) {
existing.slots.push({ day: day.day, dayShort: day.dayShort, time: cls.time });
if (cls.hasSlots) existing.hasSlots = true;
if (cls.recruiting) existing.recruiting = true;
if (cls.level && !existing.level) existing.level = cls.level;
} else {
map.set(key, {
trainer: cls.trainer,
type: cls.type,
level: cls.level,
hasSlots: !!cls.hasSlots,
recruiting: !!cls.recruiting,
location: cls.locationName,
locationAddress: cls.locationAddress,
slots: [{ day: day.day, dayShort: day.dayShort, time: cls.time }],
});
}
}
}
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 {
typeDots: Record<string, string>;
filteredDays: ScheduleDayMerged[];
filterType: string | null;
setFilterType: (type: string | null) => void;
filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void;
showLocation?: boolean;
onBook?: (groupInfo: string) => void;
}
export function GroupView({
typeDots,
filteredDays,
filterType,
setFilterType,
filterTrainer,
setFilterTrainer,
showLocation,
onBook,
}: GroupViewProps) {
const groups = buildGroups(filteredDays);
const byTrainer = groupByTrainer(groups);
if (groups.length === 0) {
return (
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">
Нет занятий по выбранным фильтрам
</div>
);
}
return (
<div className="mt-8 space-y-3 px-4 sm:px-6 lg:px-8 xl:px-6 max-w-4xl mx-auto">
{Array.from(byTrainer.entries()).map(([trainer, trainerGroups]) => {
const byType = groupByType(trainerGroups);
const totalGroups = trainerGroups.length;
return (
<div
key={trainer}
className="rounded-xl border border-neutral-200 bg-white overflow-hidden dark:border-white/[0.06] dark:bg-[#0a0a0a]"
>
{/* Trainer header */}
<button
onClick={() => setFilterTrainer(filterTrainer === trainer ? null : trainer)}
className={`flex items-center gap-2 w-full px-4 py-2.5 text-left transition-colors cursor-pointer ${
filterTrainer === trainer
? "bg-gold/10 dark:bg-gold/5"
: "bg-neutral-50 dark:bg-white/[0.02]"
}`}
>
<User size={14} className={filterTrainer === trainer ? "text-gold" : "text-neutral-400 dark:text-white/40"} />
<span className={`text-sm font-semibold ${
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>
</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 && (
<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}
</span>
)}
{group.hasSlots && (
<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>
)}
{group.recruiting && (
<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>
)}
{/* Location */}
{showLocation && group.location && (
<span className="flex items-center gap-1 text-[10px] text-neutral-400 dark:text-white/25">
<MapPin size={9} />
{group.location}
</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>
);
}

View File

@@ -0,0 +1,212 @@
"use client";
import { User, X, MapPin } from "lucide-react";
import { shortAddress } from "./constants";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
interface MobileScheduleProps {
typeDots: Record<string, string>;
filteredDays: ScheduleDayMerged[];
filterType: string | null;
setFilterType: (type: string | null) => void;
filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void;
hasActiveFilter: boolean;
clearFilters: () => void;
showLocation?: boolean;
}
function ClassRow({
cls,
typeDots,
filterType,
setFilterType,
filterTrainer,
setFilterTrainer,
showLocation,
}: {
cls: ScheduleClassWithLocation;
typeDots: Record<string, string>;
filterType: string | null;
setFilterType: (type: string | null) => void;
filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void;
showLocation?: boolean;
}) {
return (
<div
className={`ml-3 flex items-start gap-3 rounded-lg px-3 py-2 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}
>
{/* Time */}
<span className="shrink-0 w-[72px] text-xs font-semibold tabular-nums text-neutral-500 dark:text-white/40 pt-0.5">
{cls.time}
</span>
{/* Info — tappable trainer & type */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<button
onClick={() => setFilterTrainer(filterTrainer === cls.trainer ? null : cls.trainer)}
className={`truncate text-sm font-medium text-left active:opacity-60 ${filterTrainer === cls.trainer ? "text-gold underline underline-offset-2" : "text-neutral-800 dark:text-white/80"}`}
>
{cls.trainer}
</button>
{cls.hasSlots && (
<span className="shrink-0 rounded-full bg-emerald-500/15 border border-emerald-500/25 px-1.5 py-px text-[9px] font-semibold text-emerald-600 dark:text-emerald-400">
места
</span>
)}
{cls.recruiting && (
<span className="shrink-0 rounded-full bg-sky-500/15 border border-sky-500/25 px-1.5 py-px text-[9px] font-semibold text-sky-600 dark:text-sky-400">
набор
</span>
)}
{cls.level && (
<span className="shrink-0 rounded-full bg-rose-500/15 border border-rose-500/25 px-1.5 py-px text-[9px] font-semibold text-rose-600 dark:text-rose-400">
{cls.level}
</span>
)}
</div>
<div className="mt-0.5 flex items-center gap-2">
<button
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)}
className={`flex items-center gap-1.5 active:opacity-60 ${filterType === cls.type ? "opacity-100" : ""}`}
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
<span className={`text-[11px] ${filterType === cls.type ? "text-gold underline underline-offset-2" : "text-neutral-400 dark:text-white/30"}`}>{cls.type}</span>
</button>
{showLocation && cls.locationName && (
<span className="flex items-center gap-0.5 text-[10px] text-neutral-400 dark:text-white/20">
<MapPin size={8} className="shrink-0" />
{cls.locationName}
</span>
)}
</div>
</div>
</div>
);
}
export function MobileSchedule({
typeDots,
filteredDays,
filterType,
setFilterType,
filterTrainer,
setFilterTrainer,
hasActiveFilter,
clearFilters,
showLocation,
}: MobileScheduleProps) {
return (
<div className="mt-6 px-4 sm:hidden">
{/* Active filter indicator */}
{hasActiveFilter && (
<div className="mb-3 flex items-center justify-between rounded-xl bg-gold/10 px-4 py-2.5 dark:bg-gold/5">
<div className="flex items-center gap-2 text-xs font-medium text-gold-dark dark:text-gold-light">
{filterTrainer && (
<span className="flex items-center gap-1">
<User size={11} />
{filterTrainer}
</span>
)}
{filterType && (
<span className="flex items-center gap-1">
<span className={`h-1.5 w-1.5 rounded-full ${typeDots[filterType] ?? "bg-white/30"}`} />
{filterType}
</span>
)}
</div>
<button
onClick={clearFilters}
className="flex items-center gap-1 rounded-full px-2 py-1 text-[11px] text-gold-dark dark:text-gold-light active:bg-gold/20"
>
<X size={12} />
Сбросить
</button>
</div>
)}
{filteredDays.length > 0 ? (
<div className="space-y-1">
{filteredDays.map((day) => {
// Group classes by location when showLocation is true
const locationGroups = showLocation
? Array.from(
day.classes.reduce((map, cls) => {
const loc = cls.locationName ?? "";
if (!map.has(loc)) {
map.set(loc, { address: cls.locationAddress, classes: [] });
}
map.get(loc)!.classes.push(cls);
return map;
}, new Map<string, { address?: string; classes: ScheduleClassWithLocation[] }>())
)
: null;
return (
<div key={day.day}>
{/* Day header */}
<div className="flex items-center gap-2.5 py-2.5">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
{day.dayShort}
</span>
<span className="text-sm font-semibold text-neutral-900 dark:text-white/90">
{day.day}
</span>
</div>
{/* Class rows */}
<div className="ml-1 border-l-2 border-neutral-200 dark:border-white/[0.08]">
{locationGroups ? (
// Split by location
locationGroups.map(([locName, { address, classes }]) => (
<div key={locName}>
{/* Location sub-header */}
<div className="ml-3 flex items-center gap-1 px-3 py-1.5">
<MapPin size={9} className="shrink-0 text-neutral-400 dark:text-white/20" />
<span className="text-[10px] font-medium text-neutral-400 dark:text-white/25">
{locName}
{address && <span className="text-neutral-300 dark:text-white/15"> · {shortAddress(address)}</span>}
</span>
</div>
{classes.map((cls, i) => (
<ClassRow
key={i}
cls={cls}
typeDots={typeDots}
filterType={filterType}
setFilterType={setFilterType}
filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainer}
/>
))}
</div>
))
) : (
// Single location — no sub-headers
day.classes.map((cls, i) => (
<ClassRow
key={i}
cls={cls}
typeDots={typeDots}
filterType={filterType}
setFilterType={setFilterType}
filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainer}
/>
))
)}
</div>
</div>
);
})}
</div>
) : (
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">
Нет занятий по выбранным фильтрам
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,154 @@
"use client";
import { useState } from "react";
import { User, X, ChevronDown, Clock, Calendar } from "lucide-react";
import {
pillBase,
pillActive,
pillInactive,
TIME_PRESETS,
type StatusFilter,
type TimeFilter,
} from "./constants";
interface ScheduleFiltersProps {
typeDots: Record<string, string>;
types: string[];
hasAnySlots: boolean;
hasAnyRecruiting: boolean;
filterType: string | null;
setFilterType: (type: string | null) => void;
filterTrainer: string | null;
filterStatus: StatusFilter;
setFilterStatus: (status: StatusFilter) => void;
filterTime: TimeFilter;
setFilterTime: (time: TimeFilter) => void;
availableDays: { day: string; dayShort: string }[];
filterDaySet: Set<string>;
toggleDay: (day: string) => void;
hasActiveFilter: boolean;
clearFilters: () => void;
}
export function ScheduleFilters({
typeDots,
types,
hasAnySlots,
hasAnyRecruiting,
filterType,
setFilterType,
filterTrainer,
filterStatus,
setFilterStatus,
filterTime,
setFilterTime,
availableDays,
filterDaySet,
toggleDay,
hasActiveFilter,
clearFilters,
}: ScheduleFiltersProps) {
const [showWhen, setShowWhen] = useState(false);
const hasTimeFilter = filterDaySet.size > 0 || filterTime !== "all";
return (
<>
{/* Single row: type + status + when + active trainer indicator + clear */}
<div className="mt-5 hidden sm:flex items-center justify-center gap-1.5 flex-wrap">
{/* Class types */}
{types.map((type) => (
<button
key={type}
onClick={() => setFilterType(filterType === type ? null : type)}
className={`${pillBase} ${filterType === type ? pillActive : pillInactive}`}
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[type] ?? "bg-white/30"}`} />
{type}
</button>
))}
{/* Divider */}
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
{/* Status filters */}
{hasAnySlots && (
<button
onClick={() => setFilterStatus(filterStatus === "hasSlots" ? "all" : "hasSlots")}
className={`${pillBase} ${filterStatus === "hasSlots" ? "bg-emerald-500/20 text-emerald-700 border border-emerald-500/40 dark:text-emerald-400 dark:border-emerald-500/30" : pillInactive}`}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
Есть места
</button>
)}
{hasAnyRecruiting && (
<button
onClick={() => setFilterStatus(filterStatus === "recruiting" ? "all" : "recruiting")}
className={`${pillBase} ${filterStatus === "recruiting" ? "bg-sky-500/20 text-sky-700 border border-sky-500/40 dark:text-sky-400 dark:border-sky-500/30" : pillInactive}`}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
Набор
</button>
)}
{/* Divider */}
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
{/* When dropdown toggle */}
<button
onClick={() => setShowWhen(!showWhen)}
className={`${pillBase} ${hasTimeFilter ? pillActive : pillInactive}`}
>
<Clock size={11} />
Когда
<ChevronDown size={10} className={`transition-transform duration-200 ${showWhen ? "rotate-180" : ""}`} />
</button>
{/* Active trainer indicator (set by clicking trainer in cards) */}
{filterTrainer && (
<span className={`${pillBase} ${pillActive}`}>
<User size={11} />
{filterTrainer}
</span>
)}
{/* Clear */}
{hasActiveFilter && (
<button
onClick={clearFilters}
className="inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[11px] text-neutral-400 hover:text-neutral-600 dark:text-white/25 dark:hover:text-white/50 transition-colors cursor-pointer"
>
<X size={11} />
</button>
)}
</div>
{/* When panel — expandable: days + time presets */}
{showWhen && (
<div className="mt-2 hidden sm:flex items-center justify-center gap-1.5 flex-wrap">
<Calendar size={11} className="text-neutral-400 dark:text-white/25" />
{availableDays.map(({ day, dayShort }) => (
<button
key={day}
onClick={() => toggleDay(day)}
className={`${pillBase} ${filterDaySet.has(day) ? pillActive : pillInactive}`}
>
{dayShort}
</button>
))}
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
{TIME_PRESETS.map((preset) => (
<button
key={preset.value}
onClick={() => setFilterTime(filterTime === preset.value ? "all" : preset.value)}
className={`${pillBase} ${filterTime === preset.value ? pillActive : pillInactive}`}
>
{preset.label}
</button>
))}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,111 @@
/** Hardcoded fallback — overridden by admin-chosen colors when available */
export const TYPE_DOT_FALLBACK: Record<string, string> = {
"Exotic Pole Dance": "bg-gold",
"Pole Dance": "bg-rose-500",
"Body Plastic": "bg-purple-500",
"Трюковые комбинации с пилоном": "bg-amber-500",
};
const COLOR_KEY_TO_DOT: Record<string, string> = {
rose: "bg-rose-500",
orange: "bg-orange-500",
amber: "bg-amber-500",
yellow: "bg-yellow-400",
lime: "bg-lime-500",
emerald: "bg-emerald-500",
teal: "bg-teal-500",
cyan: "bg-cyan-500",
sky: "bg-sky-500",
blue: "bg-blue-500",
indigo: "bg-indigo-500",
violet: "bg-violet-500",
purple: "bg-purple-500",
fuchsia: "bg-fuchsia-500",
pink: "bg-pink-500",
red: "bg-red-500",
};
const FALLBACK_DOTS = [
"bg-rose-500", "bg-orange-500", "bg-amber-500", "bg-yellow-400",
"bg-lime-500", "bg-emerald-500", "bg-teal-500", "bg-cyan-500",
"bg-sky-500", "bg-blue-500", "bg-indigo-500", "bg-violet-500",
"bg-purple-500", "bg-fuchsia-500", "bg-pink-500", "bg-red-500",
];
/** Build a type→dot map from class items with optional color field */
export function buildTypeDots(
classItems?: { name: string; color?: string }[]
): Record<string, string> {
if (!classItems?.length) return TYPE_DOT_FALLBACK;
const map: Record<string, string> = {};
const usedSlots = new Set<number>();
// First pass: explicit colors
classItems.forEach((item) => {
if (item.color && COLOR_KEY_TO_DOT[item.color]) {
map[item.name] = COLOR_KEY_TO_DOT[item.color];
const idx = FALLBACK_DOTS.indexOf(COLOR_KEY_TO_DOT[item.color]);
if (idx >= 0) usedSlots.add(idx);
}
});
// Second pass: assign remaining to unused slots
let nextSlot = 0;
classItems.forEach((item) => {
if (map[item.name]) return;
while (usedSlots.has(nextSlot) && nextSlot < FALLBACK_DOTS.length) nextSlot++;
map[item.name] = FALLBACK_DOTS[nextSlot % FALLBACK_DOTS.length];
usedSlots.add(nextSlot);
nextSlot++;
});
return map;
}
export type StatusFilter = "all" | "hasSlots" | "recruiting";
export type TimeFilter = "all" | "morning" | "afternoon" | "evening";
export const TIME_PRESETS: { value: TimeFilter; label: string; range: [number, number] }[] = [
{ value: "morning", label: "Утро", range: [0, 12 * 60] },
{ value: "afternoon", label: "День", range: [12 * 60, 18 * 60] },
{ value: "evening", label: "Вечер", range: [18 * 60, 24 * 60] },
];
/** Parse start time from "HH:MMHH:MM" to minutes since midnight */
export function startTimeMinutes(time: string): number {
const start = time.split("")[0]?.trim() ?? "";
const [h, m] = start.split(":").map(Number);
if (isNaN(h) || isNaN(m)) return 0;
return h * 60 + m;
}
/** Extended class type with location info for cross-location views */
export interface ScheduleClassWithLocation {
time: string;
trainer: string;
type: string;
level?: string;
hasSlots?: boolean;
recruiting?: boolean;
groupId?: string;
locationName?: string;
locationAddress?: string;
}
/** Strip "г. Минск, " prefix from address for compact display */
export function shortAddress(address: string): string {
return address.replace(/^г\.\s*Минск,?\s*/i, "").trim();
}
export interface ScheduleDayMerged {
day: string;
dayShort: string;
classes: ScheduleClassWithLocation[];
}
export const pillBase =
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-medium transition-all duration-200 cursor-pointer whitespace-nowrap";
export const pillActive =
"bg-gold/20 text-gold-dark border border-gold/40 dark:text-gold-light dark:border-gold/30";
export const pillInactive =
"border border-neutral-200 text-neutral-500 hover:border-neutral-300 dark:border-white/[0.08] dark:text-white/35 dark:hover:border-white/15";

View File

@@ -0,0 +1,249 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import Image from "next/image";
import { UI_CONFIG } from "@/lib/config";
import type { TeamMember } from "@/types/content";
const {
autoPlayMs: AUTO_PLAY_MS,
pauseMs: PAUSE_MS,
cardSpacing: CARD_SPACING,
} = UI_CONFIG.team;
function wrapIndex(i: number, total: number) {
return ((i % total) + total) % total;
}
function getDiff(index: number, active: number, total: number) {
let diff = index - active;
if (diff > total / 2) diff -= total;
if (diff < -total / 2) diff += total;
return diff;
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v));
}
// Slot properties for each position (0=center, 1=near, 2=mid, 3=far, 4=hidden)
const SLOTS = [
{ w: 280, h: 400, opacity: 1, scale: 1, x: 0, brightness: 1, grayscale: 0, z: 10, border: true },
{ w: 220, h: 340, opacity: 0.8, scale: 0.97, x: 260, brightness: 0.6, grayscale: 0.2, z: 5, border: false },
{ w: 180, h: 280, opacity: 0.6, scale: 0.93, x: 470, brightness: 0.45, grayscale: 0.35, z: 3, border: false },
{ w: 150, h: 230, opacity: 0.35, scale: 0.88, x: 640, brightness: 0.3, grayscale: 0.5, z: 2, border: false },
{ w: 120, h: 180, opacity: 0, scale: 0.83, x: 780, brightness: 0.2, grayscale: 0.8, z: 1, border: false },
];
interface TeamCarouselProps {
members: TeamMember[];
activeIndex: number;
onActiveChange: (index: number) => void;
}
export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarouselProps) {
const total = members.length;
const [dragOffset, setDragOffset] = useState(0);
const isDraggingRef = useRef(false);
const wasDragRef = useRef(false);
const pausedUntilRef = useRef(0);
const dragStartRef = useRef<{ x: number; startIndex: number } | null>(null);
// Pause auto-rotation when activeIndex changes externally (e.g. dot click)
const prevIndexRef = useRef(activeIndex);
useEffect(() => {
if (prevIndexRef.current !== activeIndex) {
prevIndexRef.current = activeIndex;
pausedUntilRef.current = Date.now() + PAUSE_MS;
}
}, [activeIndex]);
// Auto-rotate — completely skip while dragging
useEffect(() => {
const id = setInterval(() => {
if (isDraggingRef.current) return;
if (Date.now() < pausedUntilRef.current) return;
onActiveChange((activeIndex + 1) % total);
}, AUTO_PLAY_MS);
return () => clearInterval(id);
}, [total, activeIndex, onActiveChange]);
// Pointer handlers
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
isDraggingRef.current = true;
wasDragRef.current = false;
dragStartRef.current = { x: e.clientX, startIndex: activeIndex };
setDragOffset(0);
},
[activeIndex]
);
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
if (!dragStartRef.current) return;
const dx = e.clientX - dragStartRef.current.x;
if (Math.abs(dx) > 10) wasDragRef.current = true;
setDragOffset(dx);
},
[]
);
// Deferred index update — avoids calling parent setState during render
// (onLostPointerCapture can fire during React reconciliation)
const pendingIndexRef = useRef<number | null>(null);
useEffect(() => {
if (pendingIndexRef.current !== null) {
onActiveChange(pendingIndexRef.current);
pendingIndexRef.current = null;
}
});
const onPointerUp = useCallback(() => {
if (!dragStartRef.current) return;
const startIdx = dragStartRef.current.startIndex;
const currentOffset = dragOffset;
const wasDrag = Math.abs(currentOffset) > 10;
const steps = wasDrag ? Math.round(currentOffset / CARD_SPACING) : 0;
setDragOffset(0);
if (steps !== 0) {
pendingIndexRef.current = wrapIndex(startIdx - steps, total);
}
dragStartRef.current = null;
isDraggingRef.current = false;
pausedUntilRef.current = Date.now() + PAUSE_MS;
}, [total, dragOffset]);
// Compute interpolated style for each card
const baseIndex = dragStartRef.current ? dragStartRef.current.startIndex : activeIndex;
function getCardStyle(index: number) {
const baseDiff = getDiff(index, baseIndex, total);
const fractionalShift = dragOffset / CARD_SPACING;
const continuousDiff = baseDiff + fractionalShift;
const absDiff = Math.abs(continuousDiff);
if (absDiff > 4) return null;
const lowerSlot = Math.floor(absDiff);
const upperSlot = Math.ceil(absDiff);
const t = absDiff - lowerSlot;
const s0 = SLOTS[clamp(lowerSlot, 0, 4)];
const s1 = SLOTS[clamp(upperSlot, 0, 4)];
const sign = continuousDiff >= 0 ? 1 : -1;
const x = sign * lerp(s0.x, s1.x, t);
const w = lerp(s0.w, s1.w, t);
const h = lerp(s0.h, s1.h, t);
const opacity = lerp(s0.opacity, s1.opacity, t);
const scale = lerp(s0.scale, s1.scale, t);
const brightness = lerp(s0.brightness, s1.brightness, t);
const grayscale = lerp(s0.grayscale, s1.grayscale, t);
const z = Math.round(lerp(s0.z, s1.z, t));
const showBorder = absDiff < 0.5;
if (opacity < 0.02) return null;
return {
width: w,
height: h,
opacity,
zIndex: z,
transform: `translateX(${x}px) scale(${scale})`,
filter: `brightness(${brightness}) grayscale(${grayscale})`,
borderColor: showBorder ? "rgba(201,169,110,0.3)" : "transparent",
boxShadow: showBorder
? "0 0 60px rgba(201,169,110,0.12)"
: "none",
transition: isDraggingRef.current
? "none"
: "all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
isCenter: absDiff < 0.5,
};
}
return (
<div
className="relative mx-auto flex items-end justify-center cursor-grab select-none active:cursor-grabbing touch-pan-y"
style={{ height: UI_CONFIG.team.stageHeight }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onLostPointerCapture={onPointerUp}
>
{/* Spotlight cone */}
<div
className="pointer-events-none absolute left-1/2 -translate-x-1/2 bottom-0"
style={{
width: 400,
height: 500,
background:
"conic-gradient(from 180deg at 50% 0%, transparent 30%, rgba(201,169,110,0.06) 45%, rgba(201,169,110,0.1) 50%, rgba(201,169,110,0.06) 55%, transparent 70%)",
}}
/>
{/* Cards */}
{members.map((m, i) => {
const style = getCardStyle(i);
if (!style) return null;
return (
<div
key={m.name}
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={{
width: style.width,
height: style.height,
opacity: style.opacity,
zIndex: style.zIndex,
transform: style.transform,
filter: style.filter,
borderColor: style.isCenter ? "transparent" : style.borderColor,
boxShadow: style.isCenter
? "0 0 40px rgba(201,169,110,0.15), 0 0 80px rgba(201,169,110,0.08)"
: style.boxShadow,
transition: style.transition,
}}
>
<Image
src={m.image}
alt={m.name}
fill
loading="lazy"
sizes="280px"
className="object-cover"
draggable={false}
/>
{style.isCenter && (
<>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-5">
<h3 className="text-lg font-bold text-white sm:text-xl drop-shadow-lg">
{m.name}
</h3>
<p className="text-sm font-medium text-gold-light drop-shadow-lg">
{m.role}
</p>
</div>
</>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { Instagram } from "lucide-react";
import type { TeamMember } from "@/types/content";
interface TeamMemberInfoProps {
members: TeamMember[];
activeIndex: number;
onSelect: (index: number) => void;
onOpenBio: () => void;
}
export function TeamMemberInfo({ members, activeIndex, onSelect, onOpenBio }: TeamMemberInfoProps) {
const member = members[activeIndex];
return (
<div
key={activeIndex}
className="mx-auto mt-8 max-w-lg text-center"
style={{
animation: "team-info-in 0.6s cubic-bezier(0.16, 1, 0.3, 1)",
}}
>
{member.instagram && (
<a
href={member.instagram}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-white/40 transition-colors hover:text-gold-light"
>
<Instagram size={14} />
{member.instagram.split("/").filter(Boolean).pop()}
</a>
)}
{member.description && (
<p className="mt-3 text-sm leading-relaxed text-white/55">
{member.description}
</p>
)}
<button
onClick={onOpenBio}
className="mt-3 text-sm font-medium text-gold hover:text-gold-light transition-colors cursor-pointer"
>
Подробнее
</button>
{/* Progress dots */}
<div className="mt-6 flex items-center justify-center gap-1.5">
{members.map((_, i) => (
<button
key={i}
onClick={() => onSelect(i)}
className={`h-1.5 rounded-full transition-all duration-500 cursor-pointer ${
i === activeIndex
? "w-6 bg-gold"
: "w-1.5 bg-white/15 hover:bg-white/30"
}`}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,478 @@
import { useState, useEffect, useRef, useCallback } from "react";
import Image from "next/image";
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Award, Scale, Clock, MapPin } from "lucide-react";
import type { TeamMember, RichListItem, VictoryItem, ScheduleLocation } from "@/types/content";
import { SignupModal } from "@/components/ui/SignupModal";
interface TeamProfileProps {
member: TeamMember;
onBack: () => void;
schedule?: ScheduleLocation[];
}
export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
const [lightbox, setLightbox] = useState<string | null>(null);
const [bookingGroup, setBookingGroup] = useState<string | null>(null);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
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 (
<div
className="w-full"
style={{ animation: "team-info-in 0.6s cubic-bezier(0.16, 1, 0.3, 1)" }}
>
{/* 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
onClick={onBack}
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={14} />
Назад
</button>
<div className="relative aspect-[3/4] overflow-hidden rounded-2xl border border-white/[0.06]">
<Image
src={member.image}
alt={member.name}
fill
sizes="(min-width: 1024px) 380px, (min-width: 640px) 340px, 100vw"
className="object-cover"
/>
{/* 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" />
{/* Name + role overlay at top */}
<div className="absolute top-0 left-0 right-0 p-6 sm:p-8">
<h3
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}
</p>
{member.instagram && (
<a
href={member.instagram}
target="_blank"
rel="noopener noreferrer"
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} />
{member.instagram.split("/").filter(Boolean).pop()}
</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 && (
<p className={`text-sm leading-relaxed text-white/45 ${hasBio ? "mt-8 border-t border-white/[0.06] pt-6" : ""}`}>
{member.description}
</p>
)}
{/* Empty state */}
{!hasBio && !member.description && (
<p className="text-sm text-white/30 italic">
Информация скоро появится
</p>
)}
</div>
</div>
</div>
</div>
{/* Image lightbox */}
{lightbox && (
<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)}
>
<button
onClick={() => setLightbox(null)}
aria-label="Закрыть"
className="absolute top-4 right-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20 transition-colors"
>
<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>
)}
<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>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { useState, useEffect } from "react";
import { ChevronUp } from "lucide-react";
import { UI_CONFIG } from "@/lib/config";
export function BackToTop() {
const [visible, setVisible] = useState(false);
useEffect(() => {
function handleScroll() {
setVisible(window.scrollY > UI_CONFIG.scrollThresholds.backToTop);
}
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<button
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
aria-label="Наверх"
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-gold/30 bg-black/60 text-gold-light backdrop-blur-sm transition-all duration-300 hover:bg-gold/20 hover:border-gold/50 ${
visible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0 pointer-events-none"
}`}
>
<ChevronUp size={18} />
</button>
);
}

View File

@@ -2,7 +2,6 @@ import Link from "next/link";
interface ButtonProps {
href?: string;
variant?: "primary" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
children: React.ReactNode;
className?: string;
@@ -17,13 +16,12 @@ const sizes = {
export function Button({
href,
variant = "primary",
size = "md",
children,
className = "",
onClick,
}: ButtonProps) {
const classes = `btn-${variant} ${sizes[size]} ${className}`;
const classes = `btn-primary ${sizes[size]} ${className}`;
if (href) {
return (

View File

@@ -1,119 +0,0 @@
"use client";
import { useEffect } from "react";
import Image from "next/image";
import { X, Flame, Sparkles, Wind, Zap, Star, Monitor } from "lucide-react";
import type { ClassItem } from "@/types";
const iconMap: Record<string, React.ReactNode> = {
flame: <Flame size={20} />,
sparkles: <Sparkles size={20} />,
wind: <Wind size={20} />,
zap: <Zap size={20} />,
star: <Star size={20} />,
monitor: <Monitor size={20} />,
};
interface ClassModalProps {
classItem: ClassItem | null;
onClose: () => void;
}
export function ClassModal({ classItem, onClose }: ClassModalProps) {
useEffect(() => {
if (!classItem) return;
document.body.style.overflow = "hidden";
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = "";
document.removeEventListener("keydown", handleKeyDown);
};
}, [classItem, onClose]);
if (!classItem) return null;
const heroImage = classItem.images?.[0];
return (
<div
className="modal-overlay fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-lg sm:items-center sm:p-4"
onClick={onClose}
>
<div
className="modal-content relative flex w-full max-h-[90vh] flex-col overflow-hidden rounded-t-3xl bg-white sm:max-w-2xl sm:rounded-3xl dark:bg-[#111]"
onClick={(e) => e.stopPropagation()}
>
{/* Hero image banner */}
{heroImage && (
<div className="relative h-52 w-full shrink-0 sm:h-64">
<Image
src={heroImage}
alt={classItem.name}
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
{/* Close button */}
<button
onClick={onClose}
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-sm transition-all hover:bg-black/60 hover:text-white"
aria-label="Закрыть"
>
<X size={16} />
</button>
{/* Title on image */}
<div className="absolute bottom-0 left-0 right-0 p-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/15 text-white backdrop-blur-sm">
{iconMap[classItem.icon]}
</div>
<h3 className="text-2xl font-bold text-white">
{classItem.name}
</h3>
</div>
</div>
</div>
)}
{/* Content */}
<div className="overflow-y-auto">
{/* Title fallback when no image */}
{!heroImage && (
<div className="flex items-center justify-between p-6 pb-0">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-rose-50 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400">
{iconMap[classItem.icon]}
</div>
<h3 className="heading-text text-xl font-bold">
{classItem.name}
</h3>
</div>
<button
onClick={onClose}
className="rounded-full p-1.5 text-neutral-400 transition-all hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-500 dark:hover:bg-white/[0.05] dark:hover:text-white"
aria-label="Закрыть"
>
<X size={18} />
</button>
</div>
)}
{classItem.detailedDescription && (
<div className="p-6 text-sm leading-relaxed whitespace-pre-line text-neutral-600 dark:text-neutral-400">
{classItem.detailedDescription}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { UI_CONFIG } from "@/lib/config";
interface Heart {
id: number;
@@ -15,7 +16,7 @@ export function FloatingHearts() {
const [hearts, setHearts] = useState<Heart[]>([]);
useEffect(() => {
const generated: Heart[] = Array.from({ length: 12 }, (_, i) => ({
const generated: Heart[] = Array.from({ length: UI_CONFIG.team.floatingHeartsCount }, (_, i) => ({
id: i,
left: Math.random() * 100,
size: 8 + Math.random() * 16,
@@ -33,7 +34,7 @@ export function FloatingHearts() {
{hearts.map((heart) => (
<div
key={heart.id}
className="absolute text-rose-500"
className="absolute text-gold"
style={{
left: `${heart.left}%`,
bottom: "-20px",

View File

@@ -0,0 +1,161 @@
interface HeroLogoProps {
className?: string;
size?: number;
}
// Heart SVG split into 3 sub-paths for independent stroke animation
const PATHS = [
// Main heart shape (largest, outer contour)
"M118.02,188.43 C118.04,184.10 120.51,173.30 122.96,166.79 C126.11,158.42 133.55,147.62 144.55,135.42 C165.53,112.15 170.96,101.38 170.99,82.98 C171.00,72.35 168.51,62.96 162.47,50.94 C160.00,46.02 157.78,42.00 157.53,42.00 C157.29,42.00 158.24,45.04 159.64,48.75 C163.04,57.78 165.96,71.24 165.96,78.00 C165.97,85.89 163.51,95.22 159.27,103.40 C156.31,109.09 152.52,113.57 140.17,126.00 C131.69,134.53 123.42,143.44 121.79,145.81 C116.23,153.88 110.87,167.99 109.81,177.28 C109.51,179.99 109.37,179.90 105.02,174.28 C102.55,171.10 98.74,166.54 96.55,164.15 L92.56,159.80 L95.53,157.70 C100.61,154.12 105.90,148.12 108.76,142.70 C111.22,138.02 111.50,136.50 111.47,127.50 C111.45,118.43 111.02,116.15 106.86,103.00 C101.17,85.06 99.60,76.75 100.25,68.17 C100.75,61.51 104.60,48.83 107.29,45.00 C108.57,43.16 108.76,43.69 109.31,50.84 C110.42,65.22 115.99,75.08 126.37,81.04 C133.31,85.02 133.82,84.76 128.92,79.75 C124.20,74.93 119.44,65.68 118.16,58.84 C116.46,49.75 119.09,39.24 125.73,28.59 L128.79,23.69 L130.02,29.59 C130.69,32.84 133.23,39.92 135.65,45.33 C143.15,62.02 144.36,69.90 141.53,83.29 C140.04,90.28 134.00,104.04 127.66,114.86 C125.68,118.24 124.39,120.97 124.78,120.93 C125.18,120.90 128.41,117.53 131.97,113.46 C145.06,98.47 150.50,85.84 150.46,70.50 C150.43,59.80 149.70,57.36 141.46,40.79 C137.98,33.80 134.83,26.02 134.45,23.49 C133.85,19.50 134.07,18.56 136.10,16.40 C139.50,12.77 147.93,7.39 153.86,5.06 L159.00,3.03 L159.00,9.32 C159.00,18.37 162.11,24.01 172.06,33.00 C176.46,36.97 180.72,41.50 181.53,43.06 C183.67,47.20 183.39,56.94 180.95,63.13 C178.14,70.25 180.87,67.95 184.97,59.74 C190.78,48.12 188.70,39.73 177.15,28.15 C173.28,24.27 169.43,20.06 168.61,18.80 C166.51,15.58 164.79,7.21 165.54,3.83 C166.16,1.00 166.17,1.00 174.33,1.01 C178.82,1.02 184.15,1.29 186.17,1.63 C189.69,2.21 189.81,2.37 189.29,5.62 C188.42,10.94 190.83,20.71 195.10,29.20 C197.26,33.49 199.19,37.00 199.40,37.00 C199.62,37.00 198.67,33.51 197.31,29.25 C195.35,23.12 194.91,19.90 195.17,13.83 C195.36,9.61 195.85,5.81 196.28,5.39 C197.35,4.31 205.52,8.43 211.67,13.15 C218.45,18.35 221.00,23.72 220.99,32.74 C220.98,36.46 220.28,41.98 219.42,45.00 C218.57,48.02 217.63,51.40 217.34,52.50 C216.30,56.51 222.34,45.32 224.52,39.20 C225.76,35.73 227.01,32.66 227.29,32.38 C227.57,32.09 228.79,34.48 229.99,37.68 C238.21,59.57 232.78,83.80 215.76,101.15 C209.43,107.60 207.42,108.54 209.17,104.25 C210.91,100.00 210.37,85.54 208.08,74.64 C206.93,69.22 205.95,62.24 205.90,59.14 C205.80,53.85 205.74,53.72 204.93,57.00 C204.45,58.92 204.13,68.15 204.22,77.50 C204.37,93.11 204.20,94.91 202.15,99.45 C198.99,106.51 192.06,115.46 190.76,114.16 C188.49,111.89 189.93,84.88 192.72,77.19 C194.09,73.45 189.30,79.05 186.68,84.26 C182.02,93.55 180.69,101.03 181.41,113.97 L182.05,125.50 L169.94,135.00 C153.90,147.58 132.06,170.01 124.13,182.05 C118.83,190.09 118.00,190.96 118.02,188.43 Z",
// Left inner detail
"M83.09,150.59 C78.00,145.44 77.78,144.99 78.44,141.34 C78.82,139.23 81.24,133.00 83.81,127.50 C88.47,117.57 88.50,117.43 88.49,107.00 C88.47,99.38 87.96,94.99 86.59,91.00 C84.28,84.21 77.06,69.61 76.36,70.30 C76.08,70.58 76.56,72.19 77.43,73.87 C79.91,78.65 82.99,92.88 82.99,99.57 C83.00,108.39 80.69,114.86 73.96,124.82 C70.68,129.67 68.00,134.17 68.00,134.82 C68.00,135.47 67.62,136.00 67.16,136.00 C66.07,136.00 57.00,128.93 57.00,128.07 C57.00,127.71 59.03,123.47 61.50,118.66 C66.60,108.75 67.24,103.18 64.48,92.59 C62.01,83.09 61.32,83.22 61.40,93.17 C61.45,100.19 61.02,103.39 59.69,106.10 C57.49,110.57 48.29,121.00 46.56,121.00 C44.40,121.00 39.79,109.24 39.24,102.34 C38.56,93.90 40.48,89.09 48.68,78.77 C62.32,61.60 65.53,49.22 60.98,31.41 C58.70,22.51 54.61,13.20 50.08,6.62 C47.54,2.92 47.30,2.10 48.60,1.60 C50.50,0.87 66.31,0.80 68.17,1.51 C69.18,1.90 69.42,4.19 69.15,10.95 C68.88,18.05 69.27,21.45 71.06,27.48 C72.30,31.66 73.77,35.36 74.33,35.70 C74.97,36.10 75.06,35.62 74.57,34.41 C74.15,33.36 73.57,28.88 73.27,24.45 C72.70,15.76 74.85,5.38 77.44,4.39 C79.59,3.56 92.09,10.37 97.73,15.45 C100.57,18.00 103.83,21.61 104.99,23.48 L107.09,26.88 L103.66,31.69 C94.93,43.97 91.54,55.17 91.64,71.50 C91.72,84.83 92.69,89.79 99.08,109.50 C105.86,130.41 104.79,139.90 94.39,151.01 C91.83,153.75 89.44,156.00 89.08,156.00 C88.72,156.00 86.03,153.57 83.09,150.59 Z",
// Far left small detail
"M29.50,109.90 C26.20,107.67 21.64,104.05 19.38,101.84 L15.26,97.83 L17.24,92.67 C19.86,85.83 19.20,74.50 15.57,64.04 C12.16,54.24 10.98,53.26 12.75,61.71 C14.48,69.97 13.94,81.02 11.53,86.50 L9.77,90.50 L6.92,84.50 C2.82,75.84 1.00,67.75 1.00,58.18 C1.00,42.04 6.09,29.69 17.39,18.40 C23.48,12.31 32.07,6.09 30.82,8.67 C30.59,9.13 28.88,12.62 27.02,16.44 C21.43,27.90 22.74,38.16 31.08,48.28 C35.14,53.21 36.55,53.01 33.93,47.87 C31.25,42.61 30.40,34.47 31.90,28.31 C33.17,23.08 42.81,3.00 44.05,3.00 C44.47,3.00 46.18,6.79 47.86,11.42 C58.07,39.63 56.90,53.23 42.67,72.14 C31.96,86.38 29.60,96.21 33.92,108.52 C34.98,111.54 35.77,113.99 35.67,113.97 C35.58,113.96 32.80,112.12 29.50,109.90 Z",
];
// Approximate path lengths for each sub-path
const PATH_LENGTHS = [1800, 700, 300];
// Animation config per sub-path: staggered delays for continuous feel
const ANIM_CONFIG = [
{ dur: "5s", delay: "0s" },
{ dur: "3s", delay: "1.5s" },
{ dur: "2s", delay: "3s" },
];
const FULL_PATH = PATHS.join(" ");
// Glitter sparkle positions (x, y) placed within the heart shape
const SPARKLES = [
{ x: 150, y: 30, delay: 0, dur: 2.4 },
{ x: 185, y: 55, delay: 1.1, dur: 2.0 },
{ x: 200, y: 85, delay: 0.5, dur: 2.8 },
{ x: 170, y: 110, delay: 2.0, dur: 2.2 },
{ x: 145, y: 75, delay: 1.6, dur: 2.6 },
{ x: 130, y: 50, delay: 0.3, dur: 2.1 },
{ x: 160, y: 140, delay: 2.5, dur: 2.4 },
{ x: 125, y: 160, delay: 1.8, dur: 2.0 },
{ x: 105, y: 100, delay: 0.8, dur: 2.5 },
{ x: 90, y: 70, delay: 1.3, dur: 2.3 },
{ x: 75, y: 45, delay: 2.2, dur: 2.1 },
{ x: 60, y: 80, delay: 0.6, dur: 2.7 },
{ x: 50, y: 55, delay: 1.9, dur: 2.0 },
{ x: 40, y: 95, delay: 0.2, dur: 2.4 },
{ x: 115, y: 130, delay: 1.4, dur: 2.6 },
];
export function HeroLogo({ className = "", size = 220 }: HeroLogoProps) {
const h = Math.round(size * (192 / 234));
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 234 192"
width={size}
height={h}
className={className}
role="img"
aria-label="Black Heart logo"
>
<defs>
{/* Dark metal gradient for fill */}
<radialGradient id="metal-fill" cx="50%" cy="35%" r="65%" fx="50%" fy="30%">
<stop offset="0%" stopColor="#333" />
<stop offset="50%" stopColor="#1a1a1a" />
<stop offset="100%" stopColor="#111" />
</radialGradient>
{/* Gold glow filter for the stroke */}
<filter id="gold-glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Sparkle glow filter */}
<filter id="sparkle-glow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Clip to heart shape so sparkles stay inside */}
<clipPath id="heart-clip">
<path d={FULL_PATH} fillRule="evenodd" />
</clipPath>
</defs>
{/* Base heart: dark metal */}
<path
fill="url(#metal-fill)"
fillRule="evenodd"
d={FULL_PATH}
/>
{/* Glitter sparkles on heart surface — odd-indexed hidden on mobile via CSS class */}
<g clipPath="url(#heart-clip)" filter="url(#sparkle-glow)">
{SPARKLES.map((s, i) => (
<circle key={`sparkle-${i}`} cx={s.x} cy={s.y} r="1.8" fill="#d4b87a" className={i % 2 ? "hidden sm:block" : ""}>
<animate
attributeName="opacity"
values="0;0;0.9;1;0.9;0;0"
dur={`${s.dur}s`}
begin={`${s.delay}s`}
repeatCount="indefinite"
/>
<animate
attributeName="r"
values="0.8;1.8;0.8"
dur={`${s.dur}s`}
begin={`${s.delay}s`}
repeatCount="indefinite"
/>
</circle>
))}
</g>
{/* Animated gold glint — one per sub-path, staggered */}
{PATHS.map((d, i) => {
const len = PATH_LENGTHS[i];
const dashLen = len * 0.15;
const gapLen = len * 0.85;
const { dur, delay } = ANIM_CONFIG[i];
return (
<path
key={i}
d={d}
fill="none"
stroke="#c9a96e"
strokeWidth="1.5"
strokeOpacity="0.6"
strokeDasharray={`${dashLen} ${gapLen}`}
filter="url(#gold-glow)"
>
<animate
attributeName="stroke-dashoffset"
values={`${len};0`}
dur={dur}
begin={delay}
repeatCount="indefinite"
/>
</path>
);
})}
{/* Constant gold edge highlight */}
<path
d={FULL_PATH}
fill="none"
stroke="#c9a96e"
strokeWidth="0.75"
strokeOpacity="0.3"
fillRule="evenodd"
/>
</svg>
);
}

View File

@@ -0,0 +1,14 @@
interface IconBadgeProps {
children: React.ReactNode;
className?: string;
}
export function IconBadge({ children, className = "" }: IconBadgeProps) {
return (
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gold/10 text-gold-dark transition-colors group-hover:bg-gold/15 dark:text-gold-light ${className}`}
>
{children}
</div>
);
}

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

@@ -1,15 +1,22 @@
interface SectionHeadingProps {
children: React.ReactNode;
className?: string;
centered?: boolean;
}
export function SectionHeading({ children, className = "" }: SectionHeadingProps) {
export function SectionHeading({ children, className = "", centered = false }: SectionHeadingProps) {
return (
<h2
className={`font-display text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl ${className}`}
>
{children}
<span className="mt-3 block h-[2px] w-16 rounded-full bg-gradient-to-r from-rose-500 to-rose-500/0" />
</h2>
<div className={centered ? "text-center" : ""}>
<h2
className={`font-display text-4xl font-bold uppercase tracking-wide sm:text-5xl lg:text-6xl gradient-text ${className}`}
>
{children}
</h2>
<span
className={`mt-4 block h-[1px] w-20 bg-gradient-to-r from-gold to-transparent ${
centered ? "mx-auto" : ""
}`}
/>
</div>
);
}

View File

@@ -0,0 +1,188 @@
"use client";
import { useRef, useEffect, useState, useCallback } from "react";
import { UI_CONFIG } from "@/lib/config";
interface ShowcaseLayoutProps<T> {
items: T[];
activeIndex: number;
onSelect: (index: number) => void;
onHoverChange?: (hovering: boolean) => void;
renderDetail: (item: T, index: number) => React.ReactNode;
renderSelectorItem: (item: T, index: number, isActive: boolean) => React.ReactNode;
counter?: boolean;
}
export function ShowcaseLayout<T>({
items,
activeIndex,
onSelect,
onHoverChange,
renderDetail,
renderSelectorItem,
counter = false,
}: ShowcaseLayoutProps<T>) {
const selectorRef = useRef<HTMLDivElement>(null);
const activeItemRef = useRef<HTMLButtonElement>(null);
const detailRef = useRef<HTMLDivElement>(null);
const detailWrapRef = useRef<HTMLDivElement>(null);
const [minHeight, setMinHeight] = useState<number | undefined>(undefined);
const measuredHeights = useRef<number[]>([]);
const [isUserInteracting, setIsUserInteracting] = useState(false);
const [displayIndex, setDisplayIndex] = useState(activeIndex);
const [fading, setFading] = useState(false);
// Track max height across all seen items to prevent shrinking
useEffect(() => {
if (!detailRef.current || fading) return;
const h = detailRef.current.offsetHeight;
measuredHeights.current[displayIndex] = h;
const maxH = Math.max(...measuredHeights.current.filter(Boolean));
if (maxH > (minHeight ?? 0)) {
setMinHeight(maxH);
}
}, [displayIndex, fading]);
useEffect(() => {
if (activeIndex === displayIndex) return;
setFading(true);
const timeout = setTimeout(() => {
setDisplayIndex(activeIndex);
setFading(false);
}, UI_CONFIG.showcase.fadeMs);
return () => clearTimeout(timeout);
}, [activeIndex, displayIndex]);
// Auto-scroll selector only when item is out of view
useEffect(() => {
if (isUserInteracting) return;
const container = selectorRef.current;
const activeEl = activeItemRef.current;
if (!container || !activeEl) return;
const isHorizontal = window.innerWidth < 1024;
if (isHorizontal) {
const elLeft = activeEl.offsetLeft;
const elRight = elLeft + activeEl.offsetWidth;
const scrollLeft = container.scrollLeft;
const viewRight = scrollLeft + container.offsetWidth;
if (elLeft < scrollLeft || elRight > viewRight) {
const left = elLeft - container.offsetWidth / 2 + activeEl.offsetWidth / 2;
container.scrollTo({ left, behavior: "smooth" });
}
} else {
const elTop = activeEl.offsetTop;
const elBottom = elTop + activeEl.offsetHeight;
const scrollTop = container.scrollTop;
const viewBottom = scrollTop + container.offsetHeight;
if (elTop < scrollTop || elBottom > viewBottom) {
const top = elTop - container.offsetHeight / 2 + activeEl.offsetHeight / 2;
container.scrollTo({ top, behavior: "smooth" });
}
}
}, [activeIndex, isUserInteracting]);
// Swipe support on detail area
const touchStart = useRef<{ x: number; y: number } | null>(null);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}, []);
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
if (!touchStart.current) return;
const dx = e.changedTouches[0].clientX - touchStart.current.x;
const dy = e.changedTouches[0].clientY - touchStart.current.y;
touchStart.current = null;
if (Math.abs(dx) > UI_CONFIG.showcase.swipeThreshold && Math.abs(dx) > Math.abs(dy) * 1.5) {
if (dx < 0 && activeIndex < items.length - 1) {
onSelect(activeIndex + 1);
} else if (dx > 0 && activeIndex > 0) {
onSelect(activeIndex - 1);
}
}
},
[activeIndex, items.length, onSelect],
);
function handleMouseEnter() {
setIsUserInteracting(true);
onHoverChange?.(true);
}
function handleMouseLeave() {
setIsUserInteracting(false);
onHoverChange?.(false);
}
return (
<div
className="flex flex-col-reverse gap-6 lg:flex-row lg:gap-8"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Detail area */}
<div className="lg:w-[60%]">
<div
ref={detailWrapRef}
style={minHeight != null ? { minHeight } : undefined}
>
<div
ref={detailRef}
className={`transition-all duration-300 ease-out ${
fading
? "opacity-0 translate-y-2"
: "opacity-100 translate-y-0"
}`}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{renderDetail(items[displayIndex], displayIndex)}
</div>
</div>
{/* Counter */}
{counter && (
<div className="mt-3 flex items-center justify-center gap-2 lg:justify-start">
<span className="text-xs font-medium tabular-nums text-gold">
{String(activeIndex + 1).padStart(2, "0")}
</span>
<span className="h-[1px] w-8 bg-white/10" />
<span className="text-xs tabular-nums text-neutral-500">
{String(items.length).padStart(2, "0")}
</span>
</div>
)}
</div>
{/* Selector */}
<div className="lg:w-[40%]">
<div
ref={selectorRef}
className="grid grid-cols-2 gap-2 lg:grid-cols-1 lg:gap-3 lg:max-h-[600px] lg:overflow-y-auto lg:pr-1 styled-scrollbar"
>
{items.map((item, i) => (
<button
key={i}
ref={i === activeIndex ? activeItemRef : null}
onClick={() => onSelect(i)}
className={`cursor-pointer rounded-xl border-2 text-left transition-all duration-300 ${
i === activeIndex
? "border-gold/60 bg-gold/10 dark:bg-gold/5"
: "border-transparent bg-neutral-100 hover:bg-neutral-200 dark:bg-white/[0.03] dark:hover:bg-white/[0.06]"
}`}
>
{renderSelectorItem(item, i, i === activeIndex)}
</button>
))}
</div>
</div>
</div>
);
}

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

@@ -1,31 +0,0 @@
import { Instagram } from "lucide-react";
interface SocialLinksProps {
instagram?: string;
instagramHandle?: string;
className?: string;
iconSize?: number;
}
export function SocialLinks({
instagram,
instagramHandle,
className = "",
iconSize = 24,
}: SocialLinksProps) {
return (
<div className={`flex items-center gap-4 ${className}`}>
{instagram && (
<a
href={instagram}
target="_blank"
rel="noopener noreferrer"
className="social-icon flex items-center gap-2"
>
<Instagram size={iconSize} />
{instagramHandle && <span className="text-sm font-medium">{instagramHandle}</span>}
</a>
)}
</div>
);
}

View File

@@ -1,91 +0,0 @@
"use client";
import { useEffect } from "react";
import Image from "next/image";
import { X, Instagram } from "lucide-react";
import type { TeamMember } from "@/types";
interface TeamMemberModalProps {
member: TeamMember | null;
onClose: () => void;
}
export function TeamMemberModal({ member, onClose }: TeamMemberModalProps) {
useEffect(() => {
if (!member) return;
document.body.style.overflow = "hidden";
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = "";
document.removeEventListener("keydown", handleKeyDown);
};
}, [member, onClose]);
if (!member) return null;
return (
<div
className="modal-overlay fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-lg sm:items-center sm:p-4"
onClick={onClose}
>
<div
className="modal-content relative flex w-full max-h-[90vh] flex-col overflow-hidden rounded-t-3xl bg-white sm:max-w-lg sm:rounded-3xl dark:bg-[#111]"
onClick={(e) => e.stopPropagation()}
>
{/* Hero photo */}
<div className="relative h-72 w-full shrink-0 sm:h-80">
<Image
src={member.image}
alt={member.name}
fill
className="object-cover"
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
{/* Close button */}
<button
onClick={onClose}
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-sm transition-all hover:bg-black/60 hover:text-white"
aria-label="Закрыть"
>
<X size={16} />
</button>
{/* Name + Instagram on photo */}
<div className="absolute bottom-0 left-0 right-0 p-6">
<h3 className="text-2xl font-bold text-white">
{member.name}
</h3>
{member.instagram && (
<a
href={member.instagram}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-2 text-sm text-white/70 transition-colors hover:text-rose-400"
>
<Instagram size={15} className="shrink-0" />
<span>{member.instagram.split("/").filter(Boolean).pop()}</span>
</a>
)}
</div>
</div>
{/* Description */}
{member.description && (
<div className="overflow-y-auto p-6">
<p className="text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
{member.description}
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -8,8 +8,7 @@ export function ThemeToggle() {
useEffect(() => {
const stored = localStorage.getItem("theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const isDark = stored === "dark" || (!stored && prefersDark);
const isDark = stored !== "light";
setDark(isDark);
document.documentElement.classList.toggle("dark", isDark);
}, []);
@@ -25,9 +24,9 @@ export function ThemeToggle() {
<button
onClick={toggle}
aria-label="Переключить тему"
className="social-icon rounded-full p-2"
className="rounded-full p-2 text-neutral-400 transition-all duration-300 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-500 dark:hover:bg-white/[0.05] dark:hover:text-white"
>
{dark ? <Sun size={20} /> : <Moon size={20} />}
{dark ? <Sun size={18} /> : <Moon size={18} />}
</button>
);
}

View File

@@ -24,8 +24,8 @@ export const siteContent: SiteContent = {
title: "Настоящие профи!",
members: [
{
name: "Виктор Артемов",
role: "Тренер",
name: "Виктор Артёмов",
role: "Pole Fitness · Exotic · Strip",
image: "/images/team/viktor-artyomov.webp",
instagram: "https://instagram.com/viktor.artyomov/",
description:
@@ -33,103 +33,123 @@ export const siteContent: SiteContent = {
},
{
name: "Анна Тарыба",
role: "Тренер",
role: "Exotic Pole Dance",
image: "/images/team/anna-taryba.webp",
instagram: "https://instagram.com/annataryba/",
description:
"Я смогла в кратчайшие сроки достичь высочайших вершин в Exotic Pole Dance. Многократная призёрка чемпионатов в различных категориях. Основала свою команду ExoTeAM, где готовлю учениц к выходу на сцену. Люблю создавать хореографии в разных жанрах — от ярких и сложных до выразительных и плавных. Веду учеников от начального уровня до выступлений и медалей. Помогу освоить любые элементы и достичь идеальных линий!",
"Мощь и сила в каждой связке. Мои акцентные хореографии созданы для продвинутого уровня, где вы сможете раскрыть свой потенциал и почувствовать себя настоящей королевой танца. Готовьтесь к интенсивному погружению в мир уверенных движений и сложных элементов, где каждое занятие — это новый вызов и триумф!",
},
{
name: "Анастасия Чалей",
role: "Тренер",
role: "Exotic Pole Dance",
image: "/images/team/anastasia-chaley.webp",
instagram: "https://instagram.com/nastya_chaley/",
description:
"Я тренер-хореограф по Exotic Pole Dance и Strip. Танцевала абсолютно разные стили — хип-хоп, джаз-фанк, вог, хаус, поппинг, крамп, дэнсхолл, тверк — поэтому мои хореографии не похожи одна на другую. Люблю как яркие и акцентные танцы, так и плавные и тягучие. Со мной вы сможете насладиться всеми сторонами своей личности. Призёрка множества чемпионатов. Приходите на занятия — танцы это радость!",
"Вас ждут креативные хореографии, акцент на музыкальность и подачу, развитие уверенности и раскрытие вашей индивидуальности. Присоединяйтесь к тренировкам, где царит атмосфера радости и танцевального вдохновения! Мой вайб«танцы это радость».",
},
{
name: "Ольга Демидова",
role: "Тренер",
role: "Pole Dance",
image: "/images/team/olga-demidova.webp",
instagram: "https://instagram.com/don_olga_red/",
description:
начала заниматься Pole Dance 5 лет назад с нуля. За это время участвовала и становилась призёром чемпионатов по Pole Art, Pole Sport и Exotic Pole Dance. У меня крепкая трюковая база, я знаю, как повысить гибкость, и всему этому смогу научить вас на своих занятиях. Люблю свою работу и жду вас на тренировках!",
вдохновляющий лидер, который открывает двери в мир удивительного Pole Dance. С каждым занятием помогаю своим ученикам преодолевать собственные границы и достигать результатов, которые казались недостижимыми.",
},
{
name: "Галина Савицкая",
role: "Тренер",
image: "/images/team/galina-savitskaya.webp",
description:
"Безумно люблю растяжку и помогу полюбить её и вам! Использую упражнения ЛФК и точечные лайфхаки для удобных положений при растяжке ног, спины и плеч. Научу тянуться в паре и чувствовать безопасное расслабление и напряжение. 10 лет занимаюсь растяжкой в условиях пилонного спорта и танца.",
},
{
name: "Ирина Третьюкович",
role: "Тренер",
name: "Ирина Третьякович",
role: "Exotic Pole Dance",
image: "/images/team/irina-tretyukovich.webp",
instagram: "https://instagram.com/irkatretya/",
description:
"Я тренер по Exotic Pole Dance. За короткий период смогла выйти на профессиональный уровень и поучаствовать во многих чемпионатах, в том числе международных — конечно же, не без призовых мест! Моя сильная сторона — трюковые комбинации на пилоне и их использование в танцевальных связках. Если вам нужны сильные руки, красивое подтянутое тело, музыкальность и пластичность — буду ждать на своих тренировках!",
"Вас ждёт калейдоскоп эмоций: от сексуальной связки до нежной лирики и даже мистического драйва. Мои хореографии всегда энергичны и непредсказуемы, пробуждают самые смелые ваши стороны. Приготовьтесь к скоростному погружению в мир танца, где каждое движение — это вызов и откровение!",
},
{
name: "Надежда Сыч",
role: "Тренер",
role: "Exotic Pole Dance · Body Plastic",
image: "/images/team/nadezhda-sukh.webp",
instagram: "https://instagram.com/nadja.dance/",
description:
"Я обучаю партерной акробатике, балансам, трюковому пилону и сексуальным танцам. Занятия у меня — это волшебное путешествие, где вы научитесь основам акробатических элементов, разовьёте навыки в балансах и стойках, а флаги и трюковые комбинации с пилоном не будут казаться чем-то недостижимым. Вы раскроете свою индивидуальность через чувственные танцы, наполненные грацией и пластикой. Присоединяйтесь и окунитесь в мир, где танец становится искусством!",
"Со мной вы научитесь кайфовать от себя и раскрывать свою сексуальность. Помогу развить силу, баланс и пластику, а главное — почувствовать себя желанной и привлекательной.",
},
{
name: "Ирина Карпусь",
role: "Тренер",
role: "Exotic Pole Dance",
image: "/images/team/irina-karpus.webp",
instagram: "https://instagram.com/karpus_iri/",
description:
"Я пришла в Exotic Pole Dance относительно недавно и полюбила его навсегда. В танце люблю и стремлюсь к красивым линиям и элегантности, но никогда не забываю про силовую часть и трюки. На занятиях стараюсь найти к каждому индивидуальный подход, чтобы тренировка была комфортной и продуктивной. Помогу раскрыть ваши сильные стороны и полюбить танец. Буду ждать вас на занятиях!",
"Я проводник в мир чувственного Exotic Pole Dance. Мои хореографии проникают в самое сердце, а занятия — идеальный старт для тех, кто хочет раскрыть свою женственность и уверенность в себе.",
},
{
name: "Юлия Книга",
role: "Тренер",
role: "Erotic Pole Dance",
image: "/images/team/yuliya-kniga.webp",
instagram: "https://instagram.com/knigynzel/",
description:
тренер по Exotic Pole Dance. В прошлом была танцовщицей эротического жанра, откуда и пошла моя любовь к танцам. Я точно знаю все техники раскрепощения, научу тебя быть плавной, музыкальной и сексуальной. Мои хореографии могут быть как быстрыми и динамичными с трюковыми элементами, так и медленными, томными и манящими. Помогу раскрыть тебя как танцора со всех сторон!",
не просто инструктор, я настоящий вдохновитель и проводник в мир Erotic Pole Dance. Мои тренировки — это не просто набор упражнений, это целое искусство, в котором каждая из вас чувствует себя особенной и ценной.",
},
{
name: "Алена Чигилейчик",
role: "Тренер",
name: "Алёна Чигилейчик",
role: "Exotic Pole Dance",
image: "/images/team/elena-chigileychik.webp",
instagram: "https://instagram.com/alenachygi/",
description:
"За несколько лет я смогла самостоятельно обучиться Exotic Pole Dance и занять 3 место в категории профи. Имею отличную спортивную базу. Танцую в основном flow, но всегда ищу новое и меняю стили хореографий. Обожаю эмоциональную подачу и точность в движениях, ощущение каждого сантиметра тела и то, как музыка позволяет раскрываться в танце. Научу чувствовать себя с музыкой одним целым!",
"Создаю атмосферу, где каждая деталь имеет значение. Мои занятия — это разнообразие стилей, где внимание уделяется каждому движению, а дружелюбная атмосфера помогает раскрыться и почувствовать себя уверенно.",
},
{
name: "Елена Тарасевич",
role: "Тренер",
role: "Body Plastic",
image: "/images/team/elena-tarasevic.webp",
instagram: "https://instagram.com/cerceia/",
description:
"Я воздушный гимнаст, практик акройоги и тренер по стретчингу и Airyoga. В спорте и танцах более 15 лет, стаж тренера — около 9 лет. Многократный призёр соревнований по воздушно-спортивному эквилибру России, стран СНГ и международных фестивалей. Прошла обучение у чемпионки мира по воздушной гимнастике, цирковых акробатов, балерин и художественных гимнастов. За плечами более 30 семинаров по функциональной анатомии, биомеханике и йогатерапии.",
},
{
name: "Ольга Грабовец",
role: "Тренер",
image: "/images/team/olga-grabovets.webp",
instagram: "https://instagram.com/lo_woolf/",
description:
"Я амбассадор красивых линий и натянутых стоп! За 1,5 года выросла от новичка до тренера по Exotic Pole Dance. Многократный призёр чемпионатов. Для меня в танце очень важна музыкальность, и я стараюсь это почерпнуть у разных педагогов — не только наших, но и зарубежных.",
"Ваш ключ к здоровому, гибкому и гармоничному телу. Знаю каждую связку, каждую клеточку вашего тела. Чувствую ваши ограничения, предугадываю ваши возможности и бережно веду вас к границам вашей гибкости.",
},
{
name: "Кристина Войтович",
role: "Тренер",
role: "Exotic Pole Dance",
image: "/images/team/kristina-voytovich.webp",
instagram: "https://instagram.com/chris_voytovich/",
description:
"Я всегда мечтала заниматься Exotic Pole Dance и смогла не только осуществить свою мечту, но и стать тренером! Постоянно совершенствую навыки, посещаю интенсивы и мастер-классы, регулярно участвую в соревнованиях. Мой стиль преподавания объединяет элементы танца, стретчинга, акробатики и силовых упражнений. Стараюсь создать комфортную атмосферу, чтобы каждая ученица могла наслаждаться процессом обучения!",
"В моих танцах кипит безумная смесь силы и чувственности. Обожаю переключаться между разными хореографиями: чувственными, дерзкими, меланхоличными, сексуальными... Каждая из них — это взрыв эмоций.",
},
{
name: "Екатерина Матлахова",
role: "Exotic · Pole Dance",
image: "/images/team/ekaterina-matlakhova.webp",
description:
"Создаю чувственные хореографии, где женственность расцветает в сексуальных движениях, изящных линиях и плавных переходах, подкреплённых эстетичными силовыми элементами. В моих танцах рождаются богини!",
},
{
name: "Лилия Огурцова",
role: "Exotic · Pole Dance",
image: "/images/team/liliya-ogurtsova.webp",
description:
"Я проведу вас в мир акцентных и чарующих хореографий. Мои занятия наполнены мистическим вайбом, драйвом и энергией. Уделяю особое внимание развитию силы, прокачке тела и чистоте движений, а также эмоциональной подаче в танце.",
},
{
name: "Наталья Анцух",
role: "Exotic Pole Dance",
image: "/images/team/natalya-antsukh.webp",
description:
"Каждое занятие — это праздник для тела и души, где стиль, грация и внутренняя сила объединяются воедино. Новичок или профессионал — я научу вас танцевать с уверенностью, раскрывать свою женственность и получать удовольствие от каждого движения.",
},
{
name: "Яна Артюкевич",
role: "Pole Dance",
image: "/images/team/yana-artyukevich.webp",
description:
"На моих занятиях вы научитесь красиво и уверенно владеть своим телом, освоите базовые трюки и элементы на пилоне — шаг за шагом, в уютной и вдохновляющей атмосфере. Укрепим мышцы, улучшим растяжку и осанку, а в процессе — почувствуете невероятную уверенность, сексуальность и внутреннюю силу.",
},
{
name: "Анжела Бобко",
role: "Pole Dance",
image: "/images/team/anzhela-bobko.webp",
description:
"Мой индивидуальный подход и внимательное отношение к каждому ученику создают атмосферу доверия и поддержки. Со мной вы не просто осваиваете технику — вы преодолеваете себя и становитесь лучшей версией себя.",
},
],
},
classes: {
title: "Скорее, мы ждём!",
title: "Направления",
items: [
{
name: "Exotic Pole Dance",
@@ -137,34 +157,34 @@ export const siteContent: SiteContent = {
"Чувственная хореография с элементами pole dance в каблуках.",
icon: "sparkles",
detailedDescription:
"Чувственный, эстетичный, сексуальный вид танца. Он богат на плавные линии, манящие прогибы и развитие вашей женственности.\n\nВы получаете:\n— уверенность в себе,\n— красивую фигуру и развитие всех групп мышц,\n— раскрытие себя с новой стороны и возможность влюбиться заново,\n— вы учитесь наслаждаться собой.",
"Стиль танца на пилоне, где акцент делается на чувственность, пластику. В Exotic Pole Dance используется обувь на высоких каблуках (стрипы), развивающий гибкость, силу, женственность и уверенность.\n\nВы получаете:\n— уверенность в себе,\n— красивую фигуру и развитие всех групп мышц,\n— раскрытие себя с новой стороны,\n— вы учитесь наслаждаться собой.",
images: ["/images/classes/exot.webp", "/images/classes/exot-w.webp"],
},
{
name: "Pole Dance",
description:
"Сила, грация и пластика на пилоне. Для любого уровня подготовки.",
"Искусство на пилоне: акробатические трюки, силовые элементы и грация.",
icon: "flame",
detailedDescription:
"Пилон — это отличный тренажер для рук, ног, спины и пресса. Pole Dance учит красиво двигаться, улучшает растяжку, силовые показатели и выдержку.\n\nВы получите:\n— силу и грацию,\n— прекрасную растяжку,\n— правильную осанку,\n— прекрасное настроение.",
"Вид искусства на пилоне, включающий акробатические трюки, силовые элементы и грациозные движения. Подходит для развития силы, выносливости и уровня технического мастерства.\n\nВы получите:\n— силу и грацию,\n— прекрасную растяжку,\n— правильную осанку,\n— прекрасное настроение.",
images: ["/images/classes/pole-dance.webp"],
},
{
name: "Body Plastic",
description:
"Танцевальное направление, раскрывающее женственность и пластику тела.",
"Пластичность, гибкость и осознанность тела в каждом движении.",
icon: "wind",
detailedDescription:
"Растяжка — это искусство, которое позволяет вам не только улучшить гибкость, но и раскрыть истинную красоту вашего тела. Это больше, чем просто упражнения — это плавные движения, которые учат вас слушать своё тело и чувствовать его.\n\nЗанимаясь растяжкой, вы получите:\n— уверенность в себе,\n— красивую осанку и гибкость,\n— улучшение общего тонуса тела и расслабление мышц,\n— возможность открыть новые грани своей чувственности и женственности,\n— умение наслаждаться каждым движением и моментом.\n\nРастяжка помогает вам не только достигнуть физического совершенства, но и найти внутреннюю гармонию и любовь к себе.",
"Тренировка, направленная на пластичность, гибкость и осознанность всего тела, помогает лучше управлять своим движением. Body Plastic объединяет растяжку, силу, контроль и пластичность, что помогает развивать тело гармонично и быстро.\n\nВместо односторонней растяжки он учит не только растягиваться, но и сохранять баланс, управлять каждым движением, что особенно важно для pole dance, акробатики и других тренировок.",
images: ["/images/classes/body-plastic.webp"],
},
{
name: "Партерная акробатика",
name: "Трюковые комбинации с пилоном",
description:
"Акробатические элементы в партере для развития силы и гибкости.",
"Яркие трюки, акробатические элементы и впечатляющие комбинации.",
icon: "zap",
detailedDescription:
"Партерная акробатика — это завораживающее сочетание силы, гибкости и грации, которое раскрывает безграничные возможности вашего тела. Этот вид искусства позволяет вам воплотить в жизнь самые смелые акробатические элементы, создавая уникальные и впечатляющие комбинации на полу.\n\nЗанимаясь партерной акробатикой, вы получите:\n— невероятную физическую силу и выносливость,\n— улучшение координации и равновесия,\n— развитие всех групп мышц и повышение гибкости,\n— возможность выразить себя через мощные и динамичные движения,\n— уверенность в своих возможностях и преодоление собственных границ.\n\nПартерная акробатика — это путь к совершенству тела и духа, который дарит ощущение полёта и свободы на земле.",
"Направление с акцентом на выполнение трюков, акробатических элементов и их комбинаций. Идеально подходит для тех, кто хочет освоить яркие, эффектные трюки и создать впечатляющие комбинации для выступлений и личного развития.",
images: ["/images/classes/parter-1.webp", "/images/classes/parter-2.webp"],
},
{
@@ -173,7 +193,7 @@ export const siteContent: SiteContent = {
"Уникальные занятия с приглашёнными топовыми тренерами.",
icon: "star",
detailedDescription:
"Мастер-классы — это уникальная возможность погрузиться в чувственный мир танца, где каждое движение наполнено грацией и страстью. Наши мастер-классы созданы для тех, кто хочет открыть в себе новые грани женственности и научиться выражать свои эмоции через танец.\n\nПриходя на наши мастер-классы, вы получите:\n— уверенность в себе и своих возможностях,\n— возможность раскрыть свою чувственность и сексуальность,\n— умение наслаждаться каждым моментом и каждым движением,\n— опыт от профессиональных тренеров, которые помогут вам достичь новых высот.\n\nНаши мастер-классы — это не просто тренировки, это путь к самопознанию и любви к своему телу. Присоединяйтесь к нам и откройте для себя мир танца, где каждый шаг приносит удовольствие и уверенность.",
"Мастер-классы — это уникальная возможность погрузиться в чувственный мир танца, где каждое движение наполнено грацией и страстью. Наши мастер-классы созданы для тех, кто хочет открыть в себе новые грани женственности и научиться выражать свои эмоции через танец.\n\nПриходя на наши мастер-классы, вы получите:\n— уверенность в себе и своих возможностях,\n— возможность раскрыть свою чувственность и сексуальность,\n— умение наслаждаться каждым моментом и каждым движением,\n— опыт от профессиональных тренеров.",
images: ["/images/classes/master-class-1.webp", "/images/classes/master-class-2.webp", "/images/classes/master-class-3.webp"],
},
{
@@ -181,16 +201,256 @@ export const siteContent: SiteContent = {
description: "Тренировки в удобное время из любой точки мира.",
icon: "monitor",
detailedDescription:
"Если вы находитесь не в Минске, у вас всё равно есть уникальная возможность тренироваться, расти и развиваться с нами! Мы предлагаем занятия онлайн по следующим направлениям: партерная акробатика, Pole Dance, Exotic Pole Dance, Exo-tricks, полёты.\n\nМы предлагаем два способа работы: самостоятельный и VIP. В самостоятельный тариф входит доступ к видеозаписям уроков по выбранному направлению, в VIP-тарифе вы также получите доступ к чату с куратором в Telegram, который подскажет и скорректирует в случае трудностей в процессе изучения материала.",
"Если вы находитесь не в Минске, у вас всё равно есть уникальная возможность тренироваться, расти и развиваться с нами! Мы предлагаем занятия онлайн по следующим направлениям: партерная акробатика, Pole Dance, Exotic Pole Dance, Exo-tricks, полёты.\n\nМы предлагаем два способа работы: самостоятельный и VIP. В самостоятельный тариф входит доступ к видеозаписям уроков по выбранному направлению, в VIP-тарифе вы также получите доступ к чату с куратором в Telegram.",
images: ["/images/classes/online-classes.webp"],
},
],
},
faq: {
title: "Частые вопросы",
items: [
{
question: "Что такое Exotic Pole Dance, Pole Dance и Body Plastic?",
answer:
"Exotic Pole Dance — стиль танца на пилоне, где акцент делается на чувственность, пластику. Используется обувь на высоких каблуках (стрипы), развивающий гибкость, силу, женственность и уверенность.\n\nPole Dance — вид искусства на пилоне, включающий акробатические трюки, силовые элементы и грациозные движения. Подходит для развития силы, выносливости и технического мастерства.\n\nBody Plastic — тренировка, направленная на пластичность, гибкость и осознанность всего тела, помогает лучше управлять своим движением.",
},
{
question: "Нужно ли иметь специальную подготовку, чтобы начать заниматься?",
answer:
"Нет, специальная подготовка не требуется. Уровень физической подготовки будет расти постепенно в процессе тренировок. Важно иметь желание и готовность к обучению.",
},
{
question: "Какая одежда нужна для занятий?",
answer:
"Pole Dance: важны шорты и топ, чтобы кожа на бёдрах и животе соприкасалась с пилоном для сцепления.\n\nExotic Pole Dance: на начальных этапах лучше шорты, можно леггинсы, топ/лиф, наколенники и желательно стрипы. На начальном этапе можно начинать без стрипов в носочках.",
},
{
question: "Какие группы по уровню существуют в вашей студии?",
answer:
"У нас есть группы для начинающих — «С нуля», где вы можете освоить базовые движения и технику. Также есть группы для продолжающих и для любого уровня подготовки — чтобы все могли развиваться и совершенствоваться в приятной и поддерживающей атмосфере.",
},
{
question: "Можно ли начать заниматься Exotic Pole Dance в любом возрасте?",
answer:
"Да, конечно! Возраст не имеет значения — этот вид спорта подходит для всех желающих развивать силу, гибкость и уверенность в себе. Единственное ограничение — от 18 лет.",
},
{
question: "Я чувствую себя скованно. Как раскрепоститься на тренировках Exotic Pole Dance?",
answer:
"Exotic Pole Dance — это про самовыражение и принятие себя. Не бойтесь проявлять свои эмоции, экспериментировать с движениями. Постепенно вы почувствуете себя увереннее и свободнее. Наши тренеры создают на занятиях комфортную и поддерживающую атмосферу.",
},
{
question: "Как быстро я смогу делать трюки на пилоне?",
answer:
"Это индивидуально и зависит от вашей физической подготовки, регулярности тренировок и способностей к обучению. Первые простые трюки обычно осваиваются в течение нескольких недель.",
},
{
question: "Body Plastic — это растяжка?",
answer:
"Body Plastic — это не только про растяжку. Body Plastic объединяет растяжку, силу, контроль и пластичность, что помогает развивать тело гармонично и быстро. Вместо односторонней растяжки он учит не только растягиваться, но и сохранять баланс, управлять каждым движением, что особенно важно для pole dance, акробатики и других тренировок.",
},
{
question: "Что включает направление «Трюковые комбинации с пилоном»?",
answer:
"Трюковые комбинации с пилоном — это направление с акцентом на выполнение трюков, акробатических элементов и их комбинаций. Это направление идеально подходит для тех, кто хочет освоить яркие, эффектные трюки и создать впечатляющие комбинации для выступлений и личного развития.",
},
{
question: "Сколько раз в неделю нужно заниматься?",
answer:
"Для новичков рекомендуется начинать с 23 раз в неделю. По мере развития физической формы и навыков можно увеличивать количество тренировок.",
},
{
question: "Участие в чемпионатах: обязательно ли это?",
answer:
"Нет, участие в чемпионатах — это не обязательно. Это скорее вопрос вашего личного желания и готовности. Если вы чувствуете в себе силы, мотивацию и хотите попробовать что-то новое, то не стесняйтесь сообщить об этом своему тренеру! Он поможет оценить ваши возможности и подготовиться к чемпионату наилучшим образом.",
},
],
},
pricing: {
title: "Стоимость",
subtitle: "Все абонементы идут с привязкой к группе, кроме безлимитного",
items: [
{ name: "Абонемент 8 × 90 мин", price: "175 BYN" },
{ name: "Абонемент 4 × 90 мин", price: "105 BYN" },
{ name: "Абонемент 8 × 60 мин", price: "145 BYN" },
{ name: "Абонемент 4 × 60 мин", price: "105 BYN" },
{ name: "Разовое занятие 1,5 часа", price: "30 BYN" },
{ name: "Разовое занятие 1 час", price: "25 BYN" },
{ name: "Пробное занятие", price: "25 BYN", note: "1,5 часа или 1 час" },
{
name: "Безлимитный абонемент",
price: "240 / 410 BYN",
note: "2 недели / месяц (обязательна предварительная запись)",
},
],
rentalTitle: "Аренда зала",
rentalItems: [
{ name: "С абонементом", price: "20 BYN", note: "+5 BYN за каждого доп. человека" },
{
name: "Без абонемента (Машерова 17/4, 6 этаж + Притыцкого 62/М)",
price: "35 BYN",
note: "+5 BYN за каждого доп. человека",
},
{
name: "Без абонемента (Машерова 17/4, 2 этаж)",
price: "25 BYN",
note: "+5 BYN за каждого доп. человека",
},
],
rules: [
"Абонемент является персональным и не подлежит передаче другим лицам.",
"Абонемент необходимо предъявлять администратору перед каждым занятием.",
"Оплата абонементов и разовых посещений производится до начала занятия.",
"Компенсация за пропущенные занятия не предусмотрена.",
"Срок действия абонемента — 4 недели.",
"Абонемент можно заморозить не более двух раз в год на срок до 2 недель (на время отпуска или командировки).",
"В случае болезни, подтверждённой больничным листом, возможно продление срока действия абонемента.",
],
},
masterClasses: {
title: "Мастер-классы",
items: [],
},
schedule: {
title: "Расписание",
locations: [
{
name: "Притыцкого 62/М",
address: "г. Минск, Притыцкого, 62/М",
days: [
{
day: "Понедельник",
dayShort: "ПН",
classes: [
{ time: "11:0012:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
{ time: "18:0019:30", trainer: "Надежда Сыч", type: "Exotic Pole Dance" },
{ time: "19:3021:00", trainer: "Екатерина Матлахова", type: "Exotic Pole Dance" },
{ time: "21:0022:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
],
},
{
day: "Вторник",
dayShort: "ВТ",
classes: [
{ time: "10:0011:30", trainer: "Анжела Бобко", type: "Pole Dance", recruiting: true },
{ time: "18:0019:30", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
{ time: "19:3021:00", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
{ time: "21:0022:30", trainer: "Виктор Артёмов", type: "Трюковые комбинации с пилоном" },
],
},
{
day: "Среда",
dayShort: "СР",
classes: [
{ time: "18:3020:00", trainer: "Виктор Артёмов", type: "Трюковые комбинации с пилоном", level: "Продвинутый" },
{ time: "20:0021:30", trainer: "Алёна Чигилейчик", type: "Exotic Pole Dance" },
{ time: "21:3022:30", trainer: "Алёна Чигилейчик", type: "Pole Dance" },
],
},
{
day: "Четверг",
dayShort: "ЧТ",
classes: [
{ time: "11:0012:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
{ time: "18:0019:30", trainer: "Надежда Сыч", type: "Exotic Pole Dance" },
{ time: "19:3021:00", trainer: "Екатерина Матлахова", type: "Exotic Pole Dance" },
{ time: "21:0022:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
],
},
{
day: "Пятница",
dayShort: "ПТ",
classes: [
{ time: "10:0011:30", trainer: "Анжела Бобко", type: "Pole Dance", recruiting: true },
{ time: "18:0019:30", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
{ time: "19:3021:00", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
{ time: "21:0022:30", trainer: "Виктор Артёмов", type: "Трюковые комбинации с пилоном" },
],
},
{
day: "Суббота",
dayShort: "СБ",
classes: [
{ time: "14:0015:00", trainer: "Алёна Чигилейчик", type: "Pole Dance" },
{ time: "15:0016:30", trainer: "Алёна Чигилейчик", type: "Exotic Pole Dance" },
],
},
{
day: "Воскресенье",
dayShort: "ВС",
classes: [
{ time: "12:0013:30", trainer: "Кристина Войтович", type: "Body Plastic" },
],
},
],
},
{
name: "Машерова 17/4",
address: "г. Минск, Машерова, 17/4",
days: [
{
day: "Понедельник",
dayShort: "ПН",
classes: [
{ time: "18:0019:00", trainer: "Ирина Карпусь", type: "Exotic Pole Dance" },
{ time: "19:0020:30", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
{ time: "20:3022:00", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
],
},
{
day: "Вторник",
dayShort: "ВТ",
classes: [
{ time: "18:3020:00", trainer: "Анастасия Чалей", type: "Exotic Pole Dance" },
{ time: "21:3023:00", trainer: "Лилия Огурцова", type: "Exotic Pole Dance", hasSlots: true },
],
},
{
day: "Среда",
dayShort: "СР",
classes: [
{ time: "18:0019:30", trainer: "Ольга Демидова", type: "Pole Dance" },
{ time: "19:3021:00", trainer: "Ольга Демидова", type: "Body Plastic" },
],
},
{
day: "Четверг",
dayShort: "ЧТ",
classes: [
{ time: "18:0019:00", trainer: "Ирина Карпусь", type: "Exotic Pole Dance" },
{ time: "19:0020:30", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
{ time: "20:3022:00", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
],
},
{
day: "Пятница",
dayShort: "ПТ",
classes: [
{ time: "18:3020:00", trainer: "Анастасия Чалей", type: "Exotic Pole Dance" },
{ time: "21:3023:00", trainer: "Лилия Огурцова", type: "Exotic Pole Dance", hasSlots: true },
],
},
{
day: "Суббота",
dayShort: "СБ",
classes: [
{ time: "10:3012:00", trainer: "Елена Тарасевич", type: "Body Plastic" },
{ time: "12:0013:30", trainer: "Ольга Демидова", type: "Pole Dance" },
],
},
],
},
],
},
news: {
title: "Новости",
items: [],
},
contact: {
title: "Контакты",
addresses: [
"г. Минск, Матерова, 17к4",
"г. Минск, Притыцкого, 62к1",
"г. Минск, Машерова, 17/4",
"г. Минск, Притыцкого, 62/М",
],
phone: "+375 29 389-70-01",
instagram: "https://instagram.com/blackheartdancehouse/",

97
src/data/seed.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* Seed script — populates the SQLite database from content.ts
* Run: npx tsx src/data/seed.ts
*/
import Database from "better-sqlite3";
import path from "path";
import { siteContent } from "./content";
const DB_PATH =
process.env.DATABASE_PATH ||
path.join(process.cwd(), "db", "blackheart.db");
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
// Create tables
db.exec(`
CREATE TABLE IF NOT EXISTS sections (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS team_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
role TEXT NOT NULL,
image TEXT NOT NULL,
instagram TEXT,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
`);
// Seed sections (team members go in their own table)
const sectionData: Record<string, unknown> = {
meta: siteContent.meta,
hero: siteContent.hero,
about: siteContent.about,
classes: siteContent.classes,
masterClasses: siteContent.masterClasses,
faq: siteContent.faq,
pricing: siteContent.pricing,
schedule: siteContent.schedule,
contact: siteContent.contact,
};
// Team section stores only the title
sectionData.team = { title: siteContent.team.title };
const upsertSection = db.prepare(
`INSERT INTO sections (key, data, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
);
const insertMember = db.prepare(
`INSERT INTO team_members (name, role, image, instagram, description, sort_order)
VALUES (?, ?, ?, ?, ?, ?)`
);
const tx = db.transaction(() => {
// Upsert all sections
for (const [key, data] of Object.entries(sectionData)) {
upsertSection.run(key, JSON.stringify(data));
}
// Clear existing team members and re-insert
db.prepare("DELETE FROM team_members").run();
siteContent.team.members.forEach((m, i) => {
insertMember.run(
m.name,
m.role,
m.image,
m.instagram ?? null,
m.description ?? null,
i
);
});
});
tx();
const sectionCount = (
db.prepare("SELECT COUNT(*) as c FROM sections").get() as { c: number }
).c;
const memberCount = (
db.prepare("SELECT COUNT(*) as c FROM team_members").get() as { c: number }
).c;
console.log(`Seeded ${sectionCount} sections and ${memberCount} team members.`);
console.log(`Database: ${DB_PATH}`);
db.close();

View File

@@ -0,0 +1,45 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
interface UseShowcaseRotationOptions {
totalItems: number;
autoPlayInterval?: number;
pauseDuration?: number;
}
export function useShowcaseRotation({
totalItems,
autoPlayInterval = 4000,
pauseDuration = 10000,
}: UseShowcaseRotationOptions) {
const [activeIndex, setActiveIndex] = useState(0);
const pausedUntil = useRef(0);
const hoveringRef = useRef(false);
const select = useCallback(
(index: number) => {
setActiveIndex(index);
pausedUntil.current = Date.now() + pauseDuration;
},
[pauseDuration],
);
const setHovering = useCallback((hovering: boolean) => {
hoveringRef.current = hovering;
}, []);
useEffect(() => {
if (totalItems <= 1) return;
const id = setInterval(() => {
if (hoveringRef.current) return;
if (Date.now() < pausedUntil.current) return;
setActiveIndex((prev) => (prev + 1) % totalItems);
}, autoPlayInterval);
return () => clearInterval(id);
}, [totalItems, autoPlayInterval]);
return { activeIndex, select, setHovering };
}

53
src/lib/auth-edge.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Edge-compatible auth helpers (for middleware).
* Uses Web Crypto API instead of Node.js crypto.
*/
const COOKIE_NAME = "bh-admin-token";
function getSecret(): string {
const secret = process.env.AUTH_SECRET;
if (!secret) throw new Error("AUTH_SECRET is not set");
return secret;
}
function base64urlEncode(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function hmacSign(data: string, secret: string): Promise<string> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
return base64urlEncode(sig);
}
export async function verifyToken(token: string): Promise<boolean> {
try {
const [data, sig] = token.split(".");
if (!data || !sig) return false;
const expectedSig = await hmacSign(data, getSecret());
if (sig !== expectedSig) return false;
const payload = JSON.parse(atob(data.replace(/-/g, "+").replace(/_/g, "/"))) as {
role: string;
exp: number;
};
return payload.role === "admin" && payload.exp > Date.now();
} catch {
return false;
}
}
export { COOKIE_NAME };

78
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,78 @@
import { cookies } from "next/headers";
import crypto from "crypto";
const COOKIE_NAME = "bh-admin-token";
const TOKEN_TTL = 24 * 60 * 60 * 1000; // 24 hours
function getSecret(): string {
const secret = process.env.AUTH_SECRET;
if (!secret) throw new Error("AUTH_SECRET is not set");
return secret;
}
function getAdminPassword(): string {
const pw = process.env.ADMIN_PASSWORD;
if (!pw) throw new Error("ADMIN_PASSWORD is not set");
return pw;
}
export function verifyPassword(password: string): boolean {
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 {
const payload = {
role: "admin",
exp: Date.now() + TOKEN_TTL,
};
const data = Buffer.from(JSON.stringify(payload)).toString("base64url");
const sig = crypto
.createHmac("sha256", getSecret())
.update(data)
.digest("base64url");
return `${data}.${sig}`;
}
export async function isAuthenticated(): Promise<boolean> {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) return false;
return verifyTokenNode(token);
}
/** Node.js runtime token verification (for API routes / server components) */
function verifyTokenNode(token: string): boolean {
try {
const [data, sig] = token.split(".");
if (!data || !sig) return false;
const expectedSig = crypto
.createHmac("sha256", getSecret())
.update(data)
.digest("base64url");
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) return false;
const payload = JSON.parse(
Buffer.from(data, "base64url").toString()
) as { role: string; exp: number };
return payload.role === "admin" && payload.exp > Date.now();
} catch {
return false;
}
}
export const CSRF_COOKIE_NAME = "bh-csrf-token";
export function generateCsrfToken(): string {
return crypto.randomBytes(32).toString("base64url");
}
export { COOKIE_NAME };

21
src/lib/config.ts Normal file
View File

@@ -0,0 +1,21 @@
export const UI_CONFIG = {
scrollThresholds: {
header: 20,
backToTop: 600,
},
team: {
autoPlayMs: 4500,
pauseMs: 12000,
cardSpacing: 260,
stageHeight: 440,
floatingHeartsCount: 12,
},
faq: {
visibleCount: 4,
},
showcase: {
autoPlayInterval: 5000,
fadeMs: 250,
swipeThreshold: 50,
},
} as const;

View File

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

29
src/lib/content.ts Normal file
View File

@@ -0,0 +1,29 @@
import { getSiteContent } from "@/lib/db";
import { siteContent as fallback } from "@/data/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 {
const now = Date.now();
if (cached && now < cached.expiresAt) {
return cached.data;
}
try {
const content = getSiteContent();
if (content) {
cached = { data: content, expiresAt: now + CACHE_TTL };
return content;
}
return fallback;
} catch {
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 });
}

1206
src/lib/db.ts Normal file

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

Some files were not shown because too many files have changed in this diff Show More