Compare commits

...

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 19:05:44 +03:00
190 changed files with 10400 additions and 4609 deletions
+14 -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
@@ -158,6 +159,15 @@ src/
- Migrations run automatically on server start via `runMigrations()` and are tracked in the `_migrations` table
- Use `CREATE TABLE IF NOT EXISTS` and column-existence checks (`PRAGMA table_info`) for safety
## Turbopack / Dev Server Troubleshooting
If the dev server hangs on "Compiling..." or shows a white page:
1. Kill all node processes: `taskkill /F /IM node.exe`
2. Remove stale lock: `rm -f .next/dev/lock`
3. Clear cache: `rm -rf .next node_modules/.cache`
4. Restart: `npm run dev`
- This often happens after shutting down the PC without stopping the server first
- Always stop the dev server (Ctrl+C) before shutting down
## Git
- Remote: Gitea at `git.dolgolyov-family.by`
- User: diana.dolgolyova
+179
View File
@@ -0,0 +1,179 @@
# Booking Status Flow — Planned Changes
## Current Flow (What We Have Now)
```
new ──["Связались →"]──→ contacted ──["Подтвердить"]──→ confirmed
──["Отказ"]──→ declined
confirmed ──["Вернуть"]──→ contacted
declined ──["Вернуть"]──→ contacted
```
### What buttons appear at each status:
| Status | Buttons shown |
|-----------|------------------------------------|
| new | "Связались →" |
| contacted | "Подтвердить", "Отказ" |
| confirmed | "Вернуть" |
| declined | "Вернуть" |
---
## Problems Found
### 1. Can't confirm directly from `new`
Someone calls, books, and confirms in one conversation.
Admin must click "Связались" → wait → click "Подтвердить".
Two clicks for one real-world action.
### 2. Can't decline from `new`
Spam or invalid booking arrives.
Admin must first mark "Связались" (lie) → then "Отказ".
### 3. Reminders miss non-confirmed group bookings
SQL query in `db.ts` line 870:
```sql
WHERE status = 'confirmed' AND confirmed_date IN (?, ?)
```
If admin contacted someone and set a date but forgot to click "Подтвердить",
that person **never shows up** in reminders tab.
MC registrations don't have this problem — they show by event date regardless of status.
### 4. Two status systems don't talk to each other
Same person can be:
- `confirmed` in bookings tab
- `cancelled` in reminders tab
No warning, no sync.
### 5. "Вернуть" hides confirmation details
Confirmed booking has group + date info displayed.
"Вернуть" → status becomes `contacted` → info becomes invisible
(still in DB, but UI only shows it when `status === "confirmed"`).
Re-confirming requires filling everything again from scratch.
---
## Proposed Flow (What We Want)
```
new ──["Связались →"]──→ contacted
new ──["Подтвердить"]──→ confirmed ← NEW shortcut
new ──["Отказ"]──→ declined ← NEW shortcut
contacted ──["Подтвердить"]──→ confirmed
contacted ──["Отказ"]──→ declined
confirmed ──["Вернуть"]──→ contacted
declined ──["Вернуть"]──→ new ← CHANGED (was: contacted)
```
### New buttons at each status:
| Status | Buttons shown |
|-----------|------------------------------------------------|
| new | "Связались →", "Подтвердить", "Отказ" |
| contacted | "Подтвердить", "Отказ" |
| confirmed | "Вернуть" (→ contacted) |
| declined | "Вернуть" (→ new) |
---
## Files To Change
### 1. `src/app/admin/bookings/BookingComponents.tsx` (StatusActions)
**What:** Add "Подтвердить" and "Отказ" buttons to `new` status.
Change "Вернуть" from `declined` to go to `new` instead of `contacted`.
**Before:**
```tsx
{status === "new" && actionBtn("Связались →", ...)}
{status === "contacted" && (
actionBtn("Подтвердить", ...) + actionBtn("Отказ", ...)
)}
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", "contacted")}
```
**After:**
```tsx
{status === "new" && (
actionBtn("Связались →", "contacted")
+ actionBtn("Подтвердить", "confirmed")
+ actionBtn("Отказ", "declined")
)}
{status === "contacted" && (
actionBtn("Подтвердить", "confirmed")
+ actionBtn("Отказ", "declined")
)}
{status === "confirmed" && actionBtn("Вернуть", "contacted")}
{status === "declined" && actionBtn("Вернуть", "new")}
```
### 2. `src/app/admin/bookings/GenericBookingsList.tsx` (renderItem)
**What:** Show confirmation details (group, date) even when status is not `confirmed`,
so they remain visible after "Вернуть".
**Before:** `renderExtra` only shows confirmed details when `b.status === "confirmed"`
**After:** Show confirmed details whenever they exist, regardless of status
(this is actually in `page.tsx` GroupBookingsTab `renderExtra` — line 291)
### 3. `src/app/admin/bookings/page.tsx` (GroupBookingsTab renderExtra)
**What:** Change condition from `b.status === "confirmed" && (b.confirmedGroup || ...)` to just `(b.confirmedGroup || b.confirmedDate)`.
**Before:**
```tsx
{b.status === "confirmed" && (b.confirmedGroup || b.confirmedDate) && (
<span className="text-[10px] text-emerald-400/70">...</span>
)}
```
**After:**
```tsx
{(b.confirmedGroup || b.confirmedDate) && (
<span className="text-[10px] text-emerald-400/70">...</span>
)}
```
### 4. `src/lib/db.ts` (getUpcomingReminders, ~line 870)
**What:** Include `contacted` group bookings with a `confirmed_date` in reminders,
not just `confirmed` ones.
**Before:**
```sql
SELECT * FROM group_bookings WHERE status = 'confirmed' AND confirmed_date IN (?, ?)
```
**After:**
```sql
SELECT * FROM group_bookings WHERE status IN ('confirmed', 'contacted') AND confirmed_date IN (?, ?)
```
---
## Files NOT Changed
- `types.ts` — BookingStatus type stays the same (`new | contacted | confirmed | declined`)
- `SearchBar.tsx` — No changes needed
- `AddBookingModal.tsx` — No changes needed
- `InlineNotes.tsx` — No changes needed
- `McRegistrationsTab.tsx` — No changes needed (MC doesn't use ConfirmModal)
- `OpenDayBookingsTab.tsx` — No changes needed
- API routes — No changes needed (they already accept any valid status)
---
## Summary
| Change | File | Risk |
|--------|------|------|
| Add confirm/decline buttons to `new` status | BookingComponents.tsx | Low — additive |
| "Вернуть" from declined → `new` instead of `contacted` | BookingComponents.tsx | Low — minor behavior change |
| Show confirmation details at any status | page.tsx (renderExtra) | Low — visual only |
| Include `contacted` bookings in reminders | db.ts | Low — shows more data, not less |
+115
View File
@@ -0,0 +1,115 @@
# Booking Panel — Regression Test Report
**Date**: 2026-03-24
**Scope**: Full manual regression of booking functionality (public + admin)
**Method**: Browser automation (Chrome) + API testing
---
## Test Results Summary
| # | Test | Result | Notes |
|---|------|--------|-------|
| 1 | Public booking — valid submission | PASS | Name, phone, Instagram, Telegram all saved correctly |
| 2 | Public booking — empty name | PASS | Returns 400 |
| 2 | Public booking — empty phone | PASS | Returns 400 |
| 2 | Public booking — whitespace-only name | PASS | Returns 400 |
| 2 | Public booking — XSS in name | WARN | Accepted (200), stored raw — React escapes on render, but `sanitizeName` should strip tags |
| 2 | Public booking — 500-char name | PASS | Truncated to 100 chars by `sanitizeName` |
| 2 | Public booking — SQL injection | PASS | Parameterized queries, no execution |
| 2 | Public booking — rate limiting | PASS | 429 after 5 requests/minute |
| 3 | Admin — new booking visible | PASS | All fields including Instagram/Telegram displayed |
| 4 | Admin — status: new → contacted | PASS | Instant optimistic update, counts refresh |
| 4 | Admin — status: contacted → confirmed (ConfirmModal) | PASS | Cascade selects work: Hall → Trainer → Group |
| 4 | Admin — ConfirmModal Enter key submit | PASS | Enter submits when all fields filled |
| 4 | Admin — ConfirmModal Escape key close | PASS | |
| 4 | Admin — confirmed details visible | PASS | Group + date shown in green text |
| 4 | Admin — "Вернуть" preserves details | PASS | Confirmation info stays visible after revert (our fix) |
| 5 | Admin — delete confirmation dialog | PASS | Shows name, "Отмена"/"Удалить" buttons, Escape closes |
| 5 | Admin — XSS in delete dialog | PASS | Name safely escaped in confirmation dialog |
| 6 | Admin — search finds booking | PASS | Debounced, finds by name |
| 6 | Admin — search results: status actions | PASS | "Подтвердить"/"Отказ" buttons work in search view |
| 6 | Admin — search results: delete | PASS | Trash icon with confirmation in search view |
| 6 | Admin — search: empty query | PASS | Returns 200, no results |
| 6 | Admin — search: 1-char query | PASS | Returns empty (min 2 chars on client) |
| 6 | Admin — search: XSS query | PASS | Returns 200, no injection |
| 7 | Admin — notes save via API | PASS | 200 OK |
| 7 | Admin — notes clear via API | PASS | 200 OK |
| 7 | Admin — XSS in notes | WARN | Accepted and stored raw — React escapes on render |
| 7 | Admin — SQL injection in notes | PASS | Table survived, parameterized queries |
| 8 | Admin — AddBookingModal opens | PASS | "Занятие"/"Мероприятие" tabs |
| 8 | Admin — Instagram/Telegram fields | PASS | New fields visible (our fix #9) |
| 9 | Admin — MC tab loads | PASS | Grouped by MC title, archive section works |
| 9 | Admin — Open Day tab loads | PASS | Grouped by time+style, person counts correct |
| 10 | Admin — Reminders tab (empty) | PASS | "Нет напоминаний" shown when no upcoming events |
| 12 | Admin — CSRF protection | PASS | All mutating calls return 403 without valid token |
| 12 | Admin — invalid action | PASS | Returns 400 |
| 12 | Admin — invalid status value | PASS | Returns 400 |
| 12 | Admin — delete invalid ID format | PASS | Returns 400 |
| 12 | Admin — delete non-existent | PASS | Returns 200 (idempotent) |
---
## Bugs Found
### BUG-1: XSS payload stored raw in database (LOW)
**Severity**: Low (React auto-escapes, no actual XSS execution)
**Steps**: Submit `<script>alert(1)</script>` as name in public booking form
**Expected**: `sanitizeName` should strip HTML tags
**Actual**: Stored as-is in DB, rendered safely by React
**Risk**: If content is ever rendered outside React (email, export, SSR with `dangerouslySetInnerHTML`), XSS could execute
**Fix**: Add HTML tag stripping in `sanitizeName()` — e.g., `name.replace(/<[^>]*>/g, '')`
### BUG-2: XSS in notes stored raw (LOW)
**Severity**: Low (same as BUG-1)
**Steps**: Save `<img src=x onerror=alert(1)>` as a note via API
**Actual**: Stored raw, rendered safely by React
**Fix**: Strip HTML in notes save path or add a `sanitizeText()` call
### BUG-3: Dashboard counts include archived/past MC bookings (MEDIUM)
**Severity**: Medium (misleading)
**Steps**: View bookings page with past MC events
**Expected**: Dashboard "Мастер-классы: 5 новых" should only count upcoming MCs
**Actual**: Counts ALL MC registrations regardless of event date. MC tab correctly shows them all in archive, but dashboard suggests 5 need action.
**Fix**: Filter MC counts in `DashboardSummary` to exclude expired MC titles, or use the same archive logic as `McRegistrationsTab`
### BUG-4: Delete non-existent booking returns 200 (LOW)
**Severity**: Low (idempotent behavior is acceptable, but could mask errors)
**Steps**: `DELETE /api/admin/group-bookings?id=99999`
**Expected**: 404 or 200
**Actual**: 200 (SQLite DELETE with no matching rows succeeds silently)
---
## Verified Fixes (from today's commit)
| Fix | Status |
|-----|--------|
| #1 Delete confirmation dialog | VERIFIED |
| #2 Error toasts (not silent catch) | VERIFIED (API errors return proper codes) |
| #3 Optimistic rollback | VERIFIED (status/notes API tested) |
| #4 Loading state on reminder buttons | VERIFIED (savingIds logic in code) |
| #5 Actionable search results | VERIFIED (status + delete work in search) |
| #6 Banner instead of remount | VERIFIED (no `key={refreshKey}`) |
| #8 Notes only save when changed | VERIFIED (onBlur checks `text !== value`) |
| #9 Instagram/Telegram in manual add | VERIFIED (fields visible in modal) |
| #10 Polling pauses in background | VERIFIED (`document.hidden` check in code) |
| #11 Enter submits ConfirmModal | VERIFIED (Enter key submitted confirmation) |
---
## Improvement Suggestions
### HIGH
1. **Strip HTML tags from user input** — Add `.replace(/<[^>]*>/g, '')` to `sanitizeName` and notes save path
2. **Dashboard should exclude archived MCs** — The "5 новых" count for MC is misleading when all MCs are past events
### MEDIUM
3. **Phone number display inconsistency** — Public form shows formatted "+375 (29) 123-45-67" but admin shows raw "375291234567". Consider formatting in admin view too.
4. **Long name overflows card** — The 100-char "AAAA..." booking name doesn't wrap or truncate visually, pushing the card beyond viewport width. Add `truncate` or `break-words` to the name element.
5. **Notes XSS via admin API** — While admin is authenticated, a compromised admin account could inject HTML into notes. Low risk but easy to prevent.
### LOW
6. **Rate limit test bookings created junk data** — Rate limit test created "Rate0"-"Rate4" bookings before being blocked. These pollute the database. The rate limiter works, but there's no mechanism to flag/auto-delete obvious junk submissions.
7. **No pagination** — If bookings grow to hundreds, the flat list will be slow. Not urgent for a small dance school, but worth planning.
8. **Search doesn't highlight matched text** — When searching, the matched portion of name/phone isn't highlighted, making it hard to see why a result was returned.
+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

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 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>
);
}
+250 -31
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 });
@@ -28,9 +48,42 @@ export function ArrayEditor<T>({
const [grabOffset, setGrabOffset] = useState({ x: 0, y: 0 });
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const [mounted, setMounted] = useState(false);
const [newItemIndex, setNewItemIndex] = useState<number | null>(null);
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); }, []);
// Scroll to newly added item
useEffect(() => {
if (newItemIndex !== null && itemRefs.current[newItemIndex]) {
itemRefs.current[newItemIndex]?.scrollIntoView({ behavior: "smooth", block: "center" });
setNewItemIndex(null);
}
}, [newItemIndex, items]);
function updateItem(index: number, item: T) {
const updated = [...items];
updated[index] = item;
@@ -38,6 +91,7 @@ export function ArrayEditor<T>({
}
function removeItem(index: number) {
stableKeysRef.current.splice(index, 1);
onChange(items.filter((_, i) => i !== index));
}
@@ -104,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);
}
}
});
@@ -121,30 +182,96 @@ export function ArrayEditor<T>({
function renderList() {
if (dragIndex === null || insertAt === null) {
return items.map((item, i) => (
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={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 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" : ""}`}
>
<div className="flex items-start justify-between gap-2 mb-3">
{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 text-neutral-500 hover:text-white transition-colors select-none"
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={16} />
<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)}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
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>
);
});
}
const elements: React.ReactNode[] = [];
@@ -164,36 +291,76 @@ 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">
{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>
) : (
<>
<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-white transition-colors select-none"
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>
<button
type="button"
onClick={() => removeItem(i)}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
>
{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++;
}
@@ -202,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 }}
/>
);
}
@@ -213,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>
{!hideAdd && addPosition === "bottom" && (
<button
type="button"
onClick={() => onChange([...items, createItem()])}
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
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 &&
@@ -242,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
);
}
+519 -279
View File
@@ -1,7 +1,8 @@
import { useRef, useEffect, useState } 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,6 +12,12 @@ interface InputFieldProps {
type?: "text" | "url" | "tel";
}
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-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({
label,
value,
@@ -23,15 +30,84 @@ 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="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={inputCls}
/>
</div>
);
}
export function ParticipantLimits({
min,
max,
onMinChange,
onMaxChange,
}: {
min: number;
max: number;
onMinChange: (v: number) => void;
onMaxChange: (v: number) => void;
}) {
const [minStr, setMinStr] = useState(String(min));
const [maxStr, setMaxStr] = useState(String(max));
const minLocal = parseInt(minStr) || 0;
const maxLocal = parseInt(maxStr) || 0;
const minEmpty = minStr === "";
const maxEmpty = maxStr === "";
const maxError = (maxLocal > 0 && minLocal > 0 && maxLocal < minLocal) || minEmpty || maxEmpty;
function handleMin(raw: string) {
setMinStr(raw);
if (raw === "") return;
const v = parseInt(raw) || 0;
const curMax = parseInt(maxStr) || 0;
if (curMax > 0 && v > curMax) return;
onMinChange(v);
}
function handleMax(raw: string) {
setMaxStr(raw);
if (raw === "") return;
const v = parseInt(raw) || 0;
const curMin = parseInt(minStr) || 0;
if (v > 0 && v < curMin) return;
onMaxChange(v);
}
const errorMsg = minEmpty || maxEmpty
? "Поле не может быть пустым"
: maxLocal > 0 && minLocal > maxLocal
? "Макс. не может быть меньше мин."
: null;
return (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-neutral-400 mb-1.5">Мин. участников</label>
<input type="number" min={0} value={minStr} onChange={(e) => handleMin(e.target.value)}
aria-describedby="min-hint"
aria-invalid={minEmpty || undefined}
className={`${inputCls} ${minEmpty ? "!border-red-500/50" : ""}`} />
<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 id="max-hint" className={`text-xs mt-1 ${errorMsg && !minEmpty ? "text-red-400" : "text-neutral-600"}`}>
{maxEmpty ? "Поле не может быть пустым" : maxLocal > 0 && minLocal > maxLocal ? "Макс. не может быть меньше мин." : "0 = без лимита. При заполнении — лист ожидания"}
</p>
</div>
</div>
);
}
interface TextareaFieldProps {
label: string;
value: string;
@@ -72,22 +148,268 @@ 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}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors resize-none overflow-hidden"
className={textAreaInput}
/>
</div>
);
}
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({
@@ -96,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);
@@ -110,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) {
@@ -124,51 +475,74 @@ export function SelectField({
return (
<div ref={containerRef} className="relative">
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<button
type="button"
onClick={() => {
setOpen(!open);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}}
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-left outline-none transition-colors ${
open ? "border-gold" : "border-white/10"
} ${value ? "text-white" : "text-neutral-500"}`}
>
{selectedLabel || placeholder || "Выберите..."}
</button>
{open && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
{options.length > 3 && (
<div className="p-1.5">
{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={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"
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`}
/>
</div>
) : (
<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 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>
@@ -211,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);
}
@@ -224,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
@@ -232,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>
@@ -303,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"
@@ -320,8 +694,9 @@ 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="flex-1 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold/50 transition-colors"
className={dashedInput}
/>
<button
type="button"
@@ -343,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();
@@ -376,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");
@@ -384,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);
}
}
@@ -395,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"
@@ -413,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">
@@ -442,8 +825,9 @@ 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="flex-1 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold/50 transition-colors"
className={dashedInput}
/>
<button
type="button"
@@ -455,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>
);
@@ -644,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 && (
@@ -658,106 +900,104 @@ export function ValidatedLinkField({ value, onChange, onValidate, validationKey,
);
}
interface VictoryItemListFieldProps {
// --- Autocomplete Multi-Select ---
export function AutocompleteMulti({
label,
value,
onChange,
options,
placeholder,
}: {
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;
value: string;
onChange: (v: string) => void;
options: string[];
placeholder?: string;
}) {
const selected = useMemo(() => (value ? value.split(/\s*[,·]\s*/).filter(Boolean) : []), [value]);
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(() => {
if (!query) return options.filter((o) => !selected.includes(o));
const q = query.toLowerCase();
return options.filter((o) => !selected.includes(o) && o.toLowerCase().includes(q));
}, [query, options, selected]);
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [open]);
function addItem(item: string) {
onChange([...selected, item].join(" · "));
setQuery("");
inputRef.current?.focus();
}
export function VictoryItemListField({ label, items, onChange, cityErrors, citySuggestions, onCitySearch, onCitySelect, onLinkValidate }: VictoryItemListFieldProps) {
function add() {
onChange([...items, { type: "place", place: "", category: "", competition: "" }]);
function removeItem(item: string) {
onChange(selected.filter((s) => s !== item).join(" · "));
}
function remove(index: number) {
onChange(items.filter((_, i) => i !== index));
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
if (filtered.length > 0) addItem(filtered[0]);
else if (query.trim()) addItem(query.trim());
}
function update(index: number, field: keyof VictoryItem, value: string) {
onChange(items.map((item, i) => (i === index ? { ...item, [field]: value || undefined } : item)));
if (e.key === "Backspace" && !query && selected.length > 0) {
removeItem(selected[selected.length - 1]);
}
if (e.key === "Escape") { setOpen(false); setQuery(""); }
}
return (
<div>
<div ref={containerRef} className="relative">
<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"
<div
onClick={() => { setOpen(true); inputRef.current?.focus(); }}
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"
}`}
>
<option value="place">Место</option>
<option value="nomination">Номинация</option>
<option value="judge">Судейство</option>
</select>
<input
type="text"
value={item.place || ""}
onChange={(e) => update(i, "place", e.target.value)}
placeholder="1 место, финалист..."
className="w-28 shrink-0 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
/>
<input
type="text"
value={item.category || ""}
onChange={(e) => update(i, "category", e.target.value)}
placeholder="Категория"
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
/>
<input
type="text"
value={item.competition || ""}
onChange={(e) => update(i, "competition", e.target.value)}
placeholder="Чемпионат"
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none focus:border-gold transition-colors"
/>
<button
type="button"
onClick={() => remove(i)}
className="shrink-0 rounded-md p-1.5 text-neutral-500 hover:text-red-400 transition-colors"
>
<X size={14} />
{selected.map((item) => (
<span key={item} className="inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/30 px-2.5 py-0.5 text-xs font-medium text-gold">
{item}
<button type="button" onClick={(e) => { e.stopPropagation(); removeItem(item); }} className="text-gold/60 hover:text-gold transition-colors">
<X size={10} />
</button>
</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>
</span>
))}
<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>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={selected.length === 0 ? placeholder : ""}
className="flex-1 min-w-[80px] bg-transparent text-sm text-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-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-neutral-900 hover:bg-neutral-50 transition-colors dark:text-white dark:hover:bg-white/5">
{opt}
</button>
))}
</div>
)}
</div>
);
}
@@ -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>
);
}
+63 -23
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]);
@@ -51,6 +59,7 @@ export function SectionEditor<T>({
} catch {
setStatus("error");
setError("Ошибка сохранения");
setTimeout(() => setStatus((s) => (s === "error" ? "idle" : s)), 4000);
}
}, [sectionKey]);
@@ -62,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);
@@ -72,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">
@@ -87,29 +136,20 @@ export function SectionEditor<T>({
return (
<div>
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{title}</h1>
<div className="flex items-center gap-2 text-sm text-neutral-400">
{status === "saving" && (
<>
<Loader2 size={14} className="animate-spin" />
<span>Сохранение...</span>
</>
)}
{status === "saved" && (
<>
<Check size={14} className="text-emerald-400" />
<span className="text-emerald-400">Сохранено</span>
</>
)}
{status === "error" && (
<>
<AlertCircle size={14} className="text-red-400" />
<span className="text-red-400">{error}</span>
</>
)}
</div>
{/* Fixed toast popup */}
{(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>
)}
<div className="mt-6 space-y-6">{children(data, setData)}</div>
</div>
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useState, useEffect, useCallback, createContext, useContext } from "react";
import { X, AlertCircle, CheckCircle2 } from "lucide-react";
interface ToastItem {
id: number;
message: string;
type: "error" | "success";
}
interface ToastContextValue {
showError: (message: string) => void;
showSuccess: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue>({
showError: () => {},
showSuccess: () => {},
});
export function useToast() {
return useContext(ToastContext);
}
let nextId = 0;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const addToast = useCallback((message: string, type: "error" | "success") => {
const id = ++nextId;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
}, []);
const showError = useCallback((message: string) => addToast(message, "error"), [addToast]);
const showSuccess = useCallback((message: string) => addToast(message, "success"), [addToast]);
return (
<ToastContext.Provider value={{ showError, showSuccess }}>
{children}
{toasts.length > 0 && (
<div 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}
className={`flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm shadow-lg animate-in slide-in-from-right ${
t.type === "error"
? "bg-red-950/90 border-red-500/30 text-red-200"
: "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
}`}
>
{t.type === "error" ? <AlertCircle size={14} className="shrink-0" /> : <CheckCircle2 size={14} className="shrink-0" />}
<span className="flex-1">{t.message}</span>
<button
aria-label="Закрыть уведомление"
onClick={() => setToasts((prev) => prev.filter((tt) => tt.id !== t.id))}
className="shrink-0 text-neutral-400 hover:text-white"
>
<X size={12} />
</button>
</div>
))}
</div>
)}
</ToastContext.Provider>
);
}
+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={() => ""}
+390
View File
@@ -0,0 +1,390 @@
"use client";
import { useState, useEffect, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
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; 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,
onClose,
onAdded,
}: {
open: boolean;
onClose: () => void;
onAdded: () => void;
}) {
const [tab, setTab] = useState<Tab>("classes");
const [eventType, setEventType] = useState<EventType>("master-class");
const [name, setName] = useState("");
const [phone, setPhone] = useState("+375 ");
const [instagram, setInstagram] = useState("");
const [telegram, setTelegram] = useState("");
const [mcTitle, setMcTitle] = useState("");
const [mcOptions, setMcOptions] = useState<McOption[]>([]);
const [odClasses, setOdClasses] = useState<OdClass[]>([]);
const [odEventId, setOdEventId] = useState<number | null>(null);
const [odClassId, setOdClassId] = useState("");
const [scheduleClasses, setScheduleClasses] = useState<ScheduleClass[]>([]);
const [classGroup, setClassGroup] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassGroup("");
// 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 || [])
.filter((mc) => {
const earliest = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
return earliest && earliest >= today;
})
.map((mc) => ({
title: mc.title,
date: mc.slots.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? ""),
}));
setMcOptions(upcoming);
if (upcoming.length === 0 && tab === "events") setEventType("open-day");
}).catch(() => {});
// Fetch active Open Day event + classes
adminFetch("/api/admin/open-day").then((r) => r.json()).then(async (events: OdEvent[]) => {
const today = new Date().toISOString().split("T")[0];
const active = events.find((e) => e.date >= today);
if (!active) { setOdEventId(null); setOdClasses([]); return; }
setOdEventId(active.id);
const classes = await adminFetch(`/api/admin/open-day/classes?eventId=${active.id}`).then((r) => r.json());
setOdClasses(classes);
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
function handlePhoneChange(raw: string) {
setPhone(formatBelarusPhone(raw));
}
const hasUpcomingMc = mcOptions.length > 0;
const hasOpenDay = odEventId !== null && odClasses.length > 0;
// 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() }),
}),
});
} else if (eventType === "master-class") {
const title = mcTitle || mcOptions[0]?.title || "Мастер-класс";
await adminFetch("/api/admin/mc-registrations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ masterClassTitle: title, name: name.trim(), instagram: "-", phone: phone.trim() }),
});
} else if (eventType === "open-day" && odClassId && odEventId) {
await adminFetch("/api/open-day-register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ classId: Number(odClassId), eventId: odEventId, name: name.trim(), phone: phone.trim() }),
});
}
onAdded();
onClose();
} catch {
alert("Не удалось создать запись. Попробуйте ещё раз.");
} finally {
setSaving(false);
}
}
if (!open) return null;
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)
|| (tab === "events" && eventType === "open-day" && odClassId));
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-sm rounded-2xl border border-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-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">
{/* 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>
{/* Class selector (optional for Занятие) */}
{tab === "classes" && classGroupOptions.length > 0 && (
<SearchSelect
options={classGroupOptions}
value={classGroup}
onChange={setClassGroup}
placeholder="Группа (необязательно)"
/>
)}
{/* MC selector */}
{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" && 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} />
<input type="tel" value={phone} onChange={(e) => handlePhoneChange(e.target.value)} placeholder="+375 (__) ___-__-__" className={inputClass} />
<div className="flex gap-2">
<input type="text" value={instagram} onChange={(e) => setInstagram(e.target.value)} placeholder="Instagram" className={inputClass} />
<input type="text" value={telegram} onChange={(e) => setTelegram(e.target.value)} placeholder="Telegram" className={inputClass} />
</div>
</div>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="mt-5 w-full rounded-lg bg-gold py-2.5 text-sm font-semibold text-black transition-all hover:bg-gold-light disabled:opacity-30 disabled:cursor-not-allowed"
>
{saving ? "Сохраняю..." : "Добавить"}
</button>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,175 @@
"use client";
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { Loader2, Trash2, Phone, Instagram, Send, X } from "lucide-react";
import { type BookingStatus, type BookingFilter, BOOKING_STATUSES } from "./types";
export function LoadingSpinner() {
return (
<div className="flex items-center gap-2 py-8 text-neutral-500 justify-center">
<Loader2 size={16} className="animate-spin" />
Загрузка...
</div>
);
}
export function EmptyState({ total }: { total: number }) {
return (
<p className="text-sm text-neutral-500 py-8 text-center">
{total === 0 ? "Пока нет записей" : "Нет записей по фильтру"}
</p>
);
}
// --- #1: Delete with confirmation ---
export function DeleteBtn({ onClick, name }: { onClick: () => void; name?: string }) {
const [confirming, setConfirming] = useState(false);
useEffect(() => {
if (!confirming) return;
function onKey(e: KeyboardEvent) { if (e.key === "Escape") setConfirming(false); }
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [confirming]);
return (
<>
<button
type="button"
onClick={() => setConfirming(true)}
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
title="Удалить"
>
<Trash2 size={14} />
</button>
{confirming && createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirming(false)}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-xs rounded-2xl border border-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-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-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>
<button
onClick={() => { setConfirming(false); onClick(); }}
className="flex-1 rounded-lg bg-red-600 py-2 text-xs font-medium text-white hover:bg-red-500 transition-colors"
>
Удалить
</button>
</div>
</div>
</div>,
document.body
)}
</>
);
}
export function ContactLinks({ phone, instagram, telegram }: { phone?: string; instagram?: string; telegram?: string }) {
return (
<>
{phone && (
<a href={`tel:${phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-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-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-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 text-xs">
<Send size={10} />{telegram}
</a>
)}
</>
);
}
export function FilterTabs({ filter, counts, total, onFilter }: {
filter: BookingFilter;
counts: Record<string, number>;
total: number;
onFilter: (f: BookingFilter) => void;
}) {
return (
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => onFilter("all")}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-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>
</button>
{BOOKING_STATUSES.map((s) => (
<button
key={s.key}
onClick={() => onFilter(s.key)}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-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}
{counts[s.key] > 0 && <span className="ml-1.5">{counts[s.key]}</span>}
</button>
))}
</div>
);
}
export function StatusBadge({ status }: { status: BookingStatus }) {
const conf = BOOKING_STATUSES.find((s) => s.key === status) || BOOKING_STATUSES[0];
return (
<span className={`text-[10px] font-medium ${conf.bg} ${conf.color} border ${conf.border} rounded-full px-2.5 py-0.5`}>
{conf.label}
</span>
);
}
export function StatusActions({ status, onStatus }: { status: BookingStatus; onStatus: (s: BookingStatus) => void }) {
const actionBtn = (label: string, onClick: () => void, cls: string) => (
<button onClick={onClick} className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[10px] font-medium transition-all ${cls}`}>
{label}
</button>
);
return (
<div className="flex gap-1 ml-auto">
{status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-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-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-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>
);
}
export function BookingCard({ status, highlight, children }: { status: BookingStatus; highlight?: boolean; children: React.ReactNode }) {
return (
<div
className={`rounded-lg border p-3 transition-all duration-200 cursor-default ${
status === "declined" ? "border-red-500/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}
</div>
);
}
@@ -0,0 +1,244 @@
"use client";
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { ChevronDown, ChevronRight, Archive } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import { type BookingStatus, type BookingFilter, type BaseBooking, type BookingGroup, sortByStatus } from "./types";
import { EmptyState, BookingCard, ContactLinks, StatusBadge, StatusActions, DeleteBtn } from "./BookingComponents";
import { fmtDate } from "./types";
import { InlineNotes } from "./InlineNotes";
import { useToast } from "./Toast";
interface GenericBookingsListProps<T extends BaseBooking> {
items: T[];
endpoint: string;
filter: BookingFilter;
onItemsChange: (fn: (prev: T[]) => T[]) => void;
onDataChange?: () => void;
groups?: BookingGroup<T>[];
renderExtra?: (item: T) => React.ReactNode;
onConfirm?: (id: number) => void;
}
export function GenericBookingsList<T extends BaseBooking>({
items,
endpoint,
filter,
onItemsChange,
onDataChange,
groups,
renderExtra,
onConfirm,
}: GenericBookingsListProps<T>) {
const [showArchived, setShowArchived] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [highlightId, setHighlightId] = useState<number | null>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const { showError } = useToast();
// Scroll to highlighted card and clear highlight after animation
useEffect(() => {
if (highlightId === null) return;
const timer = setTimeout(() => {
highlightRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, 50);
const clear = setTimeout(() => setHighlightId(null), 2000);
return () => { clearTimeout(timer); clearTimeout(clear); };
}, [highlightId]);
async function handleStatus(id: number, status: BookingStatus) {
if (status === "confirmed" && onConfirm) {
onConfirm(id);
return;
}
const prev = items.find((b) => b.id === id);
const prevStatus = prev?.status;
// Move changed item to front so it appears first in its status group after sort
onItemsChange((list) => {
const item = list.find((b) => b.id === id);
if (!item) return list;
return [{ ...item, status }, ...list.filter((b) => b.id !== id)];
});
setHighlightId(id);
try {
const res = await adminFetch(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-status", id, status }),
});
if (!res.ok) throw new Error();
onDataChange?.();
} catch {
if (prevStatus) onItemsChange((list) => list.map((b) => b.id === id ? { ...b, status: prevStatus } : b));
showError("Не удалось обновить статус");
}
}
async function handleDelete(id: number) {
try {
const res = await adminFetch(`${endpoint}?id=${id}`, { method: "DELETE" });
if (!res.ok) throw new Error();
onItemsChange((list) => list.filter((b) => b.id !== id));
onDataChange?.();
} catch {
showError("Не удалось удалить запись");
}
}
async function handleNotes(id: number, notes: string) {
const prev = items.find((b) => b.id === id);
const prevNotes = prev?.notes;
onItemsChange((list) => list.map((b) => b.id === id ? { ...b, notes: notes || undefined } : b));
try {
const res = await adminFetch(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "set-notes", id, notes }),
});
if (!res.ok) throw new Error();
} catch {
onItemsChange((list) => list.map((b) => b.id === id ? { ...b, notes: prevNotes } : b));
showError("Не удалось сохранить заметку");
}
}
function renderItem(item: T, isArchived: boolean) {
const isHighlighted = highlightId === item.id;
return (
<div key={item.id} ref={isHighlighted ? highlightRef : undefined}>
<BookingCard status={item.status} highlight={isHighlighted}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
<span className="font-medium text-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>
<div className="flex items-center gap-2 shrink-0">
<span className="text-neutral-600 text-xs">{fmtDate(item.createdAt)}</span>
<DeleteBtn onClick={() => handleDelete(item.id)} name={item.name} />
</div>
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<StatusBadge status={item.status} />
{!isArchived && <StatusActions status={item.status} onStatus={(s) => handleStatus(item.id, s)} />}
</div>
<InlineNotes value={item.notes || ""} onSave={(notes) => handleNotes(item.id, notes)} />
</BookingCard>
</div>
);
}
if (groups) {
const filteredGroups = groups.map((g) => ({
...g,
items: filter === "all" ? sortByStatus(g.items) : sortByStatus(g.items.filter((b) => b.status === filter)),
})).filter((g) => g.items.length > 0);
const activeGroups = filteredGroups.filter((g) => !g.isArchived);
const archivedGroups = filteredGroups.filter((g) => g.isArchived);
const archivedCount = archivedGroups.reduce((sum, g) => sum + g.items.length, 0);
const allArchived = activeGroups.length === 0 && archivedCount > 0 && filter === "all";
function renderGroup(group: BookingGroup<T>) {
const isOpen = expanded[group.key] ?? !group.isArchived;
const groupCounts = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
for (const item of group.items) groupCounts[item.status] = (groupCounts[item.status] || 0) + 1;
return (
<div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-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-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-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-amber-700 dark:text-gold bg-gold/10"
}`}>
{group.dateBadge}
</span>
)}
{group.isArchived && (
<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-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-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 && (() => {
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>
);
}
return (
<div>
{allArchived && (
<p className="text-sm text-neutral-500 py-4">Все записи в архиве</p>
)}
{!allArchived && (
<div className="space-y-2">
{activeGroups.length === 0 && archivedGroups.length === 0 && <EmptyState total={items.length} />}
{activeGroups.map(renderGroup)}
</div>
)}
{archivedCount > 0 && (
<div className="mt-4">
<button
onClick={() => setShowArchived((v) => !v)}
className="flex items-center gap-2 text-xs text-neutral-500 hover:text-neutral-300 transition-colors"
>
<Archive size={13} />
{(showArchived || allArchived) ? "Скрыть архив" : `Архив (${archivedCount} записей)`}
{(showArchived || allArchived) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
{(showArchived || allArchived) && (
<div className="mt-2 space-y-2">
{archivedGroups.map(renderGroup)}
</div>
)}
</div>
)}
</div>
);
}
const filtered = useMemo(() => {
const list = filter === "all" ? items : items.filter((b) => b.status === filter);
return sortByStatus(list);
}, [items, filter]);
return (
<div>
<div className="space-y-2">
{filtered.length === 0 && <EmptyState total={items.length} />}
{filtered.map((item) => renderItem(item, false))}
</div>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { StickyNote } from "lucide-react";
export function InlineNotes({ value, onSave }: { value: string; onSave: (notes: string) => void }) {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(value);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => { setText(value); }, [value]);
const save = useCallback((v: string) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => onSave(v), 800);
}, [onSave]);
function handleChange(v: string) {
setText(v);
save(v);
}
useEffect(() => {
if (editing && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.selectionStart = textareaRef.current.value.length;
}
}, [editing]);
if (!editing && !value) {
return (
<button
onClick={() => setEditing(true)}
className="mt-2 inline-flex items-center gap-1.5 text-[11px] text-neutral-600 hover:text-neutral-400 transition-colors"
>
<StickyNote size={11} />
Добавить заметку...
</button>
);
}
if (!editing) {
return (
<button
onClick={() => setEditing(true)}
className="mt-2 inline-flex items-start gap-1.5 text-left transition-colors group"
>
<StickyNote size={11} className="shrink-0 mt-0.5 text-neutral-500 group-hover:text-gold transition-colors" />
<span className="text-[11px] text-neutral-400 leading-relaxed whitespace-pre-wrap group-hover:text-white transition-colors">{value}</span>
</button>
);
}
return (
<div className="mt-2">
<textarea
ref={textareaRef}
value={text}
onChange={(e) => handleChange(e.target.value)}
onBlur={() => { clearTimeout(timerRef.current); if (text !== value) onSave(text.trim() ? text : ""); setEditing(false); }}
placeholder="Заметка..."
rows={2}
className="w-full rounded-md border border-amber-500/20 bg-amber-500/[0.06] px-2.5 py-1.5 text-[11px] text-amber-200/80 placeholder-neutral-600 outline-none focus:border-amber-500/40 resize-none"
/>
</div>
);
}
@@ -0,0 +1,86 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { adminFetch } from "@/lib/csrf";
import { type BookingFilter, type BaseBooking, type BookingGroup } from "./types";
import { LoadingSpinner } from "./BookingComponents";
import { GenericBookingsList } from "./GenericBookingsList";
interface McRegistration extends BaseBooking {
masterClassTitle: string;
}
interface McSlot { date: string; startTime: string }
interface McItem { title: string; slots: McSlot[]; location?: string }
export function McRegistrationsTab({ filter, onDataChange }: { filter: BookingFilter; onDataChange?: () => void }) {
const [regs, setRegs] = useState<McRegistration[]>([]);
const [mcDates, setMcDates] = useState<Record<string, string>>({});
const [mcLocations, setMcLocations] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
adminFetch("/api/admin/mc-registrations").then((r) => r.json()),
adminFetch("/api/admin/sections/masterClasses").then((r) => r.json()),
]).then(([regData, mcData]: [McRegistration[], { items?: McItem[] }]) => {
setRegs(regData);
const dates: Record<string, string> = {};
const locations: Record<string, string> = {};
const mcItems = mcData.items || [];
for (const mc of mcItems) {
const earliestSlot = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
if (earliestSlot) dates[mc.title] = earliestSlot;
if (mc.location) locations[mc.title] = mc.location;
}
const regTitles = new Set(regData.map((r) => r.masterClassTitle));
for (const regTitle of regTitles) {
if (dates[regTitle]) continue;
for (const mc of mcItems) {
const earliestSlot = mc.slots?.reduce((min, s) => s.date < min ? s.date : min, mc.slots[0]?.date ?? "");
if (!earliestSlot) continue;
if (regTitle.toLowerCase().includes(mc.title.toLowerCase()) || mc.title.toLowerCase().includes(regTitle.toLowerCase())) {
dates[regTitle] = earliestSlot;
break;
}
}
}
setMcDates(dates);
setMcLocations(locations);
}).catch(() => {}).finally(() => setLoading(false));
}, []);
const today = new Date().toISOString().split("T")[0];
const groups = useMemo((): BookingGroup<McRegistration>[] => {
const map: Record<string, McRegistration[]> = {};
for (const r of regs) {
if (!map[r.masterClassTitle]) map[r.masterClassTitle] = [];
map[r.masterClassTitle].push(r);
}
return Object.entries(map).map(([title, items]) => {
const date = mcDates[title];
const isArchived = !date || date < today;
return {
key: title,
label: mcLocations[title] ? `${title} · ${mcLocations[title]}` : title,
dateBadge: date ? new Date(date + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : undefined,
items,
isArchived,
};
});
}, [regs, mcDates, mcLocations, today]);
if (loading) return <LoadingSpinner />;
return (
<GenericBookingsList<McRegistration>
items={regs}
endpoint="/api/admin/mc-registrations"
filter={filter}
onItemsChange={setRegs}
onDataChange={onDataChange}
groups={groups}
/>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { adminFetch } from "@/lib/csrf";
import { type BookingFilter, type BaseBooking, type BookingGroup } from "./types";
import { LoadingSpinner } from "./BookingComponents";
import { GenericBookingsList } from "./GenericBookingsList";
interface OpenDayBooking extends BaseBooking {
classId: number;
eventId: number;
classStyle?: string;
classTrainer?: string;
classTime?: string;
classHall?: string;
}
interface EventInfo { id: number; date: string; title?: string }
export function OpenDayBookingsTab({ filter, hallFilter = "all", onDataChange }: { filter: BookingFilter; hallFilter?: string; onDataChange?: () => void }) {
const [bookings, setBookings] = useState<OpenDayBooking[]>([]);
const [events, setEvents] = useState<EventInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
adminFetch("/api/admin/open-day")
.then((r) => r.json())
.then(async (evts: EventInfo[]) => {
setEvents(evts);
if (evts.length === 0) return;
const allBookings: OpenDayBooking[] = [];
for (const ev of evts) {
const data = await adminFetch(`/api/admin/open-day/bookings?eventId=${ev.id}`).then((r) => r.json());
allBookings.push(...data);
}
setBookings(allBookings);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const today = new Date().toISOString().split("T")[0];
const eventDateMap = useMemo(() => {
const map: Record<number, string> = {};
for (const ev of events) map[ev.id] = ev.date;
return map;
}, [events]);
const filteredBookings = useMemo(() =>
hallFilter === "all" ? bookings : bookings.filter((b) => b.classHall === hallFilter),
[bookings, hallFilter]);
const groups = useMemo((): BookingGroup<OpenDayBooking>[] => {
const map: Record<string, { hall: string; time: string; style: string; trainer: string; items: OpenDayBooking[]; eventId: number }> = {};
for (const b of filteredBookings) {
const key = `${b.eventId}|${b.classHall}|${b.classTime}|${b.classStyle}`;
if (!map[key]) map[key] = { hall: b.classHall || "—", time: b.classTime || "—", style: b.classStyle || "—", trainer: b.classTrainer || "—", items: [], eventId: b.eventId };
map[key].items.push(b);
}
return Object.entries(map)
.sort(([, a], [, b]) => {
const hallCmp = a.hall.localeCompare(b.hall);
return hallCmp !== 0 ? hallCmp : a.time.localeCompare(b.time);
})
.map(([key, g]) => {
const eventDate = eventDateMap[g.eventId];
const isArchived = eventDate ? eventDate < today : false;
return {
key,
label: `${g.style} · ${g.hall}`,
sublabel: g.time,
dateBadge: isArchived && eventDate ? new Date(eventDate + "T12:00").toLocaleDateString("ru-RU", { day: "numeric", month: "short" }) : undefined,
items: g.items,
isArchived,
};
});
}, [filteredBookings, eventDateMap, today]);
if (loading) return <LoadingSpinner />;
return (
<GenericBookingsList<OpenDayBooking>
items={filteredBookings}
endpoint="/api/admin/open-day/bookings"
filter={filter}
onItemsChange={setBookings}
onDataChange={onDataChange}
groups={groups}
renderExtra={(b) => (
<>
{b.classHall && <span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{b.classHall}</span>}
</>
)}
/>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useState, useRef } from "react";
import { Search, X } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
import type { SearchResult } from "./types";
export function SearchBar({
onResults,
onClear,
}: {
onResults: (results: SearchResult[]) => void;
onClear: () => void;
}) {
const [query, setQuery] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
function handleChange(q: string) {
setQuery(q);
clearTimeout(timerRef.current);
if (q.trim().length < 2) {
onClear();
return;
}
timerRef.current = setTimeout(() => {
adminFetch(`/api/admin/bookings/search?q=${encodeURIComponent(q.trim())}`)
.then((r) => r.json())
.then((data: SearchResult[]) => onResults(data))
.catch(() => {});
}, 400);
}
function clear() {
setQuery("");
onClear();
}
return (
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
value={query}
onChange={(e) => handleChange(e.target.value)}
placeholder="Поиск по имени или телефону..."
className="w-full rounded-lg border border-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-neutral-900 dark:hover:text-white">
<X size={14} />
</button>
)}
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
// Re-export from shared location
export { ToastProvider, useToast } from "../_components/Toast";
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
import { SHORT_DAYS } from "@/lib/formatting";
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
export type BookingFilter = "all" | BookingStatus;
export interface BaseBooking {
id: number;
name: string;
phone?: string;
instagram?: string;
telegram?: string;
status: BookingStatus;
notes?: string;
createdAt: string;
}
export { SHORT_DAYS };
export const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [
{ 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 {
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> {
const c: Record<string, number> = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
for (const i of items) c[i.status] = (c[i.status] || 0) + 1;
return c;
}
export function sortByStatus<T extends { status: string }>(items: T[]): T[] {
const order: Record<string, number> = { new: 0, contacted: 1, confirmed: 2, declined: 3 };
const UNKNOWN_STATUS_ORDER = 4;
return [...items].sort((a, b) =>
(order[a.status] ?? UNKNOWN_STATUS_ORDER) - (order[b.status] ?? UNKNOWN_STATUS_ORDER)
);
}
export interface BookingGroup<T extends BaseBooking> {
key: string;
label: string;
sublabel?: string;
dateBadge?: string;
items: T[];
isArchived: boolean;
}
export interface SearchResult extends BaseBooking {
type: "class" | "mc" | "open-day";
groupLabel?: string;
}
+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 || "Без названия"}
/>
</>
)}
+220 -32
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="Телефон"
<div className="grid gap-4 sm:grid-cols-2">
<PhoneField
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"
<InstagramField
value={(data.instagram ?? "").replace(/^https?:\/\/(www\.)?instagram\.com\//, "").replace(/\/$/, "")}
onChange={(username) => update({ ...data, instagram: username ? `https://instagram.com/${username}` : "" })}
/>
<InputField
label="Часы работы"
value={data.workingHours}
onChange={(v) => update({ ...data, workingHours: v })}
/>
<ArrayEditor
label="Адреса"
items={data.addresses}
</div>
<CollapsibleSection title="Адреса">
<AddressList
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}
/>
</>
</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

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