Compare commits
81 Commits
9f1697fd93
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 9cf09b6894 | |||
| 1f6e314af6 | |||
| 86a04bb8c0 | |||
| dcb31415bc |
3
.gitignore
vendored
@@ -36,6 +36,9 @@ yarn-error.log*
|
|||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
# database
|
||||||
|
/db/
|
||||||
|
|
||||||
# claude
|
# claude
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
|||||||
164
CLAUDE.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# BLACK HEART DANCE HOUSE — Project Context
|
||||||
|
|
||||||
|
## About
|
||||||
|
Landing page for "BLACK HEART DANCE HOUSE" — a pole dance school in Minsk, Belarus.
|
||||||
|
Instagram: @blackheartdancehouse
|
||||||
|
Content language: Russian
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **Next.js 16** (App Router, TypeScript, Turbopack)
|
||||||
|
- **Tailwind CSS v4** (dark mode only, gold/black theme)
|
||||||
|
- **lucide-react** for icons
|
||||||
|
- **better-sqlite3** for SQLite database
|
||||||
|
- **Fonts**: Inter (body) + Oswald (headings) via `next/font`
|
||||||
|
- **Hosting**: Vercel (planned)
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
- Function declarations for components (not arrow functions)
|
||||||
|
- PascalCase for component files, camelCase for utils
|
||||||
|
- `@/` path alias for imports
|
||||||
|
- `next/image` with `unoptimized` for PNGs that need transparency preserved
|
||||||
|
- Header nav uses `lg:` breakpoint (1024px) for desktop/mobile switch (9 nav links + CTA need the space)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── layout.tsx # Root layout, fonts, metadata
|
||||||
|
│ ├── page.tsx # Landing: Hero → [OpenDay] → About → Team → Classes → MasterClasses → Schedule → Pricing → News → FAQ → Contact
|
||||||
|
│ ├── globals.css # Tailwind imports
|
||||||
|
│ ├── styles/
|
||||||
|
│ │ ├── theme.css # Theme variables, semantic classes
|
||||||
|
│ │ └── animations.css # Keyframes, scroll reveal, modal animations
|
||||||
|
│ ├── admin/
|
||||||
|
│ │ ├── page.tsx # Dashboard with 13 section cards
|
||||||
|
│ │ ├── login/ # Password auth
|
||||||
|
│ │ ├── layout.tsx # Sidebar nav shell (14 items)
|
||||||
|
│ │ ├── _components/ # SectionEditor, FormField, ArrayEditor, NotifyToggle
|
||||||
|
│ │ ├── meta/ # SEO editor
|
||||||
|
│ │ ├── hero/ # Hero editor
|
||||||
|
│ │ ├── about/ # About editor
|
||||||
|
│ │ ├── team/ # Team list + [id] editor
|
||||||
|
│ │ ├── classes/ # Classes editor with icon picker
|
||||||
|
│ │ ├── master-classes/ # MC editor with registrations + notification toggles
|
||||||
|
│ │ ├── open-day/ # Open Day event editor (settings + grid + bookings)
|
||||||
|
│ │ ├── schedule/ # Schedule editor
|
||||||
|
│ │ ├── bookings/ # Group booking management with notification toggles
|
||||||
|
│ │ ├── pricing/ # Pricing editor
|
||||||
|
│ │ ├── faq/ # FAQ editor
|
||||||
|
│ │ ├── news/ # News editor
|
||||||
|
│ │ └── contact/ # Contact editor
|
||||||
|
│ └── api/
|
||||||
|
│ ├── auth/login/ # POST login
|
||||||
|
│ ├── logout/ # POST logout
|
||||||
|
│ ├── admin/
|
||||||
|
│ │ ├── sections/[key]/ # GET/PUT section data
|
||||||
|
│ │ ├── team/ # CRUD team members
|
||||||
|
│ │ ├── team/[id]/ # GET/PUT/DELETE single member
|
||||||
|
│ │ ├── team/reorder/ # PUT reorder
|
||||||
|
│ │ ├── upload/ # POST file upload (whitelisted folders)
|
||||||
|
│ │ ├── mc-registrations/ # CRUD registrations + notification toggle
|
||||||
|
│ │ ├── group-bookings/ # CRUD group bookings + notification toggle
|
||||||
|
│ │ ├── open-day/ # CRUD events
|
||||||
|
│ │ ├── open-day/classes/ # CRUD event classes
|
||||||
|
│ │ ├── open-day/bookings/ # CRUD event bookings + notification toggle
|
||||||
|
│ │ └── validate-instagram/ # GET check username
|
||||||
|
│ ├── master-class-register/ # POST public MC signup
|
||||||
|
│ ├── group-booking/ # POST public group booking
|
||||||
|
│ └── open-day-register/ # POST public Open Day booking
|
||||||
|
├── components/
|
||||||
|
│ ├── layout/
|
||||||
|
│ │ ├── Header.tsx # Sticky nav, mobile menu, booking modal ("use client")
|
||||||
|
│ │ └── Footer.tsx
|
||||||
|
│ ├── sections/
|
||||||
|
│ │ ├── Hero.tsx # Hero with animated logo, floating hearts
|
||||||
|
│ │ ├── About.tsx # About with stats (trainers, classes, locations)
|
||||||
|
│ │ ├── Team.tsx # Carousel + profile view
|
||||||
|
│ │ ├── Classes.tsx # Showcase layout with icon selector
|
||||||
|
│ │ ├── MasterClasses.tsx # Cards with signup modal
|
||||||
|
│ │ ├── OpenDay.tsx # Open Day schedule grid + booking (conditional)
|
||||||
|
│ │ ├── Schedule.tsx # Day/group views with filters
|
||||||
|
│ │ ├── Pricing.tsx # Tabs: prices, rental, rules
|
||||||
|
│ │ ├── News.tsx # Featured + compact articles
|
||||||
|
│ │ ├── FAQ.tsx # Accordion with show more
|
||||||
|
│ │ └── Contact.tsx # Info + Yandex Maps iframe
|
||||||
|
│ └── ui/
|
||||||
|
│ ├── Button.tsx
|
||||||
|
│ ├── SectionHeading.tsx
|
||||||
|
│ ├── BookingModal.tsx # Booking form → Instagram DM + DB save
|
||||||
|
│ ├── MasterClassSignupModal.tsx # MC registration form → API
|
||||||
|
│ ├── OpenDaySignupModal.tsx # Open Day class booking → API
|
||||||
|
│ ├── NewsModal.tsx # News detail popup
|
||||||
|
│ ├── Reveal.tsx # Intersection Observer scroll reveal
|
||||||
|
│ ├── BackToTop.tsx
|
||||||
|
│ └── ...
|
||||||
|
├── data/
|
||||||
|
│ └── content.ts # Fallback Russian text (DB takes priority)
|
||||||
|
├── lib/
|
||||||
|
│ ├── constants.ts # BRAND constants, NAV_LINKS
|
||||||
|
│ ├── config.ts # UI_CONFIG (thresholds, counts)
|
||||||
|
│ ├── db.ts # SQLite DB, 6 migrations, CRUD for all tables
|
||||||
|
│ ├── auth.ts # Token signing (Node.js)
|
||||||
|
│ ├── auth-edge.ts # Token verification (Edge/Web Crypto)
|
||||||
|
│ ├── content.ts # getContent() — DB with fallback
|
||||||
|
│ └── openDay.ts # getActiveOpenDay() — server-side Open Day loader
|
||||||
|
├── proxy.ts # Middleware: auth guard for /admin/*
|
||||||
|
└── types/
|
||||||
|
├── index.ts
|
||||||
|
├── content.ts # SiteContent, TeamMember, ClassItem, MasterClassItem, etc.
|
||||||
|
└── navigation.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Brand / Styling
|
||||||
|
- **Accent**: gold (`#c9a96e` / `hsl(37, 42%, 61%)`)
|
||||||
|
- **Background**: `#050505` – `#0a0a0a` (dark only)
|
||||||
|
- **Surface**: `#171717` dark cards
|
||||||
|
- Logo: transparent PNG heart with gold glow, uses `unoptimized`
|
||||||
|
|
||||||
|
## Content Data
|
||||||
|
- Primary source: SQLite database (`db/blackheart.db`)
|
||||||
|
- Fallback: `src/data/content.ts` (auto-seeds DB on first access)
|
||||||
|
- Admin panel edits go to DB, site reads from DB via `getContent()`
|
||||||
|
- 12 team members with photos, Instagram links, bios, victories, education
|
||||||
|
- 6 class types (Exotic Pole Dance, Pole Dance, Body Plastic, etc.)
|
||||||
|
- Master classes with date/time slots and public registration
|
||||||
|
- 2 addresses in Minsk, Yandex Maps embed with markers
|
||||||
|
- Contact: phone, Instagram (no email)
|
||||||
|
|
||||||
|
## Admin Panel
|
||||||
|
- Password-based auth with HMAC-SHA256 signed JWT (24h TTL)
|
||||||
|
- Cookie: `bh-admin-token` (httpOnly, secure in prod)
|
||||||
|
- Auto-save with 800ms debounce on all section editors
|
||||||
|
- Team members: drag-reorder, photo upload, rich bio (experience, victories, education)
|
||||||
|
- Master classes: slots, registration viewer with notification tracking (confirm + reminder), trainer/style autocomplete
|
||||||
|
- Group bookings: saved to DB from BookingModal, admin page at `/admin/bookings` with notification toggles
|
||||||
|
- Open Day: event settings (date, pricing, discount rules, min bookings), schedule grid (halls × time slots), per-class booking with auto-cancel threshold, public section after Hero
|
||||||
|
- Shared `NotifyToggle` component (`src/app/admin/_components/NotifyToggle.tsx`) used across MC registrations, group bookings, and Open Day bookings
|
||||||
|
- File upload: whitelisted folders (`team`, `master-classes`, `news`, `classes`), max 5MB, image types only
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
- **CSRF protection**: Double-submit cookie pattern. Login sets `bh-csrf-token` cookie (JS-readable). All admin fetch calls use `adminFetch()` from `src/lib/csrf.ts` which sends the token as `X-CSRF-Token` header. Middleware (`proxy.ts`) validates header matches cookie on POST/PUT/DELETE to `/api/admin/*`. **Always use `adminFetch()` instead of `fetch()` for admin API calls.**
|
||||||
|
- File upload validates: MIME type, file extension, whitelisted folder (no path traversal)
|
||||||
|
- API routes validate: input types, string lengths, numeric IDs
|
||||||
|
- Public MC registration: length-limited but **no rate limiting yet** (add before production)
|
||||||
|
|
||||||
|
## Upcoming Features
|
||||||
|
- **Rate limiting** on public endpoints (`/api/master-class-register`, `/api/group-booking`, `/api/open-day-register`)
|
||||||
|
- **DB backup mechanism** — automated/manual backup of `db/blackheart.db` with rotation
|
||||||
|
|
||||||
|
## AST Index
|
||||||
|
- **Always use the AST index** at `memory/ast-index.md` when searching for components, props, hooks, types, or styles
|
||||||
|
- Contains: component tree, all exports, props, hooks, client/server status, CSS classes, keyframes
|
||||||
|
- Update the index when adding/removing/renaming files or exports
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
- **Never drop/recreate the database** — admin data (photos, edits, registrations) lives there
|
||||||
|
- Schema changes go through versioned migrations in `src/lib/db.ts` (`migrations` array)
|
||||||
|
- Add a new entry with the next version number; never modify existing migrations
|
||||||
|
- Migrations run automatically on server start via `runMigrations()` and are tracked in the `_migrations` table
|
||||||
|
- Use `CREATE TABLE IF NOT EXISTS` and column-existence checks (`PRAGMA table_info`) for safety
|
||||||
|
|
||||||
|
## Git
|
||||||
|
- Remote: Gitea at `git.dolgolyov-family.by`
|
||||||
|
- User: diana.dolgolyova
|
||||||
|
- Branch: main
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
serverExternalPackages: ["better-sqlite3"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
892
package-lock.json
generated
@@ -6,9 +6,11 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"seed": "tsx src/data/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
"lucide-react": "^0.576.0",
|
"lucide-react": "^0.576.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -16,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -24,6 +27,7 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/images/classes/body-plastic.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/images/classes/exot-w.webp
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
public/images/classes/exot.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/images/classes/master-class-1.webp
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
public/images/classes/master-class-2.webp
Normal file
|
After Width: | Height: | Size: 313 KiB |
BIN
public/images/classes/master-class-3.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/images/classes/online-classes.webp
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
public/images/classes/parter-1.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/images/classes/parter-2.webp
Normal file
|
After Width: | Height: | Size: 481 KiB |
BIN
public/images/classes/pole-dance.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
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
|
After Width: | Height: | Size: 579 KiB |
BIN
public/images/team/angel-1773234723454.PNG
Normal file
|
After Width: | Height: | Size: 313 KiB |
BIN
public/images/team/photo-2025-06-28-23-11-20-1773234496259.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
|
import { InputField } from "../_components/FormField";
|
||||||
|
|
||||||
|
interface HeroData {
|
||||||
|
headline: string;
|
||||||
|
subheadline: string;
|
||||||
|
ctaText: string;
|
||||||
|
ctaHref: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeroEditorPage() {
|
||||||
|
return (
|
||||||
|
<SectionEditor<HeroData> sectionKey="hero" title="Главный экран">
|
||||||
|
{(data, update) => (
|
||||||
|
<>
|
||||||
|
<InputField
|
||||||
|
label="Заголовок"
|
||||||
|
value={data.headline}
|
||||||
|
onChange={(v) => update({ ...data, headline: v })}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Подзаголовок"
|
||||||
|
value={data.subheadline}
|
||||||
|
onChange={(v) => update({ ...data, subheadline: v })}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Текст кнопки"
|
||||||
|
value={data.ctaText}
|
||||||
|
onChange={(v) => update({ ...data, ctaText: v })}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Ссылка кнопки"
|
||||||
|
value={data.ctaHref}
|
||||||
|
onChange={(v) => update({ ...data, ctaHref: v })}
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionEditor>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Sparkles,
|
||||||
|
Users,
|
||||||
|
BookOpen,
|
||||||
|
Star,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
HelpCircle,
|
||||||
|
Phone,
|
||||||
|
FileText,
|
||||||
|
Globe,
|
||||||
|
Newspaper,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
ChevronLeft,
|
||||||
|
ClipboardList,
|
||||||
|
DoorOpen,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ href: "/admin", label: "Дашборд", icon: LayoutDashboard },
|
||||||
|
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe },
|
||||||
|
{ href: "/admin/hero", label: "Главный экран", icon: Sparkles },
|
||||||
|
{ href: "/admin/about", label: "О студии", icon: FileText },
|
||||||
|
{ href: "/admin/team", label: "Команда", icon: Users },
|
||||||
|
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
|
||||||
|
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
|
||||||
|
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen },
|
||||||
|
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
|
||||||
|
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
|
||||||
|
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
|
||||||
|
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
||||||
|
{ href: "/admin/news", label: "Новости", icon: Newspaper },
|
||||||
|
{ href: "/admin/contact", label: "Контакты", icon: Phone },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [unreadTotal, setUnreadTotal] = useState(0);
|
||||||
|
|
||||||
|
// Don't render admin shell on login page
|
||||||
|
if (pathname === "/admin/login") {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch unread counts — poll every 30s
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
useEffect(() => {
|
||||||
|
function fetchCounts() {
|
||||||
|
adminFetch("/api/admin/unread-counts")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: { total: number }) => setUnreadTotal(data.total))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
fetchCounts();
|
||||||
|
const interval = setInterval(fetchCounts, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await fetch("/api/logout", { method: "POST" });
|
||||||
|
router.push("/admin/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
if (href === "/admin") return pathname === "/admin";
|
||||||
|
return pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-neutral-950 text-white">
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/60 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-white/10 bg-neutral-900 transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
|
||||||
|
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||||
|
<Link href="/admin" className="text-lg font-bold">
|
||||||
|
BLACK HEART
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className="lg:hidden text-neutral-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = isActive(item.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-gold/10 text-gold font-medium"
|
||||||
|
: "text-neutral-400 hover:text-white hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
{item.label}
|
||||||
|
{item.href === "/admin/bookings" && unreadTotal > 0 && (
|
||||||
|
<span className="ml-auto rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||||
|
{unreadTotal > 99 ? "99+" : unreadTotal}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t border-white/10 p-3 space-y-1">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
target="_blank"
|
||||||
|
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
Открыть сайт
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-red-400 hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{/* Top bar (mobile) */}
|
||||||
|
<header className="flex items-center gap-3 border-b border-white/10 px-4 py-3 lg:hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="text-neutral-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<Menu size={24} />
|
||||||
|
</button>
|
||||||
|
<span className="font-bold">BLACK HEART</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 p-4 sm:p-6 lg:p-8">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
getOpenDayClasses,
|
||||||
|
addOpenDayClass,
|
||||||
|
updateOpenDayClass,
|
||||||
|
deleteOpenDayClass,
|
||||||
|
} from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const eventIdStr = request.nextUrl.searchParams.get("eventId");
|
||||||
|
if (!eventIdStr) return NextResponse.json({ error: "eventId is required" }, { status: 400 });
|
||||||
|
const eventId = parseInt(eventIdStr, 10);
|
||||||
|
if (isNaN(eventId)) return NextResponse.json({ error: "Invalid eventId" }, { status: 400 });
|
||||||
|
return NextResponse.json(getOpenDayClasses(eventId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { eventId, hall, startTime, endTime, trainer, style } = body;
|
||||||
|
if (!eventId || !hall || !startTime || !endTime || !trainer || !style) {
|
||||||
|
return NextResponse.json({ error: "All fields required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const id = addOpenDayClass(eventId, { hall, startTime, endTime, trainer, style });
|
||||||
|
return NextResponse.json({ ok: true, id });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : "Internal error";
|
||||||
|
if (msg.includes("UNIQUE")) {
|
||||||
|
return NextResponse.json({ error: "Этот слот уже занят" }, { status: 409 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
if (!body.id) return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
const { id, ...data } = body;
|
||||||
|
updateOpenDayClass(id, data);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/open-day/classes] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const idStr = request.nextUrl.searchParams.get("id");
|
||||||
|
if (!idStr) return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
const id = parseInt(idStr, 10);
|
||||||
|
if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
|
||||||
|
deleteOpenDayClass(id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
69
src/app/api/admin/open-day/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
getOpenDayEvents,
|
||||||
|
getOpenDayEvent,
|
||||||
|
createOpenDayEvent,
|
||||||
|
updateOpenDayEvent,
|
||||||
|
deleteOpenDayEvent,
|
||||||
|
} from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const idStr = request.nextUrl.searchParams.get("id");
|
||||||
|
if (idStr) {
|
||||||
|
const id = parseInt(idStr, 10);
|
||||||
|
if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
|
||||||
|
const event = getOpenDayEvent(id);
|
||||||
|
if (!event) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
return NextResponse.json(event);
|
||||||
|
}
|
||||||
|
return NextResponse.json(getOpenDayEvents(), {
|
||||||
|
headers: { "Cache-Control": "private, max-age=60" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
if (!body.date || typeof body.date !== "string") {
|
||||||
|
return NextResponse.json({ error: "date is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
// Warn if date is in the past
|
||||||
|
const eventDate = new Date(body.date + "T23:59:59");
|
||||||
|
if (eventDate < new Date()) {
|
||||||
|
return NextResponse.json({ error: "Дата не может быть в прошлом" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const id = createOpenDayEvent(body);
|
||||||
|
return NextResponse.json({ ok: true, id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/open-day] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
if (!body.id) return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
const { id, ...data } = body;
|
||||||
|
if (data.date) {
|
||||||
|
const eventDate = new Date(data.date + "T23:59:59");
|
||||||
|
if (eventDate < new Date()) {
|
||||||
|
return NextResponse.json({ error: "Дата не может быть в прошлом" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateOpenDayEvent(id, data);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/open-day] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const idStr = request.nextUrl.searchParams.get("id");
|
||||||
|
if (!idStr) return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
const id = parseInt(idStr, 10);
|
||||||
|
if (isNaN(id)) return NextResponse.json({ error: "Invalid id" }, { status: 400 });
|
||||||
|
deleteOpenDayEvent(id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
37
src/app/api/admin/reminders/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getUpcomingReminders, setReminderStatus } from "@/lib/db";
|
||||||
|
import type { ReminderStatus } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(getUpcomingReminders());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { table, id, status } = body;
|
||||||
|
|
||||||
|
const validTables = ["mc_registrations", "group_bookings", "open_day_bookings"];
|
||||||
|
const validStatuses = ["pending", "coming", "cancelled", null];
|
||||||
|
|
||||||
|
if (!validTables.includes(table)) {
|
||||||
|
return NextResponse.json({ error: "Invalid table" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!id || typeof id !== "number") {
|
||||||
|
return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
return NextResponse.json({ error: "Invalid status" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
setReminderStatus(
|
||||||
|
table as "mc_registrations" | "group_bookings" | "open_day_bookings",
|
||||||
|
id,
|
||||||
|
status as ReminderStatus | null
|
||||||
|
);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/reminders] error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/admin/sections/[key]/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getSection, setSection, SECTION_KEYS } from "@/lib/db";
|
||||||
|
import { siteContent } from "@/data/content";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { invalidateContentCache } from "@/lib/content";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ key: string }> };
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
|
const { key } = await params;
|
||||||
|
if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) {
|
||||||
|
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = getSection(key);
|
||||||
|
if (!data) {
|
||||||
|
// Auto-seed from fallback content if section doesn't exist yet
|
||||||
|
const fallback = (siteContent as unknown as Record<string, unknown>)[key];
|
||||||
|
if (fallback) {
|
||||||
|
setSection(key, fallback);
|
||||||
|
data = fallback;
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: "Section not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=60" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: Params) {
|
||||||
|
const { key } = await params;
|
||||||
|
if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) {
|
||||||
|
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await request.json();
|
||||||
|
setSection(key, data);
|
||||||
|
invalidateContentCache();
|
||||||
|
revalidatePath("/");
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
46
src/app/api/admin/team/[id]/route.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getTeamMember, updateTeamMember, deleteTeamMember } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
function parseId(raw: string): number | null {
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isInteger(n) && n > 0 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
const numId = parseId(id);
|
||||||
|
if (!numId) {
|
||||||
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const member = getTeamMember(numId);
|
||||||
|
if (!member) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
const numId = parseId(id);
|
||||||
|
if (!numId) {
|
||||||
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const data = await request.json();
|
||||||
|
updateTeamMember(numId, data);
|
||||||
|
revalidatePath("/");
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
const numId = parseId(id);
|
||||||
|
if (!numId) {
|
||||||
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
|
}
|
||||||
|
deleteTeamMember(numId);
|
||||||
|
revalidatePath("/");
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
16
src/app/api/admin/team/reorder/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { reorderTeamMembers } from "@/lib/db";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
const { ids } = await request.json() as { ids: number[] };
|
||||||
|
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100 || !ids.every((id) => Number.isInteger(id) && id > 0)) {
|
||||||
|
return NextResponse.json({ error: "ids must be a non-empty array of positive integers (max 100)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
reorderTeamMembers(ids);
|
||||||
|
revalidatePath("/");
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
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
@@ -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
@@ -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
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const username = request.nextUrl.searchParams.get("username")?.trim();
|
||||||
|
if (!username) {
|
||||||
|
return NextResponse.json({ valid: false, error: "No username" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://www.instagram.com/${username}/`, {
|
||||||
|
method: "HEAD",
|
||||||
|
redirect: "follow",
|
||||||
|
headers: {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instagram returns 200 for existing profiles, 404 for non-existing
|
||||||
|
const valid = res.ok;
|
||||||
|
return NextResponse.json({ valid });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[admin/validate-instagram] error:", err);
|
||||||
|
// Network error or timeout — don't block the user
|
||||||
|
return NextResponse.json({ valid: true, uncertain: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { verifyPassword, signToken, generateCsrfToken, COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const body = await request.json() as { password?: string };
|
||||||
|
|
||||||
|
if (!body.password || !verifyPassword(body.password)) {
|
||||||
|
return NextResponse.json({ error: "Неверный пароль" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = signToken();
|
||||||
|
const csrfToken = generateCsrfToken();
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
|
||||||
|
response.cookies.set(COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
|
||||||
|
httpOnly: false, // JS must read this to send as header
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "strict",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
41
src/app/api/group-booking/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { addGroupBooking } from "@/lib/db";
|
||||||
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
|
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 5, 60_000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Слишком много запросов. Попробуйте через минуту." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, phone, groupInfo, instagram, telegram } = body;
|
||||||
|
|
||||||
|
const cleanName = sanitizeName(name);
|
||||||
|
if (!cleanName) {
|
||||||
|
return NextResponse.json({ error: "Имя обязательно" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanPhone = sanitizePhone(phone);
|
||||||
|
if (!cleanPhone) {
|
||||||
|
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = addGroupBooking(
|
||||||
|
cleanName,
|
||||||
|
cleanPhone,
|
||||||
|
sanitizeText(groupInfo),
|
||||||
|
sanitizeHandle(instagram),
|
||||||
|
sanitizeHandle(telegram)
|
||||||
|
);
|
||||||
|
return NextResponse.json({ ok: true, id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[group-booking] POST error:", err);
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/api/logout/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
response.cookies.set(COOKIE_NAME, "", {
|
||||||
|
httpOnly: true,
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
response.cookies.set(CSRF_COOKIE_NAME, "", {
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
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
@@ -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 ===== */
|
||||||
@@ -16,6 +19,17 @@ html {
|
|||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Selection ===== */
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: rgba(201, 169, 110, 0.3);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
html {
|
html {
|
||||||
scroll-behavior: auto;
|
scroll-behavior: auto;
|
||||||
@@ -32,6 +46,40 @@ html {
|
|||||||
/* ===== Focus ===== */
|
/* ===== Focus ===== */
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
@apply outline-2 outline-offset-2 outline-neutral-900;
|
@apply outline-2 outline-offset-2 outline-gold;
|
||||||
@apply dark:outline-white;
|
}
|
||||||
|
|
||||||
|
/* ===== 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,43 +13,31 @@ const oswald = Oswald({
|
|||||||
subsets: ["latin", "cyrillic"],
|
subsets: ["latin", "cyrillic"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export function generateMetadata(): Metadata {
|
||||||
title: siteContent.meta.title,
|
const { meta } = getContent();
|
||||||
description: siteContent.meta.description,
|
return {
|
||||||
openGraph: {
|
title: meta.title,
|
||||||
title: "BLACK HEART DANCE HOUSE",
|
description: meta.description,
|
||||||
description: siteContent.meta.description,
|
openGraph: {
|
||||||
locale: "ru_RU",
|
title: meta.title,
|
||||||
type: "website",
|
description: meta.description,
|
||||||
},
|
locale: "ru_RU",
|
||||||
};
|
type: "website",
|
||||||
|
},
|
||||||
const themeScript = `
|
};
|
||||||
(function() {
|
}
|
||||||
var stored = localStorage.getItem('theme');
|
|
||||||
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
if (stored === 'dark' || (!stored && prefersDark)) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="ru" suppressHydrationWarning>
|
<html lang="ru" className="dark">
|
||||||
<head>
|
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
|
||||||
</head>
|
|
||||||
<body
|
<body
|
||||||
className={`${inter.variable} ${oswald.variable} surface-base font-sans antialiased`}
|
className={`${inter.variable} ${oswald.variable} surface-base font-sans antialiased`}
|
||||||
>
|
>
|
||||||
<Header />
|
{children}
|
||||||
<main className="pt-16">{children}</main>
|
|
||||||
<Footer />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Header } from "@/components/layout/Header";
|
||||||
|
import { Footer } from "@/components/layout/Footer";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 text-center">
|
<>
|
||||||
<h1 className="font-display text-6xl font-bold">404</h1>
|
<Header />
|
||||||
<p className="body-text mt-4 text-lg">Страница не найдена</p>
|
<main>
|
||||||
<div className="mt-8">
|
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 text-center">
|
||||||
<Button href="/">На главную</Button>
|
<h1 className="font-display text-6xl font-bold">404</h1>
|
||||||
</div>
|
<p className="body-text mt-4 text-lg">Страница не найдена</p>
|
||||||
</div>
|
<div className="mt-8">
|
||||||
|
<Button href="/">На главную</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,48 @@ 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 { 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 />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
@keyframes hero-fade-in-up {
|
@keyframes hero-fade-in-up {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(24px);
|
transform: translateY(32px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -14,11 +14,77 @@
|
|||||||
@keyframes hero-fade-in-scale {
|
@keyframes hero-fade-in-scale {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.85);
|
transform: scale(0.8);
|
||||||
|
filter: blur(10px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradient-shift {
|
||||||
|
0%, 100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.4;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes heartbeat {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
15% {
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
45% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes heart-float {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0) scale(0.5);
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-100vh) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,22 +92,108 @@
|
|||||||
|
|
||||||
.hero-logo {
|
.hero-logo {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
animation: hero-fade-in-scale 1s ease-out 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 0.8s ease-out 0.4s forwards;
|
animation: hero-fade-in-up 1s cubic-bezier(0.16, 1, 0.3, 1) 0.5s forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-subtitle {
|
.hero-subtitle {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
animation: hero-fade-in-up 0.8s ease-out 0.7s forwards;
|
animation: hero-fade-in-up 1s cubic-bezier(0.16, 1, 0.3, 1) 0.8s forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-cta {
|
.hero-cta {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
animation: hero-fade-in-up 0.8s ease-out 1s forwards;
|
animation: hero-fade-in-up 1s cubic-bezier(0.16, 1, 0.3, 1) 1.1s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Hero Background ===== */
|
||||||
|
|
||||||
|
.hero-bg-gradient {
|
||||||
|
background: radial-gradient(ellipse 80% 60% at 50% -20%, rgba(201, 169, 110, 0.12), transparent),
|
||||||
|
radial-gradient(ellipse 60% 40% at 80% 50%, rgba(201, 169, 110, 0.06), transparent),
|
||||||
|
radial-gradient(ellipse 60% 40% at 20% 80%, rgba(201, 169, 110, 0.04), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-glow-orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(80px);
|
||||||
|
animation: pulse-glow 6s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: filter, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Gradient Text ===== */
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
#8a6f3e 0%,
|
||||||
|
#c9a96e 20%,
|
||||||
|
#8a6f3e 40%,
|
||||||
|
#c9a96e 60%,
|
||||||
|
#6b5530 80%,
|
||||||
|
#8a6f3e 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
animation: gradient-shift 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode gradient text */
|
||||||
|
.gradient-text-light {
|
||||||
|
background: linear-gradient(135deg, #171717 0%, #c9a96e 50%, #171717 100%);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
animation: gradient-shift 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Animated Border ===== */
|
||||||
|
|
||||||
|
.animated-border {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-border::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(135deg, rgba(201, 169, 110, 0.3), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.15));
|
||||||
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-border:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
background: linear-gradient(135deg, rgba(201, 169, 110, 0.6), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Glow Effect ===== */
|
||||||
|
|
||||||
|
.glow-hover {
|
||||||
|
transition: box-shadow 0.5s ease, transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-hover:hover {
|
||||||
|
box-shadow: 0 0 30px rgba(201, 169, 110, 0.1), 0 0 60px rgba(201, 169, 110, 0.05);
|
||||||
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Scroll Reveal ===== */
|
/* ===== Scroll Reveal ===== */
|
||||||
@@ -49,7 +201,7 @@
|
|||||||
.reveal {
|
.reveal {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(30px);
|
transform: translateY(30px);
|
||||||
transition: opacity 0.7s ease-out, transform 0.7s ease-out;
|
transition: opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1), transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reveal.visible {
|
.reveal.visible {
|
||||||
@@ -57,6 +209,145 @@
|
|||||||
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 ===== */
|
||||||
|
|
||||||
|
@keyframes modal-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-overlay-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
animation: modal-overlay-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
animation: modal-fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Team Info Fade ===== */
|
||||||
|
|
||||||
|
@keyframes team-info-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Team Card Glitter ===== */
|
||||||
|
|
||||||
|
@keyframes glitter-move {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 0%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 200%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-glitter {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated gold border glow */
|
||||||
|
.team-card-glitter::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
transparent 20%,
|
||||||
|
rgba(201, 169, 110, 0.6) 30%,
|
||||||
|
rgba(212, 184, 122, 1) 35%,
|
||||||
|
transparent 45%,
|
||||||
|
transparent 55%,
|
||||||
|
rgba(201, 169, 110, 0.5) 65%,
|
||||||
|
rgba(212, 184, 122, 0.9) 70%,
|
||||||
|
transparent 80%
|
||||||
|
);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: glitter-move 3s linear infinite;
|
||||||
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
will-change: background-position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Notification Pulse ===== */
|
||||||
|
|
||||||
|
@keyframes pulse-urgent {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-urgent {
|
||||||
|
animation: pulse-urgent 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Section Divider ===== */
|
||||||
|
|
||||||
|
.section-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(201, 169, 110, 0.15), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Reduced Motion ===== */
|
/* ===== Reduced Motion ===== */
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -66,6 +357,7 @@
|
|||||||
.hero-cta {
|
.hero-cta {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
|
filter: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reveal {
|
.reveal {
|
||||||
@@ -73,4 +365,32 @@
|
|||||||
transform: none !important;
|
transform: none !important;
|
||||||
transition: none !important;
|
transition: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-overlay,
|
||||||
|
.modal-content {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text,
|
||||||
|
.gradient-text-light {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-glow-orb,
|
||||||
|
.hero-logo-heartbeat {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-detail-enter,
|
||||||
|
.showcase-detail-enter img {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-hover:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-card-glitter::before {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,29 @@
|
|||||||
/* ===== Navigation ===== */
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
@apply text-sm font-medium text-neutral-600 transition-colors duration-200;
|
|
||||||
@apply hover:text-neutral-900;
|
|
||||||
@apply dark:text-neutral-400 dark:hover:text-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-icon {
|
|
||||||
@apply text-neutral-500 transition-colors duration-200;
|
|
||||||
@apply hover:text-neutral-900;
|
|
||||||
@apply dark:text-neutral-400 dark:hover:text-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Cards ===== */
|
|
||||||
|
|
||||||
.card {
|
|
||||||
@apply rounded-2xl border p-6 transition-all duration-200 cursor-pointer;
|
|
||||||
@apply border-neutral-200 bg-neutral-50;
|
|
||||||
@apply hover:border-neutral-400 hover:shadow-lg;
|
|
||||||
@apply dark:border-neutral-800 dark:bg-neutral-900;
|
|
||||||
@apply dark:hover:border-neutral-600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Buttons ===== */
|
/* ===== Buttons ===== */
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply inline-flex items-center justify-center font-medium rounded-full transition-colors duration-200 cursor-pointer;
|
@apply inline-flex items-center justify-center font-semibold rounded-full transition-all duration-300 cursor-pointer;
|
||||||
@apply bg-neutral-900 text-white;
|
@apply bg-gold text-black;
|
||||||
@apply hover:bg-neutral-700;
|
@apply hover:bg-gold-light hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
|
||||||
@apply dark:bg-white dark:text-neutral-900;
|
@apply dark:bg-gold dark:text-black;
|
||||||
@apply dark:hover:bg-neutral-200;
|
@apply dark:hover:bg-gold-light dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline {
|
/* ===== Scrollbar ===== */
|
||||||
@apply inline-flex items-center justify-center font-medium rounded-full transition-colors duration-200 cursor-pointer;
|
|
||||||
@apply border border-neutral-900 text-neutral-900;
|
.modal-content {
|
||||||
@apply hover:bg-neutral-900 hover:text-white;
|
scrollbar-color: rgb(163 163 163) transparent;
|
||||||
@apply dark:border-white dark:text-white;
|
|
||||||
@apply dark:hover:bg-white dark:hover:text-neutral-900;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
.modal-content * {
|
||||||
@apply inline-flex items-center justify-center font-medium rounded-full transition-colors duration-200 cursor-pointer;
|
scrollbar-color: rgb(163 163 163) transparent;
|
||||||
@apply text-neutral-600;
|
|
||||||
@apply hover:text-neutral-900;
|
|
||||||
@apply dark:text-neutral-400 dark:hover:text-white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Contact ===== */
|
@variant dark {
|
||||||
|
.modal-content {
|
||||||
|
scrollbar-color: rgb(64 64 64) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.contact-item {
|
.modal-content * {
|
||||||
@apply flex items-center gap-4;
|
scrollbar-color: rgb(64 64 64) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-icon {
|
|
||||||
@apply shrink-0 text-neutral-900;
|
|
||||||
@apply dark:text-neutral-50;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,125 @@
|
|||||||
/* ===== Surfaces ===== */
|
/* ===== Surfaces ===== */
|
||||||
|
|
||||||
.surface-base {
|
.surface-base {
|
||||||
@apply bg-white text-neutral-900;
|
@apply bg-neutral-50 text-neutral-900;
|
||||||
@apply dark:bg-neutral-950 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-neutral-900;
|
@apply dark:bg-[#080808];
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-glass {
|
.surface-glass {
|
||||||
@apply bg-white/80 backdrop-blur-md;
|
@apply bg-white/70 backdrop-blur-xl;
|
||||||
@apply dark:bg-neutral-950/80;
|
@apply dark:bg-black/40 dark:backdrop-blur-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-card {
|
||||||
|
@apply bg-white/80 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-neutral-800;
|
@apply dark:border-white/[0.08];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Text ===== */
|
/* ===== Text ===== */
|
||||||
|
|
||||||
.heading-text {
|
.heading-text {
|
||||||
@apply text-neutral-900;
|
@apply text-neutral-900;
|
||||||
@apply dark:text-neutral-50;
|
@apply dark:text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
||||||
@apply text-neutral-500;
|
@apply text-neutral-500;
|
||||||
@apply dark:text-neutral-400;
|
@apply dark:text-neutral-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-text {
|
||||||
|
@apply text-gold-dark;
|
||||||
|
@apply dark:text-gold-light;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Layout ===== */
|
/* ===== Layout ===== */
|
||||||
|
|
||||||
.section-padding {
|
.section-padding {
|
||||||
@apply py-10 sm:py-14;
|
@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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { BRAND } from "@/lib/constants";
|
import { BRAND } from "@/lib/constants";
|
||||||
import { siteContent } from "@/data/content";
|
import { Heart } from "lucide-react";
|
||||||
import { SocialLinks } from "@/components/ui/SocialLinks";
|
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const { contact } = siteContent;
|
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="surface-muted theme-border border-t">
|
<footer className="relative border-t border-neutral-200 bg-neutral-100 dark:border-white/[0.08] dark:bg-[#050505]">
|
||||||
<div className="section-container flex flex-col items-center gap-4 py-8 sm:flex-row sm:justify-between">
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
<p className="muted-text text-sm">
|
<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">
|
||||||
© {year} {BRAND.name}
|
© {year} {BRAND.name}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-neutral-500">
|
||||||
|
<span>Made with</span>
|
||||||
|
<Heart size={14} className="fill-gold text-gold" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,67 +1,187 @@
|
|||||||
"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 } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { BRAND, NAV_LINKS } from "@/lib/constants";
|
import { BRAND, NAV_LINKS } from "@/lib/constants";
|
||||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
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 [activeSection, setActiveSection] = useState("");
|
||||||
|
const [bookingOpen, setBookingOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ticking = false;
|
||||||
|
function handleScroll() {
|
||||||
|
if (!ticking) {
|
||||||
|
ticking = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setScrolled(window.scrollY > UI_CONFIG.scrollThresholds.header);
|
||||||
|
ticking = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Listen for booking open events from other components
|
||||||
|
useEffect(() => {
|
||||||
|
function onOpenBooking() {
|
||||||
|
setBookingOpen(true);
|
||||||
|
}
|
||||||
|
window.addEventListener("open-booking", onOpenBooking);
|
||||||
|
return () => window.removeEventListener("open-booking", onOpenBooking);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter out nav links whose target section doesn't exist on the page
|
||||||
|
const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS);
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleLinks(NAV_LINKS.filter((l) => document.getElementById(l.href.replace("#", ""))));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sectionIds = visibleLinks.map((l) => l.href.replace("#", ""));
|
||||||
|
const observers: IntersectionObserver[] = [];
|
||||||
|
|
||||||
|
// Observe hero — when visible, clear active section
|
||||||
|
const hero = document.querySelector("section:first-of-type");
|
||||||
|
if (hero) {
|
||||||
|
const heroObserver = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) setActiveSection("");
|
||||||
|
},
|
||||||
|
{ rootMargin: "-20% 0px -70% 0px" },
|
||||||
|
);
|
||||||
|
heroObserver.observe(hero);
|
||||||
|
observers.push(heroObserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionIds.forEach((id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setActiveSection(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "-40% 0px -55% 0px" },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
observers.push(observer);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observers.forEach((o) => o.disconnect());
|
||||||
|
}, [visibleLinks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="surface-glass theme-border fixed top-0 z-50 w-full border-b">
|
<header
|
||||||
<div className="flex h-16 items-center justify-between px-6 sm:px-8">
|
className={`fixed top-0 z-50 w-full transition-all duration-500 ${
|
||||||
<Link href="/" className="flex items-center gap-2">
|
scrolled
|
||||||
<Image
|
? "bg-black/40 shadow-none backdrop-blur-xl"
|
||||||
src="/images/logo.png"
|
: "bg-transparent"
|
||||||
alt={BRAND.name}
|
}`}
|
||||||
width={32}
|
>
|
||||||
height={32}
|
<div className="flex h-16 items-center justify-between px-6 sm:px-10 lg:px-16">
|
||||||
unoptimized
|
<Link href="/" className="group flex items-center gap-2.5">
|
||||||
className="dark:invert"
|
<div className="relative flex h-8 w-8 items-center justify-center">
|
||||||
/>
|
<div
|
||||||
<span className="font-display text-lg font-bold tracking-tight">
|
className="absolute inset-0 rounded-full transition-all duration-300 group-hover:scale-125"
|
||||||
|
style={{
|
||||||
|
background: "radial-gradient(circle, rgba(201,169,110,0.5) 0%, rgba(201,169,110,0.15) 50%, transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<HeroLogo
|
||||||
|
size={24}
|
||||||
|
className="relative text-black transition-transform duration-300 drop-shadow-[0_0_3px_rgba(201,169,110,0.5)] group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="font-display text-lg font-bold tracking-tight text-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) => {
|
||||||
<a key={link.href} href={link.href} className="nav-link">
|
const isActive = activeSection === link.href.replace("#", "");
|
||||||
{link.label}
|
return (
|
||||||
</a>
|
<a
|
||||||
))}
|
key={link.href}
|
||||||
<ThemeToggle />
|
href={link.href}
|
||||||
|
className={`relative whitespace-nowrap py-1 text-xs lg:text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${
|
||||||
|
isActive
|
||||||
|
? "text-gold-light after:w-full"
|
||||||
|
: "text-neutral-400 after:w-0 hover:text-white hover:after:w-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => setBookingOpen(true)}
|
||||||
|
className="rounded-full bg-gold px-4 py-1.5 text-sm font-semibold text-black transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
Записаться
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 md:hidden">
|
<div className="flex items-center gap-2 lg:hidden">
|
||||||
<ThemeToggle />
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
aria-label="Меню"
|
aria-label={menuOpen ? "Закрыть меню" : "Открыть меню"}
|
||||||
className="body-text rounded-md p-2"
|
aria-expanded={menuOpen}
|
||||||
|
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} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{menuOpen && (
|
{/* Mobile menu */}
|
||||||
<nav className="surface-base theme-border border-t px-6 py-4 sm:px-8 md:hidden">
|
<div
|
||||||
{NAV_LINKS.map((link) => (
|
className={`overflow-hidden transition-all duration-300 lg:hidden ${
|
||||||
<a
|
menuOpen ? "max-h-80 opacity-100" : "max-h-0 opacity-0"
|
||||||
key={link.href}
|
}`}
|
||||||
href={link.href}
|
>
|
||||||
onClick={() => setMenuOpen(false)}
|
<nav className="border-t border-white/[0.06] bg-black/40 px-6 py-4 backdrop-blur-xl sm:px-8">
|
||||||
className="nav-link block py-3"
|
{visibleLinks.map((link) => {
|
||||||
>
|
const isActive = activeSection === link.href.replace("#", "");
|
||||||
{link.label}
|
return (
|
||||||
</a>
|
<a
|
||||||
))}
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
className={`block py-3 text-base transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "text-gold-light"
|
||||||
|
: "text-neutral-400 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
setBookingOpen(true);
|
||||||
|
}}
|
||||||
|
className="mt-2 w-full rounded-full bg-gold py-3 text-sm font-semibold text-black transition-all hover:bg-gold-light cursor-pointer"
|
||||||
|
>
|
||||||
|
Записаться
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<SignupModal open={bookingOpen} onClose={() => setBookingOpen(false)} endpoint="/api/group-booking" />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +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 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="surface-muted section-padding">
|
<section id="about" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]">
|
||||||
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
<div className="section-container">
|
<div className="section-container">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeading>{about.title}</SectionHeading>
|
<SectionHeading centered>{about.title}</SectionHeading>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-8 max-w-3xl space-y-4">
|
<div className="mt-14 mx-auto max-w-2xl space-y-8 text-center">
|
||||||
{about.paragraphs.map((text, i) => (
|
{about.paragraphs.map((text) => (
|
||||||
<Reveal key={i}>
|
<Reveal key={text}>
|
||||||
<p className="body-text text-lg leading-relaxed">{text}</p>
|
<p className="text-xl leading-relaxed text-neutral-600 dark:text-neutral-300 sm:text-2xl">
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
</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,37 +1,115 @@
|
|||||||
import { Flame, Sparkles, Wind, Zap, Star, Monitor } from "lucide-react";
|
"use client";
|
||||||
import { siteContent } from "@/data/content";
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { icons } 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 { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
|
||||||
|
import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
|
||||||
|
import type { ClassItem, SiteContent } from "@/types";
|
||||||
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
|
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
// kebab "heart-pulse" → PascalCase "HeartPulse"
|
||||||
flame: <Flame size={32} />,
|
function toPascal(kebab: string) {
|
||||||
sparkles: <Sparkles size={32} />,
|
return kebab.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
||||||
wind: <Wind size={32} />,
|
}
|
||||||
zap: <Zap size={32} />,
|
|
||||||
star: <Star size={32} />,
|
|
||||||
monitor: <Monitor size={32} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Classes() {
|
function getIcon(key: string) {
|
||||||
const { classes } = siteContent;
|
const Icon = icons[toPascal(key) as keyof typeof icons];
|
||||||
|
return Icon ? <Icon size={20} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassesProps {
|
||||||
|
data: SiteContent["classes"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Classes({ data: classes }: ClassesProps) {
|
||||||
|
const { activeIndex, select, setHovering } = useShowcaseRotation({
|
||||||
|
totalItems: classes.items.length,
|
||||||
|
autoPlayInterval: UI_CONFIG.showcase.autoPlayInterval,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="classes" className="surface-muted section-padding">
|
<section id="classes" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]">
|
||||||
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
<div className="section-container">
|
<div className="section-container">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeading>{classes.title}</SectionHeading>
|
<SectionHeading centered>{classes.title}</SectionHeading>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-12 grid gap-6 sm:grid-cols-2">
|
<div className="mt-14">
|
||||||
{classes.items.map((item) => (
|
<Reveal>
|
||||||
<Reveal key={item.name}>
|
<ShowcaseLayout<ClassItem>
|
||||||
<div className="card">
|
items={classes.items}
|
||||||
<div className="heading-text">{iconMap[item.icon]}</div>
|
activeIndex={activeIndex}
|
||||||
<h3 className="heading-text mt-4 text-xl font-semibold">{item.name}</h3>
|
onSelect={select}
|
||||||
<p className="body-text mt-2">{item.description}</p>
|
onHoverChange={setHovering}
|
||||||
</div>
|
renderDetail={(item) => (
|
||||||
</Reveal>
|
<div>
|
||||||
))}
|
{/* Hero image */}
|
||||||
|
{item.images && item.images[0] && (
|
||||||
|
<div className="team-card-glitter relative aspect-[16/9] w-full overflow-hidden rounded-2xl">
|
||||||
|
<Image
|
||||||
|
src={item.images[0]}
|
||||||
|
alt={item.name}
|
||||||
|
fill
|
||||||
|
loading="lazy"
|
||||||
|
sizes="(min-width: 1024px) 60vw, 100vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
{/* Gradient overlay */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||||
|
|
||||||
|
{/* Icon + name overlay */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-6">
|
||||||
|
<div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-gold/20 text-gold-light backdrop-blur-sm">
|
||||||
|
{getIcon(item.icon)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-bold text-white">
|
||||||
|
{item.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,69 +1,73 @@
|
|||||||
import { MapPin, Phone, Mail, 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="surface-base section-padding">
|
<section id="contact" className="relative section-padding bg-neutral-50 dark:bg-[#050505]">
|
||||||
<div className="section-container grid items-start gap-12 lg:grid-cols-2">
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
|
<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-12 space-y-6">
|
<div className="mt-10 space-y-5">
|
||||||
{contact.addresses.map((address, i) => (
|
{contact.addresses.map((address, i) => (
|
||||||
<div key={i} className="contact-item">
|
<div key={i} className="group flex items-center gap-4">
|
||||||
<MapPin size={20} className="contact-icon" />
|
<IconBadge><MapPin size={18} /></IconBadge>
|
||||||
<p className="body-text">{address}</p>
|
<p className="body-text">{address}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="contact-item">
|
<div className="group flex items-center gap-4">
|
||||||
<Phone size={20} className="contact-icon" />
|
<IconBadge><Phone size={18} /></IconBadge>
|
||||||
<a href={`tel:${contact.phone}`} className="nav-link text-base">
|
<a
|
||||||
|
href={`tel:${contact.phone}`}
|
||||||
|
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="contact-item">
|
<div className="group flex items-center gap-4">
|
||||||
<Mail size={20} className="contact-icon" />
|
<IconBadge><Clock size={18} /></IconBadge>
|
||||||
<a href={`mailto:${contact.email}`} className="nav-link text-base">
|
|
||||||
{contact.email}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="contact-item">
|
|
||||||
<Clock size={20} className="contact-icon" />
|
|
||||||
<p className="body-text">{contact.workingHours}</p>
|
<p className="body-text">{contact.workingHours}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="theme-border contact-item border-t pt-6">
|
<div className="border-t border-neutral-200 pt-5 dark:border-white/[0.08]">
|
||||||
<Instagram size={20} className="contact-icon" />
|
<div className="group flex items-center gap-4">
|
||||||
<a
|
<IconBadge><Instagram size={18} /></IconBadge>
|
||||||
href={contact.instagram}
|
<a
|
||||||
target="_blank"
|
href={contact.instagram}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="nav-link text-base"
|
rel="noopener noreferrer"
|
||||||
>
|
className="text-neutral-600 transition-colors hover:text-gold-dark dark:text-neutral-300 dark:hover:text-gold-light"
|
||||||
{BRAND.instagramHandle}
|
>
|
||||||
</a>
|
{BRAND.instagramHandle}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="theme-border overflow-hidden rounded-2xl border">
|
<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%"
|
||||||
height="350"
|
height="380"
|
||||||
style={{ border: 0 }}
|
style={{ border: 0 }}
|
||||||
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
@@ -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,35 +1,133 @@
|
|||||||
import Image from "next/image";
|
"use client";
|
||||||
import { siteContent } from "@/data/content";
|
|
||||||
import { BRAND } from "@/lib/constants";
|
|
||||||
import { Button } from "@/components/ui/Button";
|
|
||||||
|
|
||||||
export function Hero() {
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
const { hero } = siteContent;
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { FloatingHearts } from "@/components/ui/FloatingHearts";
|
||||||
|
import { HeroLogo } from "@/components/ui/HeroLogo";
|
||||||
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
|
interface HeroProps {
|
||||||
|
data: SiteContent["hero"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hero({ data: hero }: HeroProps) {
|
||||||
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
|
const scrolledRef = useRef(false);
|
||||||
|
|
||||||
|
const scrollToNext = useCallback(() => {
|
||||||
|
const hero = sectionRef.current;
|
||||||
|
if (!hero) return;
|
||||||
|
// Find the next sibling section
|
||||||
|
let next = hero.nextElementSibling;
|
||||||
|
while (next && next.tagName !== "SECTION") {
|
||||||
|
next = next.nextElementSibling;
|
||||||
|
}
|
||||||
|
next?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hero = sectionRef.current;
|
||||||
|
if (!hero) return;
|
||||||
|
|
||||||
|
function handleWheel(e: WheelEvent) {
|
||||||
|
// Only trigger when scrolling down and still inside hero
|
||||||
|
if (e.deltaY <= 0 || scrolledRef.current) return;
|
||||||
|
if (window.scrollY > 10) return; // already scrolled past hero top
|
||||||
|
|
||||||
|
scrolledRef.current = true;
|
||||||
|
scrollToNext();
|
||||||
|
|
||||||
|
// Reset after animation completes
|
||||||
|
setTimeout(() => { scrolledRef.current = false; }, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchStart(e: TouchEvent) {
|
||||||
|
(hero as HTMLElement).dataset.touchY = String(e.touches[0].clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd(e: TouchEvent) {
|
||||||
|
const startY = Number((hero as HTMLElement).dataset.touchY);
|
||||||
|
const endY = e.changedTouches[0].clientY;
|
||||||
|
const diff = startY - endY;
|
||||||
|
|
||||||
|
// Swipe down (finger moves up) with enough distance
|
||||||
|
if (diff > 50 && !scrolledRef.current && window.scrollY < 10) {
|
||||||
|
scrolledRef.current = true;
|
||||||
|
scrollToNext();
|
||||||
|
setTimeout(() => { scrolledRef.current = false; }, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hero.addEventListener("wheel", handleWheel, { passive: true });
|
||||||
|
hero.addEventListener("touchstart", handleTouchStart, { passive: true });
|
||||||
|
hero.addEventListener("touchend", handleTouchEnd, { passive: true });
|
||||||
|
return () => {
|
||||||
|
hero.removeEventListener("wheel", handleWheel);
|
||||||
|
hero.removeEventListener("touchstart", handleTouchStart);
|
||||||
|
hero.removeEventListener("touchend", handleTouchEnd);
|
||||||
|
};
|
||||||
|
}, [scrollToNext]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="surface-base section-container flex min-h-svh items-center justify-center">
|
<section ref={sectionRef} className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
|
||||||
<div className="text-center">
|
{/* Animated gradient background */}
|
||||||
<Image
|
<div className="hero-bg-gradient absolute inset-0" />
|
||||||
src="/images/logo.png"
|
|
||||||
alt={BRAND.name}
|
{/* Glow orbs */}
|
||||||
width={280}
|
<div
|
||||||
height={280}
|
className="hero-glow-orb"
|
||||||
priority
|
style={{
|
||||||
unoptimized
|
width: "500px",
|
||||||
className="hero-logo mx-auto mb-8 dark:invert"
|
height: "500px",
|
||||||
/>
|
top: "-10%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
background: "radial-gradient(circle, rgba(201, 169, 110, 0.12), transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="hero-glow-orb"
|
||||||
|
style={{
|
||||||
|
width: "300px",
|
||||||
|
height: "300px",
|
||||||
|
bottom: "10%",
|
||||||
|
right: "10%",
|
||||||
|
background: "radial-gradient(circle, rgba(201, 169, 110, 0.08), transparent 70%)",
|
||||||
|
animationDelay: "3s",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Floating hearts */}
|
||||||
|
<FloatingHearts />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="section-container relative z-10 text-center">
|
||||||
|
<div className="hero-logo relative mx-auto mb-10 flex items-center justify-center" style={{ width: 220, height: 181 }}>
|
||||||
|
{/* Soft ambient glow behind heart */}
|
||||||
|
<div className="absolute -inset-10 rounded-full blur-[80px]" style={{ background: "radial-gradient(circle, rgba(201,169,110,0.25), transparent 70%)" }} />
|
||||||
|
<div className="hero-logo-heartbeat relative">
|
||||||
|
<HeroLogo
|
||||||
|
size={220}
|
||||||
|
className="drop-shadow-[0_0_10px_rgba(201,169,110,0.35)] drop-shadow-[0_0_40px_rgba(201,169,110,0.15)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h1 className="hero-title font-display text-5xl font-bold tracking-tight sm:text-6xl lg:text-8xl">
|
<h1 className="hero-title font-display text-5xl font-bold tracking-tight sm:text-6xl lg:text-8xl">
|
||||||
{hero.headline}
|
<span className="gradient-text">{hero.headline}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="hero-subtitle body-text mx-auto mt-6 max-w-md text-lg 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-10">
|
|
||||||
<Button href={hero.ctaHref} size="lg">
|
<div className="hero-cta mt-12">
|
||||||
|
<Button size="lg" onClick={() => window.dispatchEvent(new Event("open-booking"))}>
|
||||||
{hero.ctaText}
|
{hero.ctaText}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
227
src/components/sections/Pricing.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CreditCard, Building2, ScrollText, Crown, Sparkles, Instagram, Send, Phone } from "lucide-react";
|
||||||
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
|
import { BRAND } from "@/lib/constants";
|
||||||
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
|
type Tab = "prices" | "rental" | "rules";
|
||||||
|
|
||||||
|
interface PricingProps {
|
||||||
|
data: SiteContent["pricing"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContactHint() {
|
||||||
|
return (
|
||||||
|
<div className="mt-5 flex flex-wrap items-center justify-center gap-3 text-xs text-neutral-500">
|
||||||
|
<span>Для записи и бронирования:</span>
|
||||||
|
<a
|
||||||
|
href={BRAND.instagram}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-white/[0.06] px-3 py-1.5 text-pink-400 hover:text-pink-300 hover:border-pink-400/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Instagram size={12} />
|
||||||
|
Instagram
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://t.me/blackheartdancehouse"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-white/[0.06] px-3 py-1.5 text-blue-400 hover:text-blue-300 hover:border-blue-400/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Send size={12} />
|
||||||
|
Telegram
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="tel:+375293897001"
|
||||||
|
className="inline-flex items-center gap-1 rounded-full border border-white/[0.06] px-3 py-1.5 text-emerald-400 hover:text-emerald-300 hover:border-emerald-400/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Phone size={12} />
|
||||||
|
Позвонить
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pricing({ data: pricing }: PricingProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>("prices");
|
||||||
|
const showHint = pricing.showContactHint !== false; // default true
|
||||||
|
|
||||||
|
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ id: "prices", label: "Абонементы", icon: <CreditCard size={16} /> },
|
||||||
|
{ id: "rental", label: "Аренда зала", icon: <Building2 size={16} /> },
|
||||||
|
{ id: "rules", label: "Правила", icon: <ScrollText size={16} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Split items: featured (big card) vs regular
|
||||||
|
const featuredItem = pricing.items.find((item) => item.featured);
|
||||||
|
const regularItems = pricing.items.filter((item) => !item.featured);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="pricing" className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505]">
|
||||||
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
|
<div className="section-container">
|
||||||
|
<Reveal>
|
||||||
|
<SectionHeading centered>{pricing.title}</SectionHeading>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Reveal>
|
||||||
|
<div className="mt-12 flex flex-wrap justify-center gap-2">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`inline-flex items-center gap-2 rounded-full px-6 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? "bg-gold text-black shadow-lg shadow-gold/25"
|
||||||
|
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-white/[0.06] dark:text-neutral-300 dark:hover:bg-white/[0.1]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Prices tab */}
|
||||||
|
{activeTab === "prices" && (
|
||||||
|
<Reveal>
|
||||||
|
<div className="mx-auto mt-10 max-w-4xl">
|
||||||
|
<p className="mb-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{pricing.subtitle}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Cards grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{regularItems.map((item, i) => {
|
||||||
|
const isPopular = item.popular ?? false;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`group relative rounded-2xl border p-5 transition-all duration-300 ${
|
||||||
|
isPopular
|
||||||
|
? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10"
|
||||||
|
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Popular badge */}
|
||||||
|
{isPopular && (
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-gold px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-black shadow-md shadow-gold/30">
|
||||||
|
<Sparkles size={10} />
|
||||||
|
Популярный
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={isPopular ? "mt-1" : ""}>
|
||||||
|
{/* Name */}
|
||||||
|
<p className={`text-sm font-medium ${isPopular ? "text-gold-dark dark:text-gold-light" : "text-neutral-700 dark:text-neutral-300"}`}>
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Note */}
|
||||||
|
{item.note && (
|
||||||
|
<p className="mt-1 text-xs text-neutral-400 dark:text-neutral-500">
|
||||||
|
{item.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
<p className={`mt-3 font-display text-2xl font-bold ${isPopular ? "text-gold" : "text-neutral-900 dark:text-white"}`}>
|
||||||
|
{item.price}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Featured — big card */}
|
||||||
|
{featuredItem && (
|
||||||
|
<div className="mt-6 w-full team-card-glitter rounded-2xl border border-gold/30 bg-gradient-to-r from-gold/10 via-gold/5 to-gold/10 dark:from-gold/[0.06] dark:via-transparent dark:to-gold/[0.06] p-6 sm:p-8">
|
||||||
|
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
||||||
|
<div className="text-center sm:text-left">
|
||||||
|
<div className="flex items-center justify-center gap-2 sm:justify-start">
|
||||||
|
<Crown size={18} className="text-gold" />
|
||||||
|
<p className="text-lg font-bold text-neutral-900 dark:text-white">
|
||||||
|
{featuredItem.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{featuredItem.note && (
|
||||||
|
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{featuredItem.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="shrink-0 font-display text-3xl font-bold text-gold">
|
||||||
|
{featuredItem.price}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showHint && <ContactHint />}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rental tab */}
|
||||||
|
{activeTab === "rental" && (
|
||||||
|
<Reveal>
|
||||||
|
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
||||||
|
{pricing.rentalItems.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-neutral-900 dark:text-white">
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
{item.note && (
|
||||||
|
<p className="mt-0.5 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{item.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 font-display text-xl font-bold text-gold-dark dark:text-gold-light">
|
||||||
|
{item.price}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showHint && <ContactHint />}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rules tab */}
|
||||||
|
{activeTab === "rules" && (
|
||||||
|
<Reveal>
|
||||||
|
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
||||||
|
{pricing.rules.map((rule, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex gap-4 rounded-2xl border border-neutral-200 bg-white px-5 py-4 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
|
||||||
|
{rule}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,52 +1,72 @@
|
|||||||
import Image from "next/image";
|
"use client";
|
||||||
import { Instagram } from "lucide-react";
|
|
||||||
import { siteContent } from "@/data/content";
|
import { useState } from "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 { TeamCarousel } from "@/components/sections/team/TeamCarousel";
|
||||||
|
import { TeamMemberInfo } from "@/components/sections/team/TeamMemberInfo";
|
||||||
|
import { TeamProfile } from "@/components/sections/team/TeamProfile";
|
||||||
|
import type { SiteContent, ScheduleLocation } from "@/types/content";
|
||||||
|
|
||||||
export function Team() {
|
interface TeamProps {
|
||||||
const { team } = siteContent;
|
data: SiteContent["team"];
|
||||||
|
schedule?: ScheduleLocation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Team({ data: team, schedule }: TeamProps) {
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="team" className="surface-base section-padding">
|
<section
|
||||||
|
id="team"
|
||||||
|
className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505] overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
|
|
||||||
|
{/* Stage spotlight glow */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(ellipse 50% 70% at 50% 30%, rgba(201,169,110,0.07) 0%, transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="section-container">
|
<div className="section-container">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeading>{team.title}</SectionHeading>
|
<SectionHeading centered>{team.title}</SectionHeading>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{team.members.map((member, i) => (
|
|
||||||
<Reveal key={i}>
|
|
||||||
<div className="card text-center">
|
|
||||||
<div className="mx-auto h-32 w-32 overflow-hidden rounded-full">
|
|
||||||
<Image
|
|
||||||
src={member.image}
|
|
||||||
alt={member.name}
|
|
||||||
width={128}
|
|
||||||
height={128}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h3 className="heading-text mt-4 text-lg font-semibold">{member.name}</h3>
|
|
||||||
<p className="muted-text mt-1 text-sm">{member.role}</p>
|
|
||||||
{member.instagram && (
|
|
||||||
<a
|
|
||||||
href={member.instagram}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="nav-link mt-2 inline-flex items-center gap-1 text-sm"
|
|
||||||
>
|
|
||||||
<Instagram size={14} />
|
|
||||||
<span>
|
|
||||||
@{member.instagram.split("/").filter(Boolean).pop()}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Reveal>
|
||||||
|
<div className="mt-10 px-4 sm:px-6">
|
||||||
|
{!showProfile ? (
|
||||||
|
<>
|
||||||
|
<TeamCarousel
|
||||||
|
members={team.members}
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
onActiveChange={setActiveIndex}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-6xl">
|
||||||
|
<TeamMemberInfo
|
||||||
|
members={team.members}
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
onSelect={setActiveIndex}
|
||||||
|
onOpenBio={() => setShowProfile(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<TeamProfile
|
||||||
|
member={team.members[activeIndex]}
|
||||||
|
onBack={() => setShowProfile(false)}
|
||||||
|
schedule={schedule}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { ChevronUp } from "lucide-react";
|
||||||
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
|
|
||||||
|
export function BackToTop() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleScroll() {
|
||||||
|
setVisible(window.scrollY > UI_CONFIG.scrollThresholds.backToTop);
|
||||||
|
}
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
|
aria-label="Наверх"
|
||||||
|
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-gold/30 bg-black/60 text-gold-light backdrop-blur-sm transition-all duration-300 hover:bg-gold/20 hover:border-gold/50 ${
|
||||||
|
visible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ChevronUp size={18} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
51
src/components/ui/FloatingHearts.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
|
|
||||||
|
interface Heart {
|
||||||
|
id: number;
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
delay: number;
|
||||||
|
duration: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloatingHearts() {
|
||||||
|
const [hearts, setHearts] = useState<Heart[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const generated: Heart[] = Array.from({ length: UI_CONFIG.team.floatingHeartsCount }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
left: Math.random() * 100,
|
||||||
|
size: 8 + Math.random() * 16,
|
||||||
|
delay: Math.random() * 10,
|
||||||
|
duration: 10 + Math.random() * 15,
|
||||||
|
opacity: 0.03 + Math.random() * 0.08,
|
||||||
|
}));
|
||||||
|
setHearts(generated);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (hearts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
|
{hearts.map((heart) => (
|
||||||
|
<div
|
||||||
|
key={heart.id}
|
||||||
|
className="absolute text-gold"
|
||||||
|
style={{
|
||||||
|
left: `${heart.left}%`,
|
||||||
|
bottom: "-20px",
|
||||||
|
fontSize: `${heart.size}px`,
|
||||||
|
opacity: heart.opacity,
|
||||||
|
animation: `heart-float ${heart.duration}s ease-in ${heart.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
♥
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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
@@ -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
@@ -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 (
|
||||||
<h2
|
<div className={centered ? "text-center" : ""}>
|
||||||
className={`font-display text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl ${className}`}
|
<h2
|
||||||
>
|
className={`font-display text-4xl font-bold uppercase tracking-wide sm:text-5xl lg:text-6xl gradient-text ${className}`}
|
||||||
{children}
|
>
|
||||||
<span className="mt-2 block h-1 w-16 rounded bg-neutral-900 dark:bg-white" />
|
{children}
|
||||||
</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
@@ -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
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,132 +24,435 @@ 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:
|
||||||
|
"Я тренер со специальной методикой для подготовки учеников в Pole Fitness, Pole Exotic и Strip хореографии. Научу вас базовым стойкам, перекатам, а также более сложным комбинациям и трюкам. В спорте более 30 лет — спортивная гимнастика, тайский бокс, артистическая деятельность. Призёр внутренних и международных чемпионатов по пилону и фитнесу. Судья чемпионатов по пилону и танцам. Основатель студии Black Heart Dance House.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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:
|
||||||
|
"Мощь и сила в каждой связке. Мои акцентные хореографии созданы для продвинутого уровня, где вы сможете раскрыть свой потенциал и почувствовать себя настоящей королевой танца. Готовьтесь к интенсивному погружению в мир уверенных движений и сложных элементов, где каждое занятие — это новый вызов и триумф!",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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:
|
||||||
|
"Вас ждут креативные хореографии, акцент на музыкальность и подачу, развитие уверенности и раскрытие вашей индивидуальности. Присоединяйтесь к тренировкам, где царит атмосфера радости и танцевального вдохновения! Мой вайб — «танцы — это радость».",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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:
|
||||||
|
"Я вдохновляющий лидер, который открывает двери в мир удивительного Pole Dance. С каждым занятием помогаю своим ученикам преодолевать собственные границы и достигать результатов, которые казались недостижимыми.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Галина Савицкая",
|
name: "Ирина Третьякович",
|
||||||
role: "Тренер",
|
role: "Exotic Pole Dance",
|
||||||
image: "/images/team/galina-savitskaya.webp",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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:
|
||||||
|
"Вас ждёт калейдоскоп эмоций: от сексуальной связки до нежной лирики и даже мистического драйва. Мои хореографии всегда энергичны и непредсказуемы, пробуждают самые смелые ваши стороны. Приготовьтесь к скоростному погружению в мир танца, где каждое движение — это вызов и откровение!",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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:
|
||||||
|
"Со мной вы научитесь кайфовать от себя и раскрывать свою сексуальность. Помогу развить силу, баланс и пластику, а главное — почувствовать себя желанной и привлекательной.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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:
|
||||||
|
"Я проводник в мир чувственного 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:
|
||||||
|
"Я не просто инструктор, я настоящий вдохновитель и проводник в мир 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:
|
||||||
|
"Создаю атмосферу, где каждая деталь имеет значение. Мои занятия — это разнообразие стилей, где внимание уделяется каждому движению, а дружелюбная атмосфера помогает раскрыться и почувствовать себя уверенно.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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:
|
||||||
{
|
"Ваш ключ к здоровому, гибкому и гармоничному телу. Знаю каждую связку, каждую клеточку вашего тела. Чувствую ваши ограничения, предугадываю ваши возможности и бережно веду вас к границам вашей гибкости.",
|
||||||
name: "Ольга Грабовец",
|
|
||||||
role: "Тренер",
|
|
||||||
image: "/images/team/olga-grabovets.webp",
|
|
||||||
instagram: "https://instagram.com/lo_woolf/",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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:
|
||||||
|
"В моих танцах кипит безумная смесь силы и чувственности. Обожаю переключаться между разными хореографиями: чувственными, дерзкими, меланхоличными, сексуальными... Каждая из них — это взрыв эмоций.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
description:
|
description:
|
||||||
"Чувственная хореография с элементами pole dance в каблуках.",
|
"Чувственная хореография с элементами pole dance в каблуках.",
|
||||||
icon: "sparkles",
|
icon: "sparkles",
|
||||||
|
detailedDescription:
|
||||||
|
"Стиль танца на пилоне, где акцент делается на чувственность, пластику. В Exotic Pole Dance используется обувь на высоких каблуках (стрипы), развивающий гибкость, силу, женственность и уверенность.\n\nВы получаете:\n— уверенность в себе,\n— красивую фигуру и развитие всех групп мышц,\n— раскрытие себя с новой стороны,\n— вы учитесь наслаждаться собой.",
|
||||||
|
images: ["/images/classes/exot.webp", "/images/classes/exot-w.webp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Pole Dance",
|
name: "Pole Dance",
|
||||||
description:
|
description:
|
||||||
"Сила, грация и пластика на пилоне. Для любого уровня подготовки.",
|
"Искусство на пилоне: акробатические трюки, силовые элементы и грация.",
|
||||||
icon: "flame",
|
icon: "flame",
|
||||||
|
detailedDescription:
|
||||||
|
"Вид искусства на пилоне, включающий акробатические трюки, силовые элементы и грациозные движения. Подходит для развития силы, выносливости и уровня технического мастерства.\n\nВы получите:\n— силу и грацию,\n— прекрасную растяжку,\n— правильную осанку,\n— прекрасное настроение.",
|
||||||
|
images: ["/images/classes/pole-dance.webp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Body Plastic",
|
name: "Body Plastic",
|
||||||
description:
|
description:
|
||||||
"Танцевальное направление, раскрывающее женственность и пластику тела.",
|
"Пластичность, гибкость и осознанность тела в каждом движении.",
|
||||||
icon: "wind",
|
icon: "wind",
|
||||||
|
detailedDescription:
|
||||||
|
"Тренировка, направленная на пластичность, гибкость и осознанность всего тела, помогает лучше управлять своим движением. Body Plastic объединяет растяжку, силу, контроль и пластичность, что помогает развивать тело гармонично и быстро.\n\nВместо односторонней растяжки он учит не только растягиваться, но и сохранять баланс, управлять каждым движением, что особенно важно для pole dance, акробатики и других тренировок.",
|
||||||
|
images: ["/images/classes/body-plastic.webp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Партерная акробатика",
|
name: "Трюковые комбинации с пилоном",
|
||||||
description:
|
description:
|
||||||
"Акробатические элементы в партере для развития силы и гибкости.",
|
"Яркие трюки, акробатические элементы и впечатляющие комбинации.",
|
||||||
icon: "zap",
|
icon: "zap",
|
||||||
|
detailedDescription:
|
||||||
|
"Направление с акцентом на выполнение трюков, акробатических элементов и их комбинаций. Идеально подходит для тех, кто хочет освоить яркие, эффектные трюки и создать впечатляющие комбинации для выступлений и личного развития.",
|
||||||
|
images: ["/images/classes/parter-1.webp", "/images/classes/parter-2.webp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Мастер классы",
|
name: "Мастер классы",
|
||||||
description:
|
description:
|
||||||
"Уникальные занятия с приглашёнными топовыми тренерами.",
|
"Уникальные занятия с приглашёнными топовыми тренерами.",
|
||||||
icon: "star",
|
icon: "star",
|
||||||
|
detailedDescription:
|
||||||
|
"Мастер-классы — это уникальная возможность погрузиться в чувственный мир танца, где каждое движение наполнено грацией и страстью. Наши мастер-классы созданы для тех, кто хочет открыть в себе новые грани женственности и научиться выражать свои эмоции через танец.\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"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Онлайн занятия",
|
name: "Онлайн занятия",
|
||||||
description: "Тренировки в удобное время из любой точки мира.",
|
description: "Тренировки в удобное время из любой точки мира.",
|
||||||
icon: "monitor",
|
icon: "monitor",
|
||||||
|
detailedDescription:
|
||||||
|
"Если вы находитесь не в Минске, у вас всё равно есть уникальная возможность тренироваться, расти и развиваться с нами! Мы предлагаем занятия онлайн по следующим направлениям: партерная акробатика, Pole Dance, Exotic Pole Dance, Exo-tricks, полёты.\n\nМы предлагаем два способа работы: самостоятельный и VIP. В самостоятельный тариф входит доступ к видеозаписям уроков по выбранному направлению, в VIP-тарифе вы также получите доступ к чату с куратором в Telegram.",
|
||||||
|
images: ["/images/classes/online-classes.webp"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
faq: {
|
||||||
|
title: "Частые вопросы",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
question: "Что такое Exotic Pole Dance, Pole Dance и Body Plastic?",
|
||||||
|
answer:
|
||||||
|
"Exotic Pole Dance — стиль танца на пилоне, где акцент делается на чувственность, пластику. Используется обувь на высоких каблуках (стрипы), развивающий гибкость, силу, женственность и уверенность.\n\nPole Dance — вид искусства на пилоне, включающий акробатические трюки, силовые элементы и грациозные движения. Подходит для развития силы, выносливости и технического мастерства.\n\nBody Plastic — тренировка, направленная на пластичность, гибкость и осознанность всего тела, помогает лучше управлять своим движением.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Нужно ли иметь специальную подготовку, чтобы начать заниматься?",
|
||||||
|
answer:
|
||||||
|
"Нет, специальная подготовка не требуется. Уровень физической подготовки будет расти постепенно в процессе тренировок. Важно иметь желание и готовность к обучению.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Какая одежда нужна для занятий?",
|
||||||
|
answer:
|
||||||
|
"Pole Dance: важны шорты и топ, чтобы кожа на бёдрах и животе соприкасалась с пилоном для сцепления.\n\nExotic Pole Dance: на начальных этапах лучше шорты, можно леггинсы, топ/лиф, наколенники и желательно стрипы. На начальном этапе можно начинать без стрипов в носочках.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Какие группы по уровню существуют в вашей студии?",
|
||||||
|
answer:
|
||||||
|
"У нас есть группы для начинающих — «С нуля», где вы можете освоить базовые движения и технику. Также есть группы для продолжающих и для любого уровня подготовки — чтобы все могли развиваться и совершенствоваться в приятной и поддерживающей атмосфере.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Можно ли начать заниматься Exotic Pole Dance в любом возрасте?",
|
||||||
|
answer:
|
||||||
|
"Да, конечно! Возраст не имеет значения — этот вид спорта подходит для всех желающих развивать силу, гибкость и уверенность в себе. Единственное ограничение — от 18 лет.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Я чувствую себя скованно. Как раскрепоститься на тренировках Exotic Pole Dance?",
|
||||||
|
answer:
|
||||||
|
"Exotic Pole Dance — это про самовыражение и принятие себя. Не бойтесь проявлять свои эмоции, экспериментировать с движениями. Постепенно вы почувствуете себя увереннее и свободнее. Наши тренеры создают на занятиях комфортную и поддерживающую атмосферу.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Как быстро я смогу делать трюки на пилоне?",
|
||||||
|
answer:
|
||||||
|
"Это индивидуально и зависит от вашей физической подготовки, регулярности тренировок и способностей к обучению. Первые простые трюки обычно осваиваются в течение нескольких недель.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Body Plastic — это растяжка?",
|
||||||
|
answer:
|
||||||
|
"Body Plastic — это не только про растяжку. Body Plastic объединяет растяжку, силу, контроль и пластичность, что помогает развивать тело гармонично и быстро. Вместо односторонней растяжки он учит не только растягиваться, но и сохранять баланс, управлять каждым движением, что особенно важно для pole dance, акробатики и других тренировок.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Что включает направление «Трюковые комбинации с пилоном»?",
|
||||||
|
answer:
|
||||||
|
"Трюковые комбинации с пилоном — это направление с акцентом на выполнение трюков, акробатических элементов и их комбинаций. Это направление идеально подходит для тех, кто хочет освоить яркие, эффектные трюки и создать впечатляющие комбинации для выступлений и личного развития.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Сколько раз в неделю нужно заниматься?",
|
||||||
|
answer:
|
||||||
|
"Для новичков рекомендуется начинать с 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",
|
||||||
email: "info@blackheartdance.by",
|
|
||||||
instagram: "https://instagram.com/blackheartdancehouse/",
|
instagram: "https://instagram.com/blackheartdancehouse/",
|
||||||
mapEmbedUrl:
|
mapEmbedUrl:
|
||||||
"https://yandex.ru/map-widget/v1/?ll=27.512%2C53.912&z=12&l=map&pt=27.5656%2C53.91583%2Cpm2rdm~27.45974%2C53.90832%2Cpm2rdm",
|
"https://yandex.ru/map-widget/v1/?ll=27.512%2C53.912&z=12&l=map&pt=27.5656%2C53.91583%2Cpm2rdm~27.45974%2C53.90832%2Cpm2rdm",
|
||||||
|
|||||||
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
@@ -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 };
|
||||||
|
}
|
||||||