Compare commits

...

116 Commits

Author SHA1 Message Date
e64119aaa0 feat: hall info on booking cards, notes styling, sort + highlight fixes
- Add hall badge to Open Day and Classes booking cards
- Hall in group labels for Open Day and MC tabs
- Hall in reminders event labels
- Save confirmed_hall for group bookings (migration 17)
- Page-level hall filter for all tabs
- Waiting list uses total bookings (matches public display)
- Notes styling: subtle gray text, gold icon + white text on hover
- Cards: sort newly changed to top of status group
- Fix Open Day notes not showing (missing from row type + mapper)
2026-03-25 14:40:24 +03:00
eb949f1a37 feat: booking UX improvements — waiting list, card focus, sort order
- Auto-note "Лист ожидания" for registrations when class is full
- Waiting list triggers on confirmed count (not total registrations)
- Card highlight + scroll after status change
- Hover effect on booking cards
- Freshly changed cards appear first in their status group
- Polling no longer remounts tabs (fixes page jump on approve)
- Fix MasterClassesData missing waitingListText type
- Add Turbopack troubleshooting docs to CLAUDE.md
2026-03-25 12:53:45 +03:00
b251ee5138 feat: phone input mask +375 (XX) XXX-XX-XX in manual booking modal 2026-03-25 00:18:54 +03:00
5e93c9a746 feat: auto-scroll and highlight newly added items in all array editors 2026-03-25 00:07:54 +03:00
540d3a9297 fix: dashboard cards no longer disappear on refresh — use prop trigger instead of key remount 2026-03-25 00:00:55 +03:00
d08df61b2a fix: remove 'Закрыть' button from success popup — X button is enough 2026-03-24 23:53:41 +03:00
259b31a722 feat: admin-editable success + waiting list messages for MC and Open Day 2026-03-24 23:52:07 +03:00
0c39bdba5e refactor: compact searchable SelectField for Open Day, removed time field from slot editor 2026-03-24 23:34:16 +03:00
1047b71abe feat: auto-focus and scroll to newly created class in Open Day schedule 2026-03-24 23:11:53 +03:00
d0fad4aae5 feat: discount toggle flag, hide price from success popup
- Admin: "Добавить скидку" toggle — OFF hides discount fields and sets to 0
- Public: discount line hidden when discountPrice=0
- Success popup: removed price/booking count info
2026-03-24 23:07:06 +03:00
eb6ec5aeb6 feat: toast popup for save status — 'Сохранено' or error, no layout jump 2026-03-24 22:55:13 +03:00
8ecebe686c fix: remove all 'Сохранение...' text, show toast popup on save instead 2026-03-24 22:44:22 +03:00
0ae8f57cde fix: empty participant fields show red error, no save until filled 2026-03-24 22:34:12 +03:00
b22e4c5d39 fix: participant limits — red error when max < min, no save on invalid, shows 0 2026-03-24 22:27:38 +03:00
d08905ee93 feat: min/max participants — shared ParticipantLimits component
- New ParticipantLimits component in FormField.tsx (reusable)
- Used in both Open Day settings and MC editor — identical layout
- Open Day: event-level min/max (DB migration 15)
- MC: per-event min/max (JSON fields)
- Public: waiting list when full, spots counter, amber success modal
2026-03-24 22:11:10 +03:00
4acc88c1ab feat: Open Day class duration + max participants, UI fixes
- Class duration: editable end time (was hardcoded +1h), shown as range
- Max participants per class (0 = unlimited), shown as "3/10 чел."
- New DB migration 14: max_participants column on open_day_classes
- Min bookings moved to separate row with hint text
- "Скидка" renamed to "Цена со скидкой" for clarity
- Cancel/recover icon: Ban for cancel, RotateCcw for recover
2026-03-24 19:45:31 +03:00
353484af2e refactor: Open Day schedule — hall selector instead of full grid
Replace wide multi-column hall grid with hall selector tabs + single
column time slots. Each hall tab shows class count badge. Scales
better as more halls are added.
2026-03-24 19:34:02 +03:00
2693491fee refactor: remove booking section from Open Day admin (managed in bookings page) 2026-03-24 19:31:08 +03:00
8343374969 fix: BLACK HEART header links to /admin dashboard, not public site 2026-03-24 19:20:38 +03:00
4dabca52ee feat: BLACK HEART header text links to main site 2026-03-24 19:19:21 +03:00
afe9f7012c fix: reminders card — single line layout matching other cards, cleanup unused prop 2026-03-24 19:12:01 +03:00
87ba4d232a feat: reminder status counts in dashboard card
Напоминания card now shows: не спрош. (gold), придёт (green),
не придёт (red) — same visual style as booking cards. Clickable
to navigate to reminders tab. Skipped "нет ответа" per user request.
2026-03-24 19:08:37 +03:00
a95676ea6a fix: dashboard status counts always set filter (no toggle), hover underline
- Clicking a status count always sets that filter, even when navigating
  to a different tab (previously toggled off if same filter was active)
- Click card background to reset filter to 'all'
- Hover underline on status count links
2026-03-24 19:00:12 +03:00
67d8f6330c refactor: dashboard cards with clickable status counts + tab bar restored
- Dashboard cards show all 4 statuses inline: new (gold), contacted (blue),
  confirmed (green), declined (red) — big numbers with consistent status colors
- Each number+label is clickable to filter the tab by that status
- Tab bar restored below dashboard
- Removed filter chips from SearchBar (dashboard handles filtering)
- Open Day card uses cyan border (distinct from blue contacted status)
2026-03-24 18:56:39 +03:00
745d72f36d fix: date validation, pre-fill on re-edit, MS_PER_DAY constant
- Extract MS_PER_DAY constant to lib/constants.ts
- ConfirmModal date: max=1 year, rejects past dates and malformed years
- ConfirmModal pre-fills existing date + group when re-editing (✎)
- Confirmed date display handles malformed dates gracefully
- Red border + error for invalid dates, submit disabled
2026-03-24 18:26:28 +03:00
f6d0491ca5 fix: auto-set reminder 'coming' only for today/tomorrow confirmations
Confirming a booking for today or tomorrow auto-sets reminder_status
to 'coming'. Confirming for later dates leaves it unset — admin will
need to check closer to the event.
2026-03-24 18:00:29 +03:00
a959c22a4c fix: confirmed bookings auto-set reminder_status to 'coming'
When a group booking is confirmed with a date, the reminder status
is automatically set to 'coming' — no need to manually mark it again
in the Reminders tab.
2026-03-24 17:55:46 +03:00
aa07b64c80 fix: dashboard counters refresh after status changes
When a booking status is changed, confirmed, or deleted in any tab,
the dashboard summary cards re-fetch to show updated counts. Previously
the dashboard was stale until page reload.
2026-03-24 17:52:21 +03:00
83456c6e9d fix: unique key prefix to avoid React duplicate key warning 2026-03-24 17:42:32 +03:00
42be063c7f fix: auto-refresh bookings silently instead of showing update banner
Polling detects new bookings and silently re-fetches tab data + dashboard
counts. No more "press to update" banner — data appears automatically.
2026-03-24 17:41:08 +03:00
18c11d0611 fix: MC series uses earliest slot date for registration cutoff
Multi-session master classes are a series — once the first session
passes, the group has started and registration closes. Changed all
MC date logic from "latest slot" / "any future slot" to "earliest slot":

- DashboardSummary: upcoming = earliest slot >= today
- McRegistrationsTab: archive = earliest slot < today
- AddBookingModal: only show MCs where earliest slot >= today
- Public MasterClasses: isUpcoming checks earliest slot
2026-03-24 17:35:31 +03:00
b48cc040e1 fix: search and status filter work together consistently
- Status filter chips stay visible during text search
- Search results filtered by selected status (search + filter = AND)
- Shows "Нет записей по фильтру" when search has results but filter excludes all
2026-03-24 17:23:16 +03:00
49d710b2e7 refactor: move status filter from per-tab pills to global search bar
- Remove FilterTabs from inside each booking tab
- Add compact status chips (Все/Новая/Связались/Подтверждено/Отказ)
  below the search bar — one global filter for all tabs
- Filter chips hidden during active text search
- Status filter toggles on click (click again to deselect)
- GenericBookingsList accepts filter as prop instead of managing internally
2026-03-24 17:15:47 +03:00
057f1ff1ee fix: MC tab shows 'all archived' instead of misleading filter counts
When all booking groups are archived (e.g. past MC events), the filter
pills no longer show 'Новая 5' etc. Instead shows 'Все записи в архиве'
with archive auto-expanded. Filter counts now exclude archived items.
2026-03-24 16:54:47 +03:00
2c64951cb3 fix: 4 bugs from regression testing
- BUG-1: Strip HTML tags in sanitizeName (prevent stored XSS)
- BUG-2: Strip HTML tags in notes via sanitizeText across all 3 booking APIs
- BUG-3: Dashboard excludes archived/past MCs and expired Open Day events from counts
- BUG-4: Truncate long names in booking cards to prevent overflow
2026-03-24 16:43:19 +03:00
aa0cfe35c3 fix: comprehensive bookings admin UX improvements
- #1 Delete confirmation dialog before removing bookings
- #2 Error toasts instead of silent .catch(() => {})
- #3 Optimistic rollback — UI reverts on API failure
- #4 Loading indicator on reminder status buttons
- #5 Search results are now actionable (status change + delete)
- #6 New bookings banner instead of full tab remount
- #7 Error states for failed data loads
- #8 InlineNotes only saves on blur when value changed
- #9 AddBookingModal supports Instagram/Telegram fields
- #10 Polling pauses when browser tab is hidden
- #11 Enter key submits ConfirmModal
2026-03-24 15:54:22 +03:00
669c4a3023 fix: reminders include contacted bookings, confirmation details persist after status revert
- Reminders query now includes 'contacted' group bookings with confirmed_date,
  preventing people from being forgotten when admin hasn't clicked "Подтвердить"
- Confirmation details (group, date) remain visible regardless of booking status,
  so "Вернуть" no longer hides previously entered info
2026-03-24 15:26:05 +03:00
c87c63bc4f feat: booking panel upgrade — refactor, notes, search, manual add, polling
Phase 1 — Refactor:
- Split monolith _shared.tsx into types.ts, BookingComponents, InlineNotes,
  GenericBookingsList, AddBookingModal, SearchBar (no more _ prefix)
- All 3 tabs use GenericBookingsList — shared status workflow, filters, archive

Phase 2 — Features:
- DB migration 13: add notes column to all booking tables
- Inline notes with amber highlight, auto-save 800ms debounce
- Confirm modal comment saves to notes field
- Manual add: 2 tabs (Занятие / Мероприятие), filters expired MCs, Open Day support
- Search bar: cross-table search by name/phone
- 10s polling for real-time updates (bookings page + sidebar badge)
- Status change marks booking as seen (fixes unread count on reset)
- Confirm modal stores human-readable group label instead of raw groupId
- Confirmed group bookings appear in Reminders tab

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:34:16 +03:00
87f488e2c1 feat: dashboard summary on bookings page, archive expired MC and Open Day
- Dashboard cards show new/pending counts per tab, click to navigate
- MC tab: expired master classes (past date or deleted) move to collapsible archive
- Open Day tab: past events move to archive section
- Date badges on MC group headers (gold active, strikethrough archived)
- Fix MC content API key (masterClasses not master-classes)
- Fuzzy title matching for MC registration → content date lookup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 19:13:49 +03:00
b906216317 feat: add status workflow to MC and Open Day bookings, refactor into separate files
- DB migration v12: add status column to mc_registrations and open_day_bookings
- MC and Open Day tabs now have full status workflow (new → contacted → confirmed/declined)
- Filter tabs with counts, status badges, action buttons matching group bookings
- Extract shared components (_shared.tsx): FilterTabs, StatusBadge, StatusActions, BookingCard, ContactLinks
- Split monolith into _McRegistrationsTab.tsx, _OpenDayBookingsTab.tsx, _shared.tsx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 19:05:44 +03:00
575c684cc5 fix: mobile menu — expand to fit all nav links, center align, shrink CTA button
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:55:11 +03:00
4721043530 feat: redesign floating contact as vertical icon stack, reposition back-to-top
- Floating contact: bottom-left expandable stack (phone, Instagram, signup modal)
- Mail icon toggle button, closes on outside click
- Back-to-top: bottom-right, same size (h-14 w-14) for visual balance

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:38:36 +03:00
8b5ed3c627 feat: add Главная and День открытых дверей nav links, match page section order
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:28:56 +03:00
c3cbd90fe4 feat: hero video management — diagonal split, mobile single video, admin editor
- Hero: diagonal 3-video split on desktop, single center video on mobile (CSS breakpoint)
- Videos render client-side only to avoid hydration mismatch
- Loading overlay (z-5) hides buffering without blocking text content
- Admin hero editor: 3 fixed slots (left/center/right) with upload, preview, remove
- Center slot marked as main (used on mobile), save blocked until all 3 filled
- Upload API: supports MP4/WebM (50MB) in hero folder alongside existing image uploads
- Added videos field to hero type and fallback data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:24:29 +03:00
1c6462d340 feat: hero diagonal split with 3 dancer videos
- Three video panels with diagonal clip-paths and gold separator lines
- Each video centered in its own column for clear visibility
- Replaced nastya.mp4 with nastya-2.mp4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:37:00 +03:00
b0c9a77474 fix: filter past MC events by start time, fix no-image card collapse, center cards
- isUpcoming() now checks startTime, not just date — past events hide after they start
- Cards without images get a gradient placeholder instead of collapsing to 0 height
- Merge two Reveal wrappers into one to prevent empty Reveal stuck at opacity 0
- Use flex layout with justify-center so single cards are centered

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 18:07:41 +03:00
650f8dc719 feat: add short description for team carousel cards
- Add shortDescription field to TeamMember type
- DB migration #11: add short_description column to team_members
- Admin editor: separate "Краткое описание (для карточки)" and "Полное описание"
- Carousel shows shortDescription with line-clamp-3, falls back to description
- Full description shown in "Подробнее" profile view

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:08:50 +03:00
1acda847b7 fix: clamp team member bio to 3 lines in carousel to prevent layout shift
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 13:49:04 +03:00
96e3333e9f feat: floating contact bar, remove pricing contact links, fix admin hooks
- Add FloatingContact bar (Записаться + Instagram) visible while scrolling
- Hides on hero and near contact section, centered at bottom
- Move BackToTop button up to avoid overlap
- Remove redundant ContactHint from Pricing section (floating bar covers it)
- Fix React hooks order violation in AdminLayout (early return before useEffect)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 13:35:36 +03:00
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
127 changed files with 16316 additions and 676 deletions

3
.gitignore vendored
View File

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

147
CLAUDE.md
View File

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

1
content_tmp.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,179 @@
# Booking Status Flow — Planned Changes
## Current Flow (What We Have Now)
```
new ──["Связались →"]──→ contacted ──["Подтвердить"]──→ confirmed
──["Отказ"]──→ declined
confirmed ──["Вернуть"]──→ contacted
declined ──["Вернуть"]──→ contacted
```
### What buttons appear at each status:
| Status | Buttons shown |
|-----------|------------------------------------|
| new | "Связались →" |
| contacted | "Подтвердить", "Отказ" |
| confirmed | "Вернуть" |
| declined | "Вернуть" |
---
## Problems Found
### 1. Can't confirm directly from `new`
Someone calls, books, and confirms in one conversation.
Admin must click "Связались" → wait → click "Подтвердить".
Two clicks for one real-world action.
### 2. Can't decline from `new`
Spam or invalid booking arrives.
Admin must first mark "Связались" (lie) → then "Отказ".
### 3. Reminders miss non-confirmed group bookings
SQL query in `db.ts` line 870:
```sql
WHERE status = 'confirmed' AND confirmed_date IN (?, ?)
```
If admin contacted someone and set a date but forgot to click "Подтвердить",
that person **never shows up** in reminders tab.
MC registrations don't have this problem — they show by event date regardless of status.
### 4. Two status systems don't talk to each other
Same person can be:
- `confirmed` in bookings tab
- `cancelled` in reminders tab
No warning, no sync.
### 5. "Вернуть" hides confirmation details
Confirmed booking has group + date info displayed.
"Вернуть" → status becomes `contacted` → info becomes invisible
(still in DB, but UI only shows it when `status === "confirmed"`).
Re-confirming requires filling everything again from scratch.
---
## Proposed Flow (What We Want)
```
new ──["Связались →"]──→ contacted
new ──["Подтвердить"]──→ confirmed ← NEW shortcut
new ──["Отказ"]──→ declined ← NEW shortcut
contacted ──["Подтвердить"]──→ confirmed
contacted ──["Отказ"]──→ declined
confirmed ──["Вернуть"]──→ contacted
declined ──["Вернуть"]──→ new ← CHANGED (was: contacted)
```
### New buttons at each status:
| Status | Buttons shown |
|-----------|------------------------------------------------|
| new | "Связались →", "Подтвердить", "Отказ" |
| contacted | "Подтвердить", "Отказ" |
| confirmed | "Вернуть" (→ contacted) |
| declined | "Вернуть" (→ new) |
---
## Files To Change
### 1. `src/app/admin/bookings/BookingComponents.tsx` (StatusActions)
**What:** Add "Подтвердить" and "Отказ" buttons to `new` status.
Change "Вернуть" from `declined` to go to `new` instead of `contacted`.
**Before:**
```tsx
{status === "new" && actionBtn("Связались →", ...)}
{status === "contacted" && (
actionBtn("Подтвердить", ...) + actionBtn("Отказ", ...)
)}
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", "contacted")}
```
**After:**
```tsx
{status === "new" && (
actionBtn("Связались →", "contacted")
+ actionBtn("Подтвердить", "confirmed")
+ actionBtn("Отказ", "declined")
)}
{status === "contacted" && (
actionBtn("Подтвердить", "confirmed")
+ actionBtn("Отказ", "declined")
)}
{status === "confirmed" && actionBtn("Вернуть", "contacted")}
{status === "declined" && actionBtn("Вернуть", "new")}
```
### 2. `src/app/admin/bookings/GenericBookingsList.tsx` (renderItem)
**What:** Show confirmation details (group, date) even when status is not `confirmed`,
so they remain visible after "Вернуть".
**Before:** `renderExtra` only shows confirmed details when `b.status === "confirmed"`
**After:** Show confirmed details whenever they exist, regardless of status
(this is actually in `page.tsx` GroupBookingsTab `renderExtra` — line 291)
### 3. `src/app/admin/bookings/page.tsx` (GroupBookingsTab renderExtra)
**What:** Change condition from `b.status === "confirmed" && (b.confirmedGroup || ...)` to just `(b.confirmedGroup || b.confirmedDate)`.
**Before:**
```tsx
{b.status === "confirmed" && (b.confirmedGroup || b.confirmedDate) && (
<span className="text-[10px] text-emerald-400/70">...</span>
)}
```
**After:**
```tsx
{(b.confirmedGroup || b.confirmedDate) && (
<span className="text-[10px] text-emerald-400/70">...</span>
)}
```
### 4. `src/lib/db.ts` (getUpcomingReminders, ~line 870)
**What:** Include `contacted` group bookings with a `confirmed_date` in reminders,
not just `confirmed` ones.
**Before:**
```sql
SELECT * FROM group_bookings WHERE status = 'confirmed' AND confirmed_date IN (?, ?)
```
**After:**
```sql
SELECT * FROM group_bookings WHERE status IN ('confirmed', 'contacted') AND confirmed_date IN (?, ?)
```
---
## Files NOT Changed
- `types.ts` — BookingStatus type stays the same (`new | contacted | confirmed | declined`)
- `SearchBar.tsx` — No changes needed
- `AddBookingModal.tsx` — No changes needed
- `InlineNotes.tsx` — No changes needed
- `McRegistrationsTab.tsx` — No changes needed (MC doesn't use ConfirmModal)
- `OpenDayBookingsTab.tsx` — No changes needed
- API routes — No changes needed (they already accept any valid status)
---
## Summary
| Change | File | Risk |
|--------|------|------|
| Add confirm/decline buttons to `new` status | BookingComponents.tsx | Low — additive |
| "Вернуть" from declined → `new` instead of `contacted` | BookingComponents.tsx | Low — minor behavior change |
| Show confirmation details at any status | page.tsx (renderExtra) | Low — visual only |
| Include `contacted` bookings in reminders | db.ts | Low — shows more data, not less |

View File

@@ -0,0 +1,115 @@
# Booking Panel — Regression Test Report
**Date**: 2026-03-24
**Scope**: Full manual regression of booking functionality (public + admin)
**Method**: Browser automation (Chrome) + API testing
---
## Test Results Summary
| # | Test | Result | Notes |
|---|------|--------|-------|
| 1 | Public booking — valid submission | PASS | Name, phone, Instagram, Telegram all saved correctly |
| 2 | Public booking — empty name | PASS | Returns 400 |
| 2 | Public booking — empty phone | PASS | Returns 400 |
| 2 | Public booking — whitespace-only name | PASS | Returns 400 |
| 2 | Public booking — XSS in name | WARN | Accepted (200), stored raw — React escapes on render, but `sanitizeName` should strip tags |
| 2 | Public booking — 500-char name | PASS | Truncated to 100 chars by `sanitizeName` |
| 2 | Public booking — SQL injection | PASS | Parameterized queries, no execution |
| 2 | Public booking — rate limiting | PASS | 429 after 5 requests/minute |
| 3 | Admin — new booking visible | PASS | All fields including Instagram/Telegram displayed |
| 4 | Admin — status: new → contacted | PASS | Instant optimistic update, counts refresh |
| 4 | Admin — status: contacted → confirmed (ConfirmModal) | PASS | Cascade selects work: Hall → Trainer → Group |
| 4 | Admin — ConfirmModal Enter key submit | PASS | Enter submits when all fields filled |
| 4 | Admin — ConfirmModal Escape key close | PASS | |
| 4 | Admin — confirmed details visible | PASS | Group + date shown in green text |
| 4 | Admin — "Вернуть" preserves details | PASS | Confirmation info stays visible after revert (our fix) |
| 5 | Admin — delete confirmation dialog | PASS | Shows name, "Отмена"/"Удалить" buttons, Escape closes |
| 5 | Admin — XSS in delete dialog | PASS | Name safely escaped in confirmation dialog |
| 6 | Admin — search finds booking | PASS | Debounced, finds by name |
| 6 | Admin — search results: status actions | PASS | "Подтвердить"/"Отказ" buttons work in search view |
| 6 | Admin — search results: delete | PASS | Trash icon with confirmation in search view |
| 6 | Admin — search: empty query | PASS | Returns 200, no results |
| 6 | Admin — search: 1-char query | PASS | Returns empty (min 2 chars on client) |
| 6 | Admin — search: XSS query | PASS | Returns 200, no injection |
| 7 | Admin — notes save via API | PASS | 200 OK |
| 7 | Admin — notes clear via API | PASS | 200 OK |
| 7 | Admin — XSS in notes | WARN | Accepted and stored raw — React escapes on render |
| 7 | Admin — SQL injection in notes | PASS | Table survived, parameterized queries |
| 8 | Admin — AddBookingModal opens | PASS | "Занятие"/"Мероприятие" tabs |
| 8 | Admin — Instagram/Telegram fields | PASS | New fields visible (our fix #9) |
| 9 | Admin — MC tab loads | PASS | Grouped by MC title, archive section works |
| 9 | Admin — Open Day tab loads | PASS | Grouped by time+style, person counts correct |
| 10 | Admin — Reminders tab (empty) | PASS | "Нет напоминаний" shown when no upcoming events |
| 12 | Admin — CSRF protection | PASS | All mutating calls return 403 without valid token |
| 12 | Admin — invalid action | PASS | Returns 400 |
| 12 | Admin — invalid status value | PASS | Returns 400 |
| 12 | Admin — delete invalid ID format | PASS | Returns 400 |
| 12 | Admin — delete non-existent | PASS | Returns 200 (idempotent) |
---
## Bugs Found
### BUG-1: XSS payload stored raw in database (LOW)
**Severity**: Low (React auto-escapes, no actual XSS execution)
**Steps**: Submit `<script>alert(1)</script>` as name in public booking form
**Expected**: `sanitizeName` should strip HTML tags
**Actual**: Stored as-is in DB, rendered safely by React
**Risk**: If content is ever rendered outside React (email, export, SSR with `dangerouslySetInnerHTML`), XSS could execute
**Fix**: Add HTML tag stripping in `sanitizeName()` — e.g., `name.replace(/<[^>]*>/g, '')`
### BUG-2: XSS in notes stored raw (LOW)
**Severity**: Low (same as BUG-1)
**Steps**: Save `<img src=x onerror=alert(1)>` as a note via API
**Actual**: Stored raw, rendered safely by React
**Fix**: Strip HTML in notes save path or add a `sanitizeText()` call
### BUG-3: Dashboard counts include archived/past MC bookings (MEDIUM)
**Severity**: Medium (misleading)
**Steps**: View bookings page with past MC events
**Expected**: Dashboard "Мастер-классы: 5 новых" should only count upcoming MCs
**Actual**: Counts ALL MC registrations regardless of event date. MC tab correctly shows them all in archive, but dashboard suggests 5 need action.
**Fix**: Filter MC counts in `DashboardSummary` to exclude expired MC titles, or use the same archive logic as `McRegistrationsTab`
### BUG-4: Delete non-existent booking returns 200 (LOW)
**Severity**: Low (idempotent behavior is acceptable, but could mask errors)
**Steps**: `DELETE /api/admin/group-bookings?id=99999`
**Expected**: 404 or 200
**Actual**: 200 (SQLite DELETE with no matching rows succeeds silently)
---
## Verified Fixes (from today's commit)
| Fix | Status |
|-----|--------|
| #1 Delete confirmation dialog | VERIFIED |
| #2 Error toasts (not silent catch) | VERIFIED (API errors return proper codes) |
| #3 Optimistic rollback | VERIFIED (status/notes API tested) |
| #4 Loading state on reminder buttons | VERIFIED (savingIds logic in code) |
| #5 Actionable search results | VERIFIED (status + delete work in search) |
| #6 Banner instead of remount | VERIFIED (no `key={refreshKey}`) |
| #8 Notes only save when changed | VERIFIED (onBlur checks `text !== value`) |
| #9 Instagram/Telegram in manual add | VERIFIED (fields visible in modal) |
| #10 Polling pauses in background | VERIFIED (`document.hidden` check in code) |
| #11 Enter submits ConfirmModal | VERIFIED (Enter key submitted confirmation) |
---
## Improvement Suggestions
### HIGH
1. **Strip HTML tags from user input** — Add `.replace(/<[^>]*>/g, '')` to `sanitizeName` and notes save path
2. **Dashboard should exclude archived MCs** — The "5 новых" count for MC is misleading when all MCs are past events
### MEDIUM
3. **Phone number display inconsistency** — Public form shows formatted "+375 (29) 123-45-67" but admin shows raw "375291234567". Consider formatting in admin view too.
4. **Long name overflows card** — The 100-char "AAAA..." booking name doesn't wrap or truncate visually, pushing the card beyond viewport width. Add `truncate` or `break-words` to the name element.
5. **Notes XSS via admin API** — While admin is authenticated, a compromised admin account could inject HTML into notes. Low risk but easy to prevent.
### LOW
6. **Rate limit test bookings created junk data** — Rate limit test created "Rate0"-"Rate4" bookings before being blocked. These pollute the database. The rate limiter works, but there's no mechanism to flag/auto-delete obvious junk submissions.
7. **No pagination** — If bookings grow to hundreds, the flat list will be slow. Not urgent for a small dance school, but worth planning.
8. **Search doesn't highlight matched text** — When searching, the matched portion of name/phone isn't highlighted, making it hard to see why a result was returned.

View File

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

892
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/video/ira.mp4 Normal file

Binary file not shown.

BIN
public/video/nadezda.mp4 Normal file

Binary file not shown.

BIN
public/video/nastya-2.mp4 Normal file

Binary file not shown.

BIN
public/video/nastya.mp4 Normal file

Binary file not shown.

View File

@@ -0,0 +1,265 @@
"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);
const [newItemIndex, setNewItemIndex] = useState<number | null>(null);
useEffect(() => { setMounted(true); }, []);
// Scroll to newly added item
useEffect(() => {
if (newItemIndex !== null && itemRefs.current[newItemIndex]) {
itemRefs.current[newItemIndex]?.scrollIntoView({ behavior: "smooth", block: "center" });
setNewItemIndex(null);
}
}, [newItemIndex, items]);
function updateItem(index: number, item: T) {
const updated = [...items];
updated[index] = item;
onChange(updated);
}
function removeItem(index: number) {
onChange(items.filter((_, i) => i !== index));
}
const startDrag = useCallback(
(clientX: number, clientY: number, index: number) => {
const el = itemRefs.current[index];
if (!el) return;
const rect = el.getBoundingClientRect();
setDragIndex(index);
setInsertAt(index);
setMousePos({ x: clientX, y: clientY });
setDragSize({ w: rect.width, h: rect.height });
setGrabOffset({ x: clientX - rect.left, y: clientY - rect.top });
},
[]
);
const handleGripMouseDown = useCallback(
(e: React.MouseEvent, index: number) => {
e.preventDefault();
startDrag(e.clientX, e.clientY, index);
},
[startDrag]
);
useEffect(() => {
if (dragIndex === null) return;
document.body.style.userSelect = "none";
function onMouseMove(e: MouseEvent) {
setMousePos({ x: e.clientX, y: e.clientY });
let newInsert = items.length;
for (let i = 0; i < items.length; i++) {
if (i === dragIndex) continue;
const el = itemRefs.current[i];
if (!el) continue;
const rect = el.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
newInsert = i;
break;
}
}
setInsertAt(newInsert);
}
function onMouseUp() {
// Read current values from state updaters but defer onChange to avoid
// calling parent setState during React's render/updater cycle
let capturedDrag: number | null = null;
let capturedInsert: number | null = null;
setDragIndex((prev) => { capturedDrag = prev; return null; });
setInsertAt((prev) => { capturedInsert = prev; return null; });
// Defer the reorder to next microtask so React finishes its batch first
queueMicrotask(() => {
if (capturedDrag !== null && capturedInsert !== null) {
let targetIndex = capturedInsert;
if (capturedDrag < targetIndex) targetIndex -= 1;
if (capturedDrag !== targetIndex) {
const updated = [...items];
const [moved] = updated.splice(capturedDrag, 1);
updated.splice(targetIndex, 0, moved);
onChange(updated);
}
}
});
}
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 bg-neutral-900/50 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-all ${
newItemIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
}`}
>
<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()]); setNewItemIndex(items.length); }}
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,830 @@
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";
}
const inputCls = "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";
export function InputField({
label,
value,
onChange,
placeholder,
type = "text",
}: InputFieldProps) {
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={inputCls}
/>
</div>
);
}
export function ParticipantLimits({
min,
max,
onMinChange,
onMaxChange,
}: {
min: number;
max: number;
onMinChange: (v: number) => void;
onMaxChange: (v: number) => void;
}) {
const [minStr, setMinStr] = useState(String(min));
const [maxStr, setMaxStr] = useState(String(max));
const minLocal = parseInt(minStr) || 0;
const maxLocal = parseInt(maxStr) || 0;
const minEmpty = minStr === "";
const maxEmpty = maxStr === "";
const maxError = (maxLocal > 0 && minLocal > 0 && maxLocal < minLocal) || minEmpty || maxEmpty;
function handleMin(raw: string) {
setMinStr(raw);
if (raw === "") return;
const v = parseInt(raw) || 0;
const curMax = parseInt(maxStr) || 0;
if (curMax > 0 && v > curMax) return;
onMinChange(v);
}
function handleMax(raw: string) {
setMaxStr(raw);
if (raw === "") return;
const v = parseInt(raw) || 0;
const curMin = parseInt(minStr) || 0;
if (v > 0 && v < curMin) return;
onMaxChange(v);
}
const errorMsg = minEmpty || maxEmpty
? "Поле не может быть пустым"
: maxLocal > 0 && minLocal > maxLocal
? "Макс. не может быть меньше мин."
: null;
return (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Мин. участников</label>
<input type="number" min={0} value={minStr} onChange={(e) => handleMin(e.target.value)}
className={`${inputCls} ${minEmpty ? "!border-red-500/50" : ""}`} />
<p className={`text-[10px] mt-1 ${minEmpty ? "text-red-400" : "text-neutral-600"}`}>
{minEmpty ? "Поле не может быть пустым" : "Если записей меньше — занятие можно отменить"}
</p>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Макс. участников</label>
<input type="number" min={0} value={maxStr} onChange={(e) => handleMax(e.target.value)}
className={`${inputCls} ${maxEmpty || (maxLocal > 0 && minLocal > maxLocal) ? "!border-red-500/50" : ""}`} />
<p className={`text-[10px] mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
{maxEmpty ? "Поле не может быть пустым" : maxLocal > 0 && minLocal > maxLocal ? "Макс. не может быть меньше мин." : "0 = без лимита. При заполнении — лист ожидания"}
</p>
</div>
</div>
);
}
interface TextareaFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
}
export function TextareaField({
label,
value,
onChange,
placeholder,
rows = 3,
}: TextareaFieldProps) {
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}, [value]);
useEffect(() => {
function onResize() {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<textarea
ref={ref}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
className="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 && <label className="block text-sm text-neutral-400 mb-1.5">{label}</label>}
<button
type="button"
onClick={() => {
setOpen(!open);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}}
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`}
>
{selectedLabel || placeholder || "Выберите..."}
</button>
{open && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
{options.length > 3 && (
<div className="p-1.5">
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск..."
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
/>
</div>
)}
<div className="max-h-48 overflow-y-auto">
{filtered.length === 0 && (
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
)}
{filtered.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
onChange(opt.value);
setOpen(false);
setSearch("");
}}
className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-white/5 ${
opt.value === value ? "text-gold bg-gold/5" : "text-white"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
)}
</div>
);
}
interface TimeRangeFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
}
export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFieldProps) {
const parts = value.split("");
const start = parts[0]?.trim() || "";
const end = parts[1]?.trim() || "";
function update(s: string, e: string) {
if (s && e) {
onChange(`${s}${e}`);
} else if (s) {
onChange(s);
} else {
onChange("");
}
}
function handleStartChange(newStart: string) {
if (newStart && end && newStart >= end) {
update(newStart, "");
} else {
update(newStart, end);
}
}
function handleEndChange(newEnd: string) {
if (start && newEnd && newEnd <= start) return;
update(start, newEnd);
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="flex items-center gap-2">
<input
type="time"
value={start}
onChange={(e) => handleStartChange(e.target.value)}
onBlur={onBlur}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
/>
<span className="text-neutral-500"></span>
<input
type="time"
value={end}
onChange={(e) => handleEndChange(e.target.value)}
onBlur={onBlur}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
/>
</div>
</div>
);
}
interface ToggleFieldProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
export function ToggleField({ label, checked, onChange }: ToggleFieldProps) {
return (
<label className="flex items-center gap-2 cursor-pointer">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 rounded-full transition-colors ${
checked ? "bg-gold" : "bg-neutral-700"
}`}
>
<span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
checked ? "translate-x-5" : ""
}`}
/>
</button>
<span className="text-sm text-neutral-300">{label}</span>
</label>
);
}
interface ListFieldProps {
label: string;
items: string[];
onChange: (items: string[]) => void;
placeholder?: string;
}
export function ListField({ label, items, onChange, placeholder }: ListFieldProps) {
const [draft, setDraft] = useState("");
function add() {
const val = draft.trim();
if (!val) return;
onChange([...items, val]);
setDraft("");
}
function remove(index: number) {
onChange(items.filter((_, i) => i !== index));
}
function update(index: number, value: string) {
onChange(items.map((item, i) => (i === index ? value : item)));
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="space-y-2">
{items.map((item, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
value={item}
onChange={(e) => update(i, e.target.value)}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-4 py-2 text-sm text-white outline-none focus:border-gold transition-colors"
/>
<button
type="button"
onClick={() => remove(i)}
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
>
<X size={14} />
</button>
</div>
))}
<div className="flex items-center gap-2">
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
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,108 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { Loader2, Check, AlertCircle } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
interface SectionEditorProps<T> {
sectionKey: string;
title: string;
children: (data: T, update: (data: T) => void) => React.ReactNode;
}
const DEBOUNCE_MS = 800;
export function SectionEditor<T>({
sectionKey,
title,
children,
}: SectionEditorProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [error, setError] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialLoadRef = useRef(true);
useEffect(() => {
adminFetch(`/api/admin/sections/${sectionKey}`)
.then((r) => {
if (!r.ok) throw new Error("Failed to load");
return r.json();
})
.then(setData)
.catch(() => setError("Не удалось загрузить данные"))
.finally(() => setLoading(false));
}, [sectionKey]);
const save = useCallback(async (dataToSave: T) => {
setStatus("saving");
setError("");
try {
const res = await adminFetch(`/api/admin/sections/${sectionKey}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(dataToSave),
});
if (!res.ok) throw new Error("Failed to save");
setStatus("saved");
setTimeout(() => setStatus((s) => (s === "saved" ? "idle" : s)), 2000);
} catch {
setStatus("error");
setError("Ошибка сохранения");
setTimeout(() => setStatus((s) => (s === "error" ? "idle" : s)), 4000);
}
}, [sectionKey]);
// Auto-save with debounce whenever data changes (skip initial load)
useEffect(() => {
if (!data) return;
if (initialLoadRef.current) {
initialLoadRef.current = false;
return;
}
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
save(data);
}, DEBOUNCE_MS);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [data, save]);
if (loading) {
return (
<div className="flex items-center gap-2 text-neutral-400">
<Loader2 size={18} className="animate-spin" />
Загрузка...
</div>
);
}
if (!data) {
return <p className="text-red-400">{error || "Данные не найдены"}</p>;
}
return (
<div>
<h1 className="text-2xl font-bold">{title}</h1>
{/* Fixed toast popup */}
{(status === "saved" || status === "error") && (
<div className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
status === "saved"
? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
: "bg-red-950/90 border-red-500/30 text-red-200"
}`}>
{status === "saved" && <><Check size={14} /> Сохранено</>}
{status === "error" && <><AlertCircle size={14} /> {error}</>}
</div>
)}
<div className="mt-6 space-y-6">{children(data, setData)}</div>
</div>
);
}

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,237 @@
"use client";
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
type Tab = "classes" | "events";
type EventType = "master-class" | "open-day";
interface McOption { title: string; date: string }
interface OdClass { id: number; style: string; time: string; hall: string }
interface OdEvent { id: number; date: string; title?: string }
export function AddBookingModal({
open,
onClose,
onAdded,
}: {
open: boolean;
onClose: () => void;
onAdded: () => void;
}) {
const [tab, setTab] = useState<Tab>("classes");
const [eventType, setEventType] = useState<EventType>("master-class");
const [name, setName] = useState("");
const [phone, setPhone] = useState("+375 ");
const [instagram, setInstagram] = useState("");
const [telegram, setTelegram] = useState("");
const [mcTitle, setMcTitle] = useState("");
const [mcOptions, setMcOptions] = useState<McOption[]>([]);
const [odClasses, setOdClasses] = useState<OdClass[]>([]);
const [odEventId, setOdEventId] = useState<number | null>(null);
const [odClassId, setOdClassId] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId("");
// Fetch upcoming MCs (filter out expired)
adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()).then((data: { items?: { title: string; slots: { date: string }[] }[] }) => {
const today = new Date().toISOString().split("T")[0];
const upcoming = (data.items || [])
.filter((mc) => {
const earliest = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
return earliest && earliest >= today;
})
.map((mc) => ({
title: mc.title,
date: mc.slots.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? ""),
}));
setMcOptions(upcoming);
if (upcoming.length === 0 && tab === "events") setEventType("open-day");
}).catch(() => {});
// Fetch active Open Day event + classes
adminFetch("/api/admin/open-day").then((r) => r.json()).then(async (events: OdEvent[]) => {
const today = new Date().toISOString().split("T")[0];
const active = events.find((e) => e.date >= today);
if (!active) { setOdEventId(null); setOdClasses([]); return; }
setOdEventId(active.id);
const classes = await adminFetch(`/api/admin/open-day/classes?eventId=${active.id}`).then((r) => r.json());
setOdClasses(classes);
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
function handlePhoneChange(raw: string) {
let digits = raw.replace(/\D/g, "");
if (!digits.startsWith("375")) digits = "375" + digits.replace(/^375?/, "");
digits = digits.slice(0, 12);
let formatted = "+375";
const rest = digits.slice(3);
if (rest.length > 0) formatted += " (" + rest.slice(0, 2);
if (rest.length >= 2) formatted += ") ";
if (rest.length > 2) formatted += rest.slice(2, 5);
if (rest.length > 5) formatted += "-" + rest.slice(5, 7);
if (rest.length > 7) formatted += "-" + rest.slice(7, 9);
setPhone(formatted);
}
const hasUpcomingMc = mcOptions.length > 0;
const hasOpenDay = odEventId !== null && odClasses.length > 0;
const hasEvents = hasUpcomingMc || hasOpenDay;
async function handleSubmit() {
if (!name.trim() || !phone.trim()) return;
setSaving(true);
try {
if (tab === "classes") {
await adminFetch("/api/admin/group-bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name.trim(),
phone: phone.trim(),
...(instagram.trim() && { instagram: instagram.trim() }),
...(telegram.trim() && { telegram: telegram.trim() }),
}),
});
} else if (eventType === "master-class") {
const title = mcTitle || mcOptions[0]?.title || "Мастер-класс";
await adminFetch("/api/admin/mc-registrations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ masterClassTitle: title, name: name.trim(), instagram: "-", phone: phone.trim() }),
});
} else if (eventType === "open-day" && odClassId && odEventId) {
await adminFetch("/api/open-day-register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ classId: Number(odClassId), eventId: odEventId, name: name.trim(), phone: phone.trim() }),
});
}
onAdded();
onClose();
} finally {
setSaving(false);
}
}
if (!open) return null;
const inputClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 placeholder-neutral-500";
const tabBtn = (key: Tab, label: string, disabled?: boolean) => (
<button
key={key}
onClick={() => !disabled && setTab(key)}
disabled={disabled}
className={`flex-1 rounded-lg py-2 text-xs font-medium transition-all ${
tab === key ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
}`}
>
{label}
</button>
);
const canSubmit = name.trim() && phone.trim() && !saving
&& (tab === "classes" || (tab === "events" && eventType === "master-class" && hasUpcomingMc)
|| (tab === "events" && eventType === "open-day" && odClassId));
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
<X size={16} />
</button>
<h3 className="text-base font-bold text-white">Добавить запись</h3>
<p className="mt-1 text-xs text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
<div className="mt-4 space-y-3">
{/* Tab: Classes vs Events */}
<div className="flex gap-2">
{tabBtn("classes", "Занятие")}
{tabBtn("events", "Мероприятие", !hasEvents)}
</div>
{/* Events sub-selector */}
{tab === "events" && (
<div className="flex gap-2">
{hasUpcomingMc && (
<button
onClick={() => setEventType("master-class")}
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${
eventType === "master-class" ? "bg-purple-500/15 text-purple-400 border border-purple-500/30" : "bg-neutral-800/50 text-neutral-500 border border-white/5 hover:text-neutral-300"
}`}
>
Мастер-класс
</button>
)}
{hasOpenDay && (
<button
onClick={() => setEventType("open-day")}
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${
eventType === "open-day" ? "bg-blue-500/15 text-blue-400 border border-blue-500/30" : "bg-neutral-800/50 text-neutral-500 border border-white/5 hover:text-neutral-300"
}`}
>
Open Day
</button>
)}
</div>
)}
{/* MC selector */}
{tab === "events" && eventType === "master-class" && mcOptions.length > 0 && (
<select value={mcTitle} onChange={(e) => setMcTitle(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
<option value="" className="bg-neutral-900">Выберите мастер-класс</option>
{mcOptions.map((mc) => (
<option key={mc.title} value={mc.title} className="bg-neutral-900">
{mc.title}
</option>
))}
</select>
)}
{/* Open Day class selector */}
{tab === "events" && eventType === "open-day" && odClasses.length > 0 && (
<select value={odClassId} onChange={(e) => setOdClassId(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
<option value="" className="bg-neutral-900">Выберите занятие</option>
{odClasses.map((c) => (
<option key={c.id} value={c.id} className="bg-neutral-900">
{c.time} · {c.style} · {c.hall}
</option>
))}
</select>
)}
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Имя" className={inputClass} />
<input type="tel" value={phone} onChange={(e) => handlePhoneChange(e.target.value)} placeholder="+375 (__) ___-__-__" className={inputClass} />
<div className="flex gap-2">
<input type="text" value={instagram} onChange={(e) => setInstagram(e.target.value)} placeholder="Instagram" className={inputClass} />
<input type="text" value={telegram} onChange={(e) => setTelegram(e.target.value)} placeholder="Telegram" className={inputClass} />
</div>
</div>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="mt-5 w-full rounded-lg bg-gold py-2.5 text-sm font-semibold text-black transition-all hover:bg-gold-light disabled:opacity-30 disabled:cursor-not-allowed"
>
{saving ? "Сохраняю..." : "Добавить"}
</button>
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1,175 @@
"use client";
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { Loader2, Trash2, Phone, Instagram, Send, X } from "lucide-react";
import { type BookingStatus, type BookingFilter, BOOKING_STATUSES } from "./types";
export function LoadingSpinner() {
return (
<div className="flex items-center gap-2 py-8 text-neutral-500 justify-center">
<Loader2 size={16} className="animate-spin" />
Загрузка...
</div>
);
}
export function EmptyState({ total }: { total: number }) {
return (
<p className="text-sm text-neutral-500 py-8 text-center">
{total === 0 ? "Пока нет записей" : "Нет записей по фильтру"}
</p>
);
}
// --- #1: Delete with confirmation ---
export function DeleteBtn({ onClick, name }: { onClick: () => void; name?: string }) {
const [confirming, setConfirming] = useState(false);
useEffect(() => {
if (!confirming) return;
function onKey(e: KeyboardEvent) { if (e.key === "Escape") setConfirming(false); }
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [confirming]);
return (
<>
<button
type="button"
onClick={() => setConfirming(true)}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
title="Удалить"
>
<Trash2 size={14} />
</button>
{confirming && createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirming(false)}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-xs rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<button onClick={() => setConfirming(false)} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
<X size={16} />
</button>
<h3 className="text-sm font-bold text-white">Удалить запись?</h3>
{name && <p className="mt-1 text-xs text-neutral-400">{name}</p>}
<p className="mt-2 text-xs text-neutral-500">Это действие нельзя отменить.</p>
<div className="mt-4 flex gap-2">
<button
onClick={() => setConfirming(false)}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 py-2 text-xs font-medium text-neutral-300 hover:bg-neutral-700 transition-colors"
>
Отмена
</button>
<button
onClick={() => { setConfirming(false); onClick(); }}
className="flex-1 rounded-lg bg-red-600 py-2 text-xs font-medium text-white hover:bg-red-500 transition-colors"
>
Удалить
</button>
</div>
</div>
</div>,
document.body
)}
</>
);
}
export function ContactLinks({ phone, instagram, telegram }: { phone?: string; instagram?: string; telegram?: string }) {
return (
<>
{phone && (
<a href={`tel:${phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
<Phone size={10} />{phone}
</a>
)}
{instagram && (
<a href={`https://ig.me/m/${instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
<Instagram size={10} />{instagram}
</a>
)}
{telegram && (
<a href={`https://t.me/${telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
<Send size={10} />{telegram}
</a>
)}
</>
);
}
export function FilterTabs({ filter, counts, total, onFilter }: {
filter: BookingFilter;
counts: Record<string, number>;
total: number;
onFilter: (f: BookingFilter) => void;
}) {
return (
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => onFilter("all")}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
}`}
>
Все <span className="text-neutral-500 ml-1">{total}</span>
</button>
{BOOKING_STATUSES.map((s) => (
<button
key={s.key}
onClick={() => onFilter(s.key)}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
}`}
>
{s.label}
{counts[s.key] > 0 && <span className="ml-1.5">{counts[s.key]}</span>}
</button>
))}
</div>
);
}
export function StatusBadge({ status }: { status: BookingStatus }) {
const conf = BOOKING_STATUSES.find((s) => s.key === status) || BOOKING_STATUSES[0];
return (
<span className={`text-[10px] font-medium ${conf.bg} ${conf.color} border ${conf.border} rounded-full px-2.5 py-0.5`}>
{conf.label}
</span>
);
}
export function StatusActions({ status, onStatus }: { status: BookingStatus; onStatus: (s: BookingStatus) => void }) {
const actionBtn = (label: string, onClick: () => void, cls: string) => (
<button onClick={onClick} className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium transition-all ${cls}`}>
{label}
</button>
);
return (
<div className="flex gap-1 ml-auto">
{status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20")}
{status === "contacted" && (
<>
{actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20")}
{actionBtn("Отказ", () => onStatus("declined"), "bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20")}
</>
)}
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", () => onStatus("contacted"), "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300")}
</div>
);
}
export function BookingCard({ status, highlight, children }: { status: BookingStatus; highlight?: boolean; children: React.ReactNode }) {
return (
<div
className={`rounded-lg border p-3 transition-all duration-200 cursor-default ${
status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50 hover:opacity-70 hover:border-red-500/30"
: status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02] hover:border-emerald-500/30 hover:bg-emerald-500/[0.05]"
: status === "new" ? "border-gold/20 bg-gold/[0.03] hover:border-gold/40 hover:bg-gold/[0.06]"
: "border-white/10 bg-neutral-800/30 hover:border-white/20 hover:bg-neutral-800/50"
}${highlight ? " ring-2 ring-gold/40 animate-[pulse_1s_ease-in-out_1]" : ""}`}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,230 @@
"use client";
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { ChevronDown, ChevronRight, Archive } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingGroup, sortByStatus } from "./types";
import { EmptyState, BookingCard, ContactLinks, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents";
import { fmtDate } from "./types";
import { InlineNotes } from "./InlineNotes";
import { useToast } from "./Toast";
interface GenericBookingsListProps<T extends BaseBooking> {
items: T[];
endpoint: string;
filter: BookingFilter;
onItemsChange: (fn: (prev: T[]) => T[]) => void;
onDataChange?: () => void;
groups?: BookingGroup<T>[];
renderExtra?: (item: T) => React.ReactNode;
onConfirm?: (id: number) => void;
}
export function GenericBookingsList<T extends BaseBooking>({
items,
endpoint,
filter,
onItemsChange,
onDataChange,
groups,
renderExtra,
onConfirm,
}: GenericBookingsListProps<T>) {
const [showArchived, setShowArchived] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [highlightId, setHighlightId] = useState<number | null>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const { showError } = useToast();
// Scroll to highlighted card and clear highlight after animation
useEffect(() => {
if (highlightId === null) return;
const timer = setTimeout(() => {
highlightRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, 50);
const clear = setTimeout(() => setHighlightId(null), 2000);
return () => { clearTimeout(timer); clearTimeout(clear); };
}, [highlightId]);
async function handleStatus(id: number, status: BookingStatus) {
if (status === "confirmed" && onConfirm) {
onConfirm(id);
return;
}
const prev = items.find((b) => b.id === id);
const prevStatus = prev?.status;
// Move changed item to front so it appears first in its status group after sort
onItemsChange((list) => {
const item = list.find((b) => b.id === id);
if (!item) return list;
return [{ ...item, status }, ...list.filter((b) => b.id !== id)];
});
setHighlightId(id);
try {
const res = await adminFetch(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-status", id, status }),
});
if (!res.ok) throw new Error();
onDataChange?.();
} catch {
if (prevStatus) onItemsChange((list) => list.map((b) => b.id === id ? { ...b, status: prevStatus } : b));
showError("Не удалось обновить статус");
}
}
async function handleDelete(id: number) {
try {
const res = await adminFetch(`${endpoint}?id=${id}`, { method: "DELETE" });
if (!res.ok) throw new Error();
onItemsChange((list) => list.filter((b) => b.id !== id));
onDataChange?.();
} catch {
showError("Не удалось удалить запись");
}
}
async function handleNotes(id: number, notes: string) {
const prev = items.find((b) => b.id === id);
const prevNotes = prev?.notes;
onItemsChange((list) => list.map((b) => b.id === id ? { ...b, notes: notes || undefined } : b));
try {
const res = await adminFetch(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-notes", id, notes }),
});
if (!res.ok) throw new Error();
} catch {
onItemsChange((list) => list.map((b) => b.id === id ? { ...b, notes: prevNotes } : b));
showError("Не удалось сохранить заметку");
}
}
function renderItem(item: T, isArchived: boolean) {
const isHighlighted = highlightId === item.id;
return (
<div key={item.id} ref={isHighlighted ? highlightRef : undefined}>
<BookingCard status={item.status} highlight={isHighlighted}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
<span className="font-medium text-white truncate max-w-[200px]">{item.name}</span>
<ContactLinks phone={item.phone} instagram={item.instagram} telegram={item.telegram} />
{renderExtra?.(item)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-neutral-600 text-xs">{fmtDate(item.createdAt)}</span>
<DeleteBtn onClick={() => handleDelete(item.id)} name={item.name} />
</div>
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<StatusBadge status={item.status} />
{!isArchived && <StatusActions status={item.status} onStatus={(s) => handleStatus(item.id, s)} />}
</div>
<InlineNotes value={item.notes || ""} onSave={(notes) => handleNotes(item.id, notes)} />
</BookingCard>
</div>
);
}
if (groups) {
const filteredGroups = groups.map((g) => ({
...g,
items: filter === "all" ? sortByStatus(g.items) : sortByStatus(g.items.filter((b) => b.status === filter)),
})).filter((g) => g.items.length > 0);
const activeGroups = filteredGroups.filter((g) => !g.isArchived);
const archivedGroups = filteredGroups.filter((g) => g.isArchived);
const archivedCount = archivedGroups.reduce((sum, g) => sum + g.items.length, 0);
const allArchived = activeGroups.length === 0 && archivedCount > 0 && filter === "all";
function renderGroup(group: BookingGroup<T>) {
const isOpen = expanded[group.key] ?? !group.isArchived;
const groupCounts = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
for (const item of group.items) groupCounts[item.status] = (groupCounts[item.status] || 0) + 1;
return (
<div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-white/5 opacity-60" : "border-white/10"}`}>
<button
onClick={() => setExpanded((p) => ({ ...p, [group.key]: !isOpen }))}
className={`w-full flex items-center gap-3 px-4 py-3 transition-colors text-left ${group.isArchived ? "bg-neutral-900/50 hover:bg-neutral-800/50" : "bg-neutral-900 hover:bg-neutral-800/80"}`}
>
{isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
{group.sublabel && (
<span className={`text-xs font-medium shrink-0 ${group.isArchived ? "text-neutral-500" : "text-gold"}`}>{group.sublabel}</span>
)}
<span className={`font-medium text-sm truncate ${group.isArchived ? "text-neutral-400" : "text-white"}`}>{group.label}</span>
{group.dateBadge && (
<span className={`text-[10px] rounded-full px-2 py-0.5 shrink-0 ${
group.isArchived ? "text-neutral-600 bg-neutral-800 line-through" : "text-gold bg-gold/10"
}`}>
{group.dateBadge}
</span>
)}
{group.isArchived && (
<span className="text-[10px] text-neutral-600 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">архив</span>
)}
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">{group.items.length} чел.</span>
{!group.isArchived && (
<div className="flex gap-2 ml-auto text-[10px]">
{groupCounts.new > 0 && <span className="text-gold">{groupCounts.new} новых</span>}
{groupCounts.contacted > 0 && <span className="text-blue-400">{groupCounts.contacted} связ.</span>}
{groupCounts.confirmed > 0 && <span className="text-emerald-400">{groupCounts.confirmed} подтв.</span>}
</div>
)}
</button>
{isOpen && (
<div className="px-4 pb-3 pt-1 space-y-2">
{group.items.map((item) => renderItem(item, group.isArchived))}
</div>
)}
</div>
);
}
return (
<div>
{allArchived && (
<p className="text-sm text-neutral-500 py-4">Все записи в архиве</p>
)}
{!allArchived && (
<div className="space-y-2">
{activeGroups.length === 0 && archivedGroups.length === 0 && <EmptyState total={items.length} />}
{activeGroups.map(renderGroup)}
</div>
)}
{archivedCount > 0 && (
<div className="mt-4">
<button
onClick={() => setShowArchived((v) => !v)}
className="flex items-center gap-2 text-xs text-neutral-500 hover:text-neutral-300 transition-colors"
>
<Archive size={13} />
{(showArchived || allArchived) ? "Скрыть архив" : `Архив (${archivedCount} записей)`}
{(showArchived || allArchived) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
{(showArchived || allArchived) && (
<div className="mt-2 space-y-2">
{archivedGroups.map(renderGroup)}
</div>
)}
</div>
)}
</div>
);
}
const filtered = useMemo(() => {
const list = filter === "all" ? items : items.filter((b) => b.status === filter);
return sortByStatus(list);
}, [items, filter]);
return (
<div>
<div className="space-y-2">
{filtered.length === 0 && <EmptyState total={items.length} />}
{filtered.map((item) => renderItem(item, false))}
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { StickyNote } from "lucide-react";
export function InlineNotes({ value, onSave }: { value: string; onSave: (notes: string) => void }) {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(value);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => { setText(value); }, [value]);
const save = useCallback((v: string) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => onSave(v), 800);
}, [onSave]);
function handleChange(v: string) {
setText(v);
save(v);
}
useEffect(() => {
if (editing && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.selectionStart = textareaRef.current.value.length;
}
}, [editing]);
if (!editing && !value) {
return (
<button
onClick={() => setEditing(true)}
className="mt-2 inline-flex items-center gap-1.5 text-[11px] text-neutral-600 hover:text-neutral-400 transition-colors"
>
<StickyNote size={11} />
Добавить заметку...
</button>
);
}
if (!editing) {
return (
<button
onClick={() => setEditing(true)}
className="mt-2 inline-flex items-start gap-1.5 text-left transition-colors group"
>
<StickyNote size={11} className="shrink-0 mt-0.5 text-neutral-500 group-hover:text-gold transition-colors" />
<span className="text-[11px] text-neutral-400 leading-relaxed whitespace-pre-wrap group-hover:text-white transition-colors">{value}</span>
</button>
);
}
return (
<div className="mt-2">
<textarea
ref={textareaRef}
value={text}
onChange={(e) => handleChange(e.target.value)}
onBlur={() => { clearTimeout(timerRef.current); if (text !== value) onSave(text.trim() ? text : ""); setEditing(false); }}
placeholder="Заметка..."
rows={2}
className="w-full rounded-md border border-amber-500/20 bg-amber-500/[0.06] px-2.5 py-1.5 text-[11px] text-amber-200/80 placeholder-neutral-600 outline-none focus:border-amber-500/40 resize-none"
/>
</div>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { adminFetch } from "@/lib/csrf";
import { type BookingFilter, type BaseBooking, type BookingGroup } from "./types";
import { LoadingSpinner } from "./BookingComponents";
import { GenericBookingsList } from "./GenericBookingsList";
interface McRegistration extends BaseBooking {
masterClassTitle: string;
}
interface McSlot { date: string; startTime: string }
interface McItem { title: string; slots: McSlot[]; location?: string }
export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFilter; onDataChange?: () => void }) {
const [regs, setRegs] = useState<McRegistration[]>([]);
const [mcDates, setMcDates] = useState<Record<string, string>>({});
const [mcLocations, setMcLocations] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
adminFetch("/api/admin/mc-registrations").then((r) => r.json()),
adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()),
]).then(([regData, mcData]: [McRegistration[], { items?: McItem[] }]) => {
setRegs(regData);
const dates: Record<string, string> = {};
const locations: Record<string, string> = {};
const mcItems = mcData.items || [];
for (const mc of mcItems) {
const earliestSlot = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
if (earliestSlot) dates[mc.title] = earliestSlot;
if (mc.location) locations[mc.title] = mc.location;
}
const regTitles = new Set(regData.map((r) => r.masterClassTitle));
for (const regTitle of regTitles) {
if (dates[regTitle]) continue;
for (const mc of mcItems) {
const earliestSlot = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
if (!earliestSlot) continue;
if (regTitle.toLowerCase().includes(mc.title.toLowerCase()) || mc.title.toLowerCase().includes(regTitle.toLowerCase())) {
dates[regTitle] = earliestSlot;
break;
}
}
}
setMcDates(dates);
setMcLocations(locations);
}).catch(() => {}).finally(() => setLoading(false));
}, []);
const today = new Date().toISOString().split("T")[0];
const groups = useMemo((): BookingGroup<McRegistration>[] => {
const map: Record<string, McRegistration[]> = {};
for (const r of regs) {
if (!map[r.masterClassTitle]) map[r.masterClassTitle] = [];
map[r.masterClassTitle].push(r);
}
return Object.entries(map).map(([title, items]) => {
const date = mcDates[title];
const isArchived = !date || date < today;
return {
key: title,
label: mcLocations[title] ? `${title} · ${mcLocations[title]}` : title,
dateBadge: date ? new Date(date + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : undefined,
items,
isArchived,
};
});
}, [regs, mcDates, mcLocations, today]);
if (loading) return <LoadingSpinner />;
return (
<GenericBookingsList<McRegistration>
items={regs}
endpoint="/api/admin/mc-registrations"
filter={filter}
onItemsChange={setRegs}
onDataChange={onDataChange}
groups={groups}
/>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { adminFetch } from "@/lib/csrf";
import { type BookingFilter, type BaseBooking, type BookingGroup } from "./types";
import { LoadingSpinner } from "./BookingComponents";
import { GenericBookingsList } from "./GenericBookingsList";
interface OpenDayBooking extends BaseBooking {
classId: number;
eventId: number;
classStyle?: string;
classTrainer?: string;
classTime?: string;
classHall?: string;
}
interface EventInfo { id: number; date: string; title?: string }
export function OpenDayBookingsTab({ filter, hallFilter = "all", onDataChange }: { filter: BookingFilter; hallFilter?: string; onDataChange?: () => void }) {
const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
const [events, setEvents] = useState<EventInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
adminFetch("/api/admin/open-day")
.then((r) => r.json())
.then(async (evts: EventInfo[]) => {
setEvents(evts);
if (evts.length === 0) return;
const allBookings: OpenDayBooking[] = [];
for (const ev of evts) {
const data = await adminFetch(`/api/admin/open-day/bookings?eventId=${ev.id}`).then((r) => r.json());
allBookings.push(...data);
}
setBookings(allBookings);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const today = new Date().toISOString().split("T")[0];
const eventDateMap = useMemo(() => {
const map: Record<number, string> = {};
for (const ev of events) map[ev.id] = ev.date;
return map;
}, [events]);
const filteredBookings = useMemo(() =>
hallFilter === "all" ? bookings : bookings.filter((b) => b.classHall === hallFilter),
[bookings, hallFilter]);
const groups = useMemo((): BookingGroup<OpenDayBooking>[] => {
const map: Record<string, { hall: string; time: string; style: string; trainer: string; items: OpenDayBooking[]; eventId: number }> = {};
for (const b of filteredBookings) {
const key = `${b.eventId}|${b.classHall}|${b.classTime}|${b.classStyle}`;
if (!map[key]) map[key] = { hall: b.classHall || "—", time: b.classTime || "—", style: b.classStyle || "—", trainer: b.classTrainer || "—", items: [], eventId: b.eventId };
map[key].items.push(b);
}
return Object.entries(map)
.sort(([, a], [, b]) => {
const hallCmp = a.hall.localeCompare(b.hall);
return hallCmp !== 0 ? hallCmp : a.time.localeCompare(b.time);
})
.map(([key, g]) => {
const eventDate = eventDateMap[g.eventId];
const isArchived = eventDate ? eventDate < today : false;
return {
key,
label: `${g.style} · ${g.hall}`,
sublabel: g.time,
dateBadge: isArchived && eventDate ? new Date(eventDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : undefined,
items: g.items,
isArchived,
};
});
}, [filteredBookings, eventDateMap, today]);
if (loading) return <LoadingSpinner />;
return (
<GenericBookingsList<OpenDayBooking>
items={filteredBookings}
endpoint="/api/admin/open-day/bookings"
filter={filter}
onItemsChange={setBookings}
onDataChange={onDataChange}
groups={groups}
renderExtra={(b) => (
<>
{b.classHall && <span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{b.classHall}</span>}
</>
)}
/>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useState, useRef } from "react";
import { Search, X } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { SearchResult } from "./types";
export function SearchBar({
onResults,
onClear,
}: {
onResults: (results: SearchResult[]) => void;
onClear: () => void;
}) {
const [query, setQuery] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
function handleChange(q: string) {
setQuery(q);
clearTimeout(timerRef.current);
if (q.trim().length < 2) {
onClear();
return;
}
timerRef.current = setTimeout(() => {
adminFetch(`/api/admin/bookings/search?q=${encodeURIComponent(q.trim())}`)
.then((r) => r.json())
.then((data: SearchResult[]) => onResults(data))
.catch(() => {});
}, 400);
}
function clear() {
setQuery("");
onClear();
}
return (
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
value={query}
onChange={(e) => handleChange(e.target.value)}
placeholder="Поиск по имени или телефону..."
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] py-2 pl-9 pr-8 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40"
/>
{query && (
<button onClick={clear} className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white">
<X size={14} />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import { useState, useEffect, useCallback, createContext, useContext } from "react";
import { X, AlertCircle, CheckCircle2 } from "lucide-react";
interface ToastItem {
id: number;
message: string;
type: "error" | "success";
}
interface ToastContextValue {
showError: (message: string) => void;
showSuccess: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue>({
showError: () => {},
showSuccess: () => {},
});
export function useToast() {
return useContext(ToastContext);
}
let nextId = 0;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const addToast = useCallback((message: string, type: "error" | "success") => {
const id = ++nextId;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
}, []);
const showError = useCallback((message: string) => addToast(message, "error"), [addToast]);
const showSuccess = useCallback((message: string) => addToast(message, "success"), [addToast]);
return (
<ToastContext.Provider value={{ showError, showSuccess }}>
{children}
{toasts.length > 0 && (
<div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
{toasts.map((t) => (
<div
key={t.id}
className={`flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm shadow-lg animate-in slide-in-from-right ${
t.type === "error"
? "bg-red-950/90 border-red-500/30 text-red-200"
: "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
}`}
>
{t.type === "error" ? <AlertCircle size={14} className="shrink-0" /> : <CheckCircle2 size={14} className="shrink-0" />}
<span className="flex-1">{t.message}</span>
<button
onClick={() => setToasts((prev) => prev.filter((tt) => tt.id !== t.id))}
className="shrink-0 text-neutral-400 hover:text-white"
>
<X size={12} />
</button>
</div>
))}
</div>
)}
</ToastContext.Provider>
);
}

View File

@@ -0,0 +1,988 @@
"use client";
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { Phone, Instagram, Send, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X, Plus } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { MS_PER_DAY } from "@/lib/constants";
import { type BookingStatus, type BookingFilter, type SearchResult, SHORT_DAYS, fmtDate } from "./types";
import { LoadingSpinner, ContactLinks, BookingCard, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents";
import { GenericBookingsList } from "./GenericBookingsList";
import { AddBookingModal } from "./AddBookingModal";
import { SearchBar } from "./SearchBar";
import { McRegistrationsTab } from "./McRegistrationsTab";
import { OpenDayBookingsTab } from "./OpenDayBookingsTab";
import { ToastProvider, useToast } from "./Toast";
// --- Types ---
interface GroupBooking {
id: number;
name: string;
phone: string;
groupInfo?: string;
instagram?: string;
telegram?: string;
notifiedConfirm: boolean;
notifiedReminder: boolean;
status: BookingStatus;
confirmedDate?: string;
confirmedGroup?: string;
confirmedComment?: string;
notes?: string;
createdAt: string;
}
type Tab = "reminders" | "classes" | "master-classes" | "open-day";
// --- Confirm Booking Modal ---
function ConfirmModal({
open,
bookingName,
groupInfo,
existingDate,
existingGroup,
allClasses,
onConfirm,
onClose,
}: {
open: boolean;
bookingName: string;
groupInfo?: string;
existingDate?: string;
existingGroup?: string;
allClasses: ScheduleClassInfo[];
onConfirm: (data: { group: string; hall?: string; date: string; comment?: string }) => void;
onClose: () => void;
}) {
const [hall, setHall] = useState("");
const [trainer, setTrainer] = useState("");
const [group, setGroup] = useState("");
const [date, setDate] = useState("");
const [comment, setComment] = useState("");
useEffect(() => {
if (!open) return;
const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0];
setDate(existingDate && existingDate.length === 10 ? existingDate : tomorrow); setComment("");
// Try to match groupInfo or existingGroup against schedule to pre-fill
const matchText = existingGroup || groupInfo;
if (matchText && allClasses.length > 0) {
const info = matchText.toLowerCase();
// Score each class against groupInfo, pick best match
let bestMatch: ScheduleClassInfo | null = null;
let bestScore = 0;
for (const c of allClasses) {
let score = 0;
if (info.includes(c.type.toLowerCase())) score += 3;
if (info.includes(c.trainer.toLowerCase())) score += 3;
if (info.includes(c.time)) score += 2;
const dayShort = (SHORT_DAYS[c.day] || c.day.slice(0, 2)).toLowerCase();
if (info.includes(dayShort)) score += 1;
const hallWords = c.hall.toLowerCase().split(/[\s/,]+/);
if (hallWords.some((w) => w.length > 2 && info.includes(w))) score += 2;
if (score > bestScore) { bestScore = score; bestMatch = c; }
}
const match = bestScore >= 4 ? bestMatch : null;
if (match) {
setHall(match.hall);
setTrainer(match.trainer);
setGroup(match.groupId || `${match.type}|${match.time}|${match.address}`);
return;
}
}
setHall(""); setTrainer(""); setGroup("");
}, [open, groupInfo, existingDate, existingGroup, allClasses]);
// Cascading options
const halls = useMemo(() => [...new Set(allClasses.map((c) => c.hall))], [allClasses]);
const trainers = useMemo(() => {
if (!hall) return [];
return [...new Set(allClasses.filter((c) => c.hall === hall).map((c) => c.trainer))].sort();
}, [allClasses, hall]);
const groups = useMemo(() => {
if (!hall || !trainer) return [];
const filtered = allClasses.filter((c) => c.hall === hall && c.trainer === trainer);
// Group by groupId — merge days for the same group
const byId = new Map<string, { type: string; slots: { day: string; time: string }[]; id: string }>();
for (const c of filtered) {
const id = c.groupId || `${c.type}|${c.time}|${c.address}`;
const existing = byId.get(id);
if (existing) {
if (!existing.slots.some((s) => s.day === c.day)) existing.slots.push({ day: c.day, time: c.time });
} else {
byId.set(id, { type: c.type, slots: [{ day: c.day, time: c.time }], id });
}
}
return [...byId.values()].map((g) => {
const sameTime = g.slots.every((s) => s.time === g.slots[0].time);
const label = sameTime
? `${g.type}, ${g.slots.map((s) => SHORT_DAYS[s.day] || s.day.slice(0, 2)).join("/")} ${g.slots[0].time}`
: `${g.type}, ${g.slots.map((s) => `${SHORT_DAYS[s.day] || s.day.slice(0, 2)} ${s.time}`).join(", ")}`;
return { label, value: g.id };
}).sort((a, b) => a.label.localeCompare(b.label));
}, [allClasses, hall, trainer]);
// Reset downstream on upstream change (skip during initial pre-fill)
const initRef = useRef(false);
useEffect(() => {
if (initRef.current) { setTrainer(""); setGroup(""); }
initRef.current = true;
}, [hall]);
useEffect(() => {
if (initRef.current && trainer === "") setGroup("");
}, [trainer]);
// Reset init flag when modal closes
useEffect(() => { if (!open) initRef.current = false; }, [open]);
// #11: Keyboard submit
const today = open ? new Date().toISOString().split("T")[0] : "";
const canSubmit = group && date && date.length === 10 && date >= today;
const handleSubmit = useCallback(() => {
if (canSubmit) {
const groupLabel = groups.find((g) => g.value === group)?.label || group;
onConfirm({ group: groupLabel, hall: hall || undefined, date, comment: comment.trim() || undefined });
}
}, [canSubmit, group, hall, date, comment, groups, onConfirm]);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
if (e.key === "Enter" && canSubmit) { e.preventDefault(); handleSubmit(); }
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose, canSubmit, handleSubmit]);
if (!open) return null;
const selectClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 [color-scheme:dark] disabled:opacity-30 disabled:cursor-not-allowed";
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" onClick={onClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose} aria-label="Закрыть" className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
<X size={16} />
</button>
<h3 className="text-base font-bold text-white">Подтвердить запись</h3>
<p className="mt-1 text-xs text-neutral-400">{bookingName}</p>
<div className="mt-4 space-y-3">
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Зал</label>
<select value={hall} onChange={(e) => setHall(e.target.value)} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите зал</option>
{halls.map((h) => <option key={h} value={h} className="bg-neutral-900">{h}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Тренер</label>
<select value={trainer} onChange={(e) => setTrainer(e.target.value)} disabled={!hall} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите тренера</option>
{trainers.map((t) => <option key={t} value={t} className="bg-neutral-900">{t}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Группа</label>
<select value={group} onChange={(e) => setGroup(e.target.value)} disabled={!trainer} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите группу</option>
{groups.map((g) => <option key={g.value} value={g.value} className="bg-neutral-900">{g.label}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Дата занятия</label>
<input
type="date"
value={date}
min={today}
max={new Date(Date.now() + MS_PER_DAY * 365).toISOString().split("T")[0]}
disabled={!group}
onChange={(e) => setDate(e.target.value)}
className={`${selectClass} ${date && (date < today || date.length !== 10) ? "!border-red-500/50" : ""}`}
/>
{date && (date < today || date.length !== 10) && (
<p className="text-[10px] text-red-400 mt-1">{date < today ? "Дата не может быть в прошлом" : "Неверный формат даты"}</p>
)}
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
<input
type="text"
value={comment}
disabled={!group}
onChange={(e) => setComment(e.target.value)}
placeholder="Первое занятие, пробный"
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40 disabled:opacity-30 disabled:cursor-not-allowed"
/>
</div>
</div>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="mt-5 w-full rounded-lg bg-emerald-600 py-2.5 text-sm font-semibold text-white transition-all hover:bg-emerald-500 disabled:opacity-30 disabled:cursor-not-allowed"
>
Подтвердить
</button>
</div>
</div>,
document.body
);
}
// --- Group Bookings Tab ---
interface ScheduleClassInfo { type: string; trainer: string; time: string; day: string; hall: string; address: string; groupId?: string }
interface ScheduleLocation { name: string; address: string; days: { day: string; classes: { time: string; trainer: string; type: string; groupId?: string }[] }[] }
function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onDataChange?: () => void }) {
const [bookings, setBookings] = useState<GroupBooking[]>([]);
const [allClasses, setAllClasses] = useState<ScheduleClassInfo[]>([]);
const [loading, setLoading] = useState(true);
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
Promise.all([
adminFetch("/api/admin/group-bookings").then((r) => r.json()),
adminFetch("/api/admin/sections/schedule").then((r) => r.json()),
])
.then(([bookingData, scheduleData]: [GroupBooking[], { locations?: ScheduleLocation[] }]) => {
setBookings(bookingData);
const classes: ScheduleClassInfo[] = [];
for (const loc of scheduleData.locations || []) {
const shortAddr = loc.address?.split(",")[0] || loc.name;
for (const day of loc.days) {
for (const cls of day.classes) {
classes.push({ type: cls.type, trainer: cls.trainer, time: cls.time, day: day.day, hall: loc.name, address: shortAddr, groupId: cls.groupId });
}
}
}
setAllClasses(classes);
})
.catch(() => setError(true))
.finally(() => setLoading(false));
}, []);
const confirmingBooking = bookings.find((b) => b.id === confirmingId);
async function handleConfirm(data: { group: string; hall?: string; date: string; comment?: string }) {
if (!confirmingId) return;
const existing = bookings.find((b) => b.id === confirmingId);
const notes = data.comment
? (existing?.notes ? `${existing.notes}\n${data.comment}` : data.comment)
: existing?.notes;
setBookings((prev) => prev.map((b) => b.id === confirmingId ? {
...b, status: "confirmed" as BookingStatus,
confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes,
} : b));
await Promise.all([
adminFetch("/api/admin/group-bookings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }),
}),
data.comment ? adminFetch("/api/admin/group-bookings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
}) : Promise.resolve(),
]);
setConfirmingId(null);
onDataChange?.();
}
if (loading) return <LoadingSpinner />;
if (error) return <p className="text-sm text-red-400 py-8 text-center">Не удалось загрузить данные</p>;
return (
<>
<GenericBookingsList<GroupBooking>
items={bookings}
endpoint="/api/admin/group-bookings"
filter={filter}
onItemsChange={setBookings}
onDataChange={onDataChange}
onConfirm={(id) => setConfirmingId(id)}
renderExtra={(b) => (
<>
{b.groupInfo && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>}
{b.confirmedHall && <span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{b.confirmedHall}</span>}
{(b.confirmedGroup || b.confirmedDate) && (
<button
onClick={(e) => { e.stopPropagation(); setConfirmingId(b.id); }}
className="text-[10px] text-emerald-400/70 hover:text-emerald-300 transition-colors cursor-pointer"
title="Изменить"
>
{b.confirmedGroup}
{b.confirmedDate && b.confirmedDate.length === 10 && ` · ${new Date(b.confirmedDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`}
{" ✎"}
</button>
)}
</>
)}
/>
<ConfirmModal
open={confirmingId !== null}
bookingName={confirmingBooking?.name ?? ""}
groupInfo={confirmingBooking?.groupInfo}
existingDate={confirmingBooking?.confirmedDate}
existingGroup={confirmingBooking?.confirmedGroup}
allClasses={allClasses}
onClose={() => setConfirmingId(null)}
onConfirm={handleConfirm}
/>
</>
);
}
// --- Reminders Tab ---
interface ReminderItem {
id: number;
type: "class" | "master-class" | "open-day";
table: "mc_registrations" | "group_bookings" | "open_day_bookings";
name: string;
phone?: string;
instagram?: string;
telegram?: string;
reminderStatus?: string;
eventLabel: string;
eventHall?: string;
eventDate: string;
}
type ReminderStatus = "pending" | "coming" | "cancelled";
const STATUS_CONFIG: Record<ReminderStatus, { label: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }> = {
pending: { label: "Нет ответа", icon: Clock, color: "text-amber-400", bg: "bg-amber-500/10", border: "border-amber-500/20" },
coming: { label: "Придёт", icon: CheckCircle2, color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
cancelled: { label: "Не придёт", icon: XCircle, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" },
};
const TYPE_CONFIG = {
"master-class": { label: "МК", icon: Star, color: "text-purple-400" },
"open-day": { label: "Open Day", icon: DoorOpen, color: "text-gold" },
"class": { label: "Занятие", icon: Calendar, color: "text-blue-400" },
};
function RemindersTab() {
const [items, setItems] = useState<ReminderItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [savingIds, setSavingIds] = useState<Set<string>>(new Set());
const { showError } = useToast();
useEffect(() => {
adminFetch("/api/admin/reminders")
.then((r) => r.json())
.then((data: ReminderItem[]) => setItems(data))
.catch(() => setError(true))
.finally(() => setLoading(false));
}, []);
async function setStatus(item: ReminderItem, status: ReminderStatus | null) {
const key = `${item.table}-${item.id}`;
const prevStatus = item.reminderStatus;
setSavingIds((prev) => new Set(prev).add(key));
setItems((prev) => prev.map((i) => i.id === item.id && i.table === item.table ? { ...i, reminderStatus: status ?? undefined } : i));
try {
const res = await adminFetch("/api/admin/reminders", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ table: item.table, id: item.id, status }),
});
if (!res.ok) throw new Error();
} catch {
setItems((prev) => prev.map((i) => i.id === item.id && i.table === item.table ? { ...i, reminderStatus: prevStatus } : i));
showError("Не удалось обновить статус");
} finally {
setSavingIds((prev) => { const next = new Set(prev); next.delete(key); return next; });
}
}
if (loading) return <LoadingSpinner />;
if (error) return <p className="text-sm text-red-400 py-8 text-center">Не удалось загрузить напоминания</p>;
const today = new Date().toISOString().split("T")[0];
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split("T")[0];
const todayItems = items.filter((i) => i.eventDate === today);
const tomorrowItems = items.filter((i) => i.eventDate === tomorrow);
// Stats
function countByStatus(list: ReminderItem[]) {
const coming = list.filter((i) => i.reminderStatus === "coming").length;
const cancelled = list.filter((i) => i.reminderStatus === "cancelled").length;
const pending = list.filter((i) => i.reminderStatus === "pending").length;
const notAsked = list.filter((i) => !i.reminderStatus).length;
return { coming, cancelled, pending, notAsked, total: list.length };
}
if (items.length === 0) {
return (
<div className="py-12 text-center">
<Bell size={32} className="mx-auto text-neutral-600 mb-3" />
<p className="text-neutral-400">Нет напоминаний все на контроле</p>
<p className="text-xs text-neutral-600 mt-1">Здесь появятся записи на сегодня и завтра</p>
</div>
);
}
// Group items by event within each day
function groupByEvent(dayItems: ReminderItem[]) {
const map: Record<string, { type: ReminderItem["type"]; label: string; items: ReminderItem[] }> = {};
for (const item of dayItems) {
const key = `${item.type}|${item.eventLabel}`;
if (!map[key]) map[key] = { type: item.type, label: item.eventLabel, items: [] };
map[key].items.push(item);
}
return Object.values(map);
}
const STATUS_SECTIONS = [
{ key: "not-asked", label: "Не спрошены", color: "text-gold", bg: "bg-gold/10", match: (i: ReminderItem) => !i.reminderStatus },
{ key: "pending", label: "Нет ответа", color: "text-amber-400", bg: "bg-amber-500/10", match: (i: ReminderItem) => i.reminderStatus === "pending" },
{ key: "coming", label: "Придёт", color: "text-emerald-400", bg: "bg-emerald-500/10", match: (i: ReminderItem) => i.reminderStatus === "coming" },
{ key: "cancelled", label: "Не придёт", color: "text-red-400", bg: "bg-red-500/10", match: (i: ReminderItem) => i.reminderStatus === "cancelled" },
];
function renderPerson(item: ReminderItem) {
const currentStatus = item.reminderStatus as ReminderStatus | undefined;
const isSaving = savingIds.has(`${item.table}-${item.id}`);
return (
<div
key={`${item.table}-${item.id}`}
className={`rounded-lg border p-3 transition-colors ${
!currentStatus ? "border-gold/20 bg-gold/[0.03]"
: currentStatus === "coming" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
: currentStatus === "cancelled" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
: currentStatus === "pending" ? "border-amber-500/15 bg-amber-500/[0.02]"
: "border-white/5 bg-neutral-800/30"
}`}
>
<div className="flex items-center gap-2 flex-wrap text-sm">
<span className="font-medium text-white">{item.name}</span>
{item.phone && (
<a href={`tel:${item.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
<Phone size={10} />{item.phone}
</a>
)}
{item.instagram && (
<a href={`https://ig.me/m/${item.instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
<Instagram size={10} />{item.instagram}
</a>
)}
{item.telegram && (
<a href={`https://t.me/${item.telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
<Send size={10} />{item.telegram}
</a>
)}
<div className={`flex gap-1 ml-auto ${isSaving ? "opacity-50 pointer-events-none" : ""}`}>
{(["coming", "pending", "cancelled"] as ReminderStatus[]).map((st) => {
const conf = STATUS_CONFIG[st];
const Icon = conf.icon;
const active = currentStatus === st;
return (
<button
key={st}
onClick={() => setStatus(item, active ? null : st)}
disabled={isSaving}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium transition-all ${
active
? `${conf.bg} ${conf.color} border ${conf.border}`
: "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300"
}`}
>
<Icon size={10} />
{conf.label}
</button>
);
})}
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{[
{ label: "Сегодня", date: today, items: todayItems },
{ label: "Завтра", date: tomorrow, items: tomorrowItems },
]
.filter((g) => g.items.length > 0)
.map((group) => {
const eventGroups = groupByEvent(group.items);
return (
<div key={group.date}>
<div className="flex items-center gap-3 mb-3">
<h3 className="text-sm font-bold text-white">{group.label}</h3>
<span className="text-[10px] text-neutral-500">
{new Date(group.date + "T12:00").toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" })}
</span>
</div>
<div className="space-y-3">
{eventGroups.map((eg) => {
const typeConf = TYPE_CONFIG[eg.type];
const TypeIcon = typeConf.icon;
const egStats = countByStatus(eg.items);
return (
<div key={eg.label} className="rounded-xl border border-white/10 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-900">
<TypeIcon size={13} className={typeConf.color} />
<span className="text-sm font-medium text-white">{eg.label}{eg.items[0]?.eventHall ? ` · ${eg.items[0].eventHall}` : ""}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{eg.items.length} чел.</span>
<div className="flex gap-2 ml-auto text-[10px]">
{egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>}
{egStats.cancelled > 0 && <span className="text-red-400">{egStats.cancelled} не придёт</span>}
{egStats.pending > 0 && <span className="text-amber-400">{egStats.pending} нет ответа</span>}
{egStats.notAsked > 0 && <span className="text-gold">{egStats.notAsked} не спрошены</span>}
</div>
</div>
<div className="px-4 pb-3 pt-1">
{STATUS_SECTIONS
.map((sec) => ({ ...sec, items: eg.items.filter(sec.match) }))
.filter((sec) => sec.items.length > 0)
.map((sec) => (
<div key={sec.key} className="mt-2 first:mt-0">
<span className={`text-[10px] font-medium ${sec.color} ${sec.bg} rounded-full px-2 py-0.5`}>
{sec.label} · {sec.items.length}
</span>
<div className="mt-1.5 space-y-1.5">
{sec.items.map(renderPerson)}
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
// --- Dashboard Summary ---
interface TabCounts { new: number; contacted: number; confirmed: number; declined: number }
interface DashboardCounts {
classes: TabCounts;
mc: TabCounts;
od: TabCounts;
remindersToday: number;
remindersTomorrow: number;
remindersNotAsked: number;
remindersComing: number;
remindersCancelled: number;
}
function countByStatus(items: { status: string }[]): TabCounts {
const c = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
for (const i of items) if (i.status in c) c[i.status as keyof TabCounts]++;
return c;
}
function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
refreshTrigger: number;
onNavigate: (tab: Tab) => void;
onFilter: (f: BookingFilter) => void;
}) {
const [counts, setCounts] = useState<DashboardCounts | null>(null);
useEffect(() => {
const today = new Date().toISOString().split("T")[0];
const tomorrow = new Date(Date.now() + MS_PER_DAY).toISOString().split("T")[0];
Promise.all([
adminFetch("/api/admin/group-bookings").then((r) => r.json()),
Promise.all([
adminFetch("/api/admin/mc-registrations").then((r) => r.json()),
adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()),
]).then(([regs, mcData]: [{ status: string; masterClassTitle: string }[], { items?: { title: string; slots: { date: string }[] }[] }]) => {
const upcomingTitles = new Set<string>();
for (const mc of mcData.items || []) {
const earliest = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
if (earliest && earliest >= today) upcomingTitles.add(mc.title);
}
return regs.filter((r) => upcomingTitles.has(r.masterClassTitle));
}),
adminFetch("/api/admin/open-day").then((r) => r.json()).then(async (events: { id: number; date: string }[]) => {
const active = events.find((e) => e.date >= today);
if (!active) return [];
return adminFetch(`/api/admin/open-day/bookings?eventId=${active.id}`).then((r) => r.json());
}),
adminFetch("/api/admin/reminders").then((r) => r.json()).catch(() => []),
]).then(([gb, mc, od, rem]: [{ status: string }[], { status: string }[], { status: string }[], { eventDate: string; reminderStatus?: string }[]]) => {
const upcoming = rem.filter((r) => r.eventDate === today || r.eventDate === tomorrow);
setCounts({
classes: countByStatus(gb),
mc: countByStatus(mc),
od: countByStatus(od),
remindersToday: rem.filter((r) => r.eventDate === today).length,
remindersTomorrow: rem.filter((r) => r.eventDate === tomorrow).length,
remindersNotAsked: upcoming.filter((r) => !r.reminderStatus).length,
remindersComing: upcoming.filter((r) => r.reminderStatus === "coming").length,
remindersCancelled: upcoming.filter((r) => r.reminderStatus === "cancelled").length,
});
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshTrigger]);
if (!counts) return null;
const cards: { tab: Tab; label: string; counts: TabCounts | null; color: string; urgentColor: string }[] = [
{ tab: "reminders", label: "Напоминания", counts: null, color: "border-amber-500/20", urgentColor: "text-amber-400" },
{ tab: "classes", label: "Занятия", counts: counts.classes, color: "border-gold/20", urgentColor: "text-gold" },
{ tab: "master-classes", label: "Мастер-классы", counts: counts.mc, color: "border-purple-500/20", urgentColor: "text-purple-400" },
{ tab: "open-day", label: "Open Day", counts: counts.od, color: "border-cyan-500/20", urgentColor: "text-cyan-400" },
];
const hasWork = cards.some((c) => {
if (c.counts) return c.counts.new + c.counts.contacted + c.counts.confirmed + c.counts.declined > 0;
return counts.remindersToday + counts.remindersTomorrow > 0;
});
if (!hasWork) return null;
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mt-4">
{cards.map((c) => {
// Reminders card
if (c.tab === "reminders") {
const total = counts.remindersToday + counts.remindersTomorrow;
if (total === 0) return (
<div key={c.tab} className="rounded-xl border border-white/5 bg-neutral-900/50 p-3 opacity-40">
<p className="text-xs text-neutral-500">{c.label}</p>
<p className="text-lg font-bold text-neutral-600 mt-1"></p>
</div>
);
return (
<button key={c.tab} onClick={() => onNavigate(c.tab)}
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}>
<p className="text-xs text-neutral-400">{c.label}</p>
<div className="flex items-baseline gap-2 mt-1 flex-wrap">
{counts.remindersNotAsked > 0 && (
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); }}>
<span className="text-lg font-bold text-gold">{counts.remindersNotAsked}</span>
<span className="text-[10px] text-neutral-500">не спрош.</span>
</span>
)}
{counts.remindersComing > 0 && (
<>
{counts.remindersNotAsked > 0 && <span className="text-neutral-700">·</span>}
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); }}>
<span className="text-sm font-medium text-emerald-400">{counts.remindersComing}</span>
<span className="text-[10px] text-neutral-500">придёт</span>
</span>
</>
)}
{counts.remindersCancelled > 0 && (
<>
{(counts.remindersNotAsked > 0 || counts.remindersComing > 0) && <span className="text-neutral-700">·</span>}
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); }}>
<span className="text-sm font-medium text-red-400">{counts.remindersCancelled}</span>
<span className="text-[10px] text-neutral-500">не придёт</span>
</span>
</>
)}
</div>
</button>
);
}
// Booking cards — big numbers for new/contacted, small chips for confirmed/declined
const tc = c.counts!;
const total = tc.new + tc.contacted + tc.confirmed + tc.declined;
if (total === 0) return (
<div key={c.tab} className="rounded-xl border border-white/5 bg-neutral-900/50 p-3 opacity-40">
<p className="text-xs text-neutral-500">{c.label}</p>
<p className="text-lg font-bold text-neutral-600 mt-1"></p>
</div>
);
return (
<button key={c.tab} onClick={() => { onNavigate(c.tab); onFilter("all"); }}
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}>
<p className="text-xs text-neutral-400">{c.label}</p>
<div className="flex items-baseline gap-2 mt-1 flex-wrap">
{tc.new > 0 && (
<>
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("new"); }}>
<span className="text-lg font-bold text-gold">{tc.new}</span>
<span className="text-[10px] text-neutral-500">новых</span>
</span>
</>
)}
{tc.contacted > 0 && (
<>
{tc.new > 0 && <span className="text-neutral-700">·</span>}
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("contacted"); }}>
<span className="text-sm font-medium text-blue-400">{tc.contacted}</span>
<span className="text-[10px] text-neutral-500">в работе</span>
</span>
</>
)}
{tc.confirmed > 0 && (
<>
{(tc.new > 0 || tc.contacted > 0) && <span className="text-neutral-700">·</span>}
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("confirmed"); }}>
<span className="text-sm font-medium text-emerald-400">{tc.confirmed}</span>
<span className="text-[10px] text-neutral-500">подтв.</span>
</span>
</>
)}
{tc.declined > 0 && (
<>
{(tc.new > 0 || tc.contacted > 0 || tc.confirmed > 0) && <span className="text-neutral-700">·</span>}
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("declined"); }}>
<span className="text-sm font-medium text-red-400">{tc.declined}</span>
<span className="text-[10px] text-neutral-500">отказ</span>
</span>
</>
)}
</div>
</button>
);
})}
</div>
);
}
// --- Main Page ---
const TABS: { key: Tab; label: string }[] = [
{ key: "reminders", label: "Напоминания" },
{ key: "classes", label: "Занятия" },
{ key: "master-classes", label: "Мастер-классы" },
{ key: "open-day", label: "День открытых дверей" },
];
const ENDPOINT_MAP: Record<string, string> = {
class: "/api/admin/group-bookings",
mc: "/api/admin/mc-registrations",
"open-day": "/api/admin/open-day/bookings",
};
function BookingsPageInner() {
const [tab, setTab] = useState<Tab>("reminders");
const [addOpen, setAddOpen] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
const [statusFilter, setStatusFilter] = useState<BookingFilter>("all");
const [hallFilter, setHallFilter] = useState("all");
const [halls, setHalls] = useState<string[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
const [dashboardKey, setDashboardKey] = useState(0);
const refreshDashboard = useCallback(() => setDashboardKey((k) => k + 1), []);
const lastTotalRef = useRef<number | null>(null);
const { showError } = useToast();
// Fetch available halls from schedule
useEffect(() => {
adminFetch("/api/admin/sections/schedule")
.then((r) => r.json())
.then((data: { locations?: { name: string }[] }) => {
const names = data.locations?.map((l) => l.name).filter(Boolean) ?? [];
setHalls([...new Set(names)]);
})
.catch(() => {});
}, []);
// Poll for new bookings, auto-refresh silently
useEffect(() => {
const id = setInterval(() => {
if (document.hidden) return;
adminFetch("/api/admin/unread-counts")
.then((r) => r.json())
.then((data: { total: number }) => {
if (lastTotalRef.current !== null && data.total !== lastTotalRef.current) {
refreshDashboard();
}
lastTotalRef.current = data.total;
})
.catch(() => {});
}, 10000);
return () => clearInterval(id);
}, []);
// #5: Search result status change
async function handleSearchStatus(result: SearchResult, status: BookingStatus) {
const endpoint = ENDPOINT_MAP[result.type];
if (!endpoint) return;
const prevStatus = result.status;
setSearchResults((prev) => prev?.map((r) => r.id === result.id && r.type === result.type ? { ...r, status } : r) ?? null);
try {
const res = await adminFetch(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-status", id: result.id, status }),
});
if (!res.ok) throw new Error();
} catch {
setSearchResults((prev) => prev?.map((r) => r.id === result.id && r.type === result.type ? { ...r, status: prevStatus } : r) ?? null);
showError("Не удалось обновить статус");
}
}
// #5: Search result delete
async function handleSearchDelete(result: SearchResult) {
const endpoint = ENDPOINT_MAP[result.type];
if (!endpoint) return;
try {
const res = await adminFetch(`${endpoint}?id=${result.id}`, { method: "DELETE" });
if (!res.ok) throw new Error();
setSearchResults((prev) => prev?.filter((r) => !(r.id === result.id && r.type === result.type)) ?? null);
} catch {
showError("Не удалось удалить запись");
}
}
const TYPE_LABELS: Record<string, string> = { class: "Занятие", mc: "Мастер-класс", "open-day": "Open Day" };
return (
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">Записи</h1>
<button
onClick={() => setAddOpen(true)}
className="flex h-8 w-8 items-center justify-center rounded-lg bg-gold/10 text-gold border border-gold/30 hover:bg-gold/20 transition-all"
title="Добавить запись"
>
<Plus size={16} />
</button>
</div>
{/* Search */}
<div className="mt-3">
<SearchBar
onResults={setSearchResults}
onClear={() => setSearchResults(null)}
/>
</div>
{/* Hall filter */}
{halls.length > 1 && (
<div className="mt-3 flex gap-2 flex-wrap">
<button
onClick={() => setHallFilter("all")}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
hallFilter === "all" ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-white border border-transparent"
}`}
>
Все залы
</button>
{halls.map((hall) => (
<button
key={hall}
onClick={() => setHallFilter(hallFilter === hall ? "all" : hall)}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
hallFilter === hall ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-white border border-transparent"
}`}
>
{hall}
</button>
))}
</div>
)}
{searchResults ? (
/* #5: Actionable search results — filtered by status */
(() => {
const filtered = statusFilter === "all" ? searchResults : searchResults.filter((r) => r.status === statusFilter);
return (
<div className="mt-4 space-y-2">
{filtered.length === 0 ? (
<p className="text-sm text-neutral-500 py-8 text-center">{searchResults.length === 0 ? "Ничего не найдено" : "Нет записей по фильтру"}</p>
) : (
filtered.map((r) => (
<BookingCard key={`${r.type}-${r.id}`} status={r.status as BookingStatus}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{TYPE_LABELS[r.type] || r.type}</span>
<span className="font-medium text-white">{r.name}</span>
<ContactLinks phone={r.phone} instagram={r.instagram} telegram={r.telegram} />
{r.groupLabel && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{r.groupLabel}</span>}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-neutral-600 text-xs">{fmtDate(r.createdAt)}</span>
<DeleteBtn onClick={() => handleSearchDelete(r)} name={r.name} />
</div>
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<StatusBadge status={r.status as BookingStatus} />
<StatusActions status={r.status as BookingStatus} onStatus={(s) => handleSearchStatus(r, s)} />
</div>
{r.notes && <p className="mt-1.5 text-[10px] text-neutral-500 truncate">{r.notes}</p>}
</BookingCard>
))
)}
</div>
);
})()
) : (
<>
{/* Dashboard — what needs attention */}
<DashboardSummary refreshTrigger={dashboardKey + refreshKey} onNavigate={setTab} onFilter={setStatusFilter} />
{/* Tabs */}
<div className="mt-5 flex border-b border-white/10">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
tab === t.key ? "text-gold" : "text-neutral-400 hover:text-white"
}`}
>
{t.label}
{tab === t.key && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gold rounded-full" />
)}
</button>
))}
</div>
{/* Tab content */}
<div className="mt-4">
{tab === "reminders" && <RemindersTab key={refreshKey} />}
{tab === "classes" && <GroupBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "master-classes" && <McRegistrationsTab filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} hallFilter={hallFilter} onDataChange={refreshDashboard} />}
</div>
</>
)}
<AddBookingModal
open={addOpen}
onClose={() => setAddOpen(false)}
onAdded={() => { setRefreshKey((k) => k + 1); refreshDashboard(); }}
/>
</div>
);
}
export default function BookingsPage() {
return (
<ToastProvider>
<BookingsPageInner />
</ToastProvider>
);
}

View File

@@ -0,0 +1,57 @@
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
export type BookingFilter = "all" | BookingStatus;
export interface BaseBooking {
id: number;
name: string;
phone?: string;
instagram?: string;
telegram?: string;
status: BookingStatus;
notes?: string;
createdAt: string;
}
export const SHORT_DAYS: Record<string, string> = {
"Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ",
"Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС",
};
export const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [
{ key: "new", label: "Новая", color: "text-gold", bg: "bg-gold/10", border: "border-gold/30" },
{ key: "contacted", label: "Связались", color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" },
{ key: "confirmed", label: "Подтверждено", color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/30" },
{ key: "declined", label: "Отказ", color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
];
export function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString("ru-RU");
}
export function countStatuses(items: { status: string }[]): Record<string, number> {
const c: Record<string, number> = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
for (const i of items) c[i.status] = (c[i.status] || 0) + 1;
return c;
}
export function sortByStatus<T extends { status: string }>(items: T[]): T[] {
const order: Record<string, number> = { new: 0, contacted: 1, confirmed: 2, declined: 3 };
const UNKNOWN_STATUS_ORDER = 4;
return [...items].sort((a, b) =>
(order[a.status] ?? UNKNOWN_STATUS_ORDER) - (order[b.status] ?? UNKNOWN_STATUS_ORDER)
);
}
export interface BookingGroup<T extends BaseBooking> {
key: string;
label: string;
sublabel?: string;
dateBadge?: string;
items: T[];
isArchived: boolean;
}
export interface SearchResult extends BaseBooking {
type: "class" | "mc" | "open-day";
groupLabel?: string;
}

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

266
src/app/admin/hero/page.tsx Normal file
View File

@@ -0,0 +1,266 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField } from "../_components/FormField";
import { adminFetch } from "@/lib/csrf";
import { Upload, X, Loader2, Smartphone, Monitor, Star } from "lucide-react";
interface HeroData {
headline: string;
subheadline: string;
ctaText: string;
ctaHref: string;
videos?: string[];
}
const SLOTS = [
{ key: "left", label: "Левое", sublabel: "Диагональ слева" },
{ key: "center", label: "Центр", sublabel: "Главное видео" },
{ key: "right", label: "Правое", sublabel: "Диагональ справа" },
] as const;
function VideoSlot({
label,
sublabel,
src,
isCenter,
onUpload,
onRemove,
uploading,
}: {
label: string;
sublabel: string;
src: string | null;
isCenter: boolean;
onUpload: (file: File) => void;
onRemove: () => void;
uploading: boolean;
}) {
const fileRef = useRef<HTMLInputElement>(null);
return (
<div className="space-y-2">
{/* Label */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-300">{label}</span>
{isCenter && (
<span className="inline-flex items-center gap-1 rounded-full bg-[#c9a96e]/15 px-2 py-0.5 text-[10px] font-medium text-[#c9a96e]">
<Smartphone size={10} />
мобильная версия
</span>
)}
</div>
<p className="text-xs text-neutral-500">{sublabel}</p>
{/* Slot */}
{src ? (
<div className={`group relative overflow-hidden rounded-lg border ${
isCenter ? "border-[#c9a96e]/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700"
}`}>
<video
src={src}
muted
loop
playsInline
autoPlay
className="aspect-[9/16] w-full object-cover bg-black"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2">
<p className="truncate text-xs text-neutral-400">
{src.split("/").pop()}
</p>
</div>
{isCenter && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-[#c9a96e]/90 px-2 py-0.5 text-[10px] font-bold text-black">
<Star size={10} fill="currentColor" />
MAIN
</div>
)}
<button
onClick={onRemove}
className="absolute top-2 right-2 rounded-full bg-black/70 p-1.5 text-neutral-400 opacity-0 transition-opacity hover:text-red-400 group-hover:opacity-100"
title="Удалить"
>
<X size={14} />
</button>
</div>
) : (
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className={`flex aspect-[9/16] w-full items-center justify-center rounded-lg border-2 border-dashed transition-colors disabled:opacity-50 ${
isCenter
? "border-[#c9a96e]/30 text-[#c9a96e]/50 hover:border-[#c9a96e]/60 hover:text-[#c9a96e]"
: "border-neutral-700 text-neutral-500 hover:border-neutral-500 hover:text-neutral-300"
}`}
>
{uploading ? (
<Loader2 size={24} className="animate-spin" />
) : (
<div className="flex flex-col items-center gap-2">
<Upload size={24} />
<span className="text-xs font-medium">Загрузить</span>
<span className="text-[10px] opacity-60">MP4, до 50МБ</span>
</div>
)}
</button>
)}
<input
ref={fileRef}
type="file"
accept="video/mp4,video/webm"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onUpload(file);
if (fileRef.current) fileRef.current.value = "";
}}
/>
</div>
);
}
function VideoManager({
videos,
onChange,
}: {
videos: string[];
onChange: (videos: string[]) => void;
}) {
const [slots, setSlots] = useState<(string | null)[]>(() => [
videos[0] || null,
videos[1] || null,
videos[2] || null,
]);
const [uploadingIdx, setUploadingIdx] = useState<number | null>(null);
const syncToParent = useCallback(
(updated: (string | null)[]) => {
setSlots(updated);
// Only propagate when all 3 are filled
if (updated.every((s) => s !== null)) {
onChange(updated as string[]);
}
},
[onChange]
);
async function handleUpload(idx: number, file: File) {
setUploadingIdx(idx);
try {
const form = new FormData();
form.append("file", file);
form.append("folder", "hero");
const res = await adminFetch("/api/admin/upload", {
method: "POST",
body: form,
});
if (!res.ok) {
const err = await res.json();
alert(err.error || "Ошибка загрузки");
return;
}
const { path } = await res.json();
const updated = [...slots];
updated[idx] = path;
syncToParent(updated);
} finally {
setUploadingIdx(null);
}
}
function handleRemove(idx: number) {
const updated = [...slots];
updated[idx] = null;
setSlots(updated);
// Don't propagate incomplete state — keep old saved videos in DB
}
const allFilled = slots.every((s) => s !== null);
const filledCount = slots.filter((s) => s !== null).length;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-neutral-300">
Видео на главном экране
</label>
{!allFilled && (
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-400">
Загружено {filledCount}/3 загрузите все для сохранения
</span>
)}
{allFilled && (
<span className="inline-flex items-center gap-1 rounded bg-emerald-500/10 px-2 py-0.5 text-[11px] text-emerald-400">
Все видео загружены
</span>
)}
</div>
<div className="grid gap-4 sm:grid-cols-3">
{SLOTS.map((slot, i) => (
<VideoSlot
key={slot.key}
label={slot.label}
sublabel={slot.sublabel}
src={slots[i]}
isCenter={i === 1}
uploading={uploadingIdx === i}
onUpload={(file) => handleUpload(i, file)}
onRemove={() => handleRemove(i)}
/>
))}
</div>
<div className="flex gap-4 rounded-lg bg-neutral-800/50 p-3 text-xs text-neutral-500">
<div className="flex items-center gap-1.5">
<Monitor size={13} />
<span>ПК диагональный сплит из 3 видео</span>
</div>
<div className="flex items-center gap-1.5">
<Smartphone size={13} />
<span>Телефон только центральное видео</span>
</div>
</div>
</div>
);
}
export default function HeroEditorPage() {
return (
<SectionEditor<HeroData> sectionKey="hero" title="Главный экран">
{(data, update) => (
<>
<VideoManager
videos={data.videos || []}
onChange={(videos) => update({ ...data, videos })}
/>
<InputField
label="Заголовок"
value={data.headline}
onChange={(v) => update({ ...data, headline: v })}
/>
<InputField
label="Подзаголовок"
value={data.subheadline}
onChange={(v) => update({ ...data, subheadline: v })}
/>
<InputField
label="Текст кнопки"
value={data.ctaText}
onChange={(v) => update({ ...data, ctaText: v })}
/>
<InputField
label="Ссылка кнопки"
value={data.ctaHref}
onChange={(v) => update({ ...data, ctaHref: v })}
type="url"
/>
</>
)}
</SectionEditor>
);
}

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

@@ -0,0 +1,176 @@
"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);
const isLoginPage = pathname === "/admin/login";
// Fetch unread counts — poll every 10s
useEffect(() => {
if (isLoginPage) return;
function fetchCounts() {
adminFetch("/api/admin/unread-counts")
.then((r) => r.json())
.then((data: { total: number }) => setUnreadTotal(data.total))
.catch(() => {});
}
fetchCounts();
const interval = setInterval(fetchCounts, 10000);
return () => clearInterval(interval);
}, [isLoginPage]);
// Don't render admin shell on login page
if (isLoginPage) {
return <>{children}</>;
}
async function handleLogout() {
await fetch("/api/logout", { method: "POST" });
router.push("/admin/login");
}
function isActive(href: string) {
if (href === "/admin") return pathname === "/admin";
return pathname.startsWith(href);
}
return (
<div className="flex min-h-screen bg-neutral-950 text-white">
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/60 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-white/10 bg-neutral-900 transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<Link href="/admin" className="text-lg font-bold">
BLACK HEART
</Link>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden text-neutral-400 hover:text-white"
>
<X size={20} />
</button>
</div>
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors ${
active
? "bg-gold/10 text-gold font-medium"
: "text-neutral-400 hover:text-white hover:bg-white/5"
}`}
>
<Icon size={18} />
{item.label}
{item.href === "/admin/bookings" && unreadTotal > 0 && (
<span className="ml-auto rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
{unreadTotal > 99 ? "99+" : unreadTotal}
</span>
)}
</Link>
);
})}
</nav>
<div className="border-t border-white/10 p-3 space-y-1">
<Link
href="/"
target="_blank"
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-white hover:bg-white/5 transition-colors"
>
<ChevronLeft size={18} />
Открыть сайт
</Link>
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-red-400 hover:bg-white/5 transition-colors"
>
<LogOut size={18} />
Выйти
</button>
</div>
</aside>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Top bar (mobile) */}
<header className="flex items-center gap-3 border-b border-white/10 px-4 py-3 lg:hidden">
<button
onClick={() => setSidebarOpen(true)}
className="text-neutral-400 hover:text-white"
>
<Menu size={24} />
</button>
<a href="/admin" className="font-bold hover:text-gold transition-colors">BLACK HEART</a>
</header>
<main className="flex-1 p-4 sm:p-6 lg:p-8">{children}</main>
</div>
</div>
);
}

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,644 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField, ParticipantLimits } 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;
waitingListText?: 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="Вы записаны! Мы свяжемся с вами"
/>
<TextareaField
label="Текст для листа ожидания"
value={data.waitingListText || ""}
onChange={(v) => update({ ...data, waitingListText: v || undefined })}
placeholder="Все места заняты, но мы добавили вас в лист ожидания..."
rows={2}
/>
<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 })
}
/>
<ParticipantLimits
min={item.minParticipants ?? 0}
max={item.maxParticipants ?? 0}
onMinChange={(v) => updateItem({ ...item, minParticipants: v })}
onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })}
/>
</div>
)}
createItem={() => ({
title: "",
image: "",
slots: [],
trainer: "",
cost: "",
style: "",
})}
addLabel="Добавить мастер-класс"
/>
</>
)}
</SectionEditor>
);
}

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,666 @@
"use client";
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import {
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw, Sparkles,
} from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { ParticipantLimits, SelectField } from "../_components/FormField";
// --- Types ---
interface OpenDayEvent {
id: number;
date: string;
title: string;
description?: string;
pricePerClass: number;
discountPrice: number;
discountThreshold: number;
minBookings: number;
maxParticipants: number;
successMessage?: string;
waitingListText?: string;
active: boolean;
}
interface OpenDayClass {
id: number;
eventId: number;
hall: string;
startTime: string;
endTime: string;
trainer: string;
style: string;
cancelled: boolean;
sortOrder: number;
bookingCount: number;
maxParticipants: number;
}
// --- Helpers ---
function generateTimeSlots(startHour: number, endHour: number): string[] {
const slots: string[] = [];
for (let h = startHour; h < endHour; h++) {
slots.push(`${h.toString().padStart(2, "0")}:00`);
}
return slots;
}
function addHour(time: string): string {
const [h, m] = time.split(":").map(Number);
return `${(h + 1).toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
}
// --- Event Settings ---
function EventSettings({
event,
onChange,
}: {
event: OpenDayEvent;
onChange: (patch: Partial<OpenDayEvent>) => void;
}) {
return (
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-4">
<h2 className="text-lg font-bold flex items-center gap-2">
<Calendar size={18} className="text-gold" />
Настройки мероприятия
</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Название</label>
<input
type="text"
value={event.title}
onChange={(e) => onChange({ title: e.target.value })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Дата</label>
<input
type="date"
value={event.date}
onChange={(e) => onChange({ date: e.target.value })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
/>
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Описание</label>
<textarea
value={event.description || ""}
onChange={(e) => onChange({ description: e.target.value || undefined })}
rows={2}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-none"
placeholder="Описание мероприятия..."
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Текст после записи</label>
<textarea
value={event.successMessage || ""}
onChange={(e) => onChange({ successMessage: 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>
<label className="block text-sm text-neutral-400 mb-1.5">Текст для листа ожидания</label>
<textarea
value={event.waitingListText || ""}
onChange={(e) => onChange({ waitingListText: 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>
<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 sm:max-w-xs"
/>
</div>
{/* Discount toggle + fields */}
<div>
<button
type="button"
onClick={() => {
if (event.discountPrice > 0) onChange({ discountPrice: 0, discountThreshold: 0 });
else onChange({ discountPrice: event.pricePerClass - 5, discountThreshold: 3 });
}}
className={`flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${
event.discountPrice > 0
? "bg-gold/15 text-gold border border-gold/30"
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
}`}
>
<Sparkles size={14} />
{event.discountPrice > 0 ? "Скидка включена" : "Добавить скидку"}
</button>
{event.discountPrice > 0 && (
<div className="grid gap-4 sm:grid-cols-2 mt-3">
<div>
<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>
)}
</div>
<ParticipantLimits
min={event.minBookings}
max={event.maxParticipants ?? 0}
onMinChange={(v) => onChange({ minBookings: v })}
onMaxChange={(v) => onChange({ maxParticipants: v })}
/>
<div className="flex items-center gap-3 pt-1">
<button
type="button"
onClick={() => onChange({ active: !event.active })}
className={`relative flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${
event.active
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30"
: "bg-neutral-800 text-neutral-400 border border-white/10"
}`}
>
{event.active ? <CheckCircle2 size={14} /> : <Ban size={14} />}
{event.active ? "Опубликовано" : "Черновик"}
</button>
<span className="text-xs text-neutral-500">
{event.pricePerClass} BYN / занятие{event.discountPrice > 0 && event.discountThreshold > 0 && `, от ${event.discountThreshold}${event.discountPrice} BYN`}
</span>
</div>
</div>
);
}
// --- New Class Form (create only on save) ---
function NewClassForm({
startTime,
trainers,
styles,
onSave,
onCancel,
}: {
startTime: string;
trainers: string[];
styles: string[];
onSave: (data: { trainer: string; style: string; endTime: string }) => void;
onCancel: () => void;
}) {
const [style, setStyle] = useState("");
const [trainer, setTrainer] = useState("");
const endTime = addHour(startTime);
const formRef = useRef<HTMLDivElement>(null);
useEffect(() => {
formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, []);
// Auto-save on click outside
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (formRef.current && !formRef.current.contains(e.target as Node)) {
if (style && trainer) onSave({ trainer, style, endTime });
else onCancel();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [style, trainer, endTime, onSave, onCancel]);
const canSave = style && trainer;
return (
<div ref={formRef} className="p-2 space-y-1.5 ring-1 ring-gold/30 rounded-lg">
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
<div className="flex gap-1 justify-end">
<button onClick={onCancel} className="text-[10px] text-neutral-500 hover:text-white px-1">Отмена</button>
<button onClick={() => canSave && onSave({ trainer, style, endTime })} disabled={!canSave}
className="text-[10px] text-gold hover:text-gold-light px-1 font-medium disabled:opacity-30 disabled:cursor-not-allowed">OK</button>
</div>
</div>
);
}
// --- Class Grid Cell ---
function ClassCell({
cls,
minBookings,
trainers,
styles,
onUpdate,
onDelete,
onCancel,
}: {
cls: OpenDayClass;
minBookings: number;
trainers: string[];
styles: string[];
onUpdate: (id: number, data: Partial<OpenDayClass>) => void;
onDelete: (id: number) => void;
onCancel: (id: number) => void;
}) {
const [editing, setEditing] = useState(false);
const [trainer, setTrainer] = useState(cls.trainer);
const [style, setStyle] = useState(cls.style);
const atRisk = cls.bookingCount < minBookings && !cls.cancelled;
function save() {
if (trainer.trim() && style.trim()) {
onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim() });
setEditing(false);
}
}
if (editing) {
return (
<div className="p-2 space-y-1.5 rounded-lg">
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
<div className="flex gap-1 justify-end">
<button onClick={() => setEditing(false)} className="text-[10px] text-neutral-500 hover:text-white px-1">
Отмена
</button>
<button onClick={save} className="text-[10px] text-gold hover:text-gold-light px-1 font-medium">
OK
</button>
</div>
</div>
);
}
return (
<div
className={`group relative p-2 rounded-lg cursor-pointer transition-all ${
cls.cancelled
? "bg-neutral-800/30 opacity-50"
: atRisk
? "bg-red-500/5 border border-red-500/20"
: "bg-gold/5 border border-gold/15 hover:border-gold/30"
}`}
onClick={() => setEditing(true)}
>
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-white truncate">{cls.style}</span>
<span className="text-[10px] text-neutral-500">{cls.startTime}{cls.endTime}</span>
</div>
<div className="text-[10px] text-neutral-400 truncate">{cls.trainer}</div>
<div className="flex items-center gap-1 mt-1">
<span className={`text-[10px] font-medium ${
cls.cancelled
? "text-neutral-500 line-through"
: atRisk
? "text-red-400"
: "text-emerald-400"
}`}>
{cls.bookingCount} чел.
</span>
{atRisk && !cls.cancelled && (
<span className="text-[9px] text-red-400">мин. {minBookings}</span>
)}
{cls.cancelled && <span className="text-[9px] text-neutral-500">отменено</span>}
</div>
{/* Actions */}
<div className="absolute top-1 right-1 hidden group-hover:flex gap-0.5">
<button
onClick={(e) => { e.stopPropagation(); onCancel(cls.id); }}
className={`rounded p-0.5 ${cls.cancelled ? "text-neutral-500 hover:text-emerald-400" : "text-neutral-500 hover:text-yellow-400"}`}
title={cls.cancelled ? "Восстановить" : "Отменить"}
>
{cls.cancelled ? <RotateCcw size={10} /> : <Ban size={10} />}
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete(cls.id); }}
className="rounded p-0.5 text-neutral-500 hover:text-red-400"
title="Удалить"
>
<Trash2 size={10} />
</button>
</div>
</div>
);
}
// --- Schedule Grid ---
function ScheduleGrid({
eventId,
minBookings,
halls,
classes,
trainers,
styles,
onClassesChange,
}: {
eventId: number;
minBookings: number;
halls: string[];
classes: OpenDayClass[];
trainers: string[];
styles: string[];
onClassesChange: () => void;
}) {
const [selectedHall, setSelectedHall] = useState(halls[0] ?? "");
const timeSlots = generateTimeSlots(10, 22);
// Build lookup: time -> class for selected hall
const hallClasses = useMemo(() => {
const map: Record<string, OpenDayClass> = {};
for (const cls of classes) {
if (cls.hall === selectedHall) map[cls.startTime] = cls;
}
return map;
}, [classes, selectedHall]);
// Count classes per hall for the tab badges
const hallCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const hall of halls) counts[hall] = 0;
for (const cls of classes) counts[cls.hall] = (counts[cls.hall] || 0) + 1;
return counts;
}, [classes, halls]);
const [creatingTime, setCreatingTime] = useState<string | null>(null);
async function confirmCreate(startTime: string, data: { trainer: string; style: string; endTime: string }) {
await adminFetch("/api/admin/open-day/classes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }),
});
setCreatingTime(null);
onClassesChange();
}
async function updateClass(id: number, data: Partial<OpenDayClass>) {
await adminFetch("/api/admin/open-day/classes", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, ...data }),
});
onClassesChange();
}
async function deleteClass(id: number) {
await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
onClassesChange();
}
async function cancelClass(id: number) {
const cls = classes.find((c) => c.id === id);
if (!cls) return;
await updateClass(id, { cancelled: !cls.cancelled });
}
return (
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-3">
<h2 className="text-lg font-bold">Расписание</h2>
{halls.length === 0 ? (
<p className="text-sm text-neutral-500">Нет залов в расписании. Добавьте локации в разделе «Расписание».</p>
) : (
<>
{/* Hall selector */}
<div className="flex gap-2 flex-wrap">
{halls.map((hall) => (
<button
key={hall}
onClick={() => setSelectedHall(hall)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
selectedHall === hall
? "bg-gold/20 text-gold border border-gold/40"
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
}`}
>
{hall}
{hallCounts[hall] > 0 && (
<span className={`ml-1.5 ${selectedHall === hall ? "text-gold/60" : "text-neutral-600"}`}>
{hallCounts[hall]}
</span>
)}
</button>
))}
</div>
{/* Time slots for selected hall */}
<div className="space-y-1">
{timeSlots.map((time) => {
const cls = hallClasses[time];
return (
<div key={time} className="flex items-start gap-3 border-t border-white/5 py-1.5">
<span className="text-xs text-neutral-500 w-12 pt-1.5 shrink-0">{time}</span>
<div className="flex-1">
{cls ? (
<ClassCell
cls={cls}
minBookings={minBookings}
trainers={trainers}
styles={styles}
onUpdate={updateClass}
onDelete={deleteClass}
onCancel={cancelClass}
/>
) : creatingTime === time ? (
<NewClassForm
startTime={time}
trainers={trainers}
styles={styles}
onSave={(data) => confirmCreate(time, data)}
onCancel={() => setCreatingTime(null)}
/>
) : (
<button
onClick={() => setCreatingTime(time)}
className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors"
>
<Plus size={12} className="mx-auto" />
</button>
)}
</div>
</div>
);
})}
</div>
</>
)}
</div>
);
}
// --- Main Page ---
export default function OpenDayAdminPage() {
const [event, setEvent] = useState<OpenDayEvent | null>(null);
const [classes, setClasses] = useState<OpenDayClass[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle");
const [trainers, setTrainers] = useState<string[]>([]);
const [styles, setStyles] = useState<string[]>([]);
const [halls, setHalls] = useState<string[]>([]);
const saveTimerRef = { current: null as ReturnType<typeof setTimeout> | null };
// Load data
useEffect(() => {
Promise.all([
adminFetch("/api/admin/open-day").then((r) => r.json()),
adminFetch("/api/admin/team").then((r) => r.json()),
adminFetch("/api/admin/sections/classes").then((r) => r.json()),
adminFetch("/api/admin/sections/schedule").then((r) => r.json()),
])
.then(([events, members, classesData, scheduleData]: [OpenDayEvent[], { name: string }[], { items: { name: string }[] }, { locations: { name: string }[] }]) => {
if (events.length > 0) {
setEvent(events[0]);
loadClasses(events[0].id);
}
setTrainers(members.map((m) => m.name));
setStyles(classesData.items.map((c) => c.name));
setHalls(scheduleData.locations.map((l) => l.name));
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
function loadClasses(eventId: number) {
adminFetch(`/api/admin/open-day/classes?eventId=${eventId}`)
.then((r) => r.json())
.then((data: OpenDayClass[]) => setClasses(data))
.catch(() => {});
}
// Auto-save event changes
const saveEvent = useCallback(
(updated: OpenDayEvent) => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
setSaving(true);
try {
const res = await adminFetch("/api/admin/open-day", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updated),
});
setSaveStatus(res.ok ? "saved" : "error");
} catch {
setSaveStatus("error");
}
setSaving(false);
setTimeout(() => setSaveStatus("idle"), 2000);
}, 800);
},
[]
);
function handleEventChange(patch: Partial<OpenDayEvent>) {
if (!event) return;
const updated = { ...event, ...patch };
setEvent(updated);
saveEvent(updated);
}
async function createEvent() {
const today = new Date().toISOString().split("T")[0];
const res = await adminFetch("/api/admin/open-day", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ date: today }),
});
const { id } = await res.json();
setEvent({
id,
date: today,
title: "День открытых дверей",
pricePerClass: 30,
discountPrice: 20,
discountThreshold: 3,
minBookings: 4,
maxParticipants: 0,
active: true,
});
}
async function deleteEvent() {
if (!event) return;
await adminFetch(`/api/admin/open-day?id=${event.id}`, { method: "DELETE" });
setEvent(null);
setClasses([]);
}
if (loading) {
return (
<div className="flex items-center gap-2 py-12 text-neutral-500 justify-center">
<Loader2 size={18} className="animate-spin" />
Загрузка...
</div>
);
}
if (!event) {
return (
<div className="text-center py-12">
<h1 className="text-2xl font-bold">День открытых дверей</h1>
<p className="mt-2 text-neutral-400">Создайте мероприятие, чтобы начать</p>
<button
onClick={createEvent}
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-gold px-6 py-3 text-sm font-semibold text-black hover:bg-gold-light transition-colors"
>
<Plus size={16} />
Создать мероприятие
</button>
</div>
);
}
return (
<div className="space-y-6">
{saveStatus !== "idle" && (
<div className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
saveStatus === "saved" ? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200" : "bg-red-950/90 border-red-500/30 text-red-200"
}`}>
{saveStatus === "saved" ? "Сохранено" : "Ошибка сохранения"}
</div>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">День открытых дверей</h1>
</div>
<button
onClick={deleteEvent}
className="flex items-center gap-1.5 rounded-lg border border-red-500/20 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 size={12} />
Удалить
</button>
</div>
<EventSettings event={event} onChange={handleEventChange} />
<ScheduleGrid
eventId={event.id}
minBookings={event.minBookings}
halls={halls}
classes={classes}
trainers={trainers}
styles={styles}
onClassesChange={() => loadClasses(event.id)}
/>
</div>
);
}

141
src/app/admin/page.tsx Normal file
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,368 @@
"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;
shortDescription: 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: "",
shortDescription: "",
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,
shortDescription: member.shortDescription || "",
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.shortDescription}
onChange={(v) => setData({ ...data, shortDescription: v })}
rows={2}
placeholder="1-2 предложения для карусели"
/>
<TextareaField
label="Полное описание (для страницы тренера)"
value={data.description}
onChange={(v) => setData({ ...data, description: v })}
rows={6}
/>
<div className="border-t border-white/5 pt-4 mt-4">
<p className="text-sm font-medium text-neutral-300 mb-4">Биография</p>
<div className="space-y-4">
<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,64 @@
import { NextRequest, NextResponse } from "next/server";
import { getDb } from "@/lib/db";
export async function GET(request: NextRequest) {
const q = request.nextUrl.searchParams.get("q")?.trim();
if (!q || q.length < 2) {
return NextResponse.json([]);
}
const db = getDb();
const like = `%${q}%`;
const groupRows = db.prepare(
"SELECT id, name, phone, instagram, telegram, status, notes, created_at, group_info FROM group_bookings WHERE name LIKE ? OR phone LIKE ? ORDER BY created_at DESC LIMIT 20"
).all(like, like) as { id: number; name: string; phone: string; instagram: string | null; telegram: string | null; status: string; notes: string | null; created_at: string; group_info: string | null }[];
const mcRows = db.prepare(
"SELECT id, name, phone, instagram, telegram, status, notes, created_at, master_class_title FROM mc_registrations WHERE name LIKE ? OR phone LIKE ? ORDER BY created_at DESC LIMIT 20"
).all(like, like) as { id: number; name: string; phone: string | null; instagram: string; telegram: string | null; status: string; notes: string | null; created_at: string; master_class_title: string }[];
const odRows = db.prepare(
"SELECT id, name, phone, instagram, telegram, status, notes, created_at FROM open_day_bookings WHERE name LIKE ? OR phone LIKE ? ORDER BY created_at DESC LIMIT 20"
).all(like, like) as { id: number; name: string; phone: string; instagram: string | null; telegram: string | null; status: string; notes: string | null; created_at: string }[];
const results = [
...groupRows.map((r) => ({
type: "class" as const,
id: r.id,
name: r.name,
phone: r.phone,
instagram: r.instagram ?? undefined,
telegram: r.telegram ?? undefined,
status: r.status || "new",
notes: r.notes ?? undefined,
createdAt: r.created_at,
groupLabel: r.group_info ?? undefined,
})),
...mcRows.map((r) => ({
type: "mc" as const,
id: r.id,
name: r.name,
phone: r.phone ?? undefined,
instagram: r.instagram ?? undefined,
telegram: r.telegram ?? undefined,
status: r.status || "new",
notes: r.notes ?? undefined,
createdAt: r.created_at,
groupLabel: r.master_class_title,
})),
...odRows.map((r) => ({
type: "open-day" as const,
id: r.id,
name: r.name,
phone: r.phone,
instagram: r.instagram ?? undefined,
telegram: r.telegram ?? undefined,
status: r.status || "new",
notes: r.notes ?? undefined,
createdAt: r.created_at,
})),
];
return NextResponse.json(results.slice(0, 50));
}

View File

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

View File

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server";
import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration, setMcRegistrationStatus, updateBookingNotes } from "@/lib/db";
import { sanitizeText } from "@/lib/validation";
export async function GET(request: NextRequest) {
const title = request.nextUrl.searchParams.get("title");
if (title) {
return NextResponse.json(getMcRegistrations(title));
}
// No title = return all registrations
return NextResponse.json(getAllMcRegistrations());
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { masterClassTitle, name, instagram, telegram } = body;
if (!masterClassTitle || !name || !instagram) {
return NextResponse.json({ error: "masterClassTitle, name, instagram are required" }, { status: 400 });
}
const id = addMcRegistration(masterClassTitle.trim(), name.trim(), instagram.trim(), telegram?.trim() || undefined);
return NextResponse.json({ ok: true, id });
} catch (err) {
console.error("[admin/mc-registrations] error:", err);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
// Set booking status
if (body.action === "set-status") {
const { id, status } = body;
if (!id || !status) return NextResponse.json({ error: "id, status required" }, { status: 400 });
if (!["new", "contacted", "confirmed", "declined"].includes(status)) {
return NextResponse.json({ error: "Invalid status" }, { status: 400 });
}
setMcRegistrationStatus(id, status);
return NextResponse.json({ ok: true });
}
// Set notes
if (body.action === "set-notes") {
const { id, notes } = body;
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 });
updateBookingNotes("mc_registrations", id, sanitizeText(notes, 1000) ?? "");
return NextResponse.json({ ok: true });
}
// Toggle notification status
if (body.action === "toggle-notify") {
const { id, field, value } = body;
if (!id || !field || typeof value !== "boolean") {
return NextResponse.json({ error: "id, field, value are required" }, { status: 400 });
}
if (field !== "notified_confirm" && field !== "notified_reminder") {
return NextResponse.json({ error: "Invalid field" }, { status: 400 });
}
toggleMcNotification(id, field, value);
return NextResponse.json({ ok: true });
}
// Regular update
const { id, name, instagram, telegram } = body;
if (!id || !name || !instagram) {
return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 });
}
updateMcRegistration(id, name.trim(), instagram.trim(), telegram?.trim() || undefined);
return NextResponse.json({ ok: true });
} catch (err) {
console.error("[admin/mc-registrations] error:", err);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const idStr = request.nextUrl.searchParams.get("id");
if (!idStr) {
return NextResponse.json({ error: "id parameter is required" }, { status: 400 });
}
const id = parseInt(idStr, 10);
if (isNaN(id)) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
}
deleteMcRegistration(id);
return NextResponse.json({ ok: true });
}

View File

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

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, no-cache" },
});
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
const IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
const VIDEO_TYPES = ["video/mp4", "video/webm"];
const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"];
const VIDEO_EXTENSIONS = [".mp4", ".webm"];
const IMAGE_FOLDERS = ["team", "master-classes", "news", "classes"];
const VIDEO_FOLDERS = ["hero"];
const ALL_FOLDERS = [...IMAGE_FOLDERS, ...VIDEO_FOLDERS];
const IMAGE_MAX_SIZE = 5 * 1024 * 1024; // 5MB
const VIDEO_MAX_SIZE = 50 * 1024 * 1024; // 50MB
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
const rawFolder = (formData.get("folder") as string) || "team";
const folder = ALL_FOLDERS.includes(rawFolder) ? rawFolder : "team";
const isVideoFolder = VIDEO_FOLDERS.includes(folder);
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const allowedTypes = isVideoFolder ? VIDEO_TYPES : IMAGE_TYPES;
if (!allowedTypes.includes(file.type)) {
const msg = isVideoFolder
? "Only MP4 and WebM videos are allowed"
: "Only JPEG, PNG, WebP, and AVIF are allowed";
return NextResponse.json({ error: msg }, { status: 400 });
}
const maxSize = isVideoFolder ? VIDEO_MAX_SIZE : IMAGE_MAX_SIZE;
if (file.size > maxSize) {
const label = isVideoFolder ? "50MB" : "5MB";
return NextResponse.json(
{ error: `File too large (max ${label})` },
{ status: 400 }
);
}
// Validate and sanitize filename
const ext = path.extname(file.name).toLowerCase() || (isVideoFolder ? ".mp4" : ".webp");
const allowedExts = isVideoFolder ? VIDEO_EXTENSIONS : IMAGE_EXTENSIONS;
if (!allowedExts.includes(ext)) {
return NextResponse.json(
{ error: "Invalid file extension" },
{ status: 400 }
);
}
const baseName = file.name
.replace(ext, "")
.toLowerCase()
.replace(/[^a-z0-9а-яё-]/gi, "-")
.replace(/-+/g, "-")
.slice(0, 50);
const fileName = `${baseName}-${Date.now()}${ext}`;
const subDir = isVideoFolder ? path.join("video") : path.join("images", folder);
const dir = path.join(process.cwd(), "public", subDir);
await mkdir(dir, { recursive: true });
const buffer = Buffer.from(await file.arrayBuffer());
const filePath = path.join(dir, fileName);
await writeFile(filePath, buffer);
const publicPath = `/${subDir.replace(/\\/g, "/")}/${fileName}`;
return NextResponse.json({ path: publicPath });
}

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,59 @@
import { NextResponse } from "next/server";
import { addMcRegistration, getMcRegistrations, getSection } from "@/lib/db";
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
import type { MasterClassItem } from "@/types/content";
export async function POST(request: Request) {
const ip = getClientIp(request);
if (!checkRateLimit(ip, 5, 60_000)) {
return NextResponse.json(
{ error: "Слишком много запросов. Попробуйте через минуту." },
{ status: 429 }
);
}
try {
const body = await request.json();
const { masterClassTitle, name, phone, instagram, telegram } = body;
const cleanTitle = sanitizeText(masterClassTitle, 200);
if (!cleanTitle) {
return NextResponse.json({ error: "masterClassTitle is required" }, { status: 400 });
}
const cleanName = sanitizeName(name);
if (!cleanName) {
return NextResponse.json({ error: "Имя обязательно" }, { status: 400 });
}
const cleanPhone = sanitizePhone(phone);
if (!cleanPhone) {
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
}
// Check if MC is full — if so, booking goes to waiting list
const mcSection = getSection("masterClasses") as { items?: MasterClassItem[] } | null;
const mcItem = mcSection?.items?.find((mc) => mc.title === cleanTitle);
let isWaiting = false;
if (mcItem?.maxParticipants && mcItem.maxParticipants > 0) {
const currentRegs = getMcRegistrations(cleanTitle);
const confirmedCount = currentRegs.filter((r) => r.status === "confirmed").length;
isWaiting = confirmedCount >= mcItem.maxParticipants;
}
const id = addMcRegistration(
cleanTitle,
cleanName,
sanitizeHandle(instagram) ?? "",
sanitizeHandle(telegram),
cleanPhone,
isWaiting ? "Лист ожидания" : undefined
);
return NextResponse.json({ ok: true, id, isWaiting });
} catch (err) {
console.error("[master-class-register] POST error:", err);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}

View File

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

View File

@@ -46,5 +46,40 @@ body {
/* ===== Focus ===== */ /* ===== Focus ===== */
:focus-visible { :focus-visible {
@apply outline-2 outline-offset-2 outline-[#c9a96e]; @apply outline-2 outline-offset-2 outline-gold;
}
/* ===== Scrollbar hide utility ===== */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* ===== Admin dark scrollbar ===== */
.admin-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
.admin-scrollbar::-webkit-scrollbar {
width: 6px;
}
.admin-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.admin-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.admin-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
} }

View File

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

View File

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

View File

@@ -2,22 +2,55 @@ import { Hero } from "@/components/sections/Hero";
import { Team } from "@/components/sections/Team"; import { Team } from "@/components/sections/Team";
import { About } from "@/components/sections/About"; import { About } from "@/components/sections/About";
import { Classes } from "@/components/sections/Classes"; import { Classes } from "@/components/sections/Classes";
import { MasterClasses } from "@/components/sections/MasterClasses";
import { Schedule } from "@/components/sections/Schedule";
import { Pricing } from "@/components/sections/Pricing"; import { Pricing } from "@/components/sections/Pricing";
import { News } from "@/components/sections/News";
import { FAQ } from "@/components/sections/FAQ"; import { FAQ } from "@/components/sections/FAQ";
import { Contact } from "@/components/sections/Contact"; import { Contact } from "@/components/sections/Contact";
import { BackToTop } from "@/components/ui/BackToTop"; import { BackToTop } from "@/components/ui/BackToTop";
import { FloatingContact } from "@/components/ui/FloatingContact";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { getContent } from "@/lib/content";
import { OpenDay } from "@/components/sections/OpenDay";
import { getActiveOpenDay } from "@/lib/openDay";
import { getAllMcRegistrations } from "@/lib/db";
export default function HomePage() { export default function HomePage() {
const content = getContent();
const openDayData = getActiveOpenDay();
// Count MC registrations per title for capacity check
const allMcRegs = getAllMcRegistrations();
const mcRegCounts: Record<string, number> = {};
for (const reg of allMcRegs) mcRegCounts[reg.masterClassTitle] = (mcRegCounts[reg.masterClassTitle] || 0) + 1;
return ( return (
<> <>
<Hero /> <Header />
<About /> <main>
<Team /> <Hero data={content.hero} />
<Classes /> {openDayData && <OpenDay data={openDayData} />}
<Pricing /> <About
<FAQ /> data={content.about}
<Contact /> 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} regCounts={mcRegCounts} />
<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 /> <BackToTop />
<FloatingContact />
</main>
<Footer />
</> </>
); );
} }

View File

@@ -128,6 +128,7 @@
filter: blur(80px); filter: blur(80px);
animation: pulse-glow 6s ease-in-out infinite; animation: pulse-glow 6s ease-in-out infinite;
pointer-events: none; pointer-events: none;
will-change: filter, transform;
} }
/* ===== Gradient Text ===== */ /* ===== Gradient Text ===== */
@@ -283,6 +284,63 @@
} }
} }
/* ===== Team Card Glitter ===== */
@keyframes glitter-move {
0% {
background-position: 0% 0%;
}
100% {
background-position: 200% 200%;
}
}
.team-card-glitter {
position: relative;
}
/* Animated gold border glow */
.team-card-glitter::before {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 2px;
background: linear-gradient(
135deg,
transparent 20%,
rgba(201, 169, 110, 0.6) 30%,
rgba(212, 184, 122, 1) 35%,
transparent 45%,
transparent 55%,
rgba(201, 169, 110, 0.5) 65%,
rgba(212, 184, 122, 0.9) 70%,
transparent 80%
);
background-size: 200% 200%;
animation: glitter-move 3s linear infinite;
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
z-index: 1;
will-change: background-position;
}
/* ===== Notification Pulse ===== */
@keyframes pulse-urgent {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
}
50% {
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
}
}
.pulse-urgent {
animation: pulse-urgent 1.5s ease-in-out infinite;
}
/* ===== Section Divider ===== */ /* ===== Section Divider ===== */
.section-divider { .section-divider {
@@ -331,4 +389,8 @@
.glow-hover:hover { .glow-hover:hover {
transform: none; transform: none;
} }
.team-card-glitter::before {
animation: none !important;
}
} }

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-[#a08050];
@apply dark:text-[#d4b87a];
}
.social-icon {
@apply text-neutral-400 transition-all duration-300;
@apply hover:text-[#a08050];
@apply dark:text-neutral-500 dark:hover:text-[#d4b87a];
}
/* ===== Cards ===== */
.card {
@apply rounded-2xl border p-6 transition-all duration-500 cursor-pointer;
@apply border-neutral-200 bg-white;
@apply hover:border-[#c9a96e]/30 hover:shadow-lg;
@apply dark:border-white/[0.08] dark:bg-[#111];
@apply dark:hover:border-[#c9a96e]/25 dark:hover:bg-[#151515];
@apply dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.06)];
}
/* ===== Buttons ===== */ /* ===== Buttons ===== */
.btn-primary { .btn-primary {
@apply inline-flex items-center justify-center font-semibold rounded-full transition-all duration-300 cursor-pointer; @apply inline-flex items-center justify-center font-semibold rounded-full transition-all duration-300 cursor-pointer;
@apply bg-[#c9a96e] text-black; @apply bg-gold text-black;
@apply hover:bg-[#d4b87a] hover:shadow-[0_0_30px_rgba(201,169,110,0.35)]; @apply hover:bg-gold-light hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
@apply dark:bg-[#c9a96e] dark:text-black; @apply dark:bg-gold dark:text-black;
@apply dark:hover:bg-[#d4b87a] dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.35)]; @apply dark:hover:bg-gold-light dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
}
.btn-outline {
@apply inline-flex items-center justify-center font-semibold rounded-full transition-all duration-300 cursor-pointer;
@apply border border-[#c9a96e] text-[#a08050];
@apply hover:bg-[#c9a96e] hover:text-black;
@apply dark:border-[#c9a96e] dark:text-[#d4b87a];
@apply dark:hover:bg-[#c9a96e] dark:hover:text-black;
}
.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-[#a08050];
@apply dark:text-neutral-400 dark:hover:text-[#d4b87a];
} }
/* ===== Scrollbar ===== */ /* ===== Scrollbar ===== */
@@ -73,14 +27,3 @@
scrollbar-color: rgb(64 64 64) transparent; scrollbar-color: rgb(64 64 64) transparent;
} }
} }
/* ===== Contact ===== */
.contact-item {
@apply flex items-center gap-4;
}
.contact-icon {
@apply shrink-0 text-[#a08050];
@apply dark:text-[#d4b87a];
}

View File

@@ -45,8 +45,8 @@
} }
.accent-text { .accent-text {
@apply text-[#a08050]; @apply text-gold-dark;
@apply dark:text-[#d4b87a]; @apply dark:text-gold-light;
} }
/* ===== Layout ===== */ /* ===== Layout ===== */
@@ -86,7 +86,7 @@
} }
.glass-card:hover { .glass-card:hover {
@apply dark:border-[#c9a96e]/15 dark:bg-white/[0.06]; @apply dark:border-gold/15 dark:bg-white/[0.06];
} }
/* ===== Photo Filter ===== */ /* ===== Photo Filter ===== */

View File

@@ -13,7 +13,7 @@ export function Footer() {
</p> </p>
<div className="flex items-center gap-1.5 text-sm text-neutral-500"> <div className="flex items-center gap-1.5 text-sm text-neutral-500">
<span>Made with</span> <span>Made with</span>
<Heart size={14} className="fill-[#c9a96e] text-[#c9a96e]" /> <Heart size={14} className="fill-gold text-gold" />
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -4,23 +4,48 @@ import Link from "next/link";
import { Menu, X } from "lucide-react"; import { Menu, X } from "lucide-react";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { BRAND, NAV_LINKS } from "@/lib/constants"; import { BRAND, NAV_LINKS } from "@/lib/constants";
import { UI_CONFIG } from "@/lib/config";
import { HeroLogo } from "@/components/ui/HeroLogo"; import { HeroLogo } from "@/components/ui/HeroLogo";
import { SignupModal } from "@/components/ui/SignupModal";
export function Header() { export function Header() {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [activeSection, setActiveSection] = useState(""); const [activeSection, setActiveSection] = useState("");
const [bookingOpen, setBookingOpen] = useState(false);
useEffect(() => { useEffect(() => {
let ticking = false;
function handleScroll() { 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 }); window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll);
}, []); }, []);
// Listen for booking open events from other components
useEffect(() => { useEffect(() => {
const sectionIds = NAV_LINKS.map((l) => l.href.replace("#", "")); 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[] = []; const observers: IntersectionObserver[] = [];
// Observe hero — when visible, clear active section // Observe hero — when visible, clear active section
@@ -53,7 +78,7 @@ export function Header() {
}); });
return () => observers.forEach((o) => o.disconnect()); return () => observers.forEach((o) => o.disconnect());
}, []); }, [visibleLinks]);
return ( return (
<header <header
@@ -63,7 +88,7 @@ export function Header() {
: "bg-transparent" : "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"> <Link href="/" className="group flex items-center gap-2.5">
<div className="relative flex h-8 w-8 items-center justify-center"> <div className="relative flex h-8 w-8 items-center justify-center">
<div <div
@@ -77,21 +102,21 @@ export function Header() {
className="relative text-black transition-transform duration-300 drop-shadow-[0_0_3px_rgba(201,169,110,0.5)] group-hover:scale-110" className="relative text-black transition-transform duration-300 drop-shadow-[0_0_3px_rgba(201,169,110,0.5)] group-hover:scale-110"
/> />
</div> </div>
<span className="font-display text-lg font-bold tracking-tight text-[#c9a96e]"> <span className="font-display text-lg font-bold tracking-tight text-gold">
{BRAND.shortName} {BRAND.shortName}
</span> </span>
</Link> </Link>
<nav className="hidden items-center gap-8 md:flex"> <nav className="hidden items-center gap-3 lg:gap-5 xl:gap-6 lg:flex">
{NAV_LINKS.map((link) => { {visibleLinks.map((link) => {
const isActive = activeSection === link.href.replace("#", ""); const isActive = activeSection === link.href.replace("#", "");
return ( return (
<a <a
key={link.href} key={link.href}
href={link.href} href={link.href}
className={`relative py-1 text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-[#c9a96e] after:transition-all after:duration-300 ${ className={`relative whitespace-nowrap py-1 text-xs lg:text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${
isActive isActive
? "text-[#d4b87a] after:w-full" ? "text-gold-light after:w-full"
: "text-neutral-400 after:w-0 hover:text-white hover:after:w-full" : "text-neutral-400 after:w-0 hover:text-white hover:after:w-full"
}`} }`}
> >
@@ -99,12 +124,19 @@ export function Header() {
</a> </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> </nav>
<div className="flex items-center gap-2 md:hidden"> <div className="flex items-center gap-2 lg:hidden">
<button <button
onClick={() => setMenuOpen(!menuOpen)} onClick={() => setMenuOpen(!menuOpen)}
aria-label="Меню" aria-label={menuOpen ? "Закрыть меню" : "Открыть меню"}
aria-expanded={menuOpen}
className="rounded-lg p-2 text-neutral-400 transition-colors hover:text-white" className="rounded-lg p-2 text-neutral-400 transition-colors hover:text-white"
> >
{menuOpen ? <X size={24} /> : <Menu size={24} />} {menuOpen ? <X size={24} /> : <Menu size={24} />}
@@ -114,12 +146,12 @@ export function Header() {
{/* Mobile menu */} {/* Mobile menu */}
<div <div
className={`overflow-hidden transition-all duration-300 md:hidden ${ className={`overflow-hidden transition-all duration-300 lg:hidden ${
menuOpen ? "max-h-80 opacity-100" : "max-h-0 opacity-0" menuOpen ? "max-h-[80vh] opacity-100" : "max-h-0 opacity-0"
}`} }`}
> >
<nav className="border-t border-white/[0.06] bg-black/40 px-6 py-4 backdrop-blur-xl sm:px-8"> <nav className="border-t border-white/[0.06] bg-black/40 px-6 py-4 text-center backdrop-blur-xl sm:px-8">
{NAV_LINKS.map((link) => { {visibleLinks.map((link) => {
const isActive = activeSection === link.href.replace("#", ""); const isActive = activeSection === link.href.replace("#", "");
return ( return (
<a <a
@@ -128,7 +160,7 @@ export function Header() {
onClick={() => setMenuOpen(false)} onClick={() => setMenuOpen(false)}
className={`block py-3 text-base transition-colors ${ className={`block py-3 text-base transition-colors ${
isActive isActive
? "text-[#d4b87a]" ? "text-gold-light"
: "text-neutral-400 hover:text-white" : "text-neutral-400 hover:text-white"
}`} }`}
> >
@@ -136,8 +168,20 @@ export function Header() {
</a> </a>
); );
})} })}
<button
onClick={() => {
setMenuOpen(false);
setBookingOpen(true);
}}
className="mt-2 inline-block rounded-full bg-gold px-8 py-3 text-sm font-semibold text-black transition-all hover:bg-gold-light cursor-pointer"
>
Записаться
</button>
</nav> </nav>
</div> </div>
<SignupModal open={bookingOpen} onClose={() => setBookingOpen(false)} endpoint="/api/group-booking" />
</header> </header>
); );
} }

View File

@@ -1,16 +1,25 @@
import { Users, Layers, MapPin } from "lucide-react"; import { Users, Layers, MapPin } from "lucide-react";
import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import type { SiteContent } from "@/types/content";
const stats = [ interface AboutStats {
{ icon: <Users size={22} />, value: "16", label: "тренеров" }, trainers: number;
{ icon: <Layers size={22} />, value: "6", label: "направлений" }, classes: number;
{ icon: <MapPin size={22} />, value: "2", label: "зала в Минске" }, locations: number;
]; }
export function About() { interface AboutProps {
const { about } = siteContent; 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 ( return (
<section id="about" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]"> <section id="about" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]">
@@ -21,8 +30,8 @@ export function About() {
</Reveal> </Reveal>
<div className="mt-14 mx-auto max-w-2xl space-y-8 text-center"> <div className="mt-14 mx-auto max-w-2xl space-y-8 text-center">
{about.paragraphs.map((text, i) => ( {about.paragraphs.map((text) => (
<Reveal key={i}> <Reveal key={text}>
<p className="text-xl leading-relaxed text-neutral-600 dark:text-neutral-300 sm:text-2xl"> <p className="text-xl leading-relaxed text-neutral-600 dark:text-neutral-300 sm:text-2xl">
{text} {text}
</p> </p>
@@ -33,12 +42,12 @@ export function About() {
{/* Stats */} {/* Stats */}
<Reveal> <Reveal>
<div className="mx-auto mt-14 grid max-w-3xl grid-cols-3 gap-4 sm:gap-8"> <div className="mx-auto mt-14 grid max-w-3xl grid-cols-3 gap-4 sm:gap-8">
{stats.map((stat, i) => ( {statItems.map((stat, i) => (
<div <div
key={i} 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-[#c9a96e]/30 sm:p-8 dark:border-white/[0.06] dark:bg-white/[0.02] dark:hover:border-[#c9a96e]/20" 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-[#c9a96e]/10 text-[#a08050] transition-colors group-hover:bg-[#c9a96e]/20 dark:text-[#d4b87a]"> <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} {stat.icon}
</div> </div>
<span className="font-display text-3xl font-bold text-neutral-900 sm:text-4xl dark:text-white"> <span className="font-display text-3xl font-bold text-neutral-900 sm:text-4xl dark:text-white">

View File

@@ -1,28 +1,32 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { Flame, Sparkles, Wind, Zap, Star, Monitor } from "lucide-react"; import { icons } from "lucide-react";
import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout"; import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
import { useShowcaseRotation } from "@/hooks/useShowcaseRotation"; import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
import type { ClassItem } from "@/types"; import type { ClassItem, SiteContent } from "@/types";
import { UI_CONFIG } from "@/lib/config";
const iconMap: Record<string, React.ReactNode> = { // kebab "heart-pulse" → PascalCase "HeartPulse"
flame: <Flame size={20} />, function toPascal(kebab: string) {
sparkles: <Sparkles size={20} />, return kebab.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
wind: <Wind size={20} />, }
zap: <Zap size={20} />,
star: <Star size={20} />,
monitor: <Monitor size={20} />,
};
export function Classes() { function getIcon(key: string) {
const { classes } = siteContent; 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({ const { activeIndex, select, setHovering } = useShowcaseRotation({
totalItems: classes.items.length, totalItems: classes.items.length,
autoPlayInterval: 5000, autoPlayInterval: UI_CONFIG.showcase.autoPlayInterval,
}); });
return ( return (
@@ -44,20 +48,22 @@ export function Classes() {
<div> <div>
{/* Hero image */} {/* Hero image */}
{item.images && item.images[0] && ( {item.images && item.images[0] && (
<div className="relative aspect-[16/9] w-full overflow-hidden rounded-2xl"> <div className="team-card-glitter relative aspect-[16/9] w-full overflow-hidden rounded-2xl">
<Image <Image
src={item.images[0]} src={item.images[0]}
alt={item.name} alt={item.name}
fill fill
className="object-cover photo-filter" loading="lazy"
sizes="(min-width: 1024px) 60vw, 100vw"
className="object-cover"
/> />
{/* Gradient overlay */} {/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
{/* Icon + name overlay */} {/* Icon + name overlay */}
<div className="absolute bottom-0 left-0 right-0 p-6"> <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-[#c9a96e]/20 text-[#d4b87a] backdrop-blur-sm"> <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">
{iconMap[item.icon]} {getIcon(item.icon)}
</div> </div>
<h3 className="text-2xl font-bold text-white"> <h3 className="text-2xl font-bold text-white">
{item.name} {item.name}
@@ -75,28 +81,28 @@ export function Classes() {
</div> </div>
)} )}
renderSelectorItem={(item, _i, isActive) => ( renderSelectorItem={(item, _i, isActive) => (
<div className="flex items-center gap-3 p-3"> <div className="flex items-center gap-2 px-3 py-2 lg:gap-3 lg:p-3">
{/* Icon */} {/* Icon */}
<div <div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition-colors ${ className={`flex h-7 w-7 lg:h-9 lg:w-9 shrink-0 items-center justify-center rounded-lg transition-colors ${
isActive isActive
? "bg-[#c9a96e]/20 text-[#d4b87a]" ? "bg-gold/20 text-gold-light"
: "bg-neutral-200/50 text-neutral-500 dark:bg-white/[0.06] dark:text-neutral-400" : "bg-neutral-200/50 text-neutral-500 dark:bg-white/[0.06] dark:text-neutral-400"
}`} }`}
> >
{iconMap[item.icon]} {getIcon(item.icon)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p <p
className={`text-sm font-semibold truncate transition-colors ${ className={`text-xs lg:text-sm font-semibold truncate transition-colors ${
isActive isActive
? "text-[#c9a96e]" ? "text-gold"
: "text-neutral-700 dark:text-neutral-300" : "text-neutral-700 dark:text-neutral-300"
}`} }`}
> >
{item.name} {item.name}
</p> </p>
<p className="text-xs text-neutral-500 dark:text-neutral-500 truncate"> <p className="hidden lg:block text-xs text-neutral-500 dark:text-neutral-500 truncate">
{item.description} {item.description}
</p> </p>
</div> </div>

View File

@@ -1,58 +1,54 @@
import { MapPin, Phone, Clock, Instagram } from "lucide-react"; import { MapPin, Phone, Clock, Instagram } from "lucide-react";
import { siteContent } from "@/data/content";
import { BRAND } from "@/lib/constants"; import { BRAND } from "@/lib/constants";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { IconBadge } from "@/components/ui/IconBadge";
import type { ContactInfo } from "@/types/content";
export function Contact() { interface ContactProps {
const { contact } = siteContent; data: ContactInfo;
}
export function Contact({ data: contact }: ContactProps) {
return ( return (
<section id="contact" className="relative section-padding bg-neutral-50 dark:bg-[#050505]"> <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-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> <Reveal>
<SectionHeading>{contact.title}</SectionHeading> <SectionHeading>{contact.title}</SectionHeading>
<div className="mt-10 space-y-5"> <div className="mt-10 space-y-5">
{contact.addresses.map((address, i) => ( {contact.addresses.map((address, i) => (
<div key={i} className="group flex items-center gap-4"> <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-[#c9a96e]/10 text-[#a08050] transition-colors group-hover:bg-[#c9a96e]/15 dark:bg-[#c9a96e]/10 dark:text-[#d4b87a] dark:group-hover:bg-[#c9a96e]/15"> <IconBadge><MapPin size={18} /></IconBadge>
<MapPin size={18} />
</div>
<p className="body-text">{address}</p> <p className="body-text">{address}</p>
</div> </div>
))} ))}
<div className="group flex items-center gap-4"> <div className="group flex items-center gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#c9a96e]/10 text-[#a08050] transition-colors group-hover:bg-[#c9a96e]/15 dark:bg-[#c9a96e]/10 dark:text-[#d4b87a] dark:group-hover:bg-[#c9a96e]/15"> <IconBadge><Phone size={18} /></IconBadge>
<Phone size={18} />
</div>
<a <a
href={`tel:${contact.phone}`} href={`tel:${contact.phone}`}
className="text-neutral-600 transition-colors hover:text-[#a08050] dark:text-neutral-300 dark:hover:text-[#d4b87a]" className="text-neutral-600 transition-colors hover:text-gold-dark dark:text-neutral-300 dark:hover:text-gold-light"
> >
{contact.phone} {contact.phone}
</a> </a>
</div> </div>
<div className="group flex items-center gap-4"> <div className="group flex items-center gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#c9a96e]/10 text-[#a08050] transition-colors group-hover:bg-[#c9a96e]/15 dark:bg-[#c9a96e]/10 dark:text-[#d4b87a] dark:group-hover:bg-[#c9a96e]/15"> <IconBadge><Clock size={18} /></IconBadge>
<Clock size={18} />
</div>
<p className="body-text">{contact.workingHours}</p> <p className="body-text">{contact.workingHours}</p>
</div> </div>
<div className="border-t border-neutral-200 pt-5 dark:border-white/[0.08]"> <div className="border-t border-neutral-200 pt-5 dark:border-white/[0.08]">
<div className="group flex items-center gap-4"> <div className="group flex items-center gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#c9a96e]/10 text-[#a08050] transition-colors group-hover:bg-[#c9a96e]/15 dark:bg-[#c9a96e]/10 dark:text-[#d4b87a] dark:group-hover:bg-[#c9a96e]/15"> <IconBadge><Instagram size={18} /></IconBadge>
<Instagram size={18} />
</div>
<a <a
href={contact.instagram} href={contact.instagram}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-neutral-600 transition-colors hover:text-[#a08050] dark:text-neutral-300 dark:hover:text-[#d4b87a]" className="text-neutral-600 transition-colors hover:text-gold-dark dark:text-neutral-300 dark:hover:text-gold-light"
> >
{BRAND.instagramHandle} {BRAND.instagramHandle}
</a> </a>
@@ -71,6 +67,7 @@ export function Contact() {
allowFullScreen allowFullScreen
loading="lazy" loading="lazy"
title="Карта" title="Карта"
className="dark:invert dark:hue-rotate-180 dark:brightness-95 dark:contrast-90"
/> />
</div> </div>
</Reveal> </Reveal>

View File

@@ -1,19 +1,29 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Plus, Minus } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { UI_CONFIG } from "@/lib/config";
import type { SiteContent } from "@/types/content";
export function FAQ() { const VISIBLE_COUNT = UI_CONFIG.faq.visibleCount;
const { faq } = siteContent;
interface FAQProps {
data: SiteContent["faq"];
}
export function FAQ({ data: faq }: FAQProps) {
const [openIndex, setOpenIndex] = useState<number | null>(null); const [openIndex, setOpenIndex] = useState<number | null>(null);
const [expanded, setExpanded] = useState(false);
function toggle(index: number) { function toggle(index: number) {
setOpenIndex(openIndex === index ? null : index); setOpenIndex(openIndex === index ? null : index);
} }
const visibleItems = expanded ? faq.items : faq.items.slice(0, VISIBLE_COUNT);
const hasMore = faq.items.length > VISIBLE_COUNT;
return ( return (
<section id="faq" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]"> <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-divider absolute top-0 left-0 right-0" />
@@ -22,44 +32,52 @@ export function FAQ() {
<SectionHeading centered>{faq.title}</SectionHeading> <SectionHeading centered>{faq.title}</SectionHeading>
</Reveal> </Reveal>
<div className="mx-auto mt-14 grid max-w-5xl gap-x-10 lg:grid-cols-2"> <div className="mx-auto mt-12 max-w-3xl space-y-2.5">
{[0, 1].map((col) => { {visibleItems.map((item, idx) => {
const half = Math.ceil(faq.items.length / 2); const isOpen = openIndex === idx;
const items = col === 0 ? faq.items.slice(0, half) : faq.items.slice(half);
const offset = col === 0 ? 0 : half;
return (
<div key={col}>
{items.map((item, i) => {
const idx = offset + i;
return ( return (
<Reveal key={idx}> <Reveal key={idx}>
<div <div
className={`border-b border-neutral-200 dark:border-white/[0.06] ${ className={`rounded-xl border transition-all duration-300 ${
i === 0 ? "border-t" : "" 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 <button
onClick={() => toggle(idx)} onClick={() => toggle(idx)}
className="flex w-full items-center justify-between gap-4 py-5 text-left transition-colors" className="flex w-full items-center gap-3 px-5 py-4 text-left cursor-pointer"
> >
<span className="text-sm font-medium text-neutral-900 dark:text-white sm:text-base"> {/* 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} {item.question}
</span> </span>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-neutral-100 transition-all duration-300 dark:bg-white/[0.06]">
{openIndex === idx ? ( <ChevronDown
<Minus size={14} className="text-[#c9a96e]" /> size={16}
) : ( className={`shrink-0 transition-all duration-300 ${
<Plus size={14} className="text-neutral-400 dark:text-neutral-500" /> isOpen ? "text-gold rotate-180" : "text-neutral-400 dark:text-neutral-500"
)} }`}
</span> />
</button> </button>
<div <div
className={`grid transition-all duration-300 ease-out ${ className={`grid transition-all duration-300 ease-out ${
openIndex === idx ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0" isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
}`} }`}
> >
<div className="overflow-hidden"> <div className="overflow-hidden">
<div className="pb-5 pr-10 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400 whitespace-pre-line"> <div className="px-5 pb-4 pl-14 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400 whitespace-pre-line">
{item.answer} {item.answer}
</div> </div>
</div> </div>
@@ -68,9 +86,27 @@ export function FAQ() {
</Reveal> </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> </div>
); </Reveal>
})} )}
</div> </div>
</div> </div>
</section> </section>

View File

@@ -1,41 +1,146 @@
"use client"; "use client";
import { siteContent } from "@/data/content"; import { useEffect, useRef, useCallback, useState } from "react";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { FloatingHearts } from "@/components/ui/FloatingHearts"; import { FloatingHearts } from "@/components/ui/FloatingHearts";
import { HeroLogo } from "@/components/ui/HeroLogo"; import { HeroLogo } from "@/components/ui/HeroLogo";
import { ChevronDown } from "lucide-react"; import type { SiteContent } from "@/types/content";
export function Hero() { const DEFAULT_VIDEOS = ["/video/ira.mp4", "/video/nadezda.mp4", "/video/nastya-2.mp4"];
const { hero } = siteContent;
interface HeroProps {
data: SiteContent["hero"];
}
export function Hero({ data: hero }: HeroProps) {
const sectionRef = useRef<HTMLElement>(null);
const scrolledRef = useRef(false);
const overlayRef = useRef<HTMLDivElement>(null);
const readyCount = useRef(0);
const [mounted, setMounted] = useState(false);
const videos = hero.videos?.length ? hero.videos : DEFAULT_VIDEOS;
const centerVideo = videos[Math.floor(videos.length / 2)] || videos[0];
const totalVideos = videos.slice(0, 3).length + 1; // desktop (3) + mobile (1)
useEffect(() => { setMounted(true); }, []);
const handleVideoReady = useCallback(() => {
readyCount.current += 1;
if (readyCount.current >= totalVideos && overlayRef.current) {
overlayRef.current.style.opacity = "0";
}
}, [totalVideos]);
const scrollToNext = useCallback(() => {
const el = sectionRef.current;
if (!el) return;
let next = el.nextElementSibling;
while (next && next.tagName !== "SECTION") {
next = next.nextElementSibling;
}
next?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
function handleWheel(e: WheelEvent) {
if (e.deltaY <= 0 || scrolledRef.current) return;
if (window.scrollY > 10) return;
scrolledRef.current = true;
scrollToNext();
setTimeout(() => { scrolledRef.current = false; }, 1000);
}
function handleTouchStart(e: TouchEvent) {
(el as HTMLElement).dataset.touchY = String(e.touches[0].clientY);
}
function handleTouchEnd(e: TouchEvent) {
const startY = Number((el as HTMLElement).dataset.touchY);
const endY = e.changedTouches[0].clientY;
if (startY - endY > 50 && !scrolledRef.current && window.scrollY < 10) {
scrolledRef.current = true;
scrollToNext();
setTimeout(() => { scrolledRef.current = false; }, 1000);
}
}
el.addEventListener("wheel", handleWheel, { passive: true });
el.addEventListener("touchstart", handleTouchStart, { passive: true });
el.addEventListener("touchend", handleTouchEnd, { passive: true });
return () => {
el.removeEventListener("wheel", handleWheel);
el.removeEventListener("touchstart", handleTouchStart);
el.removeEventListener("touchend", handleTouchEnd);
};
}, [scrollToNext]);
return ( return (
<section className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]"> <section id="hero" ref={sectionRef} className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
{/* Animated gradient background */} {/* Videos render only after hydration to avoid SSR mismatch */}
<div className="hero-bg-gradient absolute inset-0" /> {mounted && (
<>
{/* Mobile: single centered video */}
<div className="absolute inset-0 md:hidden">
<video
autoPlay muted loop playsInline preload="auto"
onCanPlayThrough={handleVideoReady}
className="absolute inset-0 h-full w-full object-cover object-center"
>
<source src={centerVideo} type="video/mp4" />
</video>
<div className="absolute inset-0 bg-black/50" />
</div>
{/* Glow orbs */} {/* Desktop: diagonal split with all videos */}
<div className="absolute inset-0 hidden md:block">
{videos.slice(0, 3).map((src, i) => {
const positions = [
{ left: "0%", width: "38%" },
{ left: "31%", width: "38%" },
{ left: "62%", width: "38%" },
];
const clips = [
"polygon(0 0, 100% 0, 86% 100%, 0 100%)",
"polygon(14% 0, 100% 0, 86% 100%, 0 100%)",
"polygon(14% 0, 100% 0, 100% 100%, 0 100%)",
];
return (
<div <div
className="hero-glow-orb" key={src}
className="absolute top-0 bottom-0 overflow-hidden"
style={{ style={{
width: "500px", left: positions[i].left,
height: "500px", width: positions[i].width,
top: "-10%", clipPath: clips[i],
left: "50%",
transform: "translateX(-50%)",
background: "radial-gradient(circle, rgba(201, 169, 110, 0.12), transparent 70%)",
}} }}
/> >
<video
autoPlay muted loop playsInline preload="auto"
onCanPlayThrough={handleVideoReady}
className="absolute inset-0 h-full w-full object-cover object-center"
>
<source src={src} type="video/mp4" />
</video>
<div className="absolute inset-0 bg-black/50" />
</div>
);
})}
{/* Gold diagonal lines between panels */}
<svg className="absolute inset-0 h-full w-full z-10 pointer-events-none" preserveAspectRatio="none" viewBox="0 0 100 100">
<line x1="38" y1="0" x2="33" y2="100" stroke="rgba(201,169,110,0.25)" strokeWidth="0.15" />
<line x1="69" y1="0" x2="64" y2="100" stroke="rgba(201,169,110,0.25)" strokeWidth="0.15" />
</svg>
</div>
</>
)}
{/* Loading overlay — covers videos but not content */}
<div <div
className="hero-glow-orb" ref={overlayRef}
style={{ className="absolute inset-0 z-[5] bg-[#050505] pointer-events-none transition-opacity duration-1000"
width: "300px",
height: "300px",
bottom: "10%",
right: "10%",
background: "radial-gradient(circle, rgba(201, 169, 110, 0.08), transparent 70%)",
animationDelay: "3s",
}}
/> />
{/* Floating hearts */} {/* Floating hearts */}
@@ -44,7 +149,6 @@ export function Hero() {
{/* Content */} {/* Content */}
<div className="section-container relative z-10 text-center"> <div className="section-container relative z-10 text-center">
<div className="hero-logo relative mx-auto mb-10 flex items-center justify-center" style={{ width: 220, height: 181 }}> <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="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"> <div className="hero-logo-heartbeat relative">
<HeroLogo <HeroLogo
@@ -63,22 +167,11 @@ export function Hero() {
</p> </p>
<div className="hero-cta mt-12"> <div className="hero-cta mt-12">
<Button href={hero.ctaHref} size="lg"> <Button size="lg" onClick={() => window.dispatchEvent(new Event("open-booking"))}>
{hero.ctaText} {hero.ctaText}
</Button> </Button>
</div> </div>
</div> </div>
{/* Scroll indicator */}
<div className="hero-cta absolute bottom-8 left-1/2 -translate-x-1/2">
<a
href="#about"
className="flex flex-col items-center gap-1 text-neutral-600 transition-colors hover:text-[#d4b87a]"
>
<span className="text-xs uppercase tracking-widest">Scroll</span>
<ChevronDown size={20} className="animate-bounce" />
</a>
</div>
</section> </section>
); );
} }

View File

@@ -0,0 +1,286 @@
"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"];
regCounts?: Record<string, number>;
}
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 now = new Date();
const slots = item.slots ?? [];
if (slots.length === 0) return false;
// Series MC: check earliest slot — if first session passed, group already started
const earliestSlot = slots.reduce((min, s) => s.date < min.date ? s : min, slots[0]);
const d = parseDate(earliestSlot.date);
if (earliestSlot.startTime) {
const [h, m] = earliestSlot.startTime.split(":").map(Number);
d.setHours(h, m, 0, 0);
} else {
d.setHours(23, 59, 59, 999);
}
return d > now;
}
function MasterClassCard({
item,
currentRegs,
onSignup,
}: {
item: MasterClassItem;
currentRegs: number;
onSignup: () => void;
}) {
const duration = item.slots[0] ? calcDuration(item.slots[0]) : "";
const slotsDisplay = formatSlots(item.slots);
const maxP = item.maxParticipants ?? 0;
const isFull = maxP > 0 && currentRegs >= maxP;
return (
<div className="group relative flex w-full max-w-sm flex-col overflow-hidden rounded-2xl bg-black">
{/* Full-bleed image or placeholder */}
<div className="relative aspect-[3/4] sm:aspect-[2/3] w-full overflow-hidden">
{item.image ? (
<>
<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"
/>
<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 className="absolute inset-0 bg-gradient-to-b from-neutral-800 to-black" />
)}
</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>
{/* Spots info */}
{(maxP > 0 || (item.minParticipants && item.minParticipants > 0)) && (
<div className="mb-3 flex items-center gap-3 text-[11px]">
{maxP > 0 && (
<span className={isFull ? "text-amber-400" : "text-white/40"}>
{currentRegs}/{maxP} мест
</span>
)}
{item.minParticipants && item.minParticipants > 0 && currentRegs < item.minParticipants && (
<span className="text-red-400/70">
мин. {item.minParticipants} для проведения
</span>
)}
</div>
)}
{/* Price + Actions */}
<div className="flex items-center gap-3">
<button
onClick={onSignup}
className={`flex-1 rounded-xl py-3 text-sm font-bold uppercase tracking-wide transition-all cursor-pointer ${
isFull
? "bg-amber-500/15 text-amber-400 hover:bg-amber-500/25"
: "bg-gold text-black hover:bg-gold-light hover:shadow-lg hover:shadow-gold/25"
}`}
>
{isFull ? "Лист ожидания" : "Записаться"}
</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, regCounts = {} }: 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>
<Reveal>
{upcoming.length === 0 ? (
<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>
) : (
<div className="mx-auto mt-10 flex max-w-5xl flex-wrap justify-center gap-5">
{upcoming.map((item) => (
<MasterClassCard
key={item.title}
item={item}
currentRegs={regCounts[item.title] ?? 0}
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}
waitingMessage={data.waitingListText}
/>
</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,202 @@
"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>
{event.discountPrice > 0 && event.discountThreshold > 0 && (
<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}
maxParticipants={event.maxParticipants}
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 }}
successMessage={event.successMessage}
waitingMessage={event.waitingListText}
/>
)}
</section>
);
}
function ClassCard({
cls,
maxParticipants = 0,
onSignup,
}: {
cls: OpenDayClass;
maxParticipants?: number;
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>
);
}
const isFull = maxParticipants > 0 && cls.bookingCount >= maxParticipants;
return (
<div className={`rounded-xl border p-4 transition-all ${isFull ? "border-white/5 bg-neutral-900/50" : "border-white/10 bg-neutral-900 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>
{maxParticipants > 0 && (
<p className={`text-[10px] mt-1 ${isFull ? "text-amber-400" : "text-neutral-500"}`}>
{cls.bookingCount}/{maxParticipants} мест
</p>
)}
</div>
<button
onClick={() => onSignup({ classId: cls.id, label })}
className={`shrink-0 rounded-full px-4 py-2 text-xs font-medium transition-colors cursor-pointer ${
isFull
? "bg-amber-500/10 border border-amber-500/20 text-amber-400 hover:bg-amber-500/20"
: "bg-gold/10 border border-gold/20 text-gold hover:bg-gold/20"
}`}
>
{isFull ? "Лист ожидания" : "Записаться"}
</button>
</div>
</div>
);
}

View File

@@ -1,23 +1,29 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { CreditCard, Building2, ScrollText } from "lucide-react"; import { CreditCard, Building2, ScrollText, Crown, Sparkles } from "lucide-react";
import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import type { SiteContent } from "@/types/content";
type Tab = "prices" | "rental" | "rules"; type Tab = "prices" | "rental" | "rules";
export function Pricing() { interface PricingProps {
const { pricing } = siteContent; data: SiteContent["pricing"];
const [activeTab, setActiveTab] = useState<Tab>("prices"); }
export function Pricing({ data: pricing }: PricingProps) {
const [activeTab, setActiveTab] = useState<Tab>("prices");
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "prices", label: "Абонементы", icon: <CreditCard size={16} /> }, { id: "prices", label: "Абонементы", icon: <CreditCard size={16} /> },
{ id: "rental", label: "Аренда зала", icon: <Building2 size={16} /> }, { id: "rental", label: "Аренда зала", icon: <Building2 size={16} /> },
{ id: "rules", label: "Правила", icon: <ScrollText 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 ( return (
<section id="pricing" className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505]"> <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-divider absolute top-0 left-0 right-0" />
@@ -33,9 +39,9 @@ export function Pricing() {
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(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 ${ 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 activeTab === tab.id
? "bg-[#c9a96e] text-black shadow-lg shadow-[#c9a96e]/25" ? "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]" : "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]"
}`} }`}
> >
@@ -49,44 +55,81 @@ export function Pricing() {
{/* Prices tab */} {/* Prices tab */}
{activeTab === "prices" && ( {activeTab === "prices" && (
<Reveal> <Reveal>
<div className="mx-auto mt-10 max-w-2xl"> <div className="mx-auto mt-10 max-w-4xl">
<p className="mb-8 text-center text-sm text-neutral-500 dark:text-neutral-400"> <p className="mb-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
{pricing.subtitle} {pricing.subtitle}
</p> </p>
<div className="overflow-hidden rounded-2xl border border-neutral-200 dark:border-white/[0.06]">
{pricing.items.map((item, i) => { {/* Cards grid */}
const isPopular = i === 0; <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 ( return (
<div <div
key={i} key={i}
className={`group flex items-center justify-between gap-4 px-6 py-5 transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.03] ${ className={`group relative rounded-2xl border p-5 transition-all duration-300 ${
i > 0 ? "border-t border-neutral-100 dark:border-white/[0.04]" : "" isPopular
} ${isPopular ? "bg-[#c9a96e]/[0.04] dark:bg-[#c9a96e]/[0.03]" : ""}`} ? "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]"
}`}
> >
<div> {/* Popular badge */}
<div className="flex items-center gap-2">
<p className="font-medium text-neutral-900 dark:text-white">
{item.name}
</p>
{isPopular && ( {isPopular && (
<span className="rounded-full bg-[#c9a96e]/15 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-[#a08050] dark:text-[#d4b87a]"> <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> </span>
)}
</div> </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 && ( {item.note && (
<p className="mt-0.5 text-sm text-neutral-500 dark:text-neutral-400"> <p className="mt-1 text-xs text-neutral-400 dark:text-neutral-500">
{item.note} {item.note}
</p> </p>
)} )}
</div>
<span className="shrink-0 font-display text-xl font-bold text-[#a08050] dark:text-[#d4b87a]"> {/* Price */}
<p className={`mt-3 font-display text-2xl font-bold ${isPopular ? "text-gold" : "text-neutral-900 dark:text-white"}`}>
{item.price} {item.price}
</span> </p>
</div>
</div> </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>
)}
</div> </div>
</Reveal> </Reveal>
)} )}
@@ -94,14 +137,11 @@ export function Pricing() {
{/* Rental tab */} {/* Rental tab */}
{activeTab === "rental" && ( {activeTab === "rental" && (
<Reveal> <Reveal>
<div className="mx-auto mt-10 max-w-2xl"> <div className="mx-auto mt-10 max-w-2xl space-y-3">
<div className="overflow-hidden rounded-2xl border border-neutral-200 dark:border-white/[0.06]">
{pricing.rentalItems.map((item, i) => ( {pricing.rentalItems.map((item, i) => (
<div <div
key={i} key={i}
className={`group flex items-center justify-between gap-4 px-6 py-5 transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.03] ${ 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]"
i > 0 ? "border-t border-neutral-100 dark:border-white/[0.04]" : ""
}`}
> >
<div> <div>
<p className="font-medium text-neutral-900 dark:text-white"> <p className="font-medium text-neutral-900 dark:text-white">
@@ -113,12 +153,12 @@ export function Pricing() {
</p> </p>
)} )}
</div> </div>
<span className="shrink-0 font-display text-xl font-bold text-[#a08050] dark:text-[#d4b87a]"> <span className="shrink-0 font-display text-xl font-bold text-gold-dark dark:text-gold-light">
{item.price} {item.price}
</span> </span>
</div> </div>
))} ))}
</div>
</div> </div>
</Reveal> </Reveal>
)} )}
@@ -126,16 +166,16 @@ export function Pricing() {
{/* Rules tab */} {/* Rules tab */}
{activeTab === "rules" && ( {activeTab === "rules" && (
<Reveal> <Reveal>
<div className="mx-auto mt-10 max-w-2xl space-y-4"> <div className="mx-auto mt-10 max-w-2xl space-y-3">
{pricing.rules.map((rule, i) => ( {pricing.rules.map((rule, i) => (
<div <div
key={i} key={i}
className="flex gap-4" 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-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#c9a96e]/10 text-xs font-bold text-[#a08050] dark:bg-[#c9a96e]/10 dark:text-[#d4b87a]"> <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} {i + 1}
</span> </span>
<p className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300 sm:text-base"> <p className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
{rule} {rule}
</p> </p>
</div> </div>

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,165 +1,21 @@
"use client"; "use client";
import { useState, useRef, useCallback, useEffect } from "react"; import { useState } from "react";
import Image from "next/image";
import { Instagram } from "lucide-react";
import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { 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";
const AUTO_PLAY_MS = 4500; interface TeamProps {
const PAUSE_MS = 12000; data: SiteContent["team"];
const CARD_SPACING = 260; // px between card centers schedule?: ScheduleLocation[];
function wrapIndex(i: number, total: number) {
return ((i % total) + total) % total;
} }
function getDiff(index: number, active: number, total: number) { export function Team({ data: team, schedule }: TeamProps) {
let diff = index - active;
if (diff > total / 2) diff -= total;
if (diff < -total / 2) diff += total;
return diff;
}
// Interpolation helpers
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 },
];
export function Team() {
const { team } = siteContent;
const total = team.members.length;
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [dragOffset, setDragOffset] = useState(0); const [showProfile, setShowProfile] = useState(false);
const isDraggingRef = useRef(false);
const pausedUntilRef = useRef(0);
const dragStartRef = useRef<{ x: number; startIndex: number } | null>(null);
const member = team.members[activeIndex];
const goTo = useCallback(
(i: number) => {
setActiveIndex(wrapIndex(i, total));
setDragOffset(0);
pausedUntilRef.current = Date.now() + PAUSE_MS;
},
[total]
);
// Auto-rotate — completely skip while dragging
useEffect(() => {
const id = setInterval(() => {
if (isDraggingRef.current) return;
if (Date.now() < pausedUntilRef.current) return;
setActiveIndex((i) => (i + 1) % total);
}, AUTO_PLAY_MS);
return () => clearInterval(id);
}, [total]);
// Pointer handlers
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
isDraggingRef.current = true;
setActiveIndex((cur) => {
dragStartRef.current = { x: e.clientX, startIndex: cur };
return cur;
});
setDragOffset(0);
},
[]
);
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
if (!dragStartRef.current) return;
const dx = e.clientX - dragStartRef.current.x;
setDragOffset(dx);
},
[]
);
const onPointerUp = useCallback(() => {
if (!dragStartRef.current) return;
const startIdx = dragStartRef.current.startIndex;
// Read current dragOffset from state via functional update trick
setDragOffset((currentOffset) => {
const wasDrag = Math.abs(currentOffset) > 10;
const steps = wasDrag ? Math.round(currentOffset / CARD_SPACING) : 0;
if (steps !== 0) {
const newIndex = wrapIndex(startIdx - steps, total);
setActiveIndex(newIndex);
}
return 0; // reset offset
});
dragStartRef.current = null;
isDraggingRef.current = false;
pausedUntilRef.current = Date.now() + PAUSE_MS;
}, [total]);
// Compute interpolated style for each card
// During drag, base position is startIndex; otherwise activeIndex
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;
// Interpolate between the two nearest slot positions
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 ( return (
<section <section
@@ -181,123 +37,36 @@ export function Team() {
<Reveal> <Reveal>
<SectionHeading centered>{team.title}</SectionHeading> <SectionHeading centered>{team.title}</SectionHeading>
</Reveal> </Reveal>
</div>
<Reveal> <Reveal>
<div className="mt-10"> <div className="mt-10 px-4 sm:px-6">
{/* Stage */} {!showProfile ? (
<div
className="relative mx-auto flex items-end justify-center cursor-grab select-none active:cursor-grabbing touch-pan-y"
style={{ height: 440 }}
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 */}
{team.members.map((m, i) => {
const style = getCardStyle(i);
if (!style) return null;
return (
<div
key={m.name}
className="absolute bottom-0 overflow-hidden rounded-2xl border pointer-events-none"
style={{
width: style.width,
height: style.height,
opacity: style.opacity,
zIndex: style.zIndex,
transform: style.transform,
filter: style.filter,
borderColor: style.borderColor,
boxShadow: style.boxShadow,
transition: style.transition,
}}
>
<Image
src={m.image}
alt={m.name}
fill
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" /> <TeamCarousel
<div className="absolute bottom-0 left-0 right-0 p-5"> members={team.members}
<h3 className="text-lg font-bold text-white sm:text-xl drop-shadow-lg"> activeIndex={activeIndex}
{m.name} onActiveChange={setActiveIndex}
</h3> />
<p className="text-sm font-medium text-[#d4b87a] drop-shadow-lg">
{m.role} <div className="mx-auto max-w-6xl">
</p> <TeamMemberInfo
members={team.members}
activeIndex={activeIndex}
onSelect={setActiveIndex}
onOpenBio={() => setShowProfile(true)}
/>
</div> </div>
</> </>
)} ) : (
</div> <TeamProfile
); member={team.members[activeIndex]}
})} onBack={() => setShowProfile(false)}
schedule={schedule}
</div>
{/* Member info */}
<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-[#d4b87a]"
>
<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>
)}
{/* Progress dots */}
<div className="mt-6 flex items-center justify-center gap-1.5">
{team.members.map((_, i) => (
<button
key={i}
onClick={() => goTo(i)}
className={`h-1.5 rounded-full transition-all duration-500 cursor-pointer ${
i === activeIndex
? "w-6 bg-[#c9a96e]"
: "w-1.5 bg-white/15 hover:bg-white/30"
}`}
/> />
))} )}
</div>
</div>
</div> </div>
</Reveal> </Reveal>
</div>
</section> </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.shortDescription || member.description) && (
<p className="mt-3 text-sm leading-relaxed text-white/55 line-clamp-3">
{member.shortDescription || 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>
);
}

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