Compare commits

...

58 Commits

Author SHA1 Message Date
diana.dolgolyova 1571b63ec3 feat: separate /team and /schedule pages with Pinterest-style team grid
- Add /team page with masonry grid, style filters, search, and trainer profiles
- Add /schedule page as dedicated full-page schedule viewer
- Landing page: replace team section with auto-scrolling photo marquee (TeamPreview)
  - Clicking a trainer opens modal overlay with profile (no page navigation)
  - "Познакомиться с командой" links to full /team gallery
- Landing page: replace schedule section with compact CTA linking to /schedule
- Header: support mixed route links (/team) and hash links (#about)
  - Sub-pages show all nav links, hash links prefixed with /
  - Sub-pages always use scrolled header style (readable on light theme)
- Remove unused content.ts and seed.ts (DB is primary data source)
- Add marquee and grid card entrance animations
2026-04-13 22:01:38 +03:00
diana.dolgolyova 8c84da279e fix: widen classes container, wrap schedule tags on mobile
Classes section: replace section-container with wider max-w-[96rem] to reduce side gaps.
Schedule DayCard: allow tags to flex-wrap so they don't clip on narrow screens.
2026-04-12 21:01:10 +03:00
diana.dolgolyova 03d3cad0a7 fix: remove section-glow radial gradient — visible as artifact between sections 2026-04-12 20:27:59 +03:00
diana.dolgolyova 89f132634d feat: gold headings, glass header, fixed glow positioning
- SectionHeading: gold text instead of gradient-text
- Hero h1: gold, subtitle: white/80
- Header: pure backdrop-blur-xl glass, no bg color
- Section glow: fixed top:0, original size 600x400
- Section padding: py-20 sm:py-28
- Markup: headings/bold text-neutral-900 dark:text-white
2026-04-12 20:26:46 +03:00
diana.dolgolyova b7eacce479 feat: widen content container max-w-6xl → max-w-7xl (+128px) 2026-04-12 16:08:14 +03:00
diana.dolgolyova a832af9344 revert: remove decorative side lines — didn't look right 2026-04-12 16:03:08 +03:00
diana.dolgolyova b738976111 feat: flowing gold side lines + pure glass header
- DecorativeLines: SVG flowing bezier curves on both sides, gold
  gradient with fade at top/bottom, desktop only (xl+), starts
  after hero section
- Header: pure backdrop-blur-xl glass with no bg color overlay,
  subtle border-white/8% bottom line
- Removed old straight CSS lines from body
2026-04-12 15:58:58 +03:00
diana.dolgolyova a00fdaa760 feat: decorative gold side lines + frosted glass header
- Gold vertical lines on both sides (desktop 1280px+), fixed position,
  gradient fade at top/bottom, positioned at 3vw from edges
- Header light mode: frosted glass (bg-white/60 backdrop-blur-xl)
  with subtle bottom border, matching dark mode aesthetic
2026-04-12 15:55:26 +03:00
diana.dolgolyova 9f86bcbce9 fix: markup headings and bold text visible in light mode 2026-04-12 15:52:19 +03:00
diana.dolgolyova 28afcc18bc fix: hero always dark bg — no light/dark split for video section 2026-04-12 15:49:23 +03:00
diana.dolgolyova b9510213d7 fix: headings white instead of gold gradient, gold as accent only
- SectionHeading: text-neutral-900/dark:text-white instead of
  gradient-text — cleaner, more premium look
- Hero: white h1 instead of gradient-text, gold reserved for
  subtitle and CTA button only
- Gold accent now used sparingly: underlines, badges, buttons, hover
  states — not headings
2026-04-12 15:46:27 +03:00
diana.dolgolyova bac46aeb34 feat: dark luxury gold — spacing, typography, frosted glass, glow
Design direction: "Dark Luxury Gold" — more breathing room, bolder
headings, frosted glass cards, and richer gold accents.

- Section padding: py-20→py-24, sm:py-32→sm:py-36, px-6→sm:px-10
- SectionHeading: text-6xl→7xl on lg, tracking-wide→wider, longer
  gold gradient underline with via-gold/40
- Section glow: larger radius (800px), positioned higher (-40px),
  elliptical shape for dark mode
- Glass card: backdrop-blur-md→lg in dark, border white/8%, hover
  with subtle gold glow shadow
- Section divider: wider gradient spread with 5% transparent edges
- About/FAQ/Pricing/DayCard: frosted glass in dark mode with
  backdrop-blur-md, subtle gold glow on hover
2026-04-12 15:34:01 +03:00
diana.dolgolyova 3621503470 feat: add reusable UI primitives for admin and public site
Admin (ui.tsx): AdminInput, AdminSelect, AdminTextarea, AdminButton,
AdminModal + adminStyles token object for consistent styling.

Public (ModalBase.tsx): Shared modal with overlay, backdrop blur, focus
trap, ESC handling, and portal rendering.

Public (TabButton.tsx): Unified active/inactive tab with gold styling.
2026-04-12 15:27:40 +03:00
diana.dolgolyova a080ef5a8e fix: remaining admin & layout light theme polish
- Admin forms, dialogs, and page editors: light-mode borders, text contrast
- YandexMap: theme-aware map styles
- Layout: theme init script adjustments
2026-04-10 21:36:33 +03:00
diana.dolgolyova 97663c514e fix: admin bookings light theme — readable names, badges, and actions
- Name text: text-white → text-neutral-900 dark:text-white
- Status badges: -400 colors → -600 for light, dark: keeps -400
- Contact links: same -600/-400 split
- BookingCard borders/bg: stronger opacity for light mode
- Action buttons: dark:text for light contrast
- Group counters: amber-700/blue-600/emerald-600 for light
2026-04-10 21:33:22 +03:00
diana.dolgolyova 0e626451e7 feat: comprehensive light theme support across entire site
- CSS foundation: theme-aware scrollbars, section glows, glass cards with
  gold shadows, stronger animated borders and glow effects for light mode
- Hero: consistent dark-video treatment for both themes, brighter gold
  gradient text, glowing CTA button
- Gradient text: auto-switch to warm gold tones on light backgrounds via
  html:not(.dark) selector
- Team profile: inverted ambient photo bg with white overlay for light,
  dark text/borders, gold-dark labels for contrast
- All sections: text-neutral-500→600 upgrades for WCAG AA contrast,
  gold shadow accents on cards (About, Pricing, FAQ, DayCard, News)
- Admin: replaced hardcoded #c9a96e with theme tokens, fixed select
  options, array editor borders, booking badges contrast
- Header: white text on transparent hero, dark text after scroll
- UI components: BackToTop, FloatingHearts, ShowcaseLayout tabs,
  SignupModal, NewsModal, GroupCard adapted for light backgrounds
- Updated CLAUDE.md to reflect dual theme support
2026-04-10 21:30:56 +03:00
diana.dolgolyova a587736dd3 feat: mobile UX, admin polish, rate limiting, and media assets
- Mobile responsiveness improvements across admin and public sections
- Admin: bookings modal, open-day page, team page, layout polish
- Added rate limiting, CSRF hardening, auth-edge improvements
- Scroll reveal, floating contact, back-to-top, Yandex map fixes
- Schedule filters refactor, team profile/info component updates
- New useTrainerPhotos hook
- Added class, team, master-class, and news images
2026-04-10 18:42:54 +03:00
diana.dolgolyova bbe485d8fc feat: add author credit to footer 2026-04-05 19:28:27 +03:00
diana.dolgolyova 16ac56f62e feat: mobile UX improvements across admin and public site
- Team carousel: simple swipe on mobile instead of drag
- Schedule: filter button inline with hall tabs, larger on mobile
- Schedule filters: fix nested button hydration error
- Admin bookings: select dropdown on mobile, filter highlight on dashboard cards
- Admin bookings: searchable dropdowns in add booking modal with class selector
- Admin bookings: waiting list divider inside groups
- Admin bookings: new bookings appear without page reload
- Admin open-day: action buttons visible on mobile, confirm dialog, click-outside to close edit
- API: pass groupInfo on group booking creation
- SignupModal: Instagram link on success popup
2026-04-03 17:06:55 +03:00
diana.dolgolyova fa26092ea4 fix: mobile horizontal overflow and hero responsiveness
- Remove scrollbar-gutter: stable, add overflow-x: hidden on html
- Cap section-glow pseudo-element width to viewport on mobile
- Scale down hero logo, text, spacing, and button for small screens (iPhone SE)
2026-04-03 00:14:42 +03:00
diana.dolgolyova bf9b1876b5 feat: hero UI polish — vignette, larger text, bolder button, spacing
- Vignette overlay: radial gradient darkens edges, guides eye to center
- Video overlay reduced 50% → 40% for brighter backgrounds
- Subtitle: larger (xl/2xl), brighter (gold/80), wider max-w-xl
- Button: larger padding, pulse glow on hover, stronger shadow
- Spacing: more room between logo, title, subtitle, and button
2026-04-02 15:43:56 +03:00
diana.dolgolyova 3c0be33b1c feat: transparent glass hero button, gold text
Hero CTA button: transparent gold/15 background with backdrop blur,
gold border, gold text. Frosted glass effect over video.
2026-04-02 15:41:51 +03:00
diana.dolgolyova 06f7dcf570 fix: admin sticky header, pricing rules textarea, hero 3D text shadow
- Admin mobile header: sticky top so it stays visible on scroll
- Pricing rules: auto-expanding textarea instead of single-line input
- Hero text: layered gold+shadow for 3D depth effect
2026-04-02 15:35:48 +03:00
diana.dolgolyova 2c6bee9eb1 feat: MC detail popup, image crop editor, empty dates support
Master Classes:
- Detail popup on card click with description, all dates, location+address
- Card shows only first date (or "Скоро" if no dates)
- Trainer name clickable to open bio
- Text backdrop panel on cards for readability
- No photo overlay darkening
- Fix crash when MC has no/empty slots
- Price "BYN" no longer duplicated
- Admin: ImageCropField replaces PhotoPreview (with focal/zoom)
- Admin: RichTextarea for description
- Admin: photo+fields side-by-side layout, fixed photo width

Pricing:
- Added rentalSubtitle field for rental tab info
2026-04-02 15:00:44 +03:00
diana.dolgolyova 2d13b82507 fix: dashboard card order, nav labels from DB, pricing rental info
- Dashboard cards reordered to match sidebar nav sequence
- Added missing "Формы записи" card to dashboard
- Nav menu reads section titles from DOM headings (not hardcoded)
- Pricing: moved subtitle inside Абонементы tab, renamed to "Доп. информация"
- Pricing: added "Доп. информация" field to Аренда tab
2026-03-31 00:17:26 +03:00
diana.dolgolyova ae30be8f9d fix: schedule status labels, Open Day halls, unsaved data guards
Schedule:
- Status badges use admin config labels (not hardcoded text) everywhere
- DayCard: level badge moved next to status badge
- Single location: hide "Все студии" tab, auto-select the only hall
- Group view: hide per-card address when all share same location
- Filter tooltip z-index fixed (above dropdowns)
- Trainer bio: status labels from config, not raw keys

Open Day:
- Hall name + address shown in schedule grid headers
- Only one class card editable at a time (edit/create mutually exclusive)
- Bigger action buttons (cancel/delete) on class cards
- Create as empty draft (not pre-filled with published status)
- Fix discount threshold input (allow delete to empty)
- Skip auto-save during partial date input

Admin:
- SectionEditor: unsaved data guard (force-save before navigation)
- Open Day + Team: same navigation guards
- Contact: removed working hours field
- TimeRangeField: allow end time hour changes
- Schedule cards: visible borders, 90min default duration
- Trainer bio: RichTextarea for description
- Open Day: RichTextarea for description
2026-03-30 22:57:36 +03:00
diana.dolgolyova 06be6b48ce feat: contact page improvements, Yandex map from addresses
- Instagram field: @username input with API validation (like team page)
- Phone validation: blocks auto-save when incomplete, shows warning
- SectionEditor: validate prop to conditionally block saves
- Yandex Map: auto-generated from addresses via Nominatim geocoding,
  dark theme, no API key needed
- Schedule: address hint linking to Contacts
- Renamed "Всплывающие окна" → "Формы записи", moved after Записи
2026-03-30 16:59:24 +03:00
diana.dolgolyova 22bd117dae feat: rich text editor, image crop component, empty DB resilience
- RichTextarea with toolbar (Bold, Italic, List, Heading) + Ctrl+B/I
  hotkeys (layout-independent), active state highlighting, preview mode
- Shared ImageCropField component (replaces duplicate in news/classes)
  with drag-to-reposition, Ctrl+scroll zoom, compact layout
- SectionEditor defaultData prop — all admin pages handle empty DB
- Team: section title editable, toast notifications, unsaved data warning
  on navigation (back button, sidebar links, browser close)
- Carousel: continuous card wrapping during drag, edge fade for small teams
- Markup renderer: **bold**, *italic*, ## headings, 🤍 bullet points
- Empty DB guards on all public site sections
- Fix: upload error handling, contact phone field, "team" section key
2026-03-30 00:40:08 +03:00
diana.dolgolyova e56a6a1608 fix: remove fallback content, fix video upload and positioning
- Remove hardcoded fallback data — DB is sole content source
- Sections render conditionally when data exists
- Hero video slots save after each upload (not only when all 3 filled)
- Video positions preserved (left/center/right) with empty string slots
- Client-side 10MB hard limit on video uploads with clear error
- Server-side upload error handling for body size limits
- Guard Team section against empty members array
- Clean up old uploaded images and videos
2026-03-29 22:17:11 +03:00
diana.dolgolyova 77ad2a6b68 fix: comprehensive UI/UX accessibility and usability improvements
Public site: skip-to-content link, mobile menu focus trap + Escape key,
aria-current on nav, keyboard navigation for carousels/tabs/articles,
ARIA roles (tablist/tab/tabpanel, combobox/listbox, region, dialog),
form labels + aria-describedby, 44px touch targets, semantic HTML
(<time>, <del>), prefers-reduced-motion on Hero scroll hijack,
mobile schedule filters, URL hash sync on scroll for correct refresh.

Admin panel: password toggle aria-label, toast aria-live regions,
SelectField keyboard navigation (Arrow/Enter/Escape), aria-invalid
on validation errors, sidebar hamburger aria-label/expanded,
nav aria-label, ArrayEditor aria-expanded on collapsible items.
2026-03-29 20:42:14 +03:00
diana.dolgolyova 024424c578 feat: unified badges, filter UI overhaul, trainer bio link
- ScheduleBadge: shared gold badge component for all status/level tags
- Replace hardcoded emerald/sky/rose badge colors with unified gold style
- DayCard, GroupCard, MobileSchedule all use ScheduleBadge
- Location tag moved before status badges, gold styling
- GroupCard: flex-col with mt-auto button alignment
- TeamProfile: pass hasSlots/status to GroupCard

Filter modal redesign:
- Status: horizontal Airbnb-style toggle cards with ? info tips
- Experience: vertical radio list with gold dots
- When: days + time inputs on single row
- Click-to-toggle InfoTip replacing hover tooltips
- Gold speech bubble tooltip design

Schedule cards:
- Trainer name click opens bio instead of filtering
- DayCard location header: gold background, no duplicate address
2026-03-29 17:04:40 +03:00
diana.dolgolyova bdeedcfcc8 fix: schedule status system — auto-key, config order, label lookup
- Auto-generate status key from label (admin doesn't need to set keys)
- Remove visible key field from status config editor
- Order statuses/levels in filters by config order (matches admin panel)
- Shared findStatusConfig() for robust label lookup (by key, label, or derived key)
- Custom status badges in DayCard, GroupCard, MobileSchedule
- Simplified filter logic with clsStatus helper
- Removed dead code: TIME_PRESETS, StatusFilter type
- SelectField: blur input after selection to prevent re-open
2026-03-28 00:33:55 +03:00
diana.dolgolyova b322c969f2 fix: schedule filters — restore status card design, responsive layout, cleanup
- Status: card layout on desktop, compact pills on mobile
- Experience: compact pills with ? hover tooltips (unchanged)
- Remove time presets (Morning/Day/Evening), keep only from-to inputs
- Combine Days + Time into single "Когда" section
- Fix tooltip overflow causing scrollbars (max-w + right-aligned)
- Tighten modal spacing (p-6→px-6 py-4, space-y-7→space-y-5)
- Clean unused imports (pillActive, pillInactive, User, Clock, etc.)
2026-03-27 23:54:16 +03:00
diana.dolgolyova a69c08482f feat: schedule filters overhaul, local fonts, configurable statuses/levels
Schedule filters:
- Airbnb-style filter modal with sections: directions, trainer, status, level, days, time
- Multi-select trainer filter with search input
- Custom time range (from-to) with preset shortcuts
- Gold tag design for class types, statuses, and levels
- Hover tooltips on level/status options with descriptions from config
- Filter icon button inline with view toggle (По дням / По группам)

Admin schedule:
- Configurable experience levels and statuses (add/edit/reorder/delete)
- New scheduleConfig DB section with auto-save
- Status/level dropdowns in class editor read from config
- Status select built dynamically from config
- New status field on ScheduleClass for custom statuses

Other:
- Local fonts (Inter + Oswald) bundled in public/fonts — no Google Fonts dependency
- SelectField combobox: search in main input field, no separate search inside dropdown
- Fix carousel trainer label flash on drag release
2026-03-27 19:13:43 +03:00
diana.dolgolyova d5541a8bc9 fix: prevent trainer label flash on carousel drag release 2026-03-27 14:39:44 +03:00
diana.dolgolyova 035f68776a feat: shared GroupCard component, admin status select, schedule level filter
- Extract shared GroupCard component used by both Schedule GroupView and TeamProfile
- Admin schedule: replace hasSlots/recruiting toggles with single Status select
- User schedule: add level filter pills (Начинающий/Без опыта, Продвинутый)
- Consistent group card styling across schedule and trainer bio views
2026-03-26 23:38:51 +03:00
diana.dolgolyova c4c3a7ab0d fix: schedule grid — clean time labels and corner cell background 2026-03-26 19:56:16 +03:00
diana.dolgolyova 76307e298b refactor: comprehensive frontend review — consistency, a11y, code quality
- Replace event dispatchers with BookingContext (Hero, Header, FloatingContact)
- Add focus trap hook for modals (SignupModal, NewsModal)
- Extract shared components: CollapsibleSection, ConfirmDialog, PriceField, AdminSkeleton
- Add delete confirmation dialog to ArrayEditor
- Replace hardcoded colors (#050505, #0a0a0a, #c9a96e, #2ecc71) with theme tokens
- Add CSS variables --color-surface-deep/dark for consistent dark surfaces
- Improve contrast: muted text neutral-500 → neutral-400 in dark mode
- Fix modal z-index hierarchy (modals z-60, header z-50, floats z-40)
- Consolidate duplicate formatDate → shared formatting.ts
- Add useMemo to TeamProfile groupMap computation
- Fix typography: responsive price text in Pricing section
- Add ARIA labels/expanded to FAQ, OpenDay, ArrayEditor grip handles
- Hide number input spinners globally
- Reorder admin sidebar: Dashboard → SEO → Bookings → site section order
- Use shared PriceField in Open Day editor
- Fix schedule grid first time slot (09:00) clipped by container
- Fix pre-existing type errors (bookings, hero, db interfaces)
2026-03-26 19:45:37 +03:00
diana.dolgolyova ec08f8e8d5 feat: OpenDay trainer photos + click-to-bio; remove filter text highlights 2026-03-26 13:33:00 +03:00
diana.dolgolyova a769ea844d fix: prevent layout jump on modal open — use scrollbar-gutter: stable 2026-03-26 13:24:37 +03:00
diana.dolgolyova 8088b99a43 feat: UI improvements — scrollbar, multi-filters, pricing fix, routing, modals
- Global page scrollbar styled with gold theme
- Schedule: multi-select for class types and status tags
- Pricing: fix tab switch blink (display toggle vs conditional render)
- OpenDay: trainer name more prominent, section divider added
- Team: browser back button closes trainer bio (history API)
- Modals: block scroll + compensate scrollbar width to prevent layout shift
- Header: remove booking button from desktop nav
2026-03-26 13:23:03 +03:00
diana.dolgolyova 228e547e10 feat: reorder sections — About → Classes → Team → OpenDay → Schedule → Pricing → MC 2026-03-26 12:35:10 +03:00
diana.dolgolyova c9303e5aad feat: news pagination — replace show more/collapse with page controls 2026-03-26 12:16:56 +03:00
diana.dolgolyova c9cfe63837 feat: schedule group view — trainer photos, today badge, click-to-bio 2026-03-26 12:06:46 +03:00
diana.dolgolyova f65a6ed811 feat: schedule — default to group view, make halls more visible 2026-03-26 11:44:53 +03:00
diana.dolgolyova 09b2f40090 feat: collapse/expand all — toggle icon in ArrayEditor + pricing sections
- ArrayEditor shows ChevronsUpDown toggle when collapsible with 2+ items
- Toggle appears even without label prop (fixes pricing missing icon)
- Pricing: centralized section state with toggle-all button for all 3 sections
2026-03-26 11:31:31 +03:00
diana.dolgolyova 4c8c6eb0d2 feat: news crop editor — natural drag, zoom slider, force-dynamic page
- Rewrite crop preview: drag moves image naturally (inverted focal)
- Add zoom slider (1x-3x) + mouse wheel zoom
- Apply imageZoom on user side (featured, compact, modal)
- Force-dynamic on main page to prevent stale cache
2026-03-26 11:11:39 +03:00
diana.dolgolyova 4b6443c867 feat: news improvements — crop preview, auto-date, validation, add-to-top
- Draggable focal point crop preview for news images (admin + user + modal)
- Auto-set date+time on creation, remove date picker
- Draft validation: title, text, image required — "Черновик" badge if missing
- Empty/draft news filtered from user side
- ArrayEditor: addPosition="top" option, fix new item expand + index shift
- News sorted newest first, "Показать ещё" pagination
2026-03-26 01:34:31 +03:00
diana.dolgolyova bc0f23df34 feat: contact admin — phone mask, Instagram validation, compact addresses, remove map URL 2026-03-26 01:14:26 +03:00
diana.dolgolyova ad1715acb8 feat: news admin — collapsible cards, photo preview; user side — sort by date, show more 2026-03-26 00:59:37 +03:00
diana.dolgolyova 30398d2aeb feat: FAQ collapsible + drag UX — gold styling, compact cards, drop highlight 2026-03-26 00:55:39 +03:00
diana.dolgolyova 95c33391e5 feat: pricing admin — collapsible sections, card collapse, remove contact toggle 2026-03-26 00:48:00 +03:00
diana.dolgolyova 64e923460f feat: MC admin — collapsible cards, filters, photo preview, validation, archive
- Collapsible cards with title + hall in label, archive badge
- Archived MCs sorted to bottom, dimmed with "Архив" badge
- Cards have hover + focus-within gold border highlight
- Date validation: error text for missing dates and invalid time ranges
- Search by title/trainer + filter by date (upcoming/past) and hall
- Photo preview with hover overlay (like trainer page)
- ArrayEditor: hiddenItems, getItemBadge props, focus-within styles
2026-03-26 00:43:09 +03:00
diana.dolgolyova 6c485872b0 feat: centralize popup texts in new admin tab /admin/popups
- New admin page for shared popup texts (success, waiting list, error, Instagram hint)
- Removed popup fields from MC and Open Day admin editors
- All SignupModals now read from centralized popups config
- Stored as "popups" section in DB with fallback defaults
2026-03-25 23:48:06 +03:00
diana.dolgolyova 983bf296fc fix: mobile menu — unified header background, remove booking button 2026-03-25 23:28:24 +03:00
diana.dolgolyova 4805c3b9ea feat: classes admin collapsible cards, icon curation, color fix + user-side polish
Admin classes:
- Collapsible cards in ArrayEditor (start collapsed, expand on click)
- Curated 29 dance-relevant icons shown by default, full search as fallback
- Color swatches: used colors dimmed instead of hidden (no layout shift)

User side:
- Classes: icon + name side by side on photo overlay
- ShowcaseLayout: fix image flash during transition (2-frame swap while hidden)
- Team bio: section headings gold, admin cards focus-within highlight
2026-03-25 23:26:15 +03:00
diana.dolgolyova 24d48a9409 feat: improve trainer bio UX — reorder sections, collapsible, scroll arrows, card hover
- Reorder: Groups → Description → Education → Achievements
- Education and Achievements are collapsible (collapsed by default)
- Section headings now gold instead of gray
- Scroll arrows (left/right) replace fade indicators, always visible
- Bigger cards (w-60), wider image thumbnails
- Card hover: gold border glow, brighter text, subtle shadow (user side)
- Admin cards: hover highlight + focus-within gold border for active editing
- Auto-add draft item on blur to prevent data loss
2026-03-25 23:16:23 +03:00
diana.dolgolyova e4cb38c409 refactor: simplify team bio — replace complex achievements with simple list, remove experience
- Replace VictoryItem (type/place/category/competition/city/date) with RichListItem (text + optional link/image)
- Remove VictoryItemListField, DateRangeField, CityField and related helpers
- Remove experience field from admin form and user profile (can be in bio text)
- Simplify TeamProfile: remove victory tabs, show achievements as RichCards
- Fix auto-save: snapshot comparison prevents false saves on focus/blur
- Add save on tab leave (visibilitychange) and page close (sendBeacon)
- Add save after image uploads (main photo, achievements, education)
- Auto-migrate old VictoryItem data to RichListItem format in DB parser
2026-03-25 22:53:30 +03:00
179 changed files with 7702 additions and 3849 deletions
+5 -4
View File
@@ -7,7 +7,7 @@ Content language: Russian
## Tech Stack
- **Next.js 16** (App Router, TypeScript, Turbopack)
- **Tailwind CSS v4** (dark mode only, gold/black theme)
- **Tailwind CSS v4** (dual theme: dark default + light, gold accent)
- **lucide-react** for icons
- **better-sqlite3** for SQLite database
- **Fonts**: Inter (body) + Oswald (headings) via `next/font`
@@ -25,7 +25,7 @@ Content language: Russian
src/
├── app/
│ ├── layout.tsx # Root layout, fonts, metadata
│ ├── page.tsx # Landing: Hero → [OpenDay] → About → Team → Classes → MasterClasses → Schedule → Pricing → News → FAQ → Contact
│ ├── page.tsx # Landing: Hero → About → Classes → Team → [OpenDay] → Schedule → Pricing → MasterClasses → News → FAQ → Contact
│ ├── globals.css # Tailwind imports
│ ├── styles/
│ │ ├── theme.css # Theme variables, semantic classes
@@ -111,8 +111,9 @@ src/
## Brand / Styling
- **Accent**: gold (`#c9a96e` / `hsl(37, 42%, 61%)`)
- **Background**: `#050505` `#0a0a0a` (dark only)
- **Surface**: `#171717` dark cards
- **Dark theme** (default): background `#050505``#0a0a0a`, surface `#171717`, text `neutral-100`
- **Light theme**: background `white`/`neutral-50`, surface `white`, text `neutral-900`
- Theme toggle via `ThemeToggle` component, `.dark` class on `<html>`, stored in `localStorage`
- Logo: transparent PNG heart with gold glow, uses `unoptimized`
## Content Data
+20
View File
@@ -1,7 +1,27 @@
import type { NextConfig } from "next";
const securityHeaders = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
...(process.env.NODE_ENV === "production"
? [{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }]
: []),
];
const nextConfig: NextConfig = {
serverExternalPackages: ["better-sqlite3"],
allowedDevOrigins: [
"black-heart.dolgolyov-family.by",
"192.168.2.56",
],
headers: async () => [
{
source: "/(.*)",
headers: securityHeaders,
},
],
};
export default nextConfig;
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -H 0.0.0.0",
"build": "next build",
"start": "next start",
"lint": "next lint",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

-3
View File
@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,18 @@
"use client";
/** Reusable loading skeleton for admin pages */
export function AdminSkeleton({ rows = 3 }: { rows?: number }) {
return (
<div className="space-y-6 animate-pulse">
{/* Title skeleton */}
<div className="h-8 w-48 rounded-lg bg-neutral-800" />
{/* Content skeletons */}
{Array.from({ length: rows }, (_, i) => (
<div key={i} className="space-y-3">
<div className="h-4 w-24 rounded bg-neutral-800" />
<div className="h-10 w-full rounded-lg bg-neutral-800" />
</div>
))}
</div>
);
}
+269 -61
View File
@@ -2,7 +2,10 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import { Plus, Trash2, GripVertical } from "lucide-react";
import { Plus, Trash2, GripVertical, ChevronDown, ChevronsUpDown } from "lucide-react";
import { ConfirmDialog } from "./ConfirmDialog";
let nextItemId = 1;
interface ArrayEditorProps<T> {
items: T[];
@@ -11,16 +14,33 @@ interface ArrayEditorProps<T> {
createItem: () => T;
label?: string;
addLabel?: string;
collapsible?: boolean;
getItemTitle?: (item: T, index: number) => string;
getItemBadge?: (item: T, index: number) => React.ReactNode;
hiddenItems?: Set<number>;
addPosition?: "top" | "bottom";
/** Render grip + content + delete on a single row (compact mode) */
inline?: boolean;
/** Hide the add button (when parent manages adding) */
hideAdd?: boolean;
}
export function ArrayEditor<T>({
items,
items = [] as unknown as T[],
onChange,
renderItem,
createItem,
label,
addLabel = "Добавить",
collapsible = false,
getItemTitle,
getItemBadge,
hiddenItems,
addPosition = "bottom",
inline = false,
hideAdd = false,
}: ArrayEditorProps<T>) {
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [insertAt, setInsertAt] = useState<number | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
@@ -29,6 +49,30 @@ export function ArrayEditor<T>({
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const [mounted, setMounted] = useState(false);
const [newItemIndex, setNewItemIndex] = useState<number | null>(null);
const [droppedIndex, setDroppedIndex] = useState<number | null>(null);
const [collapsed, setCollapsed] = useState<Set<number>>(() => collapsible ? new Set(items.map((_, i) => i)) : new Set());
// Stable keys for items — avoids index-as-key issues during reorder
const stableKeysRef = useRef<number[]>([]);
if (stableKeysRef.current.length < items.length) {
while (stableKeysRef.current.length < items.length) {
stableKeysRef.current.push(nextItemId++);
}
} else if (stableKeysRef.current.length > items.length) {
stableKeysRef.current = stableKeysRef.current.slice(0, items.length);
}
function getStableKey(index: number): number {
return stableKeysRef.current[index];
}
function toggleCollapse(index: number) {
setCollapsed(prev => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
}
useEffect(() => { setMounted(true); }, []);
@@ -47,6 +91,7 @@ export function ArrayEditor<T>({
}
function removeItem(index: number) {
stableKeysRef.current.splice(index, 1);
onChange(items.filter((_, i) => i !== index));
}
@@ -113,7 +158,14 @@ export function ArrayEditor<T>({
const updated = [...items];
const [moved] = updated.splice(capturedDrag, 1);
updated.splice(targetIndex, 0, moved);
// Sync stable keys
const keys = [...stableKeysRef.current];
const [movedKey] = keys.splice(capturedDrag, 1);
keys.splice(targetIndex, 0, movedKey);
stableKeysRef.current = keys;
onChange(updated);
setDroppedIndex(targetIndex);
setTimeout(() => setDroppedIndex(null), 1500);
}
}
});
@@ -130,32 +182,96 @@ export function ArrayEditor<T>({
function renderList() {
if (dragIndex === null || insertAt === null) {
return items.map((item, i) => (
<div
key={i}
ref={(el) => { itemRefs.current[i] = el; }}
className={`rounded-lg border bg-neutral-900/50 p-4 mb-3 hover:border-white/25 hover:bg-neutral-800/50 transition-all ${
newItemIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
}`}
>
<div className="flex items-start justify-between gap-2 mb-3">
<div
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
onMouseDown={(e) => handleGripMouseDown(e, i)}
>
<GripVertical size={16} />
</div>
<button
type="button"
onClick={() => removeItem(i)}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
>
<Trash2 size={16} />
</button>
return items.map((item, i) => {
const isCollapsed = collapsible && collapsed.has(i) && newItemIndex !== i;
const isHidden = hiddenItems?.has(i) ?? false;
const title = getItemTitle?.(item, i) || `#${i + 1}`;
return (
<div
key={getStableKey(i)}
ref={(el) => { itemRefs.current[i] = el; }}
className={`rounded-lg border bg-neutral-100/80 mb-3 hover:border-neutral-300 dark:hover:border-white/25 hover:bg-neutral-200/50 focus-within:border-gold/50 focus-within:bg-neutral-200 transition-all dark:bg-neutral-900/50 dark:hover:bg-neutral-800/50 dark:focus-within:bg-neutral-800 ${
newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-neutral-200 dark:border-white/10"
} ${isHidden ? "hidden" : ""}`}
>
{inline ? (
/* Inline: grip + content + delete on one row */
<div className="flex items-start gap-1.5 p-1.5">
<div
className="cursor-grab active:cursor-grabbing rounded p-1 mt-1.5 text-neutral-500 hover:text-neutral-900 transition-colors select-none shrink-0 dark:hover:text-white"
onMouseDown={(e) => handleGripMouseDown(e, i)}
aria-label="Перетащить для сортировки"
role="button"
>
<GripVertical size={14} />
</div>
<div className="flex-1 min-w-0">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
<button
type="button"
onClick={() => setConfirmDelete(i)}
aria-label="Удалить элемент"
className="rounded p-1 mt-1.5 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
>
<Trash2 size={14} />
</button>
</div>
) : (
<>
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-neutral-900 transition-colors select-none dark:hover:text-white"
onMouseDown={(e) => handleGripMouseDown(e, i)}
aria-label="Перетащить для сортировки"
role="button"
>
<GripVertical size={16} />
</div>
{collapsible && (
<button
type="button"
onClick={() => toggleCollapse(i)}
aria-expanded={!isCollapsed}
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
>
<span className="text-sm font-medium text-neutral-700 truncate group-hover:text-neutral-900 transition-colors dark:text-neutral-300 dark:group-hover:text-white">{title}</span>
{getItemBadge?.(item, i)}
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
</button>
)}
</div>
<button
type="button"
onClick={() => setConfirmDelete(i)}
aria-label="Удалить элемент"
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
>
<Trash2 size={16} />
</button>
</div>
{collapsible ? (
<div
className="grid transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}
>
<div className="overflow-hidden">
<div className="px-4 pb-4">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
</div>
</div>
) : (
<div className="px-4 pb-4">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
)}
</>
)}
</div>
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
));
);
});
}
const elements: React.ReactNode[] = [];
@@ -175,35 +291,75 @@ export function ArrayEditor<T>({
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 }}
className="rounded-lg border-2 border-dashed border-gold/40 bg-gold/5 mb-3"
style={{ height: collapsible ? 48 : dragSize.h }}
/>
);
}
const item = items[i];
const isCollapsed = collapsible && collapsed.has(i);
const title = getItemTitle?.(item, i) || `#${i + 1}`;
elements.push(
<div
key={i}
key={getStableKey(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"
className="rounded-lg border border-neutral-200 bg-neutral-100/80 mb-3 transition-colors dark:border-white/10 dark:bg-neutral-900/50"
>
<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} />
{inline ? (
<div className="flex items-start gap-1.5 p-1.5">
<div className="cursor-grab active:cursor-grabbing rounded p-1 mt-1.5 text-neutral-500 hover:text-neutral-900 transition-colors select-none shrink-0 dark:hover:text-white"
onMouseDown={(e) => handleGripMouseDown(e, i)} aria-label="Перетащить для сортировки" role="button">
<GripVertical size={14} />
</div>
<div className="flex-1 min-w-0">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
<button type="button" onClick={() => removeItem(i)} aria-label="Удалить элемент"
className="rounded p-1 mt-1.5 text-neutral-500 hover:text-red-400 transition-colors shrink-0">
<Trash2 size={14} />
</button>
</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 className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-neutral-900 transition-colors select-none dark:hover:text-white"
onMouseDown={(e) => handleGripMouseDown(e, i)}
aria-label="Перетащить для сортировки"
role="button"
>
<GripVertical size={16} />
</div>
{collapsible && (
<button type="button" onClick={() => toggleCollapse(i)} className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group">
<span className="text-sm font-medium text-neutral-700 truncate group-hover:text-neutral-900 transition-colors dark:text-neutral-300 dark:group-hover:text-white">{title}</span>
{getItemBadge?.(item, i)}
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
</button>
)}
</div>
<button type="button" onClick={() => removeItem(i)} aria-label="Удалить элемент"
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0">
<Trash2 size={16} />
</button>
</div>
{collapsible ? (
<div className="grid transition-[grid-template-rows] duration-300 ease-out" style={{ gridTemplateRows: isCollapsed ? "0fr" : "1fr" }}>
<div className="overflow-hidden">
<div className="px-4 pb-4">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
</div>
</div>
) : (
<div className="px-4 pb-4">
{renderItem(item, i, (updated) => updateItem(i, updated))}
</div>
)}
</>
)}
</div>
);
visualIndex++;
@@ -213,8 +369,8 @@ export function ArrayEditor<T>({
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 }}
className="rounded-lg border-2 border-dashed border-gold/40 bg-gold/5 mb-3"
style={{ height: collapsible ? 48 : dragSize.h }}
/>
);
}
@@ -224,22 +380,66 @@ export function ArrayEditor<T>({
return (
<div>
{label && (
<h3 className="text-sm font-medium text-neutral-300 mb-3">{label}</h3>
{(label || (collapsible && items.length > 1)) && (
<div className="flex items-center justify-between mb-3">
{label ? <h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{label}</h3> : <div />}
{collapsible && items.length > 1 && (() => {
const allCollapsed = collapsed.size >= items.length;
return (
<button
type="button"
onClick={() => allCollapsed ? setCollapsed(new Set()) : setCollapsed(new Set(items.map((_, i) => i)))}
className="rounded p-1 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
title={allCollapsed ? "Развернуть все" : "Свернуть все"}
aria-label={allCollapsed ? "Развернуть все" : "Свернуть все"}
>
<ChevronsUpDown size={16} className={`transition-transform duration-200 ${allCollapsed ? "" : "rotate-90"}`} />
</button>
);
})()}
</div>
)}
{!hideAdd && addPosition === "top" && (
<button
type="button"
onClick={() => {
stableKeysRef.current = [nextItemId++, ...stableKeysRef.current];
onChange([createItem(), ...items]);
setNewItemIndex(0);
// Shift collapsed indices and ensure new item is expanded
setCollapsed(prev => {
const next = new Set<number>();
for (const idx of prev) next.add(idx + 1);
return next;
});
}}
className="mb-3 flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors dark:border-white/20 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/40"
>
<Plus size={16} />
{addLabel}
</button>
)}
<div>
{renderList()}
</div>
<button
type="button"
onClick={() => { onChange([...items, createItem()]); setNewItemIndex(items.length); }}
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
>
<Plus size={16} />
{addLabel}
</button>
{!hideAdd && addPosition === "bottom" && (
<button
type="button"
onClick={() => {
stableKeysRef.current.push(nextItemId++);
onChange([...items, createItem()]);
setNewItemIndex(items.length);
setCollapsed(prev => { const next = new Set(prev); next.delete(items.length); return next; });
}}
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors dark:border-white/20 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/40"
>
<Plus size={16} />
{addLabel}
</button>
)}
{/* Floating clone following cursor */}
{mounted && dragIndex !== null &&
@@ -253,13 +453,21 @@ export function ArrayEditor<T>({
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 className="h-full rounded-lg border-2 border-gold/60 bg-white/95 shadow-2xl shadow-gold/20 flex items-center gap-3 px-4 dark:bg-neutral-900/95">
<GripVertical size={16} className="text-gold shrink-0" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">{collapsible && dragIndex !== null ? (getItemTitle?.(items[dragIndex], dragIndex) || "Перемещение...") : "Перемещение элемента..."}</span>
</div>
</div>,
document.body
)}
<ConfirmDialog
open={confirmDelete !== null}
title="Удалить элемент?"
message="Это действие нельзя отменить."
onConfirm={() => { if (confirmDelete !== null) removeItem(confirmDelete); setConfirmDelete(null); }}
onCancel={() => setConfirmDelete(null)}
/>
</div>
);
}
@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
interface CollapsibleSectionProps {
title: string;
count?: number;
defaultOpen?: boolean;
isOpen?: boolean;
onToggle?: () => void;
children: React.ReactNode;
}
/**
* Shared collapsible section for admin pages.
* Supports both controlled (isOpen/onToggle) and uncontrolled (defaultOpen) modes.
*/
export function CollapsibleSection({
title,
count,
defaultOpen = true,
isOpen: controlledOpen,
onToggle,
children,
}: CollapsibleSectionProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
const toggle = onToggle ?? (() => setInternalOpen((v) => !v));
return (
<div className="rounded-xl border border-neutral-200 bg-neutral-100/50 overflow-hidden dark:border-white/10 dark:bg-neutral-800/50">
<button
type="button"
onClick={toggle}
aria-expanded={open}
className="flex items-center justify-between w-full px-5 py-3.5 text-left cursor-pointer group hover:bg-neutral-100 transition-colors dark:hover:bg-white/[0.02]"
>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-neutral-700 group-hover:text-neutral-900 transition-colors dark:text-neutral-200 dark:group-hover:text-white">
{title}
</h3>
{count !== undefined && (
<span className="text-xs text-neutral-500">{count}</span>
)}
</div>
<ChevronDown
size={16}
className={`text-neutral-500 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</button>
<div
className="grid transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
<div className="px-5 pb-5 space-y-4">{children}</div>
</div>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { AlertTriangle, X } from "lucide-react";
import { useFocusTrap } from "@/hooks/useFocusTrap";
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
destructive?: boolean;
}
export function ConfirmDialog({
open,
title,
message,
confirmLabel = "Удалить",
cancelLabel = "Отмена",
onConfirm,
onCancel,
destructive = true,
}: ConfirmDialogProps) {
const cancelRef = useRef<HTMLButtonElement>(null);
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
useEffect(() => {
if (!open) return;
cancelRef.current?.focus();
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onCancel();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onCancel]);
if (!open) return null;
return createPortal(
<div
ref={focusTrapRef}
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
role="alertdialog"
aria-modal="true"
aria-label={title}
onClick={onCancel}
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative w-full max-w-sm rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-neutral-900"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onCancel}
aria-label="Закрыть"
className="absolute right-3 top-3 rounded-full p-1 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
>
<X size={16} />
</button>
<div className="flex items-start gap-3">
{destructive && (
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-500/10">
<AlertTriangle size={20} className="text-red-400" />
</div>
)}
<div>
<h3 className="text-base font-bold text-neutral-900 dark:text-white">{title}</h3>
<p className="mt-1.5 text-sm text-neutral-600 dark:text-neutral-400">{message}</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<button
ref={cancelRef}
onClick={onCancel}
className="rounded-lg px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 transition-colors cursor-pointer dark:text-neutral-300 dark:hover:bg-white/[0.06]"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition-colors cursor-pointer ${
destructive
? "bg-red-600 text-white hover:bg-red-500"
: "bg-gold text-black hover:bg-gold-light"
}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body
);
}
+373 -306
View File
@@ -1,7 +1,8 @@
import { useRef, useEffect, useState, useMemo } from "react";
import { Plus, X, Upload, Loader2, Link, ImageIcon, Calendar, AlertCircle, MapPin } from "lucide-react";
import { useRef, useEffect, useState, useMemo, useCallback } from "react";
import { Plus, X, Upload, Loader2, Link, ImageIcon, AlertCircle, Bold, Italic, List, Heading2, Pencil } from "lucide-react";
import { formatMarkup } from "@/lib/markup";
import { adminFetch } from "@/lib/csrf";
import type { RichListItem, VictoryItem } from "@/types/content";
import type { RichListItem } from "@/types/content";
interface InputFieldProps {
label: string;
@@ -11,10 +12,10 @@ interface InputFieldProps {
type?: "text" | "url" | "tel";
}
const baseInput = "w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none hover:border-gold/30 focus:border-gold transition-colors";
const baseInput = "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500";
const textAreaInput = `${baseInput} resize-none overflow-hidden`;
const smallInput = "rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none hover:border-gold/30 focus:border-gold transition-colors";
const dashedInput = "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 hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors";
const smallInput = "rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-600";
const dashedInput = "flex-1 rounded-lg border border-dashed border-neutral-200 bg-neutral-100/50 px-4 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors dark:border-white/10 dark:bg-neutral-800/50 dark:text-white dark:placeholder-neutral-600";
const inputCls = baseInput;
export function InputField({
@@ -29,7 +30,7 @@ export function InputField({
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<input
type={type}
value={value}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={inputCls}
@@ -86,16 +87,20 @@ export function ParticipantLimits({
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Мин. участников</label>
<input type="number" min={0} value={minStr} onChange={(e) => handleMin(e.target.value)}
aria-describedby="min-hint"
aria-invalid={minEmpty || undefined}
className={`${inputCls} ${minEmpty ? "!border-red-500/50" : ""}`} />
<p className={`text-[10px] mt-1 ${minEmpty ? "text-red-400" : "text-neutral-600"}`}>
<p id="min-hint" className={`text-xs mt-1 ${minEmpty ? "text-red-400" : "text-neutral-600"}`}>
{minEmpty ? "Поле не может быть пустым" : "Если записей меньше — занятие можно отменить"}
</p>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Макс. участников</label>
<input type="number" min={0} value={maxStr} onChange={(e) => handleMax(e.target.value)}
aria-describedby="max-hint"
aria-invalid={(maxEmpty || (maxLocal > 0 && minLocal > maxLocal)) || undefined}
className={`${inputCls} ${maxEmpty || (maxLocal > 0 && minLocal > maxLocal) ? "!border-red-500/50" : ""}`} />
<p className={`text-[10px] mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
<p id="max-hint" className={`text-xs mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
{maxEmpty ? "Поле не может быть пустым" : maxLocal > 0 && minLocal > maxLocal ? "Макс. не может быть меньше мин." : "0 = без лимита. При заполнении — лист ожидания"}
</p>
</div>
@@ -143,7 +148,7 @@ export function TextareaField({
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<textarea
ref={ref}
value={value}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
@@ -153,12 +158,258 @@ export function TextareaField({
);
}
interface RichTextareaProps {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
}
export function RichTextarea({
label,
value,
onChange,
placeholder,
rows = 4,
}: RichTextareaProps) {
const ref = useRef<HTMLTextAreaElement>(null);
const [editing, setEditing] = useState(false);
const hasContent = Boolean(value?.trim());
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);
}, []);
const wrapSelection = useCallback((before: string, after: string) => {
const el = ref.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const text = value ?? "";
const selected = text.slice(start, end);
// If already wrapped, unwrap
const beforeCheck = text.slice(Math.max(0, start - before.length), start);
const afterCheck = text.slice(end, end + after.length);
if (beforeCheck === before && afterCheck === after) {
const newText = text.slice(0, start - before.length) + selected + text.slice(end + after.length);
onChange(newText);
requestAnimationFrame(() => {
el.selectionStart = start - before.length;
el.selectionEnd = end - before.length;
el.focus();
});
return;
}
const newText = text.slice(0, start) + before + selected + after + text.slice(end);
onChange(newText);
requestAnimationFrame(() => {
if (selected) {
el.selectionStart = start + before.length;
el.selectionEnd = end + before.length;
} else {
el.selectionStart = start + before.length;
el.selectionEnd = start + before.length;
}
el.focus();
});
}, [value, onChange]);
const insertAtCursor = useCallback((text: string) => {
const el = ref.current;
if (!el) return;
const start = el.selectionStart;
const current = value ?? "";
// Add newline before if not at start of line
const lineStart = current.lastIndexOf("\n", start - 1) + 1;
const prefix = start > lineStart ? "\n" : "";
const newText = current.slice(0, start) + prefix + text + current.slice(start);
onChange(newText);
const cursorPos = start + prefix.length + text.length;
requestAnimationFrame(() => {
el.selectionStart = cursorPos;
el.selectionEnd = cursorPos;
el.focus();
});
}, [value, onChange]);
// Track active formatting at cursor position
const [cursorPos, setCursorPos] = useState<{ start: number; end: number }>({ start: 0, end: 0 });
const updateCursorPos = useCallback(() => {
const el = ref.current;
if (!el) return;
setCursorPos({ start: el.selectionStart, end: el.selectionEnd });
}, []);
const isBold = useMemo(() => {
const text = value ?? "";
const { start, end } = cursorPos;
if (start !== end) {
// Check if selection is wrapped in **
return text.slice(Math.max(0, start - 2), start) === "**" && text.slice(end, end + 2) === "**";
}
// Check if cursor is inside **...**
const before = text.slice(0, start);
const after = text.slice(start);
const lastOpen = before.lastIndexOf("**");
if (lastOpen === -1) return false;
const betweenOpen = before.slice(lastOpen + 2);
if (betweenOpen.includes("**")) return false;
return after.indexOf("**") !== -1;
}, [value, cursorPos]);
const isItalic = useMemo(() => {
const text = value ?? "";
const { start, end } = cursorPos;
if (start !== end) {
const cb = text[start - 1];
const ca = text[end];
return cb === "*" && ca === "*" && text[start - 2] !== "*" && text[end + 1] !== "*";
}
const before = text.slice(0, start);
const after = text.slice(start);
// Find single * (not **) before cursor
const lastStar = before.lastIndexOf("*");
if (lastStar === -1) return false;
if (lastStar > 0 && before[lastStar - 1] === "*") return false;
const nextStar = after.indexOf("*");
if (nextStar === -1) return false;
if (after[nextStar + 1] === "*") return false;
return true;
}, [value, cursorPos]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// Use e.code for layout-independent shortcuts (works with Russian layout)
if ((e.ctrlKey || e.metaKey) && e.code === "KeyB") {
e.preventDefault();
wrapSelection("**", "**");
}
if ((e.ctrlKey || e.metaKey) && e.code === "KeyI") {
e.preventDefault();
wrapSelection("*", "*");
}
}, [wrapSelection]);
const toolbarBtn = (active: boolean) =>
`rounded p-1.5 transition-colors ${
active
? "text-gold bg-gold/15"
: "text-neutral-500 hover:text-neutral-900 hover:bg-neutral-200 dark:hover:text-white dark:hover:bg-white/10"
}`;
// Preview mode: show rendered markup
if (!editing && hasContent) {
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div
onClick={() => {
setEditing(true);
requestAnimationFrame(() => ref.current?.focus());
}}
className="group rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 cursor-text hover:border-gold/30 transition-colors relative dark:border-white/10 dark:bg-neutral-800"
>
<div className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
{formatMarkup(value)}
</div>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="flex items-center gap-1 text-xs text-neutral-500">
<Pencil size={10} />
редактировать
</span>
</div>
</div>
</div>
);
}
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="rounded-lg border border-neutral-200 bg-neutral-100 overflow-hidden hover:border-gold/30 focus-within:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800">
{/* Toolbar */}
<div className="flex items-center gap-0.5 px-2 py-1 border-b border-neutral-200 dark:border-white/5">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => wrapSelection("**", "**")}
className={toolbarBtn(isBold)}
title="Жирный (Ctrl+B)"
>
<Bold size={14} />
</button>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => wrapSelection("*", "*")}
className={toolbarBtn(isItalic)}
title="Курсив (Ctrl+I)"
>
<Italic size={14} />
</button>
<div className="w-px h-4 bg-white/10 mx-1" />
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertAtCursor("🤍 ")}
className={toolbarBtn(false)}
title="Пункт списка"
>
<List size={14} />
</button>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => wrapSelection("## ", "")}
className={toolbarBtn(false)}
title="Подзаголовок"
>
<Heading2 size={14} />
</button>
</div>
{/* Textarea */}
<textarea
ref={ref}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onKeyUp={updateCursorPos}
onClick={updateCursorPos}
onSelect={updateCursorPos}
onBlur={() => setEditing(false)}
placeholder={placeholder}
rows={rows}
className="w-full bg-transparent px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none resize-none dark:text-white dark:placeholder-neutral-500"
/>
</div>
</div>
);
}
interface SelectFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
hint?: string;
}
export function SelectField({
@@ -167,9 +418,11 @@ export function SelectField({
onChange,
options,
placeholder,
hint,
}: SelectFieldProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [highlightIndex, setHighlightIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -181,6 +434,33 @@ export function SelectField({
})
: options;
const showSearch = options.length > 3;
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
setSearch("");
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) { setOpen(true); setHighlightIndex(0); return; }
setHighlightIndex((prev) => (prev + 1) % filtered.length);
}
if (e.key === "ArrowUp") {
e.preventDefault();
if (!open) { setOpen(true); setHighlightIndex(filtered.length - 1); return; }
setHighlightIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
}
if (e.key === "Enter" && open && highlightIndex >= 0 && highlightIndex < filtered.length) {
e.preventDefault();
onChange(filtered[highlightIndex].value);
setOpen(false);
setSearch("");
setHighlightIndex(-1);
}
}
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
@@ -195,51 +475,74 @@ export function SelectField({
return (
<div ref={containerRef} className="relative">
{label && <label className="block text-sm text-neutral-400 mb-1.5">{label}</label>}
<button
type="button"
onClick={() => {
setOpen(!open);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}}
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`}
>
{selectedLabel || placeholder || "Выберите..."}
</button>
{label && (
<label className="flex items-center gap-1.5 text-sm text-neutral-400 mb-1.5">
{label}
{hint && (
<span className="group relative">
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-neutral-300 text-[10px] text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors cursor-help dark:border-white/15 dark:hover:text-white dark:hover:border-white/30">?</span>
<span className="absolute left-6 top-1/2 -translate-y-1/2 z-50 w-52 rounded-lg border border-neutral-200 bg-white px-3 py-2 text-[11px] leading-relaxed text-neutral-700 shadow-xl opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300">
{hint}
</span>
</span>
)}
</label>
)}
{showSearch ? (
<input
ref={inputRef}
type="text"
value={open ? search : selectedLabel}
onChange={(e) => { setSearch(e.target.value); if (!open) setOpen(true); setHighlightIndex(0); }}
onFocus={() => { setOpen(true); setSearch(""); }}
onKeyDown={handleKeyDown}
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
placeholder={placeholder || "Выберите..."}
className={`w-full rounded-lg border bg-neutral-100 text-neutral-900 outline-none transition-colors dark:bg-neutral-800 dark:text-white ${
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
} ${open ? "border-gold" : "border-neutral-200 dark:border-white/10"} placeholder-neutral-400 dark:placeholder-neutral-500`}
/>
) : (
<button
type="button"
onClick={() => setOpen(!open)}
onKeyDown={handleKeyDown}
aria-expanded={open}
aria-haspopup="listbox"
className={`w-full rounded-lg border bg-neutral-100 text-left outline-none transition-colors dark:bg-neutral-800 ${
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
} ${open ? "border-gold" : "border-neutral-200 dark:border-white/10"} ${value ? "text-neutral-900 dark: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 role="listbox" className="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/10 dark:bg-neutral-800">
<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) => (
{filtered.map((opt, idx) => (
<button
key={opt.value}
key={opt.value || `opt-${idx}`}
type="button"
role="option"
aria-selected={opt.value === value}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setHighlightIndex(idx)}
onClick={() => {
onChange(opt.value);
setOpen(false);
setSearch("");
setHighlightIndex(-1);
inputRef.current?.blur();
}}
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"
}`}
className={`w-full px-4 py-2 text-left text-sm transition-colors ${
idx === highlightIndex ? "bg-neutral-100 dark:bg-white/10" : "hover:bg-neutral-50 dark:hover:bg-white/5"
} ${opt.value === value ? "text-gold bg-gold/5" : "text-neutral-900 dark:text-white"}`}
>
{opt.label}
</button>
@@ -282,7 +585,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
}
function handleEndChange(newEnd: string) {
if (start && newEnd && newEnd <= start) return;
// Always allow the change — validation handles the error display
update(start, newEnd);
}
@@ -295,7 +598,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
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"
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2.5 text-neutral-900 outline-none focus:border-gold transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]"
/>
<span className="text-neutral-500"></span>
<input
@@ -303,7 +606,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
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"
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2.5 text-neutral-900 outline-none focus:border-gold transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]"
/>
</div>
</div>
@@ -374,7 +677,7 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp
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"
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2 text-sm text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
/>
<button
type="button"
@@ -391,6 +694,7 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
onBlur={add}
placeholder={placeholder || "Добавить..."}
className={dashedInput}
/>
@@ -414,11 +718,13 @@ interface VictoryListFieldProps {
onChange: (items: RichListItem[]) => void;
placeholder?: string;
onLinkValidate?: (key: string, error: string | null) => void;
onUploadComplete?: () => void;
}
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate }: VictoryListFieldProps) {
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate, onUploadComplete }: VictoryListFieldProps) {
const [draft, setDraft] = useState("");
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
const [uploadError, setUploadError] = useState("");
function add() {
const val = draft.trim();
@@ -447,6 +753,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
const file = e.target.files?.[0];
if (!file) return;
setUploadingIndex(index);
setUploadError("");
const formData = new FormData();
formData.append("file", file);
formData.append("folder", "team");
@@ -455,8 +762,13 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
const result = await res.json();
if (result.path) {
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
onUploadComplete?.();
} else {
setUploadError(result.error || "Ошибка загрузки");
}
} catch { /* upload failed */ } finally {
} catch {
setUploadError("Не удалось загрузить файл");
} finally {
setUploadingIndex(null);
}
}
@@ -466,13 +778,13 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
<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 key={i} className="rounded-lg border border-neutral-200 bg-neutral-100/80 p-2.5 space-y-1.5 transition-colors hover:border-gold/30 hover:bg-neutral-200/80 focus-within:border-gold/50 focus-within:bg-neutral-200 dark:border-white/10 dark:bg-neutral-800/50 dark:hover:bg-neutral-800/80 dark:focus-within:bg-neutral-800">
<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"
className="flex-1 rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
/>
<button
type="button"
@@ -484,7 +796,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
</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">
<div className="flex items-center gap-1 rounded bg-neutral-200 px-1.5 py-0.5 text-[11px] text-neutral-700 dark:bg-neutral-700/50 dark: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">
@@ -513,6 +825,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
onBlur={add}
placeholder={placeholder || "Добавить..."}
className={dashedInput}
/>
@@ -526,150 +839,8 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
</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>
{uploadError && (
<p role="alert" className="mt-1.5 text-xs text-red-400">{uploadError}</p>
)}
</div>
);
@@ -715,8 +886,8 @@ export function ValidatedLinkField({ value, onChange, onValidate, validationKey,
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"
className={`w-full rounded-md border bg-neutral-100 px-2 py-1 text-xs text-neutral-900 placeholder-neutral-400 outline-none transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-600 ${
error ? "border-red-500/50" : "border-neutral-200 focus:border-gold/50 dark:border-white/5"
}`}
/>
{error && (
@@ -729,110 +900,6 @@ export function ValidatedLinkField({ value, onChange, onValidate, validationKey,
);
}
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 ${smallInput}`}
/>
<input
type="text"
value={item.category || ""}
onChange={(e) => update(i, "category", e.target.value)}
placeholder="Категория"
className={`flex-1 ${smallInput}`}
/>
<input
type="text"
value={item.competition || ""}
onChange={(e) => update(i, "competition", e.target.value)}
placeholder="Чемпионат"
className={`flex-1 ${smallInput}`}
/>
<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>
);
}
// --- Autocomplete Multi-Select ---
export function AutocompleteMulti({
label,
@@ -898,8 +965,8 @@ export function AutocompleteMulti({
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<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 hover:border-gold/30"
className={`flex flex-wrap items-center gap-1.5 rounded-lg border bg-neutral-100 px-3 py-2 min-h-[42px] cursor-text transition-colors dark:bg-neutral-800 ${
open ? "border-gold" : "border-neutral-200 hover:border-gold/30 dark:border-white/10"
}`}
>
{selected.map((item) => (
@@ -918,14 +985,14 @@ export function AutocompleteMulti({
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"
className="flex-1 min-w-[80px] bg-transparent text-sm text-neutral-900 placeholder-neutral-400 outline-none dark:text-white dark:placeholder-neutral-500"
/>
</div>
{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">
<div className="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden max-h-48 overflow-y-auto dark:border-white/10 dark:bg-neutral-800">
{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">
className="w-full px-4 py-2 text-left text-sm text-neutral-900 hover:bg-neutral-50 transition-colors dark:text-white dark:hover:bg-white/5">
{opt}
</button>
))}
@@ -0,0 +1,184 @@
"use client";
import { useState, useRef, useEffect } from "react";
import Image from "next/image";
import { Upload, Loader2, ImageIcon } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
interface ImageCropData {
image: string;
focalX: number;
focalY: number;
zoom: number;
}
interface ImageCropFieldProps extends ImageCropData {
folder: string;
onChange: (data: ImageCropData) => void;
/** Aspect ratio CSS class for the preview. Default: "aspect-[16/9]" */
aspect?: string;
/** Max width CSS class for the preview container. Default: "max-w-3xl" */
maxWidth?: string;
label?: string;
}
export function ImageCropField({
image,
focalX,
focalY,
zoom,
folder,
onChange,
aspect = "aspect-[16/9]",
maxWidth = "max-w-3xl",
label = "Фото",
}: ImageCropFieldProps) {
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState("");
const [dragging, setDragging] = useState(false);
const dragStartRef = useRef({ x: 0, y: 0, startFocalX: 0, startFocalY: 0 });
const containerRef = useRef<HTMLDivElement>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setUploadError("");
const formData = new FormData();
formData.append("file", file);
formData.append("folder", folder);
try {
const res = await adminFetch("/api/admin/upload", {
method: "POST",
body: formData,
});
const result = await res.json();
if (result.path) {
onChange({ image: result.path, focalX: 50, focalY: 50, zoom: 1 });
} else {
setUploadError(result.error || "Ошибка загрузки");
}
} catch {
setUploadError("Не удалось загрузить файл");
} finally {
setUploading(false);
}
}
function handlePointerDown(e: React.PointerEvent) {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
setDragging(true);
dragStartRef.current = { x: e.clientX, y: e.clientY, startFocalX: focalX, startFocalY: focalY };
}
function handlePointerMove(e: React.PointerEvent) {
if (!dragging) return;
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const { x: startX, y: startY, startFocalX, startFocalY } = dragStartRef.current;
const dx = ((e.clientX - startX) / rect.width) * 100;
const dy = ((e.clientY - startY) / rect.height) * 100;
onChange({
image,
focalX: Math.round(Math.max(0, Math.min(100, startFocalX - dx))),
focalY: Math.round(Math.max(0, Math.min(100, startFocalY - dy))),
zoom,
});
}
function handlePointerUp() { setDragging(false); }
// Attach wheel as non-passive to allow preventDefault (stops page scroll)
useEffect(() => {
const el = containerRef.current;
if (!el) return;
function onWheel(e: WheelEvent) {
if (!e.ctrlKey && !e.metaKey) return; // Only zoom with Ctrl+scroll
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
onChange({ image, focalX, focalY, zoom: Math.round(Math.max(1, Math.min(3, zoom + delta)) * 10) / 10 });
}
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [zoom, focalX, focalY, image, onChange]);
return (
<div>
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">
{label} <span className="text-neutral-400 dark:text-neutral-600">(перетащите · Ctrl+колёсико для масштаба)</span>
</label>
{image ? (
<div className={`${maxWidth} space-y-2`}>
<div
ref={containerRef}
className={`relative ${aspect} overflow-hidden rounded-lg border border-neutral-200 cursor-grab active:cursor-grabbing select-none dark:border-white/10`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<Image
src={image}
alt="Превью"
fill
className="object-cover pointer-events-none"
style={{
objectPosition: `${focalX}% ${focalY}%`,
transform: `scale(${zoom})`,
}}
sizes="384px"
unoptimized
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500"></span>
<input
type="range"
min="1"
max="3"
step="0.1"
value={zoom}
onChange={(e) => onChange({ image, focalX, focalY, zoom: parseFloat(e.target.value) })}
className="flex-1 h-1 accent-[#c9a96e] cursor-pointer"
/>
<span className="text-xs text-neutral-500">+</span>
{zoom > 1 && (
<button
type="button"
onClick={() => onChange({ image, focalX: 50, focalY: 50, zoom: 1 })}
className="text-xs text-neutral-500 hover:text-white transition-colors"
>
Сбросить
</button>
)}
</div>
<div className="flex items-center gap-2">
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-neutral-200 px-2.5 py-1 text-xs text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25">
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
Заменить
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
</label>
<button
type="button"
onClick={() => onChange({ image: "", focalX: 50, focalY: 50, zoom: 1 })}
className="rounded-md px-2.5 py-1 text-xs text-neutral-500 hover:text-red-400 transition-colors"
>
Удалить
</button>
</div>
</div>
) : (
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-neutral-500 hover:border-gold/30 hover:text-neutral-700 transition-colors dark:border-white/15 dark:hover:text-neutral-300">
{uploading ? <Loader2 size={14} className="animate-spin" /> : <ImageIcon size={14} />}
<span className="text-xs">{uploading ? "Загрузка..." : "Загрузить фото"}</span>
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
</label>
)}
{uploadError && (
<p role="alert" className="mt-1.5 text-xs text-red-400">{uploadError}</p>
)}
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
interface PriceFieldProps {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
}
export function PriceField({ label, value, onChange, placeholder = "0" }: PriceFieldProps) {
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
return (
<div>
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">{label}</label>
<div className="flex rounded-lg border border-neutral-200 bg-neutral-100 focus-within:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800">
<input
type="text"
inputMode="decimal"
value={raw}
onChange={(e) => {
const v = e.target.value.replace(/[^\d.,\s]/g, "");
onChange(v ? `${v} BYN` : "");
}}
placeholder={placeholder}
className="flex-1 bg-transparent px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none min-w-0 dark:text-white dark:placeholder-neutral-500"
/>
<span className="flex items-center pr-4 text-sm font-medium text-gold select-none">
BYN
</span>
</div>
</div>
);
}
+53 -4
View File
@@ -7,6 +7,9 @@ import { adminFetch } from "@/lib/csrf";
interface SectionEditorProps<T> {
sectionKey: string;
title: string;
defaultData?: Partial<T>;
/** Return true if data is valid and can be saved. Blocks auto-save when false. */
validate?: (data: T) => boolean;
children: (data: T, update: (data: T) => void) => React.ReactNode;
}
@@ -15,14 +18,19 @@ const DEBOUNCE_MS = 800;
export function SectionEditor<T>({
sectionKey,
title,
defaultData,
validate,
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 [status, setStatus] = useState<"idle" | "saving" | "saved" | "error" | "invalid">("idle");
const [error, setError] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialLoadRef = useRef(true);
const pendingSaveRef = useRef(false);
const defaultDataRef = useRef(defaultData);
defaultDataRef.current = defaultData;
useEffect(() => {
adminFetch(`/api/admin/sections/${sectionKey}`)
@@ -30,7 +38,7 @@ export function SectionEditor<T>({
if (!r.ok) throw new Error("Failed to load");
return r.json();
})
.then(setData)
.then((loaded) => setData(defaultDataRef.current ? { ...defaultDataRef.current, ...loaded } as T : loaded))
.catch(() => setError("Не удалось загрузить данные"))
.finally(() => setLoading(false));
}, [sectionKey]);
@@ -63,8 +71,13 @@ export function SectionEditor<T>({
return;
}
pendingSaveRef.current = true;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (validate && !validate(data)) {
setStatus("invalid");
return;
}
save(data);
}, DEBOUNCE_MS);
@@ -73,6 +86,41 @@ export function SectionEditor<T>({
};
}, [data, save]);
// Clear pending flag after save completes
useEffect(() => {
if (status === "saved") pendingSaveRef.current = false;
}, [status]);
// Warn before leaving with unsaved changes
useEffect(() => {
function onBeforeUnload(e: BeforeUnloadEvent) {
if (pendingSaveRef.current) e.preventDefault();
}
function onLinkClick(e: MouseEvent) {
if (!pendingSaveRef.current) return;
const link = (e.target as HTMLElement).closest("a");
if (!link || link.target === "_blank") return;
const href = link.getAttribute("href");
if (!href || href.startsWith("#")) return;
// Force save immediately before navigating
if (timerRef.current) clearTimeout(timerRef.current);
if (data && (!validate || validate(data))) {
e.preventDefault();
e.stopPropagation();
save(data).then(() => {
pendingSaveRef.current = false;
window.location.href = href;
});
}
}
window.addEventListener("beforeunload", onBeforeUnload);
document.addEventListener("click", onLinkClick, true);
return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
document.removeEventListener("click", onLinkClick, true);
};
}, [data, save, validate]);
if (loading) {
return (
<div className="flex items-center gap-2 text-neutral-400">
@@ -91,14 +139,15 @@ export function SectionEditor<T>({
<h1 className="text-2xl font-bold">{title}</h1>
{/* Fixed toast popup */}
{(status === "saved" || status === "error") && (
<div className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
{(status === "saved" || status === "error" || status === "invalid") && (
<div role="status" aria-live="polite" className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
status === "saved"
? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
: "bg-red-950/90 border-red-500/30 text-red-200"
}`}>
{status === "saved" && <><Check size={14} /> Сохранено</>}
{status === "error" && <><AlertCircle size={14} /> {error}</>}
{status === "invalid" && <><AlertCircle size={14} /> Не сохранено исправьте ошибки</>}
</div>
)}
+2 -1
View File
@@ -41,7 +41,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
<ToastContext.Provider value={{ showError, showSuccess }}>
{children}
{toasts.length > 0 && (
<div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
<div role="status" aria-live="polite" className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
{toasts.map((t) => (
<div
key={t.id}
@@ -54,6 +54,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
{t.type === "error" ? <AlertCircle size={14} className="shrink-0" /> : <CheckCircle2 size={14} className="shrink-0" />}
<span className="flex-1">{t.message}</span>
<button
aria-label="Закрыть уведомление"
onClick={() => setToasts((prev) => prev.filter((tt) => tt.id !== t.id))}
className="shrink-0 text-neutral-400 hover:text-white"
>
+203
View File
@@ -0,0 +1,203 @@
/**
* Admin UI Primitives
*
* Single source of truth for admin panel styling.
* Every input, select, button, badge, card, and modal in /admin should use these.
*/
import { type ComponentPropsWithoutRef, forwardRef } from "react";
import { X } from "lucide-react";
import { useFocusTrap } from "@/hooks/useFocusTrap";
/* ============================== */
/* Style tokens */
/* ============================== */
export const adminStyles = {
/** Standard input — full width, rounded-lg */
input:
"w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500",
/** Compact input — smaller padding, text-sm */
inputSm:
"rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-600",
/** Dashed input — for "add new" fields */
inputDashed:
"flex-1 rounded-lg border border-dashed border-neutral-200 bg-neutral-100/50 px-4 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors dark:border-white/10 dark:bg-neutral-800/50 dark:text-white dark:placeholder-neutral-600",
/** Textarea — same as input + resize-none */
textarea:
"w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors resize-none dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500",
/** Native select */
select:
"w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]",
/** Select option */
option: "bg-white dark:bg-neutral-900",
/** Primary button — gold solid */
btnPrimary:
"inline-flex items-center justify-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-all hover:bg-gold-light hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed",
/** Secondary button — outline */
btnSecondary:
"inline-flex items-center justify-center gap-2 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-200 dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700",
/** Small gold accent button */
btnGoldSm:
"rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-amber-700 hover:bg-gold/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed dark:text-gold",
/** Cancel/muted small button */
btnCancelSm:
"rounded-md border border-neutral-200 px-3 py-1 text-xs text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25",
/** Danger button */
btnDanger:
"inline-flex items-center justify-center gap-2 rounded-lg bg-red-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-red-500",
/** Card container */
card:
"rounded-xl border border-neutral-200 bg-white p-5 dark:border-white/10 dark:bg-neutral-900",
/** Modal overlay */
modalOverlay:
"fixed inset-0 z-50 flex items-center justify-center p-4",
/** Modal backdrop */
modalBackdrop:
"absolute inset-0 bg-black/70 backdrop-blur-sm",
/** Modal content */
modalContent:
"relative w-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]",
/** Modal close button */
modalClose:
"absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 transition-colors cursor-pointer dark:hover:bg-white/[0.06] dark:hover:text-white",
/** Label */
label:
"block text-xs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400",
/** Section heading in admin */
sectionTitle:
"text-lg font-bold text-neutral-900 dark:text-white",
/** Dashed add-item button */
addButton:
"flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors dark:border-white/20 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/40",
} as const;
/* ============================== */
/* Input components */
/* ============================== */
interface AdminInputProps extends ComponentPropsWithoutRef<"input"> {
variant?: "default" | "sm" | "dashed";
}
export const AdminInput = forwardRef<HTMLInputElement, AdminInputProps>(
function AdminInput({ variant = "default", className = "", ...props }, ref) {
const base =
variant === "sm" ? adminStyles.inputSm
: variant === "dashed" ? adminStyles.inputDashed
: adminStyles.input;
return <input ref={ref} className={`${base} ${className}`} {...props} />;
},
);
interface AdminTextareaProps extends ComponentPropsWithoutRef<"textarea"> {
autoResize?: boolean;
}
export const AdminTextarea = forwardRef<HTMLTextAreaElement, AdminTextareaProps>(
function AdminTextarea({ autoResize, className = "", ...props }, ref) {
function handleInput(e: React.FormEvent<HTMLTextAreaElement>) {
if (autoResize) {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}
}
return (
<textarea
ref={ref}
className={`${adminStyles.textarea} ${className}`}
{...props}
{...(autoResize ? { onInput: handleInput } : {})}
/>
);
},
);
interface AdminSelectProps extends ComponentPropsWithoutRef<"select"> {
children: React.ReactNode;
}
export const AdminSelect = forwardRef<HTMLSelectElement, AdminSelectProps>(
function AdminSelect({ className = "", children, ...props }, ref) {
return (
<select ref={ref} className={`${adminStyles.select} ${className}`} {...props}>
{children}
</select>
);
},
);
/* ============================== */
/* Button components */
/* ============================== */
interface AdminButtonProps extends ComponentPropsWithoutRef<"button"> {
variant?: "primary" | "secondary" | "danger" | "goldSm" | "cancelSm";
}
export function AdminButton({ variant = "primary", className = "", ...props }: AdminButtonProps) {
const base =
variant === "secondary" ? adminStyles.btnSecondary
: variant === "danger" ? adminStyles.btnDanger
: variant === "goldSm" ? adminStyles.btnGoldSm
: variant === "cancelSm" ? adminStyles.btnCancelSm
: adminStyles.btnPrimary;
return <button className={`${base} ${className}`} {...props} />;
}
/* ============================== */
/* Modal component */
/* ============================== */
interface AdminModalProps {
open: boolean;
onClose: () => void;
title?: string;
maxWidth?: string;
children: React.ReactNode;
}
export function AdminModal({ open, onClose, title, maxWidth = "max-w-sm", children }: AdminModalProps) {
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
if (!open) return null;
return (
<div className={adminStyles.modalOverlay} onClick={onClose}>
<div className={adminStyles.modalBackdrop} />
<div
ref={focusTrapRef}
className={`${adminStyles.modalContent} ${maxWidth}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={title}
>
<button onClick={onClose} className={adminStyles.modalClose} aria-label="Закрыть">
<X size={16} />
</button>
{title && <h3 className="text-sm font-bold text-neutral-900 dark:text-white mb-4">{title}</h3>}
{children}
</div>
</div>
);
}
+8 -6
View File
@@ -1,7 +1,7 @@
"use client";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { InputField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
interface AboutData {
@@ -11,7 +11,7 @@ interface AboutData {
export default function AboutEditorPage() {
return (
<SectionEditor<AboutData> sectionKey="about" title="О студии">
<SectionEditor<AboutData> sectionKey="about" title="О студии" defaultData={{ paragraphs: [] }}>
{(data, update) => (
<>
<InputField
@@ -23,12 +23,14 @@ export default function AboutEditorPage() {
label="Параграфы"
items={data.paragraphs}
onChange={(paragraphs) => update({ ...data, paragraphs })}
inline
renderItem={(text, _i, updateItem) => (
<TextareaField
label={`Параграф`}
<textarea
value={text}
onChange={updateItem}
rows={3}
onChange={(e) => updateItem(e.target.value)}
rows={2}
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors resize-none dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
placeholder="Текст параграфа..."
/>
)}
createItem={() => ""}
+233 -80
View File
@@ -1,16 +1,131 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { X, ChevronDown } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { formatBelarusPhone, SHORT_DAYS } from "@/lib/formatting";
type Tab = "classes" | "events";
type EventType = "master-class" | "open-day";
interface McOption { title: string; date: string }
interface OdClass { id: number; style: string; time: string; hall: string }
interface OdClass { id: number; style: string; start_time: string; hall: string; trainer: string }
interface OdEvent { id: number; date: string; title?: string }
interface ScheduleClass { type: string; trainer: string; time: string; day: string; hall: string; groupId?: string }
function shortName(fullName: string) {
const parts = fullName.trim().split(/\s+/);
// Names stored as "Имя Фамилия" → show "Фамилия И."
return parts.length > 1 ? `${parts[1]} ${parts[0][0]}.` : parts[0];
}
// --- Searchable dropdown ---
interface SearchSelectOption { value: string; label: string }
function SearchSelect({ options, value, onChange, placeholder }: {
options: SearchSelectOption[];
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selected = options.find((o) => o.value === value);
const filtered = search
? (() => {
const tokens = search.toLowerCase().split(/\s+/).filter(Boolean);
return options.filter((o) => {
const label = o.label.toLowerCase();
return tokens.every((t) => label.includes(t));
});
})()
: 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">
<div
onClick={() => { setOpen(true); setTimeout(() => inputRef.current?.focus(), 0); }}
className={`flex items-center gap-2 w-full rounded-lg border px-3 py-2 text-sm cursor-text transition-colors ${
open ? "border-gold/40 bg-neutral-200/60 dark:bg-white/[0.06]" : "border-neutral-200 bg-neutral-100 dark:border-white/[0.08] dark:bg-white/[0.04]"
}`}
>
{open ? (
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={selected ? selected.label : placeholder}
className="flex-1 bg-transparent text-neutral-900 placeholder-neutral-400 outline-none text-sm dark:text-white dark:placeholder-neutral-500"
onKeyDown={(e) => {
if (e.key === "Escape") { setOpen(false); setSearch(""); }
if (e.key === "Backspace" && !search && value) { onChange(""); }
}}
/>
) : (
<span className={`flex-1 truncate ${selected ? "text-neutral-900 dark:text-white" : "text-neutral-500"}`}>
{selected ? selected.label : placeholder}
</span>
)}
{value && !open ? (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onChange(""); }}
className="text-neutral-500 hover:text-white transition-colors shrink-0"
>
<X size={14} />
</button>
) : (
<ChevronDown size={14} className={`text-neutral-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} />
)}
</div>
{open && (
<div className="absolute z-20 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/[0.08] dark:bg-[#141414]">
<div className="max-h-48 overflow-y-scroll admin-scrollbar">
{filtered.length === 0 && (
<p className="px-3 py-2 text-xs text-neutral-500">Ничего не найдено</p>
)}
{filtered.map((o) => (
<button
key={o.value}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => { onChange(o.value); setOpen(false); setSearch(""); }}
className={`w-full px-3 py-2 text-left text-sm transition-colors ${
o.value === value ? "bg-gold/10 text-gold" : "text-neutral-900 hover:bg-neutral-50 dark:text-white dark:hover:bg-white/[0.05]"
}`}
>
{o.label}
</button>
))}
</div>
</div>
)}
</div>
);
}
// --- Modal ---
export function AddBookingModal({
open,
@@ -32,13 +147,28 @@ export function AddBookingModal({
const [odClasses, setOdClasses] = useState<OdClass[]>([]);
const [odEventId, setOdEventId] = useState<number | null>(null);
const [odClassId, setOdClassId] = useState("");
const [scheduleClasses, setScheduleClasses] = useState<ScheduleClass[]>([]);
const [classGroup, setClassGroup] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId("");
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassGroup("");
// Fetch upcoming MCs (filter out expired)
// Fetch schedule classes
adminFetch("/api/admin/sections/schedule").then((r) => r.json()).then((data: { locations?: { name: string; days: { day: string; classes: { type: string; trainer: string; time: string; groupId?: string }[] }[] }[] }) => {
const classes: ScheduleClass[] = [];
for (const loc of data.locations || []) {
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, groupId: cls.groupId });
}
}
}
setScheduleClasses(classes);
}).catch(() => {});
// Fetch upcoming MCs
adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()).then((data: { items?: { title: string; slots: { date: string }[] }[] }) => {
const today = new Date().toISOString().split("T")[0];
const upcoming = (data.items || [])
@@ -74,34 +204,61 @@ export function AddBookingModal({
}, [open, onClose]);
function handlePhoneChange(raw: string) {
let digits = raw.replace(/\D/g, "");
if (!digits.startsWith("375")) digits = "375" + digits.replace(/^375?/, "");
digits = digits.slice(0, 12);
let formatted = "+375";
const rest = digits.slice(3);
if (rest.length > 0) formatted += " (" + rest.slice(0, 2);
if (rest.length >= 2) formatted += ") ";
if (rest.length > 2) formatted += rest.slice(2, 5);
if (rest.length > 5) formatted += "-" + rest.slice(5, 7);
if (rest.length > 7) formatted += "-" + rest.slice(7, 9);
setPhone(formatted);
setPhone(formatBelarusPhone(raw));
}
const hasUpcomingMc = mcOptions.length > 0;
const hasOpenDay = odEventId !== null && odClasses.length > 0;
const hasEvents = hasUpcomingMc || hasOpenDay;
// Flat group options: one searchable dropdown
const classGroupOptions = useMemo((): SearchSelectOption[] => {
const byKey = new Map<string, { type: string; trainer: string; hall: string; slots: { day: string; time: string }[]; id: string }>();
for (const c of scheduleClasses) {
const id = c.groupId || `${c.type}|${c.trainer}|${c.time}|${c.hall}`;
const existing = byKey.get(id);
if (existing) {
if (!existing.slots.some((s) => s.day === c.day)) existing.slots.push({ day: c.day, time: c.time });
} else {
byKey.set(id, { type: c.type, trainer: c.trainer, hall: c.hall, slots: [{ day: c.day, time: c.time }], id });
}
}
return [...byKey.values()].map((g) => {
const sameTime = g.slots.every((s) => s.time === g.slots[0].time);
const days = sameTime
? `${g.slots.map((s) => SHORT_DAYS[s.day] || s.day.slice(0, 2)).join("/")} ${g.slots[0].time}`
: g.slots.map((s) => `${SHORT_DAYS[s.day] || s.day.slice(0, 2)} ${s.time}`).join(", ");
return {
value: g.id,
label: `${shortName(g.trainer)} · ${g.type} · ${days} · ${g.hall}`,
};
}).sort((a, b) => a.label.localeCompare(b.label));
}, [scheduleClasses]);
const mcSelectOptions: SearchSelectOption[] = mcOptions.map((mc) => ({
value: mc.title,
label: mc.title,
}));
const odSelectOptions: SearchSelectOption[] = odClasses.map((c) => ({
value: String(c.id),
label: `${shortName(c.trainer)}${c.start_time} · ${c.hall}`,
}));
async function handleSubmit() {
if (!name.trim() || !phone.trim()) return;
setSaving(true);
try {
if (tab === "classes") {
const groupInfo = classGroup
? classGroupOptions.find((o) => o.value === classGroup)?.label
: undefined;
await adminFetch("/api/admin/group-bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name.trim(),
phone: phone.trim(),
...(groupInfo && { groupInfo }),
...(instagram.trim() && { instagram: instagram.trim() }),
...(telegram.trim() && { telegram: telegram.trim() }),
}),
@@ -122,6 +279,8 @@ export function AddBookingModal({
}
onAdded();
onClose();
} catch {
alert("Не удалось создать запись. Попробуйте ещё раз.");
} finally {
setSaving(false);
}
@@ -129,19 +288,7 @@ export function AddBookingModal({
if (!open) return null;
const inputClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 placeholder-neutral-500";
const tabBtn = (key: Tab, label: string, disabled?: boolean) => (
<button
key={key}
onClick={() => !disabled && setTab(key)}
disabled={disabled}
className={`flex-1 rounded-lg py-2 text-xs font-medium transition-all ${
tab === key ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
}`}
>
{label}
</button>
);
const inputClass = "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 placeholder-neutral-400 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500";
const canSubmit = name.trim() && phone.trim() && !saving
&& (tab === "classes" || (tab === "events" && eventType === "master-class" && hasUpcomingMc)
@@ -150,69 +297,75 @@ export function AddBookingModal({
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
<div className="relative w-full max-w-sm rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white">
<X size={16} />
</button>
<h3 className="text-base font-bold text-white">Добавить запись</h3>
<p className="mt-1 text-xs text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
<h3 className="text-base font-bold text-neutral-900 dark:text-white">Добавить запись</h3>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
<div className="mt-4 space-y-3">
{/* Tab: Classes vs Events */}
<div className="flex gap-2">
{tabBtn("classes", "Занятие")}
{tabBtn("events", "Мероприятие", !hasEvents)}
{/* Type selector — single row */}
<div className="flex rounded-lg border border-neutral-200 bg-neutral-100 p-0.5 dark:border-white/[0.08] dark:bg-white/[0.03]">
<button
onClick={() => setTab("classes")}
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
tab === "classes" ? "bg-gold/20 text-gold shadow-sm" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
}`}
>
Занятие
</button>
{hasUpcomingMc && (
<button
onClick={() => { setTab("events"); setEventType("master-class"); }}
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
tab === "events" && eventType === "master-class" ? "bg-purple-500/15 text-purple-400 shadow-sm" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
}`}
>
Мастер-класс
</button>
)}
{hasOpenDay && (
<button
onClick={() => { setTab("events"); setEventType("open-day"); }}
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
tab === "events" && eventType === "open-day" ? "bg-blue-500/15 text-blue-400 shadow-sm" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
}`}
>
Open Day
</button>
)}
</div>
{/* Events sub-selector */}
{tab === "events" && (
<div className="flex gap-2">
{hasUpcomingMc && (
<button
onClick={() => setEventType("master-class")}
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${
eventType === "master-class" ? "bg-purple-500/15 text-purple-400 border border-purple-500/30" : "bg-neutral-800/50 text-neutral-500 border border-white/5 hover:text-neutral-300"
}`}
>
Мастер-класс
</button>
)}
{hasOpenDay && (
<button
onClick={() => setEventType("open-day")}
className={`flex-1 rounded-lg py-1.5 text-[11px] font-medium transition-all ${
eventType === "open-day" ? "bg-blue-500/15 text-blue-400 border border-blue-500/30" : "bg-neutral-800/50 text-neutral-500 border border-white/5 hover:text-neutral-300"
}`}
>
Open Day
</button>
)}
</div>
{/* Class selector (optional for Занятие) */}
{tab === "classes" && classGroupOptions.length > 0 && (
<SearchSelect
options={classGroupOptions}
value={classGroup}
onChange={setClassGroup}
placeholder="Группа (необязательно)"
/>
)}
{/* MC selector */}
{tab === "events" && eventType === "master-class" && mcOptions.length > 0 && (
<select value={mcTitle} onChange={(e) => setMcTitle(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
<option value="" className="bg-neutral-900">Выберите мастер-класс</option>
{mcOptions.map((mc) => (
<option key={mc.title} value={mc.title} className="bg-neutral-900">
{mc.title}
</option>
))}
</select>
{tab === "events" && eventType === "master-class" && mcSelectOptions.length > 0 && (
<SearchSelect
options={mcSelectOptions}
value={mcTitle}
onChange={setMcTitle}
placeholder="Выберите мастер-класс"
/>
)}
{/* Open Day class selector */}
{tab === "events" && eventType === "open-day" && odClasses.length > 0 && (
<select value={odClassId} onChange={(e) => setOdClassId(e.target.value)} className={inputClass + " [color-scheme:dark]"}>
<option value="" className="bg-neutral-900">Выберите занятие</option>
{odClasses.map((c) => (
<option key={c.id} value={c.id} className="bg-neutral-900">
{c.time} · {c.style} · {c.hall}
</option>
))}
</select>
{tab === "events" && eventType === "open-day" && odSelectOptions.length > 0 && (
<SearchSelect
options={odSelectOptions}
value={odClassId}
onChange={setOdClassId}
placeholder="Выберите занятие"
/>
)}
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Имя" className={inputClass} />
+19 -19
View File
@@ -47,17 +47,17 @@ export function DeleteBtn({ onClick, name }: { onClick: () => void; name?: strin
{confirming && createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirming(false)}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-xs rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<button onClick={() => setConfirming(false)} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
<div className="relative w-full max-w-xs rounded-2xl border border-neutral-200 bg-white p-5 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]" onClick={(e) => e.stopPropagation()}>
<button onClick={() => setConfirming(false)} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white">
<X size={16} />
</button>
<h3 className="text-sm font-bold text-white">Удалить запись?</h3>
{name && <p className="mt-1 text-xs text-neutral-400">{name}</p>}
<p className="mt-2 text-xs text-neutral-500">Это действие нельзя отменить.</p>
<h3 className="text-sm font-bold text-neutral-900 dark:text-white">Удалить запись?</h3>
{name && <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{name}</p>}
<p className="mt-2 text-xs text-neutral-400 dark:text-neutral-500">Это действие нельзя отменить.</p>
<div className="mt-4 flex gap-2">
<button
onClick={() => setConfirming(false)}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 py-2 text-xs font-medium text-neutral-300 hover:bg-neutral-700 transition-colors"
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 py-2 text-xs font-medium text-neutral-700 hover:bg-neutral-200 transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
Отмена
</button>
@@ -80,17 +80,17 @@ export function ContactLinks({ phone, instagram, telegram }: { phone?: string; i
return (
<>
{phone && (
<a href={`tel:${phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
<a href={`tel:${phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-500 dark:text-emerald-400 dark:hover:text-emerald-300 text-xs">
<Phone size={10} />{phone}
</a>
)}
{instagram && (
<a href={`https://ig.me/m/${instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
<a href={`https://ig.me/m/${instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-600 hover:text-pink-500 dark:text-pink-400 dark:hover:text-pink-300 text-xs">
<Instagram size={10} />{instagram}
</a>
)}
{telegram && (
<a href={`https://t.me/${telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
<a href={`https://t.me/${telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 text-xs">
<Send size={10} />{telegram}
</a>
)}
@@ -109,7 +109,7 @@ export function FilterTabs({ filter, counts, total, onFilter }: {
<button
onClick={() => onFilter("all")}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-100 text-neutral-500 border border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
}`}
>
Все <span className="text-neutral-500 ml-1">{total}</span>
@@ -119,7 +119,7 @@ export function FilterTabs({ filter, counts, total, onFilter }: {
key={s.key}
onClick={() => onFilter(s.key)}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-100 text-neutral-500 border border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
}`}
>
{s.label}
@@ -147,14 +147,14 @@ export function StatusActions({ status, onStatus }: { status: BookingStatus; onS
);
return (
<div className="flex gap-1 ml-auto">
{status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20")}
{status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/30 hover:bg-blue-500/20")}
{status === "contacted" && (
<>
{actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20")}
{actionBtn("Отказ", () => onStatus("declined"), "bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20")}
{actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20")}
{actionBtn("Отказ", () => onStatus("declined"), "bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/30 hover:bg-red-500/20")}
</>
)}
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", () => onStatus("contacted"), "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300")}
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", () => onStatus("contacted"), "bg-neutral-100 text-neutral-600 border border-neutral-300 hover:border-neutral-400 hover:text-neutral-800 dark:bg-neutral-800/50 dark:text-neutral-500 dark:border-transparent dark:hover:border-white/10 dark:hover:text-neutral-300")}
</div>
);
}
@@ -163,10 +163,10 @@ export function BookingCard({ status, highlight, children }: { status: BookingSt
return (
<div
className={`rounded-lg border p-3 transition-all duration-200 cursor-default ${
status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50 hover:opacity-70 hover:border-red-500/30"
: status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02] hover:border-emerald-500/30 hover:bg-emerald-500/[0.05]"
: status === "new" ? "border-gold/20 bg-gold/[0.03] hover:border-gold/40 hover:bg-gold/[0.06]"
: "border-white/10 bg-neutral-800/30 hover:border-white/20 hover:bg-neutral-800/50"
status === "declined" ? "border-red-500/20 bg-red-500/[0.04] opacity-50 hover:opacity-70 hover:border-red-500/30 dark:border-red-500/15 dark:bg-red-500/[0.02]"
: status === "confirmed" ? "border-emerald-500/20 bg-emerald-500/[0.04] hover:border-emerald-500/30 hover:bg-emerald-500/[0.08] dark:border-emerald-500/15 dark:bg-emerald-500/[0.02] dark:hover:bg-emerald-500/[0.05]"
: status === "new" ? "border-gold/30 bg-gold/[0.06] hover:border-gold/50 hover:bg-gold/[0.1] dark:border-gold/20 dark:bg-gold/[0.03] dark:hover:border-gold/40 dark:hover:bg-gold/[0.06]"
: "border-neutral-200 bg-neutral-50 hover:border-neutral-300 hover:bg-neutral-100 dark:border-white/10 dark:bg-neutral-800/30 dark:hover:border-white/20 dark:hover:bg-neutral-800/50"
}${highlight ? " ring-2 ring-gold/40 animate-[pulse_1s_ease-in-out_1]" : ""}`}
>
{children}
+29 -15
View File
@@ -109,7 +109,7 @@ export function GenericBookingsList<T extends BaseBooking>({
<BookingCard status={item.status} highlight={isHighlighted}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
<span className="font-medium text-white truncate max-w-[200px]">{item.name}</span>
<span className="font-medium text-neutral-900 dark:text-white truncate max-w-[200px]">{item.name}</span>
<ContactLinks phone={item.phone} instagram={item.instagram} telegram={item.telegram} />
{renderExtra?.(item)}
</div>
@@ -144,40 +144,54 @@ export function GenericBookingsList<T extends BaseBooking>({
const groupCounts = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
for (const item of group.items) groupCounts[item.status] = (groupCounts[item.status] || 0) + 1;
return (
<div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-white/5 opacity-60" : "border-white/10"}`}>
<div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-neutral-100 opacity-60 dark:border-white/5" : "border-neutral-200 dark:border-white/10"}`}>
<button
onClick={() => setExpanded((p) => ({ ...p, [group.key]: !isOpen }))}
className={`w-full flex items-center gap-3 px-4 py-3 transition-colors text-left ${group.isArchived ? "bg-neutral-900/50 hover:bg-neutral-800/50" : "bg-neutral-900 hover:bg-neutral-800/80"}`}
className={`w-full flex items-center gap-3 px-4 py-3 transition-colors text-left ${group.isArchived ? "bg-neutral-100/50 hover:bg-neutral-200/50 dark:bg-neutral-900/50 dark:hover:bg-neutral-800/50" : "bg-neutral-50 hover:bg-neutral-200/80 dark:bg-neutral-900 dark:hover:bg-neutral-800/80"}`}
>
{isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
{group.sublabel && (
<span className={`text-xs font-medium shrink-0 ${group.isArchived ? "text-neutral-500" : "text-gold"}`}>{group.sublabel}</span>
)}
<span className={`font-medium text-sm truncate ${group.isArchived ? "text-neutral-400" : "text-white"}`}>{group.label}</span>
<span className={`font-medium text-sm truncate ${group.isArchived ? "text-neutral-500 dark:text-neutral-400" : "text-neutral-900 dark:text-white"}`}>{group.label}</span>
{group.dateBadge && (
<span className={`text-[10px] rounded-full px-2 py-0.5 shrink-0 ${
group.isArchived ? "text-neutral-600 bg-neutral-800 line-through" : "text-gold bg-gold/10"
group.isArchived ? "text-neutral-600 bg-neutral-800 line-through" : "text-amber-700 dark:text-gold bg-gold/10"
}`}>
{group.dateBadge}
</span>
)}
{group.isArchived && (
<span className="text-[10px] text-neutral-600 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">архив</span>
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 shrink-0 dark:text-neutral-600 dark:bg-neutral-800">архив</span>
)}
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">{group.items.length} чел.</span>
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 shrink-0 dark:bg-neutral-800">{group.items.length} чел.</span>
{!group.isArchived && (
<div className="flex gap-2 ml-auto text-[10px]">
{groupCounts.new > 0 && <span className="text-gold">{groupCounts.new} новых</span>}
{groupCounts.contacted > 0 && <span className="text-blue-400">{groupCounts.contacted} связ.</span>}
{groupCounts.confirmed > 0 && <span className="text-emerald-400">{groupCounts.confirmed} подтв.</span>}
{groupCounts.new > 0 && <span className="text-amber-700 dark:text-gold">{groupCounts.new} новых</span>}
{groupCounts.contacted > 0 && <span className="text-blue-600 dark:text-blue-400">{groupCounts.contacted} связ.</span>}
{groupCounts.confirmed > 0 && <span className="text-emerald-600 dark:text-emerald-400">{groupCounts.confirmed} подтв.</span>}
</div>
)}
</button>
{isOpen && (
<div className="px-4 pb-3 pt-1 space-y-2">
{group.items.map((item) => renderItem(item, group.isArchived))}
</div>
)}
{isOpen && (() => {
const regular = group.items.filter((i) => !i.notes?.includes("Лист ожидания"));
const waiting = group.items.filter((i) => i.notes?.includes("Лист ожидания"));
return (
<div className="px-4 pb-3 pt-1 space-y-2">
{regular.map((item) => renderItem(item, group.isArchived))}
{waiting.length > 0 && (
<>
<div className="flex items-center gap-2 pt-1">
<div className="flex-1 h-px bg-amber-500/20" />
<span className="text-[10px] text-amber-400 shrink-0">лист ожидания</span>
<div className="flex-1 h-px bg-amber-500/20" />
</div>
{waiting.map((item) => renderItem(item, group.isArchived))}
</>
)}
</div>
);
})()}
</div>
);
}
+2 -2
View File
@@ -43,10 +43,10 @@ export function SearchBar({
value={query}
onChange={(e) => handleChange(e.target.value)}
placeholder="Поиск по имени или телефону..."
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] py-2 pl-9 pr-8 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40"
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 py-2 pl-9 pr-8 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold/40 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500"
/>
{query && (
<button onClick={clear} className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white">
<button onClick={clear} className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-900 dark:hover:text-white">
<X size={14} />
</button>
)}
+92 -68
View File
@@ -28,6 +28,7 @@ interface GroupBooking {
status: BookingStatus;
confirmedDate?: string;
confirmedGroup?: string;
confirmedHall?: string;
confirmedComment?: string;
notes?: string;
createdAt: string;
@@ -160,43 +161,43 @@ function ConfirmModal({
if (!open) return null;
const selectClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 [color-scheme:dark] disabled:opacity-30 disabled:cursor-not-allowed";
const selectClass = "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 [color-scheme:light] disabled:opacity-30 disabled:cursor-not-allowed dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:[color-scheme:dark]";
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">
<div className="relative w-full max-w-sm rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]" 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-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark: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>
<h3 className="text-base font-bold text-neutral-900 dark:text-white">Подтвердить запись</h3>
<p className="mt-1 text-xs text-neutral-500 dark: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>
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Зал</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>)}
<option value="" className="bg-white dark:bg-neutral-900">Выберите зал</option>
{halls.map((h) => <option key={h} value={h} className="bg-white dark:bg-neutral-900">{h}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Тренер</label>
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Тренер</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>)}
<option value="" className="bg-white dark:bg-neutral-900">Выберите тренера</option>
{trainers.map((t) => <option key={t} value={t} className="bg-white dark:bg-neutral-900">{t}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Группа</label>
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Группа</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>)}
<option value="" className="bg-white dark:bg-neutral-900">Выберите группу</option>
{groups.map((g) => <option key={g.value} value={g.value} className="bg-white dark:bg-neutral-900">{g.label}</option>)}
</select>
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Дата занятия</label>
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Дата занятия</label>
<input
type="date"
value={date}
@@ -211,14 +212,14 @@ function ConfirmModal({
)}
</div>
<div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Комментарий <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"
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold/40 disabled:opacity-30 disabled:cursor-not-allowed dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500"
/>
</div>
</div>
@@ -282,18 +283,23 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
...b, status: "confirmed" as BookingStatus,
confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes,
} : b));
await Promise.all([
adminFetch("/api/admin/group-bookings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }),
}),
data.comment ? adminFetch("/api/admin/group-bookings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
}) : Promise.resolve(),
]);
try {
await Promise.all([
adminFetch("/api/admin/group-bookings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }),
}),
data.comment ? adminFetch("/api/admin/group-bookings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
}) : Promise.resolve(),
]);
} catch {
// Revert optimistic update on failure
setBookings((prev) => prev.map((b) => b.id === confirmingId ? { ...b, ...existing } : b));
}
setConfirmingId(null);
onDataChange?.();
}
@@ -312,8 +318,8 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
onConfirm={(id) => setConfirmingId(id)}
renderExtra={(b) => (
<>
{b.groupInfo && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>}
{b.confirmedHall && <span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{b.confirmedHall}</span>}
{b.groupInfo && <span className="text-xs text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:text-neutral-400 dark:bg-neutral-800">{b.groupInfo}</span>}
{b.confirmedHall && <span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:bg-neutral-800">{b.confirmedHall}</span>}
{(b.confirmedGroup || b.confirmedDate) && (
<button
onClick={(e) => { e.stopPropagation(); setConfirmingId(b.id); }}
@@ -465,11 +471,11 @@ function RemindersTab() {
: 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"
: "border-neutral-200 bg-neutral-100/30 dark:border-white/5 dark:bg-neutral-800/30"
}`}
>
<div className="flex items-center gap-2 flex-wrap text-sm">
<span className="font-medium text-white">{item.name}</span>
<span className="font-medium text-neutral-900 dark: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}
@@ -536,11 +542,11 @@ function RemindersTab() {
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">
<div key={eg.label} className="rounded-xl border border-neutral-200 overflow-hidden dark:border-white/10">
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-50 dark:bg-neutral-900">
<TypeIcon size={13} className={typeConf.color} />
<span className="text-sm font-medium text-white">{eg.label}{eg.items[0]?.eventHall ? ` · ${eg.items[0].eventHall}` : ""}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{eg.items.length} чел.</span>
<span className="text-sm font-medium text-neutral-900 dark:text-white">{eg.label}{eg.items[0]?.eventHall ? ` · ${eg.items[0].eventHall}` : ""}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:bg-neutral-800">{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>}
@@ -596,10 +602,12 @@ function countByStatus(items: { status: string }[]): TabCounts {
}
function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
function DashboardSummary({ refreshTrigger, onNavigate, onFilter, activeTab, activeFilter }: {
refreshTrigger: number;
onNavigate: (tab: Tab) => void;
onFilter: (f: BookingFilter) => void;
activeTab: Tab;
activeFilter: BookingFilter;
}) {
const [counts, setCounts] = useState<DashboardCounts | null>(null);
@@ -664,15 +672,15 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
if (c.tab === "reminders") {
const total = counts.remindersToday + counts.remindersTomorrow;
if (total === 0) return (
<div key={c.tab} className="rounded-xl border border-white/5 bg-neutral-900/50 p-3 opacity-40">
<div key={c.tab} className="rounded-xl border border-neutral-100 bg-neutral-50 p-3 opacity-40 dark:border-white/5 dark:bg-neutral-900/50">
<p className="text-xs text-neutral-500">{c.label}</p>
<p className="text-lg font-bold text-neutral-600 mt-1"></p>
<p className="text-lg font-bold text-neutral-400 mt-1 dark:text-neutral-600"></p>
</div>
);
return (
<button key={c.tab} onClick={() => onNavigate(c.tab)}
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}>
<p className="text-xs text-neutral-400">{c.label}</p>
className={`rounded-xl border ${c.color} bg-neutral-50 p-3 text-left transition-all hover:bg-neutral-100 hover:scale-[1.02] dark:bg-neutral-900 dark:hover:bg-neutral-800/80`}>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{c.label}</p>
<div className="flex items-baseline gap-2 mt-1 flex-wrap">
{counts.remindersNotAsked > 0 && (
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
@@ -710,20 +718,25 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
const tc = c.counts!;
const total = tc.new + tc.contacted + tc.confirmed + tc.declined;
if (total === 0) return (
<div key={c.tab} className="rounded-xl border border-white/5 bg-neutral-900/50 p-3 opacity-40">
<div key={c.tab} className="rounded-xl border border-neutral-100 bg-neutral-50 p-3 opacity-40 dark:border-white/5 dark:bg-neutral-900/50">
<p className="text-xs text-neutral-500">{c.label}</p>
<p className="text-lg font-bold text-neutral-600 mt-1"></p>
<p className="text-lg font-bold text-neutral-400 mt-1 dark:text-neutral-600"></p>
</div>
);
const isActiveCard = activeTab === c.tab;
const hl = (status: BookingFilter) =>
isActiveCard && activeFilter === status
? "rounded-md bg-neutral-200 px-1.5 -mx-1.5 py-0.5 -my-0.5 ring-1 ring-neutral-300 dark:bg-white/10 dark:ring-white/20"
: "";
return (
<button key={c.tab} onClick={() => { onNavigate(c.tab); onFilter("all"); }}
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}>
<p className="text-xs text-neutral-400">{c.label}</p>
className={`rounded-xl border ${c.color} bg-neutral-50 p-3 text-left transition-all hover:bg-neutral-100 hover:scale-[1.02] dark:bg-neutral-900 dark:hover:bg-neutral-800/80`}>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{c.label}</p>
<div className="flex items-baseline gap-2 mt-1 flex-wrap">
{tc.new > 0 && (
<>
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("new"); }}>
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("new")}`}
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "new" && isActiveCard ? "all" : "new"); }}>
<span className="text-lg font-bold text-gold">{tc.new}</span>
<span className="text-[10px] text-neutral-500">новых</span>
</span>
@@ -732,8 +745,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
{tc.contacted > 0 && (
<>
{tc.new > 0 && <span className="text-neutral-700">·</span>}
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("contacted"); }}>
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("contacted")}`}
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "contacted" && isActiveCard ? "all" : "contacted"); }}>
<span className="text-sm font-medium text-blue-400">{tc.contacted}</span>
<span className="text-[10px] text-neutral-500">в работе</span>
</span>
@@ -742,8 +755,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
{tc.confirmed > 0 && (
<>
{(tc.new > 0 || tc.contacted > 0) && <span className="text-neutral-700">·</span>}
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("confirmed"); }}>
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("confirmed")}`}
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "confirmed" && isActiveCard ? "all" : "confirmed"); }}>
<span className="text-sm font-medium text-emerald-400">{tc.confirmed}</span>
<span className="text-[10px] text-neutral-500">подтв.</span>
</span>
@@ -752,8 +765,8 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter }: {
{tc.declined > 0 && (
<>
{(tc.new > 0 || tc.contacted > 0 || tc.confirmed > 0) && <span className="text-neutral-700">·</span>}
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter("declined"); }}>
<span className={`inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all ${hl("declined")}`}
onClick={(e) => { e.stopPropagation(); onNavigate(c.tab); onFilter(activeFilter === "declined" && isActiveCard ? "all" : "declined"); }}>
<span className="text-sm font-medium text-red-400">{tc.declined}</span>
<span className="text-[10px] text-neutral-500">отказ</span>
</span>
@@ -884,7 +897,7 @@ function BookingsPageInner() {
<button
onClick={() => setHallFilter("all")}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
hallFilter === "all" ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-white border border-transparent"
hallFilter === "all" ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-neutral-900 border border-transparent dark:hover:text-white"
}`}
>
Все залы
@@ -894,7 +907,7 @@ function BookingsPageInner() {
key={hall}
onClick={() => setHallFilter(hallFilter === hall ? "all" : hall)}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
hallFilter === hall ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-white border border-transparent"
hallFilter === hall ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-neutral-900 border border-transparent dark:hover:text-white"
}`}
>
{hall}
@@ -916,10 +929,10 @@ function BookingsPageInner() {
<BookingCard key={`${r.type}-${r.id}`} status={r.status as BookingStatus}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{TYPE_LABELS[r.type] || r.type}</span>
<span className="font-medium text-white">{r.name}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:bg-neutral-800">{TYPE_LABELS[r.type] || r.type}</span>
<span className="font-medium text-neutral-900 dark:text-white">{r.name}</span>
<ContactLinks phone={r.phone} instagram={r.instagram} telegram={r.telegram} />
{r.groupLabel && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{r.groupLabel}</span>}
{r.groupLabel && <span className="text-xs text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:text-neutral-400 dark:bg-neutral-800">{r.groupLabel}</span>}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-neutral-600 text-xs">{fmtDate(r.createdAt)}</span>
@@ -940,16 +953,27 @@ function BookingsPageInner() {
) : (
<>
{/* Dashboard — what needs attention */}
<DashboardSummary refreshTrigger={dashboardKey + refreshKey} onNavigate={setTab} onFilter={setStatusFilter} />
<DashboardSummary refreshTrigger={dashboardKey + refreshKey} onNavigate={setTab} onFilter={setStatusFilter} activeTab={tab} activeFilter={statusFilter} />
{/* Tabs */}
<div className="mt-5 flex border-b border-white/10">
{/* Tabs — select on mobile, tabs on desktop */}
<div className="mt-5 sm:hidden">
<select
value={tab}
onChange={(e) => setTab(e.target.value as Tab)}
className="w-full rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-900 outline-none focus:border-gold/40 transition-colors dark:border-white/10 dark:bg-neutral-900 dark:text-white dark:[color-scheme:dark]"
>
{TABS.map((t) => (
<option key={t.key} value={t.key}>{t.label}</option>
))}
</select>
</div>
<div className="mt-5 hidden sm:flex border-b border-neutral-200 dark: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"
className={`shrink-0 px-4 py-2.5 text-sm font-medium transition-colors relative whitespace-nowrap ${
tab === t.key ? "text-gold" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
}`}
>
{t.label}
@@ -963,9 +987,9 @@ function BookingsPageInner() {
{/* Tab content */}
<div className="mt-4">
{tab === "reminders" && <RemindersTab key={refreshKey} />}
{tab === "classes" && <GroupBookingsTab filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "master-classes" && <McRegistrationsTab filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "open-day" && <OpenDayBookingsTab filter={statusFilter} hallFilter={hallFilter} onDataChange={refreshDashboard} />}
{tab === "classes" && <GroupBookingsTab key={refreshKey} filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "master-classes" && <McRegistrationsTab key={refreshKey} filter={statusFilter} onDataChange={refreshDashboard} />}
{tab === "open-day" && <OpenDayBookingsTab key={refreshKey} filter={statusFilter} hallFilter={hallFilter} onDataChange={refreshDashboard} />}
</div>
</>
)}
@@ -973,7 +997,7 @@ function BookingsPageInner() {
<AddBookingModal
open={addOpen}
onClose={() => setAddOpen(false)}
onAdded={() => { setRefreshKey((k) => k + 1); refreshDashboard(); }}
onAdded={() => { setStatusFilter("all"); setRefreshKey((k) => k + 1); refreshDashboard(); }}
/>
</div>
);
+17 -9
View File
@@ -1,3 +1,5 @@
import { SHORT_DAYS } from "@/lib/formatting";
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
export type BookingFilter = "all" | BookingStatus;
@@ -12,20 +14,26 @@ export interface BaseBooking {
createdAt: string;
}
export const SHORT_DAYS: Record<string, string> = {
"Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ",
"Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС",
};
export { SHORT_DAYS };
export const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [
{ key: "new", label: "Новая", color: "text-gold", bg: "bg-gold/10", border: "border-gold/30" },
{ key: "contacted", label: "Связались", color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" },
{ key: "confirmed", label: "Подтверждено", color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/30" },
{ key: "declined", label: "Отказ", color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
{ key: "new", label: "Новая", color: "text-amber-700 dark:text-gold", bg: "bg-gold/10", border: "border-gold/30" },
{ key: "contacted", label: "Связались", color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" },
{ key: "confirmed", label: "Подтверждено", color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/30" },
{ key: "declined", label: "Отказ", color: "text-red-600 dark:text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
];
export function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString("ru-RU");
const d = new Date(iso);
const now = new Date();
const sameYear = d.getFullYear() === now.getFullYear();
const date = d.toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
...(sameYear ? {} : { year: "numeric" }),
});
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
return `${date}, ${time}`;
}
export function countStatuses(items: { status: string }[]): Record<string, number> {
+80 -18
View File
@@ -2,23 +2,66 @@
import { useState, useRef, useEffect, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { InputField, TextareaField, RichTextarea } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { icons, type LucideIcon } from "lucide-react";
import { ImageCropField } from "../_components/ImageCropField";
import {
icons, type LucideIcon,
Flame, Heart, HeartPulse, Star, Sparkles, Music, Zap, Crown,
Dumbbell, Wind, Moon, Sun, Ribbon, Gem, Feather, CircleDot,
Activity, Drama, PersonStanding, Footprints, PartyPopper, Flower2,
Waves, Eye, Orbit, Brush, Palette, HandMetal, Theater,
} from "lucide-react";
// Curated icons for dance school
const CURATED_ICONS: { key: string; Icon: LucideIcon; label: string }[] = [
{ key: "flame", Icon: Flame, label: "Flame" },
{ key: "heart", Icon: Heart, label: "Heart" },
{ key: "heart-pulse", Icon: HeartPulse, label: "HeartPulse" },
{ key: "star", Icon: Star, label: "Star" },
{ key: "sparkles", Icon: Sparkles, label: "Sparkles" },
{ key: "music", Icon: Music, label: "Music" },
{ key: "zap", Icon: Zap, label: "Zap" },
{ key: "crown", Icon: Crown, label: "Crown" },
{ key: "dumbbell", Icon: Dumbbell, label: "Dumbbell" },
{ key: "wind", Icon: Wind, label: "Wind" },
{ key: "moon", Icon: Moon, label: "Moon" },
{ key: "sun", Icon: Sun, label: "Sun" },
{ key: "ribbon", Icon: Ribbon, label: "Ribbon" },
{ key: "gem", Icon: Gem, label: "Gem" },
{ key: "feather", Icon: Feather, label: "Feather" },
{ key: "circle-dot", Icon: CircleDot, label: "CircleDot" },
{ key: "activity", Icon: Activity, label: "Activity" },
{ key: "drama", Icon: Drama, label: "Drama" },
{ key: "person-standing", Icon: PersonStanding, label: "PersonStanding" },
{ key: "footprints", Icon: Footprints, label: "Footprints" },
{ key: "party-popper", Icon: PartyPopper, label: "PartyPopper" },
{ key: "flower-2", Icon: Flower2, label: "Flower" },
{ key: "waves", Icon: Waves, label: "Waves" },
{ key: "eye", Icon: Eye, label: "Eye" },
{ key: "orbit", Icon: Orbit, label: "Orbit" },
{ key: "brush", Icon: Brush, label: "Brush" },
{ key: "palette", Icon: Palette, label: "Palette" },
{ key: "hand-metal", Icon: HandMetal, label: "HandMetal" },
{ key: "theater", Icon: Theater, label: "Theater" },
];
// 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 }
// Full icon list for search fallback
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]));
const ICON_BY_KEY = Object.fromEntries([
...CURATED_ICONS.map((i) => [i.key, i]),
...ALL_ICONS.map((i) => [i.key, i]),
]);
function IconPicker({
value,
@@ -46,9 +89,12 @@ function IconPicker({
}, [open]);
const filtered = useMemo(() => {
if (!search) return ALL_ICONS.slice(0, 60);
if (!search) return CURATED_ICONS;
const q = search.toLowerCase();
return ALL_ICONS.filter((i) => i.label.toLowerCase().includes(q)).slice(0, 60);
// Search curated first, then all icons
const curated = CURATED_ICONS.filter((i) => i.label.toLowerCase().includes(q));
const rest = ALL_ICONS.filter((i) => i.label.toLowerCase().includes(q) && !curated.some((c) => c.key === i.key));
return [...curated, ...rest].slice(0, 40);
}, [search]);
const SelectedIcon = selected?.Icon;
@@ -63,8 +109,8 @@ function IconPicker({
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"
className={`w-full flex items-center gap-2.5 rounded-lg border bg-neutral-100 px-4 py-2.5 text-left text-neutral-900 outline-none transition-colors dark:bg-neutral-800 dark:text-white ${
open ? "border-gold" : "border-neutral-200 dark:border-white/10"
}`}
>
{SelectedIcon ? (
@@ -72,21 +118,21 @@ function IconPicker({
<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="flex h-6 w-6 items-center justify-center rounded-md bg-neutral-200 text-neutral-500 dark:bg-white/10">?</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="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/10 dark:bg-neutral-800">
<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"
placeholder="Поиск..."
className="w-full rounded-md border border-neutral-200 bg-neutral-100 px-3 py-1.5 text-sm text-neutral-900 outline-none focus:border-gold/50 placeholder:text-neutral-400 dark:border-white/10 dark:bg-neutral-900 dark:text-white dark:placeholder:text-neutral-600"
/>
</div>
<div className="p-2 max-h-56 overflow-y-auto">
@@ -107,7 +153,7 @@ function IconPicker({
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"
: "text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white"
}`}
>
<Icon size={20} />
@@ -150,13 +196,16 @@ interface ClassesData {
icon: string;
detailedDescription?: string;
images?: string[];
imageFocalX?: number;
imageFocalY?: number;
imageZoom?: number;
color?: string;
}[];
}
export default function ClassesEditorPage() {
return (
<SectionEditor<ClassesData> sectionKey="classes" title="Направления">
<SectionEditor<ClassesData> sectionKey="classes" title="Направления" defaultData={{ items: [] }}>
{(data, update) => (
<>
<InputField
@@ -188,18 +237,21 @@ export default function ClassesEditorPage() {
</label>
<div className="flex flex-wrap gap-1.5">
{COLOR_SWATCHES.map((c) => {
const isUsed = data.items.some(
const isSelected = item.color === c.value;
const isUsed = !isSelected && data.items.some(
(other) => other !== item && other.color === c.value
);
if (isUsed) return null;
return (
<button
key={c.value}
type="button"
disabled={isUsed}
onClick={() => updateItem({ ...item, color: c.value })}
className={`h-6 w-6 rounded-full ${c.bg} transition-all ${
item.color === c.value
isSelected
? "ring-2 ring-white ring-offset-1 ring-offset-neutral-900 scale-110"
: isUsed
? "opacity-15 cursor-not-allowed"
: "opacity-50 hover:opacity-100"
}`}
/>
@@ -207,13 +259,21 @@ export default function ClassesEditorPage() {
})}
</div>
</div>
<ImageCropField
image={item.images?.[0] || ""}
focalX={item.imageFocalX ?? 50}
focalY={item.imageFocalY ?? 50}
zoom={item.imageZoom ?? 1}
folder="classes"
onChange={(d) => updateItem({ ...item, images: d.image ? [d.image] : [], imageFocalX: d.focalX, imageFocalY: d.focalY, imageZoom: d.zoom })}
/>
<TextareaField
label="Краткое описание"
value={item.description}
onChange={(v) => updateItem({ ...item, description: v })}
rows={2}
/>
<TextareaField
<RichTextarea
label="Подробное описание"
value={item.detailedDescription || ""}
onChange={(v) =>
@@ -231,6 +291,8 @@ export default function ClassesEditorPage() {
images: [],
})}
addLabel="Добавить направление"
collapsible
getItemTitle={(item) => item.name || "Без названия"}
/>
</>
)}
+226 -38
View File
@@ -1,54 +1,242 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { Plus, X, AlertCircle, Check, Loader2 } from "lucide-react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { InputField } from "../_components/FormField";
import { CollapsibleSection } from "../_components/CollapsibleSection";
import { adminFetch } from "@/lib/csrf";
import type { ContactInfo } from "@/types/content";
// --- Phone input with mask ---
function PhoneField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
function formatPhone(raw: string): string {
const digits = raw.replace(/\D/g, "").slice(0, 12);
if (digits.length === 0) return "+375 ";
let result = "+";
for (let i = 0; i < digits.length; i++) {
if (i === 3) result += " (";
if (i === 5) result += ") ";
if (i === 8) result += "-";
if (i === 10) result += "-";
result += digits[i];
}
return result;
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const formatted = formatPhone(e.target.value);
onChange(formatted);
}
const digits = (value ?? "").replace(/\D/g, "");
const isComplete = digits.length === 12;
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Телефон</label>
<div className="relative">
<input
type="tel"
value={value ?? ""}
onChange={handleChange}
placeholder="+375 (XX) XXX-XX-XX"
className={`w-full rounded-lg border bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 ${
value && !isComplete ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
}`}
/>
{isComplete && (
<Check size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400" />
)}
</div>
{value && !isComplete && (
<p className="mt-1 text-xs text-red-400">Формат: +375 (XX) XXX-XX-XX данные не сохранятся</p>
)}
</div>
);
}
// --- Instagram field like team page (username with @ prefix + validation) ---
function InstagramField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [status, setStatus] = useState<"idle" | "checking" | "valid" | "invalid">("idle");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Extract username from URL or @-prefixed input
function extractUsername(raw: string): string {
if (!raw) return "";
return raw
.replace(/^https?:\/\/(www\.)?instagram\.com\//, "")
.replace(/\/$/, "")
.replace(/^@/, "");
}
const validateUsername = useCallback((username: string) => {
if (timerRef.current) clearTimeout(timerRef.current);
if (!username) { setStatus("idle"); return; }
setStatus("checking");
timerRef.current = setTimeout(async () => {
try {
const res = await adminFetch(`/api/admin/validate-instagram?username=${encodeURIComponent(username)}`);
const result = await res.json();
setStatus(result.valid ? "valid" : "invalid");
} catch {
setStatus("idle");
}
}, 800);
}, []);
// On mount, if value exists, mark as valid (trusted existing data)
const initializedRef = useRef(false);
if (value && !initializedRef.current) {
initializedRef.current = true;
if (status === "idle") setStatus("valid");
}
return (
<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={value ?? ""}
onChange={(e) => {
const username = extractUsername(e.target.value);
onChange(username);
validateUsername(username);
}}
placeholder="blackheartdancehouse"
className={`w-full rounded-lg border bg-neutral-100 pl-8 pr-10 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 ${
status === "invalid"
? "border-red-500 focus:border-red-500"
: status === "valid"
? "border-green-500/50 focus:border-green-500"
: "border-neutral-200 focus:border-gold dark:border-white/10"
}`}
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2">
{status === "checking" && <Loader2 size={14} className="animate-spin text-neutral-400" />}
{status === "valid" && <Check size={14} className="text-green-400" />}
{status === "invalid" && <AlertCircle size={14} className="text-red-400" />}
</span>
</div>
{status === "invalid" && (
<p className="mt-1 text-xs text-red-400">Аккаунт не найден</p>
)}
{value && status !== "invalid" && status !== "checking" && (
<a
href={`https://instagram.com/${value}`}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-block text-xs text-neutral-500 hover:text-gold transition-colors"
>
instagram.com/{value}
</a>
)}
</div>
);
}
// --- Compact address list ---
function AddressList({ items, onChange }: { items: string[]; onChange: (items: string[]) => void }) {
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 className="space-y-2">
{items.map((addr, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
value={addr}
onChange={(e) => update(i, e.target.value)}
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-sm text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
/>
<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(); } }}
onBlur={add}
placeholder="Добавить адрес..."
className="flex-1 rounded-lg border border-dashed border-neutral-300 bg-neutral-100/50 px-4 py-2.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold/50 transition-colors dark:border-white/15 dark:bg-neutral-800/50 dark:text-white dark:placeholder-neutral-500"
/>
<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>
);
}
function isPhoneValid(phone: string | undefined): boolean {
if (!phone) return true; // empty is ok
return (phone.replace(/\D/g, "")).length === 12;
}
export default function ContactEditorPage() {
return (
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты">
<SectionEditor<ContactInfo>
sectionKey="contact"
title="Контакты"
defaultData={{ addresses: [], instagram: "" }}
validate={(data) => isPhoneValid(data.phone)}
>
{(data, update) => (
<>
<div className="space-y-4">
<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}
/>
</>
<div className="grid gap-4 sm:grid-cols-2">
<PhoneField
value={data.phone}
onChange={(v) => update({ ...data, phone: v })}
/>
<InstagramField
value={(data.instagram ?? "").replace(/^https?:\/\/(www\.)?instagram\.com\//, "").replace(/\/$/, "")}
onChange={(username) => update({ ...data, instagram: username ? `https://instagram.com/${username}` : "" })}
/>
</div>
<CollapsibleSection title="Адреса">
<AddressList
items={data.addresses ?? []}
onChange={(addresses) => update({ ...data, addresses })}
/>
</CollapsibleSection>
</div>
)}
</SectionEditor>
);
+3 -1
View File
@@ -11,7 +11,7 @@ interface FAQData {
export default function FAQEditorPage() {
return (
<SectionEditor<FAQData> sectionKey="faq" title="FAQ">
<SectionEditor<FAQData> sectionKey="faq" title="FAQ" defaultData={{ items: [] }}>
{(data, update) => (
<>
<InputField
@@ -23,6 +23,8 @@ export default function FAQEditorPage() {
label="Вопросы и ответы"
items={data.items}
onChange={(items) => update({ ...data, items })}
collapsible
getItemTitle={(item) => item.question || "Без вопроса"}
renderItem={(item, _i, updateItem) => (
<div className="space-y-3">
<InputField
+27 -22
View File
@@ -6,7 +6,7 @@ import { InputField } from "../_components/FormField";
import { adminFetch } from "@/lib/csrf";
import { Upload, X, Loader2, Smartphone, Monitor, Star } from "lucide-react";
const MAX_VIDEO_SIZE_MB = 8;
const MAX_VIDEO_SIZE_MB = 10;
const MAX_VIDEO_SIZE_BYTES = MAX_VIDEO_SIZE_MB * 1024 * 1024;
function formatFileSize(bytes: number): string {
@@ -67,7 +67,7 @@ function VideoSlot({
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-300">{label}</span>
{isCenter && (
<span className="inline-flex items-center gap-1 rounded-full bg-[#c9a96e]/15 px-2 py-0.5 text-[10px] font-medium text-[#c9a96e]">
<span className="inline-flex items-center gap-1 rounded-full bg-gold/15 px-2 py-0.5 text-[10px] font-medium text-gold">
<Smartphone size={10} />
мобильная версия
</span>
@@ -79,7 +79,7 @@ function VideoSlot({
{src ? (
<div
className={`group relative overflow-hidden rounded-lg border ${
isCenter ? "border-[#c9a96e]/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700"
isCenter ? "border-gold/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700"
}`}
onMouseEnter={() => videoRef.current?.play()}
onMouseLeave={() => { videoRef.current?.pause(); }}
@@ -104,7 +104,7 @@ function VideoSlot({
)}
</div>
{isCenter && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-[#c9a96e]/90 px-2 py-0.5 text-[10px] font-bold text-black">
<div className="absolute top-2 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-gold/90 px-2 py-0.5 text-[10px] font-bold text-black">
<Star size={10} fill="currentColor" />
MAIN
</div>
@@ -115,6 +115,7 @@ function VideoSlot({
</div>
<button
onClick={onRemove}
aria-label={`Удалить видео: ${label}`}
className="absolute top-2 right-2 rounded-full bg-black/70 p-1.5 text-neutral-400 opacity-0 transition-opacity hover:text-red-400 group-hover:opacity-100"
title="Удалить"
>
@@ -127,7 +128,7 @@ function VideoSlot({
disabled={uploading}
className={`flex aspect-[9/16] w-full items-center justify-center rounded-lg border-2 border-dashed transition-colors disabled:opacity-50 ${
isCenter
? "border-[#c9a96e]/30 text-[#c9a96e]/50 hover:border-[#c9a96e]/60 hover:text-[#c9a96e]"
? "border-gold/30 text-gold/50 hover:border-gold/60 hover:text-gold"
: "border-neutral-700 text-neutral-500 hover:border-neutral-500 hover:text-neutral-300"
}`}
>
@@ -163,7 +164,7 @@ function VideoSizeInfo({ totalSize, totalMb, rating }: { totalSize: number; tota
return (
<button
onClick={() => setOpen((v) => !v)}
className="w-full text-left rounded-lg bg-neutral-800/50 px-3 py-2 transition-colors hover:bg-neutral-800/80"
className="w-full text-left rounded-lg bg-neutral-100/80 px-3 py-2 transition-colors hover:bg-neutral-200/80 dark:bg-neutral-800/50 dark:hover:bg-neutral-800/80"
>
<div className="flex items-center justify-between">
<span className="text-xs text-neutral-400">Общий вес: <span className={`font-medium ${rating.color}`}>{formatFileSize(totalSize)}</span></span>
@@ -208,10 +209,8 @@ function VideoManager({
const syncToParent = useCallback(
(updated: (string | null)[]) => {
setSlots(updated);
// Only propagate when all 3 are filled
if (updated.every((s) => s !== null)) {
onChange(updated as string[]);
}
// Save all 3 slots (empty string for unfilled) to preserve positions
onChange(updated.map((s) => s || ""));
},
[onChange]
);
@@ -232,7 +231,7 @@ function VideoManager({
});
}, [slots]);
const totalSize = fileSizes.reduce((sum, s) => sum + (s || 0), 0);
const totalSize = fileSizes.reduce((sum: number, s) => sum + (s || 0), 0);
const totalMb = totalSize / (1024 * 1024);
function getLoadRating(mb: number): { label: string; color: string } {
@@ -245,10 +244,10 @@ function VideoManager({
async function handleUpload(idx: number, file: File) {
if (file.size > MAX_VIDEO_SIZE_BYTES) {
const sizeMb = (file.size / (1024 * 1024)).toFixed(1);
setSizeWarning(`Видео ${sizeMb} МБ — рекомендуем до ${MAX_VIDEO_SIZE_MB} МБ для быстрой загрузки`);
} else {
setSizeWarning(null);
alert(`Видео ${sizeMb} МБ — максимум ${MAX_VIDEO_SIZE_MB} МБ. Сожмите видео и попробуйте снова.`);
return;
}
setSizeWarning(null);
setUploadingIdx(idx);
try {
const form = new FormData();
@@ -259,14 +258,21 @@ function VideoManager({
body: form,
});
if (!res.ok) {
const err = await res.json();
alert(err.error || "Ошибка загрузки");
const text = await res.text();
let msg = "Ошибка загрузки";
try {
const err = JSON.parse(text);
msg = err.error || msg;
} catch { /* empty response */ }
alert(`${msg} (${res.status})`);
return;
}
const { path } = await res.json();
const updated = [...slots];
updated[idx] = path;
syncToParent(updated);
} catch (e) {
alert(`Ошибка сети: ${e instanceof Error ? e.message : "попробуйте снова"}`);
} finally {
setUploadingIdx(null);
}
@@ -275,8 +281,7 @@ function VideoManager({
function handleRemove(idx: number) {
const updated = [...slots];
updated[idx] = null;
setSlots(updated);
// Don't propagate incomplete state — keep old saved videos in DB
syncToParent(updated);
}
const allFilled = slots.every((s) => s !== null);
@@ -300,7 +305,7 @@ function VideoManager({
)}
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-3 max-w-3xl">
{SLOTS.map((slot, i) => (
<VideoSlot
key={slot.key}
@@ -350,17 +355,17 @@ export default function HeroEditorPage() {
<InputField
label="Заголовок"
value={data.headline}
value={data.headline || ""}
onChange={(v) => update({ ...data, headline: v })}
/>
<InputField
label="Подзаголовок"
value={data.subheadline}
value={data.subheadline || ""}
onChange={(v) => update({ ...data, subheadline: v })}
/>
<InputField
label="Текст кнопки"
value={data.ctaText}
value={data.ctaText || ""}
onChange={(v) => update({ ...data, ctaText: v })}
/>
</>
+36 -22
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { adminFetch } from "@/lib/csrf";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import {
LayoutDashboard,
Sparkles,
@@ -23,22 +24,25 @@ import {
ChevronLeft,
ClipboardList,
DoorOpen,
MessageSquare,
} from "lucide-react";
const NAV_ITEMS = [
{ href: "/admin", label: "Дашборд", icon: LayoutDashboard },
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe },
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
{ href: "/admin/popups", label: "Формы записи", icon: MessageSquare },
// Sections follow user-side order: Hero → About → Classes → Team → OpenDay → Schedule → Pricing → MC → News → FAQ → Contact
{ 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/team", label: "Команда", icon: Users },
{ 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/master-classes", label: "Мастер-классы", icon: Star },
{ href: "/admin/news", label: "Новости", icon: Newspaper },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
{ href: "/admin/contact", label: "Контакты", icon: Phone },
];
@@ -53,17 +57,19 @@ export default function AdminLayout({
const [unreadTotal, setUnreadTotal] = useState(0);
const isLoginPage = pathname === "/admin/login";
// Fetch unread counts — poll every 10s
// Fetch unread counts — poll every 10s, stop after 3 consecutive failures
useEffect(() => {
if (isLoginPage) return;
let failures = 0;
let interval: ReturnType<typeof setInterval>;
function fetchCounts() {
adminFetch("/api/admin/unread-counts")
.then((r) => r.json())
.then((data: { total: number }) => setUnreadTotal(data.total))
.catch(() => {});
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data: { total: number }) => { setUnreadTotal(data.total); failures = 0; })
.catch(() => { failures++; if (failures >= 3 && interval) clearInterval(interval); });
}
fetchCounts();
const interval = setInterval(fetchCounts, 10000);
interval = setInterval(fetchCounts, 10000);
return () => clearInterval(interval);
}, [isLoginPage]);
@@ -83,7 +89,7 @@ export default function AdminLayout({
}
return (
<div className="flex min-h-screen bg-neutral-950 text-white">
<div className="flex min-h-screen bg-neutral-50 text-neutral-900 dark:bg-neutral-950 dark:text-white">
{/* Mobile overlay */}
{sidebarOpen && (
<div
@@ -94,23 +100,24 @@ export default function AdminLayout({
{/* 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 ${
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-neutral-200 bg-white dark:border-white/10 dark: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">
<div className="flex items-center justify-between border-b border-neutral-200 dark: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"
aria-label="Закрыть меню"
className="lg:hidden text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
>
<X size={20} />
</button>
</div>
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
<nav aria-label="Навигация панели управления" className="flex-1 overflow-y-auto p-3 space-y-1">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
@@ -122,13 +129,13 @@ export default function AdminLayout({
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"
: "text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-white dark: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">
<span aria-label={`${unreadTotal} непрочитанных`} className="ml-auto rounded-full bg-red-500 text-white text-xs font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
{unreadTotal > 99 ? "99+" : unreadTotal}
</span>
)}
@@ -137,18 +144,22 @@ export default function AdminLayout({
})}
</nav>
<div className="border-t border-white/10 p-3 space-y-1">
<div className="border-t border-neutral-200 dark:border-white/10 p-3 space-y-1">
<div className="flex items-center justify-between px-3 py-1">
<span className="text-xs text-neutral-400 dark:text-neutral-500">Тема</span>
<ThemeToggle />
</div>
<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"
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-white dark: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"
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-500 hover:text-red-500 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-red-400 dark:hover:bg-white/5 transition-colors"
>
<LogOut size={18} />
Выйти
@@ -159,14 +170,17 @@ export default function AdminLayout({
{/* 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">
<header className="sticky top-0 z-30 flex items-center gap-3 border-b border-neutral-200 bg-white dark:border-white/10 dark:bg-neutral-950 px-4 py-3 lg:hidden">
<button
onClick={() => setSidebarOpen(true)}
className="text-neutral-400 hover:text-white"
aria-label="Открыть меню"
aria-expanded={sidebarOpen}
className="text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
>
<Menu size={24} />
</button>
<a href="/admin" className="font-bold hover:text-gold transition-colors">BLACK HEART</a>
<a href="/admin" className="font-bold hover:text-gold transition-colors flex-1">BLACK HEART</a>
<ThemeToggle />
</header>
<main className="flex-1 p-4 sm:p-6 lg:p-8">{children}</main>
+29 -15
View File
@@ -2,11 +2,13 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
export default function AdminLoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
@@ -34,33 +36,45 @@ export default function AdminLoginPage() {
}
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-950 px-4">
<div className="flex min-h-screen items-center justify-center bg-neutral-50 px-4 dark:bg-neutral-950">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-6 rounded-2xl border border-white/10 bg-neutral-900 p-8"
className="w-full max-w-sm space-y-6 rounded-2xl border border-neutral-200 bg-white p-8 dark:border-white/10 dark:bg-neutral-900"
>
<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>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">BLACK HEART</h1>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Панель управления</p>
</div>
<div>
<label htmlFor="password" className="block text-sm text-neutral-400 mb-2">
<label htmlFor="password" className="block text-sm text-neutral-500 mb-2 dark:text-neutral-400">
Пароль
</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 className="relative">
<input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-3 pr-11 text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
placeholder="Введите пароль"
autoFocus
aria-describedby={error ? "login-error" : undefined}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? "Скрыть пароль" : "Показать пароль"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
tabIndex={-1}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
{error && (
<p className="text-sm text-red-400 text-center">{error}</p>
<p id="login-error" role="alert" className="text-sm text-red-400 text-center">{error}</p>
)}
<button
+358 -268
View File
@@ -1,45 +1,49 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { useState, useEffect } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
import { InputField, TextareaField, RichTextarea, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
import { ImageCropField } from "../_components/ImageCropField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { Plus, X, Upload, Loader2, ImageIcon, AlertCircle, Check } from "lucide-react";
import { PriceField } from "../_components/PriceField";
import { Plus, X, Loader2, AlertCircle, Check, Search } 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();
// --- Helpers ---
function isItemArchived(item: MasterClassItem): boolean {
const slots = item.slots ?? [];
if (slots.length === 0) return false;
const today = new Date().toISOString().slice(0, 10);
return slots.every((s) => s.date && s.date < today);
}
function itemMatchesSearch(item: MasterClassItem, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
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>
(item.title || "").toLowerCase().includes(q) ||
(item.trainer || "").toLowerCase().includes(q)
);
}
function itemMatchesDateFilter(item: MasterClassItem, filter: "all" | "upcoming" | "past"): boolean {
if (filter === "all") return true;
const archived = isItemArchived(item);
return filter === "past" ? archived : !archived;
}
function itemMatchesLocation(item: MasterClassItem, locationFilter: string): boolean {
if (!locationFilter) return true;
return (item.location || "") === locationFilter;
}
interface MasterClassesData {
title: string;
successMessage?: string;
waitingListText?: string;
items: MasterClassItem[];
}
// --- Location Select ---
function LocationSelect({
value,
@@ -64,7 +68,7 @@ function LocationSelect({
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"
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:border-white/25 dark:hover:text-white"
}`}
>
{active && <Check size={10} className="inline mr-1" />}
@@ -94,6 +98,13 @@ function calcDurationText(startTime: string, endTime: string): string {
return `${m} мин`;
}
function hasTimeError(startTime: string, endTime: string): boolean {
if (!startTime || !endTime) return false;
const [sh, sm] = startTime.split(":").map(Number);
const [eh, em] = endTime.split(":").map(Number);
return (eh * 60 + em) <= (sh * 60 + sm);
}
function SlotsField({
slots,
onChange,
@@ -125,48 +136,61 @@ function SlotsField({
<div className="space-y-2">
{slots.map((slot, i) => {
const dur = calcDurationText(slot.startTime, slot.endTime);
const timeError = hasTimeError(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>
<div key={i}>
<div 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-100 px-3 py-2 text-sm text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
!slot.date ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
}`}
/>
<input
type="time"
value={slot.startTime}
onChange={(e) => updateSlot(i, { startTime: e.target.value })}
className={`w-[100px] rounded-lg border bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
timeError ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
}`}
/>
<span className="text-neutral-500 text-xs">&ndash;</span>
<input
type="time"
value={slot.endTime}
onChange={(e) => updateSlot(i, { endTime: e.target.value })}
className={`w-[100px] rounded-lg border bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
timeError ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
}`}
/>
{dur && (
<span className="text-[11px] text-neutral-500 bg-neutral-200/50 rounded-full px-2 py-0.5 dark:bg-neutral-800/50">
{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>
{!slot.date && (
<p className="mt-0.5 ml-1 text-[11px] text-red-400">Укажите дату</p>
)}
{timeError && (
<p className="mt-0.5 ml-1 text-[11px] text-red-400">Время окончания должно быть позже начала</p>
)}
<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"
className="flex items-center gap-2 rounded-lg border border-dashed border-neutral-200 bg-neutral-100/50 px-3 py-1.5 text-xs text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors dark:border-white/10 dark:bg-neutral-800/50"
>
<Plus size={12} />
Добавить дату
@@ -176,93 +200,7 @@ function SlotsField({
);
}
// --- 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>
);
}
// PhotoPreview replaced by shared ImageCropField
// --- Instagram Link Field ---
function InstagramLinkField({
@@ -285,8 +223,8 @@ function InstagramLinkField({
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"
className={`w-full rounded-lg border bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 ${
error ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
}`}
/>
{value && !error && (
@@ -341,11 +279,113 @@ function ValidationHint({ fields }: { fields: Record<string, string> }) {
);
}
// --- Filter bar ---
type DateFilter = "all" | "upcoming" | "past";
const DATE_FILTER_LABELS: Record<DateFilter, string> = {
all: "Все",
upcoming: "Предстоящие",
past: "Прошедшие",
};
function FilterBar({
search,
onSearchChange,
dateFilter,
onDateFilterChange,
locationFilter,
onLocationFilterChange,
locations,
totalCount,
visibleCount,
}: {
search: string;
onSearchChange: (v: string) => void;
dateFilter: DateFilter;
onDateFilterChange: (v: DateFilter) => void;
locationFilter: string;
onLocationFilterChange: (v: string) => void;
locations: { name: string; address: string }[];
totalCount: number;
visibleCount: number;
}) {
return (
<div className="space-y-3 mb-4">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Поиск по названию или тренеру..."
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 pl-10 pr-4 py-2.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
/>
{search && (
<button
type="button"
onClick={() => onSearchChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
>
<X size={14} />
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex gap-1">
{(Object.keys(DATE_FILTER_LABELS) as DateFilter[]).map((key) => (
<button
key={key}
type="button"
onClick={() => onDateFilterChange(key)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
dateFilter === key
? "bg-gold/20 text-gold border border-gold/40"
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:border-white/25 dark:hover:text-white"
}`}
>
{DATE_FILTER_LABELS[key]}
</button>
))}
</div>
{locations.length > 0 && (
<>
<span className="text-neutral-600 text-xs">|</span>
<div className="flex gap-1">
{locations.map((loc) => (
<button
key={loc.name}
type="button"
onClick={() => onLocationFilterChange(locationFilter === loc.name ? "" : loc.name)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
locationFilter === loc.name
? "bg-gold/20 text-gold border border-gold/40"
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:border-white/25 dark:hover:text-white"
}`}
>
{loc.name}
</button>
))}
</div>
</>
)}
{visibleCount < totalCount && (
<span className="text-xs text-neutral-500 ml-auto">
{visibleCount} из {totalCount}
</span>
)}
</div>
</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 }[]>([]);
const [search, setSearch] = useState("");
const [dateFilter, setDateFilter] = useState<DateFilter>("all");
const [locationFilter, setLocationFilter] = useState("");
useEffect(() => {
// Fetch trainers from team
@@ -377,135 +417,185 @@ export default function MasterClassesEditorPage() {
<SectionEditor<MasterClassesData>
sectionKey="masterClasses"
title="Мастер-классы"
defaultData={{ items: [] }}
>
{(data, update) => (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
{(data, update) => {
// Sort: active first, archived at bottom
const displayItems = [...data.items].sort((a, b) => {
const aArch = isItemArchived(a);
const bArch = isItemArchived(b);
if (aArch === bArch) return 0;
return aArch ? 1 : -1;
});
<InputField
label="Текст после записи (success popup)"
value={data.successMessage || ""}
onChange={(v) => update({ ...data, successMessage: v || undefined })}
placeholder="Вы записаны! Мы свяжемся с вами"
/>
const hiddenItems = new Set<number>();
displayItems.forEach((item, i) => {
if (
!itemMatchesSearch(item, search) ||
!itemMatchesDateFilter(item, dateFilter) ||
!itemMatchesLocation(item, locationFilter)
) {
hiddenItems.add(i);
}
});
<TextareaField
label="Текст для листа ожидания"
value={data.waitingListText || ""}
onChange={(v) => update({ ...data, waitingListText: v || undefined })}
placeholder="Все места заняты, но мы добавили вас в лист ожидания..."
rows={2}
/>
const visibleCount = data.items.length - hiddenItems.size;
<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" : "",
}}
/>
return (
<>
<InputField
label="Заголовок секции"
value={data.title}
onChange={(v) => update({ ...data, title: v })}
/>
<InputField
label="Название"
value={item.title}
onChange={(v) => updateItem({ ...item, title: v })}
placeholder="Мастер-класс от Анны Тарыбы"
/>
<FilterBar
search={search}
onSearchChange={setSearch}
dateFilter={dateFilter}
onDateFilterChange={setDateFilter}
locationFilter={locationFilter}
onLocationFilterChange={setLocationFilter}
locations={locations}
totalCount={data.items.length}
visibleCount={visibleCount}
/>
<ImageUploadField
value={item.image}
onChange={(v) => updateItem({ ...item, image: v })}
/>
<ArrayEditor
label="Мастер-классы"
items={displayItems}
onChange={(items) => update({ ...data, items })}
collapsible
hiddenItems={hiddenItems}
getItemTitle={(item) => {
const base = item.location
? `${item.title || "Без названия"} · ${item.location}`
: item.title || "Без названия";
return base;
}}
getItemBadge={(item) =>
isItemArchived(item) ? (
<span className="shrink-0 rounded-full bg-neutral-700/50 px-2 py-0.5 text-[10px] font-medium text-neutral-500">
Архив
</span>
) : null
}
renderItem={(item, _i, updateItem) => {
const archived = isItemArchived(item);
return (
<div className={`space-y-3 ${archived ? "opacity-50" : ""}`}>
<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>
<ValidationHint
fields={{
Название: item.title,
Тренер: item.trainer,
Стиль: item.style,
Стоимость: item.cost,
"Даты и время": (item.slots ?? []).length > 0 ? "ok" : "",
}}
/>
<PriceField
label="Стоимость"
value={item.cost}
onChange={(v) => updateItem({ ...item, cost: v })}
placeholder="40"
/>
<InputField
label="Название"
value={item.title}
onChange={(v) => updateItem({ ...item, title: v })}
placeholder="Мастер-класс от Анны Тарыбы"
/>
{locations.length > 0 && (
<LocationSelect
value={item.location || ""}
onChange={(v) =>
updateItem({ ...item, location: v || undefined })
}
locations={locations}
/>
)}
{/* Photo + key fields side by side */}
<div className="flex gap-5 items-center">
<div className="w-[220px] shrink-0">
<ImageCropField
image={item.image || ""}
focalX={item.imageFocalX ?? 50}
focalY={item.imageFocalY ?? 50}
zoom={item.imageZoom ?? 1}
folder="master-classes"
label="Фото"
aspect="aspect-[2/3]"
maxWidth="max-w-[220px]"
onChange={(d) => updateItem({ ...item, image: d.image, imageFocalX: d.focalX, imageFocalY: d.focalY, imageZoom: d.zoom })}
/>
</div>
<div className="flex-1 space-y-3 min-w-0">
<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>
<div className="grid gap-3 sm:grid-cols-2">
<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}
/>
)}
</div>
<InstagramLinkField
value={item.instagramUrl || ""}
onChange={(v) =>
updateItem({ ...item, instagramUrl: v || undefined })
}
/>
<ParticipantLimits
min={item.minParticipants ?? 0}
max={item.maxParticipants ?? 0}
onMinChange={(v) => updateItem({ ...item, minParticipants: v })}
onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })}
/>
</div>
</div>
<SlotsField
slots={item.slots ?? []}
onChange={(slots) => updateItem({ ...item, slots })}
/>
<SlotsField
slots={item.slots ?? []}
onChange={(slots) => updateItem({ ...item, slots })}
/>
<TextareaField
label="Описание"
value={item.description || ""}
onChange={(v) =>
updateItem({ ...item, description: v || undefined })
}
placeholder="Описание мастер-класса, трек, стиль..."
rows={3}
/>
<InstagramLinkField
value={item.instagramUrl || ""}
onChange={(v) =>
updateItem({ ...item, instagramUrl: v || undefined })
}
/>
<ParticipantLimits
min={item.minParticipants ?? 0}
max={item.maxParticipants ?? 0}
onMinChange={(v) => updateItem({ ...item, minParticipants: v })}
onMaxChange={(v) => updateItem({ ...item, maxParticipants: v })}
/>
</div>
)}
createItem={() => ({
title: "",
image: "",
slots: [],
trainer: "",
cost: "",
style: "",
})}
addLabel="Добавить мастер-класс"
/>
</>
)}
<RichTextarea
label="Описание"
value={item.description || ""}
onChange={(v) =>
updateItem({ ...item, description: v || undefined })
}
placeholder="Описание мастер-класса, трек, стиль..."
rows={3}
/>
</div>
);
}}
createItem={() => ({
title: "",
image: "",
slots: [],
trainer: "",
cost: "",
style: "",
})}
addLabel="Добавить мастер-класс"
/>
</>
);
}}
</SectionEditor>
);
}
+66 -121
View File
@@ -1,11 +1,10 @@
"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 { ImageCropField } from "../_components/ImageCropField";
import { AlertCircle } from "lucide-react";
import type { NewsItem } from "@/types/content";
interface NewsData {
@@ -13,96 +12,9 @@ interface NewsData {
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="Новости">
<SectionEditor<NewsData> sectionKey="news" title="Новости" defaultData={{ items: [] }}>
{(data, update) => (
<>
<InputField
@@ -115,49 +27,82 @@ export default function NewsEditorPage() {
label="Новости"
items={data.items}
onChange={(items) => update({ ...data, items })}
renderItem={(item, _i, updateItem) => (
collapsible
getItemTitle={(item) => {
const title = item.title || "Без заголовка";
if (item.date) {
try {
const d = new Date(item.date);
const date = d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
const time = item.date.includes("T") ? ` ${d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}` : "";
return `${title} · ${date}${time}`;
} catch { /* ignore */ }
}
return title;
}}
getItemBadge={(item) => {
const missing = [
!item.title.trim() && "заголовок",
!item.text.trim() && "текст",
!item.image && "фото",
].filter(Boolean);
if (missing.length === 0) return null;
return (
<span className="shrink-0 rounded-full bg-red-500/10 border border-red-500/20 px-2 py-0.5 text-[10px] font-medium text-red-400">
Черновик
</span>
);
}}
renderItem={(item, _i, updateItem) => {
const missing = [
!item.title.trim() && "Заголовок",
!item.text.trim() && "Текст",
!item.image && "Изображение",
].filter(Boolean);
return (
<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]"
/>
{missing.length > 0 && (
<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.join(", ")}</span>
</div>
</div>
)}
<InputField
label="Заголовок"
value={item.title}
onChange={(v) => updateItem({ ...item, title: v })}
/>
<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>
<ImageCropField
image={item.image || ""}
focalX={item.imageFocalX ?? 50}
focalY={item.imageFocalY ?? 50}
zoom={item.imageZoom ?? 1}
folder="news"
aspect="aspect-[21/9]"
label="Изображение"
onChange={(d) => updateItem({ ...item, image: d.image || undefined, imageFocalX: d.focalX, imageFocalY: d.focalY, imageZoom: d.zoom })}
/>
<InputField
label="Ссылка (необязательно)"
value={item.link || ""}
onChange={(v) => updateItem({ ...item, link: v || undefined })}
placeholder="https://instagram.com/p/..."
/>
</div>
)}
);
}}
createItem={(): NewsItem => ({
title: "",
text: "",
date: new Date().toISOString().slice(0, 10),
date: new Date().toISOString(),
})}
addLabel="Добавить новость"
addPosition="top"
/>
</>
)}
+225 -119
View File
@@ -5,7 +5,8 @@ import {
Plus, X, Loader2, Calendar, Trash2, Ban, CheckCircle2, RotateCcw, Sparkles,
} from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { ParticipantLimits, SelectField } from "../_components/FormField";
import { ParticipantLimits, SelectField, RichTextarea } from "../_components/FormField";
import { PriceField } from "../_components/PriceField";
// --- Types ---
@@ -19,8 +20,6 @@ interface OpenDayEvent {
discountThreshold: number;
minBookings: number;
maxParticipants: number;
successMessage?: string;
waitingListText?: string;
active: boolean;
}
@@ -64,7 +63,7 @@ function EventSettings({
onChange: (patch: Partial<OpenDayEvent>) => void;
}) {
return (
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-4">
<div className="rounded-xl border border-neutral-200 bg-white p-5 space-y-4 dark:border-white/10 dark:bg-neutral-900">
<h2 className="text-lg font-bold flex items-center gap-2">
<Calendar size={18} className="text-gold" />
Настройки мероприятия
@@ -72,66 +71,52 @@ function EventSettings({
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Название</label>
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">Название</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"
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Дата</label>
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">Дата</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]"
onChange={(e) => {
const newDate = e.target.value;
const isPast = newDate && newDate < new Date().toISOString().slice(0, 10);
onChange({ date: newDate, ...(isPast || !newDate ? { active: false } : {}) });
}}
className={`w-full rounded-lg border bg-neutral-100 px-4 py-2.5 text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
event.date && event.date < new Date().toISOString().slice(0, 10)
? "border-amber-500/50"
: "border-neutral-200 focus:border-gold dark:border-white/10"
}`}
/>
{!event.date && (
<p className="mt-1 text-[11px] text-amber-400">Укажите дату для публикации</p>
)}
{event.date && event.date < new Date().toISOString().slice(0, 10) && (
<p className="mt-1 text-[11px] text-amber-400">Дата в прошлом переведено в черновик</p>
)}
</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>
<RichTextarea
label="Описание"
value={event.description || ""}
onChange={(v) => onChange({ description: v || undefined })}
rows={3}
placeholder="Описание мероприятия..."
/>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Текст после записи</label>
<textarea
value={event.successMessage || ""}
onChange={(e) => onChange({ successMessage: e.target.value || undefined })}
rows={2}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-none"
placeholder="Вы записаны!"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Текст для листа ожидания</label>
<textarea
value={event.waitingListText || ""}
onChange={(e) => onChange({ waitingListText: e.target.value || undefined })}
rows={2}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-none"
placeholder="Все места заняты, но мы добавили вас в лист ожидания..."
/>
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label>
<input
type="number"
value={event.pricePerClass}
onChange={(e) => onChange({ pricePerClass: parseInt(e.target.value) || 0 })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors sm:max-w-xs"
<div className="sm:max-w-xs">
<PriceField
label="Цена за занятие"
value={event.pricePerClass ? `${event.pricePerClass} BYN` : ""}
onChange={(v) => onChange({ pricePerClass: parseInt(v) || 0 })}
/>
</div>
@@ -146,7 +131,7 @@ function EventSettings({
className={`flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${
event.discountPrice > 0
? "bg-gold/15 text-gold border border-gold/30"
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
}`}
>
<Sparkles size={14} />
@@ -155,21 +140,19 @@ function EventSettings({
{event.discountPrice > 0 && (
<div className="grid gap-4 sm:grid-cols-2 mt-3">
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Цена со скидкой (BYN)</label>
<input
type="number"
value={event.discountPrice}
onChange={(e) => onChange({ discountPrice: parseInt(e.target.value) || 0 })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
<PriceField
label="Цена со скидкой"
value={event.discountPrice ? `${event.discountPrice} BYN` : ""}
onChange={(v) => onChange({ discountPrice: parseInt(v) || 0 })}
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1.5">От N занятий</label>
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">От 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"
value={event.discountThreshold || ""}
onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 0 })}
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
/>
</div>
</div>
@@ -186,16 +169,28 @@ function EventSettings({
<div className="flex items-center gap-3 pt-1">
<button
type="button"
onClick={() => onChange({ active: !event.active })}
onClick={() => {
const isPast = !event.date || event.date < new Date().toISOString().slice(0, 10);
if (!event.active && isPast) return;
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"
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 cursor-pointer"
: !event.date || event.date < new Date().toISOString().slice(0, 10)
? "bg-neutral-100 text-neutral-400 border border-neutral-200 opacity-50 cursor-not-allowed dark:bg-neutral-800 dark:text-neutral-500 dark:border-white/5"
: "bg-neutral-100 text-neutral-500 border border-neutral-200 cursor-pointer hover:border-gold/40 hover:text-gold hover:bg-gold/5 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10"
}`}
>
{event.active ? <CheckCircle2 size={14} /> : <Ban size={14} />}
{event.active ? "Опубликовано" : "Черновик"}
{event.active ? (
<><CheckCircle2 size={14} /> Опубликовано</>
) : (
<><Ban size={14} /> Черновик</>
)}
</button>
{!event.active && (!event.date || event.date < new Date().toISOString().slice(0, 10)) && (
<span className="text-[11px] text-amber-400">Укажите будущую дату для публикации</span>
)}
<span className="text-xs text-neutral-500">
{event.pricePerClass} BYN / занятие{event.discountPrice > 0 && event.discountThreshold > 0 && `, от ${event.discountThreshold}${event.discountPrice} BYN`}
</span>
@@ -246,10 +241,10 @@ function NewClassForm({
<div ref={formRef} className="p-2 space-y-1.5 ring-1 ring-gold/30 rounded-lg">
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
<div className="flex gap-1 justify-end">
<button onClick={onCancel} className="text-[10px] text-neutral-500 hover:text-white px-1">Отмена</button>
<div className="flex gap-2 justify-end mt-2">
<button onClick={onCancel} className="rounded-md border border-neutral-200 px-3 py-1 text-xs text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25">Отмена</button>
<button onClick={() => canSave && onSave({ trainer, style, endTime })} disabled={!canSave}
className="text-[10px] text-gold hover:text-gold-light px-1 font-medium disabled:opacity-30 disabled:cursor-not-allowed">OK</button>
className="rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed">Сохранить</button>
</div>
</div>
);
@@ -262,6 +257,8 @@ function ClassCell({
minBookings,
trainers,
styles,
editing,
onEdit,
onUpdate,
onDelete,
onCancel,
@@ -270,11 +267,12 @@ function ClassCell({
minBookings: number;
trainers: string[];
styles: string[];
editing: boolean;
onEdit: (id: number | null) => void;
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);
@@ -283,7 +281,7 @@ function ClassCell({
function save() {
if (trainer.trim() && style.trim()) {
onUpdate(cls.id, { trainer: trainer.trim(), style: style.trim() });
setEditing(false);
onEdit(null);
}
}
@@ -292,12 +290,12 @@ function ClassCell({
<div className="p-2 space-y-1.5 rounded-lg">
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
<div className="flex gap-1 justify-end">
<button onClick={() => setEditing(false)} className="text-[10px] text-neutral-500 hover:text-white px-1">
<div className="flex gap-2 justify-end mt-2">
<button onClick={() => onEdit(null)} className="rounded-md border border-white/10 px-3 py-1 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
Отмена
</button>
<button onClick={save} className="text-[10px] text-gold hover:text-gold-light px-1 font-medium">
OK
<button onClick={save} className="rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors">
Сохранить
</button>
</div>
</div>
@@ -308,15 +306,15 @@ function ClassCell({
<div
className={`group relative p-2 rounded-lg cursor-pointer transition-all ${
cls.cancelled
? "bg-neutral-800/30 opacity-50"
? "bg-neutral-200/50 opacity-50 dark:bg-neutral-800/30"
: atRisk
? "bg-red-500/5 border border-red-500/20"
: "bg-gold/5 border border-gold/15 hover:border-gold/30"
: "bg-gold/10 border border-gold/25 dark:bg-gold/5 dark:border-gold/15 hover:border-gold/30"
}`}
onClick={() => setEditing(true)}
onClick={() => onEdit(cls.id)}
>
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-white truncate">{cls.style}</span>
<span className="text-xs font-medium text-neutral-900 truncate dark:text-white">{cls.style}</span>
<span className="text-[10px] text-neutral-500">{cls.startTime}{cls.endTime}</span>
</div>
<div className="text-[10px] text-neutral-400 truncate">{cls.trainer}</div>
@@ -335,21 +333,21 @@ function ClassCell({
)}
{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">
{/* Actions — always visible on mobile, hover on desktop */}
<div className="absolute top-1.5 right-1.5 flex gap-1 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); onCancel(cls.id); }}
className={`rounded p-0.5 ${cls.cancelled ? "text-neutral-500 hover:text-emerald-400" : "text-neutral-500 hover:text-yellow-400"}`}
className={`rounded-md p-1 transition-colors ${cls.cancelled ? "text-neutral-500 hover:text-emerald-400 hover:bg-emerald-400/10" : "text-neutral-500 hover:text-yellow-400 hover:bg-yellow-400/10"}`}
title={cls.cancelled ? "Восстановить" : "Отменить"}
>
{cls.cancelled ? <RotateCcw size={10} /> : <Ban size={10} />}
{cls.cancelled ? <RotateCcw size={14} /> : <Ban size={14} />}
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete(cls.id); }}
className="rounded p-0.5 text-neutral-500 hover:text-red-400"
className="rounded-md p-1 text-neutral-500 hover:text-red-400 hover:bg-red-400/10 transition-colors"
title="Удалить"
>
<Trash2 size={10} />
<Trash2 size={14} />
</button>
</div>
</div>
@@ -376,8 +374,23 @@ function ScheduleGrid({
onClassesChange: () => void;
}) {
const [selectedHall, setSelectedHall] = useState(halls[0] ?? "");
const [editingClassId, setEditingClassId] = useState<number | null>(null);
const [confirmAction, setConfirmAction] = useState<{ message: string; onConfirm: () => void } | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
const timeSlots = generateTimeSlots(10, 22);
// Close edit mode when clicking outside the grid
useEffect(() => {
if (editingClassId === null) return;
function handleClick(e: MouseEvent) {
if (gridRef.current && !gridRef.current.contains(e.target as Node)) {
setEditingClassId(null);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [editingClassId]);
// Build lookup: time -> class for selected hall
const hallClasses = useMemo(() => {
const map: Record<string, OpenDayClass> = {};
@@ -398,37 +411,64 @@ function ScheduleGrid({
const [creatingTime, setCreatingTime] = useState<string | null>(null);
async function confirmCreate(startTime: string, data: { trainer: string; style: string; endTime: string }) {
await adminFetch("/api/admin/open-day/classes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }),
});
setCreatingTime(null);
onClassesChange();
try {
const res = await adminFetch("/api/admin/open-day/classes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }),
});
if (!res.ok) throw new Error();
setCreatingTime(null);
onClassesChange();
} catch {
alert("Не удалось создать занятие");
}
}
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 }),
try {
const res = await adminFetch("/api/admin/open-day/classes", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, ...data }),
});
if (!res.ok) throw new Error();
onClassesChange();
} catch {
alert("Не удалось обновить занятие");
}
}
function deleteClass(id: number) {
setConfirmAction({
message: "Удалить занятие? Это действие нельзя отменить.",
onConfirm: async () => {
try {
const res = await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
if (!res.ok) throw new Error();
onClassesChange();
} catch {
alert("Не удалось удалить занятие");
}
},
});
onClassesChange();
}
async function deleteClass(id: number) {
await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
onClassesChange();
}
async function cancelClass(id: number) {
function cancelClass(id: number) {
const cls = classes.find((c) => c.id === id);
if (!cls) return;
await updateClass(id, { cancelled: !cls.cancelled });
setConfirmAction({
message: cls.cancelled
? "Восстановить занятие?"
: `Отменить занятие? (${cls.bookingCount} записей)`,
onConfirm: async () => {
await updateClass(id, { cancelled: !cls.cancelled });
},
});
}
return (
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-3">
<div ref={gridRef} className="rounded-xl border border-neutral-200 bg-white p-5 space-y-3 dark:border-white/10 dark:bg-neutral-900">
<h2 className="text-lg font-bold">Расписание</h2>
{halls.length === 0 ? (
@@ -444,7 +484,7 @@ function ScheduleGrid({
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
selectedHall === hall
? "bg-gold/20 text-gold border border-gold/40"
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
}`}
>
{hall}
@@ -462,8 +502,8 @@ function ScheduleGrid({
{timeSlots.map((time) => {
const cls = hallClasses[time];
return (
<div key={time} className="flex items-start gap-3 border-t border-white/5 py-1.5">
<span className="text-xs text-neutral-500 w-12 pt-1.5 shrink-0">{time}</span>
<div key={time} className="flex items-start gap-3 border-t border-neutral-200 py-1.5 dark:border-white/5">
<span className="text-xs text-neutral-400 w-12 pt-1.5 shrink-0 dark:text-neutral-500">{time}</span>
<div className="flex-1">
{cls ? (
<ClassCell
@@ -471,6 +511,8 @@ function ScheduleGrid({
minBookings={minBookings}
trainers={trainers}
styles={styles}
editing={editingClassId === cls.id}
onEdit={(id) => { setEditingClassId(id); if (id) setCreatingTime(null); }}
onUpdate={updateClass}
onDelete={deleteClass}
onCancel={cancelClass}
@@ -485,8 +527,8 @@ function ScheduleGrid({
/>
) : (
<button
onClick={() => setCreatingTime(time)}
className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors"
onClick={() => { setCreatingTime(time); setEditingClassId(null); }}
className="w-full rounded-lg border border-dashed border-neutral-200 p-2 text-neutral-400 hover:text-gold hover:border-gold/20 transition-colors dark:border-white/5 dark:text-neutral-600"
>
<Plus size={12} className="mx-auto" />
</button>
@@ -498,6 +540,30 @@ function ScheduleGrid({
</div>
</>
)}
{/* Confirm dialog */}
{confirmAction && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirmAction(null)}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div className="relative w-full max-w-xs rounded-xl border border-neutral-200 bg-white p-5 shadow-2xl dark:border-white/[0.08] dark:bg-[#141414]" onClick={(e) => e.stopPropagation()}>
<p className="text-sm text-neutral-900 text-center dark:text-white">{confirmAction.message}</p>
<div className="mt-4 flex gap-2 justify-center">
<button
onClick={() => setConfirmAction(null)}
className="rounded-lg border border-neutral-200 px-4 py-2 text-xs font-medium text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25"
>
Нет
</button>
<button
onClick={() => { confirmAction.onConfirm(); setConfirmAction(null); }}
className="rounded-lg bg-gold/20 border border-gold/30 px-4 py-2 text-xs font-medium text-gold hover:bg-gold/30 transition-colors"
>
Да
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -514,7 +580,8 @@ export default function OpenDayAdminPage() {
const [trainers, setTrainers] = useState<string[]>([]);
const [styles, setStyles] = useState<string[]>([]);
const [halls, setHalls] = useState<string[]>([]);
const saveTimerRef = { current: null as ReturnType<typeof setTimeout> | null };
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingSaveRef = useRef(false);
// Load data
useEffect(() => {
@@ -545,8 +612,11 @@ export default function OpenDayAdminPage() {
}
// Auto-save event changes
const eventRef = useRef<OpenDayEvent | null>(null);
const saveEvent = useCallback(
(updated: OpenDayEvent) => {
eventRef.current = updated;
pendingSaveRef.current = true;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
setSaving(true);
@@ -557,6 +627,7 @@ export default function OpenDayAdminPage() {
body: JSON.stringify(updated),
});
setSaveStatus(res.ok ? "saved" : "error");
if (res.ok) pendingSaveRef.current = false;
} catch {
setSaveStatus("error");
}
@@ -567,31 +638,66 @@ export default function OpenDayAdminPage() {
[]
);
// Warn before leaving with unsaved changes
useEffect(() => {
function onBeforeUnload(e: BeforeUnloadEvent) {
if (pendingSaveRef.current) e.preventDefault();
}
function onLinkClick(e: MouseEvent) {
if (!pendingSaveRef.current) return;
const link = (e.target as HTMLElement).closest("a");
if (!link || link.target === "_blank") return;
const href = link.getAttribute("href");
if (!href || href.startsWith("#")) return;
e.preventDefault();
e.stopPropagation();
// Force save then navigate
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
const data = eventRef.current;
if (data) {
adminFetch("/api/admin/open-day", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).finally(() => { window.location.href = href; });
} else {
window.location.href = href;
}
}
window.addEventListener("beforeunload", onBeforeUnload);
document.addEventListener("click", onLinkClick, true);
return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
document.removeEventListener("click", onLinkClick, true);
};
}, []);
function handleEventChange(patch: Partial<OpenDayEvent>) {
if (!event) return;
const updated = { ...event, ...patch };
setEvent(updated);
// Skip auto-save only if date is partially typed (prevents 400 errors)
if (updated.date && updated.date.length > 0 && updated.date.length < 10) return;
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 }),
body: JSON.stringify({ date: "" }),
});
const { id } = await res.json();
setEvent({
id,
date: today,
title: "День открытых дверей",
pricePerClass: 30,
discountPrice: 20,
discountThreshold: 3,
minBookings: 4,
date: "",
title: "",
pricePerClass: 0,
discountPrice: 0,
discountThreshold: 0,
minBookings: 0,
maxParticipants: 0,
active: true,
active: false,
});
}

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