Compare commits
78 Commits
9cf09b6894
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 96e3333e9f | |||
| 1bfd502930 | |||
| 8d1e3fb596 | |||
| 0ec2361a16 | |||
| e4a9b71bfe | |||
| e617660467 | |||
| 3458f88367 | |||
| 9e0aa5b5dc | |||
| 5cd23473c8 | |||
| b1adbbfe3d | |||
| e63b902081 | |||
| 66dce3f8f5 | |||
| 127990e532 | |||
| 4e766d6957 | |||
| b94ee69033 | |||
| 7497ede2fd | |||
| 6cbdba2197 | |||
| 3ac6a4d840 | |||
| 26cb9a9772 | |||
| 4a1a2d7512 | |||
| b9800c1cc2 | |||
| f29dbe0c9f | |||
| 340a1d2f7f | |||
| f5e80c792a | |||
| 84b0bc4d60 | |||
| 6981376171 | |||
| 4f92057411 | |||
| ce033074cd | |||
| d4751975d2 | |||
| 1b391cdde6 | |||
| d3bb43af80 | |||
| 5030edd0d6 | |||
| 627781027b | |||
| 4918184852 | |||
| 921d10800b | |||
| ed90cd5924 | |||
| 03f0524ba3 | |||
| 46ad10e8a0 | |||
| 8ff7713cf2 | |||
| 604a52e04c | |||
| 8ef5fc975c | |||
| 5fe2500dbe | |||
| 21f3887bc9 | |||
| 9d0b4b0fba | |||
| fc523b2045 | |||
| bfa59a8d18 | |||
| b5262b4adc | |||
| 5c23b622f9 | |||
| 85c61cfacd | |||
| 27ef3bd694 | |||
| b145d5416a | |||
| e6c7bcf7f4 | |||
| ed5a164d59 | |||
| 27c1348f89 | |||
| d5afaf92ba | |||
| 08e4af1d55 | |||
| 7ff850f21a | |||
| e5fae578ab | |||
| a4dc8173fc | |||
| 13b68484e1 | |||
| 0c8c45dcd9 | |||
| d5b1873f83 | |||
| e42c3c7a51 | |||
| 8fbbc94024 | |||
| f2b840416d | |||
| 303c52653c | |||
| 22a59ae9af | |||
| 8d2f482e99 | |||
| 233c117afa | |||
| fed99f27b5 | |||
| 26e78edf5c | |||
| 3ff69d6945 | |||
| 0b2d3310af | |||
| 96081ccfe3 | |||
| c28c9a05a8 | |||
| 04963fb0de | |||
| 0ed0a91161 | |||
| a75922c730 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -36,6 +36,9 @@ yarn-error.log*
|
|||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
# database
|
||||||
|
/db/
|
||||||
|
|
||||||
# claude
|
# claude
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
|||||||
138
CLAUDE.md
138
CLAUDE.md
@@ -6,9 +6,10 @@ Instagram: @blackheartdancehouse
|
|||||||
Content language: Russian
|
Content language: Russian
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
- **Next.js 15** (App Router, TypeScript)
|
- **Next.js 16** (App Router, TypeScript, Turbopack)
|
||||||
- **Tailwind CSS v4** (light + dark mode, class-based toggle)
|
- **Tailwind CSS v4** (dark mode only, gold/black theme)
|
||||||
- **lucide-react** for icons
|
- **lucide-react** for icons
|
||||||
|
- **better-sqlite3** for SQLite database
|
||||||
- **Fonts**: Inter (body) + Oswald (headings) via `next/font`
|
- **Fonts**: Inter (body) + Oswald (headings) via `next/font`
|
||||||
- **Hosting**: Vercel (planned)
|
- **Hosting**: Vercel (planned)
|
||||||
|
|
||||||
@@ -16,61 +17,146 @@ 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
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
- Remote: Gitea at `git.dolgolyov-family.by`
|
- Remote: Gitea at `git.dolgolyov-family.by`
|
||||||
|
|||||||
@@ -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
892
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
public/images/logo.svg
Normal file
3
public/images/logo.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234 192" fill="currentColor" fill-rule="evenodd">
|
||||||
|
<path d="M118.02,188.43 C118.04,184.10 120.51,173.30 122.96,166.79 C126.11,158.42 133.55,147.62 144.55,135.42 C165.53,112.15 170.96,101.38 170.99,82.98 C171.00,72.35 168.51,62.96 162.47,50.94 C160.00,46.02 157.78,42.00 157.53,42.00 C157.29,42.00 158.24,45.04 159.64,48.75 C163.04,57.78 165.96,71.24 165.96,78.00 C165.97,85.89 163.51,95.22 159.27,103.40 C156.31,109.09 152.52,113.57 140.17,126.00 C131.69,134.53 123.42,143.44 121.79,145.81 C116.23,153.88 110.87,167.99 109.81,177.28 C109.51,179.99 109.37,179.90 105.02,174.28 C102.55,171.10 98.74,166.54 96.55,164.15 L92.56,159.80 L95.53,157.70 C100.61,154.12 105.90,148.12 108.76,142.70 C111.22,138.02 111.50,136.50 111.47,127.50 C111.45,118.43 111.02,116.15 106.86,103.00 C101.17,85.06 99.60,76.75 100.25,68.17 C100.75,61.51 104.60,48.83 107.29,45.00 C108.57,43.16 108.76,43.69 109.31,50.84 C110.42,65.22 115.99,75.08 126.37,81.04 C133.31,85.02 133.82,84.76 128.92,79.75 C124.20,74.93 119.44,65.68 118.16,58.84 C116.46,49.75 119.09,39.24 125.73,28.59 L128.79,23.69 L130.02,29.59 C130.69,32.84 133.23,39.92 135.65,45.33 C143.15,62.02 144.36,69.90 141.53,83.29 C140.04,90.28 134.00,104.04 127.66,114.86 C125.68,118.24 124.39,120.97 124.78,120.93 C125.18,120.90 128.41,117.53 131.97,113.46 C145.06,98.47 150.50,85.84 150.46,70.50 C150.43,59.80 149.70,57.36 141.46,40.79 C137.98,33.80 134.83,26.02 134.45,23.49 C133.85,19.50 134.07,18.56 136.10,16.40 C139.50,12.77 147.93,7.39 153.86,5.06 L159.00,3.03 L159.00,9.32 C159.00,18.37 162.11,24.01 172.06,33.00 C176.46,36.97 180.72,41.50 181.53,43.06 C183.67,47.20 183.39,56.94 180.95,63.13 C178.14,70.25 180.87,67.95 184.97,59.74 C190.78,48.12 188.70,39.73 177.15,28.15 C173.28,24.27 169.43,20.06 168.61,18.80 C166.51,15.58 164.79,7.21 165.54,3.83 C166.16,1.00 166.17,1.00 174.33,1.01 C178.82,1.02 184.15,1.29 186.17,1.63 C189.69,2.21 189.81,2.37 189.29,5.62 C188.42,10.94 190.83,20.71 195.10,29.20 C197.26,33.49 199.19,37.00 199.40,37.00 C199.62,37.00 198.67,33.51 197.31,29.25 C195.35,23.12 194.91,19.90 195.17,13.83 C195.36,9.61 195.85,5.81 196.28,5.39 C197.35,4.31 205.52,8.43 211.67,13.15 C218.45,18.35 221.00,23.72 220.99,32.74 C220.98,36.46 220.28,41.98 219.42,45.00 C218.57,48.02 217.63,51.40 217.34,52.50 C216.30,56.51 222.34,45.32 224.52,39.20 C225.76,35.73 227.01,32.66 227.29,32.38 C227.57,32.09 228.79,34.48 229.99,37.68 C238.21,59.57 232.78,83.80 215.76,101.15 C209.43,107.60 207.42,108.54 209.17,104.25 C210.91,100.00 210.37,85.54 208.08,74.64 C206.93,69.22 205.95,62.24 205.90,59.14 C205.80,53.85 205.74,53.72 204.93,57.00 C204.45,58.92 204.13,68.15 204.22,77.50 C204.37,93.11 204.20,94.91 202.15,99.45 C198.99,106.51 192.06,115.46 190.76,114.16 C188.49,111.89 189.93,84.88 192.72,77.19 C194.09,73.45 189.30,79.05 186.68,84.26 C182.02,93.55 180.69,101.03 181.41,113.97 L182.05,125.50 L169.94,135.00 C153.90,147.58 132.06,170.01 124.13,182.05 C118.83,190.09 118.00,190.96 118.02,188.43 Z M83.09,150.59 C78.00,145.44 77.78,144.99 78.44,141.34 C78.82,139.23 81.24,133.00 83.81,127.50 C88.47,117.57 88.50,117.43 88.49,107.00 C88.47,99.38 87.96,94.99 86.59,91.00 C84.28,84.21 77.06,69.61 76.36,70.30 C76.08,70.58 76.56,72.19 77.43,73.87 C79.91,78.65 82.99,92.88 82.99,99.57 C83.00,108.39 80.69,114.86 73.96,124.82 C70.68,129.67 68.00,134.17 68.00,134.82 C68.00,135.47 67.62,136.00 67.16,136.00 C66.07,136.00 57.00,128.93 57.00,128.07 C57.00,127.71 59.03,123.47 61.50,118.66 C66.60,108.75 67.24,103.18 64.48,92.59 C62.01,83.09 61.32,83.22 61.40,93.17 C61.45,100.19 61.02,103.39 59.69,106.10 C57.49,110.57 48.29,121.00 46.56,121.00 C44.40,121.00 39.79,109.24 39.24,102.34 C38.56,93.90 40.48,89.09 48.68,78.77 C62.32,61.60 65.53,49.22 60.98,31.41 C58.70,22.51 54.61,13.20 50.08,6.62 C47.54,2.92 47.30,2.10 48.60,1.60 C50.50,0.87 66.31,0.80 68.17,1.51 C69.18,1.90 69.42,4.19 69.15,10.95 C68.88,18.05 69.27,21.45 71.06,27.48 C72.30,31.66 73.77,35.36 74.33,35.70 C74.97,36.10 75.06,35.62 74.57,34.41 C74.15,33.36 73.57,28.88 73.27,24.45 C72.70,15.76 74.85,5.38 77.44,4.39 C79.59,3.56 92.09,10.37 97.73,15.45 C100.57,18.00 103.83,21.61 104.99,23.48 L107.09,26.88 L103.66,31.69 C94.93,43.97 91.54,55.17 91.64,71.50 C91.72,84.83 92.69,89.79 99.08,109.50 C105.86,130.41 104.79,139.90 94.39,151.01 C91.83,153.75 89.44,156.00 89.08,156.00 C88.72,156.00 86.03,153.57 83.09,150.59 Z M29.50,109.90 C26.20,107.67 21.64,104.05 19.38,101.84 L15.26,97.83 L17.24,92.67 C19.86,85.83 19.20,74.50 15.57,64.04 C12.16,54.24 10.98,53.26 12.75,61.71 C14.48,69.97 13.94,81.02 11.53,86.50 L9.77,90.50 L6.92,84.50 C2.82,75.84 1.00,67.75 1.00,58.18 C1.00,42.04 6.09,29.69 17.39,18.40 C23.48,12.31 32.07,6.09 30.82,8.67 C30.59,9.13 28.88,12.62 27.02,16.44 C21.43,27.90 22.74,38.16 31.08,48.28 C35.14,53.21 36.55,53.01 33.93,47.87 C31.25,42.61 30.40,34.47 31.90,28.31 C33.17,23.08 42.81,3.00 44.05,3.00 C44.47,3.00 46.18,6.79 47.86,11.42 C58.07,39.63 56.90,53.23 42.67,72.14 C31.96,86.38 29.60,96.21 33.92,108.52 C34.98,111.54 35.77,113.99 35.67,113.97 C35.58,113.96 32.80,112.12 29.50,109.90 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/images/team/75398-original-1773399182323.jpg
Normal file
BIN
public/images/team/75398-original-1773399182323.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 579 KiB |
BIN
public/images/team/angel-1773234723454.PNG
Normal file
BIN
public/images/team/angel-1773234723454.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 313 KiB |
BIN
public/images/team/photo-2025-06-28-23-11-20-1773234496259.jpg
Normal file
BIN
public/images/team/photo-2025-06-28-23-11-20-1773234496259.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
254
src/app/admin/_components/ArrayEditor.tsx
Normal file
254
src/app/admin/_components/ArrayEditor.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Plus, Trash2, GripVertical } from "lucide-react";
|
||||||
|
|
||||||
|
interface ArrayEditorProps<T> {
|
||||||
|
items: T[];
|
||||||
|
onChange: (items: T[]) => void;
|
||||||
|
renderItem: (item: T, index: number, update: (item: T) => void) => React.ReactNode;
|
||||||
|
createItem: () => T;
|
||||||
|
label?: string;
|
||||||
|
addLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArrayEditor<T>({
|
||||||
|
items,
|
||||||
|
onChange,
|
||||||
|
renderItem,
|
||||||
|
createItem,
|
||||||
|
label,
|
||||||
|
addLabel = "Добавить",
|
||||||
|
}: ArrayEditorProps<T>) {
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
|
const [insertAt, setInsertAt] = useState<number | null>(null);
|
||||||
|
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||||
|
const [dragSize, setDragSize] = useState({ w: 0, h: 0 });
|
||||||
|
const [grabOffset, setGrabOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { setMounted(true); }, []);
|
||||||
|
|
||||||
|
function updateItem(index: number, item: T) {
|
||||||
|
const updated = [...items];
|
||||||
|
updated[index] = item;
|
||||||
|
onChange(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(index: number) {
|
||||||
|
onChange(items.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDrag = useCallback(
|
||||||
|
(clientX: number, clientY: number, index: number) => {
|
||||||
|
const el = itemRefs.current[index];
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
setDragIndex(index);
|
||||||
|
setInsertAt(index);
|
||||||
|
setMousePos({ x: clientX, y: clientY });
|
||||||
|
setDragSize({ w: rect.width, h: rect.height });
|
||||||
|
setGrabOffset({ x: clientX - rect.left, y: clientY - rect.top });
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGripMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startDrag(e.clientX, e.clientY, index);
|
||||||
|
},
|
||||||
|
[startDrag]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dragIndex === null) return;
|
||||||
|
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
setMousePos({ x: e.clientX, y: e.clientY });
|
||||||
|
|
||||||
|
let newInsert = items.length;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (i === dragIndex) continue;
|
||||||
|
const el = itemRefs.current[i];
|
||||||
|
if (!el) continue;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const midY = rect.top + rect.height / 2;
|
||||||
|
if (e.clientY < midY) {
|
||||||
|
newInsert = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInsertAt(newInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
// Read current values from state updaters but defer onChange to avoid
|
||||||
|
// calling parent setState during React's render/updater cycle
|
||||||
|
let capturedDrag: number | null = null;
|
||||||
|
let capturedInsert: number | null = null;
|
||||||
|
|
||||||
|
setDragIndex((prev) => { capturedDrag = prev; return null; });
|
||||||
|
setInsertAt((prev) => { capturedInsert = prev; return null; });
|
||||||
|
|
||||||
|
// Defer the reorder to next microtask so React finishes its batch first
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (capturedDrag !== null && capturedInsert !== null) {
|
||||||
|
let targetIndex = capturedInsert;
|
||||||
|
if (capturedDrag < targetIndex) targetIndex -= 1;
|
||||||
|
if (capturedDrag !== targetIndex) {
|
||||||
|
const updated = [...items];
|
||||||
|
const [moved] = updated.splice(capturedDrag, 1);
|
||||||
|
updated.splice(targetIndex, 0, moved);
|
||||||
|
onChange(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
|
window.addEventListener("mouseup", onMouseUp);
|
||||||
|
return () => {
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
}, [dragIndex, items, onChange]);
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
if (dragIndex === null || insertAt === null) {
|
||||||
|
return items.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
|
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-3">
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||||
|
>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(i)}
|
||||||
|
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements: React.ReactNode[] = [];
|
||||||
|
let visualIndex = 0;
|
||||||
|
let placeholderPos = insertAt;
|
||||||
|
if (insertAt > dragIndex) placeholderPos = insertAt - 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (i === dragIndex) {
|
||||||
|
elements.push(
|
||||||
|
<div key={`hidden-${i}`} ref={(el) => { itemRefs.current[i] = el; }} className="hidden" />
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visualIndex === placeholderPos) {
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key="placeholder"
|
||||||
|
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3"
|
||||||
|
style={{ height: dragSize.h }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = items[i];
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
|
className="rounded-lg border border-white/10 bg-neutral-900/50 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-3">
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||||
|
>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(i)}
|
||||||
|
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{renderItem(item, i, (updated) => updateItem(i, updated))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
visualIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visualIndex === placeholderPos) {
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key="placeholder"
|
||||||
|
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-3"
|
||||||
|
style={{ height: dragSize.h }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{label && (
|
||||||
|
<h3 className="text-sm font-medium text-neutral-300 mb-3">{label}</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{renderList()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange([...items, createItem()])}
|
||||||
|
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{addLabel}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Floating clone following cursor */}
|
||||||
|
{mounted && dragIndex !== null &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[9999] pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: mousePos.x - grabOffset.x,
|
||||||
|
top: mousePos.y - grabOffset.y,
|
||||||
|
width: dragSize.w,
|
||||||
|
height: dragSize.h,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="h-full rounded-lg border-2 border-rose-500 bg-neutral-900/95 shadow-2xl shadow-rose-500/20 flex items-center gap-3 px-4">
|
||||||
|
<GripVertical size={16} className="text-rose-400 shrink-0" />
|
||||||
|
<span className="text-sm text-neutral-300">Перемещение элемента...</span>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
763
src/app/admin/_components/FormField.tsx
Normal file
763
src/app/admin/_components/FormField.tsx
Normal file
@@ -0,0 +1,763 @@
|
|||||||
|
import { useRef, useEffect, useState } from "react";
|
||||||
|
import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import type { RichListItem, VictoryItem } from "@/types/content";
|
||||||
|
|
||||||
|
interface InputFieldProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
type?: "text" | "url" | "tel";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InputField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
type = "text",
|
||||||
|
}: InputFieldProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextareaFieldProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextareaField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
rows = 3,
|
||||||
|
}: TextareaFieldProps) {
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = el.scrollHeight + "px";
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onResize() {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = el.scrollHeight + "px";
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => window.removeEventListener("resize", onResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-none overflow-hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectFieldProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
}: SelectFieldProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const selectedLabel = options.find((o) => o.value === value)?.label || "";
|
||||||
|
const filtered = search
|
||||||
|
? options.filter((o) => {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return o.label.toLowerCase().split(/\s+/).some((word) => word.startsWith(q));
|
||||||
|
})
|
||||||
|
: options;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handle(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handle);
|
||||||
|
return () => document.removeEventListener("mousedown", handle);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(!open);
|
||||||
|
setSearch("");
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}}
|
||||||
|
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-left outline-none transition-colors ${
|
||||||
|
open ? "border-gold" : "border-white/10"
|
||||||
|
} ${value ? "text-white" : "text-neutral-500"}`}
|
||||||
|
>
|
||||||
|
{selectedLabel || placeholder || "Выберите..."}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||||
|
{options.length > 3 && (
|
||||||
|
<div className="p-1.5">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Поиск..."
|
||||||
|
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
|
||||||
|
)}
|
||||||
|
{filtered.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(opt.value);
|
||||||
|
setOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-white/5 ${
|
||||||
|
opt.value === value ? "text-gold bg-gold/5" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeRangeFieldProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFieldProps) {
|
||||||
|
const parts = value.split("–");
|
||||||
|
const start = parts[0]?.trim() || "";
|
||||||
|
const end = parts[1]?.trim() || "";
|
||||||
|
|
||||||
|
function update(s: string, e: string) {
|
||||||
|
if (s && e) {
|
||||||
|
onChange(`${s}–${e}`);
|
||||||
|
} else if (s) {
|
||||||
|
onChange(s);
|
||||||
|
} else {
|
||||||
|
onChange("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStartChange(newStart: string) {
|
||||||
|
if (newStart && end && newStart >= end) {
|
||||||
|
update(newStart, "");
|
||||||
|
} else {
|
||||||
|
update(newStart, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEndChange(newEnd: string) {
|
||||||
|
if (start && newEnd && newEnd <= start) return;
|
||||||
|
update(start, newEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={start}
|
||||||
|
onChange={(e) => handleStartChange(e.target.value)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
<span className="text-neutral-500">–</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={end}
|
||||||
|
onChange={(e) => handleEndChange(e.target.value)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToggleFieldProps {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToggleField({ label, checked, onChange }: ToggleFieldProps) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||||||
|
checked ? "bg-gold" : "bg-neutral-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
|
||||||
|
checked ? "translate-x-5" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-neutral-300">{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListFieldProps {
|
||||||
|
label: string;
|
||||||
|
items: string[];
|
||||||
|
onChange: (items: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListField({ label, items, onChange, placeholder }: ListFieldProps) {
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
const val = draft.trim();
|
||||||
|
if (!val) return;
|
||||||
|
onChange([...items, val]);
|
||||||
|
setDraft("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index: number) {
|
||||||
|
onChange(items.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(index: number, value: string) {
|
||||||
|
onChange(items.map((item, i) => (i === index ? value : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item}
|
||||||
|
onChange={(e) => update(i, e.target.value)}
|
||||||
|
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-4 py-2 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||||
|
placeholder={placeholder || "Добавить..."}
|
||||||
|
className="flex-1 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold/50 transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={add}
|
||||||
|
disabled={!draft.trim()}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-gold transition-colors disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VictoryListFieldProps {
|
||||||
|
label: string;
|
||||||
|
items: RichListItem[];
|
||||||
|
onChange: (items: RichListItem[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
onLinkValidate?: (key: string, error: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate }: VictoryListFieldProps) {
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
const val = draft.trim();
|
||||||
|
if (!val) return;
|
||||||
|
onChange([...items, { text: val }]);
|
||||||
|
setDraft("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index: number) {
|
||||||
|
onChange(items.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateText(index: number, text: string) {
|
||||||
|
onChange(items.map((item, i) => (i === index ? { ...item, text } : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLink(index: number, link: string) {
|
||||||
|
onChange(items.map((item, i) => (i === index ? { ...item, link: link || undefined } : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage(index: number) {
|
||||||
|
onChange(items.map((item, i) => (i === index ? { ...item, image: undefined } : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(index: number, e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploadingIndex(index);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("folder", "team");
|
||||||
|
try {
|
||||||
|
const res = await adminFetch("/api/admin/upload", { method: "POST", body: formData });
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.path) {
|
||||||
|
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
|
||||||
|
}
|
||||||
|
} catch { /* upload failed */ } finally {
|
||||||
|
setUploadingIndex(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-2.5 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.text}
|
||||||
|
onChange={(e) => updateText(i, e.target.value)}
|
||||||
|
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
className="shrink-0 rounded-md p-1.5 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{item.image ? (
|
||||||
|
<div className="flex items-center gap-1 rounded bg-neutral-700/50 px-1.5 py-0.5 text-[11px] text-neutral-300">
|
||||||
|
<ImageIcon size={10} className="text-gold" />
|
||||||
|
<span className="max-w-[80px] truncate">{item.image.split("/").pop()}</span>
|
||||||
|
<button type="button" onClick={() => removeImage(i)} className="text-neutral-500 hover:text-red-400">
|
||||||
|
<X size={9} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<label className="flex cursor-pointer items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-neutral-500 hover:text-neutral-300 transition-colors">
|
||||||
|
{uploadingIndex === i ? <Loader2 size={10} className="animate-spin" /> : <Upload size={10} />}
|
||||||
|
{uploadingIndex === i ? "..." : "Фото"}
|
||||||
|
<input type="file" accept="image/*" onChange={(e) => handleUpload(i, e)} className="hidden" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<ValidatedLinkField
|
||||||
|
value={item.link || ""}
|
||||||
|
onChange={(v) => updateLink(i, v)}
|
||||||
|
validationKey={`edu-${i}`}
|
||||||
|
onValidate={onLinkValidate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||||
|
placeholder={placeholder || "Добавить..."}
|
||||||
|
className="flex-1 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold/50 transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={add}
|
||||||
|
disabled={!draft.trim()}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-gold transition-colors disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Date Range Picker ---
|
||||||
|
// Parses Russian date formats: "22.02.2025", "22-23.02.2025", "22.02-01.03.2025"
|
||||||
|
function parseDateRange(value: string): { start: string; end: string } {
|
||||||
|
if (!value) return { start: "", end: "" };
|
||||||
|
|
||||||
|
// "22-23.02.2025" → same month range
|
||||||
|
const sameMonth = value.match(/^(\d{1,2})-(\d{1,2})\.(\d{2})\.(\d{4})$/);
|
||||||
|
if (sameMonth) {
|
||||||
|
const [, d1, d2, m, y] = sameMonth;
|
||||||
|
return {
|
||||||
|
start: `${y}-${m}-${d1.padStart(2, "0")}`,
|
||||||
|
end: `${y}-${m}-${d2.padStart(2, "0")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// "22.02-01.03.2025" → cross-month range
|
||||||
|
const crossMonth = value.match(/^(\d{1,2})\.(\d{2})-(\d{1,2})\.(\d{2})\.(\d{4})$/);
|
||||||
|
if (crossMonth) {
|
||||||
|
const [, d1, m1, d2, m2, y] = crossMonth;
|
||||||
|
return {
|
||||||
|
start: `${y}-${m1}-${d1.padStart(2, "0")}`,
|
||||||
|
end: `${y}-${m2}-${d2.padStart(2, "0")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// "22.02.2025" → single date
|
||||||
|
const single = value.match(/^(\d{1,2})\.(\d{2})\.(\d{4})$/);
|
||||||
|
if (single) {
|
||||||
|
const [, d, m, y] = single;
|
||||||
|
const iso = `${y}-${m}-${d.padStart(2, "0")}`;
|
||||||
|
return { start: iso, end: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start: "", end: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateRange(start: string, end: string): string {
|
||||||
|
if (!start) return "";
|
||||||
|
const [sy, sm, sd] = start.split("-");
|
||||||
|
if (!end) return `${sd}.${sm}.${sy}`;
|
||||||
|
const [ey, em, ed] = end.split("-");
|
||||||
|
if (sm === em && sy === ey) return `${sd}-${ed}.${sm}.${sy}`;
|
||||||
|
return `${sd}.${sm}-${ed}.${em}.${ey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DateRangeFieldProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DateRangeField({ value, onChange }: DateRangeFieldProps) {
|
||||||
|
const { start, end } = parseDateRange(value);
|
||||||
|
|
||||||
|
function handleChange(s: string, e: string) {
|
||||||
|
onChange(formatDateRange(s, e));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar size={11} className="text-neutral-500 shrink-0" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={start}
|
||||||
|
onChange={(e) => handleChange(e.target.value, end)}
|
||||||
|
className="w-[130px] rounded-md border border-white/10 bg-neutral-800 px-1.5 py-1.5 text-xs text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
<span className="text-neutral-500 text-xs">—</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={end}
|
||||||
|
min={start}
|
||||||
|
onChange={(e) => handleChange(start, e.target.value)}
|
||||||
|
className="w-[130px] rounded-md border border-white/10 bg-neutral-800 px-1.5 py-1.5 text-xs text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- City Autocomplete Field ---
|
||||||
|
interface CityFieldProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
error?: string;
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
|
suggestions?: string[];
|
||||||
|
onSelectSuggestion?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CityField({ value, onChange, error, onSearch, suggestions, onSelectSuggestion }: CityFieldProps) {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focused) return;
|
||||||
|
function handle(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setFocused(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handle);
|
||||||
|
return () => document.removeEventListener("mousedown", handle);
|
||||||
|
}, [focused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin size={11} className="absolute left-2 top-1/2 -translate-y-1/2 text-neutral-500 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
onSearch?.(e.target.value);
|
||||||
|
}}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
placeholder="Город, страна"
|
||||||
|
className={`w-full rounded-md border bg-neutral-800 pl-6 pr-3 py-1.5 text-sm text-white placeholder-neutral-600 outline-none transition-colors ${
|
||||||
|
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{error && <AlertCircle size={12} className="absolute right-2 top-1/2 -translate-y-1/2 text-red-400" />}
|
||||||
|
</div>
|
||||||
|
{error && <p className="mt-0.5 text-[10px] text-red-400">{error}</p>}
|
||||||
|
{focused && suggestions && suggestions.length > 0 && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectSuggestion?.(s);
|
||||||
|
setFocused(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Link Field with Validation ---
|
||||||
|
interface ValidatedLinkFieldProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onValidate?: (key: string, error: string | null) => void;
|
||||||
|
validationKey?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValidatedLinkField({ value, onChange, onValidate, validationKey, placeholder }: ValidatedLinkFieldProps) {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function validate(url: string) {
|
||||||
|
if (!url) {
|
||||||
|
setError(null);
|
||||||
|
onValidate?.(validationKey || "", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
setError(null);
|
||||||
|
onValidate?.(validationKey || "", null);
|
||||||
|
} catch {
|
||||||
|
setError("Некорректная ссылка");
|
||||||
|
onValidate?.(validationKey || "", "invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 flex-1">
|
||||||
|
<Link size={12} className="text-neutral-500 shrink-0" />
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
validate(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder={placeholder || "Ссылка..."}
|
||||||
|
className={`w-full rounded-md border bg-neutral-800 px-2 py-1 text-xs text-white placeholder-neutral-600 outline-none transition-colors ${
|
||||||
|
error ? "border-red-500/50" : "border-white/5 focus:border-gold/50"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<span className="absolute right-1.5 top-1/2 -translate-y-1/2">
|
||||||
|
<AlertCircle size={10} className="text-red-400" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VictoryItemListFieldProps {
|
||||||
|
label: string;
|
||||||
|
items: VictoryItem[];
|
||||||
|
onChange: (items: VictoryItem[]) => void;
|
||||||
|
cityErrors?: Record<number, string>;
|
||||||
|
citySuggestions?: { index: number; items: string[] } | null;
|
||||||
|
onCitySearch?: (index: number, query: string) => void;
|
||||||
|
onCitySelect?: (index: number, value: string) => void;
|
||||||
|
onLinkValidate?: (key: string, error: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VictoryItemListField({ label, items, onChange, cityErrors, citySuggestions, onCitySearch, onCitySelect, onLinkValidate }: VictoryItemListFieldProps) {
|
||||||
|
function add() {
|
||||||
|
onChange([...items, { type: "place", place: "", category: "", competition: "" }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index: number) {
|
||||||
|
onChange(items.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(index: number, field: keyof VictoryItem, value: string) {
|
||||||
|
onChange(items.map((item, i) => (i === index ? { ...item, [field]: value || undefined } : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-2.5 space-y-1.5">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<select
|
||||||
|
value={item.type || "place"}
|
||||||
|
onChange={(e) => update(i, "type", e.target.value)}
|
||||||
|
className="w-32 shrink-0 rounded-md border border-white/10 bg-neutral-800 px-2 py-1.5 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||||
|
>
|
||||||
|
<option value="place">Место</option>
|
||||||
|
<option value="nomination">Номинация</option>
|
||||||
|
<option value="judge">Судейство</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.place || ""}
|
||||||
|
onChange={(e) => update(i, "place", e.target.value)}
|
||||||
|
placeholder="1 место, финалист..."
|
||||||
|
className="w-28 shrink-0 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.category || ""}
|
||||||
|
onChange={(e) => update(i, "category", e.target.value)}
|
||||||
|
placeholder="Категория"
|
||||||
|
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.competition || ""}
|
||||||
|
onChange={(e) => update(i, "competition", e.target.value)}
|
||||||
|
placeholder="Чемпионат"
|
||||||
|
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
className="shrink-0 rounded-md p-1.5 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<CityField
|
||||||
|
value={item.location || ""}
|
||||||
|
onChange={(v) => update(i, "location", v)}
|
||||||
|
error={cityErrors?.[i]}
|
||||||
|
onSearch={(q) => onCitySearch?.(i, q)}
|
||||||
|
suggestions={citySuggestions?.index === i ? citySuggestions.items : undefined}
|
||||||
|
onSelectSuggestion={(v) => onCitySelect?.(i, v)}
|
||||||
|
/>
|
||||||
|
<DateRangeField
|
||||||
|
value={item.date || ""}
|
||||||
|
onChange={(v) => update(i, "date", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ValidatedLinkField
|
||||||
|
value={item.link || ""}
|
||||||
|
onChange={(v) => update(i, "link", v)}
|
||||||
|
validationKey={`victory-${i}`}
|
||||||
|
onValidate={onLinkValidate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={add}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Добавить достижение
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/app/admin/_components/NotifyToggle.tsx
Normal file
71
src/app/admin/_components/NotifyToggle.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Bell, CheckCircle2 } from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
function Toggle({
|
||||||
|
done,
|
||||||
|
urgent,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
done: boolean;
|
||||||
|
urgent: boolean;
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
title={label}
|
||||||
|
className={`relative flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium transition-all ${
|
||||||
|
done
|
||||||
|
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30"
|
||||||
|
: urgent
|
||||||
|
? "bg-red-500/15 text-red-400 border border-red-500/30 pulse-urgent"
|
||||||
|
: "bg-neutral-700/50 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={10} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotifyToggle({
|
||||||
|
confirmed,
|
||||||
|
reminded,
|
||||||
|
confirmUrgent,
|
||||||
|
reminderUrgent,
|
||||||
|
onToggleConfirm,
|
||||||
|
onToggleReminder,
|
||||||
|
}: {
|
||||||
|
confirmed: boolean;
|
||||||
|
reminded: boolean;
|
||||||
|
confirmUrgent?: boolean;
|
||||||
|
reminderUrgent?: boolean;
|
||||||
|
onToggleConfirm: () => void;
|
||||||
|
onToggleReminder: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Toggle
|
||||||
|
done={confirmed}
|
||||||
|
urgent={confirmUrgent ?? !confirmed}
|
||||||
|
icon={CheckCircle2}
|
||||||
|
label="Подтверждение"
|
||||||
|
onToggle={onToggleConfirm}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
done={reminded}
|
||||||
|
urgent={reminderUrgent ?? false}
|
||||||
|
icon={Bell}
|
||||||
|
label="Напоминание"
|
||||||
|
onToggle={onToggleReminder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/app/admin/_components/SectionEditor.tsx
Normal file
117
src/app/admin/_components/SectionEditor.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { Loader2, Check, AlertCircle } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
|
||||||
|
interface SectionEditorProps<T> {
|
||||||
|
sectionKey: string;
|
||||||
|
title: string;
|
||||||
|
children: (data: T, update: (data: T) => void) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 800;
|
||||||
|
|
||||||
|
export function SectionEditor<T>({
|
||||||
|
sectionKey,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: SectionEditorProps<T>) {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const initialLoadRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch(`/api/admin/sections/${sectionKey}`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error("Failed to load");
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(setData)
|
||||||
|
.catch(() => setError("Не удалось загрузить данные"))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [sectionKey]);
|
||||||
|
|
||||||
|
const save = useCallback(async (dataToSave: T) => {
|
||||||
|
setStatus("saving");
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await adminFetch(`/api/admin/sections/${sectionKey}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(dataToSave),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to save");
|
||||||
|
setStatus("saved");
|
||||||
|
setTimeout(() => setStatus((s) => (s === "saved" ? "idle" : s)), 2000);
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
setError("Ошибка сохранения");
|
||||||
|
}
|
||||||
|
}, [sectionKey]);
|
||||||
|
|
||||||
|
// Auto-save with debounce whenever data changes (skip initial load)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return;
|
||||||
|
if (initialLoadRef.current) {
|
||||||
|
initialLoadRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
save(data);
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, [data, save]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-neutral-400">
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <p className="text-red-400">{error || "Данные не найдены"}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h1 className="text-2xl font-bold">{title}</h1>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-neutral-400">
|
||||||
|
{status === "saving" && (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
<span>Сохранение...</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "saved" && (
|
||||||
|
<>
|
||||||
|
<Check size={14} className="text-emerald-400" />
|
||||||
|
<span className="text-emerald-400">Сохранено</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "error" && (
|
||||||
|
<>
|
||||||
|
<AlertCircle size={14} className="text-red-400" />
|
||||||
|
<span className="text-red-400">{error}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-6">{children(data, setData)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/app/admin/about/page.tsx
Normal file
41
src/app/admin/about/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
|
import { InputField, TextareaField } from "../_components/FormField";
|
||||||
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
|
|
||||||
|
interface AboutData {
|
||||||
|
title: string;
|
||||||
|
paragraphs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AboutEditorPage() {
|
||||||
|
return (
|
||||||
|
<SectionEditor<AboutData> sectionKey="about" title="О студии">
|
||||||
|
{(data, update) => (
|
||||||
|
<>
|
||||||
|
<InputField
|
||||||
|
label="Заголовок секции"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(v) => update({ ...data, title: v })}
|
||||||
|
/>
|
||||||
|
<ArrayEditor
|
||||||
|
label="Параграфы"
|
||||||
|
items={data.paragraphs}
|
||||||
|
onChange={(paragraphs) => update({ ...data, paragraphs })}
|
||||||
|
renderItem={(text, _i, updateItem) => (
|
||||||
|
<TextareaField
|
||||||
|
label={`Параграф`}
|
||||||
|
value={text}
|
||||||
|
onChange={updateItem}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
createItem={() => ""}
|
||||||
|
addLabel="Добавить параграф"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
978
src/app/admin/bookings/page.tsx
Normal file
978
src/app/admin/bookings/page.tsx
Normal file
@@ -0,0 +1,978 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Loader2, Trash2, Phone, Instagram, Send, ChevronDown, ChevronRight, Bell, CheckCircle2, XCircle, Clock, Star, Calendar, DoorOpen, X } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
interface GroupBooking {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
groupInfo?: string;
|
||||||
|
instagram?: string;
|
||||||
|
telegram?: string;
|
||||||
|
notifiedConfirm: boolean;
|
||||||
|
notifiedReminder: boolean;
|
||||||
|
status: BookingStatus;
|
||||||
|
confirmedDate?: string;
|
||||||
|
confirmedGroup?: string;
|
||||||
|
confirmedComment?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McRegistration {
|
||||||
|
id: number;
|
||||||
|
masterClassTitle: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
instagram: string;
|
||||||
|
telegram?: string;
|
||||||
|
notifiedConfirm: boolean;
|
||||||
|
notifiedReminder: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenDayBooking {
|
||||||
|
id: number;
|
||||||
|
classId: number;
|
||||||
|
eventId: number;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
instagram?: string;
|
||||||
|
telegram?: string;
|
||||||
|
notifiedConfirm: boolean;
|
||||||
|
notifiedReminder: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
classStyle?: string;
|
||||||
|
classTrainer?: string;
|
||||||
|
classTime?: string;
|
||||||
|
classHall?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHORT_DAYS: Record<string, string> = {
|
||||||
|
"Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ",
|
||||||
|
"Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Tab = "reminders" | "classes" | "master-classes" | "open-day";
|
||||||
|
type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
|
||||||
|
type BookingFilter = "all" | BookingStatus;
|
||||||
|
|
||||||
|
const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [
|
||||||
|
{ key: "new", label: "Новая", color: "text-gold", bg: "bg-gold/10", border: "border-gold/30" },
|
||||||
|
{ key: "contacted", label: "Связались", color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" },
|
||||||
|
{ key: "confirmed", label: "Подтверждено", color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/30" },
|
||||||
|
{ key: "declined", label: "Отказ", color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Confirm Booking Modal ---
|
||||||
|
|
||||||
|
function ConfirmModal({
|
||||||
|
open,
|
||||||
|
bookingName,
|
||||||
|
groupInfo,
|
||||||
|
allClasses,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
bookingName: string;
|
||||||
|
groupInfo?: string;
|
||||||
|
allClasses: ScheduleClassInfo[];
|
||||||
|
onConfirm: (data: { group: string; date: string; comment?: string }) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [hall, setHall] = useState("");
|
||||||
|
const [trainer, setTrainer] = useState("");
|
||||||
|
const [group, setGroup] = useState("");
|
||||||
|
const [date, setDate] = useState("");
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setDate(""); setComment("");
|
||||||
|
// Try to match groupInfo against schedule to pre-fill
|
||||||
|
if (groupInfo && allClasses.length > 0) {
|
||||||
|
const info = groupInfo.toLowerCase();
|
||||||
|
// Score each class against groupInfo, pick best match
|
||||||
|
let bestMatch: ScheduleClassInfo | null = null;
|
||||||
|
let bestScore = 0;
|
||||||
|
for (const c of allClasses) {
|
||||||
|
let score = 0;
|
||||||
|
if (info.includes(c.type.toLowerCase())) score += 3;
|
||||||
|
if (info.includes(c.trainer.toLowerCase())) score += 3;
|
||||||
|
if (info.includes(c.time)) score += 2;
|
||||||
|
const dayShort = (SHORT_DAYS[c.day] || c.day.slice(0, 2)).toLowerCase();
|
||||||
|
if (info.includes(dayShort)) score += 1;
|
||||||
|
const hallWords = c.hall.toLowerCase().split(/[\s/,]+/);
|
||||||
|
if (hallWords.some((w) => w.length > 2 && info.includes(w))) score += 2;
|
||||||
|
if (score > bestScore) { bestScore = score; bestMatch = c; }
|
||||||
|
}
|
||||||
|
const match = bestScore >= 4 ? bestMatch : null;
|
||||||
|
if (match) {
|
||||||
|
setHall(match.hall);
|
||||||
|
setTrainer(match.trainer);
|
||||||
|
setGroup(match.groupId || `${match.type}|${match.time}|${match.address}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHall(""); setTrainer(""); setGroup("");
|
||||||
|
}, [open, groupInfo, allClasses]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
// Cascading options
|
||||||
|
const halls = useMemo(() => [...new Set(allClasses.map((c) => c.hall))], [allClasses]);
|
||||||
|
|
||||||
|
const trainers = useMemo(() => {
|
||||||
|
if (!hall) return [];
|
||||||
|
return [...new Set(allClasses.filter((c) => c.hall === hall).map((c) => c.trainer))].sort();
|
||||||
|
}, [allClasses, hall]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
if (!hall || !trainer) return [];
|
||||||
|
const filtered = allClasses.filter((c) => c.hall === hall && c.trainer === trainer);
|
||||||
|
// Group by groupId — merge days for the same group
|
||||||
|
const byId = new Map<string, { type: string; slots: { day: string; time: string }[]; id: string }>();
|
||||||
|
for (const c of filtered) {
|
||||||
|
const id = c.groupId || `${c.type}|${c.time}|${c.address}`;
|
||||||
|
const existing = byId.get(id);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.slots.some((s) => s.day === c.day)) existing.slots.push({ day: c.day, time: c.time });
|
||||||
|
} else {
|
||||||
|
byId.set(id, { type: c.type, slots: [{ day: c.day, time: c.time }], id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()].map((g) => {
|
||||||
|
const sameTime = g.slots.every((s) => s.time === g.slots[0].time);
|
||||||
|
const label = sameTime
|
||||||
|
? `${g.type}, ${g.slots.map((s) => SHORT_DAYS[s.day] || s.day.slice(0, 2)).join("/")} ${g.slots[0].time}`
|
||||||
|
: `${g.type}, ${g.slots.map((s) => `${SHORT_DAYS[s.day] || s.day.slice(0, 2)} ${s.time}`).join(", ")}`;
|
||||||
|
return { label, value: g.id };
|
||||||
|
}).sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
}, [allClasses, hall, trainer]);
|
||||||
|
|
||||||
|
// Reset downstream on upstream change (skip during initial pre-fill)
|
||||||
|
const initRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (initRef.current) { setTrainer(""); setGroup(""); }
|
||||||
|
initRef.current = true;
|
||||||
|
}, [hall]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (initRef.current && trainer === "") setGroup("");
|
||||||
|
}, [trainer]);
|
||||||
|
// Reset init flag when modal closes
|
||||||
|
useEffect(() => { if (!open) initRef.current = false; }, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const selectClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 [color-scheme:dark] disabled:opacity-30 disabled:cursor-not-allowed";
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" onClick={onClose}>
|
||||||
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
|
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button onClick={onClose} aria-label="Закрыть" className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h3 className="text-base font-bold text-white">Подтвердить запись</h3>
|
||||||
|
<p className="mt-1 text-xs text-neutral-400">{bookingName}</p>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Зал</label>
|
||||||
|
<select value={hall} onChange={(e) => setHall(e.target.value)} className={selectClass}>
|
||||||
|
<option value="" className="bg-neutral-900">Выберите зал</option>
|
||||||
|
{halls.map((h) => <option key={h} value={h} className="bg-neutral-900">{h}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Тренер</label>
|
||||||
|
<select value={trainer} onChange={(e) => setTrainer(e.target.value)} disabled={!hall} className={selectClass}>
|
||||||
|
<option value="" className="bg-neutral-900">Выберите тренера</option>
|
||||||
|
{trainers.map((t) => <option key={t} value={t} className="bg-neutral-900">{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Группа</label>
|
||||||
|
<select value={group} onChange={(e) => setGroup(e.target.value)} disabled={!trainer} className={selectClass}>
|
||||||
|
<option value="" className="bg-neutral-900">Выберите группу</option>
|
||||||
|
{groups.map((g) => <option key={g.value} value={g.value} className="bg-neutral-900">{g.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Дата занятия</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
min={today}
|
||||||
|
disabled={!group}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={comment}
|
||||||
|
disabled={!group}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="Первое занятие, пробный"
|
||||||
|
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (group && date) {
|
||||||
|
onConfirm({ group, date, comment: comment.trim() || undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!group || !date}
|
||||||
|
className="mt-5 w-full rounded-lg bg-emerald-600 py-2.5 text-sm font-semibold text-white transition-all hover:bg-emerald-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Подтвердить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group Bookings Tab ---
|
||||||
|
|
||||||
|
interface ScheduleClassInfo { type: string; trainer: string; time: string; day: string; hall: string; address: string; groupId?: string }
|
||||||
|
interface ScheduleLocation { name: string; address: string; days: { day: string; classes: { time: string; trainer: string; type: string; groupId?: string }[] }[] }
|
||||||
|
|
||||||
|
function GroupBookingsTab() {
|
||||||
|
const [bookings, setBookings] = useState<GroupBooking[]>([]);
|
||||||
|
const [allClasses, setAllClasses] = useState<ScheduleClassInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filter, setFilter] = useState<BookingFilter>("all");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
adminFetch("/api/admin/group-bookings").then((r) => r.json()),
|
||||||
|
adminFetch("/api/admin/sections/schedule").then((r) => r.json()),
|
||||||
|
])
|
||||||
|
.then(([bookingData, scheduleData]: [GroupBooking[], { locations?: ScheduleLocation[] }]) => {
|
||||||
|
setBookings(bookingData);
|
||||||
|
const classes: ScheduleClassInfo[] = [];
|
||||||
|
for (const loc of scheduleData.locations || []) {
|
||||||
|
const shortAddr = loc.address?.split(",")[0] || loc.name;
|
||||||
|
for (const day of loc.days) {
|
||||||
|
for (const cls of day.classes) {
|
||||||
|
classes.push({
|
||||||
|
type: cls.type,
|
||||||
|
trainer: cls.trainer,
|
||||||
|
time: cls.time,
|
||||||
|
day: day.day,
|
||||||
|
hall: loc.name,
|
||||||
|
address: shortAddr,
|
||||||
|
groupId: cls.groupId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAllClasses(classes);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const c: Record<string, number> = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
|
||||||
|
for (const b of bookings) c[b.status] = (c[b.status] || 0) + 1;
|
||||||
|
return c;
|
||||||
|
}, [bookings]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const list = filter === "all" ? bookings : bookings.filter((b) => b.status === filter);
|
||||||
|
const order: Record<string, number> = { new: 0, contacted: 1, confirmed: 2, declined: 3 };
|
||||||
|
return [...list].sort((a, b) => (order[a.status] ?? 0) - (order[b.status] ?? 0));
|
||||||
|
}, [bookings, filter]);
|
||||||
|
|
||||||
|
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||||
|
const confirmingBooking = bookings.find((b) => b.id === confirmingId);
|
||||||
|
|
||||||
|
async function handleStatus(id: number, status: BookingStatus, confirmation?: { group: string; date: string; comment?: string }) {
|
||||||
|
setBookings((prev) => prev.map((b) => b.id === id ? {
|
||||||
|
...b, status,
|
||||||
|
confirmedDate: confirmation?.date,
|
||||||
|
confirmedGroup: confirmation?.group,
|
||||||
|
confirmedComment: confirmation?.comment,
|
||||||
|
} : b));
|
||||||
|
await adminFetch("/api/admin/group-bookings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "set-status", id, status, confirmation }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await adminFetch(`/api/admin/group-bookings?id=${id}`, { method: "DELETE" });
|
||||||
|
setBookings((prev) => prev.filter((b) => b.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter("all")}
|
||||||
|
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Все <span className="text-neutral-500 ml-1">{bookings.length}</span>
|
||||||
|
</button>
|
||||||
|
{BOOKING_STATUSES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
onClick={() => setFilter(s.key)}
|
||||||
|
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
{counts[s.key] > 0 && <span className="ml-1.5">{counts[s.key]}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bookings list */}
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{filtered.length === 0 && <EmptyState total={bookings.length} />}
|
||||||
|
{filtered.map((b) => {
|
||||||
|
const statusConf = BOOKING_STATUSES.find((s) => s.key === b.status) || BOOKING_STATUSES[0];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
className={`rounded-xl border p-4 transition-colors ${
|
||||||
|
b.status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
|
||||||
|
: b.status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
|
||||||
|
: b.status === "new" ? "border-gold/20 bg-gold/[0.03]"
|
||||||
|
: "border-white/10 bg-neutral-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
|
||||||
|
<span className="font-medium text-white">{b.name}</span>
|
||||||
|
<a href={`tel:${b.phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||||
|
<Phone size={10} />{b.phone}
|
||||||
|
</a>
|
||||||
|
{b.instagram && (
|
||||||
|
<a href={`https://ig.me/m/${b.instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
|
||||||
|
<Instagram size={10} />{b.instagram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{b.telegram && (
|
||||||
|
<a href={`https://t.me/${b.telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
|
||||||
|
<Send size={10} />{b.telegram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{b.groupInfo && (
|
||||||
|
<span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="text-neutral-600 text-xs">{fmtDate(b.createdAt)}</span>
|
||||||
|
<DeleteBtn onClick={() => handleDelete(b.id)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Linear status flow */}
|
||||||
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
|
{/* Current status badge */}
|
||||||
|
<span className={`text-[10px] font-medium ${statusConf.bg} ${statusConf.color} border ${statusConf.border} rounded-full px-2.5 py-0.5`}>
|
||||||
|
{statusConf.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{b.status === "confirmed" && (
|
||||||
|
<span className="text-[10px] text-emerald-400/70">
|
||||||
|
{b.confirmedGroup}
|
||||||
|
{b.confirmedDate && ` · ${new Date(b.confirmedDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`}
|
||||||
|
{b.confirmedComment && ` · ${b.confirmedComment}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons based on current state */}
|
||||||
|
<div className="flex gap-1 ml-auto">
|
||||||
|
{b.status === "new" && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatus(b.id, "contacted")}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20 transition-all"
|
||||||
|
>
|
||||||
|
Связались →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{b.status === "contacted" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmingId(b.id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20 transition-all"
|
||||||
|
>
|
||||||
|
Подтвердить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatus(b.id, "declined")}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-all"
|
||||||
|
>
|
||||||
|
Отказ
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(b.status === "confirmed" || b.status === "declined") && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatus(b.id, "contacted")}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300 transition-all"
|
||||||
|
>
|
||||||
|
Вернуть
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={confirmingId !== null}
|
||||||
|
bookingName={confirmingBooking?.name ?? ""}
|
||||||
|
groupInfo={confirmingBooking?.groupInfo}
|
||||||
|
allClasses={allClasses}
|
||||||
|
onClose={() => setConfirmingId(null)}
|
||||||
|
onConfirm={(data) => {
|
||||||
|
if (confirmingId) handleStatus(confirmingId, "confirmed", data);
|
||||||
|
setConfirmingId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MC Registrations Tab ---
|
||||||
|
|
||||||
|
function McRegistrationsTab() {
|
||||||
|
const [regs, setRegs] = useState<McRegistration[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch("/api/admin/mc-registrations")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: McRegistration[]) => setRegs(data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Group by MC title
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map: Record<string, McRegistration[]> = {};
|
||||||
|
for (const r of regs) {
|
||||||
|
if (!map[r.masterClassTitle]) map[r.masterClassTitle] = [];
|
||||||
|
map[r.masterClassTitle].push(r);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [regs]);
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
function toggleExpand(key: string) {
|
||||||
|
setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await adminFetch(`/api/admin/mc-registrations?id=${id}`, { method: "DELETE" });
|
||||||
|
setRegs((prev) => prev.filter((r) => r.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.keys(grouped).length === 0 && <EmptyState total={regs.length} />}
|
||||||
|
{Object.entries(grouped).map(([title, items]) => {
|
||||||
|
const isOpen = expanded[title] ?? false;
|
||||||
|
return (
|
||||||
|
<div key={title} className="rounded-xl border border-white/10 overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleExpand(title)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 bg-neutral-900 hover:bg-neutral-800/80 transition-colors text-left"
|
||||||
|
>
|
||||||
|
{isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
|
||||||
|
<span className="font-medium text-white text-sm truncate">{title}</span>
|
||||||
|
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">{items.length}</span>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-4 pb-3 pt-1 space-y-1.5">
|
||||||
|
{items.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="rounded-lg border border-white/5 bg-neutral-800/30 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-sm">
|
||||||
|
<span className="font-medium text-white">{r.name}</span>
|
||||||
|
{r.phone && (
|
||||||
|
<a href={`tel:${r.phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||||
|
<Phone size={10} />{r.phone}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{r.instagram && (
|
||||||
|
<a
|
||||||
|
href={`https://ig.me/m/${r.instagram.replace(/^@/, "")}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs"
|
||||||
|
>
|
||||||
|
<Instagram size={10} />{r.instagram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{r.telegram && (
|
||||||
|
<a
|
||||||
|
href={`https://t.me/${r.telegram.replace(/^@/, "")}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs"
|
||||||
|
>
|
||||||
|
<Send size={10} />{r.telegram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="text-neutral-600 text-xs ml-auto">{fmtDate(r.createdAt)}</span>
|
||||||
|
<DeleteBtn onClick={() => handleDelete(r.id)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Open Day Bookings Tab ---
|
||||||
|
|
||||||
|
function OpenDayBookingsTab() {
|
||||||
|
const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch("/api/admin/open-day")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((events: { id: number; date: string }[]) => {
|
||||||
|
if (events.length === 0) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ev = events[0];
|
||||||
|
return adminFetch(`/api/admin/open-day/bookings?eventId=${ev.id}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: OpenDayBooking[]) => setBookings(data));
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Group by class — sorted by hall then time
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map: Record<string, { hall: string; time: string; style: string; trainer: string; items: OpenDayBooking[] }> = {};
|
||||||
|
for (const b of bookings) {
|
||||||
|
const key = `${b.classHall}|${b.classTime}|${b.classStyle}`;
|
||||||
|
if (!map[key]) map[key] = { hall: b.classHall || "—", time: b.classTime || "—", style: b.classStyle || "—", trainer: b.classTrainer || "—", items: [] };
|
||||||
|
map[key].items.push(b);
|
||||||
|
}
|
||||||
|
// Sort by hall, then time
|
||||||
|
return Object.entries(map).sort(([, a], [, b]) => {
|
||||||
|
const hallCmp = a.hall.localeCompare(b.hall);
|
||||||
|
return hallCmp !== 0 ? hallCmp : a.time.localeCompare(b.time);
|
||||||
|
});
|
||||||
|
}, [bookings]);
|
||||||
|
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
function toggleExpand(key: string) {
|
||||||
|
setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await adminFetch(`/api/admin/open-day/bookings?id=${id}`, { method: "DELETE" });
|
||||||
|
setBookings((prev) => prev.filter((b) => b.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{grouped.length === 0 && <EmptyState total={bookings.length} />}
|
||||||
|
{grouped.map(([key, group]) => {
|
||||||
|
const isOpen = expanded[key] ?? false;
|
||||||
|
return (
|
||||||
|
<div key={key} className="rounded-xl border border-white/10 overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleExpand(key)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 bg-neutral-900 hover:bg-neutral-800/80 transition-colors text-left"
|
||||||
|
>
|
||||||
|
{isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
|
||||||
|
<span className="text-gold text-xs font-medium shrink-0">{group.time}</span>
|
||||||
|
<span className="font-medium text-white text-sm truncate">{group.style}</span>
|
||||||
|
<span className="text-xs text-neutral-500 truncate hidden sm:inline">· {group.trainer}</span>
|
||||||
|
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0 ml-auto">{group.hall}</span>
|
||||||
|
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">{group.items.length} чел.</span>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-4 pb-3 pt-1 space-y-1.5">
|
||||||
|
{group.items.map((b) => (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
className="rounded-lg border border-white/5 bg-neutral-800/30 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-sm">
|
||||||
|
<span className="font-medium text-white">{b.name}</span>
|
||||||
|
<a href={`tel:${b.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||||
|
<Phone size={10} />{b.phone}
|
||||||
|
</a>
|
||||||
|
{b.instagram && (
|
||||||
|
<a
|
||||||
|
href={`https://ig.me/m/${b.instagram.replace(/^@/, "")}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs"
|
||||||
|
>
|
||||||
|
<Instagram size={10} />{b.instagram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{b.telegram && (
|
||||||
|
<a
|
||||||
|
href={`https://t.me/${b.telegram.replace(/^@/, "")}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs"
|
||||||
|
>
|
||||||
|
<Send size={10} />{b.telegram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="text-neutral-600 text-xs ml-auto">{fmtDate(b.createdAt)}</span>
|
||||||
|
<DeleteBtn onClick={() => handleDelete(b.id)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reminders Tab ---
|
||||||
|
|
||||||
|
interface ReminderItem {
|
||||||
|
id: number;
|
||||||
|
type: "class" | "master-class" | "open-day";
|
||||||
|
table: "mc_registrations" | "group_bookings" | "open_day_bookings";
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
instagram?: string;
|
||||||
|
telegram?: string;
|
||||||
|
reminderStatus?: string;
|
||||||
|
eventLabel: string;
|
||||||
|
eventDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReminderStatus = "pending" | "coming" | "cancelled";
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<ReminderStatus, { label: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }> = {
|
||||||
|
pending: { label: "Нет ответа", icon: Clock, color: "text-amber-400", bg: "bg-amber-500/10", border: "border-amber-500/20" },
|
||||||
|
coming: { label: "Придёт", icon: CheckCircle2, color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
|
||||||
|
cancelled: { label: "Не придёт", icon: XCircle, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_CONFIG = {
|
||||||
|
"master-class": { label: "МК", icon: Star, color: "text-purple-400" },
|
||||||
|
"open-day": { label: "Open Day", icon: DoorOpen, color: "text-gold" },
|
||||||
|
"class": { label: "Занятие", icon: Calendar, color: "text-blue-400" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function RemindersTab() {
|
||||||
|
const [items, setItems] = useState<ReminderItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch("/api/admin/reminders")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: ReminderItem[]) => setItems(data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function setStatus(item: ReminderItem, status: ReminderStatus | null) {
|
||||||
|
setItems((prev) => prev.map((i) => i.id === item.id && i.table === item.table ? { ...i, reminderStatus: status ?? undefined } : i));
|
||||||
|
await adminFetch("/api/admin/reminders", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ table: item.table, id: item.id, status }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||||
|
|
||||||
|
const todayItems = items.filter((i) => i.eventDate === today);
|
||||||
|
const tomorrowItems = items.filter((i) => i.eventDate === tomorrow);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
function countByStatus(list: ReminderItem[]) {
|
||||||
|
const coming = list.filter((i) => i.reminderStatus === "coming").length;
|
||||||
|
const cancelled = list.filter((i) => i.reminderStatus === "cancelled").length;
|
||||||
|
const pending = list.filter((i) => i.reminderStatus === "pending").length;
|
||||||
|
const notAsked = list.filter((i) => !i.reminderStatus).length;
|
||||||
|
return { coming, cancelled, pending, notAsked, total: list.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<Bell size={32} className="mx-auto text-neutral-600 mb-3" />
|
||||||
|
<p className="text-neutral-400">Нет напоминаний — все на контроле</p>
|
||||||
|
<p className="text-xs text-neutral-600 mt-1">Здесь появятся записи на сегодня и завтра</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group items by event within each day
|
||||||
|
function groupByEvent(dayItems: ReminderItem[]) {
|
||||||
|
const map: Record<string, { type: ReminderItem["type"]; label: string; items: ReminderItem[] }> = {};
|
||||||
|
for (const item of dayItems) {
|
||||||
|
const key = `${item.type}|${item.eventLabel}`;
|
||||||
|
if (!map[key]) map[key] = { type: item.type, label: item.eventLabel, items: [] };
|
||||||
|
map[key].items.push(item);
|
||||||
|
}
|
||||||
|
return Object.values(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_SECTIONS = [
|
||||||
|
{ key: "not-asked", label: "Не спрошены", color: "text-gold", bg: "bg-gold/10", match: (i: ReminderItem) => !i.reminderStatus },
|
||||||
|
{ key: "pending", label: "Нет ответа", color: "text-amber-400", bg: "bg-amber-500/10", match: (i: ReminderItem) => i.reminderStatus === "pending" },
|
||||||
|
{ key: "coming", label: "Придёт", color: "text-emerald-400", bg: "bg-emerald-500/10", match: (i: ReminderItem) => i.reminderStatus === "coming" },
|
||||||
|
{ key: "cancelled", label: "Не придёт", color: "text-red-400", bg: "bg-red-500/10", match: (i: ReminderItem) => i.reminderStatus === "cancelled" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderPerson(item: ReminderItem) {
|
||||||
|
const currentStatus = item.reminderStatus as ReminderStatus | undefined;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${item.table}-${item.id}`}
|
||||||
|
className={`rounded-lg border p-3 transition-colors ${
|
||||||
|
!currentStatus ? "border-gold/20 bg-gold/[0.03]"
|
||||||
|
: currentStatus === "coming" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
|
||||||
|
: currentStatus === "cancelled" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
|
||||||
|
: currentStatus === "pending" ? "border-amber-500/15 bg-amber-500/[0.02]"
|
||||||
|
: "border-white/5 bg-neutral-800/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-sm">
|
||||||
|
<span className="font-medium text-white">{item.name}</span>
|
||||||
|
{item.phone && (
|
||||||
|
<a href={`tel:${item.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||||
|
<Phone size={10} />{item.phone}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{item.instagram && (
|
||||||
|
<a href={`https://ig.me/m/${item.instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
|
||||||
|
<Instagram size={10} />{item.instagram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{item.telegram && (
|
||||||
|
<a href={`https://t.me/${item.telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
|
||||||
|
<Send size={10} />{item.telegram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1 ml-auto">
|
||||||
|
{(["coming", "pending", "cancelled"] as ReminderStatus[]).map((st) => {
|
||||||
|
const conf = STATUS_CONFIG[st];
|
||||||
|
const Icon = conf.icon;
|
||||||
|
const active = currentStatus === st;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={st}
|
||||||
|
onClick={() => setStatus(item, active ? null : st)}
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium transition-all ${
|
||||||
|
active
|
||||||
|
? `${conf.bg} ${conf.color} border ${conf.border}`
|
||||||
|
: "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={10} />
|
||||||
|
{conf.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[
|
||||||
|
{ label: "Сегодня", date: today, items: todayItems },
|
||||||
|
{ label: "Завтра", date: tomorrow, items: tomorrowItems },
|
||||||
|
]
|
||||||
|
.filter((g) => g.items.length > 0)
|
||||||
|
.map((group) => {
|
||||||
|
const eventGroups = groupByEvent(group.items);
|
||||||
|
return (
|
||||||
|
<div key={group.date}>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<h3 className="text-sm font-bold text-white">{group.label}</h3>
|
||||||
|
<span className="text-[10px] text-neutral-500">
|
||||||
|
{new Date(group.date + "T12:00").toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{eventGroups.map((eg) => {
|
||||||
|
const typeConf = TYPE_CONFIG[eg.type];
|
||||||
|
const TypeIcon = typeConf.icon;
|
||||||
|
const egStats = countByStatus(eg.items);
|
||||||
|
return (
|
||||||
|
<div key={eg.label} className="rounded-xl border border-white/10 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-900">
|
||||||
|
<TypeIcon size={13} className={typeConf.color} />
|
||||||
|
<span className="text-sm font-medium text-white">{eg.label}</span>
|
||||||
|
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{eg.items.length} чел.</span>
|
||||||
|
<div className="flex gap-2 ml-auto text-[10px]">
|
||||||
|
{egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>}
|
||||||
|
{egStats.cancelled > 0 && <span className="text-red-400">{egStats.cancelled} не придёт</span>}
|
||||||
|
{egStats.pending > 0 && <span className="text-amber-400">{egStats.pending} нет ответа</span>}
|
||||||
|
{egStats.notAsked > 0 && <span className="text-gold">{egStats.notAsked} не спрошены</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-3 pt-1">
|
||||||
|
{STATUS_SECTIONS
|
||||||
|
.map((sec) => ({ ...sec, items: eg.items.filter(sec.match) }))
|
||||||
|
.filter((sec) => sec.items.length > 0)
|
||||||
|
.map((sec) => (
|
||||||
|
<div key={sec.key} className="mt-2 first:mt-0">
|
||||||
|
<span className={`text-[10px] font-medium ${sec.color} ${sec.bg} rounded-full px-2 py-0.5`}>
|
||||||
|
{sec.label} · {sec.items.length}
|
||||||
|
</span>
|
||||||
|
<div className="mt-1.5 space-y-1.5">
|
||||||
|
{sec.items.map(renderPerson)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shared helpers ---
|
||||||
|
|
||||||
|
function LoadingSpinner() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-8 text-neutral-500 justify-center">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ total }: { total: number }) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-neutral-500 py-8 text-center">
|
||||||
|
{total === 0 ? "Пока нет записей" : "Нет записей по фильтру"}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteBtn({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Page ---
|
||||||
|
|
||||||
|
const TABS: { key: Tab; label: string }[] = [
|
||||||
|
{ key: "reminders", label: "Напоминания" },
|
||||||
|
{ key: "classes", label: "Занятия" },
|
||||||
|
{ key: "master-classes", label: "Мастер-классы" },
|
||||||
|
{ key: "open-day", label: "День открытых дверей" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function BookingsPage() {
|
||||||
|
const [tab, setTab] = useState<Tab>("reminders");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Записи</h1>
|
||||||
|
<p className="mt-1 text-neutral-400 text-sm">
|
||||||
|
Все заявки и записи в одном месте
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="mt-5 flex border-b border-white/10">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={`px-4 py-2.5 text-sm font-medium transition-colors relative ${
|
||||||
|
tab === t.key
|
||||||
|
? "text-gold"
|
||||||
|
: "text-neutral-400 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
{tab === t.key && (
|
||||||
|
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gold rounded-full" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div className="mt-4">
|
||||||
|
{tab === "reminders" && <RemindersTab />}
|
||||||
|
{tab === "classes" && <GroupBookingsTab />}
|
||||||
|
{tab === "master-classes" && <McRegistrationsTab />}
|
||||||
|
{tab === "open-day" && <OpenDayBookingsTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
src/app/admin/classes/page.tsx
Normal file
239
src/app/admin/classes/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/app/admin/contact/page.tsx
Normal file
55
src/app/admin/contact/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/app/admin/faq/page.tsx
Normal file
48
src/app/admin/faq/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/app/admin/hero/page.tsx
Normal file
43
src/app/admin/hero/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
|
import { InputField } from "../_components/FormField";
|
||||||
|
|
||||||
|
interface HeroData {
|
||||||
|
headline: string;
|
||||||
|
subheadline: string;
|
||||||
|
ctaText: string;
|
||||||
|
ctaHref: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeroEditorPage() {
|
||||||
|
return (
|
||||||
|
<SectionEditor<HeroData> sectionKey="hero" title="Главный экран">
|
||||||
|
{(data, update) => (
|
||||||
|
<>
|
||||||
|
<InputField
|
||||||
|
label="Заголовок"
|
||||||
|
value={data.headline}
|
||||||
|
onChange={(v) => update({ ...data, headline: v })}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Подзаголовок"
|
||||||
|
value={data.subheadline}
|
||||||
|
onChange={(v) => update({ ...data, subheadline: v })}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Текст кнопки"
|
||||||
|
value={data.ctaText}
|
||||||
|
onChange={(v) => update({ ...data, ctaText: v })}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Ссылка кнопки"
|
||||||
|
value={data.ctaHref}
|
||||||
|
onChange={(v) => update({ ...data, ctaHref: v })}
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
src/app/admin/layout.tsx
Normal file
176
src/app/admin/layout.tsx
Normal 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 30s
|
||||||
|
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, 30000);
|
||||||
|
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>
|
||||||
|
<span className="font-bold">BLACK HEART</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 p-4 sm:p-6 lg:p-8">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/app/admin/login/page.tsx
Normal file
76
src/app/admin/login/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export default function AdminLoginPage() {
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
router.push("/admin");
|
||||||
|
} else {
|
||||||
|
setError("Неверный пароль");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Ошибка соединения");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-neutral-950 px-4">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="w-full max-w-sm space-y-6 rounded-2xl border border-white/10 bg-neutral-900 p-8"
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-white">BLACK HEART</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-400">Панель управления</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm text-neutral-400 mb-2">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-3 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-400 text-center">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !password}
|
||||||
|
className="w-full rounded-lg bg-gold px-4 py-3 font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Вход..." : "Войти"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
628
src/app/admin/master-classes/page.tsx
Normal file
628
src/app/admin/master-classes/page.tsx
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useMemo } from "react";
|
||||||
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
|
import { InputField, TextareaField } from "../_components/FormField";
|
||||||
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
|
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
||||||
|
|
||||||
|
function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||||
|
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
<div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={raw}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
onChange(v ? `${v} BYN` : "");
|
||||||
|
}}
|
||||||
|
placeholder={placeholder ?? "0"}
|
||||||
|
className="flex-1 bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none min-w-0"
|
||||||
|
/>
|
||||||
|
<span className="flex items-center pr-4 text-sm font-medium text-gold select-none">
|
||||||
|
BYN
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MasterClassesData {
|
||||||
|
title: string;
|
||||||
|
successMessage?: string;
|
||||||
|
items: MasterClassItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Autocomplete Multi-Select ---
|
||||||
|
function AutocompleteMulti({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
options: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const selected = useMemo(() => (value ? value.split(", ").filter(Boolean) : []), [value]);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!query) return options.filter((o) => !selected.includes(o));
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
return options.filter(
|
||||||
|
(o) => !selected.includes(o) && o.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [query, options, selected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handle(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handle);
|
||||||
|
return () => document.removeEventListener("mousedown", handle);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function addItem(item: string) {
|
||||||
|
onChange([...selected, item].join(", "));
|
||||||
|
setQuery("");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(item: string) {
|
||||||
|
onChange(selected.filter((s) => s !== item).join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
addItem(filtered[0]);
|
||||||
|
} else if (query.trim()) {
|
||||||
|
addItem(query.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === "Backspace" && !query && selected.length > 0) {
|
||||||
|
removeItem(selected[selected.length - 1]);
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||||
|
{/* Selected chips + input */}
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
className={`flex flex-wrap items-center gap-1.5 rounded-lg border bg-neutral-800 px-3 py-2 min-h-[42px] cursor-text transition-colors ${
|
||||||
|
open ? "border-gold" : "border-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected.map((item) => (
|
||||||
|
<span
|
||||||
|
key={item}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/30 px-2.5 py-0.5 text-xs font-medium text-gold"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeItem(item);
|
||||||
|
}}
|
||||||
|
className="text-gold/60 hover:text-gold transition-colors"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={selected.length === 0 ? placeholder : ""}
|
||||||
|
className="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-neutral-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{open && filtered.length > 0 && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden max-h-48 overflow-y-auto">
|
||||||
|
{filtered.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => addItem(opt)}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Location Select ---
|
||||||
|
function LocationSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
locations,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
locations: { name: string; address: string }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Локация</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{locations.map((loc) => {
|
||||||
|
const active = value === loc.name;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={loc.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(active ? "" : loc.name)}
|
||||||
|
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
active
|
||||||
|
? "bg-gold/20 text-gold border border-gold/40"
|
||||||
|
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{active && <Check size={10} className="inline mr-1" />}
|
||||||
|
{loc.name}
|
||||||
|
<span className="text-neutral-500 ml-1 text-[10px]">
|
||||||
|
{loc.address.replace(/^г\.\s*\S+,\s*/, "")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Date List ---
|
||||||
|
function calcDurationText(startTime: string, endTime: string): string {
|
||||||
|
if (!startTime || !endTime) return "";
|
||||||
|
const [sh, sm] = startTime.split(":").map(Number);
|
||||||
|
const [eh, em] = endTime.split(":").map(Number);
|
||||||
|
const mins = (eh * 60 + em) - (sh * 60 + sm);
|
||||||
|
if (mins <= 0) return "";
|
||||||
|
const h = Math.floor(mins / 60);
|
||||||
|
const m = mins % 60;
|
||||||
|
if (h > 0 && m > 0) return `${h} ч ${m} мин`;
|
||||||
|
if (h > 0) return h === 1 ? "1 час" : h < 5 ? `${h} часа` : `${h} часов`;
|
||||||
|
return `${m} мин`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlotsField({
|
||||||
|
slots,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
slots: MasterClassSlot[];
|
||||||
|
onChange: (slots: MasterClassSlot[]) => void;
|
||||||
|
}) {
|
||||||
|
function addSlot() {
|
||||||
|
// Copy time from last slot for convenience
|
||||||
|
const last = slots[slots.length - 1];
|
||||||
|
onChange([...slots, {
|
||||||
|
date: "",
|
||||||
|
startTime: last?.startTime ?? "",
|
||||||
|
endTime: last?.endTime ?? "",
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSlot(index: number, patch: Partial<MasterClassSlot>) {
|
||||||
|
onChange(slots.map((s, i) => (i === index ? { ...s, ...patch } : s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSlot(index: number) {
|
||||||
|
onChange(slots.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Даты и время</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{slots.map((slot, i) => {
|
||||||
|
const dur = calcDurationText(slot.startTime, slot.endTime);
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-2 flex-wrap">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={slot.date}
|
||||||
|
onChange={(e) => updateSlot(i, { date: e.target.value })}
|
||||||
|
className={`w-[140px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
|
||||||
|
!slot.date ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={slot.startTime}
|
||||||
|
onChange={(e) => updateSlot(i, { startTime: e.target.value })}
|
||||||
|
className="w-[100px] rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
<span className="text-neutral-500 text-xs">–</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={slot.endTime}
|
||||||
|
onChange={(e) => updateSlot(i, { endTime: e.target.value })}
|
||||||
|
className="w-[100px] rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
{dur && (
|
||||||
|
<span className="text-[11px] text-neutral-500 bg-neutral-800/50 rounded-full px-2 py-0.5">
|
||||||
|
{dur}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeSlot(i)}
|
||||||
|
className="shrink-0 rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addSlot}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-3 py-1.5 text-xs text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
Добавить дату
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Image Upload ---
|
||||||
|
function ImageUploadField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("folder", "master-classes");
|
||||||
|
try {
|
||||||
|
const res = await adminFetch("/api/admin/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.path) onChange(result.path);
|
||||||
|
} catch {
|
||||||
|
/* upload failed */
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">
|
||||||
|
Изображение
|
||||||
|
</label>
|
||||||
|
{value ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg bg-neutral-700/50 px-3 py-2 text-sm text-neutral-300">
|
||||||
|
<ImageIcon size={14} className="text-gold" />
|
||||||
|
<span className="max-w-[200px] truncate">
|
||||||
|
{value.split("/").pop()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
className="rounded-lg p-2 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload size={14} />
|
||||||
|
)}
|
||||||
|
Заменить
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-3 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload size={16} />
|
||||||
|
)}
|
||||||
|
{uploading ? "Загрузка..." : "Загрузить изображение"}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Instagram Link Field ---
|
||||||
|
function InstagramLinkField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
const error = getInstagramError(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">
|
||||||
|
Ссылка на Instagram
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="https://instagram.com/p/... или /reel/..."
|
||||||
|
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
|
||||||
|
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{value && !error && (
|
||||||
|
<Check
|
||||||
|
size={14}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<AlertCircle
|
||||||
|
size={14}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-red-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-[11px] text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstagramError(url: string): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const host = parsed.hostname.replace("www.", "");
|
||||||
|
if (host !== "instagram.com" && host !== "instagr.am") {
|
||||||
|
return "Ссылка должна вести на instagram.com";
|
||||||
|
}
|
||||||
|
const validPaths = ["/p/", "/reel/", "/tv/", "/stories/"];
|
||||||
|
if (!validPaths.some((p) => parsed.pathname.includes(p))) {
|
||||||
|
return "Ожидается ссылка на пост, рилс или сторис (/p/, /reel/, /tv/)";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return "Некорректная ссылка";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validation badge ---
|
||||||
|
function ValidationHint({ fields }: { fields: Record<string, string> }) {
|
||||||
|
const missing = Object.entries(fields).filter(([, v]) => !(v ?? "").trim());
|
||||||
|
if (missing.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-1.5 rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-xs text-red-400">
|
||||||
|
<AlertCircle size={12} className="shrink-0 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
Не заполнено: {missing.map(([k]) => k).join(", ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main page ---
|
||||||
|
export default function MasterClassesEditorPage() {
|
||||||
|
const [trainers, setTrainers] = useState<string[]>([]);
|
||||||
|
const [styles, setStyles] = useState<string[]>([]);
|
||||||
|
const [locations, setLocations] = useState<{ name: string; address: string }[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch trainers from team
|
||||||
|
adminFetch("/api/admin/team")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((members: { name: string }[]) => {
|
||||||
|
setTrainers(members.map((m) => m.name));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Fetch styles from classes section
|
||||||
|
adminFetch("/api/admin/sections/classes")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { items: { name: string }[] }) => {
|
||||||
|
setStyles(data.items.map((c) => c.name));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Fetch locations from schedule section
|
||||||
|
adminFetch("/api/admin/sections/schedule")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { locations: { name: string; address: string }[] }) => {
|
||||||
|
setLocations(data.locations);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionEditor<MasterClassesData>
|
||||||
|
sectionKey="masterClasses"
|
||||||
|
title="Мастер-классы"
|
||||||
|
>
|
||||||
|
{(data, update) => (
|
||||||
|
<>
|
||||||
|
<InputField
|
||||||
|
label="Заголовок секции"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(v) => update({ ...data, title: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputField
|
||||||
|
label="Текст после записи (success popup)"
|
||||||
|
value={data.successMessage || ""}
|
||||||
|
onChange={(v) => update({ ...data, successMessage: v || undefined })}
|
||||||
|
placeholder="Вы записаны! Мы свяжемся с вами"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArrayEditor
|
||||||
|
label="Мастер-классы"
|
||||||
|
items={data.items}
|
||||||
|
onChange={(items) => update({ ...data, items })}
|
||||||
|
renderItem={(item, _i, updateItem) => (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ValidationHint
|
||||||
|
fields={{
|
||||||
|
Название: item.title,
|
||||||
|
Тренер: item.trainer,
|
||||||
|
Стиль: item.style,
|
||||||
|
Стоимость: item.cost,
|
||||||
|
"Даты и время": (item.slots ?? []).length > 0 ? "ok" : "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputField
|
||||||
|
label="Название"
|
||||||
|
value={item.title}
|
||||||
|
onChange={(v) => updateItem({ ...item, title: v })}
|
||||||
|
placeholder="Мастер-класс от Анны Тарыбы"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImageUploadField
|
||||||
|
value={item.image}
|
||||||
|
onChange={(v) => updateItem({ ...item, image: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<AutocompleteMulti
|
||||||
|
label="Тренер"
|
||||||
|
value={item.trainer}
|
||||||
|
onChange={(v) => updateItem({ ...item, trainer: v })}
|
||||||
|
options={trainers}
|
||||||
|
placeholder="Добавить тренера..."
|
||||||
|
/>
|
||||||
|
<AutocompleteMulti
|
||||||
|
label="Стиль"
|
||||||
|
value={item.style}
|
||||||
|
onChange={(v) => updateItem({ ...item, style: v })}
|
||||||
|
options={styles}
|
||||||
|
placeholder="Добавить стиль..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PriceField
|
||||||
|
label="Стоимость"
|
||||||
|
value={item.cost}
|
||||||
|
onChange={(v) => updateItem({ ...item, cost: v })}
|
||||||
|
placeholder="40"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{locations.length > 0 && (
|
||||||
|
<LocationSelect
|
||||||
|
value={item.location || ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
updateItem({ ...item, location: v || undefined })
|
||||||
|
}
|
||||||
|
locations={locations}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SlotsField
|
||||||
|
slots={item.slots ?? []}
|
||||||
|
onChange={(slots) => updateItem({ ...item, slots })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextareaField
|
||||||
|
label="Описание"
|
||||||
|
value={item.description || ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
updateItem({ ...item, description: v || undefined })
|
||||||
|
}
|
||||||
|
placeholder="Описание мастер-класса, трек, стиль..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InstagramLinkField
|
||||||
|
value={item.instagramUrl || ""}
|
||||||
|
onChange={(v) =>
|
||||||
|
updateItem({ ...item, instagramUrl: v || undefined })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
createItem={() => ({
|
||||||
|
title: "",
|
||||||
|
image: "",
|
||||||
|
slots: [],
|
||||||
|
trainer: "",
|
||||||
|
cost: "",
|
||||||
|
style: "",
|
||||||
|
})}
|
||||||
|
addLabel="Добавить мастер-класс"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/app/admin/meta/page.tsx
Normal file
31
src/app/admin/meta/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
|
import { InputField, TextareaField } from "../_components/FormField";
|
||||||
|
|
||||||
|
interface MetaData {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetaEditorPage() {
|
||||||
|
return (
|
||||||
|
<SectionEditor<MetaData> sectionKey="meta" title="SEO / Мета">
|
||||||
|
{(data, update) => (
|
||||||
|
<>
|
||||||
|
<InputField
|
||||||
|
label="Заголовок сайта (title)"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(v) => update({ ...data, title: v })}
|
||||||
|
/>
|
||||||
|
<TextareaField
|
||||||
|
label="Описание (description)"
|
||||||
|
value={data.description}
|
||||||
|
onChange={(v) => update({ ...data, description: v })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/app/admin/news/page.tsx
Normal file
166
src/app/admin/news/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
711
src/app/admin/open-day/page.tsx
Normal file
711
src/app/admin/open-day/page.tsx
Normal file
@@ -0,0 +1,711 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, ChevronDown, ChevronUp,
|
||||||
|
Phone, Instagram, Send,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import { NotifyToggle } from "../_components/NotifyToggle";
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
interface OpenDayEvent {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
pricePerClass: number;
|
||||||
|
discountPrice: number;
|
||||||
|
discountThreshold: number;
|
||||||
|
minBookings: number;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenDayClass {
|
||||||
|
id: number;
|
||||||
|
eventId: number;
|
||||||
|
hall: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
trainer: string;
|
||||||
|
style: string;
|
||||||
|
cancelled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
bookingCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenDayBooking {
|
||||||
|
id: number;
|
||||||
|
classId: number;
|
||||||
|
eventId: number;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
instagram?: string;
|
||||||
|
telegram?: string;
|
||||||
|
notifiedConfirm: boolean;
|
||||||
|
notifiedReminder: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
classStyle?: string;
|
||||||
|
classTrainer?: string;
|
||||||
|
classTime?: string;
|
||||||
|
classHall?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function generateTimeSlots(startHour: number, endHour: number): string[] {
|
||||||
|
const slots: string[] = [];
|
||||||
|
for (let h = startHour; h < endHour; h++) {
|
||||||
|
slots.push(`${h.toString().padStart(2, "0")}:00`);
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHour(time: string): string {
|
||||||
|
const [h, m] = time.split(":").map(Number);
|
||||||
|
return `${(h + 1).toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event Settings ---
|
||||||
|
|
||||||
|
function EventSettings({
|
||||||
|
event,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
event: OpenDayEvent;
|
||||||
|
onChange: (patch: Partial<OpenDayEvent>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-4">
|
||||||
|
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||||
|
<Calendar size={18} className="text-gold" />
|
||||||
|
Настройки мероприятия
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Название</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={event.title}
|
||||||
|
onChange={(e) => onChange({ title: e.target.value })}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Дата</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={event.date}
|
||||||
|
onChange={(e) => onChange({ date: e.target.value })}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Описание</label>
|
||||||
|
<textarea
|
||||||
|
value={event.description || ""}
|
||||||
|
onChange={(e) => onChange({ description: e.target.value || undefined })}
|
||||||
|
rows={2}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-none"
|
||||||
|
placeholder="Описание мероприятия..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={event.pricePerClass}
|
||||||
|
onChange={(e) => onChange({ pricePerClass: parseInt(e.target.value) || 0 })}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Скидка (BYN)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={event.discountPrice}
|
||||||
|
onChange={(e) => onChange({ discountPrice: parseInt(e.target.value) || 0 })}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">От N занятий</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={event.discountThreshold}
|
||||||
|
onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 1 })}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Мин. записей</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={event.minBookings}
|
||||||
|
onChange={(e) => onChange({ minBookings: parseInt(e.target.value) || 1 })}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ active: !event.active })}
|
||||||
|
className={`relative flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${
|
||||||
|
event.active
|
||||||
|
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30"
|
||||||
|
: "bg-neutral-800 text-neutral-400 border border-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{event.active ? <CheckCircle2 size={14} /> : <Ban size={14} />}
|
||||||
|
{event.active ? "Опубликовано" : "Черновик"}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
{event.pricePerClass} BYN / занятие, от {event.discountThreshold} — {event.discountPrice} BYN
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Class Grid Cell ---
|
||||||
|
|
||||||
|
function ClassCell({
|
||||||
|
cls,
|
||||||
|
minBookings,
|
||||||
|
trainers,
|
||||||
|
styles,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
cls: OpenDayClass;
|
||||||
|
minBookings: number;
|
||||||
|
trainers: string[];
|
||||||
|
styles: string[];
|
||||||
|
onUpdate: (id: number, data: Partial<OpenDayClass>) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
onCancel: (id: number) => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [trainer, setTrainer] = useState(cls.trainer);
|
||||||
|
const [style, setStyle] = useState(cls.style);
|
||||||
|
|
||||||
|
const atRisk = cls.bookingCount < minBookings && !cls.cancelled;
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (trainer.trim() && style.trim()) {
|
||||||
|
onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim() });
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<div className="p-2 space-y-1.5">
|
||||||
|
<select
|
||||||
|
value={trainer}
|
||||||
|
onChange={(e) => setTrainer(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-white/10 bg-neutral-800 px-2 py-1 text-xs text-white outline-none focus:border-gold"
|
||||||
|
>
|
||||||
|
<option value="">Тренер...</option>
|
||||||
|
{trainers.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={style}
|
||||||
|
onChange={(e) => setStyle(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-white/10 bg-neutral-800 px-2 py-1 text-xs text-white outline-none focus:border-gold"
|
||||||
|
>
|
||||||
|
<option value="">Стиль...</option>
|
||||||
|
{styles.map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
|
<button onClick={() => setEditing(false)} className="text-[10px] text-neutral-500 hover:text-white px-1">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button onClick={save} className="text-[10px] text-gold hover:text-gold-light px-1 font-medium">
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative p-2 rounded-lg cursor-pointer transition-all ${
|
||||||
|
cls.cancelled
|
||||||
|
? "bg-neutral-800/30 opacity-50"
|
||||||
|
: atRisk
|
||||||
|
? "bg-red-500/5 border border-red-500/20"
|
||||||
|
: "bg-gold/5 border border-gold/15 hover:border-gold/30"
|
||||||
|
}`}
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-white truncate">{cls.style}</div>
|
||||||
|
<div className="text-[10px] text-neutral-400 truncate">{cls.trainer}</div>
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<span className={`text-[10px] font-medium ${
|
||||||
|
cls.cancelled
|
||||||
|
? "text-neutral-500 line-through"
|
||||||
|
: atRisk
|
||||||
|
? "text-red-400"
|
||||||
|
: "text-emerald-400"
|
||||||
|
}`}>
|
||||||
|
{cls.bookingCount} чел.
|
||||||
|
</span>
|
||||||
|
{atRisk && !cls.cancelled && (
|
||||||
|
<span className="text-[9px] text-red-400">мин. {minBookings}</span>
|
||||||
|
)}
|
||||||
|
{cls.cancelled && <span className="text-[9px] text-neutral-500">отменено</span>}
|
||||||
|
</div>
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="absolute top-1 right-1 hidden group-hover:flex gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onCancel(cls.id); }}
|
||||||
|
className="rounded p-0.5 text-neutral-500 hover:text-yellow-400"
|
||||||
|
title={cls.cancelled ? "Восстановить" : "Отменить"}
|
||||||
|
>
|
||||||
|
<Ban size={10} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete(cls.id); }}
|
||||||
|
className="rounded p-0.5 text-neutral-500 hover:text-red-400"
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<Trash2 size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Schedule Grid ---
|
||||||
|
|
||||||
|
function ScheduleGrid({
|
||||||
|
eventId,
|
||||||
|
minBookings,
|
||||||
|
halls,
|
||||||
|
classes,
|
||||||
|
trainers,
|
||||||
|
styles,
|
||||||
|
onClassesChange,
|
||||||
|
}: {
|
||||||
|
eventId: number;
|
||||||
|
minBookings: number;
|
||||||
|
halls: string[];
|
||||||
|
classes: OpenDayClass[];
|
||||||
|
trainers: string[];
|
||||||
|
styles: string[];
|
||||||
|
onClassesChange: () => void;
|
||||||
|
}) {
|
||||||
|
const timeSlots = generateTimeSlots(10, 22);
|
||||||
|
|
||||||
|
// Build lookup: hall -> time -> class
|
||||||
|
const grid = useMemo(() => {
|
||||||
|
const map: Record<string, Record<string, OpenDayClass>> = {};
|
||||||
|
for (const hall of halls) map[hall] = {};
|
||||||
|
for (const cls of classes) {
|
||||||
|
if (!map[cls.hall]) map[cls.hall] = {};
|
||||||
|
map[cls.hall][cls.startTime] = cls;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [classes, halls]);
|
||||||
|
|
||||||
|
async function addClass(hall: string, startTime: string) {
|
||||||
|
const endTime = addHour(startTime);
|
||||||
|
await adminFetch("/api/admin/open-day/classes", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ eventId, hall, startTime, endTime, trainer: "—", style: "—" }),
|
||||||
|
});
|
||||||
|
onClassesChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateClass(id: number, data: Partial<OpenDayClass>) {
|
||||||
|
await adminFetch("/api/admin/open-day/classes", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id, ...data }),
|
||||||
|
});
|
||||||
|
onClassesChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteClass(id: number) {
|
||||||
|
await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
|
||||||
|
onClassesChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelClass(id: number) {
|
||||||
|
const cls = classes.find((c) => c.id === id);
|
||||||
|
if (!cls) return;
|
||||||
|
await updateClass(id, { cancelled: !cls.cancelled });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-3">
|
||||||
|
<h2 className="text-lg font-bold">Расписание</h2>
|
||||||
|
|
||||||
|
{halls.length === 0 ? (
|
||||||
|
<p className="text-sm text-neutral-500">Нет залов в расписании. Добавьте локации в разделе «Расписание».</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse min-w-[500px]">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left text-xs text-neutral-500 font-medium pb-2 w-16">Время</th>
|
||||||
|
{halls.map((hall) => (
|
||||||
|
<th key={hall} className="text-left text-xs text-neutral-400 font-medium pb-2 px-1">
|
||||||
|
{hall}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{timeSlots.map((time) => (
|
||||||
|
<tr key={time} className="border-t border-white/5">
|
||||||
|
<td className="text-xs text-neutral-500 py-1 pr-2 align-top pt-2">{time}</td>
|
||||||
|
{halls.map((hall) => {
|
||||||
|
const cls = grid[hall]?.[time];
|
||||||
|
return (
|
||||||
|
<td key={hall} className="py-1 px-1 align-top">
|
||||||
|
{cls ? (
|
||||||
|
<ClassCell
|
||||||
|
cls={cls}
|
||||||
|
minBookings={minBookings}
|
||||||
|
trainers={trainers}
|
||||||
|
styles={styles}
|
||||||
|
onUpdate={updateClass}
|
||||||
|
onDelete={deleteClass}
|
||||||
|
onCancel={cancelClass}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => addClass(hall, time)}
|
||||||
|
className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={12} className="mx-auto" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bookings Table ---
|
||||||
|
|
||||||
|
function BookingsSection({
|
||||||
|
eventId,
|
||||||
|
eventDate,
|
||||||
|
}: {
|
||||||
|
eventId: number;
|
||||||
|
eventDate: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const reminderUrgent = useMemo(() => {
|
||||||
|
if (!eventDate) return false;
|
||||||
|
const now = Date.now();
|
||||||
|
const twoDays = 2 * 24 * 60 * 60 * 1000;
|
||||||
|
const eventTime = new Date(eventDate + "T10:00").getTime();
|
||||||
|
const diff = eventTime - now;
|
||||||
|
return diff >= 0 && diff <= twoDays;
|
||||||
|
}, [eventDate]);
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
setLoading(true);
|
||||||
|
adminFetch(`/api/admin/open-day/bookings?eventId=${eventId}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: OpenDayBooking[]) => setBookings(data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (!open) load();
|
||||||
|
setOpen(!open);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggle(id: number, field: "notified_confirm" | "notified_reminder") {
|
||||||
|
const b = bookings.find((x) => x.id === id);
|
||||||
|
if (!b) return;
|
||||||
|
const key = field === "notified_confirm" ? "notifiedConfirm" : "notifiedReminder";
|
||||||
|
const newValue = !b[key];
|
||||||
|
setBookings((prev) => prev.map((x) => x.id === id ? { ...x, [key]: newValue } : x));
|
||||||
|
await adminFetch("/api/admin/open-day/bookings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "toggle-notify", id, field, value: newValue }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
await adminFetch(`/api/admin/open-day/bookings?id=${id}`, { method: "DELETE" });
|
||||||
|
setBookings((prev) => prev.filter((x) => x.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCount = bookings.filter((b) => !b.notifiedConfirm).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5">
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="flex items-center gap-2 text-lg font-bold hover:text-gold transition-colors"
|
||||||
|
>
|
||||||
|
{open ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||||
|
Записи
|
||||||
|
{bookings.length > 0 && (
|
||||||
|
<span className="text-sm font-normal text-neutral-400">({bookings.length})</span>
|
||||||
|
)}
|
||||||
|
{newCount > 0 && (
|
||||||
|
<span className="rounded-full bg-red-500/20 text-red-400 px-2 py-0.5 text-[10px] font-medium">
|
||||||
|
{newCount} новых
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-2 text-neutral-500 text-sm py-4 justify-center">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && bookings.length === 0 && (
|
||||||
|
<p className="text-sm text-neutral-500 text-center py-4">Пока нет записей</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bookings.map((b) => (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
className={`rounded-lg p-3 space-y-1.5 ${
|
||||||
|
!b.notifiedConfirm ? "bg-gold/[0.03] border border-gold/20" : "bg-neutral-800/50 border border-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-sm">
|
||||||
|
<span className="font-medium text-white">{b.name}</span>
|
||||||
|
<a
|
||||||
|
href={`tel:${b.phone}`}
|
||||||
|
className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs"
|
||||||
|
>
|
||||||
|
<Phone size={10} />
|
||||||
|
{b.phone}
|
||||||
|
</a>
|
||||||
|
{b.instagram && (
|
||||||
|
<a
|
||||||
|
href={`https://ig.me/m/${b.instagram.replace(/^@/, "")}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs"
|
||||||
|
>
|
||||||
|
<Instagram size={10} />
|
||||||
|
{b.instagram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{b.telegram && (
|
||||||
|
<a
|
||||||
|
href={`https://t.me/${b.telegram.replace(/^@/, "")}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs"
|
||||||
|
>
|
||||||
|
<Send size={10} />
|
||||||
|
{b.telegram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] text-neutral-500 ml-auto">
|
||||||
|
{b.classHall} {b.classTime} · {b.classStyle}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(b.id)}
|
||||||
|
className="rounded p-1 text-neutral-500 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<NotifyToggle
|
||||||
|
confirmed={b.notifiedConfirm}
|
||||||
|
reminded={b.notifiedReminder}
|
||||||
|
reminderUrgent={reminderUrgent && !b.notifiedReminder}
|
||||||
|
onToggleConfirm={() => handleToggle(b.id, "notified_confirm")}
|
||||||
|
onToggleReminder={() => handleToggle(b.id, "notified_reminder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Page ---
|
||||||
|
|
||||||
|
export default function OpenDayAdminPage() {
|
||||||
|
const [event, setEvent] = useState<OpenDayEvent | null>(null);
|
||||||
|
const [classes, setClasses] = useState<OpenDayClass[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [trainers, setTrainers] = useState<string[]>([]);
|
||||||
|
const [styles, setStyles] = useState<string[]>([]);
|
||||||
|
const [halls, setHalls] = useState<string[]>([]);
|
||||||
|
const saveTimerRef = { current: null as ReturnType<typeof setTimeout> | null };
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
adminFetch("/api/admin/open-day").then((r) => r.json()),
|
||||||
|
adminFetch("/api/admin/team").then((r) => r.json()),
|
||||||
|
adminFetch("/api/admin/sections/classes").then((r) => r.json()),
|
||||||
|
adminFetch("/api/admin/sections/schedule").then((r) => r.json()),
|
||||||
|
])
|
||||||
|
.then(([events, members, classesData, scheduleData]: [OpenDayEvent[], { name: string }[], { items: { name: string }[] }, { locations: { name: string }[] }]) => {
|
||||||
|
if (events.length > 0) {
|
||||||
|
setEvent(events[0]);
|
||||||
|
loadClasses(events[0].id);
|
||||||
|
}
|
||||||
|
setTrainers(members.map((m) => m.name));
|
||||||
|
setStyles(classesData.items.map((c) => c.name));
|
||||||
|
setHalls(scheduleData.locations.map((l) => l.name));
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function loadClasses(eventId: number) {
|
||||||
|
adminFetch(`/api/admin/open-day/classes?eventId=${eventId}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: OpenDayClass[]) => setClasses(data))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-save event changes
|
||||||
|
const saveEvent = useCallback(
|
||||||
|
(updated: OpenDayEvent) => {
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = setTimeout(async () => {
|
||||||
|
setSaving(true);
|
||||||
|
await adminFetch("/api/admin/open-day", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(updated),
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
}, 800);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleEventChange(patch: Partial<OpenDayEvent>) {
|
||||||
|
if (!event) return;
|
||||||
|
const updated = { ...event, ...patch };
|
||||||
|
setEvent(updated);
|
||||||
|
saveEvent(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEvent() {
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
const res = await adminFetch("/api/admin/open-day", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ date: today }),
|
||||||
|
});
|
||||||
|
const { id } = await res.json();
|
||||||
|
setEvent({
|
||||||
|
id,
|
||||||
|
date: today,
|
||||||
|
title: "День открытых дверей",
|
||||||
|
pricePerClass: 30,
|
||||||
|
discountPrice: 20,
|
||||||
|
discountThreshold: 3,
|
||||||
|
minBookings: 4,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEvent() {
|
||||||
|
if (!event) return;
|
||||||
|
await adminFetch(`/api/admin/open-day?id=${event.id}`, { method: "DELETE" });
|
||||||
|
setEvent(null);
|
||||||
|
setClasses([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-12 text-neutral-500 justify-center">
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<h1 className="text-2xl font-bold">День открытых дверей</h1>
|
||||||
|
<p className="mt-2 text-neutral-400">Создайте мероприятие, чтобы начать</p>
|
||||||
|
<button
|
||||||
|
onClick={createEvent}
|
||||||
|
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-gold px-6 py-3 text-sm font-semibold text-black hover:bg-gold-light transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Создать мероприятие
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">День открытых дверей</h1>
|
||||||
|
{saving && <span className="text-xs text-neutral-500">Сохранение...</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={deleteEvent}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-red-500/20 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EventSettings event={event} onChange={handleEventChange} />
|
||||||
|
|
||||||
|
<ScheduleGrid
|
||||||
|
eventId={event.id}
|
||||||
|
minBookings={event.minBookings}
|
||||||
|
halls={halls}
|
||||||
|
classes={classes}
|
||||||
|
trainers={trainers}
|
||||||
|
styles={styles}
|
||||||
|
onClassesChange={() => loadClasses(event.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BookingsSection eventId={event.id} eventDate={event.date} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/app/admin/page.tsx
Normal file
141
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Globe,
|
||||||
|
Sparkles,
|
||||||
|
FileText,
|
||||||
|
Users,
|
||||||
|
BookOpen,
|
||||||
|
Star,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
HelpCircle,
|
||||||
|
Newspaper,
|
||||||
|
Phone,
|
||||||
|
ClipboardList,
|
||||||
|
DoorOpen,
|
||||||
|
UserPlus,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
|
||||||
|
interface UnreadCounts {
|
||||||
|
groupBookings: number;
|
||||||
|
mcRegistrations: number;
|
||||||
|
openDayBookings: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARDS = [
|
||||||
|
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe, desc: "Заголовок и описание сайта" },
|
||||||
|
{ href: "/admin/hero", label: "Главный экран", icon: Sparkles, desc: "Заголовок, подзаголовок, кнопка" },
|
||||||
|
{ href: "/admin/about", label: "О студии", icon: FileText, desc: "Текст о студии" },
|
||||||
|
{ href: "/admin/team", label: "Команда", icon: Users, desc: "Тренеры и инструкторы" },
|
||||||
|
{ href: "/admin/classes", label: "Направления", icon: BookOpen, desc: "Типы занятий" },
|
||||||
|
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star, desc: "Мастер-классы и записи" },
|
||||||
|
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen, desc: "Открытые занятия, расписание, записи" },
|
||||||
|
{ href: "/admin/schedule", label: "Расписание", icon: Calendar, desc: "Расписание занятий" },
|
||||||
|
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList, desc: "Все записи и заявки" },
|
||||||
|
{ href: "/admin/pricing", label: "Цены", icon: DollarSign, desc: "Абонементы и аренда" },
|
||||||
|
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, desc: "Часто задаваемые вопросы" },
|
||||||
|
{ href: "/admin/news", label: "Новости", icon: Newspaper, desc: "Новости и анонсы" },
|
||||||
|
{ href: "/admin/contact", label: "Контакты", icon: Phone, desc: "Адреса, телефон, карта" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function UnreadWidget({ counts }: { counts: UnreadCounts }) {
|
||||||
|
if (counts.total === 0) return null;
|
||||||
|
|
||||||
|
const items: { label: string; count: number; tab: string }[] = [];
|
||||||
|
if (counts.groupBookings > 0) items.push({ label: "Занятия", count: counts.groupBookings, tab: "classes" });
|
||||||
|
if (counts.mcRegistrations > 0) items.push({ label: "Мастер-классы", count: counts.mcRegistrations, tab: "master-classes" });
|
||||||
|
if (counts.openDayBookings > 0) items.push({ label: "День открытых дверей", count: counts.openDayBookings, tab: "open-day" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/admin/bookings"
|
||||||
|
className="block rounded-xl border border-gold/20 bg-gold/[0.03] p-5 transition-all hover:border-gold/40 hover:bg-gold/[0.06]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-500/10 text-red-400">
|
||||||
|
<UserPlus size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-medium text-white">
|
||||||
|
Новые записи
|
||||||
|
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-red-500 text-white text-[11px] font-bold min-w-[20px] h-[20px] px-1.5">
|
||||||
|
{counts.total}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-neutral-400">Не подтверждённые заявки</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.tab} className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="rounded-full bg-gold/15 text-gold font-medium px-2 py-0.5">
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-400">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const [counts, setCounts] = useState<UnreadCounts | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch("/api/admin/unread-counts")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: UnreadCounts) => setCounts(data))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Панель управления</h1>
|
||||||
|
<p className="mt-1 text-neutral-400">Выберите раздел для редактирования</p>
|
||||||
|
|
||||||
|
{/* Unread bookings widget */}
|
||||||
|
{counts && counts.total > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<UnreadWidget counts={counts} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{CARDS.map((card) => {
|
||||||
|
const Icon = card.icon;
|
||||||
|
const isBookings = card.href === "/admin/bookings";
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={card.href}
|
||||||
|
href={card.href}
|
||||||
|
className="group rounded-xl border border-white/10 bg-neutral-900 p-5 transition-all hover:border-gold/30 hover:bg-neutral-900/80"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gold/10 text-gold">
|
||||||
|
<Icon size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="font-medium text-white group-hover:text-gold transition-colors flex items-center gap-2">
|
||||||
|
{card.label}
|
||||||
|
{isBookings && counts && counts.total > 0 && (
|
||||||
|
<span className="rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||||
|
{counts.total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-neutral-500">{card.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
src/app/admin/pricing/page.tsx
Normal file
205
src/app/admin/pricing/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1227
src/app/admin/schedule/page.tsx
Normal file
1227
src/app/admin/schedule/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
358
src/app/admin/team/[id]/page.tsx
Normal file
358
src/app/admin/team/[id]/page.tsx
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Save, Loader2, Check, ArrowLeft, Upload, AlertCircle } from "lucide-react";
|
||||||
|
import { InputField, TextareaField, ListField, VictoryListField, VictoryItemListField } from "../../_components/FormField";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import type { RichListItem, VictoryItem } from "@/types/content";
|
||||||
|
|
||||||
|
function extractUsername(value: string): string {
|
||||||
|
if (!value) return "";
|
||||||
|
// Strip full URL → username
|
||||||
|
const cleaned = value.replace(/^https?:\/\/(www\.)?instagram\.com\//, "").replace(/\/$/, "").replace(/^@/, "");
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemberForm {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
image: string;
|
||||||
|
instagram: string;
|
||||||
|
description: string;
|
||||||
|
experience: string[];
|
||||||
|
victories: VictoryItem[];
|
||||||
|
education: RichListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TeamMemberEditorPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const isNew = id === "new";
|
||||||
|
|
||||||
|
const [data, setData] = useState<MemberForm>({
|
||||||
|
name: "",
|
||||||
|
role: "",
|
||||||
|
image: "/images/team/placeholder.webp",
|
||||||
|
instagram: "",
|
||||||
|
description: "",
|
||||||
|
experience: [],
|
||||||
|
victories: [],
|
||||||
|
education: [],
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(!isNew);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
// Instagram validation
|
||||||
|
const [igStatus, setIgStatus] = useState<"idle" | "checking" | "valid" | "invalid">("idle");
|
||||||
|
const igTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const validateInstagram = useCallback((username: string) => {
|
||||||
|
if (igTimerRef.current) clearTimeout(igTimerRef.current);
|
||||||
|
if (!username) { setIgStatus("idle"); return; }
|
||||||
|
setIgStatus("checking");
|
||||||
|
igTimerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await adminFetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`);
|
||||||
|
const result = await res.json();
|
||||||
|
setIgStatus(result.valid ? "valid" : "invalid");
|
||||||
|
} catch {
|
||||||
|
setIgStatus("idle");
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Link validation for bio
|
||||||
|
const [linkErrors, setLinkErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
function validateUrl(url: string): boolean {
|
||||||
|
if (!url) return true;
|
||||||
|
try { new URL(url); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// City validation for victories
|
||||||
|
const [cityErrors, setCityErrors] = useState<Record<number, string>>({});
|
||||||
|
const [citySuggestions, setCitySuggestions] = useState<{ index: number; items: string[] } | null>(null);
|
||||||
|
const cityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const searchCity = useCallback((index: number, query: string) => {
|
||||||
|
if (cityTimerRef.current) clearTimeout(cityTimerRef.current);
|
||||||
|
if (!query || query.length < 2) { setCitySuggestions(null); return; }
|
||||||
|
cityTimerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&accept-language=ru`,
|
||||||
|
{ headers: { "User-Agent": "BlackheartAdmin/1.0" } }
|
||||||
|
);
|
||||||
|
const results = await res.json();
|
||||||
|
const cities = results
|
||||||
|
.map((r: Record<string, unknown>) => {
|
||||||
|
const addr = r.address as Record<string, string> | undefined;
|
||||||
|
const city = addr?.city || addr?.town || addr?.village || addr?.state || (r.name as string);
|
||||||
|
const country = addr?.country || "";
|
||||||
|
return country ? `${city}, ${country}` : city;
|
||||||
|
})
|
||||||
|
.filter((v: string, i: number, a: string[]) => a.indexOf(v) === i)
|
||||||
|
.slice(0, 6);
|
||||||
|
setCitySuggestions(cities.length > 0 ? { index, items: cities } : null);
|
||||||
|
setCityErrors((prev) => { const n = { ...prev }; delete n[index]; return n; });
|
||||||
|
} catch {
|
||||||
|
setCitySuggestions(null);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew) return;
|
||||||
|
adminFetch(`/api/admin/team/${id}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((member) => {
|
||||||
|
const username = extractUsername(member.instagram || "");
|
||||||
|
setData({
|
||||||
|
name: member.name,
|
||||||
|
role: member.role,
|
||||||
|
image: member.image,
|
||||||
|
instagram: username,
|
||||||
|
description: member.description || "",
|
||||||
|
experience: member.experience || [],
|
||||||
|
victories: member.victories || [],
|
||||||
|
education: member.education || [],
|
||||||
|
});
|
||||||
|
if (username) setIgStatus("valid"); // existing data is trusted
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id, isNew]);
|
||||||
|
|
||||||
|
const hasErrors = igStatus === "invalid" || Object.keys(linkErrors).length > 0 || Object.keys(cityErrors).length > 0;
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (hasErrors) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSaved(false);
|
||||||
|
|
||||||
|
// Build instagram as full URL for storage if username is provided
|
||||||
|
const payload = {
|
||||||
|
...data,
|
||||||
|
instagram: data.instagram ? `https://instagram.com/${data.instagram}` : "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
const res = await adminFetch("/api/admin/team", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
router.push("/admin/team");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await adminFetch(`/api/admin/team/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("folder", "team");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await adminFetch("/api/admin/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.path) {
|
||||||
|
setData((prev) => ({ ...prev, image: result.path }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Upload failed silently
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-neutral-400">
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/admin/team")}
|
||||||
|
className="rounded-lg p-2 text-neutral-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
{isNew ? "Новый участник" : data.name}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !data.name || !data.role || hasErrors || igStatus === "checking"}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : saved ? (
|
||||||
|
<Check size={16} />
|
||||||
|
) : (
|
||||||
|
<Save size={16} />
|
||||||
|
)}
|
||||||
|
{saving ? "Сохранение..." : saved ? "Сохранено!" : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-6 lg:grid-cols-[240px_1fr]">
|
||||||
|
{/* Photo */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-400 mb-2">Фото</p>
|
||||||
|
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-xl border border-white/10">
|
||||||
|
<Image
|
||||||
|
src={data.image}
|
||||||
|
alt={data.name || "Фото"}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="240px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="mt-3 flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors">
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload size={16} />
|
||||||
|
)}
|
||||||
|
{uploading ? "Загрузка..." : "Загрузить фото"}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<InputField
|
||||||
|
label="Имя"
|
||||||
|
value={data.name}
|
||||||
|
onChange={(v) => setData({ ...data, name: v })}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Роль / Специализация"
|
||||||
|
value={data.role}
|
||||||
|
onChange={(v) => setData({ ...data, role: v })}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-neutral-400 mb-1.5">Instagram</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-500 text-sm select-none">@</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.instagram}
|
||||||
|
onChange={(e) => {
|
||||||
|
const username = extractUsername(e.target.value);
|
||||||
|
setData({ ...data, instagram: username });
|
||||||
|
validateInstagram(username);
|
||||||
|
}}
|
||||||
|
placeholder="username"
|
||||||
|
className={`w-full rounded-lg border bg-neutral-800 pl-8 pr-10 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
|
||||||
|
igStatus === "invalid"
|
||||||
|
? "border-red-500 focus:border-red-500"
|
||||||
|
: igStatus === "valid"
|
||||||
|
? "border-green-500/50 focus:border-green-500"
|
||||||
|
: "border-white/10 focus:border-gold"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
{igStatus === "checking" && <Loader2 size={14} className="animate-spin text-neutral-400" />}
|
||||||
|
{igStatus === "valid" && <Check size={14} className="text-green-400" />}
|
||||||
|
{igStatus === "invalid" && <AlertCircle size={14} className="text-red-400" />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{igStatus === "invalid" && (
|
||||||
|
<p className="mt-1 text-xs text-red-400">Аккаунт не найден</p>
|
||||||
|
)}
|
||||||
|
{data.instagram && igStatus !== "invalid" && (
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">instagram.com/{data.instagram}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<TextareaField
|
||||||
|
label="Описание"
|
||||||
|
value={data.description}
|
||||||
|
onChange={(v) => setData({ ...data, description: v })}
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="border-t border-white/5 pt-4 mt-4">
|
||||||
|
<p className="text-sm font-medium text-neutral-300 mb-4">Биография</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ListField
|
||||||
|
label="Опыт"
|
||||||
|
items={data.experience}
|
||||||
|
onChange={(items) => setData({ ...data, experience: items })}
|
||||||
|
placeholder="Например: 10 лет в танцах"
|
||||||
|
/>
|
||||||
|
<VictoryItemListField
|
||||||
|
label="Достижения"
|
||||||
|
items={data.victories}
|
||||||
|
onChange={(items) => setData({ ...data, victories: items })}
|
||||||
|
cityErrors={cityErrors}
|
||||||
|
citySuggestions={citySuggestions}
|
||||||
|
onCitySearch={searchCity}
|
||||||
|
onCitySelect={(i, v) => {
|
||||||
|
const updated = data.victories.map((item, idx) => idx === i ? { ...item, location: v } : item);
|
||||||
|
setData({ ...data, victories: updated });
|
||||||
|
setCitySuggestions(null);
|
||||||
|
setCityErrors((prev) => { const n = { ...prev }; delete n[i]; return n; });
|
||||||
|
}}
|
||||||
|
onLinkValidate={(key, error) => {
|
||||||
|
setLinkErrors((prev) => {
|
||||||
|
if (error) return { ...prev, [key]: error };
|
||||||
|
const n = { ...prev }; delete n[key]; return n;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<VictoryListField
|
||||||
|
label="Образование"
|
||||||
|
items={data.education}
|
||||||
|
onChange={(items) => setData({ ...data, education: items })}
|
||||||
|
placeholder="Например: Сертификат IPSF"
|
||||||
|
onLinkValidate={(key, error) => {
|
||||||
|
setLinkErrors((prev) => {
|
||||||
|
if (error) return { ...prev, [key]: error };
|
||||||
|
const n = { ...prev }; delete n[key]; return n;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
343
src/app/admin/team/page.tsx
Normal file
343
src/app/admin/team/page.tsx
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
GripVertical,
|
||||||
|
Check,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import type { TeamMember } from "@/types/content";
|
||||||
|
|
||||||
|
type Member = TeamMember & { id: number };
|
||||||
|
|
||||||
|
export default function TeamEditorPage() {
|
||||||
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
|
const [insertAt, setInsertAt] = useState<number | null>(null);
|
||||||
|
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||||
|
const [dragSize, setDragSize] = useState({ w: 0, h: 0 });
|
||||||
|
const [grabOffset, setGrabOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminFetch("/api/admin/team")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(setMembers)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveOrder = useCallback(async (updated: Member[]) => {
|
||||||
|
setMembers(updated);
|
||||||
|
setSaving(true);
|
||||||
|
await adminFetch("/api/admin/team/reorder", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startDrag = useCallback(
|
||||||
|
(clientX: number, clientY: number, index: number) => {
|
||||||
|
const el = itemRefs.current[index];
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
setDragIndex(index);
|
||||||
|
setInsertAt(index);
|
||||||
|
setMousePos({ x: clientX, y: clientY });
|
||||||
|
setDragSize({ w: rect.width, h: rect.height });
|
||||||
|
setGrabOffset({ x: clientX - rect.left, y: clientY - rect.top });
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGripMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startDrag(e.clientX, e.clientY, index);
|
||||||
|
},
|
||||||
|
[startDrag]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, index: number) => {
|
||||||
|
const tag = (e.target as HTMLElement).closest("input, textarea, select, button, a, [role='switch']");
|
||||||
|
if (tag) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const x = e.clientX;
|
||||||
|
const y = e.clientY;
|
||||||
|
const pendingIndex = index;
|
||||||
|
let moved = false;
|
||||||
|
|
||||||
|
function onMove(ev: MouseEvent) {
|
||||||
|
const dx = ev.clientX - x;
|
||||||
|
const dy = ev.clientY - y;
|
||||||
|
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
|
||||||
|
moved = true;
|
||||||
|
cleanup();
|
||||||
|
startDrag(ev.clientX, ev.clientY, pendingIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
cleanup();
|
||||||
|
if (!moved) {
|
||||||
|
window.location.href = `/admin/team/${members[pendingIndex].id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function cleanup() {
|
||||||
|
window.removeEventListener("mousemove", onMove);
|
||||||
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
},
|
||||||
|
[startDrag, members]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dragIndex === null) return;
|
||||||
|
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
setMousePos({ x: e.clientX, y: e.clientY });
|
||||||
|
|
||||||
|
let newInsert = members.length;
|
||||||
|
for (let i = 0; i < members.length; i++) {
|
||||||
|
if (i === dragIndex) continue;
|
||||||
|
const el = itemRefs.current[i];
|
||||||
|
if (!el) continue;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const midY = rect.top + rect.height / 2;
|
||||||
|
if (e.clientY < midY) {
|
||||||
|
newInsert = i > dragIndex! ? i : i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInsertAt(newInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseUp() {
|
||||||
|
setDragIndex((prevDrag) => {
|
||||||
|
setInsertAt((prevInsert) => {
|
||||||
|
if (prevDrag !== null && prevInsert !== null) {
|
||||||
|
let targetIndex = prevInsert;
|
||||||
|
if (prevDrag < targetIndex) targetIndex -= 1;
|
||||||
|
if (prevDrag !== targetIndex) {
|
||||||
|
const updated = [...members];
|
||||||
|
const [moved] = updated.splice(prevDrag, 1);
|
||||||
|
updated.splice(targetIndex, 0, moved);
|
||||||
|
saveOrder(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
|
window.addEventListener("mouseup", onMouseUp);
|
||||||
|
return () => {
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
}, [dragIndex, members, saveOrder]);
|
||||||
|
|
||||||
|
async function deleteMember(id: number) {
|
||||||
|
if (!confirm("Удалить этого участника?")) return;
|
||||||
|
await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
|
||||||
|
setMembers((prev) => prev.filter((m) => m.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-neutral-400">
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const draggedMember = dragIndex !== null ? members[dragIndex] : null;
|
||||||
|
|
||||||
|
// Build the visual order: remove dragged item, insert placeholder at insertAt
|
||||||
|
function renderList() {
|
||||||
|
if (dragIndex === null || insertAt === null) {
|
||||||
|
// Normal render — no drag
|
||||||
|
return members.map((member, i) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
|
onMouseDown={(e) => handleCardMouseDown(e, i)}
|
||||||
|
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||||
|
>
|
||||||
|
<GripVertical size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
|
||||||
|
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="48px" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-white truncate">{member.name}</p>
|
||||||
|
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// During drag: build list without the dragged item, with placeholder inserted
|
||||||
|
const elements: React.ReactNode[] = [];
|
||||||
|
let visualIndex = 0;
|
||||||
|
|
||||||
|
// Determine where to insert placeholder relative to non-dragged items
|
||||||
|
let placeholderPos = insertAt;
|
||||||
|
if (insertAt > dragIndex) placeholderPos = insertAt - 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < members.length; i++) {
|
||||||
|
if (i === dragIndex) {
|
||||||
|
// Keep a hidden ref so midpoint detection still works
|
||||||
|
elements.push(
|
||||||
|
<div key={`hidden-${members[i].id}`} ref={(el) => { itemRefs.current[i] = el; }} className="hidden" />
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visualIndex === placeholderPos) {
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key="placeholder"
|
||||||
|
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-2"
|
||||||
|
style={{ height: dragSize.h }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = members[i];
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
|
onMouseDown={(e) => handleCardMouseDown(e, i)}
|
||||||
|
className="flex items-center gap-4 rounded-lg border border-white/10 bg-neutral-900/50 p-3 mb-2 hover:border-white/25 hover:bg-neutral-800/50 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="cursor-grab active:cursor-grabbing text-neutral-500 hover:text-white transition-colors select-none"
|
||||||
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||||
|
>
|
||||||
|
<GripVertical size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
|
||||||
|
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="48px" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-white truncate">{member.name}</p>
|
||||||
|
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); deleteMember(member.id); }} className="rounded p-2 text-neutral-400 hover:text-red-400 transition-colors">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
visualIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder at the end
|
||||||
|
if (visualIndex === placeholderPos) {
|
||||||
|
elements.push(
|
||||||
|
<div
|
||||||
|
key="placeholder"
|
||||||
|
className="rounded-lg border-2 border-dashed border-rose-500/50 bg-rose-500/5 mb-2"
|
||||||
|
style={{ height: dragSize.h }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<h1 className="text-2xl font-bold">Команда</h1>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{(saving || saved) && (
|
||||||
|
<span className="text-sm text-neutral-400 flex items-center gap-1">
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check size={14} className="text-green-400" />
|
||||||
|
)}
|
||||||
|
{saving ? "Сохранение..." : "Сохранено!"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href="/admin/team/new"
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Добавить
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{renderList()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating card following cursor */}
|
||||||
|
{dragIndex !== null &&
|
||||||
|
draggedMember &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[9999] pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: mousePos.x - grabOffset.x,
|
||||||
|
top: mousePos.y - grabOffset.y,
|
||||||
|
width: dragSize.w,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 rounded-lg border-2 border-rose-500 bg-neutral-900 p-3 shadow-2xl shadow-rose-500/20">
|
||||||
|
<div className="text-rose-400">
|
||||||
|
<GripVertical size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg">
|
||||||
|
<Image
|
||||||
|
src={draggedMember.image}
|
||||||
|
alt={draggedMember.name}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="48px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-white truncate">{draggedMember.name}</p>
|
||||||
|
<p className="text-sm text-neutral-400 truncate">{draggedMember.role}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/app/api/admin/group-bookings/route.ts
Normal file
53
src/app/api/admin/group-bookings/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getGroupBookings, toggleGroupBookingNotification, deleteGroupBooking, setGroupBookingStatus } from "@/lib/db";
|
||||||
|
import type { BookingStatus } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const bookings = getGroupBookings();
|
||||||
|
return NextResponse.json(bookings, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=30" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
if (body.action === "toggle-notify") {
|
||||||
|
const { id, field, value } = body;
|
||||||
|
if (!id || !field || typeof value !== "boolean") {
|
||||||
|
return NextResponse.json({ error: "id, field, value are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (field !== "notified_confirm" && field !== "notified_reminder") {
|
||||||
|
return NextResponse.json({ error: "Invalid field" }, { status: 400 });
|
||||||
|
}
|
||||||
|
toggleGroupBookingNotification(id, field, value);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
if (body.action === "set-status") {
|
||||||
|
const { id, status, confirmation } = body;
|
||||||
|
const valid: BookingStatus[] = ["new", "contacted", "confirmed", "declined"];
|
||||||
|
if (!id || !valid.includes(status)) {
|
||||||
|
return NextResponse.json({ error: "id and valid status are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
setGroupBookingStatus(id, status, confirmation);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/group-bookings] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const idStr = request.nextUrl.searchParams.get("id");
|
||||||
|
if (!idStr) {
|
||||||
|
return NextResponse.json({ error: "id parameter is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const id = parseInt(idStr, 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
deleteGroupBooking(id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
69
src/app/api/admin/mc-registrations/route.ts
Normal file
69
src/app/api/admin/mc-registrations/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const title = request.nextUrl.searchParams.get("title");
|
||||||
|
if (title) {
|
||||||
|
return NextResponse.json(getMcRegistrations(title));
|
||||||
|
}
|
||||||
|
// No title = return all registrations
|
||||||
|
return NextResponse.json(getAllMcRegistrations());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { masterClassTitle, name, instagram, telegram } = body;
|
||||||
|
if (!masterClassTitle || !name || !instagram) {
|
||||||
|
return NextResponse.json({ error: "masterClassTitle, name, instagram are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const id = addMcRegistration(masterClassTitle.trim(), name.trim(), instagram.trim(), telegram?.trim() || undefined);
|
||||||
|
return NextResponse.json({ ok: true, id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/mc-registrations] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Toggle notification status
|
||||||
|
if (body.action === "toggle-notify") {
|
||||||
|
const { id, field, value } = body;
|
||||||
|
if (!id || !field || typeof value !== "boolean") {
|
||||||
|
return NextResponse.json({ error: "id, field, value are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (field !== "notified_confirm" && field !== "notified_reminder") {
|
||||||
|
return NextResponse.json({ error: "Invalid field" }, { status: 400 });
|
||||||
|
}
|
||||||
|
toggleMcNotification(id, field, value);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular update
|
||||||
|
const { id, name, instagram, telegram } = body;
|
||||||
|
if (!id || !name || !instagram) {
|
||||||
|
return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
updateMcRegistration(id, name.trim(), instagram.trim(), telegram?.trim() || undefined);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/mc-registrations] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const idStr = request.nextUrl.searchParams.get("id");
|
||||||
|
if (!idStr) {
|
||||||
|
return NextResponse.json({ error: "id parameter is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const id = parseInt(idStr, 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
deleteMcRegistration(id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
44
src/app/api/admin/open-day/bookings/route.ts
Normal file
44
src/app/api/admin/open-day/bookings/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
getOpenDayBookings,
|
||||||
|
toggleOpenDayNotification,
|
||||||
|
deleteOpenDayBooking,
|
||||||
|
} from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const eventIdStr = request.nextUrl.searchParams.get("eventId");
|
||||||
|
if (!eventIdStr) return NextResponse.json({ error: "eventId is required" }, { status: 400 });
|
||||||
|
const eventId = parseInt(eventIdStr, 10);
|
||||||
|
if (isNaN(eventId)) return NextResponse.json({ error: "Invalid eventId" }, { status: 400 });
|
||||||
|
return NextResponse.json(getOpenDayBookings(eventId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
if (body.action === "toggle-notify") {
|
||||||
|
const { id, field, value } = body;
|
||||||
|
if (!id || !field || typeof value !== "boolean") {
|
||||||
|
return NextResponse.json({ error: "id, field, value required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (field !== "notified_confirm" && field !== "notified_reminder") {
|
||||||
|
return NextResponse.json({ error: "Invalid field" }, { status: 400 });
|
||||||
|
}
|
||||||
|
toggleOpenDayNotification(id, field, value);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/open-day/bookings] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const idStr = request.nextUrl.searchParams.get("id");
|
||||||
|
if (!idStr) return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
const id = parseInt(idStr, 10);
|
||||||
|
if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
|
||||||
|
deleteOpenDayBooking(id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
55
src/app/api/admin/open-day/classes/route.ts
Normal file
55
src/app/api/admin/open-day/classes/route.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
getOpenDayClasses,
|
||||||
|
addOpenDayClass,
|
||||||
|
updateOpenDayClass,
|
||||||
|
deleteOpenDayClass,
|
||||||
|
} from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const eventIdStr = request.nextUrl.searchParams.get("eventId");
|
||||||
|
if (!eventIdStr) return NextResponse.json({ error: "eventId is required" }, { status: 400 });
|
||||||
|
const eventId = parseInt(eventIdStr, 10);
|
||||||
|
if (isNaN(eventId)) return NextResponse.json({ error: "Invalid eventId" }, { status: 400 });
|
||||||
|
return NextResponse.json(getOpenDayClasses(eventId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { eventId, hall, startTime, endTime, trainer, style } = body;
|
||||||
|
if (!eventId || !hall || !startTime || !endTime || !trainer || !style) {
|
||||||
|
return NextResponse.json({ error: "All fields required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const id = addOpenDayClass(eventId, { hall, startTime, endTime, trainer, style });
|
||||||
|
return NextResponse.json({ ok: true, id });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : "Internal error";
|
||||||
|
if (msg.includes("UNIQUE")) {
|
||||||
|
return NextResponse.json({ error: "Этот слот уже занят" }, { status: 409 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
if (!body.id) return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
const { id, ...data } = body;
|
||||||
|
updateOpenDayClass(id, data);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/open-day/classes] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const idStr = request.nextUrl.searchParams.get("id");
|
||||||
|
if (!idStr) return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
const id = parseInt(idStr, 10);
|
||||||
|
if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
|
||||||
|
deleteOpenDayClass(id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
69
src/app/api/admin/open-day/route.ts
Normal file
69
src/app/api/admin/open-day/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
getOpenDayEvents,
|
||||||
|
getOpenDayEvent,
|
||||||
|
createOpenDayEvent,
|
||||||
|
updateOpenDayEvent,
|
||||||
|
deleteOpenDayEvent,
|
||||||
|
} from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const idStr = request.nextUrl.searchParams.get("id");
|
||||||
|
if (idStr) {
|
||||||
|
const id = parseInt(idStr, 10);
|
||||||
|
if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
|
||||||
|
const event = getOpenDayEvent(id);
|
||||||
|
if (!event) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
return NextResponse.json(event);
|
||||||
|
}
|
||||||
|
return NextResponse.json(getOpenDayEvents(), {
|
||||||
|
headers: { "Cache-Control": "private, max-age=60" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
if (!body.date || typeof body.date !== "string") {
|
||||||
|
return NextResponse.json({ error: "date is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
// Warn if date is in the past
|
||||||
|
const eventDate = new Date(body.date + "T23:59:59");
|
||||||
|
if (eventDate < new Date()) {
|
||||||
|
return NextResponse.json({ error: "Дата не может быть в прошлом" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const id = createOpenDayEvent(body);
|
||||||
|
return NextResponse.json({ ok: true, id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/open-day] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
if (!body.id) return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
const { id, ...data } = body;
|
||||||
|
if (data.date) {
|
||||||
|
const eventDate = new Date(data.date + "T23:59:59");
|
||||||
|
if (eventDate < new Date()) {
|
||||||
|
return NextResponse.json({ error: "Дата не может быть в прошлом" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateOpenDayEvent(id, data);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/open-day] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const idStr = request.nextUrl.searchParams.get("id");
|
||||||
|
if (!idStr) return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
const id = parseInt(idStr, 10);
|
||||||
|
if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
|
||||||
|
deleteOpenDayEvent(id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
37
src/app/api/admin/reminders/route.ts
Normal file
37
src/app/api/admin/reminders/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getUpcomingReminders, setReminderStatus } from "@/lib/db";
|
||||||
|
import type { ReminderStatus } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(getUpcomingReminders());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { table, id, status } = body;
|
||||||
|
|
||||||
|
const validTables = ["mc_registrations", "group_bookings", "open_day_bookings"];
|
||||||
|
const validStatuses = ["pending", "coming", "cancelled", null];
|
||||||
|
|
||||||
|
if (!validTables.includes(table)) {
|
||||||
|
return NextResponse.json({ error: "Invalid table" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!id || typeof id !== "number") {
|
||||||
|
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
return NextResponse.json({ error: "Invalid status" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
setReminderStatus(
|
||||||
|
table as "mc_registrations" | "group_bookings" | "open_day_bookings",
|
||||||
|
id,
|
||||||
|
status as ReminderStatus | null
|
||||||
|
);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/reminders] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/admin/sections/[key]/route.ts
Normal file
44
src/app/api/admin/sections/[key]/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getSection, setSection, SECTION_KEYS } from "@/lib/db";
|
||||||
|
import { siteContent } from "@/data/content";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { invalidateContentCache } from "@/lib/content";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ key: string }> };
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
|
const { key } = await params;
|
||||||
|
if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) {
|
||||||
|
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = getSection(key);
|
||||||
|
if (!data) {
|
||||||
|
// Auto-seed from fallback content if section doesn't exist yet
|
||||||
|
const fallback = (siteContent as unknown as Record<string, unknown>)[key];
|
||||||
|
if (fallback) {
|
||||||
|
setSection(key, fallback);
|
||||||
|
data = fallback;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: "Section not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=60" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: Params) {
|
||||||
|
const { key } = await params;
|
||||||
|
if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) {
|
||||||
|
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await request.json();
|
||||||
|
setSection(key, data);
|
||||||
|
invalidateContentCache();
|
||||||
|
revalidatePath("/");
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
46
src/app/api/admin/team/[id]/route.ts
Normal file
46
src/app/api/admin/team/[id]/route.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getTeamMember, updateTeamMember, deleteTeamMember } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
function parseId(raw: string): number | null {
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isInteger(n) && n > 0 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
const numId = parseId(id);
|
||||||
|
if (!numId) {
|
||||||
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const member = getTeamMember(numId);
|
||||||
|
if (!member) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
const numId = parseId(id);
|
||||||
|
if (!numId) {
|
||||||
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const data = await request.json();
|
||||||
|
updateTeamMember(numId, data);
|
||||||
|
revalidatePath("/");
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
const numId = parseId(id);
|
||||||
|
if (!numId) {
|
||||||
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
deleteTeamMember(numId);
|
||||||
|
revalidatePath("/");
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
16
src/app/api/admin/team/reorder/route.ts
Normal file
16
src/app/api/admin/team/reorder/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { reorderTeamMembers } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
const { ids } = await request.json() as { ids: number[] };
|
||||||
|
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100 || !ids.every((id) => Number.isInteger(id) && id > 0)) {
|
||||||
|
return NextResponse.json({ error: "ids must be a non-empty array of positive integers (max 100)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
reorderTeamMembers(ids);
|
||||||
|
revalidatePath("/");
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
36
src/app/api/admin/team/route.ts
Normal file
36
src/app/api/admin/team/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
8
src/app/api/admin/unread-counts/route.ts
Normal file
8
src/app/api/admin/unread-counts/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getUnreadBookingCounts } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(getUnreadBookingCounts(), {
|
||||||
|
headers: { "Cache-Control": "private, max-age=15" },
|
||||||
|
});
|
||||||
|
}
|
||||||
59
src/app/api/admin/upload/route.ts
Normal file
59
src/app/api/admin/upload/route.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { writeFile, mkdir } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
|
||||||
|
const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"];
|
||||||
|
const ALLOWED_FOLDERS = ["team", "master-classes", "news", "classes"];
|
||||||
|
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
const rawFolder = (formData.get("folder") as string) || "team";
|
||||||
|
const folder = ALLOWED_FOLDERS.includes(rawFolder) ? rawFolder : "team";
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Only JPEG, PNG, WebP, and AVIF are allowed" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_SIZE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "File too large (max 5MB)" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and sanitize filename
|
||||||
|
const ext = path.extname(file.name).toLowerCase() || ".webp";
|
||||||
|
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid file extension" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const baseName = file.name
|
||||||
|
.replace(ext, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9а-яё-]/gi, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.slice(0, 50);
|
||||||
|
const fileName = `${baseName}-${Date.now()}${ext}`;
|
||||||
|
|
||||||
|
const dir = path.join(process.cwd(), "public", "images", folder);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const filePath = path.join(dir, fileName);
|
||||||
|
await writeFile(filePath, buffer);
|
||||||
|
|
||||||
|
const publicPath = `/images/${folder}/${fileName}`;
|
||||||
|
return NextResponse.json({ path: publicPath });
|
||||||
|
}
|
||||||
28
src/app/api/admin/validate-instagram/route.ts
Normal file
28
src/app/api/admin/validate-instagram/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const username = request.nextUrl.searchParams.get("username")?.trim();
|
||||||
|
if (!username) {
|
||||||
|
return NextResponse.json({ valid: false, error: "No username" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://www.instagram.com/${username}/`, {
|
||||||
|
method: "HEAD",
|
||||||
|
redirect: "follow",
|
||||||
|
headers: {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instagram returns 200 for existing profiles, 404 for non-existing
|
||||||
|
const valid = res.ok;
|
||||||
|
return NextResponse.json({ valid });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/validate-instagram] error:", err);
|
||||||
|
// Network error or timeout — don't block the user
|
||||||
|
return NextResponse.json({ valid: true, uncertain: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/app/api/auth/login/route.ts
Normal file
32
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { verifyPassword, signToken, generateCsrfToken, COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const body = await request.json() as { password?: string };
|
||||||
|
|
||||||
|
if (!body.password || !verifyPassword(body.password)) {
|
||||||
|
return NextResponse.json({ error: "Неверный пароль" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = signToken();
|
||||||
|
const csrfToken = generateCsrfToken();
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
|
||||||
|
response.cookies.set(COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
|
||||||
|
httpOnly: false, // JS must read this to send as header
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "strict",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
41
src/app/api/group-booking/route.ts
Normal file
41
src/app/api/group-booking/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { addGroupBooking } from "@/lib/db";
|
||||||
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
|
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 5, 60_000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Слишком много запросов. Попробуйте через минуту." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, phone, groupInfo, instagram, telegram } = body;
|
||||||
|
|
||||||
|
const cleanName = sanitizeName(name);
|
||||||
|
if (!cleanName) {
|
||||||
|
return NextResponse.json({ error: "Имя обязательно" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanPhone = sanitizePhone(phone);
|
||||||
|
if (!cleanPhone) {
|
||||||
|
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = addGroupBooking(
|
||||||
|
cleanName,
|
||||||
|
cleanPhone,
|
||||||
|
sanitizeText(groupInfo),
|
||||||
|
sanitizeHandle(instagram),
|
||||||
|
sanitizeHandle(telegram)
|
||||||
|
);
|
||||||
|
return NextResponse.json({ ok: true, id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[group-booking] POST error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/api/logout/route.ts
Normal file
16
src/app/api/logout/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
response.cookies.set(COOKIE_NAME, "", {
|
||||||
|
httpOnly: true,
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
response.cookies.set(CSRF_COOKIE_NAME, "", {
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
47
src/app/api/master-class-register/route.ts
Normal file
47
src/app/api/master-class-register/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { addMcRegistration } from "@/lib/db";
|
||||||
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
|
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 5, 60_000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Слишком много запросов. Попробуйте через минуту." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { masterClassTitle, name, phone, instagram, telegram } = body;
|
||||||
|
|
||||||
|
const cleanTitle = sanitizeText(masterClassTitle, 200);
|
||||||
|
if (!cleanTitle) {
|
||||||
|
return NextResponse.json({ error: "masterClassTitle is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanName = sanitizeName(name);
|
||||||
|
if (!cleanName) {
|
||||||
|
return NextResponse.json({ error: "Имя обязательно" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanPhone = sanitizePhone(phone);
|
||||||
|
if (!cleanPhone) {
|
||||||
|
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = addMcRegistration(
|
||||||
|
cleanTitle,
|
||||||
|
cleanName,
|
||||||
|
sanitizeHandle(instagram) ?? "",
|
||||||
|
sanitizeHandle(telegram),
|
||||||
|
cleanPhone
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[master-class-register] POST error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/app/api/open-day-register/route.ts
Normal file
60
src/app/api/open-day-register/route.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
addOpenDayBooking,
|
||||||
|
getPersonOpenDayBookings,
|
||||||
|
getOpenDayEvent,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
|
import { sanitizeName, sanitizePhone, sanitizeHandle } from "@/lib/validation";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 10, 60_000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Слишком много запросов. Попробуйте через минуту." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { classId, eventId, name, phone, instagram, telegram } = body;
|
||||||
|
|
||||||
|
if (!classId || !eventId) {
|
||||||
|
return NextResponse.json({ error: "classId and eventId are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanName = sanitizeName(name);
|
||||||
|
if (!cleanName) {
|
||||||
|
return NextResponse.json({ error: "Имя обязательно" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanPhone = sanitizePhone(phone);
|
||||||
|
if (!cleanPhone) {
|
||||||
|
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = addOpenDayBooking(classId, eventId, {
|
||||||
|
name: cleanName,
|
||||||
|
phone: cleanPhone,
|
||||||
|
instagram: sanitizeHandle(instagram),
|
||||||
|
telegram: sanitizeHandle(telegram),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return total bookings for this person (for discount calculation)
|
||||||
|
const totalBookings = getPersonOpenDayBookings(eventId, cleanPhone);
|
||||||
|
const event = getOpenDayEvent(eventId);
|
||||||
|
const pricePerClass = event && totalBookings >= event.discountThreshold
|
||||||
|
? event.discountPrice
|
||||||
|
: event?.pricePerClass ?? 30;
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, id, totalBookings, pricePerClass });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : "Internal error";
|
||||||
|
if (msg.includes("UNIQUE")) {
|
||||||
|
return NextResponse.json({ error: "Вы уже записаны на это занятие" }, { status: 409 });
|
||||||
|
}
|
||||||
|
console.error("[open-day-register] POST error:", e);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
@theme inline {
|
@theme inline {
|
||||||
--font-display: var(--font-oswald);
|
--font-display: var(--font-oswald);
|
||||||
--font-sans: var(--font-inter);
|
--font-sans: var(--font-inter);
|
||||||
|
--color-gold: #c9a96e;
|
||||||
|
--color-gold-light: #d4b87a;
|
||||||
|
--color-gold-dark: #a08050;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Base ===== */
|
/* ===== Base ===== */
|
||||||
@@ -23,7 +26,7 @@ body {
|
|||||||
/* ===== Selection ===== */
|
/* ===== Selection ===== */
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background-color: rgba(225, 29, 72, 0.3);
|
background-color: rgba(201, 169, 110, 0.3);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,5 +46,40 @@ body {
|
|||||||
/* ===== Focus ===== */
|
/* ===== Focus ===== */
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
@apply outline-2 outline-offset-2 outline-rose-500;
|
@apply outline-2 outline-offset-2 outline-gold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Scrollbar hide utility ===== */
|
||||||
|
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Admin dark scrollbar ===== */
|
||||||
|
|
||||||
|
.admin-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,50 @@ 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 { News } from "@/components/sections/News";
|
||||||
|
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 { 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";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const content = getContent();
|
||||||
|
const openDayData = getActiveOpenDay();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Hero />
|
<Header />
|
||||||
<About />
|
<main>
|
||||||
<Team />
|
<Hero data={content.hero} />
|
||||||
<Classes />
|
{openDayData && <OpenDay data={openDayData} />}
|
||||||
<Contact />
|
<About
|
||||||
|
data={content.about}
|
||||||
|
stats={{
|
||||||
|
trainers: content.team.members.length,
|
||||||
|
classes: content.classes.items.length,
|
||||||
|
locations: content.schedule.locations.length,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Team data={content.team} schedule={content.schedule.locations} />
|
||||||
|
<Classes data={content.classes} />
|
||||||
|
<MasterClasses data={content.masterClasses} />
|
||||||
|
<Schedule data={content.schedule} classItems={content.classes.items} />
|
||||||
|
<Pricing data={content.pricing} />
|
||||||
|
<News data={content.news} />
|
||||||
|
<FAQ data={content.faq} />
|
||||||
|
<Contact data={content.contact} />
|
||||||
|
<BackToTop />
|
||||||
|
<FloatingContact />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes heartbeat {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
15% {
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
45% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes heart-float {
|
@keyframes heart-float {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -77,6 +95,10 @@
|
|||||||
animation: hero-fade-in-scale 1.2s cubic-bezier(0.16, 1, 0.3, 1) 0.1s forwards;
|
animation: hero-fade-in-scale 1.2s cubic-bezier(0.16, 1, 0.3, 1) 0.1s forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-logo-heartbeat {
|
||||||
|
animation: heartbeat 2.5s ease-in-out 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
animation: hero-fade-in-up 1s cubic-bezier(0.16, 1, 0.3, 1) 0.5s forwards;
|
animation: hero-fade-in-up 1s cubic-bezier(0.16, 1, 0.3, 1) 0.5s forwards;
|
||||||
@@ -95,9 +117,9 @@
|
|||||||
/* ===== Hero Background ===== */
|
/* ===== Hero Background ===== */
|
||||||
|
|
||||||
.hero-bg-gradient {
|
.hero-bg-gradient {
|
||||||
background: radial-gradient(ellipse 80% 60% at 50% -20%, rgba(225, 29, 72, 0.15), transparent),
|
background: radial-gradient(ellipse 80% 60% at 50% -20%, rgba(201, 169, 110, 0.12), transparent),
|
||||||
radial-gradient(ellipse 60% 40% at 80% 50%, rgba(225, 29, 72, 0.08), transparent),
|
radial-gradient(ellipse 60% 40% at 80% 50%, rgba(201, 169, 110, 0.06), transparent),
|
||||||
radial-gradient(ellipse 60% 40% at 20% 80%, rgba(225, 29, 72, 0.06), transparent);
|
radial-gradient(ellipse 60% 40% at 20% 80%, rgba(201, 169, 110, 0.04), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-glow-orb {
|
.hero-glow-orb {
|
||||||
@@ -106,12 +128,21 @@
|
|||||||
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 ===== */
|
||||||
|
|
||||||
.gradient-text {
|
.gradient-text {
|
||||||
background: linear-gradient(135deg, #fff 0%, #e11d48 50%, #fff 100%);
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
#8a6f3e 0%,
|
||||||
|
#c9a96e 20%,
|
||||||
|
#8a6f3e 40%,
|
||||||
|
#c9a96e 60%,
|
||||||
|
#6b5530 80%,
|
||||||
|
#8a6f3e 100%
|
||||||
|
);
|
||||||
background-size: 200% 200%;
|
background-size: 200% 200%;
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
@@ -121,7 +152,7 @@
|
|||||||
|
|
||||||
/* Light mode gradient text */
|
/* Light mode gradient text */
|
||||||
.gradient-text-light {
|
.gradient-text-light {
|
||||||
background: linear-gradient(135deg, #171717 0%, #e11d48 50%, #171717 100%);
|
background: linear-gradient(135deg, #171717 0%, #c9a96e 50%, #171717 100%);
|
||||||
background-size: 200% 200%;
|
background-size: 200% 200%;
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
@@ -141,7 +172,7 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
padding: 1px;
|
padding: 1px;
|
||||||
background: linear-gradient(135deg, rgba(225, 29, 72, 0.3), transparent 40%, transparent 60%, rgba(225, 29, 72, 0.15));
|
background: linear-gradient(135deg, rgba(201, 169, 110, 0.3), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.15));
|
||||||
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
mask-composite: exclude;
|
mask-composite: exclude;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -151,7 +182,7 @@
|
|||||||
|
|
||||||
.animated-border:hover::before {
|
.animated-border:hover::before {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: linear-gradient(135deg, rgba(225, 29, 72, 0.6), transparent 40%, transparent 60%, rgba(225, 29, 72, 0.4));
|
background: linear-gradient(135deg, rgba(201, 169, 110, 0.6), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.4));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Glow Effect ===== */
|
/* ===== Glow Effect ===== */
|
||||||
@@ -161,7 +192,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glow-hover:hover {
|
.glow-hover:hover {
|
||||||
box-shadow: 0 0 30px rgba(225, 29, 72, 0.1), 0 0 60px rgba(225, 29, 72, 0.05);
|
box-shadow: 0 0 30px rgba(201, 169, 110, 0.1), 0 0 60px rgba(201, 169, 110, 0.05);
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +209,38 @@
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Showcase ===== */
|
||||||
|
|
||||||
|
@keyframes showcase-detail-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes showcase-image-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-detail-enter {
|
||||||
|
animation: showcase-detail-enter 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-detail-enter img {
|
||||||
|
animation: showcase-image-enter 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Modal ===== */
|
/* ===== Modal ===== */
|
||||||
|
|
||||||
@keyframes modal-fade-in {
|
@keyframes modal-fade-in {
|
||||||
@@ -208,11 +271,81 @@
|
|||||||
animation: modal-fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
animation: modal-fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Team Info Fade ===== */
|
||||||
|
|
||||||
|
@keyframes team-info-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Team Card Glitter ===== */
|
||||||
|
|
||||||
|
@keyframes glitter-move {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 0%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 200%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-glitter {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated gold border glow */
|
||||||
|
.team-card-glitter::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
transparent 20%,
|
||||||
|
rgba(201, 169, 110, 0.6) 30%,
|
||||||
|
rgba(212, 184, 122, 1) 35%,
|
||||||
|
transparent 45%,
|
||||||
|
transparent 55%,
|
||||||
|
rgba(201, 169, 110, 0.5) 65%,
|
||||||
|
rgba(212, 184, 122, 0.9) 70%,
|
||||||
|
transparent 80%
|
||||||
|
);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: glitter-move 3s linear infinite;
|
||||||
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
will-change: background-position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Notification Pulse ===== */
|
||||||
|
|
||||||
|
@keyframes pulse-urgent {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-urgent {
|
||||||
|
animation: pulse-urgent 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Section Divider ===== */
|
/* ===== Section Divider ===== */
|
||||||
|
|
||||||
.section-divider {
|
.section-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: linear-gradient(90deg, transparent, rgba(225, 29, 72, 0.3), transparent);
|
background: linear-gradient(90deg, transparent, rgba(201, 169, 110, 0.15), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Reduced Motion ===== */
|
/* ===== Reduced Motion ===== */
|
||||||
@@ -243,11 +376,21 @@
|
|||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-glow-orb {
|
.hero-glow-orb,
|
||||||
|
.hero-logo-heartbeat {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-detail-enter,
|
||||||
|
.showcase-detail-enter img {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glow-hover:hover {
|
.glow-hover:hover {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.team-card-glitter::before {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,11 @@
|
|||||||
/* ===== Navigation ===== */
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
@apply text-sm font-medium transition-all duration-300;
|
|
||||||
@apply text-neutral-500;
|
|
||||||
@apply hover:text-neutral-900;
|
|
||||||
@apply dark:text-neutral-400 dark:hover:text-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link-active {
|
|
||||||
@apply text-rose-600;
|
|
||||||
@apply dark:text-rose-400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-icon {
|
|
||||||
@apply text-neutral-400 transition-all duration-300;
|
|
||||||
@apply hover:text-rose-600;
|
|
||||||
@apply dark:text-neutral-500 dark:hover:text-rose-400;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Cards ===== */
|
|
||||||
|
|
||||||
.card {
|
|
||||||
@apply rounded-2xl border p-6 transition-all duration-500 cursor-pointer;
|
|
||||||
@apply border-neutral-200 bg-white;
|
|
||||||
@apply hover:border-rose-200 hover:shadow-lg;
|
|
||||||
@apply dark:border-white/[0.06] dark:bg-white/[0.02];
|
|
||||||
@apply dark:hover:border-rose-500/20 dark:hover:bg-white/[0.04];
|
|
||||||
@apply dark:hover:shadow-[0_0_30px_rgba(225,29,72,0.08)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Buttons ===== */
|
/* ===== 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-rose-600 text-white;
|
@apply bg-gold text-black;
|
||||||
@apply hover:bg-rose-500 hover:shadow-[0_0_30px_rgba(225,29,72,0.4)];
|
@apply hover:bg-gold-light hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
|
||||||
@apply dark:bg-rose-600 dark:text-white;
|
@apply dark:bg-gold dark:text-black;
|
||||||
@apply dark:hover:bg-rose-500 dark:hover:shadow-[0_0_30px_rgba(225,29,72,0.4)];
|
@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-rose-600 text-rose-600;
|
|
||||||
@apply hover:bg-rose-600 hover:text-white;
|
|
||||||
@apply dark:border-rose-500 dark:text-rose-400;
|
|
||||||
@apply dark:hover:bg-rose-500 dark:hover:text-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
@apply inline-flex items-center justify-center font-medium rounded-full transition-all duration-300 cursor-pointer;
|
|
||||||
@apply text-neutral-600;
|
|
||||||
@apply hover:text-rose-600;
|
|
||||||
@apply dark:text-neutral-400 dark:hover:text-rose-400;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 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-rose-600;
|
|
||||||
@apply dark:text-rose-400;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
.surface-base {
|
.surface-base {
|
||||||
@apply bg-neutral-50 text-neutral-900;
|
@apply bg-neutral-50 text-neutral-900;
|
||||||
@apply dark:bg-[#050505] dark:text-neutral-50;
|
@apply dark:bg-[#050505] dark:text-neutral-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-muted {
|
.surface-muted {
|
||||||
@apply bg-neutral-100;
|
@apply bg-neutral-100;
|
||||||
@apply dark:bg-[#0a0a0a];
|
@apply dark:bg-[#080808];
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-glass {
|
.surface-glass {
|
||||||
@@ -17,14 +17,14 @@
|
|||||||
|
|
||||||
.surface-card {
|
.surface-card {
|
||||||
@apply bg-white/80 backdrop-blur-sm;
|
@apply bg-white/80 backdrop-blur-sm;
|
||||||
@apply dark:bg-white/[0.03] dark:backdrop-blur-sm;
|
@apply dark:bg-[#111] dark:backdrop-blur-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Borders ===== */
|
/* ===== Borders ===== */
|
||||||
|
|
||||||
.theme-border {
|
.theme-border {
|
||||||
@apply border-neutral-200;
|
@apply border-neutral-200;
|
||||||
@apply dark:border-white/[0.06];
|
@apply dark:border-white/[0.08];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Text ===== */
|
/* ===== Text ===== */
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
.body-text {
|
.body-text {
|
||||||
@apply text-neutral-600;
|
@apply text-neutral-600;
|
||||||
@apply dark:text-neutral-400;
|
@apply dark:text-neutral-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted-text {
|
.muted-text {
|
||||||
@@ -45,16 +45,81 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.accent-text {
|
.accent-text {
|
||||||
@apply text-rose-600;
|
@apply text-gold-dark;
|
||||||
@apply dark:text-rose-400;
|
@apply dark:text-gold-light;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Layout ===== */
|
/* ===== Layout ===== */
|
||||||
|
|
||||||
.section-padding {
|
.section-padding {
|
||||||
@apply py-16 sm:py-24;
|
@apply py-20 sm:py-32;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-container {
|
.section-container {
|
||||||
@apply mx-auto max-w-6xl px-6 sm:px-8;
|
@apply mx-auto max-w-6xl px-6 sm:px-8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Section Glow Backgrounds ===== */
|
||||||
|
|
||||||
|
.section-glow {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-glow::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 600px;
|
||||||
|
height: 400px;
|
||||||
|
background: radial-gradient(ellipse, rgba(201, 169, 110, 0.05), transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Glass Card ===== */
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
@apply rounded-2xl border backdrop-blur-sm transition-all duration-300;
|
||||||
|
@apply border-neutral-200/80 bg-white/90;
|
||||||
|
@apply dark:border-white/[0.06] dark:bg-white/[0.04];
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card:hover {
|
||||||
|
@apply dark:border-gold/15 dark:bg-white/[0.06];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Photo Filter ===== */
|
||||||
|
|
||||||
|
.photo-filter {
|
||||||
|
filter: saturate(0.7) sepia(0.15) brightness(0.95) contrast(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.dark) .photo-filter {
|
||||||
|
filter: saturate(0.6) sepia(0.2) brightness(0.9) contrast(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Custom Scrollbar ===== */
|
||||||
|
|
||||||
|
.styled-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(201, 169, 110, 0.25) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.styled-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.styled-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.styled-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(201, 169, 110, 0.25);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.styled-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(201, 169, 110, 0.4);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export function Footer() {
|
|||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="relative border-t border-neutral-200 bg-neutral-100 dark:border-white/[0.06] dark:bg-[#050505]">
|
<footer className="relative border-t border-neutral-200 bg-neutral-100 dark:border-white/[0.08] dark:bg-[#050505]">
|
||||||
<div className="section-divider absolute top-0 left-0 right-0" />
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
<div className="section-container flex flex-col items-center gap-4 py-10 sm:flex-row sm:justify-between">
|
<div className="section-container flex flex-col items-center gap-4 py-10 sm:flex-row sm:justify-between">
|
||||||
<p className="text-sm text-neutral-500">
|
<p className="text-sm text-neutral-500">
|
||||||
@@ -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-rose-500 text-rose-500" />
|
<Heart size={14} className="fill-gold text-gold" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,73 +1,142 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
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 { 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 [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(() => {
|
||||||
|
function onOpenBooking() {
|
||||||
|
setBookingOpen(true);
|
||||||
|
}
|
||||||
|
window.addEventListener("open-booking", onOpenBooking);
|
||||||
|
return () => window.removeEventListener("open-booking", onOpenBooking);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter out nav links whose target section doesn't exist on the page
|
||||||
|
const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS);
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleLinks(NAV_LINKS.filter((l) => document.getElementById(l.href.replace("#", ""))));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sectionIds = visibleLinks.map((l) => l.href.replace("#", ""));
|
||||||
|
const observers: IntersectionObserver[] = [];
|
||||||
|
|
||||||
|
// Observe hero — when visible, clear active section
|
||||||
|
const hero = document.querySelector("section:first-of-type");
|
||||||
|
if (hero) {
|
||||||
|
const heroObserver = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) setActiveSection("");
|
||||||
|
},
|
||||||
|
{ rootMargin: "-20% 0px -70% 0px" },
|
||||||
|
);
|
||||||
|
heroObserver.observe(hero);
|
||||||
|
observers.push(heroObserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionIds.forEach((id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setActiveSection(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "-40% 0px -55% 0px" },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
observers.push(observer);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observers.forEach((o) => o.disconnect());
|
||||||
|
}, [visibleLinks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={`fixed top-0 z-50 w-full transition-all duration-500 ${
|
className={`fixed top-0 z-50 w-full transition-all duration-500 ${
|
||||||
scrolled
|
scrolled
|
||||||
? "border-b border-white/[0.06] bg-black/40 shadow-none backdrop-blur-xl"
|
? "bg-black/40 shadow-none backdrop-blur-xl"
|
||||||
: "bg-transparent"
|
: "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
|
||||||
className="absolute inset-0 rounded-full transition-all duration-300 group-hover:scale-125"
|
className="absolute inset-0 rounded-full transition-all duration-300 group-hover:scale-125"
|
||||||
style={{
|
style={{
|
||||||
background: "radial-gradient(circle, rgba(225,29,72,0.5) 0%, rgba(225,29,72,0.15) 50%, transparent 70%)",
|
background: "radial-gradient(circle, rgba(201,169,110,0.5) 0%, rgba(201,169,110,0.15) 50%, transparent 70%)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Image
|
<HeroLogo
|
||||||
src="/images/logo.png"
|
size={24}
|
||||||
alt={BRAND.name}
|
className="relative text-black transition-transform duration-300 drop-shadow-[0_0_3px_rgba(201,169,110,0.5)] group-hover:scale-110"
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
unoptimized
|
|
||||||
className="relative transition-transform duration-300 group-hover:scale-110"
|
|
||||||
style={{
|
|
||||||
filter: "drop-shadow(0 0 3px rgba(225,29,72,0.5))",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-display text-lg font-bold tracking-tight text-white">
|
<span className="font-display text-lg font-bold tracking-tight text-gold">
|
||||||
{BRAND.shortName}
|
{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("#", "");
|
||||||
|
return (
|
||||||
<a
|
<a
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className="relative py-1 text-sm font-medium text-neutral-400 transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:w-0 after:bg-rose-500 after:transition-all after:duration-300 hover:text-white hover:after:w-full"
|
className={`relative whitespace-nowrap py-1 text-xs lg:text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${
|
||||||
|
isActive
|
||||||
|
? "text-gold-light after:w-full"
|
||||||
|
: "text-neutral-400 after:w-0 hover:text-white hover:after:w-full"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</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} />}
|
||||||
@@ -77,23 +146,42 @@ export function Header() {
|
|||||||
|
|
||||||
{/* Mobile menu */}
|
{/* Mobile menu */}
|
||||||
<div
|
<div
|
||||||
className={`overflow-hidden transition-all duration-300 md:hidden ${
|
className={`overflow-hidden transition-all duration-300 lg:hidden ${
|
||||||
menuOpen ? "max-h-80 opacity-100" : "max-h-0 opacity-0"
|
menuOpen ? "max-h-80 opacity-100" : "max-h-0 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<nav className="border-t border-white/[0.06] bg-black/40 px-6 py-4 backdrop-blur-xl sm:px-8">
|
<nav className="border-t border-white/[0.06] bg-black/40 px-6 py-4 backdrop-blur-xl sm:px-8">
|
||||||
{NAV_LINKS.map((link) => (
|
{visibleLinks.map((link) => {
|
||||||
|
const isActive = activeSection === link.href.replace("#", "");
|
||||||
|
return (
|
||||||
<a
|
<a
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
onClick={() => setMenuOpen(false)}
|
onClick={() => setMenuOpen(false)}
|
||||||
className="block py-3 text-base text-neutral-400 transition-colors hover:text-white"
|
className={`block py-3 text-base transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "text-gold-light"
|
||||||
|
: "text-neutral-400 hover:text-white"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
setBookingOpen(true);
|
||||||
|
}}
|
||||||
|
className="mt-2 w-full rounded-full bg-gold py-3 text-sm font-semibold text-black transition-all hover:bg-gold-light cursor-pointer"
|
||||||
|
>
|
||||||
|
Записаться
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<SignupModal open={bookingOpen} onClose={() => setBookingOpen(false)} endpoint="/api/group-booking" />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,65 @@
|
|||||||
import { siteContent } from "@/data/content";
|
import { Users, Layers, MapPin } from "lucide-react";
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { Heart } from "lucide-react";
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
export function About() {
|
interface AboutStats {
|
||||||
const { about } = siteContent;
|
trainers: number;
|
||||||
|
classes: number;
|
||||||
|
locations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AboutProps {
|
||||||
|
data: SiteContent["about"];
|
||||||
|
stats: AboutStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function About({ data: about, stats }: AboutProps) {
|
||||||
|
const statItems = [
|
||||||
|
{ icon: <Users size={22} />, value: String(stats.trainers), label: "тренеров" },
|
||||||
|
{ icon: <Layers size={22} />, value: String(stats.classes), label: "направлений" },
|
||||||
|
{ icon: <MapPin size={22} />, value: String(stats.locations), label: "зала в Минске" },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="about" className="relative section-padding bg-neutral-100 dark:bg-[#0a0a0a]">
|
<section id="about" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]">
|
||||||
<div className="section-divider absolute top-0 left-0 right-0" />
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
<div className="section-container">
|
<div className="section-container">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeading>{about.title}</SectionHeading>
|
<SectionHeading centered>{about.title}</SectionHeading>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-10 max-w-3xl space-y-6">
|
<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}>
|
||||||
<div className="flex gap-4">
|
<p className="text-xl leading-relaxed text-neutral-600 dark:text-neutral-300 sm:text-2xl">
|
||||||
<Heart
|
{text}
|
||||||
size={20}
|
</p>
|
||||||
className="mt-1 shrink-0 fill-rose-500/20 text-rose-500 dark:fill-rose-500/10 dark:text-rose-400"
|
|
||||||
/>
|
|
||||||
<p className="body-text text-lg leading-relaxed">{text}</p>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<Reveal>
|
||||||
|
<div className="mx-auto mt-14 grid max-w-3xl grid-cols-3 gap-4 sm:gap-8">
|
||||||
|
{statItems.map((stat, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="group flex flex-col items-center gap-3 rounded-2xl border border-neutral-200 bg-white/50 p-6 transition-all duration-300 hover:border-gold/30 sm:p-8 dark:border-white/[0.06] dark:bg-white/[0.02] dark:hover:border-gold/20"
|
||||||
|
>
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gold/10 text-gold-dark transition-colors group-hover:bg-gold/20 dark:text-gold-light">
|
||||||
|
{stat.icon}
|
||||||
|
</div>
|
||||||
|
<span className="font-display text-3xl font-bold text-neutral-900 sm:text-4xl dark:text-white">
|
||||||
|
{stat.value}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{stat.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,89 +1,117 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Flame, Sparkles, Wind, Zap, Star, Monitor, ArrowRight } 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 { ClassModal } from "@/components/ui/ClassModal";
|
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
|
||||||
import type { ClassItem } from "@/types";
|
import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
|
||||||
|
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];
|
||||||
const [selectedClass, setSelectedClass] = useState<ClassItem | null>(null);
|
return Icon ? <Icon size={20} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassesProps {
|
||||||
|
data: SiteContent["classes"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Classes({ data: classes }: ClassesProps) {
|
||||||
|
const { activeIndex, select, setHovering } = useShowcaseRotation({
|
||||||
|
totalItems: classes.items.length,
|
||||||
|
autoPlayInterval: UI_CONFIG.showcase.autoPlayInterval,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="classes" className="relative section-padding bg-neutral-100 dark:bg-[#0a0a0a]">
|
<section id="classes" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]">
|
||||||
<div className="section-divider absolute top-0 left-0 right-0" />
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
<div className="section-container">
|
<div className="section-container">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeading>{classes.title}</SectionHeading>
|
<SectionHeading centered>{classes.title}</SectionHeading>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-14 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="mt-14">
|
||||||
{classes.items.map((item) => (
|
<Reveal>
|
||||||
<Reveal key={item.name} className="h-full">
|
<ShowcaseLayout<ClassItem>
|
||||||
<div
|
items={classes.items}
|
||||||
className="group relative h-full min-h-[280px] cursor-pointer overflow-hidden rounded-2xl"
|
activeIndex={activeIndex}
|
||||||
onClick={() => setSelectedClass(item)}
|
onSelect={select}
|
||||||
>
|
onHoverChange={setHovering}
|
||||||
{/* Background image */}
|
renderDetail={(item) => (
|
||||||
|
<div>
|
||||||
|
{/* Hero image */}
|
||||||
{item.images && item.images[0] && (
|
{item.images && item.images[0] && (
|
||||||
|
<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 transition-transform duration-700 ease-out group-hover:scale-105"
|
loading="lazy"
|
||||||
|
sizes="(min-width: 1024px) 60vw, 100vw"
|
||||||
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
)}
|
{/* Gradient overlay */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||||
|
|
||||||
{/* Dark gradient overlay */}
|
{/* Icon + name overlay */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-black/10 transition-all duration-500 group-hover:from-black/95 group-hover:via-black/50" />
|
<div className="absolute bottom-0 left-0 right-0 p-6">
|
||||||
|
<div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-gold/20 text-gold-light backdrop-blur-sm">
|
||||||
{/* Rose tint on hover */}
|
{getIcon(item.icon)}
|
||||||
<div className="absolute inset-0 bg-rose-900/0 transition-all duration-500 group-hover:bg-rose-900/10" />
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="relative flex h-full flex-col justify-end p-6">
|
|
||||||
{/* Icon badge */}
|
|
||||||
<div className="mb-3 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-white/10 text-white backdrop-blur-sm transition-all duration-300 group-hover:bg-rose-500/20 group-hover:text-rose-300">
|
|
||||||
{iconMap[item.icon]}
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3 className="text-2xl font-bold text-white">
|
||||||
<h3 className="text-xl font-semibold text-white">
|
|
||||||
{item.name}
|
{item.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="mt-1.5 text-sm leading-relaxed text-white/60 line-clamp-2">
|
{/* Description */}
|
||||||
|
{item.detailedDescription && (
|
||||||
|
<div className="mt-5 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400 whitespace-pre-line">
|
||||||
|
{item.detailedDescription}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
renderSelectorItem={(item, _i, isActive) => (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 lg:gap-3 lg:p-3">
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className={`flex h-7 w-7 lg:h-9 lg:w-9 shrink-0 items-center justify-center rounded-lg transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-gold/20 text-gold-light"
|
||||||
|
: "bg-neutral-200/50 text-neutral-500 dark:bg-white/[0.06] dark:text-neutral-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getIcon(item.icon)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p
|
||||||
|
className={`text-xs lg:text-sm font-semibold truncate transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "text-gold"
|
||||||
|
: "text-neutral-700 dark:text-neutral-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
<p className="hidden lg:block text-xs text-neutral-500 dark:text-neutral-500 truncate">
|
||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Hover arrow */}
|
|
||||||
<div className="mt-3 flex items-center gap-1.5 text-sm font-medium text-rose-400 opacity-0 translate-y-2 transition-all duration-300 group-hover:opacity-100 group-hover:translate-y-0">
|
|
||||||
<span>Подробнее</span>
|
|
||||||
<ArrowRight size={14} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Reveal>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ClassModal
|
|
||||||
classItem={selectedClass}
|
|
||||||
onClose={() => setSelectedClass(null)}
|
|
||||||
/>
|
/>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-rose-50 text-rose-600 transition-colors group-hover:bg-rose-100 dark:bg-rose-500/10 dark:text-rose-400 dark:group-hover:bg-rose-500/15">
|
<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-rose-50 text-rose-600 transition-colors group-hover:bg-rose-100 dark:bg-rose-500/10 dark:text-rose-400 dark:group-hover:bg-rose-500/15">
|
<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-rose-600 dark:text-neutral-400 dark:hover:text-rose-400"
|
className="text-neutral-600 transition-colors hover:text-gold-dark dark:text-neutral-300 dark:hover:text-gold-light"
|
||||||
>
|
>
|
||||||
{contact.phone}
|
{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-rose-50 text-rose-600 transition-colors group-hover:bg-rose-100 dark:bg-rose-500/10 dark:text-rose-400 dark:group-hover:bg-rose-500/15">
|
<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.06]">
|
<div className="border-t border-neutral-200 pt-5 dark:border-white/[0.08]">
|
||||||
<div className="group flex items-center gap-4">
|
<div className="group flex items-center gap-4">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-rose-50 text-rose-600 transition-colors group-hover:bg-rose-100 dark:bg-rose-500/10 dark:text-rose-400 dark:group-hover:bg-rose-500/15">
|
<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-rose-600 dark:text-neutral-400 dark:hover:text-rose-400"
|
className="text-neutral-600 transition-colors hover:text-gold-dark dark:text-neutral-300 dark:hover:text-gold-light"
|
||||||
>
|
>
|
||||||
{BRAND.instagramHandle}
|
{BRAND.instagramHandle}
|
||||||
</a>
|
</a>
|
||||||
@@ -62,7 +58,7 @@ export function Contact() {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="overflow-hidden rounded-2xl border border-neutral-200 shadow-sm dark:border-white/[0.06] dark:shadow-[0_0_30px_rgba(225,29,72,0.05)]">
|
<div className="overflow-hidden rounded-2xl border border-neutral-200 shadow-sm dark:border-white/[0.08] dark:shadow-[0_0_30px_rgba(201,169,110,0.05)]">
|
||||||
<iframe
|
<iframe
|
||||||
src={contact.mapEmbedUrl}
|
src={contact.mapEmbedUrl}
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -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>
|
||||||
|
|||||||
114
src/components/sections/FAQ.tsx
Normal file
114
src/components/sections/FAQ.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
|
const VISIBLE_COUNT = UI_CONFIG.faq.visibleCount;
|
||||||
|
|
||||||
|
interface FAQProps {
|
||||||
|
data: SiteContent["faq"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FAQ({ data: faq }: FAQProps) {
|
||||||
|
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
function toggle(index: number) {
|
||||||
|
setOpenIndex(openIndex === index ? null : index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleItems = expanded ? faq.items : faq.items.slice(0, VISIBLE_COUNT);
|
||||||
|
const hasMore = faq.items.length > VISIBLE_COUNT;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="faq" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]">
|
||||||
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
|
<div className="section-container">
|
||||||
|
<Reveal>
|
||||||
|
<SectionHeading centered>{faq.title}</SectionHeading>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<div className="mx-auto mt-12 max-w-3xl space-y-2.5">
|
||||||
|
{visibleItems.map((item, idx) => {
|
||||||
|
const isOpen = openIndex === idx;
|
||||||
|
return (
|
||||||
|
<Reveal key={idx}>
|
||||||
|
<div
|
||||||
|
className={`rounded-xl border transition-all duration-300 ${
|
||||||
|
isOpen
|
||||||
|
? "border-gold/30 bg-gradient-to-br from-gold/[0.06] via-transparent to-gold/[0.03] shadow-md shadow-gold/5"
|
||||||
|
: "border-neutral-200 bg-white hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(idx)}
|
||||||
|
className="flex w-full items-center gap-3 px-5 py-4 text-left cursor-pointer"
|
||||||
|
>
|
||||||
|
{/* Number badge */}
|
||||||
|
<span
|
||||||
|
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-bold transition-colors duration-300 ${
|
||||||
|
isOpen
|
||||||
|
? "bg-gold text-black"
|
||||||
|
: "bg-gold/10 text-gold-dark dark:text-gold-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="flex-1 text-sm sm:text-base font-medium text-neutral-900 dark:text-white leading-snug">
|
||||||
|
{item.question}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`shrink-0 transition-all duration-300 ${
|
||||||
|
isOpen ? "text-gold rotate-180" : "text-neutral-400 dark:text-neutral-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`grid transition-all duration-300 ease-out ${
|
||||||
|
isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="px-5 pb-4 pl-14 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400 whitespace-pre-line">
|
||||||
|
{item.answer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Show more / less */}
|
||||||
|
{hasMore && (
|
||||||
|
<Reveal>
|
||||||
|
<div className="pt-2 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setExpanded(!expanded);
|
||||||
|
if (expanded) setOpenIndex(null);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-neutral-200 bg-white px-5 py-2 text-sm font-medium text-neutral-600 transition-all hover:border-gold/40 hover:text-gold dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-neutral-400 dark:hover:border-gold/30 dark:hover:text-gold cursor-pointer"
|
||||||
|
>
|
||||||
|
{expanded ? "Скрыть" : `Ещё ${faq.items.length - VISIBLE_COUNT} вопросов`}
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={`transition-transform duration-300 ${expanded ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,75 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
import { siteContent } from "@/data/content";
|
|
||||||
import { BRAND } from "@/lib/constants";
|
|
||||||
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 { ChevronDown } from "lucide-react";
|
import { HeroLogo } from "@/components/ui/HeroLogo";
|
||||||
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
export function Hero() {
|
interface HeroProps {
|
||||||
const { hero } = siteContent;
|
data: SiteContent["hero"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hero({ data: hero }: HeroProps) {
|
||||||
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
|
const scrolledRef = useRef(false);
|
||||||
|
|
||||||
|
const scrollToNext = useCallback(() => {
|
||||||
|
const hero = sectionRef.current;
|
||||||
|
if (!hero) return;
|
||||||
|
// Find the next sibling section
|
||||||
|
let next = hero.nextElementSibling;
|
||||||
|
while (next && next.tagName !== "SECTION") {
|
||||||
|
next = next.nextElementSibling;
|
||||||
|
}
|
||||||
|
next?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hero = sectionRef.current;
|
||||||
|
if (!hero) return;
|
||||||
|
|
||||||
|
function handleWheel(e: WheelEvent) {
|
||||||
|
// Only trigger when scrolling down and still inside hero
|
||||||
|
if (e.deltaY <= 0 || scrolledRef.current) return;
|
||||||
|
if (window.scrollY > 10) return; // already scrolled past hero top
|
||||||
|
|
||||||
|
scrolledRef.current = true;
|
||||||
|
scrollToNext();
|
||||||
|
|
||||||
|
// Reset after animation completes
|
||||||
|
setTimeout(() => { scrolledRef.current = false; }, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchStart(e: TouchEvent) {
|
||||||
|
(hero as HTMLElement).dataset.touchY = String(e.touches[0].clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd(e: TouchEvent) {
|
||||||
|
const startY = Number((hero as HTMLElement).dataset.touchY);
|
||||||
|
const endY = e.changedTouches[0].clientY;
|
||||||
|
const diff = startY - endY;
|
||||||
|
|
||||||
|
// Swipe down (finger moves up) with enough distance
|
||||||
|
if (diff > 50 && !scrolledRef.current && window.scrollY < 10) {
|
||||||
|
scrolledRef.current = true;
|
||||||
|
scrollToNext();
|
||||||
|
setTimeout(() => { scrolledRef.current = false; }, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hero.addEventListener("wheel", handleWheel, { passive: true });
|
||||||
|
hero.addEventListener("touchstart", handleTouchStart, { passive: true });
|
||||||
|
hero.addEventListener("touchend", handleTouchEnd, { passive: true });
|
||||||
|
return () => {
|
||||||
|
hero.removeEventListener("wheel", handleWheel);
|
||||||
|
hero.removeEventListener("touchstart", handleTouchStart);
|
||||||
|
hero.removeEventListener("touchend", handleTouchEnd);
|
||||||
|
};
|
||||||
|
}, [scrollToNext]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
|
<section ref={sectionRef} className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
|
||||||
{/* Animated gradient background */}
|
{/* Animated gradient background */}
|
||||||
<div className="hero-bg-gradient absolute inset-0" />
|
<div className="hero-bg-gradient absolute inset-0" />
|
||||||
|
|
||||||
@@ -24,7 +82,7 @@ export function Hero() {
|
|||||||
top: "-10%",
|
top: "-10%",
|
||||||
left: "50%",
|
left: "50%",
|
||||||
transform: "translateX(-50%)",
|
transform: "translateX(-50%)",
|
||||||
background: "radial-gradient(circle, rgba(225, 29, 72, 0.12), transparent 70%)",
|
background: "radial-gradient(circle, rgba(201, 169, 110, 0.12), transparent 70%)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -34,7 +92,7 @@ export function Hero() {
|
|||||||
height: "300px",
|
height: "300px",
|
||||||
bottom: "10%",
|
bottom: "10%",
|
||||||
right: "10%",
|
right: "10%",
|
||||||
background: "radial-gradient(circle, rgba(225, 29, 72, 0.08), transparent 70%)",
|
background: "radial-gradient(circle, rgba(201, 169, 110, 0.08), transparent 70%)",
|
||||||
animationDelay: "3s",
|
animationDelay: "3s",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -44,56 +102,32 @@ 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 h-[220px] w-[220px]">
|
<div className="hero-logo relative mx-auto mb-10 flex items-center justify-center" style={{ width: 220, height: 181 }}>
|
||||||
{/* Outer ambient glow */}
|
{/* Soft ambient glow behind heart */}
|
||||||
<div className="absolute -inset-16 rounded-full bg-rose-500/8 blur-[60px]" />
|
<div className="absolute -inset-10 rounded-full blur-[80px]" style={{ background: "radial-gradient(circle, rgba(201,169,110,0.25), transparent 70%)" }} />
|
||||||
{/* Rose disc — makes black heart visible as silhouette */}
|
<div className="hero-logo-heartbeat relative">
|
||||||
<div
|
<HeroLogo
|
||||||
className="absolute inset-2 rounded-full"
|
size={220}
|
||||||
style={{
|
className="drop-shadow-[0_0_10px_rgba(201,169,110,0.35)] drop-shadow-[0_0_40px_rgba(201,169,110,0.15)]"
|
||||||
background: "radial-gradient(circle, rgba(225,29,72,0.45) 0%, rgba(225,29,72,0.18) 45%, transparent 70%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Image
|
|
||||||
src="/images/logo.png"
|
|
||||||
alt={BRAND.name}
|
|
||||||
width={220}
|
|
||||||
height={220}
|
|
||||||
priority
|
|
||||||
unoptimized
|
|
||||||
className="relative"
|
|
||||||
style={{
|
|
||||||
filter:
|
|
||||||
"drop-shadow(0 0 6px rgba(225,29,72,0.5)) drop-shadow(0 0 20px rgba(225,29,72,0.25))",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h1 className="hero-title font-display text-5xl font-bold tracking-tight sm:text-6xl lg:text-8xl">
|
<h1 className="hero-title font-display text-5xl font-bold tracking-tight sm:text-6xl lg:text-8xl">
|
||||||
<span className="gradient-text">{hero.headline}</span>
|
<span className="gradient-text">{hero.headline}</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="hero-subtitle mx-auto mt-6 max-w-lg text-lg text-neutral-400 sm:text-xl">
|
<p className="hero-subtitle mx-auto mt-6 max-w-lg text-lg text-[#b8a080] sm:text-xl">
|
||||||
{hero.subheadline}
|
{hero.subheadline}
|
||||||
</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-rose-400"
|
|
||||||
>
|
|
||||||
<span className="text-xs uppercase tracking-widest">Scroll</span>
|
|
||||||
<ChevronDown size={20} className="animate-bounce" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
251
src/components/sections/MasterClasses.tsx
Normal file
251
src/components/sections/MasterClasses.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Calendar, Clock, User, MapPin, Instagram } from "lucide-react";
|
||||||
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
|
import type { SiteContent, MasterClassItem, MasterClassSlot } from "@/types";
|
||||||
|
|
||||||
|
interface MasterClassesProps {
|
||||||
|
data: SiteContent["masterClasses"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS_RU = [
|
||||||
|
"января", "февраля", "марта", "апреля", "мая", "июня",
|
||||||
|
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
||||||
|
];
|
||||||
|
|
||||||
|
const WEEKDAYS_RU = [
|
||||||
|
"воскресенье", "понедельник", "вторник", "среда",
|
||||||
|
"четверг", "пятница", "суббота",
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseDate(iso: string) {
|
||||||
|
return new Date(iso + "T00:00:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSlots(slots: MasterClassSlot[]): string {
|
||||||
|
if (slots.length === 0) return "";
|
||||||
|
const sorted = [...slots].sort(
|
||||||
|
(a, b) => parseDate(a.date).getTime() - parseDate(b.date).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const dates = sorted.map((s) => parseDate(s.date)).filter((d) => !isNaN(d.getTime()));
|
||||||
|
if (dates.length === 0) return "";
|
||||||
|
|
||||||
|
const timePart = sorted[0].startTime
|
||||||
|
? `, ${sorted[0].startTime}–${sorted[0].endTime}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (dates.length === 1) {
|
||||||
|
const d = dates[0];
|
||||||
|
return `${d.getDate()} ${MONTHS_RU[d.getMonth()]} (${WEEKDAYS_RU[d.getDay()]})${timePart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameMonth = dates.every((d) => d.getMonth() === dates[0].getMonth());
|
||||||
|
const sameWeekday = dates.every((d) => d.getDay() === dates[0].getDay());
|
||||||
|
|
||||||
|
if (sameMonth) {
|
||||||
|
const days = dates.map((d) => d.getDate()).join(" и ");
|
||||||
|
const weekdayHint = sameWeekday ? ` (${WEEKDAYS_RU[dates[0].getDay()]})` : "";
|
||||||
|
return `${days} ${MONTHS_RU[dates[0].getMonth()]}${weekdayHint}${timePart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = dates.map((d) => `${d.getDate()} ${MONTHS_RU[d.getMonth()]}`);
|
||||||
|
return parts.join(", ") + timePart;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcDuration(slot: MasterClassSlot): string {
|
||||||
|
if (!slot.startTime || !slot.endTime) return "";
|
||||||
|
const [sh, sm] = slot.startTime.split(":").map(Number);
|
||||||
|
const [eh, em] = slot.endTime.split(":").map(Number);
|
||||||
|
const mins = (eh * 60 + em) - (sh * 60 + sm);
|
||||||
|
if (mins <= 0) return "";
|
||||||
|
const h = Math.floor(mins / 60);
|
||||||
|
const m = mins % 60;
|
||||||
|
if (h > 0 && m > 0) return `${h} ч ${m} мин`;
|
||||||
|
if (h > 0) return h === 1 ? "1 час" : h < 5 ? `${h} часа` : `${h} часов`;
|
||||||
|
return `${m} мин`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUpcoming(item: MasterClassItem): boolean {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const lastDate = (item.slots ?? [])
|
||||||
|
.map((s) => parseDate(s.date))
|
||||||
|
.reduce((a, b) => (a > b ? a : b), new Date(0));
|
||||||
|
return lastDate >= today;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MasterClassCard({
|
||||||
|
item,
|
||||||
|
onSignup,
|
||||||
|
}: {
|
||||||
|
item: MasterClassItem;
|
||||||
|
onSignup: () => void;
|
||||||
|
}) {
|
||||||
|
const duration = item.slots[0] ? calcDuration(item.slots[0]) : "";
|
||||||
|
const slotsDisplay = formatSlots(item.slots);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative flex flex-col overflow-hidden rounded-2xl bg-black">
|
||||||
|
{/* Full-bleed image */}
|
||||||
|
{item.image && (
|
||||||
|
<div className="relative aspect-[3/4] sm:aspect-[2/3] w-full overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={item.image}
|
||||||
|
alt={item.title}
|
||||||
|
fill
|
||||||
|
loading="lazy"
|
||||||
|
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||||
|
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
{/* Dark overlay that intensifies on hover */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/20 to-transparent opacity-80 transition-opacity duration-500 group-hover:opacity-90" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content overlay at bottom */}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 flex flex-col p-5 sm:p-6">
|
||||||
|
{/* Tags row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-gold/40 bg-black/40 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-gold backdrop-blur-md">
|
||||||
|
{item.style}
|
||||||
|
</span>
|
||||||
|
{duration && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-0.5 text-[11px] text-white/60 backdrop-blur-md">
|
||||||
|
<Clock size={10} />
|
||||||
|
{duration}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="text-xl sm:text-2xl font-bold text-white leading-tight tracking-tight">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Trainer */}
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-sm text-white/50">
|
||||||
|
<User size={13} className="shrink-0" />
|
||||||
|
<span>{item.trainer}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="mt-4 mb-4 h-px bg-gradient-to-r from-gold/40 via-gold/20 to-transparent" />
|
||||||
|
|
||||||
|
{/* Date + Location */}
|
||||||
|
<div className="flex flex-col gap-1.5 text-sm text-white/60 mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar size={13} className="shrink-0 text-gold/70" />
|
||||||
|
<span>{slotsDisplay}</span>
|
||||||
|
</div>
|
||||||
|
{item.location && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin size={13} className="shrink-0 text-gold/70" />
|
||||||
|
<span>{item.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price + Actions */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onSignup}
|
||||||
|
className="flex-1 rounded-xl bg-gold py-3 text-sm font-bold text-black uppercase tracking-wide transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/25 cursor-pointer"
|
||||||
|
>
|
||||||
|
Записаться
|
||||||
|
</button>
|
||||||
|
{item.instagramUrl && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
window.open(item.instagramUrl, "_blank", "noopener,noreferrer")
|
||||||
|
}
|
||||||
|
aria-label={`Instagram ${item.trainer}`}
|
||||||
|
className="flex h-[46px] w-[46px] items-center justify-center rounded-xl border border-white/10 text-white/40 transition-all hover:border-gold/30 hover:text-gold cursor-pointer"
|
||||||
|
>
|
||||||
|
<Instagram size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price floating tag */}
|
||||||
|
<div className="absolute top-0 right-0 -translate-y-full mr-5 sm:mr-6 mb-2">
|
||||||
|
<span className="inline-block rounded-full bg-white/10 px-3 py-1 text-sm font-bold text-white backdrop-blur-md">
|
||||||
|
{item.cost}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MasterClasses({ data }: MasterClassesProps) {
|
||||||
|
const [signupTitle, setSignupTitle] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const upcoming = useMemo(() => {
|
||||||
|
return data.items
|
||||||
|
.filter(isUpcoming)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aFirst = parseDate(a.slots[0]?.date ?? "");
|
||||||
|
const bFirst = parseDate(b.slots[0]?.date ?? "");
|
||||||
|
return aFirst.getTime() - bFirst.getTime();
|
||||||
|
});
|
||||||
|
}, [data.items]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="master-classes"
|
||||||
|
className="section-glow relative section-padding overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
|
|
||||||
|
<div className="section-container">
|
||||||
|
<Reveal>
|
||||||
|
<SectionHeading centered>{data.title}</SectionHeading>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{upcoming.length === 0 ? (
|
||||||
|
<Reveal>
|
||||||
|
<div className="mt-10 py-12 text-center">
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-white/40">
|
||||||
|
Следите за анонсами мастер-классов в нашем{" "}
|
||||||
|
<a
|
||||||
|
href="https://instagram.com/blackheartdancehouse/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gold hover:text-gold-light underline underline-offset-2 transition-colors"
|
||||||
|
>
|
||||||
|
Instagram
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
) : (
|
||||||
|
<Reveal>
|
||||||
|
<div className="mx-auto mt-10 grid max-w-5xl grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{upcoming.map((item) => (
|
||||||
|
<MasterClassCard
|
||||||
|
key={item.title}
|
||||||
|
item={item}
|
||||||
|
onSignup={() => setSignupTitle(item.title)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SignupModal
|
||||||
|
open={signupTitle !== null}
|
||||||
|
onClose={() => setSignupTitle(null)}
|
||||||
|
subtitle={signupTitle ?? ""}
|
||||||
|
endpoint="/api/master-class-register"
|
||||||
|
extraBody={{ masterClassTitle: signupTitle }}
|
||||||
|
successMessage={data.successMessage}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/components/sections/News.tsx
Normal file
151
src/components/sections/News.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
src/components/sections/OpenDay.tsx
Normal file
184
src/components/sections/OpenDay.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { Calendar, Users, Sparkles } from "lucide-react";
|
||||||
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
|
import type { OpenDayEvent, OpenDayClass } from "@/lib/openDay";
|
||||||
|
|
||||||
|
interface OpenDayProps {
|
||||||
|
data: {
|
||||||
|
event: OpenDayEvent;
|
||||||
|
classes: OpenDayClass[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateRu(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + "T12:00:00");
|
||||||
|
return d.toLocaleDateString("ru-RU", {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenDay({ data }: OpenDayProps) {
|
||||||
|
const { event, classes } = data;
|
||||||
|
const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
|
||||||
|
|
||||||
|
// Group classes by hall
|
||||||
|
const hallGroups = useMemo(() => {
|
||||||
|
const groups: Record<string, OpenDayClass[]> = {};
|
||||||
|
for (const cls of classes) {
|
||||||
|
if (!groups[cls.hall]) groups[cls.hall] = [];
|
||||||
|
groups[cls.hall].push(cls);
|
||||||
|
}
|
||||||
|
// Sort each hall's classes by time
|
||||||
|
for (const hall in groups) {
|
||||||
|
groups[hall].sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [classes]);
|
||||||
|
|
||||||
|
const halls = Object.keys(hallGroups);
|
||||||
|
|
||||||
|
if (classes.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="open-day" className="py-10 sm:py-14">
|
||||||
|
<div className="mx-auto max-w-6xl px-4">
|
||||||
|
<Reveal>
|
||||||
|
<SectionHeading centered>{event.title}</SectionHeading>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal>
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full bg-gold/10 border border-gold/20 px-5 py-2.5 text-sm font-medium text-gold">
|
||||||
|
<Calendar size={16} />
|
||||||
|
{formatDateRu(event.date)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Pricing info */}
|
||||||
|
<Reveal>
|
||||||
|
<div className="mt-6 text-center space-y-1">
|
||||||
|
<p className="text-lg font-semibold text-white">
|
||||||
|
{event.pricePerClass} BYN <span className="text-neutral-400 font-normal text-sm">за занятие</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gold">
|
||||||
|
<Sparkles size={12} className="inline mr-1" />
|
||||||
|
От {event.discountThreshold} занятий — {event.discountPrice} BYN за каждое!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{event.description && (
|
||||||
|
<Reveal>
|
||||||
|
<p className="mt-4 text-center text-sm text-neutral-400 max-w-2xl mx-auto">
|
||||||
|
{event.description}
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Schedule Grid */}
|
||||||
|
<div className="mt-8">
|
||||||
|
{halls.length === 1 ? (
|
||||||
|
// Single hall — simple list
|
||||||
|
<Reveal>
|
||||||
|
<div className="max-w-lg mx-auto space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-400 text-center">{halls[0]}</h3>
|
||||||
|
{hallGroups[halls[0]].map((cls) => (
|
||||||
|
<ClassCard
|
||||||
|
key={cls.id}
|
||||||
|
cls={cls}
|
||||||
|
onSignup={setSignup}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
) : (
|
||||||
|
// Multiple halls — columns
|
||||||
|
<div className={`grid gap-6 ${halls.length === 2 ? "sm:grid-cols-2" : "sm:grid-cols-2 lg:grid-cols-3"}`}>
|
||||||
|
{halls.map((hall) => (
|
||||||
|
<Reveal key={hall}>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-neutral-400 mb-3 text-center">{hall}</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{hallGroups[hall].map((cls) => (
|
||||||
|
<ClassCard
|
||||||
|
key={cls.id}
|
||||||
|
cls={cls}
|
||||||
|
onSignup={setSignup}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{signup && (
|
||||||
|
<SignupModal
|
||||||
|
open
|
||||||
|
onClose={() => setSignup(null)}
|
||||||
|
subtitle={signup.label}
|
||||||
|
endpoint="/api/open-day-register"
|
||||||
|
extraBody={{ classId: signup.classId, eventId: event.id }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClassCard({
|
||||||
|
cls,
|
||||||
|
onSignup,
|
||||||
|
}: {
|
||||||
|
cls: OpenDayClass;
|
||||||
|
onSignup: (info: { classId: number; label: string }) => void;
|
||||||
|
}) {
|
||||||
|
const label = `${cls.style} · ${cls.trainer} · ${cls.startTime}–${cls.endTime}`;
|
||||||
|
|
||||||
|
if (cls.cancelled) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/5 bg-neutral-900/30 p-4 opacity-50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-neutral-500">{cls.startTime}–{cls.endTime}</span>
|
||||||
|
<p className="text-sm text-neutral-500 line-through">{cls.style}</p>
|
||||||
|
<p className="text-xs text-neutral-600">{cls.trainer}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">
|
||||||
|
Отменено
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-neutral-900 p-4 transition-all hover:border-gold/20">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-xs text-gold font-medium">{cls.startTime}–{cls.endTime}</span>
|
||||||
|
<p className="text-sm font-medium text-white mt-0.5">{cls.style}</p>
|
||||||
|
<p className="text-xs text-neutral-400 flex items-center gap-1 mt-0.5">
|
||||||
|
<Users size={10} />
|
||||||
|
{cls.trainer}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onSignup({ classId: cls.id, label })}
|
||||||
|
className="shrink-0 rounded-full bg-gold/10 border border-gold/20 px-4 py-2 text-xs font-medium text-gold hover:bg-gold/20 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Записаться
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
src/components/sections/Pricing.tsx
Normal file
189
src/components/sections/Pricing.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CreditCard, Building2, ScrollText, Crown, Sparkles } from "lucide-react";
|
||||||
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
|
type Tab = "prices" | "rental" | "rules";
|
||||||
|
|
||||||
|
interface PricingProps {
|
||||||
|
data: SiteContent["pricing"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pricing({ data: pricing }: PricingProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>("prices");
|
||||||
|
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ id: "prices", label: "Абонементы", icon: <CreditCard size={16} /> },
|
||||||
|
{ id: "rental", label: "Аренда зала", icon: <Building2 size={16} /> },
|
||||||
|
{ id: "rules", label: "Правила", icon: <ScrollText size={16} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Split items: featured (big card) vs regular
|
||||||
|
const featuredItem = pricing.items.find((item) => item.featured);
|
||||||
|
const regularItems = pricing.items.filter((item) => !item.featured);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="pricing" className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505]">
|
||||||
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
|
<div className="section-container">
|
||||||
|
<Reveal>
|
||||||
|
<SectionHeading centered>{pricing.title}</SectionHeading>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Reveal>
|
||||||
|
<div className="mt-12 flex flex-wrap justify-center gap-2">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`inline-flex items-center gap-2 rounded-full px-6 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? "bg-gold text-black shadow-lg shadow-gold/25"
|
||||||
|
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-white/[0.06] dark:text-neutral-300 dark:hover:bg-white/[0.1]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Prices tab */}
|
||||||
|
{activeTab === "prices" && (
|
||||||
|
<Reveal>
|
||||||
|
<div className="mx-auto mt-10 max-w-4xl">
|
||||||
|
<p className="mb-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{pricing.subtitle}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Cards grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{regularItems.map((item, i) => {
|
||||||
|
const isPopular = item.popular ?? false;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`group relative rounded-2xl border p-5 transition-all duration-300 ${
|
||||||
|
isPopular
|
||||||
|
? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10"
|
||||||
|
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Popular badge */}
|
||||||
|
{isPopular && (
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-gold px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-black shadow-md shadow-gold/30">
|
||||||
|
<Sparkles size={10} />
|
||||||
|
Популярный
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={isPopular ? "mt-1" : ""}>
|
||||||
|
{/* Name */}
|
||||||
|
<p className={`text-sm font-medium ${isPopular ? "text-gold-dark dark:text-gold-light" : "text-neutral-700 dark:text-neutral-300"}`}>
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Note */}
|
||||||
|
{item.note && (
|
||||||
|
<p className="mt-1 text-xs text-neutral-400 dark:text-neutral-500">
|
||||||
|
{item.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
<p className={`mt-3 font-display text-2xl font-bold ${isPopular ? "text-gold" : "text-neutral-900 dark:text-white"}`}>
|
||||||
|
{item.price}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Featured — big card */}
|
||||||
|
{featuredItem && (
|
||||||
|
<div className="mt-6 w-full team-card-glitter rounded-2xl border border-gold/30 bg-gradient-to-r from-gold/10 via-gold/5 to-gold/10 dark:from-gold/[0.06] dark:via-transparent dark:to-gold/[0.06] p-6 sm:p-8">
|
||||||
|
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
||||||
|
<div className="text-center sm:text-left">
|
||||||
|
<div className="flex items-center justify-center gap-2 sm:justify-start">
|
||||||
|
<Crown size={18} className="text-gold" />
|
||||||
|
<p className="text-lg font-bold text-neutral-900 dark:text-white">
|
||||||
|
{featuredItem.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{featuredItem.note && (
|
||||||
|
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{featuredItem.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="shrink-0 font-display text-3xl font-bold text-gold">
|
||||||
|
{featuredItem.price}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rental tab */}
|
||||||
|
{activeTab === "rental" && (
|
||||||
|
<Reveal>
|
||||||
|
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
||||||
|
{pricing.rentalItems.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-neutral-900 dark:text-white">
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
{item.note && (
|
||||||
|
<p className="mt-0.5 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{item.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 font-display text-xl font-bold text-gold-dark dark:text-gold-light">
|
||||||
|
{item.price}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rules tab */}
|
||||||
|
{activeTab === "rules" && (
|
||||||
|
<Reveal>
|
||||||
|
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
||||||
|
{pricing.rules.map((rule, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex gap-4 rounded-2xl border border-neutral-200 bg-white px-5 py-4 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
|
||||||
|
{rule}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
410
src/components/sections/Schedule.tsx
Normal file
410
src/components/sections/Schedule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,195 +1,72 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState } from "react";
|
||||||
import Image from "next/image";
|
|
||||||
import { Instagram, ChevronLeft, ChevronRight } 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 { TeamMemberModal } from "@/components/ui/TeamMemberModal";
|
import { TeamCarousel } from "@/components/sections/team/TeamCarousel";
|
||||||
import type { TeamMember } from "@/types";
|
import { TeamMemberInfo } from "@/components/sections/team/TeamMemberInfo";
|
||||||
|
import { TeamProfile } from "@/components/sections/team/TeamProfile";
|
||||||
|
import type { SiteContent, ScheduleLocation } from "@/types/content";
|
||||||
|
|
||||||
export function Team() {
|
interface TeamProps {
|
||||||
const { team } = siteContent;
|
data: SiteContent["team"];
|
||||||
const [selectedMember, setSelectedMember] = useState<TeamMember | null>(null);
|
schedule?: ScheduleLocation[];
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
}
|
||||||
const scrollTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
|
||||||
const isDragging = useRef(false);
|
|
||||||
const dragStartX = useRef(0);
|
|
||||||
const dragScrollLeft = useRef(0);
|
|
||||||
const dragMoved = useRef(false);
|
|
||||||
|
|
||||||
// Render 3 copies: [clone] [original] [clone]
|
export function Team({ data: team, schedule }: TeamProps) {
|
||||||
const tripled = [...team.members, ...team.members, ...team.members];
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
// On mount, jump to the middle set (no animation)
|
|
||||||
useEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const cardWidth = el.scrollWidth / 3;
|
|
||||||
el.scrollLeft = cardWidth;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// When scroll settles, check if we need to loop
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
if (scrollTimer.current) clearTimeout(scrollTimer.current);
|
|
||||||
scrollTimer.current = setTimeout(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const oneSetWidth = el.scrollWidth / 3;
|
|
||||||
if (el.scrollLeft < oneSetWidth * 0.3) {
|
|
||||||
el.style.scrollBehavior = "auto";
|
|
||||||
el.scrollLeft += oneSetWidth;
|
|
||||||
el.style.scrollBehavior = "";
|
|
||||||
}
|
|
||||||
if (el.scrollLeft > oneSetWidth * 1.7) {
|
|
||||||
el.style.scrollBehavior = "auto";
|
|
||||||
el.scrollLeft -= oneSetWidth;
|
|
||||||
el.style.scrollBehavior = "";
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Mouse drag handlers
|
|
||||||
function handleMouseDown(e: React.MouseEvent) {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
isDragging.current = true;
|
|
||||||
dragMoved.current = false;
|
|
||||||
dragStartX.current = e.pageX;
|
|
||||||
dragScrollLeft.current = el.scrollLeft;
|
|
||||||
el.style.scrollBehavior = "auto";
|
|
||||||
el.style.scrollSnapType = "none";
|
|
||||||
el.style.cursor = "grabbing";
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseMove(e: React.MouseEvent) {
|
|
||||||
if (!isDragging.current || !scrollRef.current) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const dx = e.pageX - dragStartX.current;
|
|
||||||
if (Math.abs(dx) > 3) dragMoved.current = true;
|
|
||||||
scrollRef.current.scrollLeft = dragScrollLeft.current - dx;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseUp() {
|
|
||||||
if (!isDragging.current || !scrollRef.current) return;
|
|
||||||
isDragging.current = false;
|
|
||||||
scrollRef.current.style.scrollBehavior = "";
|
|
||||||
scrollRef.current.style.scrollSnapType = "";
|
|
||||||
scrollRef.current.style.cursor = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCardClick(member: TeamMember) {
|
|
||||||
// Don't open modal if user was dragging
|
|
||||||
if (dragMoved.current) return;
|
|
||||||
setSelectedMember(member);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scroll(direction: "left" | "right") {
|
|
||||||
if (!scrollRef.current) return;
|
|
||||||
const amount = scrollRef.current.offsetWidth * 0.7;
|
|
||||||
scrollRef.current.scrollBy({
|
|
||||||
left: direction === "left" ? -amount : amount,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="team" className="relative section-padding bg-neutral-50 dark:bg-[#050505]">
|
<section
|
||||||
|
id="team"
|
||||||
|
className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505] overflow-hidden"
|
||||||
|
>
|
||||||
<div className="section-divider absolute top-0 left-0 right-0" />
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
|
|
||||||
|
{/* Stage spotlight glow */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(ellipse 50% 70% at 50% 30%, rgba(201,169,110,0.07) 0%, transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="section-container">
|
<div className="section-container">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeading>{team.title}</SectionHeading>
|
<SectionHeading centered>{team.title}</SectionHeading>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Carousel wrapper */}
|
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="relative mt-10">
|
<div className="mt-10 px-4 sm:px-6">
|
||||||
{/* Scroll container */}
|
{!showProfile ? (
|
||||||
<div
|
<>
|
||||||
ref={scrollRef}
|
<TeamCarousel
|
||||||
onScroll={handleScroll}
|
members={team.members}
|
||||||
onMouseDown={handleMouseDown}
|
activeIndex={activeIndex}
|
||||||
onMouseMove={handleMouseMove}
|
onActiveChange={setActiveIndex}
|
||||||
onMouseUp={handleMouseUp}
|
/>
|
||||||
onMouseLeave={handleMouseUp}
|
|
||||||
className="flex cursor-grab gap-4 overflow-x-auto px-6 pb-4 sm:px-8 scroll-smooth snap-x snap-mandatory select-none lg:px-[max(2rem,calc((100vw-72rem)/2+2rem))]"
|
<div className="mx-auto max-w-6xl">
|
||||||
style={{ scrollbarWidth: "none" }}
|
<TeamMemberInfo
|
||||||
>
|
members={team.members}
|
||||||
{tripled.map((member, i) => (
|
activeIndex={activeIndex}
|
||||||
<div
|
onSelect={setActiveIndex}
|
||||||
key={`${i}-${member.name}`}
|
onOpenBio={() => setShowProfile(true)}
|
||||||
className="group relative w-[220px] shrink-0 cursor-pointer snap-start overflow-hidden rounded-2xl sm:w-[260px]"
|
|
||||||
onClick={() => handleCardClick(member)}
|
|
||||||
>
|
|
||||||
{/* Photo */}
|
|
||||||
<div className="aspect-[3/4] w-full overflow-hidden">
|
|
||||||
<Image
|
|
||||||
src={member.image}
|
|
||||||
alt={member.name}
|
|
||||||
width={260}
|
|
||||||
height={347}
|
|
||||||
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
{/* Gradient overlay */}
|
) : (
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-80 transition-opacity duration-500 group-hover:opacity-100" />
|
<TeamProfile
|
||||||
|
member={team.members[activeIndex]}
|
||||||
{/* Rose glow on hover */}
|
onBack={() => setShowProfile(false)}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-rose-900/20 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
|
schedule={schedule}
|
||||||
|
/>
|
||||||
{/* Content */}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-4 translate-y-1 transition-transform duration-500 group-hover:translate-y-0">
|
|
||||||
<h3 className="text-base font-semibold text-white sm:text-lg">
|
|
||||||
{member.name}
|
|
||||||
</h3>
|
|
||||||
{member.instagram && (
|
|
||||||
<span
|
|
||||||
className="mt-1 inline-flex items-center gap-1.5 text-xs text-white/60 transition-colors hover:text-rose-400 sm:text-sm"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Instagram size={12} className="shrink-0" />
|
|
||||||
<a
|
|
||||||
href={member.instagram}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{member.instagram.split("/").filter(Boolean).pop()}
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Side navigation arrows */}
|
|
||||||
<button
|
|
||||||
onClick={() => scroll("left")}
|
|
||||||
className="absolute left-2 top-1/2 -translate-y-1/2 hidden h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white/80 backdrop-blur-sm transition-all hover:bg-rose-500/30 hover:text-white sm:flex"
|
|
||||||
aria-label="Назад"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={22} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => scroll("right")}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 hidden h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white/80 backdrop-blur-sm transition-all hover:bg-rose-500/30 hover:text-white sm:flex"
|
|
||||||
aria-label="Вперёд"
|
|
||||||
>
|
|
||||||
<ChevronRight size={22} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<TeamMemberModal
|
|
||||||
member={selectedMember}
|
|
||||||
onClose={() => setSelectedMember(null)}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
142
src/components/sections/schedule/DayCard.tsx
Normal file
142
src/components/sections/schedule/DayCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
src/components/sections/schedule/GroupView.tsx
Normal file
270
src/components/sections/schedule/GroupView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
src/components/sections/schedule/MobileSchedule.tsx
Normal file
212
src/components/sections/schedule/MobileSchedule.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/components/sections/schedule/ScheduleFilters.tsx
Normal file
154
src/components/sections/schedule/ScheduleFilters.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/sections/schedule/constants.ts
Normal file
111
src/components/sections/schedule/constants.ts
Normal 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:MM–HH: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";
|
||||||
249
src/components/sections/team/TeamCarousel.tsx
Normal file
249
src/components/sections/team/TeamCarousel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/components/sections/team/TeamMemberInfo.tsx
Normal file
63
src/components/sections/team/TeamMemberInfo.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Instagram } from "lucide-react";
|
||||||
|
import type { TeamMember } from "@/types/content";
|
||||||
|
|
||||||
|
interface TeamMemberInfoProps {
|
||||||
|
members: TeamMember[];
|
||||||
|
activeIndex: number;
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
onOpenBio: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamMemberInfo({ members, activeIndex, onSelect, onOpenBio }: TeamMemberInfoProps) {
|
||||||
|
const member = members[activeIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={activeIndex}
|
||||||
|
className="mx-auto mt-8 max-w-lg text-center"
|
||||||
|
style={{
|
||||||
|
animation: "team-info-in 0.6s cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.instagram && (
|
||||||
|
<a
|
||||||
|
href={member.instagram}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-white/40 transition-colors hover:text-gold-light"
|
||||||
|
>
|
||||||
|
<Instagram size={14} />
|
||||||
|
{member.instagram.split("/").filter(Boolean).pop()}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{member.description && (
|
||||||
|
<p className="mt-3 text-sm leading-relaxed text-white/55">
|
||||||
|
{member.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onOpenBio}
|
||||||
|
className="mt-3 text-sm font-medium text-gold hover:text-gold-light transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Подробнее →
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Progress dots */}
|
||||||
|
<div className="mt-6 flex items-center justify-center gap-1.5">
|
||||||
|
{members.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSelect(i)}
|
||||||
|
className={`h-1.5 rounded-full transition-all duration-500 cursor-pointer ${
|
||||||
|
i === activeIndex
|
||||||
|
? "w-6 bg-gold"
|
||||||
|
: "w-1.5 bg-white/15 hover:bg-white/30"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
478
src/components/sections/team/TeamProfile.tsx
Normal file
478
src/components/sections/team/TeamProfile.tsx
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Award, Scale, Clock, MapPin } from "lucide-react";
|
||||||
|
import type { TeamMember, RichListItem, VictoryItem, ScheduleLocation } from "@/types/content";
|
||||||
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
|
|
||||||
|
interface TeamProfileProps {
|
||||||
|
member: TeamMember;
|
||||||
|
onBack: () => void;
|
||||||
|
schedule?: ScheduleLocation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
||||||
|
const [lightbox, setLightbox] = useState<string | null>(null);
|
||||||
|
const [bookingGroup, setBookingGroup] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
if (lightbox) setLightbox(null);
|
||||||
|
else onBack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [lightbox, onBack]);
|
||||||
|
const places = member.victories?.filter(v => !v.type || v.type === 'place') ?? [];
|
||||||
|
const nominations = member.victories?.filter(v => v.type === 'nomination') ?? [];
|
||||||
|
const judging = member.victories?.filter(v => v.type === 'judge') ?? [];
|
||||||
|
const victoryTabs = [
|
||||||
|
...(places.length > 0 ? [{ key: 'place' as const, label: 'Достижения', icon: Trophy, items: places }] : []),
|
||||||
|
...(nominations.length > 0 ? [{ key: 'nomination' as const, label: 'Номинации', icon: Award, items: nominations }] : []),
|
||||||
|
...(judging.length > 0 ? [{ key: 'judge' as const, label: 'Судейство', icon: Scale, items: judging }] : []),
|
||||||
|
];
|
||||||
|
const hasVictories = victoryTabs.length > 0;
|
||||||
|
const [activeTab, setActiveTab] = useState(victoryTabs[0]?.key ?? 'place');
|
||||||
|
const hasExperience = member.experience && member.experience.length > 0;
|
||||||
|
const hasEducation = member.education && member.education.length > 0;
|
||||||
|
|
||||||
|
// Extract trainer's groups from schedule using groupId
|
||||||
|
const groupMap = new Map<string, { type: string; location: string; address: string; slots: { day: string; dayShort: string; time: string }[]; level?: string; recruiting?: boolean }>();
|
||||||
|
schedule?.forEach(location => {
|
||||||
|
location.days.forEach(day => {
|
||||||
|
day.classes
|
||||||
|
.filter(c => c.trainer === member.name)
|
||||||
|
.forEach(c => {
|
||||||
|
const key = c.groupId
|
||||||
|
? `${c.groupId}||${location.name}`
|
||||||
|
: `${c.trainer}||${c.type}||${location.name}`;
|
||||||
|
const existing = groupMap.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.slots.push({ day: day.day, dayShort: day.dayShort, time: c.time });
|
||||||
|
if (c.level && !existing.level) existing.level = c.level;
|
||||||
|
if (c.recruiting) existing.recruiting = true;
|
||||||
|
} else {
|
||||||
|
groupMap.set(key, {
|
||||||
|
type: c.type,
|
||||||
|
location: location.name,
|
||||||
|
address: location.address,
|
||||||
|
slots: [{ day: day.day, dayShort: day.dayShort, time: c.time }],
|
||||||
|
level: c.level,
|
||||||
|
recruiting: c.recruiting,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const uniqueGroups = Array.from(groupMap.values()).map(g => {
|
||||||
|
// Merge slots by day, then merge days with identical time sets
|
||||||
|
const dayMap = new Map<string, { dayShort: string; times: string[] }>();
|
||||||
|
const dayOrder: string[] = [];
|
||||||
|
for (const s of g.slots) {
|
||||||
|
const existing = dayMap.get(s.day);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.times.includes(s.time)) existing.times.push(s.time);
|
||||||
|
} else {
|
||||||
|
dayMap.set(s.day, { dayShort: s.dayShort, times: [s.time] });
|
||||||
|
dayOrder.push(s.day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const entry of dayMap.values()) entry.times.sort();
|
||||||
|
const merged: { days: string[]; times: string[] }[] = [];
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const day of dayOrder) {
|
||||||
|
if (used.has(day)) continue;
|
||||||
|
const entry = dayMap.get(day)!;
|
||||||
|
const timeKey = entry.times.join("|");
|
||||||
|
const days = [entry.dayShort];
|
||||||
|
used.add(day);
|
||||||
|
for (const other of dayOrder) {
|
||||||
|
if (used.has(other)) continue;
|
||||||
|
const o = dayMap.get(other)!;
|
||||||
|
if (o.times.join("|") === timeKey) { days.push(o.dayShort); used.add(other); }
|
||||||
|
}
|
||||||
|
merged.push({ days, times: entry.times });
|
||||||
|
}
|
||||||
|
return { ...g, merged };
|
||||||
|
});
|
||||||
|
const hasGroups = uniqueGroups.length > 0;
|
||||||
|
|
||||||
|
const hasBio = hasVictories || hasExperience || hasEducation || hasGroups;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full"
|
||||||
|
style={{ animation: "team-info-in 0.6s cubic-bezier(0.16, 1, 0.3, 1)" }}
|
||||||
|
>
|
||||||
|
{/* Magazine editorial layout */}
|
||||||
|
<div className="relative mx-auto max-w-5xl flex flex-col sm:flex-row sm:items-start">
|
||||||
|
{/* Photo — left column, sticky */}
|
||||||
|
<div className="relative shrink-0 w-full sm:w-[380px] lg:w-[420px] sm:sticky sm:top-8">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="mb-3 inline-flex items-center gap-1.5 rounded-full bg-white/[0.06] px-3 py-1.5 text-sm text-white/50 transition-colors hover:text-white hover:bg-white/[0.1] cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
<div className="relative aspect-[3/4] overflow-hidden rounded-2xl border border-white/[0.06]">
|
||||||
|
<Image
|
||||||
|
src={member.image}
|
||||||
|
alt={member.name}
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 1024px) 380px, (min-width: 640px) 340px, 100vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
{/* Top gradient for name */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-transparent to-transparent" />
|
||||||
|
{/* Bottom gradient for mobile bio peek */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent sm:hidden" />
|
||||||
|
|
||||||
|
{/* Name + role overlay at top */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 p-6 sm:p-8">
|
||||||
|
<h3
|
||||||
|
className="text-3xl sm:text-4xl font-bold text-white leading-tight"
|
||||||
|
style={{ textShadow: "0 2px 24px rgba(0,0,0,0.6)" }}
|
||||||
|
>
|
||||||
|
{member.name}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
className="mt-1.5 text-sm sm:text-base font-medium text-gold-light"
|
||||||
|
style={{ textShadow: "0 1px 12px rgba(0,0,0,0.5)" }}
|
||||||
|
>
|
||||||
|
{member.role}
|
||||||
|
</p>
|
||||||
|
{member.instagram && (
|
||||||
|
<a
|
||||||
|
href={member.instagram}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-3 inline-flex items-center gap-1.5 text-sm text-white/70 transition-colors hover:text-gold-light"
|
||||||
|
style={{ textShadow: "0 1px 8px rgba(0,0,0,0.5)" }}
|
||||||
|
>
|
||||||
|
<Instagram size={14} />
|
||||||
|
{member.instagram.split("/").filter(Boolean).pop()}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bio panel — overlaps photo edge on desktop */}
|
||||||
|
<div className="relative sm:-ml-12 sm:mt-8 mt-0 flex-1 min-w-0 z-10">
|
||||||
|
<div className="relative rounded-2xl border border-white/[0.08] overflow-hidden shadow-2xl shadow-black/40">
|
||||||
|
{/* Ambient photo background */}
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<Image
|
||||||
|
src={member.image}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="600px"
|
||||||
|
className="object-cover scale-150 blur-sm grayscale opacity-70 brightness-[0.6] contrast-[1.3]"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/20 mix-blend-multiply" />
|
||||||
|
<div className="absolute inset-0 bg-gold/10 mix-blend-color" />
|
||||||
|
</div>
|
||||||
|
<div className="relative p-5 sm:p-6">
|
||||||
|
{/* Victory tabs */}
|
||||||
|
{hasVictories && (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{victoryTabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full border px-4 py-1.5 text-sm font-medium transition-colors cursor-pointer ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? "border-gold/30 bg-gold/10 text-gold"
|
||||||
|
: "border-white/[0.08] bg-white/[0.03] text-white/40 hover:text-white/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<tab.icon size={14} />
|
||||||
|
{tab.label}
|
||||||
|
<span className={`ml-0.5 text-xs ${activeTab === tab.key ? "text-gold/60" : "text-white/20"}`}>
|
||||||
|
{tab.items.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid mt-4" style={{ gridTemplateColumns: "1fr", gridTemplateRows: "1fr" }}>
|
||||||
|
{victoryTabs.map(tab => (
|
||||||
|
<div key={tab.key} className={`col-start-1 row-start-1 ${activeTab === tab.key ? "" : "invisible"}`}>
|
||||||
|
<ScrollRow>
|
||||||
|
{tab.items.map((item, i) => (
|
||||||
|
<VictoryCard key={i} victory={item} />
|
||||||
|
))}
|
||||||
|
</ScrollRow>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Groups */}
|
||||||
|
{hasGroups && (
|
||||||
|
<div className={hasVictories ? "mt-8" : ""}>
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-gold/20 bg-gold/5 px-4 py-1.5 text-sm font-medium text-gold">
|
||||||
|
<Clock size={14} />
|
||||||
|
Группы
|
||||||
|
</span>
|
||||||
|
<ScrollRow>
|
||||||
|
{uniqueGroups.map((g, i) => (
|
||||||
|
<div key={i} className="w-48 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3 space-y-1.5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-white/80">{g.type}</p>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{g.merged.map((m, mi) => (
|
||||||
|
<div key={mi} className="flex items-center gap-1.5 text-xs text-white/50">
|
||||||
|
<Clock size={11} className="shrink-0" />
|
||||||
|
<span className="font-medium text-white/70">{m.days.join(", ")}</span>
|
||||||
|
<span>{m.times.join(", ")}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-1.5 text-xs text-white/40">
|
||||||
|
<MapPin size={11} className="mt-0.5 shrink-0" />
|
||||||
|
<span>{g.location} · {g.address.replace(/^г\.\s*\S+,\s*/, "")}</span>
|
||||||
|
</div>
|
||||||
|
{g.level && (
|
||||||
|
<p className="text-[10px] text-gold/60">{g.level}</p>
|
||||||
|
)}
|
||||||
|
{g.recruiting && (
|
||||||
|
<span className="inline-block rounded-full bg-green-500/15 border border-green-500/30 px-2 py-0.5 text-[10px] text-green-400">
|
||||||
|
Набор открыт
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setBookingGroup(`${g.type}, ${g.merged.map(m => m.days.join("/")).join(", ")} ${g.merged[0]?.times[0] ?? ""}`)}
|
||||||
|
className="w-full mt-1 rounded-lg bg-gold/15 border border-gold/25 py-1.5 text-[11px] font-semibold text-gold hover:bg-gold/25 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Записаться
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollRow>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Education */}
|
||||||
|
{hasEducation && (
|
||||||
|
<div className={hasVictories || hasGroups ? "mt-8" : ""}>
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-gold/20 bg-gold/5 px-4 py-1.5 text-sm font-medium text-gold">
|
||||||
|
<GraduationCap size={14} />
|
||||||
|
Образование
|
||||||
|
</span>
|
||||||
|
<ScrollRow>
|
||||||
|
{member.education!.map((item, i) => (
|
||||||
|
<RichCard key={i} item={item} onImageClick={setLightbox} />
|
||||||
|
))}
|
||||||
|
</ScrollRow>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Experience */}
|
||||||
|
{hasExperience && (
|
||||||
|
<div className={hasVictories || hasGroups || hasEducation ? "mt-8" : ""}>
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-gold/20 bg-gold/5 px-4 py-1.5 text-sm font-medium text-gold">
|
||||||
|
<Trophy size={15} />
|
||||||
|
Опыт
|
||||||
|
</span>
|
||||||
|
<ScrollRow>
|
||||||
|
{member.experience!.map((item, i) => (
|
||||||
|
<div key={i} className="w-48 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3">
|
||||||
|
<p className="text-sm text-white/60">{item}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollRow>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{member.description && (
|
||||||
|
<p className={`text-sm leading-relaxed text-white/45 ${hasBio ? "mt-8 border-t border-white/[0.06] pt-6" : ""}`}>
|
||||||
|
{member.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!hasBio && !member.description && (
|
||||||
|
<p className="text-sm text-white/30 italic">
|
||||||
|
Информация скоро появится
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image lightbox */}
|
||||||
|
{lightbox && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Просмотр изображения"
|
||||||
|
onClick={() => setLightbox(null)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setLightbox(null)}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className="absolute top-4 right-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
<div className="relative max-h-[85vh] max-w-[90vw]">
|
||||||
|
<Image
|
||||||
|
src={lightbox}
|
||||||
|
alt="Достижение"
|
||||||
|
width={900}
|
||||||
|
height={900}
|
||||||
|
className="rounded-lg object-contain max-h-[85vh]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SignupModal
|
||||||
|
open={bookingGroup !== null}
|
||||||
|
onClose={() => setBookingGroup(null)}
|
||||||
|
subtitle={bookingGroup ?? undefined}
|
||||||
|
endpoint="/api/group-booking"
|
||||||
|
extraBody={{ groupInfo: bookingGroup }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollRow({ children }: { children: React.ReactNode }) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragState = useRef<{ startX: number; scrollLeft: number } | null>(null);
|
||||||
|
const wasDragged = useRef(false);
|
||||||
|
|
||||||
|
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
dragState.current = { startX: e.clientX, scrollLeft: el.scrollLeft };
|
||||||
|
wasDragged.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||||
|
if (!dragState.current || !scrollRef.current) return;
|
||||||
|
const dx = e.clientX - dragState.current.startX;
|
||||||
|
if (Math.abs(dx) > 4) wasDragged.current = true;
|
||||||
|
scrollRef.current.scrollLeft = dragState.current.scrollLeft - dx;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPointerUp = useCallback(() => {
|
||||||
|
dragState.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mt-4">
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="flex items-stretch gap-3 overflow-x-auto pb-2 pt-4 cursor-grab active:cursor-grabbing select-none"
|
||||||
|
style={{ scrollbarWidth: "none", msOverflowStyle: "none", WebkitOverflowScrolling: "touch" }}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}
|
||||||
|
onLostPointerCapture={onPointerUp}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VictoryCard({ victory }: { victory: VictoryItem }) {
|
||||||
|
const hasLink = !!victory.link;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group w-44 shrink-0 rounded-xl border border-white/[0.08] overflow-visible bg-white/[0.03] relative">
|
||||||
|
<div className="absolute top-0 left-0 w-1 h-full bg-gold/40 rounded-l-xl" />
|
||||||
|
{victory.place && (
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
|
||||||
|
<span className="inline-block rounded-full border border-gold/40 bg-gold/20 px-3 py-0.5 text-xs font-bold uppercase tracking-wider text-gold whitespace-nowrap backdrop-blur-sm">
|
||||||
|
{victory.place}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`pl-4 pr-3 pb-3 space-y-1 ${victory.place ? "pt-6" : "py-3"}`}>
|
||||||
|
{victory.category && (
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-white/80">{victory.category}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-white/50">{victory.competition}</p>
|
||||||
|
{hasLink && (
|
||||||
|
<a
|
||||||
|
href={victory.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-1 inline-flex items-center gap-1 text-xs text-gold/70 hover:text-gold transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={10} />
|
||||||
|
Подробнее
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (src: string) => void }) {
|
||||||
|
const hasImage = !!item.image;
|
||||||
|
const hasLink = !!item.link;
|
||||||
|
|
||||||
|
if (hasImage) {
|
||||||
|
return (
|
||||||
|
<div className="group w-48 shrink-0 flex rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03]">
|
||||||
|
<button
|
||||||
|
onClick={() => onImageClick(item.image!)}
|
||||||
|
className="relative w-14 shrink-0 overflow-hidden cursor-pointer"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={item.image!}
|
||||||
|
alt={item.text}
|
||||||
|
fill
|
||||||
|
sizes="56px"
|
||||||
|
className="object-cover transition-transform group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 min-w-0 p-2.5">
|
||||||
|
<p className="text-xs text-white/70">{item.text}</p>
|
||||||
|
{hasLink && (
|
||||||
|
<a
|
||||||
|
href={item.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-1 inline-flex items-center gap-1 text-xs text-gold/70 hover:text-gold transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={11} />
|
||||||
|
Подробнее
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group w-48 shrink-0 rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03]">
|
||||||
|
<div className="p-3">
|
||||||
|
<p className="text-sm text-white/60">{item.text}</p>
|
||||||
|
{hasLink && (
|
||||||
|
<a
|
||||||
|
href={item.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-1.5 inline-flex items-center gap-1 text-xs text-gold/70 hover:text-gold transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={11} />
|
||||||
|
Подробнее
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/components/ui/BackToTop.tsx
Normal file
29
src/components/ui/BackToTop.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { ChevronUp } from "lucide-react";
|
||||||
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
|
|
||||||
|
export function BackToTop() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleScroll() {
|
||||||
|
setVisible(window.scrollY > UI_CONFIG.scrollThresholds.backToTop);
|
||||||
|
}
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
|
aria-label="Наверх"
|
||||||
|
className={`fixed bottom-20 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-gold/30 bg-black/60 text-gold-light backdrop-blur-sm transition-all duration-300 hover:bg-gold/20 hover:border-gold/50 ${
|
||||||
|
visible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ChevronUp size={18} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import Link from "next/link";
|
|||||||
|
|
||||||
interface ButtonProps {
|
interface ButtonProps {
|
||||||
href?: string;
|
href?: string;
|
||||||
variant?: "primary" | "outline" | "ghost";
|
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -17,13 +16,12 @@ const sizes = {
|
|||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
href,
|
href,
|
||||||
variant = "primary",
|
|
||||||
size = "md",
|
size = "md",
|
||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
onClick,
|
onClick,
|
||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
const classes = `btn-${variant} ${sizes[size]} ${className}`;
|
const classes = `btn-primary ${sizes[size]} ${className}`;
|
||||||
|
|
||||||
if (href) {
|
if (href) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { X, Flame, Sparkles, Wind, Zap, Star, Monitor } from "lucide-react";
|
|
||||||
import type { ClassItem } from "@/types";
|
|
||||||
|
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
|
||||||
flame: <Flame size={20} />,
|
|
||||||
sparkles: <Sparkles size={20} />,
|
|
||||||
wind: <Wind size={20} />,
|
|
||||||
zap: <Zap size={20} />,
|
|
||||||
star: <Star size={20} />,
|
|
||||||
monitor: <Monitor size={20} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ClassModalProps {
|
|
||||||
classItem: ClassItem | null;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ClassModal({ classItem, onClose }: ClassModalProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!classItem) return;
|
|
||||||
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [classItem, onClose]);
|
|
||||||
|
|
||||||
if (!classItem) return null;
|
|
||||||
|
|
||||||
const heroImage = classItem.images?.[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="modal-overlay fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-lg sm:items-center sm:p-4"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="modal-content relative flex w-full max-h-[90vh] flex-col overflow-hidden rounded-t-3xl bg-white sm:max-w-2xl sm:rounded-3xl dark:bg-[#111]"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Hero image banner */}
|
|
||||||
{heroImage && (
|
|
||||||
<div className="relative h-52 w-full shrink-0 sm:h-64">
|
|
||||||
<Image
|
|
||||||
src={heroImage}
|
|
||||||
alt={classItem.name}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
|
||||||
|
|
||||||
{/* Close button */}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-sm transition-all hover:bg-black/60 hover:text-white"
|
|
||||||
aria-label="Закрыть"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Title on image */}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/15 text-white backdrop-blur-sm">
|
|
||||||
{iconMap[classItem.icon]}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl font-bold text-white">
|
|
||||||
{classItem.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="overflow-y-auto">
|
|
||||||
{/* Title fallback when no image */}
|
|
||||||
{!heroImage && (
|
|
||||||
<div className="flex items-center justify-between p-6 pb-0">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-rose-50 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400">
|
|
||||||
{iconMap[classItem.icon]}
|
|
||||||
</div>
|
|
||||||
<h3 className="heading-text text-xl font-bold">
|
|
||||||
{classItem.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-full p-1.5 text-neutral-400 transition-all hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-500 dark:hover:bg-white/[0.05] dark:hover:text-white"
|
|
||||||
aria-label="Закрыть"
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{classItem.detailedDescription && (
|
|
||||||
<div className="p-6 text-sm leading-relaxed whitespace-pre-line text-neutral-600 dark:text-neutral-400">
|
|
||||||
{classItem.detailedDescription}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
65
src/components/ui/FloatingContact.tsx
Normal file
65
src/components/ui/FloatingContact.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Instagram } from "lucide-react";
|
||||||
|
import { BRAND } from "@/lib/constants";
|
||||||
|
import { SignupModal } from "./SignupModal";
|
||||||
|
|
||||||
|
export function FloatingContact() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleScroll() {
|
||||||
|
const contactEl = document.getElementById("contact");
|
||||||
|
|
||||||
|
const pastHero = window.scrollY > window.innerHeight * 0.7;
|
||||||
|
const reachedContact = contactEl
|
||||||
|
? window.scrollY + window.innerHeight > contactEl.offsetTop + 100
|
||||||
|
: false;
|
||||||
|
|
||||||
|
setVisible(pastHero && !reachedContact);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
handleScroll();
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`fixed bottom-6 left-1/2 z-40 -translate-x-1/2 flex items-center gap-3 rounded-full border border-gold/20 bg-black/80 px-2 py-2 backdrop-blur-md shadow-lg shadow-black/40 transition-all duration-400 ${
|
||||||
|
visible
|
||||||
|
? "translate-y-0 opacity-100"
|
||||||
|
: "translate-y-6 opacity-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
className="rounded-full bg-gold px-5 py-2 text-sm font-semibold text-black transition-colors hover:bg-gold-light"
|
||||||
|
>
|
||||||
|
Записаться
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={BRAND.instagram}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Instagram"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-gold/30 text-gold-light transition-colors hover:bg-gold/20 hover:border-gold/50"
|
||||||
|
>
|
||||||
|
<Instagram size={18} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SignupModal
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
title="Записаться на занятие"
|
||||||
|
subtitle="Оставьте контактные данные и мы свяжемся с вами"
|
||||||
|
endpoint="/api/group-booking"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
|
|
||||||
interface Heart {
|
interface Heart {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -15,7 +16,7 @@ export function FloatingHearts() {
|
|||||||
const [hearts, setHearts] = useState<Heart[]>([]);
|
const [hearts, setHearts] = useState<Heart[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const generated: Heart[] = Array.from({ length: 12 }, (_, i) => ({
|
const generated: Heart[] = Array.from({ length: UI_CONFIG.team.floatingHeartsCount }, (_, i) => ({
|
||||||
id: i,
|
id: i,
|
||||||
left: Math.random() * 100,
|
left: Math.random() * 100,
|
||||||
size: 8 + Math.random() * 16,
|
size: 8 + Math.random() * 16,
|
||||||
@@ -33,7 +34,7 @@ export function FloatingHearts() {
|
|||||||
{hearts.map((heart) => (
|
{hearts.map((heart) => (
|
||||||
<div
|
<div
|
||||||
key={heart.id}
|
key={heart.id}
|
||||||
className="absolute text-rose-500"
|
className="absolute text-gold"
|
||||||
style={{
|
style={{
|
||||||
left: `${heart.left}%`,
|
left: `${heart.left}%`,
|
||||||
bottom: "-20px",
|
bottom: "-20px",
|
||||||
|
|||||||
161
src/components/ui/HeroLogo.tsx
Normal file
161
src/components/ui/HeroLogo.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
interface HeroLogoProps {
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heart SVG split into 3 sub-paths for independent stroke animation
|
||||||
|
const PATHS = [
|
||||||
|
// Main heart shape (largest, outer contour)
|
||||||
|
"M118.02,188.43 C118.04,184.10 120.51,173.30 122.96,166.79 C126.11,158.42 133.55,147.62 144.55,135.42 C165.53,112.15 170.96,101.38 170.99,82.98 C171.00,72.35 168.51,62.96 162.47,50.94 C160.00,46.02 157.78,42.00 157.53,42.00 C157.29,42.00 158.24,45.04 159.64,48.75 C163.04,57.78 165.96,71.24 165.96,78.00 C165.97,85.89 163.51,95.22 159.27,103.40 C156.31,109.09 152.52,113.57 140.17,126.00 C131.69,134.53 123.42,143.44 121.79,145.81 C116.23,153.88 110.87,167.99 109.81,177.28 C109.51,179.99 109.37,179.90 105.02,174.28 C102.55,171.10 98.74,166.54 96.55,164.15 L92.56,159.80 L95.53,157.70 C100.61,154.12 105.90,148.12 108.76,142.70 C111.22,138.02 111.50,136.50 111.47,127.50 C111.45,118.43 111.02,116.15 106.86,103.00 C101.17,85.06 99.60,76.75 100.25,68.17 C100.75,61.51 104.60,48.83 107.29,45.00 C108.57,43.16 108.76,43.69 109.31,50.84 C110.42,65.22 115.99,75.08 126.37,81.04 C133.31,85.02 133.82,84.76 128.92,79.75 C124.20,74.93 119.44,65.68 118.16,58.84 C116.46,49.75 119.09,39.24 125.73,28.59 L128.79,23.69 L130.02,29.59 C130.69,32.84 133.23,39.92 135.65,45.33 C143.15,62.02 144.36,69.90 141.53,83.29 C140.04,90.28 134.00,104.04 127.66,114.86 C125.68,118.24 124.39,120.97 124.78,120.93 C125.18,120.90 128.41,117.53 131.97,113.46 C145.06,98.47 150.50,85.84 150.46,70.50 C150.43,59.80 149.70,57.36 141.46,40.79 C137.98,33.80 134.83,26.02 134.45,23.49 C133.85,19.50 134.07,18.56 136.10,16.40 C139.50,12.77 147.93,7.39 153.86,5.06 L159.00,3.03 L159.00,9.32 C159.00,18.37 162.11,24.01 172.06,33.00 C176.46,36.97 180.72,41.50 181.53,43.06 C183.67,47.20 183.39,56.94 180.95,63.13 C178.14,70.25 180.87,67.95 184.97,59.74 C190.78,48.12 188.70,39.73 177.15,28.15 C173.28,24.27 169.43,20.06 168.61,18.80 C166.51,15.58 164.79,7.21 165.54,3.83 C166.16,1.00 166.17,1.00 174.33,1.01 C178.82,1.02 184.15,1.29 186.17,1.63 C189.69,2.21 189.81,2.37 189.29,5.62 C188.42,10.94 190.83,20.71 195.10,29.20 C197.26,33.49 199.19,37.00 199.40,37.00 C199.62,37.00 198.67,33.51 197.31,29.25 C195.35,23.12 194.91,19.90 195.17,13.83 C195.36,9.61 195.85,5.81 196.28,5.39 C197.35,4.31 205.52,8.43 211.67,13.15 C218.45,18.35 221.00,23.72 220.99,32.74 C220.98,36.46 220.28,41.98 219.42,45.00 C218.57,48.02 217.63,51.40 217.34,52.50 C216.30,56.51 222.34,45.32 224.52,39.20 C225.76,35.73 227.01,32.66 227.29,32.38 C227.57,32.09 228.79,34.48 229.99,37.68 C238.21,59.57 232.78,83.80 215.76,101.15 C209.43,107.60 207.42,108.54 209.17,104.25 C210.91,100.00 210.37,85.54 208.08,74.64 C206.93,69.22 205.95,62.24 205.90,59.14 C205.80,53.85 205.74,53.72 204.93,57.00 C204.45,58.92 204.13,68.15 204.22,77.50 C204.37,93.11 204.20,94.91 202.15,99.45 C198.99,106.51 192.06,115.46 190.76,114.16 C188.49,111.89 189.93,84.88 192.72,77.19 C194.09,73.45 189.30,79.05 186.68,84.26 C182.02,93.55 180.69,101.03 181.41,113.97 L182.05,125.50 L169.94,135.00 C153.90,147.58 132.06,170.01 124.13,182.05 C118.83,190.09 118.00,190.96 118.02,188.43 Z",
|
||||||
|
// Left inner detail
|
||||||
|
"M83.09,150.59 C78.00,145.44 77.78,144.99 78.44,141.34 C78.82,139.23 81.24,133.00 83.81,127.50 C88.47,117.57 88.50,117.43 88.49,107.00 C88.47,99.38 87.96,94.99 86.59,91.00 C84.28,84.21 77.06,69.61 76.36,70.30 C76.08,70.58 76.56,72.19 77.43,73.87 C79.91,78.65 82.99,92.88 82.99,99.57 C83.00,108.39 80.69,114.86 73.96,124.82 C70.68,129.67 68.00,134.17 68.00,134.82 C68.00,135.47 67.62,136.00 67.16,136.00 C66.07,136.00 57.00,128.93 57.00,128.07 C57.00,127.71 59.03,123.47 61.50,118.66 C66.60,108.75 67.24,103.18 64.48,92.59 C62.01,83.09 61.32,83.22 61.40,93.17 C61.45,100.19 61.02,103.39 59.69,106.10 C57.49,110.57 48.29,121.00 46.56,121.00 C44.40,121.00 39.79,109.24 39.24,102.34 C38.56,93.90 40.48,89.09 48.68,78.77 C62.32,61.60 65.53,49.22 60.98,31.41 C58.70,22.51 54.61,13.20 50.08,6.62 C47.54,2.92 47.30,2.10 48.60,1.60 C50.50,0.87 66.31,0.80 68.17,1.51 C69.18,1.90 69.42,4.19 69.15,10.95 C68.88,18.05 69.27,21.45 71.06,27.48 C72.30,31.66 73.77,35.36 74.33,35.70 C74.97,36.10 75.06,35.62 74.57,34.41 C74.15,33.36 73.57,28.88 73.27,24.45 C72.70,15.76 74.85,5.38 77.44,4.39 C79.59,3.56 92.09,10.37 97.73,15.45 C100.57,18.00 103.83,21.61 104.99,23.48 L107.09,26.88 L103.66,31.69 C94.93,43.97 91.54,55.17 91.64,71.50 C91.72,84.83 92.69,89.79 99.08,109.50 C105.86,130.41 104.79,139.90 94.39,151.01 C91.83,153.75 89.44,156.00 89.08,156.00 C88.72,156.00 86.03,153.57 83.09,150.59 Z",
|
||||||
|
// Far left small detail
|
||||||
|
"M29.50,109.90 C26.20,107.67 21.64,104.05 19.38,101.84 L15.26,97.83 L17.24,92.67 C19.86,85.83 19.20,74.50 15.57,64.04 C12.16,54.24 10.98,53.26 12.75,61.71 C14.48,69.97 13.94,81.02 11.53,86.50 L9.77,90.50 L6.92,84.50 C2.82,75.84 1.00,67.75 1.00,58.18 C1.00,42.04 6.09,29.69 17.39,18.40 C23.48,12.31 32.07,6.09 30.82,8.67 C30.59,9.13 28.88,12.62 27.02,16.44 C21.43,27.90 22.74,38.16 31.08,48.28 C35.14,53.21 36.55,53.01 33.93,47.87 C31.25,42.61 30.40,34.47 31.90,28.31 C33.17,23.08 42.81,3.00 44.05,3.00 C44.47,3.00 46.18,6.79 47.86,11.42 C58.07,39.63 56.90,53.23 42.67,72.14 C31.96,86.38 29.60,96.21 33.92,108.52 C34.98,111.54 35.77,113.99 35.67,113.97 C35.58,113.96 32.80,112.12 29.50,109.90 Z",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Approximate path lengths for each sub-path
|
||||||
|
const PATH_LENGTHS = [1800, 700, 300];
|
||||||
|
|
||||||
|
// Animation config per sub-path: staggered delays for continuous feel
|
||||||
|
const ANIM_CONFIG = [
|
||||||
|
{ dur: "5s", delay: "0s" },
|
||||||
|
{ dur: "3s", delay: "1.5s" },
|
||||||
|
{ dur: "2s", delay: "3s" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FULL_PATH = PATHS.join(" ");
|
||||||
|
|
||||||
|
// Glitter sparkle positions (x, y) placed within the heart shape
|
||||||
|
const SPARKLES = [
|
||||||
|
{ x: 150, y: 30, delay: 0, dur: 2.4 },
|
||||||
|
{ x: 185, y: 55, delay: 1.1, dur: 2.0 },
|
||||||
|
{ x: 200, y: 85, delay: 0.5, dur: 2.8 },
|
||||||
|
{ x: 170, y: 110, delay: 2.0, dur: 2.2 },
|
||||||
|
{ x: 145, y: 75, delay: 1.6, dur: 2.6 },
|
||||||
|
{ x: 130, y: 50, delay: 0.3, dur: 2.1 },
|
||||||
|
{ x: 160, y: 140, delay: 2.5, dur: 2.4 },
|
||||||
|
{ x: 125, y: 160, delay: 1.8, dur: 2.0 },
|
||||||
|
{ x: 105, y: 100, delay: 0.8, dur: 2.5 },
|
||||||
|
{ x: 90, y: 70, delay: 1.3, dur: 2.3 },
|
||||||
|
{ x: 75, y: 45, delay: 2.2, dur: 2.1 },
|
||||||
|
{ x: 60, y: 80, delay: 0.6, dur: 2.7 },
|
||||||
|
{ x: 50, y: 55, delay: 1.9, dur: 2.0 },
|
||||||
|
{ x: 40, y: 95, delay: 0.2, dur: 2.4 },
|
||||||
|
{ x: 115, y: 130, delay: 1.4, dur: 2.6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function HeroLogo({ className = "", size = 220 }: HeroLogoProps) {
|
||||||
|
const h = Math.round(size * (192 / 234));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 234 192"
|
||||||
|
width={size}
|
||||||
|
height={h}
|
||||||
|
className={className}
|
||||||
|
role="img"
|
||||||
|
aria-label="Black Heart logo"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{/* Dark metal gradient for fill */}
|
||||||
|
<radialGradient id="metal-fill" cx="50%" cy="35%" r="65%" fx="50%" fy="30%">
|
||||||
|
<stop offset="0%" stopColor="#333" />
|
||||||
|
<stop offset="50%" stopColor="#1a1a1a" />
|
||||||
|
<stop offset="100%" stopColor="#111" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
{/* Gold glow filter for the stroke */}
|
||||||
|
<filter id="gold-glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
{/* Sparkle glow filter */}
|
||||||
|
<filter id="sparkle-glow" x="-100%" y="-100%" width="300%" height="300%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
{/* Clip to heart shape so sparkles stay inside */}
|
||||||
|
<clipPath id="heart-clip">
|
||||||
|
<path d={FULL_PATH} fillRule="evenodd" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Base heart: dark metal */}
|
||||||
|
<path
|
||||||
|
fill="url(#metal-fill)"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d={FULL_PATH}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Glitter sparkles on heart surface — odd-indexed hidden on mobile via CSS class */}
|
||||||
|
<g clipPath="url(#heart-clip)" filter="url(#sparkle-glow)">
|
||||||
|
{SPARKLES.map((s, i) => (
|
||||||
|
<circle key={`sparkle-${i}`} cx={s.x} cy={s.y} r="1.8" fill="#d4b87a" className={i % 2 ? "hidden sm:block" : ""}>
|
||||||
|
<animate
|
||||||
|
attributeName="opacity"
|
||||||
|
values="0;0;0.9;1;0.9;0;0"
|
||||||
|
dur={`${s.dur}s`}
|
||||||
|
begin={`${s.delay}s`}
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="r"
|
||||||
|
values="0.8;1.8;0.8"
|
||||||
|
dur={`${s.dur}s`}
|
||||||
|
begin={`${s.delay}s`}
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Animated gold glint — one per sub-path, staggered */}
|
||||||
|
{PATHS.map((d, i) => {
|
||||||
|
const len = PATH_LENGTHS[i];
|
||||||
|
const dashLen = len * 0.15;
|
||||||
|
const gapLen = len * 0.85;
|
||||||
|
const { dur, delay } = ANIM_CONFIG[i];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={i}
|
||||||
|
d={d}
|
||||||
|
fill="none"
|
||||||
|
stroke="#c9a96e"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeOpacity="0.6"
|
||||||
|
strokeDasharray={`${dashLen} ${gapLen}`}
|
||||||
|
filter="url(#gold-glow)"
|
||||||
|
>
|
||||||
|
<animate
|
||||||
|
attributeName="stroke-dashoffset"
|
||||||
|
values={`${len};0`}
|
||||||
|
dur={dur}
|
||||||
|
begin={delay}
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</path>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Constant gold edge highlight */}
|
||||||
|
<path
|
||||||
|
d={FULL_PATH}
|
||||||
|
fill="none"
|
||||||
|
stroke="#c9a96e"
|
||||||
|
strokeWidth="0.75"
|
||||||
|
strokeOpacity="0.3"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/ui/IconBadge.tsx
Normal file
14
src/components/ui/IconBadge.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
interface IconBadgeProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconBadge({ children, className = "" }: IconBadgeProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gold/10 text-gold-dark transition-colors group-hover:bg-gold/15 dark:text-gold-light ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/components/ui/NewsModal.tsx
Normal file
114
src/components/ui/NewsModal.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { X, Calendar, ExternalLink } from "lucide-react";
|
||||||
|
import type { NewsItem } from "@/types/content";
|
||||||
|
|
||||||
|
interface NewsModalProps {
|
||||||
|
item: NewsItem | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("ru-RU", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewsModal({ item, onClose }: NewsModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!item) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [item, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={item.title}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="modal-content relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl border border-white/[0.08] bg-[#0a0a0a] shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/50 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-white/[0.1] hover:text-white cursor-pointer"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{item.image && (
|
||||||
|
<div className="relative aspect-[2/1] w-full overflow-hidden rounded-t-2xl">
|
||||||
|
<Image
|
||||||
|
src={item.image}
|
||||||
|
alt={item.title}
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 768px) 672px, 100vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-[#0a0a0a] via-transparent to-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`p-6 sm:p-8 ${item.image ? "-mt-12 relative" : ""}`}>
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs text-neutral-400">
|
||||||
|
<Calendar size={12} />
|
||||||
|
{formatDate(item.date)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h2 className="mt-2 text-xl sm:text-2xl font-bold text-white leading-tight">
|
||||||
|
{item.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="mt-4 text-sm sm:text-base leading-relaxed text-neutral-300 whitespace-pre-line">
|
||||||
|
{item.text}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{item.link && (
|
||||||
|
<a
|
||||||
|
href={item.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-gold px-5 py-2.5 text-sm font-semibold text-black transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/20"
|
||||||
|
>
|
||||||
|
Подробнее
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
interface SectionHeadingProps {
|
interface SectionHeadingProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
centered?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SectionHeading({ children, className = "" }: SectionHeadingProps) {
|
export function SectionHeading({ children, className = "", centered = false }: SectionHeadingProps) {
|
||||||
return (
|
return (
|
||||||
|
<div className={centered ? "text-center" : ""}>
|
||||||
<h2
|
<h2
|
||||||
className={`font-display text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl ${className}`}
|
className={`font-display text-4xl font-bold uppercase tracking-wide sm:text-5xl lg:text-6xl gradient-text ${className}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<span className="mt-3 block h-[2px] w-16 rounded-full bg-gradient-to-r from-rose-500 to-rose-500/0" />
|
|
||||||
</h2>
|
</h2>
|
||||||
|
<span
|
||||||
|
className={`mt-4 block h-[1px] w-20 bg-gradient-to-r from-gold to-transparent ${
|
||||||
|
centered ? "mx-auto" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
188
src/components/ui/ShowcaseLayout.tsx
Normal file
188
src/components/ui/ShowcaseLayout.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useEffect, useState, useCallback } from "react";
|
||||||
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
|
|
||||||
|
interface ShowcaseLayoutProps<T> {
|
||||||
|
items: T[];
|
||||||
|
activeIndex: number;
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
onHoverChange?: (hovering: boolean) => void;
|
||||||
|
renderDetail: (item: T, index: number) => React.ReactNode;
|
||||||
|
renderSelectorItem: (item: T, index: number, isActive: boolean) => React.ReactNode;
|
||||||
|
counter?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShowcaseLayout<T>({
|
||||||
|
items,
|
||||||
|
activeIndex,
|
||||||
|
onSelect,
|
||||||
|
onHoverChange,
|
||||||
|
renderDetail,
|
||||||
|
renderSelectorItem,
|
||||||
|
counter = false,
|
||||||
|
}: ShowcaseLayoutProps<T>) {
|
||||||
|
const selectorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const activeItemRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const detailRef = useRef<HTMLDivElement>(null);
|
||||||
|
const detailWrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [minHeight, setMinHeight] = useState<number | undefined>(undefined);
|
||||||
|
const measuredHeights = useRef<number[]>([]);
|
||||||
|
const [isUserInteracting, setIsUserInteracting] = useState(false);
|
||||||
|
const [displayIndex, setDisplayIndex] = useState(activeIndex);
|
||||||
|
const [fading, setFading] = useState(false);
|
||||||
|
|
||||||
|
// Track max height across all seen items to prevent shrinking
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detailRef.current || fading) return;
|
||||||
|
const h = detailRef.current.offsetHeight;
|
||||||
|
measuredHeights.current[displayIndex] = h;
|
||||||
|
const maxH = Math.max(...measuredHeights.current.filter(Boolean));
|
||||||
|
if (maxH > (minHeight ?? 0)) {
|
||||||
|
setMinHeight(maxH);
|
||||||
|
}
|
||||||
|
}, [displayIndex, fading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeIndex === displayIndex) return;
|
||||||
|
setFading(true);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setDisplayIndex(activeIndex);
|
||||||
|
setFading(false);
|
||||||
|
}, UI_CONFIG.showcase.fadeMs);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [activeIndex, displayIndex]);
|
||||||
|
|
||||||
|
// Auto-scroll selector only when item is out of view
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUserInteracting) return;
|
||||||
|
|
||||||
|
const container = selectorRef.current;
|
||||||
|
const activeEl = activeItemRef.current;
|
||||||
|
if (!container || !activeEl) return;
|
||||||
|
|
||||||
|
const isHorizontal = window.innerWidth < 1024;
|
||||||
|
|
||||||
|
if (isHorizontal) {
|
||||||
|
const elLeft = activeEl.offsetLeft;
|
||||||
|
const elRight = elLeft + activeEl.offsetWidth;
|
||||||
|
const scrollLeft = container.scrollLeft;
|
||||||
|
const viewRight = scrollLeft + container.offsetWidth;
|
||||||
|
|
||||||
|
if (elLeft < scrollLeft || elRight > viewRight) {
|
||||||
|
const left = elLeft - container.offsetWidth / 2 + activeEl.offsetWidth / 2;
|
||||||
|
container.scrollTo({ left, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const elTop = activeEl.offsetTop;
|
||||||
|
const elBottom = elTop + activeEl.offsetHeight;
|
||||||
|
const scrollTop = container.scrollTop;
|
||||||
|
const viewBottom = scrollTop + container.offsetHeight;
|
||||||
|
|
||||||
|
if (elTop < scrollTop || elBottom > viewBottom) {
|
||||||
|
const top = elTop - container.offsetHeight / 2 + activeEl.offsetHeight / 2;
|
||||||
|
container.scrollTo({ top, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeIndex, isUserInteracting]);
|
||||||
|
|
||||||
|
// Swipe support on detail area
|
||||||
|
const touchStart = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(
|
||||||
|
(e: React.TouchEvent) => {
|
||||||
|
if (!touchStart.current) return;
|
||||||
|
const dx = e.changedTouches[0].clientX - touchStart.current.x;
|
||||||
|
const dy = e.changedTouches[0].clientY - touchStart.current.y;
|
||||||
|
touchStart.current = null;
|
||||||
|
|
||||||
|
if (Math.abs(dx) > UI_CONFIG.showcase.swipeThreshold && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
||||||
|
if (dx < 0 && activeIndex < items.length - 1) {
|
||||||
|
onSelect(activeIndex + 1);
|
||||||
|
} else if (dx > 0 && activeIndex > 0) {
|
||||||
|
onSelect(activeIndex - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeIndex, items.length, onSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleMouseEnter() {
|
||||||
|
setIsUserInteracting(true);
|
||||||
|
onHoverChange?.(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
setIsUserInteracting(false);
|
||||||
|
onHoverChange?.(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col-reverse gap-6 lg:flex-row lg:gap-8"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{/* Detail area */}
|
||||||
|
<div className="lg:w-[60%]">
|
||||||
|
<div
|
||||||
|
ref={detailWrapRef}
|
||||||
|
style={minHeight != null ? { minHeight } : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={detailRef}
|
||||||
|
className={`transition-all duration-300 ease-out ${
|
||||||
|
fading
|
||||||
|
? "opacity-0 translate-y-2"
|
||||||
|
: "opacity-100 translate-y-0"
|
||||||
|
}`}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
{renderDetail(items[displayIndex], displayIndex)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Counter */}
|
||||||
|
{counter && (
|
||||||
|
<div className="mt-3 flex items-center justify-center gap-2 lg:justify-start">
|
||||||
|
<span className="text-xs font-medium tabular-nums text-gold">
|
||||||
|
{String(activeIndex + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span className="h-[1px] w-8 bg-white/10" />
|
||||||
|
<span className="text-xs tabular-nums text-neutral-500">
|
||||||
|
{String(items.length).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selector */}
|
||||||
|
<div className="lg:w-[40%]">
|
||||||
|
<div
|
||||||
|
ref={selectorRef}
|
||||||
|
className="grid grid-cols-2 gap-2 lg:grid-cols-1 lg:gap-3 lg:max-h-[600px] lg:overflow-y-auto lg:pr-1 styled-scrollbar"
|
||||||
|
>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
ref={i === activeIndex ? activeItemRef : null}
|
||||||
|
onClick={() => onSelect(i)}
|
||||||
|
className={`cursor-pointer rounded-xl border-2 text-left transition-all duration-300 ${
|
||||||
|
i === activeIndex
|
||||||
|
? "border-gold/60 bg-gold/10 dark:bg-gold/5"
|
||||||
|
: "border-transparent bg-neutral-100 hover:bg-neutral-200 dark:bg-white/[0.03] dark:hover:bg-white/[0.06]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{renderSelectorItem(item, i, i === activeIndex)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
src/components/ui/SignupModal.tsx
Normal file
265
src/components/ui/SignupModal.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { X, CheckCircle, Send, Phone as PhoneIcon, Instagram } from "lucide-react";
|
||||||
|
import { BRAND } from "@/lib/constants";
|
||||||
|
|
||||||
|
interface SignupModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
/** API endpoint to POST to */
|
||||||
|
endpoint: string;
|
||||||
|
/** Extra fields merged into the POST body (e.g. masterClassTitle, classId, eventId, groupInfo) */
|
||||||
|
extraBody?: Record<string, unknown>;
|
||||||
|
/** Custom success message */
|
||||||
|
successMessage?: string;
|
||||||
|
/** Callback with API response data on success */
|
||||||
|
onSuccess?: (data: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignupModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title = "Записаться",
|
||||||
|
subtitle,
|
||||||
|
endpoint,
|
||||||
|
extraBody,
|
||||||
|
successMessage,
|
||||||
|
onSuccess,
|
||||||
|
}: SignupModalProps) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [phone, setPhone] = useState("+375 ");
|
||||||
|
const [instagram, setInstagram] = useState("");
|
||||||
|
const [telegram, setTelegram] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [successData, setSuccessData] = useState<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
|
function handlePhoneChange(raw: string) {
|
||||||
|
let digits = raw.replace(/\D/g, "");
|
||||||
|
if (!digits.startsWith("375")) {
|
||||||
|
digits = "375" + digits.replace(/^375?/, "");
|
||||||
|
}
|
||||||
|
digits = digits.slice(0, 12);
|
||||||
|
let formatted = "+375";
|
||||||
|
const rest = digits.slice(3);
|
||||||
|
if (rest.length > 0) formatted += " (" + rest.slice(0, 2);
|
||||||
|
if (rest.length >= 2) formatted += ") ";
|
||||||
|
if (rest.length > 2) formatted += rest.slice(2, 5);
|
||||||
|
if (rest.length > 5) formatted += "-" + rest.slice(5, 7);
|
||||||
|
if (rest.length > 7) formatted += "-" + rest.slice(7, 9);
|
||||||
|
setPhone(formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) document.body.style.overflow = "hidden";
|
||||||
|
else document.body.style.overflow = "";
|
||||||
|
return () => { document.body.style.overflow = ""; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
const cleanPhone = phone.replace(/\D/g, "");
|
||||||
|
if (cleanPhone.length < 12) {
|
||||||
|
setError("Введите корректный номер телефона");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: name.trim(),
|
||||||
|
phone: cleanPhone,
|
||||||
|
...extraBody,
|
||||||
|
};
|
||||||
|
if (instagram.trim()) body.instagram = `@${instagram.trim()}`;
|
||||||
|
if (telegram.trim()) body.telegram = `@${telegram.trim()}`;
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || "Ошибка при записи");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSuccess(true);
|
||||||
|
setSuccessData(data);
|
||||||
|
onSuccess?.(data);
|
||||||
|
} catch {
|
||||||
|
setError("network");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [name, phone, instagram, telegram, endpoint, extraBody, onSuccess]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
onClose();
|
||||||
|
setTimeout(() => {
|
||||||
|
setName("");
|
||||||
|
setPhone("+375 ");
|
||||||
|
setInstagram("");
|
||||||
|
setTelegram("");
|
||||||
|
setError("");
|
||||||
|
setSuccess(false);
|
||||||
|
setSuccessData(null);
|
||||||
|
}, 300);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
function openInstagramDM() {
|
||||||
|
const text = `Здравствуйте! Меня зовут ${name}. Хочу записаться${subtitle ? ` (${subtitle})` : ""}. Мой телефон: ${phone}`;
|
||||||
|
window.open(`https://ig.me/m/blackheartdancehouse?text=${encodeURIComponent(text)}`, "_blank");
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-label={title} onClick={handleClose}>
|
||||||
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
|
<div
|
||||||
|
className="modal-content relative w-full max-w-md rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 sm:p-8 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-white cursor-pointer"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{success ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10">
|
||||||
|
<CheckCircle size={28} className="text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-white">
|
||||||
|
{successMessage || "Вы записаны!"}
|
||||||
|
</h3>
|
||||||
|
{subtitle && <p className="mt-1 text-sm text-neutral-400">{subtitle}</p>}
|
||||||
|
{successData?.totalBookings !== undefined && (
|
||||||
|
<p className="mt-3 text-sm text-white">
|
||||||
|
Вы записаны на <span className="text-gold font-semibold">{String(successData.totalBookings)}</span> занятий.
|
||||||
|
<br />
|
||||||
|
Стоимость: <span className="text-gold font-semibold">{String(successData.pricePerClass)} BYN</span> за занятие
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="mt-6 rounded-full bg-gold px-6 py-2.5 text-sm font-semibold text-black transition-all hover:bg-gold-light cursor-pointer"
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : error === "network" ? (
|
||||||
|
/* Network error — fallback to Instagram DM */
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-amber-500/10">
|
||||||
|
<Instagram size={28} className="text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-white">Что-то пошло не так</h3>
|
||||||
|
<p className="mt-2 text-sm text-neutral-400">
|
||||||
|
Не удалось отправить заявку. Свяжитесь с нами через Instagram — мы запишем вас!
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={openInstagramDM}
|
||||||
|
className="mt-5 flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 to-pink-500 py-3 text-sm font-semibold text-white transition-all hover:opacity-90 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Instagram size={16} />
|
||||||
|
Написать в Instagram
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setError("")}
|
||||||
|
className="mt-2 text-xs text-neutral-500 hover:text-white transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-xl font-bold text-white">{title}</h3>
|
||||||
|
{subtitle && <p className="mt-1 text-sm text-neutral-400">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Ваше имя"
|
||||||
|
required
|
||||||
|
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<PhoneIcon size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => handlePhoneChange(e.target.value)}
|
||||||
|
placeholder="+375 (__) ___-__-__"
|
||||||
|
required
|
||||||
|
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-9 pr-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={instagram}
|
||||||
|
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
|
||||||
|
placeholder="Instagram"
|
||||||
|
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-7 pr-3 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={telegram}
|
||||||
|
onChange={(e) => setTelegram(e.target.value.replace(/^@/, ""))}
|
||||||
|
placeholder="Telegram"
|
||||||
|
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-7 pr-3 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && error !== "network" && (
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gold py-3 text-sm font-semibold text-black transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/20 cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Send size={15} />
|
||||||
|
{submitting ? "Записываем..." : "Записаться"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Instagram } from "lucide-react";
|
|
||||||
|
|
||||||
interface SocialLinksProps {
|
|
||||||
instagram?: string;
|
|
||||||
instagramHandle?: string;
|
|
||||||
className?: string;
|
|
||||||
iconSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SocialLinks({
|
|
||||||
instagram,
|
|
||||||
instagramHandle,
|
|
||||||
className = "",
|
|
||||||
iconSize = 24,
|
|
||||||
}: SocialLinksProps) {
|
|
||||||
return (
|
|
||||||
<div className={`flex items-center gap-4 ${className}`}>
|
|
||||||
{instagram && (
|
|
||||||
<a
|
|
||||||
href={instagram}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="social-icon flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Instagram size={iconSize} />
|
|
||||||
{instagramHandle && <span className="text-sm font-medium">{instagramHandle}</span>}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { X, Instagram } from "lucide-react";
|
|
||||||
import type { TeamMember } from "@/types";
|
|
||||||
|
|
||||||
interface TeamMemberModalProps {
|
|
||||||
member: TeamMember | null;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TeamMemberModal({ member, onClose }: TeamMemberModalProps) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (!member) return;
|
|
||||||
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [member, onClose]);
|
|
||||||
|
|
||||||
if (!member) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="modal-overlay fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-lg sm:items-center sm:p-4"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="modal-content relative flex w-full max-h-[90vh] flex-col overflow-hidden rounded-t-3xl bg-white sm:max-w-lg sm:rounded-3xl dark:bg-[#111]"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Hero photo */}
|
|
||||||
<div className="relative h-72 w-full shrink-0 sm:h-80">
|
|
||||||
<Image
|
|
||||||
src={member.image}
|
|
||||||
alt={member.name}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
{/* Gradient overlay */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
|
||||||
|
|
||||||
{/* Close button */}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute right-4 top-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-sm transition-all hover:bg-black/60 hover:text-white"
|
|
||||||
aria-label="Закрыть"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Name + Instagram on photo */}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-6">
|
|
||||||
<h3 className="text-2xl font-bold text-white">
|
|
||||||
{member.name}
|
|
||||||
</h3>
|
|
||||||
{member.instagram && (
|
|
||||||
<a
|
|
||||||
href={member.instagram}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="mt-2 inline-flex items-center gap-2 text-sm text-white/70 transition-colors hover:text-rose-400"
|
|
||||||
>
|
|
||||||
<Instagram size={15} className="shrink-0" />
|
|
||||||
<span>{member.instagram.split("/").filter(Boolean).pop()}</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{member.description && (
|
|
||||||
<div className="overflow-y-auto p-6">
|
|
||||||
<p className="text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
|
|
||||||
{member.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,8 +8,7 @@ export function ThemeToggle() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = localStorage.getItem("theme");
|
const stored = localStorage.getItem("theme");
|
||||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
const isDark = stored !== "light";
|
||||||
const isDark = stored === "dark" || (!stored && prefersDark);
|
|
||||||
setDark(isDark);
|
setDark(isDark);
|
||||||
document.documentElement.classList.toggle("dark", isDark);
|
document.documentElement.classList.toggle("dark", isDark);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -25,9 +24,9 @@ export function ThemeToggle() {
|
|||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-label="Переключить тему"
|
aria-label="Переключить тему"
|
||||||
className="social-icon rounded-full p-2"
|
className="rounded-full p-2 text-neutral-400 transition-all duration-300 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-500 dark:hover:bg-white/[0.05] dark:hover:text-white"
|
||||||
>
|
>
|
||||||
{dark ? <Sun size={20} /> : <Moon size={20} />}
|
{dark ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export const siteContent: SiteContent = {
|
|||||||
title: "Настоящие профи!",
|
title: "Настоящие профи!",
|
||||||
members: [
|
members: [
|
||||||
{
|
{
|
||||||
name: "Виктор Артемов",
|
name: "Виктор Артёмов",
|
||||||
role: "Тренер",
|
role: "Pole Fitness · Exotic · Strip",
|
||||||
image: "/images/team/viktor-artyomov.webp",
|
image: "/images/team/viktor-artyomov.webp",
|
||||||
instagram: "https://instagram.com/viktor.artyomov/",
|
instagram: "https://instagram.com/viktor.artyomov/",
|
||||||
description:
|
description:
|
||||||
@@ -33,103 +33,123 @@ export const siteContent: SiteContent = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Анна Тарыба",
|
name: "Анна Тарыба",
|
||||||
role: "Тренер",
|
role: "Exotic Pole Dance",
|
||||||
image: "/images/team/anna-taryba.webp",
|
image: "/images/team/anna-taryba.webp",
|
||||||
instagram: "https://instagram.com/annataryba/",
|
instagram: "https://instagram.com/annataryba/",
|
||||||
description:
|
description:
|
||||||
"Я смогла в кратчайшие сроки достичь высочайших вершин в Exotic Pole Dance. Многократная призёрка чемпионатов в различных категориях. Основала свою команду ExoTeAM, где готовлю учениц к выходу на сцену. Люблю создавать хореографии в разных жанрах — от ярких и сложных до выразительных и плавных. Веду учеников от начального уровня до выступлений и медалей. Помогу освоить любые элементы и достичь идеальных линий!",
|
"Мощь и сила в каждой связке. Мои акцентные хореографии созданы для продвинутого уровня, где вы сможете раскрыть свой потенциал и почувствовать себя настоящей королевой танца. Готовьтесь к интенсивному погружению в мир уверенных движений и сложных элементов, где каждое занятие — это новый вызов и триумф!",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Анастасия Чалей",
|
name: "Анастасия Чалей",
|
||||||
role: "Тренер",
|
role: "Exotic Pole Dance",
|
||||||
image: "/images/team/anastasia-chaley.webp",
|
image: "/images/team/anastasia-chaley.webp",
|
||||||
instagram: "https://instagram.com/nastya_chaley/",
|
instagram: "https://instagram.com/nastya_chaley/",
|
||||||
description:
|
description:
|
||||||
"Я тренер-хореограф по Exotic Pole Dance и Strip. Танцевала абсолютно разные стили — хип-хоп, джаз-фанк, вог, хаус, поппинг, крамп, дэнсхолл, тверк — поэтому мои хореографии не похожи одна на другую. Люблю как яркие и акцентные танцы, так и плавные и тягучие. Со мной вы сможете насладиться всеми сторонами своей личности. Призёрка множества чемпионатов. Приходите на занятия — танцы это радость!",
|
"Вас ждут креативные хореографии, акцент на музыкальность и подачу, развитие уверенности и раскрытие вашей индивидуальности. Присоединяйтесь к тренировкам, где царит атмосфера радости и танцевального вдохновения! Мой вайб — «танцы — это радость».",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Ольга Демидова",
|
name: "Ольга Демидова",
|
||||||
role: "Тренер",
|
role: "Pole Dance",
|
||||||
image: "/images/team/olga-demidova.webp",
|
image: "/images/team/olga-demidova.webp",
|
||||||
instagram: "https://instagram.com/don_olga_red/",
|
instagram: "https://instagram.com/don_olga_red/",
|
||||||
description:
|
description:
|
||||||
"Я начала заниматься Pole Dance 5 лет назад с нуля. За это время участвовала и становилась призёром чемпионатов по Pole Art, Pole Sport и Exotic Pole Dance. У меня крепкая трюковая база, я знаю, как повысить гибкость, и всему этому смогу научить вас на своих занятиях. Люблю свою работу и жду вас на тренировках!",
|
"Я вдохновляющий лидер, который открывает двери в мир удивительного Pole Dance. С каждым занятием помогаю своим ученикам преодолевать собственные границы и достигать результатов, которые казались недостижимыми.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Галина Савицкая",
|
name: "Ирина Третьякович",
|
||||||
role: "Тренер",
|
role: "Exotic Pole Dance",
|
||||||
image: "/images/team/galina-savitskaya.webp",
|
|
||||||
description:
|
|
||||||
"Безумно люблю растяжку и помогу полюбить её и вам! Использую упражнения ЛФК и точечные лайфхаки для удобных положений при растяжке ног, спины и плеч. Научу тянуться в паре и чувствовать безопасное расслабление и напряжение. 10 лет занимаюсь растяжкой в условиях пилонного спорта и танца.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Ирина Третьюкович",
|
|
||||||
role: "Тренер",
|
|
||||||
image: "/images/team/irina-tretyukovich.webp",
|
image: "/images/team/irina-tretyukovich.webp",
|
||||||
instagram: "https://instagram.com/irkatretya/",
|
instagram: "https://instagram.com/irkatretya/",
|
||||||
description:
|
description:
|
||||||
"Я тренер по Exotic Pole Dance. За короткий период смогла выйти на профессиональный уровень и поучаствовать во многих чемпионатах, в том числе международных — конечно же, не без призовых мест! Моя сильная сторона — трюковые комбинации на пилоне и их использование в танцевальных связках. Если вам нужны сильные руки, красивое подтянутое тело, музыкальность и пластичность — буду ждать на своих тренировках!",
|
"Вас ждёт калейдоскоп эмоций: от сексуальной связки до нежной лирики и даже мистического драйва. Мои хореографии всегда энергичны и непредсказуемы, пробуждают самые смелые ваши стороны. Приготовьтесь к скоростному погружению в мир танца, где каждое движение — это вызов и откровение!",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Надежда Сыч",
|
name: "Надежда Сыч",
|
||||||
role: "Тренер",
|
role: "Exotic Pole Dance · Body Plastic",
|
||||||
image: "/images/team/nadezhda-sukh.webp",
|
image: "/images/team/nadezhda-sukh.webp",
|
||||||
instagram: "https://instagram.com/nadja.dance/",
|
instagram: "https://instagram.com/nadja.dance/",
|
||||||
description:
|
description:
|
||||||
"Я обучаю партерной акробатике, балансам, трюковому пилону и сексуальным танцам. Занятия у меня — это волшебное путешествие, где вы научитесь основам акробатических элементов, разовьёте навыки в балансах и стойках, а флаги и трюковые комбинации с пилоном не будут казаться чем-то недостижимым. Вы раскроете свою индивидуальность через чувственные танцы, наполненные грацией и пластикой. Присоединяйтесь и окунитесь в мир, где танец становится искусством!",
|
"Со мной вы научитесь кайфовать от себя и раскрывать свою сексуальность. Помогу развить силу, баланс и пластику, а главное — почувствовать себя желанной и привлекательной.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Ирина Карпусь",
|
name: "Ирина Карпусь",
|
||||||
role: "Тренер",
|
role: "Exotic Pole Dance",
|
||||||
image: "/images/team/irina-karpus.webp",
|
image: "/images/team/irina-karpus.webp",
|
||||||
instagram: "https://instagram.com/karpus_iri/",
|
instagram: "https://instagram.com/karpus_iri/",
|
||||||
description:
|
description:
|
||||||
"Я пришла в Exotic Pole Dance относительно недавно и полюбила его навсегда. В танце люблю и стремлюсь к красивым линиям и элегантности, но никогда не забываю про силовую часть и трюки. На занятиях стараюсь найти к каждому индивидуальный подход, чтобы тренировка была комфортной и продуктивной. Помогу раскрыть ваши сильные стороны и полюбить танец. Буду ждать вас на занятиях!",
|
"Я проводник в мир чувственного Exotic Pole Dance. Мои хореографии проникают в самое сердце, а занятия — идеальный старт для тех, кто хочет раскрыть свою женственность и уверенность в себе.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Юлия Книга",
|
name: "Юлия Книга",
|
||||||
role: "Тренер",
|
role: "Erotic Pole Dance",
|
||||||
image: "/images/team/yuliya-kniga.webp",
|
image: "/images/team/yuliya-kniga.webp",
|
||||||
instagram: "https://instagram.com/knigynzel/",
|
instagram: "https://instagram.com/knigynzel/",
|
||||||
description:
|
description:
|
||||||
"Я тренер по Exotic Pole Dance. В прошлом была танцовщицей эротического жанра, откуда и пошла моя любовь к танцам. Я точно знаю все техники раскрепощения, научу тебя быть плавной, музыкальной и сексуальной. Мои хореографии могут быть как быстрыми и динамичными с трюковыми элементами, так и медленными, томными и манящими. Помогу раскрыть тебя как танцора со всех сторон!",
|
"Я не просто инструктор, я настоящий вдохновитель и проводник в мир Erotic Pole Dance. Мои тренировки — это не просто набор упражнений, это целое искусство, в котором каждая из вас чувствует себя особенной и ценной.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Алена Чигилейчик",
|
name: "Алёна Чигилейчик",
|
||||||
role: "Тренер",
|
role: "Exotic Pole Dance",
|
||||||
image: "/images/team/elena-chigileychik.webp",
|
image: "/images/team/elena-chigileychik.webp",
|
||||||
instagram: "https://instagram.com/alenachygi/",
|
instagram: "https://instagram.com/alenachygi/",
|
||||||
description:
|
description:
|
||||||
"За несколько лет я смогла самостоятельно обучиться Exotic Pole Dance и занять 3 место в категории профи. Имею отличную спортивную базу. Танцую в основном flow, но всегда ищу новое и меняю стили хореографий. Обожаю эмоциональную подачу и точность в движениях, ощущение каждого сантиметра тела и то, как музыка позволяет раскрываться в танце. Научу чувствовать себя с музыкой одним целым!",
|
"Создаю атмосферу, где каждая деталь имеет значение. Мои занятия — это разнообразие стилей, где внимание уделяется каждому движению, а дружелюбная атмосфера помогает раскрыться и почувствовать себя уверенно.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Елена Тарасевич",
|
name: "Елена Тарасевич",
|
||||||
role: "Тренер",
|
role: "Body Plastic",
|
||||||
image: "/images/team/elena-tarasevic.webp",
|
image: "/images/team/elena-tarasevic.webp",
|
||||||
instagram: "https://instagram.com/cerceia/",
|
instagram: "https://instagram.com/cerceia/",
|
||||||
description:
|
description:
|
||||||
"Я воздушный гимнаст, практик акройоги и тренер по стретчингу и Airyoga. В спорте и танцах более 15 лет, стаж тренера — около 9 лет. Многократный призёр соревнований по воздушно-спортивному эквилибру России, стран СНГ и международных фестивалей. Прошла обучение у чемпионки мира по воздушной гимнастике, цирковых акробатов, балерин и художественных гимнастов. За плечами более 30 семинаров по функциональной анатомии, биомеханике и йогатерапии.",
|
"Ваш ключ к здоровому, гибкому и гармоничному телу. Знаю каждую связку, каждую клеточку вашего тела. Чувствую ваши ограничения, предугадываю ваши возможности и бережно веду вас к границам вашей гибкости.",
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Ольга Грабовец",
|
|
||||||
role: "Тренер",
|
|
||||||
image: "/images/team/olga-grabovets.webp",
|
|
||||||
instagram: "https://instagram.com/lo_woolf/",
|
|
||||||
description:
|
|
||||||
"Я амбассадор красивых линий и натянутых стоп! За 1,5 года выросла от новичка до тренера по Exotic Pole Dance. Многократный призёр чемпионатов. Для меня в танце очень важна музыкальность, и я стараюсь это почерпнуть у разных педагогов — не только наших, но и зарубежных.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Кристина Войтович",
|
name: "Кристина Войтович",
|
||||||
role: "Тренер",
|
role: "Exotic Pole Dance",
|
||||||
image: "/images/team/kristina-voytovich.webp",
|
image: "/images/team/kristina-voytovich.webp",
|
||||||
instagram: "https://instagram.com/chris_voytovich/",
|
instagram: "https://instagram.com/chris_voytovich/",
|
||||||
description:
|
description:
|
||||||
"Я всегда мечтала заниматься Exotic Pole Dance и смогла не только осуществить свою мечту, но и стать тренером! Постоянно совершенствую навыки, посещаю интенсивы и мастер-классы, регулярно участвую в соревнованиях. Мой стиль преподавания объединяет элементы танца, стретчинга, акробатики и силовых упражнений. Стараюсь создать комфортную атмосферу, чтобы каждая ученица могла наслаждаться процессом обучения!",
|
"В моих танцах кипит безумная смесь силы и чувственности. Обожаю переключаться между разными хореографиями: чувственными, дерзкими, меланхоличными, сексуальными... Каждая из них — это взрыв эмоций.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Екатерина Матлахова",
|
||||||
|
role: "Exotic · Pole Dance",
|
||||||
|
image: "/images/team/ekaterina-matlakhova.webp",
|
||||||
|
description:
|
||||||
|
"Создаю чувственные хореографии, где женственность расцветает в сексуальных движениях, изящных линиях и плавных переходах, подкреплённых эстетичными силовыми элементами. В моих танцах рождаются богини!",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Лилия Огурцова",
|
||||||
|
role: "Exotic · Pole Dance",
|
||||||
|
image: "/images/team/liliya-ogurtsova.webp",
|
||||||
|
description:
|
||||||
|
"Я проведу вас в мир акцентных и чарующих хореографий. Мои занятия наполнены мистическим вайбом, драйвом и энергией. Уделяю особое внимание развитию силы, прокачке тела и чистоте движений, а также эмоциональной подаче в танце.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Наталья Анцух",
|
||||||
|
role: "Exotic Pole Dance",
|
||||||
|
image: "/images/team/natalya-antsukh.webp",
|
||||||
|
description:
|
||||||
|
"Каждое занятие — это праздник для тела и души, где стиль, грация и внутренняя сила объединяются воедино. Новичок или профессионал — я научу вас танцевать с уверенностью, раскрывать свою женственность и получать удовольствие от каждого движения.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Яна Артюкевич",
|
||||||
|
role: "Pole Dance",
|
||||||
|
image: "/images/team/yana-artyukevich.webp",
|
||||||
|
description:
|
||||||
|
"На моих занятиях вы научитесь красиво и уверенно владеть своим телом, освоите базовые трюки и элементы на пилоне — шаг за шагом, в уютной и вдохновляющей атмосфере. Укрепим мышцы, улучшим растяжку и осанку, а в процессе — почувствуете невероятную уверенность, сексуальность и внутреннюю силу.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Анжела Бобко",
|
||||||
|
role: "Pole Dance",
|
||||||
|
image: "/images/team/anzhela-bobko.webp",
|
||||||
|
description:
|
||||||
|
"Мой индивидуальный подход и внимательное отношение к каждому ученику создают атмосферу доверия и поддержки. Со мной вы не просто осваиваете технику — вы преодолеваете себя и становитесь лучшей версией себя.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
classes: {
|
classes: {
|
||||||
title: "Скорее, мы ждём!",
|
title: "Направления",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
name: "Exotic Pole Dance",
|
name: "Exotic Pole Dance",
|
||||||
@@ -137,34 +157,34 @@ export const siteContent: SiteContent = {
|
|||||||
"Чувственная хореография с элементами pole dance в каблуках.",
|
"Чувственная хореография с элементами pole dance в каблуках.",
|
||||||
icon: "sparkles",
|
icon: "sparkles",
|
||||||
detailedDescription:
|
detailedDescription:
|
||||||
"Чувственный, эстетичный, сексуальный вид танца. Он богат на плавные линии, манящие прогибы и развитие вашей женственности.\n\nВы получаете:\n— уверенность в себе,\n— красивую фигуру и развитие всех групп мышц,\n— раскрытие себя с новой стороны и возможность влюбиться заново,\n— вы учитесь наслаждаться собой.",
|
"Стиль танца на пилоне, где акцент делается на чувственность, пластику. В Exotic Pole Dance используется обувь на высоких каблуках (стрипы), развивающий гибкость, силу, женственность и уверенность.\n\nВы получаете:\n— уверенность в себе,\n— красивую фигуру и развитие всех групп мышц,\n— раскрытие себя с новой стороны,\n— вы учитесь наслаждаться собой.",
|
||||||
images: ["/images/classes/exot.webp", "/images/classes/exot-w.webp"],
|
images: ["/images/classes/exot.webp", "/images/classes/exot-w.webp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Pole Dance",
|
name: "Pole Dance",
|
||||||
description:
|
description:
|
||||||
"Сила, грация и пластика на пилоне. Для любого уровня подготовки.",
|
"Искусство на пилоне: акробатические трюки, силовые элементы и грация.",
|
||||||
icon: "flame",
|
icon: "flame",
|
||||||
detailedDescription:
|
detailedDescription:
|
||||||
"Пилон — это отличный тренажер для рук, ног, спины и пресса. Pole Dance учит красиво двигаться, улучшает растяжку, силовые показатели и выдержку.\n\nВы получите:\n— силу и грацию,\n— прекрасную растяжку,\n— правильную осанку,\n— прекрасное настроение.",
|
"Вид искусства на пилоне, включающий акробатические трюки, силовые элементы и грациозные движения. Подходит для развития силы, выносливости и уровня технического мастерства.\n\nВы получите:\n— силу и грацию,\n— прекрасную растяжку,\n— правильную осанку,\n— прекрасное настроение.",
|
||||||
images: ["/images/classes/pole-dance.webp"],
|
images: ["/images/classes/pole-dance.webp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Body Plastic",
|
name: "Body Plastic",
|
||||||
description:
|
description:
|
||||||
"Танцевальное направление, раскрывающее женственность и пластику тела.",
|
"Пластичность, гибкость и осознанность тела в каждом движении.",
|
||||||
icon: "wind",
|
icon: "wind",
|
||||||
detailedDescription:
|
detailedDescription:
|
||||||
"Растяжка — это искусство, которое позволяет вам не только улучшить гибкость, но и раскрыть истинную красоту вашего тела. Это больше, чем просто упражнения — это плавные движения, которые учат вас слушать своё тело и чувствовать его.\n\nЗанимаясь растяжкой, вы получите:\n— уверенность в себе,\n— красивую осанку и гибкость,\n— улучшение общего тонуса тела и расслабление мышц,\n— возможность открыть новые грани своей чувственности и женственности,\n— умение наслаждаться каждым движением и моментом.\n\nРастяжка помогает вам не только достигнуть физического совершенства, но и найти внутреннюю гармонию и любовь к себе.",
|
"Тренировка, направленная на пластичность, гибкость и осознанность всего тела, помогает лучше управлять своим движением. Body Plastic объединяет растяжку, силу, контроль и пластичность, что помогает развивать тело гармонично и быстро.\n\nВместо односторонней растяжки он учит не только растягиваться, но и сохранять баланс, управлять каждым движением, что особенно важно для pole dance, акробатики и других тренировок.",
|
||||||
images: ["/images/classes/body-plastic.webp"],
|
images: ["/images/classes/body-plastic.webp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Партерная акробатика",
|
name: "Трюковые комбинации с пилоном",
|
||||||
description:
|
description:
|
||||||
"Акробатические элементы в партере для развития силы и гибкости.",
|
"Яркие трюки, акробатические элементы и впечатляющие комбинации.",
|
||||||
icon: "zap",
|
icon: "zap",
|
||||||
detailedDescription:
|
detailedDescription:
|
||||||
"Партерная акробатика — это завораживающее сочетание силы, гибкости и грации, которое раскрывает безграничные возможности вашего тела. Этот вид искусства позволяет вам воплотить в жизнь самые смелые акробатические элементы, создавая уникальные и впечатляющие комбинации на полу.\n\nЗанимаясь партерной акробатикой, вы получите:\n— невероятную физическую силу и выносливость,\n— улучшение координации и равновесия,\n— развитие всех групп мышц и повышение гибкости,\n— возможность выразить себя через мощные и динамичные движения,\n— уверенность в своих возможностях и преодоление собственных границ.\n\nПартерная акробатика — это путь к совершенству тела и духа, который дарит ощущение полёта и свободы на земле.",
|
"Направление с акцентом на выполнение трюков, акробатических элементов и их комбинаций. Идеально подходит для тех, кто хочет освоить яркие, эффектные трюки и создать впечатляющие комбинации для выступлений и личного развития.",
|
||||||
images: ["/images/classes/parter-1.webp", "/images/classes/parter-2.webp"],
|
images: ["/images/classes/parter-1.webp", "/images/classes/parter-2.webp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -173,7 +193,7 @@ export const siteContent: SiteContent = {
|
|||||||
"Уникальные занятия с приглашёнными топовыми тренерами.",
|
"Уникальные занятия с приглашёнными топовыми тренерами.",
|
||||||
icon: "star",
|
icon: "star",
|
||||||
detailedDescription:
|
detailedDescription:
|
||||||
"Мастер-классы — это уникальная возможность погрузиться в чувственный мир танца, где каждое движение наполнено грацией и страстью. Наши мастер-классы созданы для тех, кто хочет открыть в себе новые грани женственности и научиться выражать свои эмоции через танец.\n\nПриходя на наши мастер-классы, вы получите:\n— уверенность в себе и своих возможностях,\n— возможность раскрыть свою чувственность и сексуальность,\n— умение наслаждаться каждым моментом и каждым движением,\n— опыт от профессиональных тренеров, которые помогут вам достичь новых высот.\n\nНаши мастер-классы — это не просто тренировки, это путь к самопознанию и любви к своему телу. Присоединяйтесь к нам и откройте для себя мир танца, где каждый шаг приносит удовольствие и уверенность.",
|
"Мастер-классы — это уникальная возможность погрузиться в чувственный мир танца, где каждое движение наполнено грацией и страстью. Наши мастер-классы созданы для тех, кто хочет открыть в себе новые грани женственности и научиться выражать свои эмоции через танец.\n\nПриходя на наши мастер-классы, вы получите:\n— уверенность в себе и своих возможностях,\n— возможность раскрыть свою чувственность и сексуальность,\n— умение наслаждаться каждым моментом и каждым движением,\n— опыт от профессиональных тренеров.",
|
||||||
images: ["/images/classes/master-class-1.webp", "/images/classes/master-class-2.webp", "/images/classes/master-class-3.webp"],
|
images: ["/images/classes/master-class-1.webp", "/images/classes/master-class-2.webp", "/images/classes/master-class-3.webp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -181,16 +201,256 @@ export const siteContent: SiteContent = {
|
|||||||
description: "Тренировки в удобное время из любой точки мира.",
|
description: "Тренировки в удобное время из любой точки мира.",
|
||||||
icon: "monitor",
|
icon: "monitor",
|
||||||
detailedDescription:
|
detailedDescription:
|
||||||
"Если вы находитесь не в Минске, у вас всё равно есть уникальная возможность тренироваться, расти и развиваться с нами! Мы предлагаем занятия онлайн по следующим направлениям: партерная акробатика, Pole Dance, Exotic Pole Dance, Exo-tricks, полёты.\n\nМы предлагаем два способа работы: самостоятельный и VIP. В самостоятельный тариф входит доступ к видеозаписям уроков по выбранному направлению, в VIP-тарифе вы также получите доступ к чату с куратором в Telegram, который подскажет и скорректирует в случае трудностей в процессе изучения материала.",
|
"Если вы находитесь не в Минске, у вас всё равно есть уникальная возможность тренироваться, расти и развиваться с нами! Мы предлагаем занятия онлайн по следующим направлениям: партерная акробатика, Pole Dance, Exotic Pole Dance, Exo-tricks, полёты.\n\nМы предлагаем два способа работы: самостоятельный и VIP. В самостоятельный тариф входит доступ к видеозаписям уроков по выбранному направлению, в VIP-тарифе вы также получите доступ к чату с куратором в Telegram.",
|
||||||
images: ["/images/classes/online-classes.webp"],
|
images: ["/images/classes/online-classes.webp"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
faq: {
|
||||||
|
title: "Частые вопросы",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
question: "Что такое Exotic Pole Dance, Pole Dance и Body Plastic?",
|
||||||
|
answer:
|
||||||
|
"Exotic Pole Dance — стиль танца на пилоне, где акцент делается на чувственность, пластику. Используется обувь на высоких каблуках (стрипы), развивающий гибкость, силу, женственность и уверенность.\n\nPole Dance — вид искусства на пилоне, включающий акробатические трюки, силовые элементы и грациозные движения. Подходит для развития силы, выносливости и технического мастерства.\n\nBody Plastic — тренировка, направленная на пластичность, гибкость и осознанность всего тела, помогает лучше управлять своим движением.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Нужно ли иметь специальную подготовку, чтобы начать заниматься?",
|
||||||
|
answer:
|
||||||
|
"Нет, специальная подготовка не требуется. Уровень физической подготовки будет расти постепенно в процессе тренировок. Важно иметь желание и готовность к обучению.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Какая одежда нужна для занятий?",
|
||||||
|
answer:
|
||||||
|
"Pole Dance: важны шорты и топ, чтобы кожа на бёдрах и животе соприкасалась с пилоном для сцепления.\n\nExotic Pole Dance: на начальных этапах лучше шорты, можно леггинсы, топ/лиф, наколенники и желательно стрипы. На начальном этапе можно начинать без стрипов в носочках.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Какие группы по уровню существуют в вашей студии?",
|
||||||
|
answer:
|
||||||
|
"У нас есть группы для начинающих — «С нуля», где вы можете освоить базовые движения и технику. Также есть группы для продолжающих и для любого уровня подготовки — чтобы все могли развиваться и совершенствоваться в приятной и поддерживающей атмосфере.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Можно ли начать заниматься Exotic Pole Dance в любом возрасте?",
|
||||||
|
answer:
|
||||||
|
"Да, конечно! Возраст не имеет значения — этот вид спорта подходит для всех желающих развивать силу, гибкость и уверенность в себе. Единственное ограничение — от 18 лет.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Я чувствую себя скованно. Как раскрепоститься на тренировках Exotic Pole Dance?",
|
||||||
|
answer:
|
||||||
|
"Exotic Pole Dance — это про самовыражение и принятие себя. Не бойтесь проявлять свои эмоции, экспериментировать с движениями. Постепенно вы почувствуете себя увереннее и свободнее. Наши тренеры создают на занятиях комфортную и поддерживающую атмосферу.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Как быстро я смогу делать трюки на пилоне?",
|
||||||
|
answer:
|
||||||
|
"Это индивидуально и зависит от вашей физической подготовки, регулярности тренировок и способностей к обучению. Первые простые трюки обычно осваиваются в течение нескольких недель.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Body Plastic — это растяжка?",
|
||||||
|
answer:
|
||||||
|
"Body Plastic — это не только про растяжку. Body Plastic объединяет растяжку, силу, контроль и пластичность, что помогает развивать тело гармонично и быстро. Вместо односторонней растяжки он учит не только растягиваться, но и сохранять баланс, управлять каждым движением, что особенно важно для pole dance, акробатики и других тренировок.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Что включает направление «Трюковые комбинации с пилоном»?",
|
||||||
|
answer:
|
||||||
|
"Трюковые комбинации с пилоном — это направление с акцентом на выполнение трюков, акробатических элементов и их комбинаций. Это направление идеально подходит для тех, кто хочет освоить яркие, эффектные трюки и создать впечатляющие комбинации для выступлений и личного развития.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Сколько раз в неделю нужно заниматься?",
|
||||||
|
answer:
|
||||||
|
"Для новичков рекомендуется начинать с 2–3 раз в неделю. По мере развития физической формы и навыков можно увеличивать количество тренировок.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Участие в чемпионатах: обязательно ли это?",
|
||||||
|
answer:
|
||||||
|
"Нет, участие в чемпионатах — это не обязательно. Это скорее вопрос вашего личного желания и готовности. Если вы чувствуете в себе силы, мотивацию и хотите попробовать что-то новое, то не стесняйтесь сообщить об этом своему тренеру! Он поможет оценить ваши возможности и подготовиться к чемпионату наилучшим образом.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pricing: {
|
||||||
|
title: "Стоимость",
|
||||||
|
subtitle: "Все абонементы идут с привязкой к группе, кроме безлимитного",
|
||||||
|
items: [
|
||||||
|
{ name: "Абонемент 8 × 90 мин", price: "175 BYN" },
|
||||||
|
{ name: "Абонемент 4 × 90 мин", price: "105 BYN" },
|
||||||
|
{ name: "Абонемент 8 × 60 мин", price: "145 BYN" },
|
||||||
|
{ name: "Абонемент 4 × 60 мин", price: "105 BYN" },
|
||||||
|
{ name: "Разовое занятие 1,5 часа", price: "30 BYN" },
|
||||||
|
{ name: "Разовое занятие 1 час", price: "25 BYN" },
|
||||||
|
{ name: "Пробное занятие", price: "25 BYN", note: "1,5 часа или 1 час" },
|
||||||
|
{
|
||||||
|
name: "Безлимитный абонемент",
|
||||||
|
price: "240 / 410 BYN",
|
||||||
|
note: "2 недели / месяц (обязательна предварительная запись)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rentalTitle: "Аренда зала",
|
||||||
|
rentalItems: [
|
||||||
|
{ name: "С абонементом", price: "20 BYN", note: "+5 BYN за каждого доп. человека" },
|
||||||
|
{
|
||||||
|
name: "Без абонемента (Машерова 17/4, 6 этаж + Притыцкого 62/М)",
|
||||||
|
price: "35 BYN",
|
||||||
|
note: "+5 BYN за каждого доп. человека",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Без абонемента (Машерова 17/4, 2 этаж)",
|
||||||
|
price: "25 BYN",
|
||||||
|
note: "+5 BYN за каждого доп. человека",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
"Абонемент является персональным и не подлежит передаче другим лицам.",
|
||||||
|
"Абонемент необходимо предъявлять администратору перед каждым занятием.",
|
||||||
|
"Оплата абонементов и разовых посещений производится до начала занятия.",
|
||||||
|
"Компенсация за пропущенные занятия не предусмотрена.",
|
||||||
|
"Срок действия абонемента — 4 недели.",
|
||||||
|
"Абонемент можно заморозить не более двух раз в год на срок до 2 недель (на время отпуска или командировки).",
|
||||||
|
"В случае болезни, подтверждённой больничным листом, возможно продление срока действия абонемента.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
masterClasses: {
|
||||||
|
title: "Мастер-классы",
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
schedule: {
|
||||||
|
title: "Расписание",
|
||||||
|
locations: [
|
||||||
|
{
|
||||||
|
name: "Притыцкого 62/М",
|
||||||
|
address: "г. Минск, Притыцкого, 62/М",
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
day: "Понедельник",
|
||||||
|
dayShort: "ПН",
|
||||||
|
classes: [
|
||||||
|
{ time: "11:00–12:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
|
||||||
|
{ time: "18:00–19:30", trainer: "Надежда Сыч", type: "Exotic Pole Dance" },
|
||||||
|
{ time: "19:30–21:00", trainer: "Екатерина Матлахова", type: "Exotic Pole Dance" },
|
||||||
|
{ time: "21:00–22:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: "Вторник",
|
||||||
|
dayShort: "ВТ",
|
||||||
|
classes: [
|
||||||
|
{ time: "10:00–11:30", trainer: "Анжела Бобко", type: "Pole Dance", recruiting: true },
|
||||||
|
{ time: "18:00–19:30", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
|
||||||
|
{ time: "19:30–21:00", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
|
||||||
|
{ time: "21:00–22:30", trainer: "Виктор Артёмов", type: "Трюковые комбинации с пилоном" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: "Среда",
|
||||||
|
dayShort: "СР",
|
||||||
|
classes: [
|
||||||
|
{ time: "18:30–20:00", trainer: "Виктор Артёмов", type: "Трюковые комбинации с пилоном", level: "Продвинутый" },
|
||||||
|
{ time: "20:00–21:30", trainer: "Алёна Чигилейчик", type: "Exotic Pole Dance" },
|
||||||
|
{ time: "21:30–22:30", trainer: "Алёна Чигилейчик", type: "Pole Dance" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: "Четверг",
|
||||||
|
dayShort: "ЧТ",
|
||||||
|
classes: [
|
||||||
|
{ time: "11:00–12:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
|
||||||
|
{ time: "18:00–19:30", trainer: "Надежда Сыч", type: "Exotic Pole Dance" },
|
||||||
|
{ time: "19:30–21:00", trainer: "Екатерина Матлахова", type: "Exotic Pole Dance" },
|
||||||
|
{ time: "21:00–22:30", trainer: "Кристина Войтович", type: "Exotic Pole Dance" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: "Пятница",
|
||||||
|
dayShort: "ПТ",
|
||||||
|
classes: [
|
||||||
|
{ time: "10:00–11:30", trainer: "Анжела Бобко", type: "Pole Dance", recruiting: true },
|
||||||
|
{ time: "18:00–19:30", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
|
||||||
|
{ time: "19:30–21:00", trainer: "Ирина Третьякович", type: "Exotic Pole Dance", hasSlots: true },
|
||||||
|
{ time: "21:00–22:30", trainer: "Виктор Артёмов", type: "Трюковые комбинации с пилоном" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: "Суббота",
|
||||||
|
dayShort: "СБ",
|
||||||
|
classes: [
|
||||||
|
{ time: "14:00–15:00", trainer: "Алёна Чигилейчик", type: "Pole Dance" },
|
||||||
|
{ time: "15:00–16:30", trainer: "Алёна Чигилейчик", type: "Exotic Pole Dance" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: "Воскресенье",
|
||||||
|
dayShort: "ВС",
|
||||||
|
classes: [
|
||||||
|
{ time: "12:00–13:30", trainer: "Кристина Войтович", type: "Body Plastic" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Машерова 17/4",
|
||||||
|
address: "г. Минск, Машерова, 17/4",
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
day: "Понедельник",
|
||||||
|
dayShort: "ПН",
|
||||||
|
classes: [
|
||||||
|
{ time: "18:00–19:00", trainer: "Ирина Карпусь", type: "Exotic Pole Dance" },
|
||||||
|
{ time: "19:00–20:30", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
|
||||||
|
{ time: "20:30–22:00", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: "Вторник",
|
||||||
|
dayShort: "ВТ",
|
||||||
|
classes: [
|
||||||
|
{ time: "18:30–20:00", trainer: "Анастасия Чалей", type: "Exotic Pole Dance" },
|
||||||
|
{ time: "21:30–23:00", trainer: "Лилия Огурцова", type: "Exotic Pole Dance", hasSlots: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: "Среда",
|
||||||
|
dayShort: "СР",
|
||||||
|
classes: [
|
||||||
|
{ time: "18:00–19:30", trainer: "Ольга Демидова", type: "Pole Dance" },
|
||||||
|
{ time: "19:30–21:00", trainer: "Ольга Демидова", type: "Body Plastic" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: "Четверг",
|
||||||
|
dayShort: "ЧТ",
|
||||||
|
classes: [
|
||||||
|
{ time: "18:00–19:00", trainer: "Ирина Карпусь", type: "Exotic Pole Dance" },
|
||||||
|
{ time: "19:00–20:30", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
|
||||||
|
{ time: "20:30–22:00", trainer: "Анна Тарыба", type: "Exotic Pole Dance" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: "Пятница",
|
||||||
|
dayShort: "ПТ",
|
||||||
|
classes: [
|
||||||
|
{ time: "18:30–20:00", trainer: "Анастасия Чалей", type: "Exotic Pole Dance" },
|
||||||
|
{ time: "21:30–23:00", trainer: "Лилия Огурцова", type: "Exotic Pole Dance", hasSlots: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: "Суббота",
|
||||||
|
dayShort: "СБ",
|
||||||
|
classes: [
|
||||||
|
{ time: "10:30–12:00", trainer: "Елена Тарасевич", type: "Body Plastic" },
|
||||||
|
{ time: "12:00–13:30", trainer: "Ольга Демидова", type: "Pole Dance" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
news: {
|
||||||
|
title: "Новости",
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
contact: {
|
contact: {
|
||||||
title: "Контакты",
|
title: "Контакты",
|
||||||
addresses: [
|
addresses: [
|
||||||
"г. Минск, Матерова, 17к4",
|
"г. Минск, Машерова, 17/4",
|
||||||
"г. Минск, Притыцкого, 62к1",
|
"г. Минск, Притыцкого, 62/М",
|
||||||
],
|
],
|
||||||
phone: "+375 29 389-70-01",
|
phone: "+375 29 389-70-01",
|
||||||
instagram: "https://instagram.com/blackheartdancehouse/",
|
instagram: "https://instagram.com/blackheartdancehouse/",
|
||||||
|
|||||||
97
src/data/seed.ts
Normal file
97
src/data/seed.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Seed script — populates the SQLite database from content.ts
|
||||||
|
* Run: npx tsx src/data/seed.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import path from "path";
|
||||||
|
import { siteContent } from "./content";
|
||||||
|
|
||||||
|
const DB_PATH =
|
||||||
|
process.env.DATABASE_PATH ||
|
||||||
|
path.join(process.cwd(), "db", "blackheart.db");
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
db.pragma("journal_mode = WAL");
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS sections (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS team_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
image TEXT NOT NULL,
|
||||||
|
instagram TEXT,
|
||||||
|
description TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Seed sections (team members go in their own table)
|
||||||
|
const sectionData: Record<string, unknown> = {
|
||||||
|
meta: siteContent.meta,
|
||||||
|
hero: siteContent.hero,
|
||||||
|
about: siteContent.about,
|
||||||
|
classes: siteContent.classes,
|
||||||
|
masterClasses: siteContent.masterClasses,
|
||||||
|
faq: siteContent.faq,
|
||||||
|
pricing: siteContent.pricing,
|
||||||
|
schedule: siteContent.schedule,
|
||||||
|
contact: siteContent.contact,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Team section stores only the title
|
||||||
|
sectionData.team = { title: siteContent.team.title };
|
||||||
|
|
||||||
|
const upsertSection = db.prepare(
|
||||||
|
`INSERT INTO sections (key, data, updated_at) VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertMember = db.prepare(
|
||||||
|
`INSERT INTO team_members (name, role, image, instagram, description, sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
// Upsert all sections
|
||||||
|
for (const [key, data] of Object.entries(sectionData)) {
|
||||||
|
upsertSection.run(key, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing team members and re-insert
|
||||||
|
db.prepare("DELETE FROM team_members").run();
|
||||||
|
|
||||||
|
siteContent.team.members.forEach((m, i) => {
|
||||||
|
insertMember.run(
|
||||||
|
m.name,
|
||||||
|
m.role,
|
||||||
|
m.image,
|
||||||
|
m.instagram ?? null,
|
||||||
|
m.description ?? null,
|
||||||
|
i
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tx();
|
||||||
|
|
||||||
|
const sectionCount = (
|
||||||
|
db.prepare("SELECT COUNT(*) as c FROM sections").get() as { c: number }
|
||||||
|
).c;
|
||||||
|
const memberCount = (
|
||||||
|
db.prepare("SELECT COUNT(*) as c FROM team_members").get() as { c: number }
|
||||||
|
).c;
|
||||||
|
|
||||||
|
console.log(`Seeded ${sectionCount} sections and ${memberCount} team members.`);
|
||||||
|
console.log(`Database: ${DB_PATH}`);
|
||||||
|
|
||||||
|
db.close();
|
||||||
45
src/hooks/useShowcaseRotation.ts
Normal file
45
src/hooks/useShowcaseRotation.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
interface UseShowcaseRotationOptions {
|
||||||
|
totalItems: number;
|
||||||
|
autoPlayInterval?: number;
|
||||||
|
pauseDuration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShowcaseRotation({
|
||||||
|
totalItems,
|
||||||
|
autoPlayInterval = 4000,
|
||||||
|
pauseDuration = 10000,
|
||||||
|
}: UseShowcaseRotationOptions) {
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const pausedUntil = useRef(0);
|
||||||
|
const hoveringRef = useRef(false);
|
||||||
|
|
||||||
|
const select = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
setActiveIndex(index);
|
||||||
|
pausedUntil.current = Date.now() + pauseDuration;
|
||||||
|
},
|
||||||
|
[pauseDuration],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setHovering = useCallback((hovering: boolean) => {
|
||||||
|
hoveringRef.current = hovering;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (totalItems <= 1) return;
|
||||||
|
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (hoveringRef.current) return;
|
||||||
|
if (Date.now() < pausedUntil.current) return;
|
||||||
|
setActiveIndex((prev) => (prev + 1) % totalItems);
|
||||||
|
}, autoPlayInterval);
|
||||||
|
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [totalItems, autoPlayInterval]);
|
||||||
|
|
||||||
|
return { activeIndex, select, setHovering };
|
||||||
|
}
|
||||||
53
src/lib/auth-edge.ts
Normal file
53
src/lib/auth-edge.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Edge-compatible auth helpers (for middleware).
|
||||||
|
* Uses Web Crypto API instead of Node.js crypto.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const COOKIE_NAME = "bh-admin-token";
|
||||||
|
|
||||||
|
function getSecret(): string {
|
||||||
|
const secret = process.env.AUTH_SECRET;
|
||||||
|
if (!secret) throw new Error("AUTH_SECRET is not set");
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64urlEncode(buf: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let binary = "";
|
||||||
|
for (const b of bytes) binary += String.fromCharCode(b);
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hmacSign(data: string, secret: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["sign"]
|
||||||
|
);
|
||||||
|
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
||||||
|
return base64urlEncode(sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyToken(token: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const [data, sig] = token.split(".");
|
||||||
|
if (!data || !sig) return false;
|
||||||
|
|
||||||
|
const expectedSig = await hmacSign(data, getSecret());
|
||||||
|
if (sig !== expectedSig) return false;
|
||||||
|
|
||||||
|
const payload = JSON.parse(atob(data.replace(/-/g, "+").replace(/_/g, "/"))) as {
|
||||||
|
role: string;
|
||||||
|
exp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
return payload.role === "admin" && payload.exp > Date.now();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { COOKIE_NAME };
|
||||||
78
src/lib/auth.ts
Normal file
78
src/lib/auth.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
const COOKIE_NAME = "bh-admin-token";
|
||||||
|
const TOKEN_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
function getSecret(): string {
|
||||||
|
const secret = process.env.AUTH_SECRET;
|
||||||
|
if (!secret) throw new Error("AUTH_SECRET is not set");
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdminPassword(): string {
|
||||||
|
const pw = process.env.ADMIN_PASSWORD;
|
||||||
|
if (!pw) throw new Error("ADMIN_PASSWORD is not set");
|
||||||
|
return pw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password: string): boolean {
|
||||||
|
const expected = getAdminPassword();
|
||||||
|
if (password.length !== expected.length) return false;
|
||||||
|
const a = Buffer.from(password);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
// Pad to equal length for timingSafeEqual
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return crypto.timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signToken(): string {
|
||||||
|
const payload = {
|
||||||
|
role: "admin",
|
||||||
|
exp: Date.now() + TOKEN_TTL,
|
||||||
|
};
|
||||||
|
const data = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||||
|
const sig = crypto
|
||||||
|
.createHmac("sha256", getSecret())
|
||||||
|
.update(data)
|
||||||
|
.digest("base64url");
|
||||||
|
return `${data}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isAuthenticated(): Promise<boolean> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||||
|
if (!token) return false;
|
||||||
|
return verifyTokenNode(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Node.js runtime token verification (for API routes / server components) */
|
||||||
|
function verifyTokenNode(token: string): boolean {
|
||||||
|
try {
|
||||||
|
const [data, sig] = token.split(".");
|
||||||
|
if (!data || !sig) return false;
|
||||||
|
|
||||||
|
const expectedSig = crypto
|
||||||
|
.createHmac("sha256", getSecret())
|
||||||
|
.update(data)
|
||||||
|
.digest("base64url");
|
||||||
|
|
||||||
|
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) return false;
|
||||||
|
|
||||||
|
const payload = JSON.parse(
|
||||||
|
Buffer.from(data, "base64url").toString()
|
||||||
|
) as { role: string; exp: number };
|
||||||
|
|
||||||
|
return payload.role === "admin" && payload.exp > Date.now();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CSRF_COOKIE_NAME = "bh-csrf-token";
|
||||||
|
|
||||||
|
export function generateCsrfToken(): string {
|
||||||
|
return crypto.randomBytes(32).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
export { COOKIE_NAME };
|
||||||
21
src/lib/config.ts
Normal file
21
src/lib/config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export const UI_CONFIG = {
|
||||||
|
scrollThresholds: {
|
||||||
|
header: 20,
|
||||||
|
backToTop: 600,
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
autoPlayMs: 4500,
|
||||||
|
pauseMs: 12000,
|
||||||
|
cardSpacing: 260,
|
||||||
|
stageHeight: 440,
|
||||||
|
floatingHeartsCount: 12,
|
||||||
|
},
|
||||||
|
faq: {
|
||||||
|
visibleCount: 4,
|
||||||
|
},
|
||||||
|
showcase: {
|
||||||
|
autoPlayInterval: 5000,
|
||||||
|
fadeMs: 250,
|
||||||
|
swipeThreshold: 50,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -11,8 +11,10 @@ export const NAV_LINKS: NavLink[] = [
|
|||||||
{ label: "О нас", href: "#about" },
|
{ label: "О нас", href: "#about" },
|
||||||
{ label: "Команда", href: "#team" },
|
{ label: "Команда", href: "#team" },
|
||||||
{ label: "Направления", href: "#classes" },
|
{ label: "Направления", href: "#classes" },
|
||||||
|
{ label: "Мастер-классы", href: "#master-classes" },
|
||||||
|
{ label: "Расписание", href: "#schedule" },
|
||||||
|
{ label: "Стоимость", href: "#pricing" },
|
||||||
|
{ label: "FAQ", href: "#faq" },
|
||||||
|
{ label: "Новости", href: "#news" },
|
||||||
{ label: "Контакты", href: "#contact" },
|
{ label: "Контакты", href: "#contact" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const API_BASE_URL =
|
|
||||||
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000/api/v1";
|
|
||||||
|
|||||||
29
src/lib/content.ts
Normal file
29
src/lib/content.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { getSiteContent } from "@/lib/db";
|
||||||
|
import { siteContent as fallback } from "@/data/content";
|
||||||
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
|
let cached: { data: SiteContent; expiresAt: number } | null = null;
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
export function getContent(): SiteContent {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cached && now < cached.expiresAt) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = getSiteContent();
|
||||||
|
if (content) {
|
||||||
|
cached = { data: content, expiresAt: now + CACHE_TTL };
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invalidate the content cache (call after admin edits). */
|
||||||
|
export function invalidateContentCache() {
|
||||||
|
cached = null;
|
||||||
|
}
|
||||||
17
src/lib/csrf.ts
Normal file
17
src/lib/csrf.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const CSRF_COOKIE_NAME = "bh-csrf-token";
|
||||||
|
|
||||||
|
function getCsrfToken(): string {
|
||||||
|
const match = document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find((c) => c.startsWith(`${CSRF_COOKIE_NAME}=`));
|
||||||
|
return match ? match.split("=")[1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrapper around fetch that auto-includes the CSRF token header for admin API calls */
|
||||||
|
export function adminFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
if (!headers.has("x-csrf-token")) {
|
||||||
|
headers.set("x-csrf-token", getCsrfToken());
|
||||||
|
}
|
||||||
|
return fetch(url, { ...init, headers });
|
||||||
|
}
|
||||||
1206
src/lib/db.ts
Normal file
1206
src/lib/db.ts
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user