Compare commits

...

3 Commits

Author SHA1 Message Date
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
95 changed files with 1120 additions and 599 deletions
+4 -3
View File
@@ -7,7 +7,7 @@ Content language: Russian
## Tech Stack ## Tech Stack
- **Next.js 16** (App Router, TypeScript, Turbopack) - **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 - **lucide-react** for icons
- **better-sqlite3** for SQLite database - **better-sqlite3** for SQLite database
- **Fonts**: Inter (body) + Oswald (headings) via `next/font` - **Fonts**: Inter (body) + Oswald (headings) via `next/font`
@@ -111,8 +111,9 @@ src/
## Brand / Styling ## Brand / Styling
- **Accent**: gold (`#c9a96e` / `hsl(37, 42%, 61%)`) - **Accent**: gold (`#c9a96e` / `hsl(37, 42%, 61%)`)
- **Background**: `#050505` `#0a0a0a` (dark only) - **Dark theme** (default): background `#050505``#0a0a0a`, surface `#171717`, text `neutral-100`
- **Surface**: `#171717` dark cards - **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` - Logo: transparent PNG heart with gold glow, uses `unoptimized`
## Content Data ## Content Data
+20
View File
@@ -1,7 +1,27 @@
import type { NextConfig } from "next"; 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 = { const nextConfig: NextConfig = {
serverExternalPackages: ["better-sqlite3"], serverExternalPackages: ["better-sqlite3"],
allowedDevOrigins: [
"black-heart.dolgolyov-family.by",
"192.168.2.56",
],
headers: async () => [
{
source: "/(.*)",
headers: securityHeaders,
},
],
}; };
export default nextConfig; export default nextConfig;
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 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.

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.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 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

+40 -19
View File
@@ -5,6 +5,8 @@ import { createPortal } from "react-dom";
import { Plus, Trash2, GripVertical, ChevronDown, ChevronsUpDown } from "lucide-react"; import { Plus, Trash2, GripVertical, ChevronDown, ChevronsUpDown } from "lucide-react";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
let nextItemId = 1;
interface ArrayEditorProps<T> { interface ArrayEditorProps<T> {
items: T[]; items: T[];
onChange: (items: T[]) => void; onChange: (items: T[]) => void;
@@ -50,6 +52,19 @@ export function ArrayEditor<T>({
const [droppedIndex, setDroppedIndex] = 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()); 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) { function toggleCollapse(index: number) {
setCollapsed(prev => { setCollapsed(prev => {
const next = new Set(prev); const next = new Set(prev);
@@ -76,6 +91,7 @@ export function ArrayEditor<T>({
} }
function removeItem(index: number) { function removeItem(index: number) {
stableKeysRef.current.splice(index, 1);
onChange(items.filter((_, i) => i !== index)); onChange(items.filter((_, i) => i !== index));
} }
@@ -142,6 +158,11 @@ export function ArrayEditor<T>({
const updated = [...items]; const updated = [...items];
const [moved] = updated.splice(capturedDrag, 1); const [moved] = updated.splice(capturedDrag, 1);
updated.splice(targetIndex, 0, moved); 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); onChange(updated);
setDroppedIndex(targetIndex); setDroppedIndex(targetIndex);
setTimeout(() => setDroppedIndex(null), 1500); setTimeout(() => setDroppedIndex(null), 1500);
@@ -167,17 +188,17 @@ export function ArrayEditor<T>({
const title = getItemTitle?.(item, i) || `#${i + 1}`; const title = getItemTitle?.(item, i) || `#${i + 1}`;
return ( return (
<div <div
key={i} key={getStableKey(i)}
ref={(el) => { itemRefs.current[i] = el; }} ref={(el) => { itemRefs.current[i] = el; }}
className={`rounded-lg border bg-neutral-900/50 mb-3 hover:border-white/25 hover:bg-neutral-800/50 focus-within:border-gold/50 focus-within:bg-neutral-800 transition-all ${ 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-white/10" newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-neutral-200 dark:border-white/10"
} ${isHidden ? "hidden" : ""}`} } ${isHidden ? "hidden" : ""}`}
> >
{inline ? ( {inline ? (
/* Inline: grip + content + delete on one row */ /* Inline: grip + content + delete on one row */
<div className="flex items-start gap-1.5 p-1.5"> <div className="flex items-start gap-1.5 p-1.5">
<div <div
className="cursor-grab active:cursor-grabbing rounded p-1 mt-1.5 text-neutral-500 hover:text-white transition-colors select-none shrink-0" 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)} onMouseDown={(e) => handleGripMouseDown(e, i)}
aria-label="Перетащить для сортировки" aria-label="Перетащить для сортировки"
role="button" role="button"
@@ -201,7 +222,7 @@ export function ArrayEditor<T>({
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}> <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="flex items-center gap-2 flex-1 min-w-0">
<div <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)} onMouseDown={(e) => handleGripMouseDown(e, i)}
aria-label="Перетащить для сортировки" aria-label="Перетащить для сортировки"
role="button" role="button"
@@ -215,7 +236,7 @@ export function ArrayEditor<T>({
aria-expanded={!isCollapsed} aria-expanded={!isCollapsed}
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group" className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
> >
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span> <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)} {getItemBadge?.(item, i)}
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} /> <ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
</button> </button>
@@ -281,15 +302,13 @@ export function ArrayEditor<T>({
const title = getItemTitle?.(item, i) || `#${i + 1}`; const title = getItemTitle?.(item, i) || `#${i + 1}`;
elements.push( elements.push(
<div <div
key={i} key={getStableKey(i)}
ref={(el) => { itemRefs.current[i] = el; }} ref={(el) => { itemRefs.current[i] = el; }}
className={`rounded-lg border bg-neutral-900/50 mb-3 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"
"border-white/10"
}`}
> >
{inline ? ( {inline ? (
<div className="flex items-start gap-1.5 p-1.5"> <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-white transition-colors select-none shrink-0" <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"> onMouseDown={(e) => handleGripMouseDown(e, i)} aria-label="Перетащить для сортировки" role="button">
<GripVertical size={14} /> <GripVertical size={14} />
</div> </div>
@@ -306,7 +325,7 @@ export function ArrayEditor<T>({
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}> <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="flex items-center gap-2 flex-1 min-w-0">
<div <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)} onMouseDown={(e) => handleGripMouseDown(e, i)}
aria-label="Перетащить для сортировки" aria-label="Перетащить для сортировки"
role="button" role="button"
@@ -315,7 +334,7 @@ export function ArrayEditor<T>({
</div> </div>
{collapsible && ( {collapsible && (
<button type="button" onClick={() => toggleCollapse(i)} className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"> <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-300 truncate group-hover:text-white transition-colors">{title}</span> <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)} {getItemBadge?.(item, i)}
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} /> <ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
</button> </button>
@@ -363,14 +382,14 @@ export function ArrayEditor<T>({
<div> <div>
{(label || (collapsible && items.length > 1)) && ( {(label || (collapsible && items.length > 1)) && (
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
{label ? <h3 className="text-sm font-medium text-neutral-300">{label}</h3> : <div />} {label ? <h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{label}</h3> : <div />}
{collapsible && items.length > 1 && (() => { {collapsible && items.length > 1 && (() => {
const allCollapsed = collapsed.size >= items.length; const allCollapsed = collapsed.size >= items.length;
return ( return (
<button <button
type="button" type="button"
onClick={() => allCollapsed ? setCollapsed(new Set()) : setCollapsed(new Set(items.map((_, i) => i)))} onClick={() => allCollapsed ? setCollapsed(new Set()) : setCollapsed(new Set(items.map((_, i) => i)))}
className="rounded p-1 text-neutral-500 hover:text-white transition-colors" className="rounded p-1 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
title={allCollapsed ? "Развернуть все" : "Свернуть все"} title={allCollapsed ? "Развернуть все" : "Свернуть все"}
aria-label={allCollapsed ? "Развернуть все" : "Свернуть все"} aria-label={allCollapsed ? "Развернуть все" : "Свернуть все"}
> >
@@ -385,6 +404,7 @@ export function ArrayEditor<T>({
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
stableKeysRef.current = [nextItemId++, ...stableKeysRef.current];
onChange([createItem(), ...items]); onChange([createItem(), ...items]);
setNewItemIndex(0); setNewItemIndex(0);
// Shift collapsed indices and ensure new item is expanded // Shift collapsed indices and ensure new item is expanded
@@ -394,7 +414,7 @@ export function ArrayEditor<T>({
return next; return next;
}); });
}} }}
className="mb-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" 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} /> <Plus size={16} />
{addLabel} {addLabel}
@@ -409,11 +429,12 @@ export function ArrayEditor<T>({
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
stableKeysRef.current.push(nextItemId++);
onChange([...items, createItem()]); onChange([...items, createItem()]);
setNewItemIndex(items.length); setNewItemIndex(items.length);
setCollapsed(prev => { const next = new Set(prev); next.delete(items.length); return next; }); 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-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors" 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} /> <Plus size={16} />
{addLabel} {addLabel}
@@ -432,9 +453,9 @@ export function ArrayEditor<T>({
height: dragSize.h, height: dragSize.h,
}} }}
> >
<div className="h-full rounded-lg border-2 border-gold/60 bg-neutral-900/95 shadow-2xl shadow-gold/20 flex items-center gap-3 px-4"> <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" /> <GripVertical size={16} className="text-gold shrink-0" />
<span className="text-sm text-neutral-300">{collapsible && dragIndex !== null ? (getItemTitle?.(items[dragIndex], dragIndex) || "Перемещение...") : "Перемещение элемента..."}</span> <span className="text-sm text-neutral-700 dark:text-neutral-300">{collapsible && dragIndex !== null ? (getItemTitle?.(items[dragIndex], dragIndex) || "Перемещение...") : "Перемещение элемента..."}</span>
</div> </div>
</div>, </div>,
document.body document.body
@@ -29,15 +29,15 @@ export function CollapsibleSection({
const toggle = onToggle ?? (() => setInternalOpen((v) => !v)); const toggle = onToggle ?? (() => setInternalOpen((v) => !v));
return ( return (
<div className="rounded-xl border border-white/10 bg-neutral-900/30 overflow-hidden"> <div className="rounded-xl border border-neutral-200 bg-neutral-100/50 overflow-hidden dark:border-white/10 dark:bg-neutral-800/50">
<button <button
type="button" type="button"
onClick={toggle} onClick={toggle}
aria-expanded={open} aria-expanded={open}
className="flex items-center justify-between w-full px-5 py-3.5 text-left cursor-pointer group hover:bg-white/[0.02] transition-colors" 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"> <div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-neutral-200 group-hover:text-white transition-colors"> <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} {title}
</h3> </h3>
{count !== undefined && ( {count !== undefined && (
@@ -3,6 +3,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { AlertTriangle, X } from "lucide-react"; import { AlertTriangle, X } from "lucide-react";
import { useFocusTrap } from "@/hooks/useFocusTrap";
interface ConfirmDialogProps { interface ConfirmDialogProps {
open: boolean; open: boolean;
@@ -26,6 +27,7 @@ export function ConfirmDialog({
destructive = true, destructive = true,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
const cancelRef = useRef<HTMLButtonElement>(null); const cancelRef = useRef<HTMLButtonElement>(null);
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -41,6 +43,7 @@ export function ConfirmDialog({
return createPortal( return createPortal(
<div <div
ref={focusTrapRef}
className="fixed inset-0 z-[60] flex items-center justify-center p-4" className="fixed inset-0 z-[60] flex items-center justify-center p-4"
role="alertdialog" role="alertdialog"
aria-modal="true" aria-modal="true"
+10 -1
View File
@@ -724,6 +724,7 @@ interface VictoryListFieldProps {
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate, onUploadComplete }: VictoryListFieldProps) { export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate, onUploadComplete }: VictoryListFieldProps) {
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null); const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
const [uploadError, setUploadError] = useState("");
function add() { function add() {
const val = draft.trim(); const val = draft.trim();
@@ -752,6 +753,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setUploadingIndex(index); setUploadingIndex(index);
setUploadError("");
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("folder", "team"); formData.append("folder", "team");
@@ -761,8 +763,12 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
if (result.path) { if (result.path) {
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item))); onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
onUploadComplete?.(); onUploadComplete?.();
} else {
setUploadError(result.error || "Ошибка загрузки");
} }
} catch { /* upload failed */ } finally { } catch {
setUploadError("Не удалось загрузить файл");
} finally {
setUploadingIndex(null); setUploadingIndex(null);
} }
} }
@@ -833,6 +839,9 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
</button> </button>
</div> </div>
</div> </div>
{uploadError && (
<p role="alert" className="mt-1.5 text-xs text-red-400">{uploadError}</p>
)}
</div> </div>
); );
} }
+10 -1
View File
@@ -34,6 +34,7 @@ export function ImageCropField({
label = "Фото", label = "Фото",
}: ImageCropFieldProps) { }: ImageCropFieldProps) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState("");
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const dragStartRef = useRef({ x: 0, y: 0, startFocalX: 0, startFocalY: 0 }); const dragStartRef = useRef({ x: 0, y: 0, startFocalX: 0, startFocalY: 0 });
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -42,6 +43,7 @@ export function ImageCropField({
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setUploading(true); setUploading(true);
setUploadError("");
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("folder", folder); formData.append("folder", folder);
@@ -53,8 +55,12 @@ export function ImageCropField({
const result = await res.json(); const result = await res.json();
if (result.path) { if (result.path) {
onChange({ image: result.path, focalX: 50, focalY: 50, zoom: 1 }); onChange({ image: result.path, focalX: 50, focalY: 50, zoom: 1 });
} else {
setUploadError(result.error || "Ошибка загрузки");
} }
} catch { /* upload failed */ } finally { } catch {
setUploadError("Не удалось загрузить файл");
} finally {
setUploading(false); setUploading(false);
} }
} }
@@ -170,6 +176,9 @@ export function ImageCropField({
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" /> <input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
</label> </label>
)} )}
{uploadError && (
<p role="alert" className="mt-1.5 text-xs text-red-400">{uploadError}</p>
)}
</div> </div>
); );
} }
+2 -1
View File
@@ -16,9 +16,10 @@ export function PriceField({ label, value, onChange, placeholder = "0" }: PriceF
<div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors"> <div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors">
<input <input
type="text" type="text"
inputMode="decimal"
value={raw} value={raw}
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value.replace(/[^\d.,\s]/g, "");
onChange(v ? `${v} BYN` : ""); onChange(v ? `${v} BYN` : "");
}} }}
placeholder={placeholder} placeholder={placeholder}
+10 -4
View File
@@ -24,11 +24,13 @@ export function SectionEditor<T>({
}: SectionEditorProps<T>) { }: SectionEditorProps<T>) {
const [data, setData] = useState<T | null>(null); const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true); 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 [error, setError] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialLoadRef = useRef(true); const initialLoadRef = useRef(true);
const pendingSaveRef = useRef(false); const pendingSaveRef = useRef(false);
const defaultDataRef = useRef(defaultData);
defaultDataRef.current = defaultData;
useEffect(() => { useEffect(() => {
adminFetch(`/api/admin/sections/${sectionKey}`) adminFetch(`/api/admin/sections/${sectionKey}`)
@@ -36,7 +38,7 @@ export function SectionEditor<T>({
if (!r.ok) throw new Error("Failed to load"); if (!r.ok) throw new Error("Failed to load");
return r.json(); return r.json();
}) })
.then((loaded) => setData(defaultData ? { ...defaultData, ...loaded } as T : loaded)) .then((loaded) => setData(defaultDataRef.current ? { ...defaultDataRef.current, ...loaded } as T : loaded))
.catch(() => setError("Не удалось загрузить данные")) .catch(() => setError("Не удалось загрузить данные"))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [sectionKey]); }, [sectionKey]);
@@ -72,7 +74,10 @@ export function SectionEditor<T>({
pendingSaveRef.current = true; pendingSaveRef.current = true;
if (timerRef.current) clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => { timerRef.current = setTimeout(() => {
if (validate && !validate(data)) return; if (validate && !validate(data)) {
setStatus("invalid");
return;
}
save(data); save(data);
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
@@ -134,7 +139,7 @@ export function SectionEditor<T>({
<h1 className="text-2xl font-bold">{title}</h1> <h1 className="text-2xl font-bold">{title}</h1>
{/* Fixed toast popup */} {/* Fixed toast popup */}
{(status === "saved" || status === "error") && ( {(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 ${ <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" status === "saved"
? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200" ? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
@@ -142,6 +147,7 @@ export function SectionEditor<T>({
}`}> }`}>
{status === "saved" && <><Check size={14} /> Сохранено</>} {status === "saved" && <><Check size={14} /> Сохранено</>}
{status === "error" && <><AlertCircle size={14} /> {error}</>} {status === "error" && <><AlertCircle size={14} /> {error}</>}
{status === "invalid" && <><AlertCircle size={14} /> Не сохранено исправьте ошибки</>}
</div> </div>
)} )}
+49 -45
View File
@@ -1,9 +1,10 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useMemo } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { X, ChevronDown } from "lucide-react"; import { X, ChevronDown } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import { formatBelarusPhone, SHORT_DAYS } from "@/lib/formatting";
type Tab = "classes" | "events"; type Tab = "classes" | "events";
type EventType = "master-class" | "open-day"; type EventType = "master-class" | "open-day";
@@ -11,7 +12,7 @@ type EventType = "master-class" | "open-day";
interface McOption { title: string; date: string } interface McOption { title: string; date: string }
interface OdClass { id: number; style: string; start_time: string; hall: string; trainer: string } interface OdClass { id: number; style: string; start_time: string; hall: string; trainer: string }
interface OdEvent { id: number; date: string; title?: string } interface OdEvent { id: number; date: string; title?: string }
interface ScheduleClass { type: string; trainer: string; time: string; day: string; hall: string } interface ScheduleClass { type: string; trainer: string; time: string; day: string; hall: string; groupId?: string }
function shortName(fullName: string) { function shortName(fullName: string) {
const parts = fullName.trim().split(/\s+/); const parts = fullName.trim().split(/\s+/);
@@ -19,10 +20,6 @@ function shortName(fullName: string) {
return parts.length > 1 ? `${parts[1]} ${parts[0][0]}.` : parts[0]; return parts.length > 1 ? `${parts[1]} ${parts[0][0]}.` : parts[0];
} }
const SHORT_DAYS: Record<string, string> = {
"Понедельник": "Пн", "Вторник": "Вт", "Среда": "Ср",
"Четверг": "Чт", "Пятница": "Пт", "Суббота": "Сб", "Воскресенье": "Вс",
};
// --- Searchable dropdown --- // --- Searchable dropdown ---
@@ -42,7 +39,13 @@ function SearchSelect({ options, value, onChange, placeholder }: {
const selected = options.find((o) => o.value === value); const selected = options.find((o) => o.value === value);
const filtered = search const filtered = search
? options.filter((o) => o.label.toLowerCase().includes(search.toLowerCase())) ? (() => {
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; : options;
useEffect(() => { useEffect(() => {
@@ -98,7 +101,7 @@ function SearchSelect({ options, value, onChange, placeholder }: {
{open && ( {open && (
<div className="absolute z-20 mt-1 w-full rounded-lg border border-white/[0.08] shadow-xl overflow-hidden" style={{ backgroundColor: "#141414" }}> <div className="absolute z-20 mt-1 w-full rounded-lg border border-white/[0.08] shadow-xl overflow-hidden" style={{ backgroundColor: "#141414" }}>
<div className="max-h-48 overflow-y-auto styled-scrollbar"> <div className="max-h-48 overflow-y-scroll admin-scrollbar">
{filtered.length === 0 && ( {filtered.length === 0 && (
<p className="px-3 py-2 text-xs text-neutral-500">Ничего не найдено</p> <p className="px-3 py-2 text-xs text-neutral-500">Ничего не найдено</p>
)} )}
@@ -145,32 +148,24 @@ export function AddBookingModal({
const [odEventId, setOdEventId] = useState<number | null>(null); const [odEventId, setOdEventId] = useState<number | null>(null);
const [odClassId, setOdClassId] = useState(""); const [odClassId, setOdClassId] = useState("");
const [scheduleClasses, setScheduleClasses] = useState<ScheduleClass[]>([]); const [scheduleClasses, setScheduleClasses] = useState<ScheduleClass[]>([]);
const [classInfo, setClassInfo] = useState(""); const [classGroup, setClassGroup] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassInfo(""); setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassGroup("");
// Fetch schedule classes // 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 }[] }[] }[] }) => { 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[] = []; const classes: ScheduleClass[] = [];
for (const loc of data.locations || []) { for (const loc of data.locations || []) {
for (const day of loc.days) { for (const day of loc.days) {
for (const cls of day.classes) { for (const cls of day.classes) {
classes.push({ type: cls.type, trainer: cls.trainer, time: cls.time, day: day.day, hall: loc.name }); classes.push({ type: cls.type, trainer: cls.trainer, time: cls.time, day: day.day, hall: loc.name, groupId: cls.groupId });
} }
} }
} }
// Deduplicate by type+trainer+time+day+hall setScheduleClasses(classes);
const seen = new Set<string>();
const unique = classes.filter((c) => {
const key = `${c.type}|${c.trainer}|${c.time}|${c.day}|${c.hall}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
setScheduleClasses(unique);
}).catch(() => {}); }).catch(() => {});
// Fetch upcoming MCs // Fetch upcoming MCs
@@ -209,27 +204,35 @@ export function AddBookingModal({
}, [open, onClose]); }, [open, onClose]);
function handlePhoneChange(raw: string) { function handlePhoneChange(raw: string) {
let digits = raw.replace(/\D/g, ""); setPhone(formatBelarusPhone(raw));
if (!digits.startsWith("375")) digits = "375" + digits.replace(/^375?/, "");
digits = digits.slice(0, 12);
let formatted = "+375";
const rest = digits.slice(3);
if (rest.length > 0) formatted += " (" + rest.slice(0, 2);
if (rest.length >= 2) formatted += ") ";
if (rest.length > 2) formatted += rest.slice(2, 5);
if (rest.length > 5) formatted += "-" + rest.slice(5, 7);
if (rest.length > 7) formatted += "-" + rest.slice(7, 9);
setPhone(formatted);
} }
const hasUpcomingMc = mcOptions.length > 0; const hasUpcomingMc = mcOptions.length > 0;
const hasOpenDay = odEventId !== null && odClasses.length > 0; const hasOpenDay = odEventId !== null && odClasses.length > 0;
// Build options for each dropdown // Flat group options: one searchable dropdown
const classOptions: SearchSelectOption[] = scheduleClasses.map((c, i) => ({ const classGroupOptions = useMemo((): SearchSelectOption[] => {
value: String(i), const byKey = new Map<string, { type: string; trainer: string; hall: string; slots: { day: string; time: string }[]; id: string }>();
label: `${shortName(c.trainer)}${c.type} · ${SHORT_DAYS[c.day] || c.day} ${c.time} · ${c.hall}`, 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) => ({ const mcSelectOptions: SearchSelectOption[] = mcOptions.map((mc) => ({
value: mc.title, value: mc.title,
@@ -246,9 +249,8 @@ export function AddBookingModal({
setSaving(true); setSaving(true);
try { try {
if (tab === "classes") { if (tab === "classes") {
const selectedClass = classInfo ? scheduleClasses[Number(classInfo)] : null; const groupInfo = classGroup
const groupInfo = selectedClass ? classGroupOptions.find((o) => o.value === classGroup)?.label
? `${selectedClass.type}, ${shortName(selectedClass.trainer)}, ${SHORT_DAYS[selectedClass.day] || selectedClass.day} ${selectedClass.time}, ${selectedClass.hall}`
: undefined; : undefined;
await adminFetch("/api/admin/group-bookings", { await adminFetch("/api/admin/group-bookings", {
method: "POST", method: "POST",
@@ -277,6 +279,8 @@ export function AddBookingModal({
} }
onAdded(); onAdded();
onClose(); onClose();
} catch {
alert("Не удалось создать запись. Попробуйте ещё раз.");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -335,12 +339,12 @@ export function AddBookingModal({
</div> </div>
{/* Class selector (optional for Занятие) */} {/* Class selector (optional for Занятие) */}
{tab === "classes" && classOptions.length > 0 && ( {tab === "classes" && classGroupOptions.length > 0 && (
<SearchSelect <SearchSelect
options={classOptions} options={classGroupOptions}
value={classInfo} value={classGroup}
onChange={setClassInfo} onChange={setClassGroup}
placeholder="Выберите занятие (необязательно)" placeholder="Группа (необязательно)"
/> />
)} )}
+19 -19
View File
@@ -47,17 +47,17 @@ export function DeleteBtn({ onClick, name }: { onClick: () => void; name?: strin
{confirming && createPortal( {confirming && createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirming(false)}> <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="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-xs rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}> <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-white/[0.06] hover:text-white"> <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} /> <X size={16} />
</button> </button>
<h3 className="text-sm font-bold text-white">Удалить запись?</h3> <h3 className="text-sm font-bold text-neutral-900 dark:text-white">Удалить запись?</h3>
{name && <p className="mt-1 text-xs text-neutral-400">{name}</p>} {name && <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{name}</p>}
<p className="mt-2 text-xs text-neutral-500">Это действие нельзя отменить.</p> <p className="mt-2 text-xs text-neutral-400 dark:text-neutral-500">Это действие нельзя отменить.</p>
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">
<button <button
onClick={() => setConfirming(false)} onClick={() => setConfirming(false)}
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 py-2 text-xs font-medium text-neutral-300 hover:bg-neutral-700 transition-colors" className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 py-2 text-xs font-medium text-neutral-700 hover:bg-neutral-200 transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
> >
Отмена Отмена
</button> </button>
@@ -80,17 +80,17 @@ export function ContactLinks({ phone, instagram, telegram }: { phone?: string; i
return ( return (
<> <>
{phone && ( {phone && (
<a href={`tel:${phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs"> <a href={`tel:${phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-500 dark:text-emerald-400 dark:hover:text-emerald-300 text-xs">
<Phone size={10} />{phone} <Phone size={10} />{phone}
</a> </a>
)} )}
{instagram && ( {instagram && (
<a href={`https://ig.me/m/${instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs"> <a href={`https://ig.me/m/${instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-600 hover:text-pink-500 dark:text-pink-400 dark:hover:text-pink-300 text-xs">
<Instagram size={10} />{instagram} <Instagram size={10} />{instagram}
</a> </a>
)} )}
{telegram && ( {telegram && (
<a href={`https://t.me/${telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs"> <a href={`https://t.me/${telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 text-xs">
<Send size={10} />{telegram} <Send size={10} />{telegram}
</a> </a>
)} )}
@@ -109,7 +109,7 @@ export function FilterTabs({ filter, counts, total, onFilter }: {
<button <button
onClick={() => onFilter("all")} onClick={() => onFilter("all")}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${ className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white" filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-100 text-neutral-500 border border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
}`} }`}
> >
Все <span className="text-neutral-500 ml-1">{total}</span> Все <span className="text-neutral-500 ml-1">{total}</span>
@@ -119,7 +119,7 @@ export function FilterTabs({ filter, counts, total, onFilter }: {
key={s.key} key={s.key}
onClick={() => onFilter(s.key)} onClick={() => onFilter(s.key)}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${ className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white" filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-100 text-neutral-500 border border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
}`} }`}
> >
{s.label} {s.label}
@@ -147,14 +147,14 @@ export function StatusActions({ status, onStatus }: { status: BookingStatus; onS
); );
return ( return (
<div className="flex gap-1 ml-auto"> <div className="flex gap-1 ml-auto">
{status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20")} {status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/30 hover:bg-blue-500/20")}
{status === "contacted" && ( {status === "contacted" && (
<> <>
{actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20")} {actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20")}
{actionBtn("Отказ", () => onStatus("declined"), "bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20")} {actionBtn("Отказ", () => onStatus("declined"), "bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/30 hover:bg-red-500/20")}
</> </>
)} )}
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", () => onStatus("contacted"), "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300")} {(status === "confirmed" || status === "declined") && actionBtn("Вернуть", () => onStatus("contacted"), "bg-neutral-100 text-neutral-600 border border-neutral-300 hover:border-neutral-400 hover:text-neutral-800 dark:bg-neutral-800/50 dark:text-neutral-500 dark:border-transparent dark:hover:border-white/10 dark:hover:text-neutral-300")}
</div> </div>
); );
} }
@@ -163,10 +163,10 @@ export function BookingCard({ status, highlight, children }: { status: BookingSt
return ( return (
<div <div
className={`rounded-lg border p-3 transition-all duration-200 cursor-default ${ className={`rounded-lg border p-3 transition-all duration-200 cursor-default ${
status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50 hover:opacity-70 hover:border-red-500/30" status === "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/15 bg-emerald-500/[0.02] hover:border-emerald-500/30 hover:bg-emerald-500/[0.05]" : 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/20 bg-gold/[0.03] hover:border-gold/40 hover:bg-gold/[0.06]" : 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-white/10 bg-neutral-800/30 hover:border-white/20 hover:bg-neutral-800/50" : "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]" : ""}`} }${highlight ? " ring-2 ring-gold/40 animate-[pulse_1s_ease-in-out_1]" : ""}`}
> >
{children} {children}
+10 -10
View File
@@ -109,7 +109,7 @@ export function GenericBookingsList<T extends BaseBooking>({
<BookingCard status={item.status} highlight={isHighlighted}> <BookingCard status={item.status} highlight={isHighlighted}>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0"> <div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
<span className="font-medium text-white truncate max-w-[200px]">{item.name}</span> <span className="font-medium text-neutral-900 dark:text-white truncate max-w-[200px]">{item.name}</span>
<ContactLinks phone={item.phone} instagram={item.instagram} telegram={item.telegram} /> <ContactLinks phone={item.phone} instagram={item.instagram} telegram={item.telegram} />
{renderExtra?.(item)} {renderExtra?.(item)}
</div> </div>
@@ -144,32 +144,32 @@ export function GenericBookingsList<T extends BaseBooking>({
const groupCounts = { new: 0, contacted: 0, confirmed: 0, declined: 0 }; const groupCounts = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
for (const item of group.items) groupCounts[item.status] = (groupCounts[item.status] || 0) + 1; for (const item of group.items) groupCounts[item.status] = (groupCounts[item.status] || 0) + 1;
return ( return (
<div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-white/5 opacity-60" : "border-white/10"}`}> <div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-neutral-100 opacity-60 dark:border-white/5" : "border-neutral-200 dark:border-white/10"}`}>
<button <button
onClick={() => setExpanded((p) => ({ ...p, [group.key]: !isOpen }))} onClick={() => setExpanded((p) => ({ ...p, [group.key]: !isOpen }))}
className={`w-full flex items-center gap-3 px-4 py-3 transition-colors text-left ${group.isArchived ? "bg-neutral-900/50 hover:bg-neutral-800/50" : "bg-neutral-900 hover:bg-neutral-800/80"}`} className={`w-full flex items-center gap-3 px-4 py-3 transition-colors text-left ${group.isArchived ? "bg-neutral-100/50 hover:bg-neutral-200/50 dark:bg-neutral-900/50 dark:hover:bg-neutral-800/50" : "bg-neutral-50 hover:bg-neutral-200/80 dark:bg-neutral-900 dark:hover:bg-neutral-800/80"}`}
> >
{isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />} {isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
{group.sublabel && ( {group.sublabel && (
<span className={`text-xs font-medium shrink-0 ${group.isArchived ? "text-neutral-500" : "text-gold"}`}>{group.sublabel}</span> <span className={`text-xs font-medium shrink-0 ${group.isArchived ? "text-neutral-500" : "text-gold"}`}>{group.sublabel}</span>
)} )}
<span className={`font-medium text-sm truncate ${group.isArchived ? "text-neutral-400" : "text-white"}`}>{group.label}</span> <span className={`font-medium text-sm truncate ${group.isArchived ? "text-neutral-500 dark:text-neutral-400" : "text-neutral-900 dark:text-white"}`}>{group.label}</span>
{group.dateBadge && ( {group.dateBadge && (
<span className={`text-[10px] rounded-full px-2 py-0.5 shrink-0 ${ <span className={`text-[10px] rounded-full px-2 py-0.5 shrink-0 ${
group.isArchived ? "text-neutral-600 bg-neutral-800 line-through" : "text-gold bg-gold/10" group.isArchived ? "text-neutral-600 bg-neutral-800 line-through" : "text-amber-700 dark:text-gold bg-gold/10"
}`}> }`}>
{group.dateBadge} {group.dateBadge}
</span> </span>
)} )}
{group.isArchived && ( {group.isArchived && (
<span className="text-[10px] text-neutral-600 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">архив</span> <span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 shrink-0 dark:text-neutral-600 dark:bg-neutral-800">архив</span>
)} )}
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">{group.items.length} чел.</span> <span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 shrink-0 dark:bg-neutral-800">{group.items.length} чел.</span>
{!group.isArchived && ( {!group.isArchived && (
<div className="flex gap-2 ml-auto text-[10px]"> <div className="flex gap-2 ml-auto text-[10px]">
{groupCounts.new > 0 && <span className="text-gold">{groupCounts.new} новых</span>} {groupCounts.new > 0 && <span className="text-amber-700 dark:text-gold">{groupCounts.new} новых</span>}
{groupCounts.contacted > 0 && <span className="text-blue-400">{groupCounts.contacted} связ.</span>} {groupCounts.contacted > 0 && <span className="text-blue-600 dark:text-blue-400">{groupCounts.contacted} связ.</span>}
{groupCounts.confirmed > 0 && <span className="text-emerald-400">{groupCounts.confirmed} подтв.</span>} {groupCounts.confirmed > 0 && <span className="text-emerald-600 dark:text-emerald-400">{groupCounts.confirmed} подтв.</span>}
</div> </div>
)} )}
</button> </button>
+59 -54
View File
@@ -161,43 +161,43 @@ function ConfirmModal({
if (!open) return null; if (!open) return null;
const selectClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 [color-scheme:dark] disabled:opacity-30 disabled:cursor-not-allowed"; const selectClass = "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 [color-scheme:light] disabled:opacity-30 disabled:cursor-not-allowed dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:[color-scheme:dark]";
return createPortal( return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" onClick={onClose}> <div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" onClick={onClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" /> <div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}> <div className="relative w-full max-w-sm rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose} aria-label="Закрыть" className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white"> <button onClick={onClose} aria-label="Закрыть" className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white">
<X size={16} /> <X size={16} />
</button> </button>
<h3 className="text-base font-bold text-white">Подтвердить запись</h3> <h3 className="text-base font-bold text-neutral-900 dark:text-white">Подтвердить запись</h3>
<p className="mt-1 text-xs text-neutral-400">{bookingName}</p> <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{bookingName}</p>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<div> <div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Зал</label> <label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Зал</label>
<select value={hall} onChange={(e) => setHall(e.target.value)} className={selectClass}> <select value={hall} onChange={(e) => setHall(e.target.value)} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите зал</option> <option value="" className="bg-white dark:bg-neutral-900">Выберите зал</option>
{halls.map((h) => <option key={h} value={h} className="bg-neutral-900">{h}</option>)} {halls.map((h) => <option key={h} value={h} className="bg-white dark:bg-neutral-900">{h}</option>)}
</select> </select>
</div> </div>
<div> <div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Тренер</label> <label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Тренер</label>
<select value={trainer} onChange={(e) => setTrainer(e.target.value)} disabled={!hall} className={selectClass}> <select value={trainer} onChange={(e) => setTrainer(e.target.value)} disabled={!hall} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите тренера</option> <option value="" className="bg-white dark:bg-neutral-900">Выберите тренера</option>
{trainers.map((t) => <option key={t} value={t} className="bg-neutral-900">{t}</option>)} {trainers.map((t) => <option key={t} value={t} className="bg-white dark:bg-neutral-900">{t}</option>)}
</select> </select>
</div> </div>
<div> <div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Группа</label> <label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Группа</label>
<select value={group} onChange={(e) => setGroup(e.target.value)} disabled={!trainer} className={selectClass}> <select value={group} onChange={(e) => setGroup(e.target.value)} disabled={!trainer} className={selectClass}>
<option value="" className="bg-neutral-900">Выберите группу</option> <option value="" className="bg-white dark:bg-neutral-900">Выберите группу</option>
{groups.map((g) => <option key={g.value} value={g.value} className="bg-neutral-900">{g.label}</option>)} {groups.map((g) => <option key={g.value} value={g.value} className="bg-white dark:bg-neutral-900">{g.label}</option>)}
</select> </select>
</div> </div>
<div> <div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Дата занятия</label> <label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Дата занятия</label>
<input <input
type="date" type="date"
value={date} value={date}
@@ -212,14 +212,14 @@ function ConfirmModal({
)} )}
</div> </div>
<div> <div>
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Комментарий <span className="text-neutral-600">(необязательно)</span></label> <label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
<input <input
type="text" type="text"
value={comment} value={comment}
disabled={!group} disabled={!group}
onChange={(e) => setComment(e.target.value)} onChange={(e) => setComment(e.target.value)}
placeholder="Первое занятие, пробный" placeholder="Первое занятие, пробный"
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40 disabled:opacity-30 disabled:cursor-not-allowed" className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold/40 disabled:opacity-30 disabled:cursor-not-allowed dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500"
/> />
</div> </div>
</div> </div>
@@ -283,18 +283,23 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
...b, status: "confirmed" as BookingStatus, ...b, status: "confirmed" as BookingStatus,
confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes, confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes,
} : b)); } : b));
await Promise.all([ try {
adminFetch("/api/admin/group-bookings", { await Promise.all([
method: "PUT", adminFetch("/api/admin/group-bookings", {
headers: { "Content-Type": "application/json" }, method: "PUT",
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }), headers: { "Content-Type": "application/json" },
}), body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }),
data.comment ? adminFetch("/api/admin/group-bookings", { }),
method: "PUT", data.comment ? adminFetch("/api/admin/group-bookings", {
headers: { "Content-Type": "application/json" }, method: "PUT",
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }), headers: { "Content-Type": "application/json" },
}) : Promise.resolve(), body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
]); }) : Promise.resolve(),
]);
} catch {
// Revert optimistic update on failure
setBookings((prev) => prev.map((b) => b.id === confirmingId ? { ...b, ...existing } : b));
}
setConfirmingId(null); setConfirmingId(null);
onDataChange?.(); onDataChange?.();
} }
@@ -313,8 +318,8 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
onConfirm={(id) => setConfirmingId(id)} onConfirm={(id) => setConfirmingId(id)}
renderExtra={(b) => ( renderExtra={(b) => (
<> <>
{b.groupInfo && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>} {b.groupInfo && <span className="text-xs text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:text-neutral-400 dark:bg-neutral-800">{b.groupInfo}</span>}
{b.confirmedHall && <span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{b.confirmedHall}</span>} {b.confirmedHall && <span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:bg-neutral-800">{b.confirmedHall}</span>}
{(b.confirmedGroup || b.confirmedDate) && ( {(b.confirmedGroup || b.confirmedDate) && (
<button <button
onClick={(e) => { e.stopPropagation(); setConfirmingId(b.id); }} onClick={(e) => { e.stopPropagation(); setConfirmingId(b.id); }}
@@ -466,11 +471,11 @@ function RemindersTab() {
: currentStatus === "coming" ? "border-emerald-500/15 bg-emerald-500/[0.02]" : currentStatus === "coming" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
: currentStatus === "cancelled" ? "border-red-500/15 bg-red-500/[0.02] opacity-50" : currentStatus === "cancelled" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
: currentStatus === "pending" ? "border-amber-500/15 bg-amber-500/[0.02]" : currentStatus === "pending" ? "border-amber-500/15 bg-amber-500/[0.02]"
: "border-white/5 bg-neutral-800/30" : "border-neutral-200 bg-neutral-100/30 dark:border-white/5 dark:bg-neutral-800/30"
}`} }`}
> >
<div className="flex items-center gap-2 flex-wrap text-sm"> <div className="flex items-center gap-2 flex-wrap text-sm">
<span className="font-medium text-white">{item.name}</span> <span className="font-medium text-neutral-900 dark:text-white">{item.name}</span>
{item.phone && ( {item.phone && (
<a href={`tel:${item.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs"> <a href={`tel:${item.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
<Phone size={10} />{item.phone} <Phone size={10} />{item.phone}
@@ -537,11 +542,11 @@ function RemindersTab() {
const TypeIcon = typeConf.icon; const TypeIcon = typeConf.icon;
const egStats = countByStatus(eg.items); const egStats = countByStatus(eg.items);
return ( return (
<div key={eg.label} className="rounded-xl border border-white/10 overflow-hidden"> <div key={eg.label} className="rounded-xl border border-neutral-200 overflow-hidden dark:border-white/10">
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-900"> <div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-50 dark:bg-neutral-900">
<TypeIcon size={13} className={typeConf.color} /> <TypeIcon size={13} className={typeConf.color} />
<span className="text-sm font-medium text-white">{eg.label}{eg.items[0]?.eventHall ? ` · ${eg.items[0].eventHall}` : ""}</span> <span className="text-sm font-medium text-neutral-900 dark:text-white">{eg.label}{eg.items[0]?.eventHall ? ` · ${eg.items[0].eventHall}` : ""}</span>
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{eg.items.length} чел.</span> <span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:bg-neutral-800">{eg.items.length} чел.</span>
<div className="flex gap-2 ml-auto text-[10px]"> <div className="flex gap-2 ml-auto text-[10px]">
{egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>} {egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>}
{egStats.cancelled > 0 && <span className="text-red-400">{egStats.cancelled} не придёт</span>} {egStats.cancelled > 0 && <span className="text-red-400">{egStats.cancelled} не придёт</span>}
@@ -667,15 +672,15 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter, activeTab, act
if (c.tab === "reminders") { if (c.tab === "reminders") {
const total = counts.remindersToday + counts.remindersTomorrow; const total = counts.remindersToday + counts.remindersTomorrow;
if (total === 0) return ( if (total === 0) return (
<div key={c.tab} className="rounded-xl border border-white/5 bg-neutral-900/50 p-3 opacity-40"> <div key={c.tab} className="rounded-xl border border-neutral-100 bg-neutral-50 p-3 opacity-40 dark:border-white/5 dark:bg-neutral-900/50">
<p className="text-xs text-neutral-500">{c.label}</p> <p className="text-xs text-neutral-500">{c.label}</p>
<p className="text-lg font-bold text-neutral-600 mt-1"></p> <p className="text-lg font-bold text-neutral-400 mt-1 dark:text-neutral-600"></p>
</div> </div>
); );
return ( return (
<button key={c.tab} onClick={() => onNavigate(c.tab)} <button key={c.tab} onClick={() => onNavigate(c.tab)}
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}> className={`rounded-xl border ${c.color} bg-neutral-50 p-3 text-left transition-all hover:bg-neutral-100 hover:scale-[1.02] dark:bg-neutral-900 dark:hover:bg-neutral-800/80`}>
<p className="text-xs text-neutral-400">{c.label}</p> <p className="text-xs text-neutral-500 dark:text-neutral-400">{c.label}</p>
<div className="flex items-baseline gap-2 mt-1 flex-wrap"> <div className="flex items-baseline gap-2 mt-1 flex-wrap">
{counts.remindersNotAsked > 0 && ( {counts.remindersNotAsked > 0 && (
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all" <span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
@@ -713,20 +718,20 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter, activeTab, act
const tc = c.counts!; const tc = c.counts!;
const total = tc.new + tc.contacted + tc.confirmed + tc.declined; const total = tc.new + tc.contacted + tc.confirmed + tc.declined;
if (total === 0) return ( if (total === 0) return (
<div key={c.tab} className="rounded-xl border border-white/5 bg-neutral-900/50 p-3 opacity-40"> <div key={c.tab} className="rounded-xl border border-neutral-100 bg-neutral-50 p-3 opacity-40 dark:border-white/5 dark:bg-neutral-900/50">
<p className="text-xs text-neutral-500">{c.label}</p> <p className="text-xs text-neutral-500">{c.label}</p>
<p className="text-lg font-bold text-neutral-600 mt-1"></p> <p className="text-lg font-bold text-neutral-400 mt-1 dark:text-neutral-600"></p>
</div> </div>
); );
const isActiveCard = activeTab === c.tab; const isActiveCard = activeTab === c.tab;
const hl = (status: BookingFilter) => const hl = (status: BookingFilter) =>
isActiveCard && activeFilter === status isActiveCard && activeFilter === status
? "rounded-md bg-white/10 px-1.5 -mx-1.5 py-0.5 -my-0.5 ring-1 ring-white/20" ? "rounded-md bg-neutral-200 px-1.5 -mx-1.5 py-0.5 -my-0.5 ring-1 ring-neutral-300 dark:bg-white/10 dark:ring-white/20"
: ""; : "";
return ( return (
<button key={c.tab} onClick={() => { onNavigate(c.tab); onFilter("all"); }} <button key={c.tab} onClick={() => { onNavigate(c.tab); onFilter("all"); }}
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}> className={`rounded-xl border ${c.color} bg-neutral-50 p-3 text-left transition-all hover:bg-neutral-100 hover:scale-[1.02] dark:bg-neutral-900 dark:hover:bg-neutral-800/80`}>
<p className="text-xs text-neutral-400">{c.label}</p> <p className="text-xs text-neutral-500 dark:text-neutral-400">{c.label}</p>
<div className="flex items-baseline gap-2 mt-1 flex-wrap"> <div className="flex items-baseline gap-2 mt-1 flex-wrap">
{tc.new > 0 && ( {tc.new > 0 && (
<> <>
@@ -892,7 +897,7 @@ function BookingsPageInner() {
<button <button
onClick={() => setHallFilter("all")} onClick={() => setHallFilter("all")}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${ className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
hallFilter === "all" ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-white border border-transparent" hallFilter === "all" ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-neutral-900 border border-transparent dark:hover:text-white"
}`} }`}
> >
Все залы Все залы
@@ -902,7 +907,7 @@ function BookingsPageInner() {
key={hall} key={hall}
onClick={() => setHallFilter(hallFilter === hall ? "all" : hall)} onClick={() => setHallFilter(hallFilter === hall ? "all" : hall)}
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${ className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
hallFilter === hall ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-white border border-transparent" hallFilter === hall ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-neutral-900 border border-transparent dark:hover:text-white"
}`} }`}
> >
{hall} {hall}
@@ -924,10 +929,10 @@ function BookingsPageInner() {
<BookingCard key={`${r.type}-${r.id}`} status={r.status as BookingStatus}> <BookingCard key={`${r.type}-${r.id}`} status={r.status as BookingStatus}>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0"> <div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{TYPE_LABELS[r.type] || r.type}</span> <span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:bg-neutral-800">{TYPE_LABELS[r.type] || r.type}</span>
<span className="font-medium text-white">{r.name}</span> <span className="font-medium text-neutral-900 dark:text-white">{r.name}</span>
<ContactLinks phone={r.phone} instagram={r.instagram} telegram={r.telegram} /> <ContactLinks phone={r.phone} instagram={r.instagram} telegram={r.telegram} />
{r.groupLabel && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{r.groupLabel}</span>} {r.groupLabel && <span className="text-xs text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:text-neutral-400 dark:bg-neutral-800">{r.groupLabel}</span>}
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<span className="text-neutral-600 text-xs">{fmtDate(r.createdAt)}</span> <span className="text-neutral-600 text-xs">{fmtDate(r.createdAt)}</span>
@@ -955,20 +960,20 @@ function BookingsPageInner() {
<select <select
value={tab} value={tab}
onChange={(e) => setTab(e.target.value as Tab)} onChange={(e) => setTab(e.target.value as Tab)}
className="w-full rounded-lg border border-white/10 bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white outline-none focus:border-gold/40 transition-colors [color-scheme:dark]" className="w-full rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-900 outline-none focus:border-gold/40 transition-colors dark:border-white/10 dark:bg-neutral-900 dark:text-white dark:[color-scheme:dark]"
> >
{TABS.map((t) => ( {TABS.map((t) => (
<option key={t.key} value={t.key}>{t.label}</option> <option key={t.key} value={t.key}>{t.label}</option>
))} ))}
</select> </select>
</div> </div>
<div className="mt-5 hidden sm:flex border-b border-white/10"> <div className="mt-5 hidden sm:flex border-b border-neutral-200 dark:border-white/10">
{TABS.map((t) => ( {TABS.map((t) => (
<button <button
key={t.key} key={t.key}
onClick={() => setTab(t.key)} onClick={() => setTab(t.key)}
className={`shrink-0 px-4 py-2.5 text-sm font-medium transition-colors relative whitespace-nowrap ${ className={`shrink-0 px-4 py-2.5 text-sm font-medium transition-colors relative whitespace-nowrap ${
tab === t.key ? "text-gold" : "text-neutral-400 hover:text-white" tab === t.key ? "text-gold" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
}`} }`}
> >
{t.label} {t.label}
+17 -9
View File
@@ -1,3 +1,5 @@
import { SHORT_DAYS } from "@/lib/formatting";
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined"; export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
export type BookingFilter = "all" | BookingStatus; export type BookingFilter = "all" | BookingStatus;
@@ -12,20 +14,26 @@ export interface BaseBooking {
createdAt: string; createdAt: string;
} }
export const SHORT_DAYS: Record<string, string> = { export { SHORT_DAYS };
"Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ",
"Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС",
};
export const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [ export const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [
{ key: "new", label: "Новая", color: "text-gold", bg: "bg-gold/10", border: "border-gold/30" }, { key: "new", label: "Новая", color: "text-amber-700 dark:text-gold", bg: "bg-gold/10", border: "border-gold/30" },
{ key: "contacted", label: "Связались", color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" }, { key: "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-400", bg: "bg-emerald-500/10", border: "border-emerald-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-400", bg: "bg-red-500/10", border: "border-red-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 { export function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString("ru-RU"); const d = new Date(iso);
const now = new Date();
const sameYear = d.getFullYear() === now.getFullYear();
const date = d.toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
...(sameYear ? {} : { year: "numeric" }),
});
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
return `${date}, ${time}`;
} }
export function countStatuses(items: { status: string }[]): Record<string, number> { export function countStatuses(items: { status: string }[]): Record<string, number> {
+5 -5
View File
@@ -67,7 +67,7 @@ function VideoSlot({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-300">{label}</span> <span className="text-sm font-medium text-neutral-300">{label}</span>
{isCenter && ( {isCenter && (
<span className="inline-flex items-center gap-1 rounded-full bg-[#c9a96e]/15 px-2 py-0.5 text-[10px] font-medium text-[#c9a96e]"> <span className="inline-flex items-center gap-1 rounded-full bg-gold/15 px-2 py-0.5 text-[10px] font-medium text-gold">
<Smartphone size={10} /> <Smartphone size={10} />
мобильная версия мобильная версия
</span> </span>
@@ -79,7 +79,7 @@ function VideoSlot({
{src ? ( {src ? (
<div <div
className={`group relative overflow-hidden rounded-lg border ${ className={`group relative overflow-hidden rounded-lg border ${
isCenter ? "border-[#c9a96e]/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700" isCenter ? "border-gold/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700"
}`} }`}
onMouseEnter={() => videoRef.current?.play()} onMouseEnter={() => videoRef.current?.play()}
onMouseLeave={() => { videoRef.current?.pause(); }} onMouseLeave={() => { videoRef.current?.pause(); }}
@@ -104,7 +104,7 @@ function VideoSlot({
)} )}
</div> </div>
{isCenter && ( {isCenter && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-[#c9a96e]/90 px-2 py-0.5 text-[10px] font-bold text-black"> <div className="absolute top-2 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-gold/90 px-2 py-0.5 text-[10px] font-bold text-black">
<Star size={10} fill="currentColor" /> <Star size={10} fill="currentColor" />
MAIN MAIN
</div> </div>
@@ -128,7 +128,7 @@ function VideoSlot({
disabled={uploading} disabled={uploading}
className={`flex aspect-[9/16] w-full items-center justify-center rounded-lg border-2 border-dashed transition-colors disabled:opacity-50 ${ className={`flex aspect-[9/16] w-full items-center justify-center rounded-lg border-2 border-dashed transition-colors disabled:opacity-50 ${
isCenter isCenter
? "border-[#c9a96e]/30 text-[#c9a96e]/50 hover:border-[#c9a96e]/60 hover:text-[#c9a96e]" ? "border-gold/30 text-gold/50 hover:border-gold/60 hover:text-gold"
: "border-neutral-700 text-neutral-500 hover:border-neutral-500 hover:text-neutral-300" : "border-neutral-700 text-neutral-500 hover:border-neutral-500 hover:text-neutral-300"
}`} }`}
> >
@@ -164,7 +164,7 @@ function VideoSizeInfo({ totalSize, totalMb, rating }: { totalSize: number; tota
return ( return (
<button <button
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="w-full text-left rounded-lg bg-neutral-800/50 px-3 py-2 transition-colors hover:bg-neutral-800/80" className="w-full text-left rounded-lg bg-neutral-100/80 px-3 py-2 transition-colors hover:bg-neutral-200/80 dark:bg-neutral-800/50 dark:hover:bg-neutral-800/80"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-neutral-400">Общий вес: <span className={`font-medium ${rating.color}`}>{formatFileSize(totalSize)}</span></span> <span className="text-xs text-neutral-400">Общий вес: <span className={`font-medium ${rating.color}`}>{formatFileSize(totalSize)}</span></span>
+7 -5
View File
@@ -56,17 +56,19 @@ export default function AdminLayout({
const [unreadTotal, setUnreadTotal] = useState(0); const [unreadTotal, setUnreadTotal] = useState(0);
const isLoginPage = pathname === "/admin/login"; const isLoginPage = pathname === "/admin/login";
// Fetch unread counts — poll every 10s // Fetch unread counts — poll every 10s, stop after 3 consecutive failures
useEffect(() => { useEffect(() => {
if (isLoginPage) return; if (isLoginPage) return;
let failures = 0;
let interval: ReturnType<typeof setInterval>;
function fetchCounts() { function fetchCounts() {
adminFetch("/api/admin/unread-counts") adminFetch("/api/admin/unread-counts")
.then((r) => r.json()) .then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data: { total: number }) => setUnreadTotal(data.total)) .then((data: { total: number }) => { setUnreadTotal(data.total); failures = 0; })
.catch(() => {}); .catch(() => { failures++; if (failures >= 3 && interval) clearInterval(interval); });
} }
fetchCounts(); fetchCounts();
const interval = setInterval(fetchCounts, 10000); interval = setInterval(fetchCounts, 10000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [isLoginPage]); }, [isLoginPage]);
+53 -38
View File
@@ -63,7 +63,7 @@ function EventSettings({
onChange: (patch: Partial<OpenDayEvent>) => void; onChange: (patch: Partial<OpenDayEvent>) => void;
}) { }) {
return ( return (
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-4"> <div className="rounded-xl border border-neutral-200 bg-white p-5 space-y-4 dark:border-white/10 dark:bg-neutral-900">
<h2 className="text-lg font-bold flex items-center gap-2"> <h2 className="text-lg font-bold flex items-center gap-2">
<Calendar size={18} className="text-gold" /> <Calendar size={18} className="text-gold" />
Настройки мероприятия Настройки мероприятия
@@ -71,16 +71,16 @@ function EventSettings({
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<label className="block text-sm text-neutral-400 mb-1.5">Название</label> <label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">Название</label>
<input <input
type="text" type="text"
value={event.title} value={event.title}
onChange={(e) => onChange({ title: e.target.value })} onChange={(e) => onChange({ title: e.target.value })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors" className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
/> />
</div> </div>
<div> <div>
<label className="block text-sm text-neutral-400 mb-1.5">Дата</label> <label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">Дата</label>
<input <input
type="date" type="date"
value={event.date} value={event.date}
@@ -89,10 +89,10 @@ function EventSettings({
const isPast = newDate && newDate < new Date().toISOString().slice(0, 10); const isPast = newDate && newDate < new Date().toISOString().slice(0, 10);
onChange({ date: newDate, ...(isPast || !newDate ? { active: false } : {}) }); onChange({ date: newDate, ...(isPast || !newDate ? { active: false } : {}) });
}} }}
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white outline-none transition-colors [color-scheme:dark] ${ className={`w-full rounded-lg border bg-neutral-100 px-4 py-2.5 text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
event.date && event.date < new Date().toISOString().slice(0, 10) event.date && event.date < new Date().toISOString().slice(0, 10)
? "border-amber-500/50" ? "border-amber-500/50"
: "border-white/10 focus:border-gold" : "border-neutral-200 focus:border-gold dark:border-white/10"
}`} }`}
/> />
{!event.date && ( {!event.date && (
@@ -131,7 +131,7 @@ function EventSettings({
className={`flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${ className={`flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${
event.discountPrice > 0 event.discountPrice > 0
? "bg-gold/15 text-gold border border-gold/30" ? "bg-gold/15 text-gold border border-gold/30"
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white" : "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
}`} }`}
> >
<Sparkles size={14} /> <Sparkles size={14} />
@@ -147,12 +147,12 @@ function EventSettings({
/> />
</div> </div>
<div> <div>
<label className="block text-sm text-neutral-400 mb-1.5">От N занятий</label> <label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">От N занятий</label>
<input <input
type="number" type="number"
value={event.discountThreshold || ""} value={event.discountThreshold || ""}
onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 0 })} onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 0 })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors" className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
/> />
</div> </div>
</div> </div>
@@ -178,8 +178,8 @@ function EventSettings({
event.active event.active
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 cursor-pointer" ? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 cursor-pointer"
: !event.date || event.date < new Date().toISOString().slice(0, 10) : !event.date || event.date < new Date().toISOString().slice(0, 10)
? "bg-neutral-800 text-neutral-500 border border-white/5 opacity-50 cursor-not-allowed" ? "bg-neutral-100 text-neutral-400 border border-neutral-200 opacity-50 cursor-not-allowed dark:bg-neutral-800 dark:text-neutral-500 dark:border-white/5"
: "bg-neutral-800 text-neutral-400 border border-white/10 cursor-pointer hover:border-gold/40 hover:text-gold hover:bg-gold/5" : "bg-neutral-100 text-neutral-500 border border-neutral-200 cursor-pointer hover:border-gold/40 hover:text-gold hover:bg-gold/5 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10"
}`} }`}
> >
{event.active ? ( {event.active ? (
@@ -242,7 +242,7 @@ function NewClassForm({
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." /> <SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." /> <SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
<div className="flex gap-2 justify-end mt-2"> <div className="flex gap-2 justify-end mt-2">
<button onClick={onCancel} className="rounded-md border border-white/10 px-3 py-1 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">Отмена</button> <button onClick={onCancel} className="rounded-md border border-neutral-200 px-3 py-1 text-xs text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25">Отмена</button>
<button onClick={() => canSave && onSave({ trainer, style, endTime })} disabled={!canSave} <button onClick={() => canSave && onSave({ trainer, style, endTime })} disabled={!canSave}
className="rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed">Сохранить</button> className="rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed">Сохранить</button>
</div> </div>
@@ -306,15 +306,15 @@ function ClassCell({
<div <div
className={`group relative p-2 rounded-lg cursor-pointer transition-all ${ className={`group relative p-2 rounded-lg cursor-pointer transition-all ${
cls.cancelled cls.cancelled
? "bg-neutral-800/30 opacity-50" ? "bg-neutral-200/50 opacity-50 dark:bg-neutral-800/30"
: atRisk : atRisk
? "bg-red-500/5 border border-red-500/20" ? "bg-red-500/5 border border-red-500/20"
: "bg-gold/5 border border-gold/15 hover:border-gold/30" : "bg-gold/10 border border-gold/25 dark:bg-gold/5 dark:border-gold/15 hover:border-gold/30"
}`} }`}
onClick={() => onEdit(cls.id)} onClick={() => onEdit(cls.id)}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-white truncate">{cls.style}</span> <span className="text-xs font-medium text-neutral-900 truncate dark:text-white">{cls.style}</span>
<span className="text-[10px] text-neutral-500">{cls.startTime}{cls.endTime}</span> <span className="text-[10px] text-neutral-500">{cls.startTime}{cls.endTime}</span>
</div> </div>
<div className="text-[10px] text-neutral-400 truncate">{cls.trainer}</div> <div className="text-[10px] text-neutral-400 truncate">{cls.trainer}</div>
@@ -411,30 +411,45 @@ function ScheduleGrid({
const [creatingTime, setCreatingTime] = useState<string | null>(null); const [creatingTime, setCreatingTime] = useState<string | null>(null);
async function confirmCreate(startTime: string, data: { trainer: string; style: string; endTime: string }) { async function confirmCreate(startTime: string, data: { trainer: string; style: string; endTime: string }) {
await adminFetch("/api/admin/open-day/classes", { try {
method: "POST", const res = await adminFetch("/api/admin/open-day/classes", {
headers: { "Content-Type": "application/json" }, method: "POST",
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }), headers: { "Content-Type": "application/json" },
}); body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }),
setCreatingTime(null); });
onClassesChange(); if (!res.ok) throw new Error();
setCreatingTime(null);
onClassesChange();
} catch {
alert("Не удалось создать занятие");
}
} }
async function updateClass(id: number, data: Partial<OpenDayClass>) { async function updateClass(id: number, data: Partial<OpenDayClass>) {
await adminFetch("/api/admin/open-day/classes", { try {
method: "PUT", const res = await adminFetch("/api/admin/open-day/classes", {
headers: { "Content-Type": "application/json" }, method: "PUT",
body: JSON.stringify({ id, ...data }), headers: { "Content-Type": "application/json" },
}); body: JSON.stringify({ id, ...data }),
onClassesChange(); });
if (!res.ok) throw new Error();
onClassesChange();
} catch {
alert("Не удалось обновить занятие");
}
} }
function deleteClass(id: number) { function deleteClass(id: number) {
setConfirmAction({ setConfirmAction({
message: "Удалить занятие? Это действие нельзя отменить.", message: "Удалить занятие? Это действие нельзя отменить.",
onConfirm: async () => { onConfirm: async () => {
await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" }); try {
onClassesChange(); const res = await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
if (!res.ok) throw new Error();
onClassesChange();
} catch {
alert("Не удалось удалить занятие");
}
}, },
}); });
} }
@@ -453,7 +468,7 @@ function ScheduleGrid({
} }
return ( return (
<div ref={gridRef} className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-3"> <div ref={gridRef} className="rounded-xl border border-neutral-200 bg-white p-5 space-y-3 dark:border-white/10 dark:bg-neutral-900">
<h2 className="text-lg font-bold">Расписание</h2> <h2 className="text-lg font-bold">Расписание</h2>
{halls.length === 0 ? ( {halls.length === 0 ? (
@@ -469,7 +484,7 @@ function ScheduleGrid({
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${ className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
selectedHall === hall selectedHall === hall
? "bg-gold/20 text-gold border border-gold/40" ? "bg-gold/20 text-gold border border-gold/40"
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white" : "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
}`} }`}
> >
{hall} {hall}
@@ -487,8 +502,8 @@ function ScheduleGrid({
{timeSlots.map((time) => { {timeSlots.map((time) => {
const cls = hallClasses[time]; const cls = hallClasses[time];
return ( return (
<div key={time} className="flex items-start gap-3 border-t border-white/5 py-1.5"> <div key={time} className="flex items-start gap-3 border-t border-neutral-200 py-1.5 dark:border-white/5">
<span className="text-xs text-neutral-500 w-12 pt-1.5 shrink-0">{time}</span> <span className="text-xs text-neutral-400 w-12 pt-1.5 shrink-0 dark:text-neutral-500">{time}</span>
<div className="flex-1"> <div className="flex-1">
{cls ? ( {cls ? (
<ClassCell <ClassCell
@@ -513,7 +528,7 @@ function ScheduleGrid({
) : ( ) : (
<button <button
onClick={() => { setCreatingTime(time); setEditingClassId(null); }} onClick={() => { setCreatingTime(time); setEditingClassId(null); }}
className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors" className="w-full rounded-lg border border-dashed border-neutral-200 p-2 text-neutral-400 hover:text-gold hover:border-gold/20 transition-colors dark:border-white/5 dark:text-neutral-600"
> >
<Plus size={12} className="mx-auto" /> <Plus size={12} className="mx-auto" />
</button> </button>
@@ -530,12 +545,12 @@ function ScheduleGrid({
{confirmAction && ( {confirmAction && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirmAction(null)}> <div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirmAction(null)}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" /> <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div className="relative w-full max-w-xs rounded-xl border border-white/[0.08] bg-[#141414] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}> <div className="relative w-full max-w-xs rounded-xl border border-neutral-200 bg-white p-5 shadow-2xl dark:border-white/[0.08] dark:bg-[#141414]" onClick={(e) => e.stopPropagation()}>
<p className="text-sm text-white text-center">{confirmAction.message}</p> <p className="text-sm text-neutral-900 text-center dark:text-white">{confirmAction.message}</p>
<div className="mt-4 flex gap-2 justify-center"> <div className="mt-4 flex gap-2 justify-center">
<button <button
onClick={() => setConfirmAction(null)} onClick={() => setConfirmAction(null)}
className="rounded-lg border border-white/10 px-4 py-2 text-xs font-medium text-neutral-400 hover:text-white hover:border-white/25 transition-colors" className="rounded-lg border border-neutral-200 px-4 py-2 text-xs font-medium text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25"
> >
Нет Нет
</button> </button>
+2 -2
View File
@@ -91,9 +91,9 @@ export default function AdminDashboard() {
useEffect(() => { useEffect(() => {
adminFetch("/api/admin/unread-counts") adminFetch("/api/admin/unread-counts")
.then((r) => r.json()) .then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((data: UnreadCounts) => setCounts(data)) .then((data: UnreadCounts) => setCounts(data))
.catch(() => {}); .catch(() => { /* initial load — non-critical */ });
}, []); }, []);
return ( return (
+22 -9
View File
@@ -81,19 +81,32 @@ export default function TeamEditorPage() {
}, [sectionTitle]); }, [sectionTitle]);
const saveOrder = useCallback(async (updated: Member[]) => { const saveOrder = useCallback(async (updated: Member[]) => {
const previous = members;
setMembers(updated); setMembers(updated);
const res = await adminFetch("/api/admin/team/reorder", { try {
method: "PUT", const res = await adminFetch("/api/admin/team/reorder", {
headers: { "Content-Type": "application/json" }, method: "PUT",
body: JSON.stringify({ ids: updated.map((m) => m.id) }), headers: { "Content-Type": "application/json" },
}); body: JSON.stringify({ ids: updated.map((m) => m.id) }),
setSaveStatus(res.ok ? "saved" : "error"); });
setSaveStatus(res.ok ? "saved" : "error");
if (!res.ok) setMembers(previous);
} catch {
setSaveStatus("error");
setMembers(previous);
}
setTimeout(() => setSaveStatus("idle"), 2000); setTimeout(() => setSaveStatus("idle"), 2000);
}, []); }, [members]);
async function deleteMember(id: number) { async function deleteMember(id: number) {
await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" }); try {
setMembers((prev) => prev.filter((m) => m.id !== id)); const res = await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error();
setMembers((prev) => prev.filter((m) => m.id !== id));
} catch {
setSaveStatus("error");
setTimeout(() => setSaveStatus("idle"), 3000);
}
} }
if (loading) { if (loading) {
+5 -3
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getGroupBookings, addGroupBooking, toggleGroupBookingNotification, deleteGroupBooking, setGroupBookingStatus, updateBookingNotes } from "@/lib/db"; import { getGroupBookings, addGroupBooking, toggleGroupBookingNotification, deleteGroupBooking, setGroupBookingStatus, updateBookingNotes } from "@/lib/db";
import type { BookingStatus } from "@/lib/db"; import type { BookingStatus } from "@/lib/db";
import { sanitizeText } from "@/lib/validation"; import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
export async function GET() { export async function GET() {
const bookings = getGroupBookings(); const bookings = getGroupBookings();
@@ -50,10 +50,12 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const { name, phone, groupInfo, instagram, telegram } = body; const { name, phone, groupInfo, instagram, telegram } = body;
if (!name?.trim() || !phone?.trim()) { const cleanName = sanitizeName(name);
const cleanPhone = sanitizePhone(phone);
if (!cleanName || !cleanPhone) {
return NextResponse.json({ error: "name and phone are required" }, { status: 400 }); return NextResponse.json({ error: "name and phone are required" }, { status: 400 });
} }
const id = addGroupBooking(name.trim(), phone.trim(), groupInfo?.trim() || undefined, instagram?.trim() || undefined, telegram?.trim() || undefined); const id = addGroupBooking(cleanName, cleanPhone, sanitizeText(groupInfo, 500), sanitizeHandle(instagram), sanitizeHandle(telegram));
return NextResponse.json({ ok: true, id }); return NextResponse.json({ ok: true, id });
} catch (err) { } catch (err) {
console.error("[admin/group-bookings] POST error:", err); console.error("[admin/group-bookings] POST error:", err);
+10 -5
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration, setMcRegistrationStatus, updateBookingNotes } from "@/lib/db"; import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration, setMcRegistrationStatus, updateBookingNotes } from "@/lib/db";
import { sanitizeText } from "@/lib/validation"; import { sanitizeName, sanitizeHandle, sanitizeText } from "@/lib/validation";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const title = request.nextUrl.searchParams.get("title"); const title = request.nextUrl.searchParams.get("title");
@@ -15,10 +15,13 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const { masterClassTitle, name, instagram, telegram } = body; const { masterClassTitle, name, instagram, telegram } = body;
if (!masterClassTitle || !name || !instagram) { const cleanTitle = sanitizeText(masterClassTitle, 200);
const cleanName = sanitizeName(name);
const cleanInstagram = sanitizeHandle(instagram);
if (!cleanTitle || !cleanName || !cleanInstagram) {
return NextResponse.json({ error: "masterClassTitle, name, instagram are required" }, { status: 400 }); return NextResponse.json({ error: "masterClassTitle, name, instagram are required" }, { status: 400 });
} }
const id = addMcRegistration(masterClassTitle.trim(), name.trim(), instagram.trim(), telegram?.trim() || undefined); const id = addMcRegistration(cleanTitle, cleanName, cleanInstagram, sanitizeHandle(telegram));
return NextResponse.json({ ok: true, id }); return NextResponse.json({ ok: true, id });
} catch (err) { } catch (err) {
console.error("[admin/mc-registrations] error:", err); console.error("[admin/mc-registrations] error:", err);
@@ -64,10 +67,12 @@ export async function PUT(request: NextRequest) {
// Regular update // Regular update
const { id, name, instagram, telegram } = body; const { id, name, instagram, telegram } = body;
if (!id || !name || !instagram) { const cleanName = sanitizeName(name);
const cleanInstagram = sanitizeHandle(instagram);
if (!id || !cleanName || !cleanInstagram) {
return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 }); return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 });
} }
updateMcRegistration(id, name.trim(), instagram.trim(), telegram?.trim() || undefined); updateMcRegistration(id, cleanName, cleanInstagram, sanitizeHandle(telegram));
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (err) { } catch (err) {
console.error("[admin/mc-registrations] error:", err); console.error("[admin/mc-registrations] error:", err);
+21 -1
View File
@@ -22,13 +22,33 @@ export async function GET(_request: NextRequest, { params }: Params) {
}); });
} }
/** Recursively sanitize string values: strip <script> and javascript: URIs */
function sanitizeValue(val: unknown): unknown {
if (typeof val === "string") {
let s = val.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
// Strip javascript: URIs in href/src-like values
s = s.replace(/javascript\s*:/gi, "");
return s;
}
if (Array.isArray(val)) return val.map(sanitizeValue);
if (val && typeof val === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
out[k] = sanitizeValue(v);
}
return out;
}
return val;
}
export async function PUT(request: NextRequest, { params }: Params) { export async function PUT(request: NextRequest, { params }: Params) {
const { key } = await params; const { key } = await params;
if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) { if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) {
return NextResponse.json({ error: "Invalid section key" }, { status: 400 }); return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
} }
const data = await request.json(); const raw = await request.json();
const data = sanitizeValue(raw);
setSection(key, data); setSection(key, data);
invalidateContentCache(); invalidateContentCache();
revalidatePath("/"); revalidatePath("/");
+9 -1
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getTeamMember, updateTeamMember, deleteTeamMember } from "@/lib/db"; import { getTeamMember, updateTeamMember, deleteTeamMember } from "@/lib/db";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { sanitizeName, sanitizeHandle, sanitizeText } from "@/lib/validation";
type Params = { params: Promise<{ id: string }> }; type Params = { params: Promise<{ id: string }> };
@@ -28,7 +29,14 @@ export async function PUT(request: NextRequest, { params }: Params) {
if (!numId) { if (!numId) {
return NextResponse.json({ error: "Invalid ID" }, { status: 400 }); return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
} }
const data = await request.json(); const raw = await request.json();
// Sanitize string fields before storing
const data = {
...raw,
name: typeof raw.name === "string" ? sanitizeName(raw.name) ?? raw.name : raw.name,
instagram: typeof raw.instagram === "string" ? sanitizeHandle(raw.instagram) : raw.instagram,
bio: typeof raw.bio === "string" ? sanitizeText(raw.bio, 2000) : raw.bio,
};
updateTeamMember(numId, data); updateTeamMember(numId, data);
revalidatePath("/"); revalidatePath("/");
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
+31
View File
@@ -6,6 +6,28 @@ const IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
const VIDEO_TYPES = ["video/mp4", "video/webm"]; const VIDEO_TYPES = ["video/mp4", "video/webm"];
const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"]; const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"];
const VIDEO_EXTENSIONS = [".mp4", ".webm"]; const VIDEO_EXTENSIONS = [".mp4", ".webm"];
/** Magic byte signatures for allowed file types */
const MAGIC_BYTES: Record<string, number[][]> = {
"image/jpeg": [[0xFF, 0xD8, 0xFF]],
"image/png": [[0x89, 0x50, 0x4E, 0x47]],
"image/webp": [[0x52, 0x49, 0x46, 0x46]], // RIFF
"image/avif": [], // AVIF uses ISOBMFF (ftyp), checked separately
"video/mp4": [], // MP4 uses ISOBMFF (ftyp), checked separately
"video/webm": [[0x1A, 0x45, 0xDF, 0xA3]], // EBML
};
function validateMagicBytes(buffer: Buffer, mimeType: string): boolean {
// ISOBMFF container (AVIF, MP4): check for 'ftyp' at offset 4
if (mimeType === "image/avif" || mimeType === "video/mp4") {
return buffer.length >= 8 && buffer.toString("ascii", 4, 8) === "ftyp";
}
const signatures = MAGIC_BYTES[mimeType];
if (!signatures || signatures.length === 0) return true;
return signatures.some((sig) =>
sig.every((byte, i) => buffer.length > i && buffer[i] === byte)
);
}
const IMAGE_FOLDERS = ["team", "master-classes", "news", "classes"]; const IMAGE_FOLDERS = ["team", "master-classes", "news", "classes"];
const VIDEO_FOLDERS = ["hero"]; const VIDEO_FOLDERS = ["hero"];
const ALL_FOLDERS = [...IMAGE_FOLDERS, ...VIDEO_FOLDERS]; const ALL_FOLDERS = [...IMAGE_FOLDERS, ...VIDEO_FOLDERS];
@@ -71,6 +93,15 @@ export async function POST(request: NextRequest) {
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
// Validate file content matches claimed MIME type
if (!validateMagicBytes(buffer, file.type)) {
return NextResponse.json(
{ error: "Содержимое файла не соответствует его типу" },
{ status: 400 }
);
}
const filePath = path.join(dir, fileName); const filePath = path.join(dir, fileName);
await writeFile(filePath, buffer); await writeFile(filePath, buffer);
@@ -1,11 +1,17 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
const INSTAGRAM_USERNAME_RE = /^[a-zA-Z0-9_.]{1,30}$/;
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const username = request.nextUrl.searchParams.get("username")?.trim(); const username = request.nextUrl.searchParams.get("username")?.trim();
if (!username) { if (!username) {
return NextResponse.json({ valid: false, error: "No username" }); return NextResponse.json({ valid: false, error: "No username" });
} }
if (!INSTAGRAM_USERNAME_RE.test(username)) {
return NextResponse.json({ valid: false, error: "Invalid username format" });
}
try { try {
const res = await fetch(`https://www.instagram.com/${username}/`, { const res = await fetch(`https://www.instagram.com/${username}/`, {
method: "HEAD", method: "HEAD",
+10 -1
View File
@@ -1,7 +1,16 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { verifyPassword, signToken, generateCsrfToken, COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth"; import { verifyPassword, signToken, generateCsrfToken, COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const ip = getClientIp(request);
if (!checkRateLimit(ip, 5, 5 * 60_000)) {
return NextResponse.json(
{ error: "Слишком много попыток. Попробуйте через 5 минут." },
{ status: 429 }
);
}
const body = await request.json() as { password?: string }; const body = await request.json() as { password?: string };
if (!body.password || !verifyPassword(body.password)) { if (!body.password || !verifyPassword(body.password)) {
@@ -23,7 +32,7 @@ export async function POST(request: NextRequest) {
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, { response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
httpOnly: false, // JS must read this to send as header httpOnly: false, // JS must read this to send as header
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
sameSite: "strict", sameSite: "lax", // Match auth cookie; strict breaks admin access from external links
path: "/", path: "/",
maxAge: 60 * 60 * 24, maxAge: 60 * 60 * 24,
}); });
+7 -2
View File
@@ -1,7 +1,12 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth"; import { COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
export async function POST() { export async function POST(request: NextRequest) {
// Verify auth cookie exists (basic protection against cross-site logout)
const token = request.cookies.get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}
const response = NextResponse.json({ ok: true }); const response = NextResponse.json({ ok: true });
response.cookies.set(COOKIE_NAME, "", { response.cookies.set(COOKIE_NAME, "", {
httpOnly: true, httpOnly: true,
+7 -10
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { addMcRegistration, getMcRegistrations, getSection } from "@/lib/db"; import { addMcRegistrationAtomic, getSection } from "@/lib/db";
import { checkRateLimit, getClientIp } from "@/lib/rateLimit"; import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation"; import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
import type { MasterClassItem } from "@/types/content"; import type { MasterClassItem } from "@/types/content";
@@ -32,23 +32,20 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 }); return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
} }
// Check if MC is full — if so, booking goes to waiting list // Determine max participants from section config
const mcSection = getSection("masterClasses") as { items?: MasterClassItem[] } | null; const mcSection = getSection("masterClasses") as { items?: MasterClassItem[] } | null;
const mcItem = mcSection?.items?.find((mc) => mc.title === cleanTitle); const mcItem = mcSection?.items?.find((mc) => mc.title === cleanTitle);
let isWaiting = false; const maxParticipants = mcItem?.maxParticipants && mcItem.maxParticipants > 0
if (mcItem?.maxParticipants && mcItem.maxParticipants > 0) { ? mcItem.maxParticipants : undefined;
const currentRegs = getMcRegistrations(cleanTitle);
const confirmedCount = currentRegs.filter((r) => r.status === "confirmed").length;
isWaiting = confirmedCount >= mcItem.maxParticipants;
}
const id = addMcRegistration( // Atomic check-and-insert inside a transaction to prevent race condition
const { id, isWaiting } = addMcRegistrationAtomic(
cleanTitle, cleanTitle,
cleanName, cleanName,
sanitizeHandle(instagram) ?? "", sanitizeHandle(instagram) ?? "",
sanitizeHandle(telegram), sanitizeHandle(telegram),
cleanPhone, cleanPhone,
isWaiting ? "Лист ожидания" : undefined maxParticipants
); );
return NextResponse.json({ ok: true, id, isWaiting }); return NextResponse.json({ ok: true, id, isWaiting });
+17 -1
View File
@@ -103,6 +103,10 @@ input[type="number"] {
html { html {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(160, 160, 160, 0.5) #f5f5f5;
}
html.dark {
scrollbar-color: rgba(201, 169, 110, 0.3) var(--color-surface-dark); scrollbar-color: rgba(201, 169, 110, 0.3) var(--color-surface-dark);
} }
@@ -111,14 +115,26 @@ html {
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #f5f5f5;
}
html.dark ::-webkit-scrollbar-track {
background: var(--color-surface-dark); background: var(--color-surface-dark);
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(201, 169, 110, 0.3); background: rgba(160, 160, 160, 0.5);
border-radius: 4px; border-radius: 4px;
} }
html.dark ::-webkit-scrollbar-thumb {
background: rgba(201, 169, 110, 0.3);
}
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(120, 120, 120, 0.6);
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(201, 169, 110, 0.5); background: rgba(201, 169, 110, 0.5);
} }
+2 -7
View File
@@ -14,19 +14,14 @@ import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import { ClientShell } from "@/components/layout/ClientShell"; import { ClientShell } from "@/components/layout/ClientShell";
import { getContent } from "@/lib/content"; import { getContent } from "@/lib/content";
export const dynamic = "force-dynamic";
import { OpenDay } from "@/components/sections/OpenDay"; import { OpenDay } from "@/components/sections/OpenDay";
import { getActiveOpenDay } from "@/lib/openDay"; import { getActiveOpenDay } from "@/lib/openDay";
import { getAllMcRegistrations } from "@/lib/db"; import { getMcRegistrationCounts } from "@/lib/db";
export default function HomePage() { export default function HomePage() {
const content = getContent(); const content = getContent();
const openDayData = getActiveOpenDay(); const openDayData = getActiveOpenDay();
// Count MC registrations per title for capacity check const mcRegCounts = getMcRegistrationCounts();
const allMcRegs = getAllMcRegistrations();
const mcRegCounts: Record<string, number> = {};
for (const reg of allMcRegs) mcRegCounts[reg.masterClassTitle] = (mcRegCounts[reg.masterClassTitle] || 0) + 1;
return ( return (
<> <>
+38 -9
View File
@@ -136,12 +136,12 @@
.gradient-text { .gradient-text {
background: linear-gradient( background: linear-gradient(
135deg, 135deg,
#8a6f3e 0%, #c9a96e 0%,
#c9a96e 20%, #e2c97e 20%,
#8a6f3e 40%, #d4b87a 40%,
#c9a96e 60%, #e2c97e 60%,
#6b5530 80%, #c9a96e 80%,
#8a6f3e 100% #d4b87a 100%
); );
background-size: 200% 200%; background-size: 200% 200%;
-webkit-background-clip: text; -webkit-background-clip: text;
@@ -150,7 +150,12 @@
animation: gradient-shift 6s ease-in-out infinite; animation: gradient-shift 6s ease-in-out infinite;
} }
/* Light mode gradient text */ /* Auto-switch gradient text for light mode */
html:not(.dark) .gradient-text {
background-image: linear-gradient(135deg, #a08050 0%, #c9a96e 25%, #8a6f3e 50%, #c9a96e 75%, #a08050 100%);
}
/* Explicit light mode gradient text class (legacy) */
.gradient-text-light { .gradient-text-light {
background: linear-gradient(135deg, #171717 0%, #c9a96e 50%, #171717 100%); background: linear-gradient(135deg, #171717 0%, #c9a96e 50%, #171717 100%);
background-size: 200% 200%; background-size: 200% 200%;
@@ -172,7 +177,7 @@
inset: 0; inset: 0;
border-radius: inherit; border-radius: inherit;
padding: 1px; padding: 1px;
background: linear-gradient(135deg, rgba(201, 169, 110, 0.3), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.15)); background: linear-gradient(135deg, rgba(201, 169, 110, 0.5), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.3));
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude; mask-composite: exclude;
pointer-events: none; pointer-events: none;
@@ -180,8 +185,16 @@
opacity: 0.5; opacity: 0.5;
} }
:is(.dark) .animated-border::before {
background: linear-gradient(135deg, rgba(201, 169, 110, 0.3), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.15));
}
.animated-border:hover::before { .animated-border:hover::before {
opacity: 1; opacity: 1;
background: linear-gradient(135deg, rgba(201, 169, 110, 0.7), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.5));
}
:is(.dark) .animated-border:hover::before {
background: linear-gradient(135deg, rgba(201, 169, 110, 0.6), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.4)); background: linear-gradient(135deg, rgba(201, 169, 110, 0.6), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.4));
} }
@@ -192,10 +205,14 @@
} }
.glow-hover:hover { .glow-hover:hover {
box-shadow: 0 0 30px rgba(201, 169, 110, 0.1), 0 0 60px rgba(201, 169, 110, 0.05); box-shadow: 0 0 20px rgba(201, 169, 110, 0.25), 0 0 50px rgba(201, 169, 110, 0.12), 0 4px 16px rgba(0, 0, 0, 0.06);
transform: translateY(-4px); transform: translateY(-4px);
} }
:is(.dark) .glow-hover:hover {
box-shadow: 0 0 30px rgba(201, 169, 110, 0.1), 0 0 60px rgba(201, 169, 110, 0.05);
}
/* ===== Scroll Reveal ===== */ /* ===== Scroll Reveal ===== */
.reveal { .reveal {
@@ -345,9 +362,21 @@
.section-divider { .section-divider {
height: 1px; height: 1px;
background: linear-gradient(90deg, transparent, rgba(201, 169, 110, 0.4), transparent);
}
:is(.dark) .section-divider {
background: linear-gradient(90deg, transparent, rgba(201, 169, 110, 0.15), transparent); background: linear-gradient(90deg, transparent, rgba(201, 169, 110, 0.15), transparent);
} }
/* ===== No-JS Fallback ===== */
/* When JS is disabled, ensure Reveal content is visible */
noscript ~ * [style*="opacity: 0"],
.no-js [style*="opacity: 0"] {
opacity: 1 !important;
transform: none !important;
}
/* ===== Reduced Motion ===== */ /* ===== Reduced Motion ===== */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
+2 -1
View File
@@ -6,7 +6,8 @@
@apply hover:bg-gold-light hover:shadow-[0_0_30px_rgba(201,169,110,0.35)]; @apply hover:bg-gold-light hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
@apply dark:bg-gold dark:text-black; @apply dark:bg-gold dark:text-black;
@apply dark:hover:bg-gold-light dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.35)]; @apply dark:hover:bg-gold-light dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
@apply focus:outline-none focus-visible:ring-2 focus-visible:ring-gold-light focus-visible:ring-offset-2 focus-visible:ring-offset-black; @apply active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none;
@apply focus:outline-none focus-visible:ring-2 focus-visible:ring-gold-light focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-black;
} }
/* ===== Scrollbar ===== */ /* ===== Scrollbar ===== */
+55 -9
View File
@@ -6,8 +6,8 @@
} }
.surface-muted { .surface-muted {
@apply bg-neutral-100; @apply bg-neutral-100 text-neutral-900;
@apply dark:bg-[var(--color-surface-deep)]; @apply dark:bg-[var(--color-surface-deep)] dark:text-neutral-100;
} }
.surface-glass { .surface-glass {
@@ -16,8 +16,8 @@
} }
.surface-card { .surface-card {
@apply bg-white/80 backdrop-blur-sm; @apply bg-white shadow-sm backdrop-blur-sm;
@apply dark:bg-neutral-900 dark:backdrop-blur-sm; @apply dark:bg-neutral-900 dark:shadow-none dark:backdrop-blur-sm;
} }
/* ===== Borders ===== */ /* ===== Borders ===== */
@@ -73,20 +73,25 @@
transform: translateX(-50%); transform: translateX(-50%);
width: min(600px, 100%); width: min(600px, 100%);
height: 400px; height: 400px;
background: radial-gradient(ellipse, rgba(201, 169, 110, 0.05), transparent 70%); background: radial-gradient(ellipse, rgba(201, 169, 110, 0.15), transparent 70%);
pointer-events: none; pointer-events: none;
} }
:is(.dark) .section-glow::before {
background: radial-gradient(ellipse, rgba(201, 169, 110, 0.05), transparent 70%);
}
/* ===== Glass Card ===== */ /* ===== Glass Card ===== */
.glass-card { .glass-card {
@apply rounded-2xl border backdrop-blur-sm transition-all duration-300; @apply rounded-2xl border backdrop-blur-sm transition-all duration-300;
@apply border-neutral-200/80 bg-white/90; @apply border-neutral-200/80 bg-white/90 shadow-sm shadow-gold/[0.04];
@apply dark:border-white/[0.06] dark:bg-white/[0.04]; @apply dark:border-white/[0.06] dark:bg-white/[0.04] dark:shadow-none;
} }
.glass-card:hover { .glass-card:hover {
@apply dark:border-gold/15 dark:bg-white/[0.06]; @apply border-gold/30 bg-white shadow-md shadow-gold/[0.08];
@apply dark:border-gold/15 dark:bg-white/[0.06] dark:shadow-none;
} }
/* ===== Photo Filter ===== */ /* ===== Photo Filter ===== */
@@ -99,10 +104,43 @@
filter: saturate(0.6) sepia(0.2) brightness(0.9) contrast(1.1); filter: saturate(0.6) sepia(0.2) brightness(0.9) contrast(1.1);
} }
/* ===== Modal Surface ===== */
.modal-surface {
@apply bg-white dark:bg-neutral-950;
}
/* ===== Theme Input ===== */
.theme-input {
@apply border-neutral-300 bg-neutral-50 text-neutral-900 placeholder-neutral-400;
@apply focus:border-gold/60 focus:bg-white;
@apply dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500;
@apply dark:focus:border-gold/40 dark:focus:bg-white/[0.06];
}
/* ===== Admin Surface ===== */
.admin-surface {
@apply bg-white text-neutral-900 dark:bg-neutral-950 dark:text-white;
}
.admin-sidebar {
@apply bg-neutral-100 border-neutral-200 dark:bg-neutral-900 dark:border-white/10;
}
.admin-nav-item {
@apply text-neutral-500 hover:text-neutral-900 hover:bg-neutral-200/60 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-white/5;
}
/* ===== Custom Scrollbar ===== */ /* ===== Custom Scrollbar ===== */
.styled-scrollbar { .styled-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(160, 160, 160, 0.5) transparent;
}
:is(.dark) .styled-scrollbar {
scrollbar-color: rgba(201, 169, 110, 0.25) transparent; scrollbar-color: rgba(201, 169, 110, 0.25) transparent;
} }
@@ -116,10 +154,18 @@
} }
.styled-scrollbar::-webkit-scrollbar-thumb { .styled-scrollbar::-webkit-scrollbar-thumb {
background: rgba(201, 169, 110, 0.25); background: rgba(160, 160, 160, 0.5);
border-radius: 4px; border-radius: 4px;
} }
:is(.dark) .styled-scrollbar::-webkit-scrollbar-thumb {
background: rgba(201, 169, 110, 0.25);
}
.styled-scrollbar::-webkit-scrollbar-thumb:hover { .styled-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(120, 120, 120, 0.6);
}
:is(.dark) .styled-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(201, 169, 110, 0.4); background: rgba(201, 169, 110, 0.4);
} }
+4 -3
View File
@@ -8,10 +8,11 @@ export function Footer() {
<footer className="relative border-t border-neutral-200 bg-neutral-100 dark:border-white/[0.08] dark:bg-[#050505]"> <footer className="relative border-t border-neutral-200 bg-neutral-100 dark:border-white/[0.08] dark:bg-[#050505]">
<div className="section-divider absolute top-0 left-0 right-0" /> <div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container flex flex-col items-center gap-4 py-10 sm:flex-row sm:justify-between"> <div className="section-container flex flex-col items-center gap-4 py-10 sm:flex-row sm:justify-between">
<p className="text-sm text-neutral-500"> <p className="text-sm text-neutral-600 dark:text-neutral-500">
&copy; {year} {BRAND.name} {/* &copy; {year} {BRAND.name} — commented out for portfolio version */}
&copy; {year} Dance Studio
</p> </p>
<div className="flex items-center gap-1.5 text-sm text-neutral-500"> <div className="flex items-center gap-1.5 text-sm text-neutral-600 dark:text-neutral-500">
<span>Made with</span> <span>Made with</span>
<Heart size={14} className="fill-gold text-gold" /> <Heart size={14} className="fill-gold text-gold" />
<span>by Diana Dolgolyova</span> <span>by Diana Dolgolyova</span>
+18 -9
View File
@@ -7,6 +7,7 @@ import { BRAND, NAV_LINKS } from "@/lib/constants";
import { UI_CONFIG } from "@/lib/config"; import { UI_CONFIG } from "@/lib/config";
import { HeroLogo } from "@/components/ui/HeroLogo"; import { HeroLogo } from "@/components/ui/HeroLogo";
import { SignupModal } from "@/components/ui/SignupModal"; import { SignupModal } from "@/components/ui/SignupModal";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import { useBooking } from "@/contexts/BookingContext"; import { useBooking } from "@/contexts/BookingContext";
export function Header() { export function Header() {
@@ -125,13 +126,14 @@ export function Header() {
<header <header
className={`fixed top-0 z-50 w-full transition-all duration-500 ${ className={`fixed top-0 z-50 w-full transition-all duration-500 ${
scrolled || menuOpen scrolled || menuOpen
? "bg-black/40 shadow-none backdrop-blur-xl" ? "bg-white/90 shadow-none backdrop-blur-xl dark:bg-black/40"
: "bg-transparent" : "bg-transparent"
}`} }`}
> >
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[60] focus:px-4 focus:py-2 focus:bg-gold focus:text-black focus:rounded font-medium">Перейти к содержимому</a> <a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[60] focus:px-4 focus:py-2 focus:bg-gold focus:text-black focus:rounded font-medium">Перейти к содержимому</a>
<div className="flex h-16 items-center justify-between px-6 sm:px-10 lg:px-16"> <div className="flex h-16 items-center justify-between px-6 sm:px-10 lg:px-16">
<Link href="/" className="group flex items-center gap-2.5"> <Link href="/" className="group flex items-center gap-2.5">
{/* Blackheart logotype — commented out for portfolio version
<div className="relative flex h-8 w-8 items-center justify-center"> <div className="relative flex h-8 w-8 items-center justify-center">
<div <div
className="absolute inset-0 rounded-full transition-all duration-300 group-hover:scale-125" className="absolute inset-0 rounded-full transition-all duration-300 group-hover:scale-125"
@@ -144,8 +146,10 @@ export function Header() {
className="relative text-black transition-transform duration-300 drop-shadow-[0_0_3px_rgba(201,169,110,0.5)] group-hover:scale-110" className="relative text-black transition-transform duration-300 drop-shadow-[0_0_3px_rgba(201,169,110,0.5)] group-hover:scale-110"
/> />
</div> </div>
*/}
{/* {BRAND.shortName} — commented out for portfolio version */}
<span className="font-display text-lg font-bold tracking-tight text-gold"> <span className="font-display text-lg font-bold tracking-tight text-gold">
{BRAND.shortName} Dance Studio
</span> </span>
</Link> </Link>
@@ -159,23 +163,27 @@ export function Header() {
aria-current={isActive ? "page" : undefined} aria-current={isActive ? "page" : undefined}
className={`relative whitespace-nowrap py-1 text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${ className={`relative whitespace-nowrap py-1 text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${
isActive isActive
? "text-gold-light after:w-full" ? "text-gold after:w-full"
: "text-neutral-400 after:w-0 hover:text-white hover:after:w-full" : scrolled
? "text-neutral-600 after:w-0 hover:text-neutral-900 hover:after:w-full dark:text-neutral-400 dark:hover:text-white"
: "text-white/80 after:w-0 hover:text-white hover:after:w-full"
}`} }`}
> >
{link.label} {link.label}
</a> </a>
); );
})} })}
<ThemeToggle />
</nav> </nav>
<div className="flex items-center gap-2 lg:hidden"> <div className="flex items-center gap-1 lg:hidden">
<ThemeToggle />
<button <button
ref={menuButtonRef} ref={menuButtonRef}
onClick={() => setMenuOpen(!menuOpen)} onClick={() => setMenuOpen(!menuOpen)}
aria-label={menuOpen ? "Закрыть меню" : "Открыть меню"} aria-label={menuOpen ? "Закрыть меню" : "Открыть меню"}
aria-expanded={menuOpen} aria-expanded={menuOpen}
className="rounded-lg p-2 text-neutral-400 transition-colors hover:text-white" className="rounded-lg p-2 text-neutral-500 transition-colors hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
> >
{menuOpen ? <X size={24} /> : <Menu size={24} />} {menuOpen ? <X size={24} /> : <Menu size={24} />}
</button> </button>
@@ -184,11 +192,12 @@ export function Header() {
{/* Mobile menu */} {/* Mobile menu */}
<div <div
aria-hidden={!menuOpen}
className={`overflow-hidden transition-all duration-300 lg:hidden ${ className={`overflow-hidden transition-all duration-300 lg:hidden ${
menuOpen ? "max-h-[80vh] opacity-100" : "max-h-0 opacity-0" menuOpen ? "max-h-[80vh] opacity-100" : "max-h-0 opacity-0"
}`} }`}
> >
<nav className="border-t border-white/[0.06] px-6 py-4 text-center sm:px-8" aria-label="Основная навигация"> <nav className="border-t border-neutral-200/60 dark:border-white/[0.06] px-6 py-4 text-center sm:px-8" aria-label="Основная навигация">
{visibleLinks.map((link, index) => { {visibleLinks.map((link, index) => {
const isActive = activeSection === link.href.replace("#", ""); const isActive = activeSection === link.href.replace("#", "");
return ( return (
@@ -200,8 +209,8 @@ export function Header() {
aria-current={isActive ? "page" : undefined} aria-current={isActive ? "page" : undefined}
className={`block py-3 text-base transition-colors ${ className={`block py-3 text-base transition-colors ${
isActive isActive
? "text-gold-light" ? "text-gold"
: "text-neutral-400 hover:text-white" : "text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
}`} }`}
> >
{link.label} {link.label}
+1 -1
View File
@@ -47,7 +47,7 @@ export function About({ data: about, stats }: AboutProps) {
<div <div
key={i} key={i}
aria-label={stat.ariaLabel} aria-label={stat.ariaLabel}
className="group flex flex-col items-center gap-3 rounded-2xl border border-neutral-200 bg-white/50 p-6 transition-all duration-300 hover:border-gold/30 sm:p-8 dark:border-white/[0.06] dark:bg-white/[0.02] dark:hover:border-gold/20" className="group flex flex-col items-center gap-3 rounded-2xl border border-neutral-200 bg-white/80 p-6 shadow-sm shadow-gold/[0.06] transition-all duration-300 hover:border-gold/30 hover:shadow-md hover:shadow-gold/[0.1] sm:p-8 dark:border-white/[0.06] dark:bg-white/[0.02] dark:shadow-none dark:hover:border-gold/20 dark:hover:shadow-none"
> >
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gold/10 text-gold-dark transition-colors group-hover:bg-gold/20 dark:text-gold-light" aria-hidden="true"> <div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gold/10 text-gold-dark transition-colors group-hover:bg-gold/20 dark:text-gold-light" aria-hidden="true">
{stat.icon} {stat.icon}
+23 -10
View File
@@ -1,7 +1,13 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { icons } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import {
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";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout"; import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
@@ -10,13 +16,20 @@ import type { ClassItem, SiteContent } from "@/types";
import { UI_CONFIG } from "@/lib/config"; import { UI_CONFIG } from "@/lib/config";
import { formatMarkup } from "@/lib/markup"; import { formatMarkup } from "@/lib/markup";
// kebab "heart-pulse" → PascalCase "HeartPulse" /** Map of kebab-case icon keys to their components (curated for dance school) */
function toPascal(kebab: string) { const ICON_MAP: Record<string, LucideIcon> = {
return kebab.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(""); "flame": Flame, "heart": Heart, "heart-pulse": HeartPulse, "star": Star,
} "sparkles": Sparkles, "music": Music, "zap": Zap, "crown": Crown,
"dumbbell": Dumbbell, "wind": Wind, "moon": Moon, "sun": Sun,
"ribbon": Ribbon, "gem": Gem, "feather": Feather, "circle-dot": CircleDot,
"activity": Activity, "drama": Drama, "person-standing": PersonStanding,
"footprints": Footprints, "party-popper": PartyPopper, "flower-2": Flower2,
"waves": Waves, "eye": Eye, "orbit": Orbit, "brush": Brush,
"palette": Palette, "hand-metal": HandMetal, "theater": Theater,
};
function getIcon(key: string) { function getIcon(key: string) {
const Icon = icons[toPascal(key) as keyof typeof icons]; const Icon = ICON_MAP[key];
return Icon ? <Icon size={20} /> : null; return Icon ? <Icon size={20} /> : null;
} }
@@ -32,7 +45,7 @@ export function Classes({ data: classes }: ClassesProps) {
}); });
return ( return (
<section id="classes" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]"> <section id="classes" className="section-glow relative section-padding bg-neutral-50 dark:bg-[#080808]">
<div className="section-divider absolute top-0 left-0 right-0" /> <div className="section-divider absolute top-0 left-0 right-0" />
<div className="section-container"> <div className="section-container">
<Reveal> <Reveal>
@@ -65,14 +78,14 @@ export function Classes({ data: classes }: ClassesProps) {
}} }}
/> />
{/* Gradient overlay */} {/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
{/* Icon + name overlay */} {/* Icon + name overlay */}
<div className="absolute bottom-0 left-0 right-0 p-6 flex items-center gap-3"> <div className="absolute bottom-0 left-0 right-0 p-6 flex items-center gap-3">
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gold/20 text-gold-light backdrop-blur-sm"> <div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gold/20 text-gold-light backdrop-blur-sm">
{getIcon(item.icon)} {getIcon(item.icon)}
</div> </div>
<h3 className="text-2xl font-bold text-white"> <h3 className="text-2xl font-bold text-white drop-shadow-[0_2px_8px_rgba(0,0,0,0.5)]">
{item.name} {item.name}
</h3> </h3>
</div> </div>
@@ -109,7 +122,7 @@ export function Classes({ data: classes }: ClassesProps) {
> >
{item.name} {item.name}
</p> </p>
<p className="hidden lg:block text-xs text-neutral-500 dark:text-neutral-500 truncate"> <p className="hidden lg:block text-xs text-neutral-600 dark:text-neutral-500 truncate">
{item.description} {item.description}
</p> </p>
</div> </div>
+2 -1
View File
@@ -42,7 +42,7 @@ export function FAQ({ data: faq }: FAQProps) {
className={`rounded-xl border transition-all duration-300 ${ className={`rounded-xl border transition-all duration-300 ${
isOpen isOpen
? "border-gold/30 bg-gradient-to-br from-gold/[0.06] via-transparent to-gold/[0.03] shadow-md shadow-gold/5" ? "border-gold/30 bg-gradient-to-br from-gold/[0.06] via-transparent to-gold/[0.03] shadow-md shadow-gold/5"
: "border-neutral-200 bg-white hover:border-neutral-300 dark:border-white/[0.06] dark:bg-neutral-950 dark:hover:border-white/[0.12]" : "border-neutral-200 bg-white shadow-sm shadow-gold/[0.03] hover:border-neutral-300 hover:shadow-md hover:shadow-gold/[0.06] dark:border-white/[0.06] dark:bg-neutral-950 dark:shadow-none dark:hover:border-white/[0.12] dark:hover:shadow-none"
}`} }`}
> >
<button <button
@@ -79,6 +79,7 @@ export function FAQ({ data: faq }: FAQProps) {
id={`faq-panel-${idx}`} id={`faq-panel-${idx}`}
role="region" role="region"
aria-labelledby={`faq-button-${idx}`} aria-labelledby={`faq-button-${idx}`}
aria-hidden={!isOpen}
className={`grid transition-all duration-300 ease-out ${ className={`grid transition-all duration-300 ease-out ${
isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0" isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
}`} }`}
+18 -6
View File
@@ -39,6 +39,16 @@ export function Hero({ data: hero }: HeroProps) {
} }
}, [totalVideos]); }, [totalVideos]);
// Fallback: fade overlay after 5s even if videos haven't loaded
useEffect(() => {
const timer = setTimeout(() => {
if (overlayRef.current && overlayRef.current.style.opacity !== "0") {
overlayRef.current.style.opacity = "0";
}
}, 5000);
return () => clearTimeout(timer);
}, []);
const scrollToNext = useCallback(() => { const scrollToNext = useCallback(() => {
const el = sectionRef.current; const el = sectionRef.current;
if (!el) return; if (!el) return;
@@ -88,14 +98,14 @@ export function Hero({ data: hero }: HeroProps) {
}, [scrollToNext]); }, [scrollToNext]);
return ( return (
<section id="hero" ref={sectionRef} aria-label="Главный баннер" className="relative flex min-h-svh items-center justify-center overflow-hidden bg-neutral-950"> <section id="hero" ref={sectionRef} aria-label="Главный баннер" className="relative flex min-h-svh items-center justify-center overflow-hidden bg-neutral-100 dark:bg-neutral-950">
{/* Videos render only after hydration to avoid SSR mismatch */} {/* Videos render only after hydration to avoid SSR mismatch */}
{mounted && ( {mounted && (
<> <>
{/* Mobile: single centered video */} {/* Mobile: single centered video */}
<div className="absolute inset-0 md:hidden"> <div className="absolute inset-0 md:hidden">
<video <video
autoPlay muted loop playsInline preload="auto" autoPlay muted loop playsInline preload="metadata"
onCanPlayThrough={handleVideoReady} onCanPlayThrough={handleVideoReady}
className="absolute inset-0 h-full w-full object-cover object-center" className="absolute inset-0 h-full w-full object-cover object-center"
> >
@@ -129,7 +139,7 @@ export function Hero({ data: hero }: HeroProps) {
}} }}
> >
<video <video
autoPlay muted loop playsInline preload="auto" autoPlay muted loop playsInline preload="metadata"
onCanPlayThrough={handleVideoReady} onCanPlayThrough={handleVideoReady}
className="absolute inset-0 h-full w-full object-cover object-center" className="absolute inset-0 h-full w-full object-cover object-center"
> >
@@ -151,7 +161,7 @@ export function Hero({ data: hero }: HeroProps) {
{/* Loading overlay — covers videos but not content */} {/* Loading overlay — covers videos but not content */}
<div <div
ref={overlayRef} ref={overlayRef}
className="absolute inset-0 z-[5] bg-neutral-950 pointer-events-none transition-opacity duration-1000" className="absolute inset-0 z-[5] bg-neutral-100 dark:bg-neutral-950 pointer-events-none transition-opacity duration-1000"
/> />
{/* Vignette — dark edges to guide eye to center */} {/* Vignette — dark edges to guide eye to center */}
@@ -164,6 +174,7 @@ export function Hero({ data: hero }: HeroProps) {
{/* Content */} {/* Content */}
<div className="section-container relative z-10 text-center" style={{ textShadow: "0 1px 0 rgba(201,169,110,0.3), 0 2px 0 rgba(201,169,110,0.2), 0 4px 8px rgba(0,0,0,0.4), 0 8px 20px rgba(0,0,0,0.3)" }}> <div className="section-container relative z-10 text-center" style={{ textShadow: "0 1px 0 rgba(201,169,110,0.3), 0 2px 0 rgba(201,169,110,0.2), 0 4px 8px rgba(0,0,0,0.4), 0 8px 20px rgba(0,0,0,0.3)" }}>
{/* Blackheart logotype — commented out for portfolio version
<div className="hero-logo relative mx-auto mb-6 sm:mb-12 flex items-center justify-center" style={{ width: 160, height: 132 }}> <div className="hero-logo relative mx-auto mb-6 sm:mb-12 flex items-center justify-center" style={{ width: 160, height: 132 }}>
<div className="absolute -inset-10 rounded-full blur-[80px]" style={{ background: "radial-gradient(circle, rgba(201,169,110,0.25), transparent 70%)" }} /> <div className="absolute -inset-10 rounded-full blur-[80px]" style={{ background: "radial-gradient(circle, rgba(201,169,110,0.25), transparent 70%)" }} />
<div className="hero-logo-heartbeat relative"> <div className="hero-logo-heartbeat relative">
@@ -173,19 +184,20 @@ export function Hero({ data: hero }: HeroProps) {
/> />
</div> </div>
</div> </div>
*/}
<h1 className="hero-title font-display text-4xl font-bold tracking-tight sm:text-6xl lg:text-8xl"> <h1 className="hero-title font-display text-4xl font-bold tracking-tight sm:text-6xl lg:text-8xl">
<span className="gradient-text">{hero.headline}</span> <span className="gradient-text">{hero.headline}</span>
</h1> </h1>
<p className="hero-subtitle mx-auto mt-5 max-w-xl text-lg text-gold/80 sm:mt-8 sm:text-2xl"> <p className="hero-subtitle mx-auto mt-5 max-w-xl text-lg text-gold-light sm:mt-8 sm:text-2xl">
{hero.subheadline} {hero.subheadline}
</p> </p>
<div className="hero-cta mt-8 sm:mt-14"> <div className="hero-cta mt-8 sm:mt-14">
<button <button
onClick={openBooking} onClick={openBooking}
className="group relative rounded-full border border-gold/60 bg-gold/15 px-8 py-4 text-base font-semibold text-gold backdrop-blur-md transition-all duration-300 hover:bg-gold/25 hover:border-gold hover:shadow-[0_0_40px_rgba(201,169,110,0.35)] cursor-pointer sm:px-10 sm:py-5 sm:text-lg" className="group relative rounded-full border border-gold bg-gold/20 px-8 py-4 text-base font-semibold text-gold-light backdrop-blur-md shadow-[0_0_30px_rgba(201,169,110,0.25)] transition-all duration-300 hover:bg-gold/30 hover:shadow-[0_0_50px_rgba(201,169,110,0.45)] cursor-pointer sm:px-10 sm:py-5 sm:text-lg"
> >
<span className="relative z-10">{hero.ctaText}</span> <span className="relative z-10">{hero.ctaText}</span>
{/* Pulse glow on hover */} {/* Pulse glow on hover */}
+23 -14
View File
@@ -6,6 +6,7 @@ import { Calendar, Clock, User, MapPin, Instagram, X } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { SignupModal } from "@/components/ui/SignupModal"; import { SignupModal } from "@/components/ui/SignupModal";
import { useFocusTrap } from "@/hooks/useFocusTrap";
import type { SiteContent, MasterClassItem, MasterClassSlot, ScheduleLocation } from "@/types"; import type { SiteContent, MasterClassItem, MasterClassSlot, ScheduleLocation } from "@/types";
import { formatMarkup } from "@/lib/markup"; import { formatMarkup } from "@/lib/markup";
@@ -119,6 +120,7 @@ function MasterClassDetail({
const slots = item.slots ?? []; const slots = item.slots ?? [];
const duration = slots[0] ? calcDuration(slots[0]) : ""; const duration = slots[0] ? calcDuration(slots[0]) : "";
const locAddress = locations?.find(l => l.name === item.location)?.address; const locAddress = locations?.find(l => l.name === item.location)?.address;
const focusTrapRef = useFocusTrap<HTMLDivElement>(true);
useEffect(() => { useEffect(() => {
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
@@ -130,10 +132,11 @@ function MasterClassDetail({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={onClose}> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={onClose}>
<div <div
ref={focusTrapRef}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={item.title} aria-label={item.title}
className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border border-white/10 bg-neutral-950 shadow-2xl" className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border border-neutral-200 bg-white dark:border-white/10 dark:bg-neutral-950 shadow-2xl"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Content */} {/* Content */}
@@ -144,7 +147,7 @@ function MasterClassDetail({
{item.style} {item.style}
</span> </span>
{duration && ( {duration && (
<span className="flex items-center gap-1 text-xs text-white/50"> <span className="flex items-center gap-1 text-xs text-neutral-500 dark:text-white/50">
<Clock size={11} /> <Clock size={11} />
{duration} {duration}
</span> </span>
@@ -158,19 +161,19 @@ function MasterClassDetail({
<button <button
onClick={onClose} onClick={onClose}
aria-label="Закрыть" aria-label="Закрыть"
className="h-9 w-9 flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors shrink-0 -mr-2" className="h-11 w-11 flex items-center justify-center rounded-full text-neutral-400 hover:text-neutral-900 hover:bg-neutral-100 dark:text-white/50 dark:hover:text-white dark:hover:bg-white/10 transition-colors shrink-0 -mr-2"
> >
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
{/* Title */} {/* Title */}
<h2 className="text-2xl font-bold text-white">{item.title}</h2> <h2 className="text-2xl font-bold text-neutral-900 dark:text-white">{item.title}</h2>
{/* Trainer */} {/* Trainer */}
<button <button
onClick={() => window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: item.trainer.split(" · ")[0] }))} onClick={() => window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: item.trainer.split(" · ")[0] }))}
className="flex items-center gap-2 text-sm text-white/80 hover:text-gold transition-colors" className="flex items-center gap-2 text-sm text-neutral-700 hover:text-gold dark:text-white/80 transition-colors"
> >
<User size={14} /> <User size={14} />
{item.trainer} {item.trainer}
@@ -178,14 +181,14 @@ function MasterClassDetail({
{/* Description */} {/* Description */}
{item.description && ( {item.description && (
<div className="text-sm leading-relaxed text-white/60"> <div className="text-sm leading-relaxed text-neutral-600 dark:text-white/60">
{formatMarkup(item.description)} {formatMarkup(item.description)}
</div> </div>
)} )}
{/* All dates */} {/* All dates */}
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-medium text-white/40 uppercase tracking-wider">Даты</h3> <h3 className="text-sm font-medium text-neutral-400 dark:text-white/40 uppercase tracking-wider">Даты</h3>
{slots.length === 0 ? ( {slots.length === 0 ? (
<p className="text-sm text-gold">Скоро дата уточняется</p> <p className="text-sm text-gold">Скоро дата уточняется</p>
) : ( ) : (
@@ -195,11 +198,11 @@ function MasterClassDetail({
return ( return (
<div key={i} className="flex items-center gap-3 text-sm"> <div key={i} className="flex items-center gap-3 text-sm">
<Calendar size={13} className="shrink-0 text-gold/60" /> <Calendar size={13} className="shrink-0 text-gold/60" />
<span className="text-white/80"> <span className="text-neutral-700 dark:text-white/80">
{d.getDate()} {MONTHS_RU[d.getMonth()]} ({WEEKDAYS_RU[d.getDay()]}) {d.getDate()} {MONTHS_RU[d.getMonth()]} ({WEEKDAYS_RU[d.getDay()]})
</span> </span>
{slot.startTime && ( {slot.startTime && (
<span className="text-white/50"> <span className="text-neutral-500 dark:text-white/50">
{slot.startTime}{slot.endTime} {slot.startTime}{slot.endTime}
</span> </span>
)} )}
@@ -212,7 +215,7 @@ function MasterClassDetail({
{/* Location */} {/* Location */}
{item.location && ( {item.location && (
<div className="flex items-center gap-2 text-sm text-white/60"> <div className="flex items-center gap-2 text-sm text-neutral-500 dark:text-white/60">
<MapPin size={13} className="text-gold/60" /> <MapPin size={13} className="text-gold/60" />
<span>{item.location}{locAddress ? ` · ${locAddress}` : ""}</span> <span>{item.location}{locAddress ? ` · ${locAddress}` : ""}</span>
</div> </div>
@@ -224,7 +227,7 @@ function MasterClassDetail({
href={item.instagramUrl} href={item.instagramUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-gold transition-colors" className="inline-flex items-center gap-2 text-sm text-neutral-400 hover:text-gold dark:text-white/50 transition-colors"
> >
<Instagram size={14} /> <Instagram size={14} />
Подробнее в Instagram Подробнее в Instagram
@@ -264,7 +267,13 @@ function MasterClassCard({
const isFull = maxP > 0 && currentRegs >= maxP; const isFull = maxP > 0 && currentRegs >= maxP;
return ( return (
<div className="group relative flex w-full max-w-sm flex-col overflow-hidden rounded-2xl bg-black cursor-pointer" onClick={onDetail}> <div
className="group relative flex w-full max-w-sm flex-col overflow-hidden rounded-2xl bg-black cursor-pointer"
onClick={onDetail}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onDetail(); } }}
role="button"
tabIndex={0}
>
{/* Full-bleed image or placeholder */} {/* Full-bleed image or placeholder */}
<div className="relative aspect-[3/4] sm:aspect-[2/3] w-full overflow-hidden"> <div className="relative aspect-[3/4] sm:aspect-[2/3] w-full overflow-hidden">
{item.image ? ( {item.image ? (
@@ -425,9 +434,9 @@ export function MasterClasses({ data, regCounts = {}, popups, locations }: Maste
</div> </div>
) : ( ) : (
<div className="mx-auto mt-10 flex max-w-5xl flex-wrap justify-center gap-5"> <div className="mx-auto mt-10 flex max-w-5xl flex-wrap justify-center gap-5">
{upcoming.map((item) => ( {upcoming.map((item, idx) => (
<MasterClassCard <MasterClassCard
key={item.title} key={`${item.title}-${idx}`}
item={item} item={item}
currentRegs={regCounts[item.title] ?? 0} currentRegs={regCounts[item.title] ?? 0}
onSignup={() => setSignupTitle(item.title)} onSignup={() => setSignupTitle(item.title)}
+10 -10
View File
@@ -32,7 +32,7 @@ function FeaturedArticle({
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className="group relative overflow-hidden rounded-3xl cursor-pointer" className="group relative overflow-hidden rounded-3xl shadow-md shadow-gold/[0.06] dark:shadow-none cursor-pointer"
onClick={onClick} onClick={onClick}
> >
{item.image && ( {item.image && (
@@ -53,16 +53,16 @@ function FeaturedArticle({
</div> </div>
)} )}
<div <div
className={`${item.image ? "absolute bottom-0 left-0 right-0 p-6 sm:p-8" : "p-6 sm:p-8 bg-neutral-900 rounded-3xl"}`} className={`${item.image ? "absolute bottom-0 left-0 right-0 p-6 sm:p-8" : "p-6 sm:p-8 bg-neutral-100 dark:bg-neutral-900 rounded-3xl"}`}
> >
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1 text-xs font-medium text-white/80 backdrop-blur-sm"> <span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium backdrop-blur-sm ${item.image ? "bg-white/15 text-white/80" : "bg-neutral-200 text-neutral-600 dark:bg-white/15 dark:text-white/80"}`}>
<Calendar size={12} /> <Calendar size={12} />
<time dateTime={item.date}>{formatDateRu(item.date)}</time> <time dateTime={item.date}>{formatDateRu(item.date)}</time>
</span> </span>
<h3 className="mt-3 text-xl sm:text-2xl font-bold text-white leading-tight"> <h3 className={`mt-3 text-xl sm:text-2xl font-bold leading-tight ${item.image ? "text-white" : "text-neutral-900 dark:text-white"}`}>
{item.title} {item.title}
</h3> </h3>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-white/70 line-clamp-3"> <p className={`mt-2 max-w-2xl text-sm leading-relaxed line-clamp-3 ${item.image ? "text-white/70" : "text-neutral-600 dark:text-white/70"}`}>
{item.text} {item.text}
</p> </p>
</div> </div>
@@ -89,7 +89,7 @@ function CompactArticle({
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className="group flex gap-4 items-start py-5 border-b border-neutral-200/60 last:border-0 dark:border-white/[0.06] cursor-pointer" className="group flex gap-4 items-start py-5 border-b border-neutral-200 last:border-0 dark:border-white/[0.06] cursor-pointer"
onClick={onClick} onClick={onClick}
> >
{item.image && ( {item.image && (
@@ -109,7 +109,7 @@ function CompactArticle({
</div> </div>
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<time dateTime={item.date} className="text-xs text-neutral-400 dark:text-white/30"> <time dateTime={item.date} className="text-xs text-neutral-500 dark:text-white/30">
{formatDateRu(item.date)} {formatDateRu(item.date)}
</time> </time>
<h3 className="mt-1 text-sm sm:text-base font-bold text-neutral-900 dark:text-white leading-snug line-clamp-2 group-hover:text-gold transition-colors"> <h3 className="mt-1 text-sm sm:text-base font-bold text-neutral-900 dark:text-white leading-snug line-clamp-2 group-hover:text-gold transition-colors">
@@ -180,7 +180,7 @@ export function News({ data }: NewsProps) {
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}} }}
disabled={page === 0} disabled={page === 0}
className="rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-medium text-neutral-400 hover:text-white hover:border-white/25 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed" className="rounded-full border border-neutral-200 bg-neutral-100 px-4 py-2 text-sm font-medium text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25"
> >
</button> </button>
@@ -197,7 +197,7 @@ export function News({ data }: NewsProps) {
className={`h-10 w-10 rounded-full text-sm font-medium transition-colors cursor-pointer ${ className={`h-10 w-10 rounded-full text-sm font-medium transition-colors cursor-pointer ${
i === page i === page
? "bg-gold text-black" ? "bg-gold text-black"
: "border border-white/10 text-neutral-400 hover:text-white hover:border-white/25" : "border border-neutral-200 text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25"
}`} }`}
> >
{i + 1} {i + 1}
@@ -211,7 +211,7 @@ export function News({ data }: NewsProps) {
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}} }}
disabled={page === totalPages - 1} disabled={page === totalPages - 1}
className="rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-medium text-neutral-400 hover:text-white hover:border-white/25 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed" className="rounded-full border border-neutral-200 bg-neutral-100 px-4 py-2 text-sm font-medium text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25"
> >
</button> </button>
+21 -28
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { useTrainerPhotos } from "@/hooks/useTrainerPhotos";
import Image from "next/image"; import Image from "next/image";
import { Calendar, Sparkles, User, MapPin } from "lucide-react"; import { Calendar, Sparkles, User, MapPin } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
@@ -33,15 +34,7 @@ export function OpenDay({ data, popups, teamMembers, locations }: OpenDayProps)
const { event, classes } = data; const { event, classes } = data;
const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null); const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
const trainerPhotos = useMemo(() => { const trainerPhotos = useTrainerPhotos(teamMembers);
const map: Record<string, string> = {};
if (teamMembers) {
for (const m of teamMembers) {
if (m.image) map[m.name] = m.image;
}
}
return map;
}, [teamMembers]);
// Group classes by hall // Group classes by hall
const hallGroups = useMemo(() => { const hallGroups = useMemo(() => {
@@ -92,8 +85,8 @@ export function OpenDay({ data, popups, teamMembers, locations }: OpenDayProps)
{/* Pricing info */} {/* Pricing info */}
<Reveal> <Reveal>
<div className="mt-6 text-center space-y-1"> <div className="mt-6 text-center space-y-1">
<p className="text-lg font-semibold text-white"> <p className="text-lg font-semibold text-neutral-900 dark:text-white">
{event.pricePerClass} BYN <span className="text-neutral-400 font-normal text-sm">за занятие</span> {event.pricePerClass} BYN <span className="text-neutral-500 dark:text-neutral-400 font-normal text-sm">за занятие</span>
</p> </p>
{event.discountPrice > 0 && event.discountThreshold > 0 && ( {event.discountPrice > 0 && event.discountThreshold > 0 && (
<p className="text-sm text-gold"> <p className="text-sm text-gold">
@@ -106,7 +99,7 @@ export function OpenDay({ data, popups, teamMembers, locations }: OpenDayProps)
{event.description && ( {event.description && (
<Reveal> <Reveal>
<div className="mt-4 text-center text-sm text-neutral-400 max-w-2xl mx-auto"> <div className="mt-4 text-center text-sm text-neutral-500 dark:text-neutral-400 max-w-2xl mx-auto">
{formatMarkup(event.description)} {formatMarkup(event.description)}
</div> </div>
</Reveal> </Reveal>
@@ -119,7 +112,7 @@ export function OpenDay({ data, popups, teamMembers, locations }: OpenDayProps)
<Reveal> <Reveal>
<div className="max-w-lg mx-auto space-y-3"> <div className="max-w-lg mx-auto space-y-3">
<div className="text-center mb-4"> <div className="text-center mb-4">
<h3 className="text-base font-semibold text-white">{halls[0]}</h3> <h3 className="text-base font-semibold text-neutral-900 dark:text-white">{halls[0]}</h3>
{hallAddress[halls[0]] && ( {hallAddress[halls[0]] && (
<p className="text-sm text-gold/70 mt-0.5 flex items-center justify-center gap-1.5"> <p className="text-sm text-gold/70 mt-0.5 flex items-center justify-center gap-1.5">
<MapPin size={13} /> <MapPin size={13} />
@@ -144,8 +137,8 @@ export function OpenDay({ data, popups, teamMembers, locations }: OpenDayProps)
{halls.map((hall) => ( {halls.map((hall) => (
<Reveal key={hall}> <Reveal key={hall}>
<div> <div>
<div className="text-center mb-4 rounded-lg bg-white/[0.03] border border-white/[0.06] py-3 px-4"> <div className="text-center mb-4 rounded-lg bg-neutral-50 border border-neutral-200 py-3 px-4 dark:bg-white/[0.03] dark:border-white/[0.06]">
<h3 className="text-base font-semibold text-white">{hall}</h3> <h3 className="text-base font-semibold text-neutral-900 dark:text-white">{hall}</h3>
{hallAddress[hall] && ( {hallAddress[hall] && (
<p className="text-sm text-gold/70 mt-0.5 flex items-center justify-center gap-1.5"> <p className="text-sm text-gold/70 mt-0.5 flex items-center justify-center gap-1.5">
<MapPin size={13} /> <MapPin size={13} />
@@ -203,15 +196,15 @@ function ClassCard({
if (cls.cancelled) { if (cls.cancelled) {
return ( return (
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3 sm:p-4 opacity-50"> <div className="rounded-xl border border-neutral-200 bg-neutral-50 p-3 sm:p-4 opacity-50 dark:border-white/[0.06] dark:bg-white/[0.02]">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0 space-y-1"> <div className="flex-1 min-w-0 space-y-1">
<span className="rounded-md bg-neutral-800 px-2 py-0.5 text-xs font-bold text-neutral-500"> <span className="rounded-md bg-neutral-200 px-2 py-0.5 text-xs font-bold text-neutral-500 dark:bg-neutral-800">
<time dateTime={`${cls.startTime}-${cls.endTime}`}>{cls.startTime}{cls.endTime}</time> <time dateTime={`${cls.startTime}-${cls.endTime}`}>{cls.startTime}{cls.endTime}</time>
</span> </span>
<p className="text-sm text-neutral-500"><del>{cls.trainer} · {cls.style}</del></p> <p className="text-sm text-neutral-500"><del>{cls.trainer} · {cls.style}</del></p>
</div> </div>
<span className="text-xs text-neutral-500 bg-neutral-800 rounded-full px-2.5 py-0.5 font-medium"> <span className="text-xs text-neutral-500 bg-neutral-200 rounded-full px-2.5 py-0.5 font-medium dark:bg-neutral-800">
Отменено Отменено
</span> </span>
</div> </div>
@@ -224,8 +217,8 @@ function ClassCard({
return ( return (
<div className={`rounded-xl border transition-all ${ <div className={`rounded-xl border transition-all ${
isFull isFull
? "border-white/[0.04] bg-white/[0.01]" ? "border-neutral-200 bg-neutral-50/50 dark:border-white/[0.04] dark:bg-white/[0.01]"
: "border-white/[0.06] bg-white/[0.02] hover:border-white/[0.12] hover:bg-white/[0.04]" : "border-neutral-200 bg-white hover:border-neutral-300 hover:bg-neutral-50 dark:border-white/[0.06] dark:bg-white/[0.02] dark:hover:border-white/[0.12] dark:hover:bg-white/[0.04]"
}`}> }`}>
<div className="flex items-start gap-3 p-3 sm:p-4"> <div className="flex items-start gap-3 p-3 sm:p-4">
{/* Trainer photo */} {/* Trainer photo */}
@@ -234,14 +227,14 @@ function ClassCard({
window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer })); window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer }));
}} }}
aria-label={`Профиль тренера: ${cls.trainer}`} aria-label={`Профиль тренера: ${cls.trainer}`}
className="relative flex items-center justify-center h-11 w-11 rounded-full overflow-hidden shrink-0 ring-1 ring-white/10 hover:ring-gold/30 transition-all cursor-pointer mt-0.5" className="relative flex items-center justify-center h-11 w-11 rounded-full overflow-hidden shrink-0 ring-1 ring-neutral-200 hover:ring-gold/30 transition-all cursor-pointer mt-0.5 dark:ring-white/10"
title={`Подробнее о ${cls.trainer}`} title={`Подробнее о ${cls.trainer}`}
> >
{trainerPhoto ? ( {trainerPhoto ? (
<Image src={trainerPhoto} alt={cls.trainer} fill className="object-cover" sizes="44px" /> <Image src={trainerPhoto} alt={cls.trainer} fill className="object-cover" sizes="44px" />
) : ( ) : (
<div className="flex items-center justify-center h-full w-full bg-white/[0.06]"> <div className="flex items-center justify-center h-full w-full bg-neutral-100 dark:bg-white/[0.06]">
<User size={16} className="text-white/40" /> <User size={16} className="text-neutral-400 dark:text-white/40" />
</div> </div>
)} )}
</button> </button>
@@ -252,7 +245,7 @@ function ClassCard({
onClick={() => { onClick={() => {
window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer })); window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer }));
}} }}
className="text-sm font-semibold text-white/90 hover:text-gold transition-colors cursor-pointer" className="text-sm font-semibold text-neutral-900 hover:text-gold transition-colors cursor-pointer dark:text-white/90"
> >
{cls.trainer} {cls.trainer}
</button> </button>
@@ -263,7 +256,7 @@ function ClassCard({
<span className="rounded-md bg-gold/10 px-2 py-0.5 text-xs font-bold text-gold min-w-[80px] text-center"> <span className="rounded-md bg-gold/10 px-2 py-0.5 text-xs font-bold text-gold min-w-[80px] text-center">
<time dateTime={`${cls.startTime}-${cls.endTime}`}>{cls.startTime}{cls.endTime}</time> <time dateTime={`${cls.startTime}-${cls.endTime}`}>{cls.startTime}{cls.endTime}</time>
</span> </span>
<span className="text-sm font-medium text-white/60">{cls.style}</span> <span className="text-sm font-medium text-neutral-500 dark:text-white/60">{cls.style}</span>
</div> </div>
</div> </div>
@@ -272,8 +265,8 @@ function ClassCard({
{maxParticipants > 0 && ( {maxParticipants > 0 && (
<span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold ${ <span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold ${
isFull isFull
? "bg-amber-500/15 border border-amber-500/25 text-amber-400" ? "bg-amber-500/15 border border-amber-500/25 text-amber-500 dark:text-amber-400"
: "bg-white/[0.04] border border-white/[0.08] text-white/45" : "bg-neutral-100 border border-neutral-200 text-neutral-600 dark:bg-white/[0.04] dark:border-white/[0.08] dark:text-white/45"
}`}> }`}>
{cls.bookingCount}/{maxParticipants} мест {cls.bookingCount}/{maxParticipants} мест
</span> </span>
@@ -287,7 +280,7 @@ function ClassCard({
className={`shrink-0 self-center rounded-xl px-4 py-2.5 text-xs font-semibold transition-all cursor-pointer ${ className={`shrink-0 self-center rounded-xl px-4 py-2.5 text-xs font-semibold transition-all cursor-pointer ${
isFull isFull
? "bg-amber-500/10 border border-amber-500/25 text-amber-400 hover:bg-amber-500/20 hover:border-amber-500/40" ? "bg-amber-500/10 border border-amber-500/25 text-amber-400 hover:bg-amber-500/20 hover:border-amber-500/40"
: "bg-gold/10 border border-gold/25 text-gold hover:bg-gold/20 hover:border-gold/40" : "bg-gold/10 border border-gold/25 text-gold hover:bg-gold/20 hover:border-gold/40 dark:bg-gold/5 dark:border-gold/15"
}`} }`}
> >
{isFull ? "Лист ожидания" : "Записаться"} {isFull ? "Лист ожидания" : "Записаться"}
+3 -3
View File
@@ -65,7 +65,7 @@ export function Pricing({ data: pricing }: PricingProps) {
className={`inline-flex items-center gap-2 rounded-full px-6 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${ className={`inline-flex items-center gap-2 rounded-full px-6 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
activeTab === tab.id activeTab === tab.id
? "bg-gold text-black shadow-lg shadow-gold/25" ? "bg-gold text-black shadow-lg shadow-gold/25"
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-white/[0.06] dark:text-neutral-300 dark:hover:bg-white/[0.1]" : "bg-white border border-neutral-300 text-neutral-600 hover:bg-neutral-50 hover:border-neutral-400 dark:border-transparent dark:bg-white/[0.06] dark:text-neutral-300 dark:hover:bg-white/[0.1]"
}`} }`}
> >
{tab.icon} {tab.icon}
@@ -92,7 +92,7 @@ export function Pricing({ data: pricing }: PricingProps) {
className={`group relative rounded-2xl border p-5 transition-all duration-300 ${ className={`group relative rounded-2xl border p-5 transition-all duration-300 ${
isPopular isPopular
? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10" ? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10"
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-neutral-950" : "border-neutral-200 bg-white shadow-sm shadow-gold/[0.04] hover:shadow-md hover:shadow-gold/[0.08] dark:border-white/[0.06] dark:bg-neutral-950 dark:shadow-none dark:hover:shadow-none"
}`} }`}
> >
{/* Popular badge */} {/* Popular badge */}
@@ -113,7 +113,7 @@ export function Pricing({ data: pricing }: PricingProps) {
{/* Note */} {/* Note */}
{item.note && ( {item.note && (
<p className="mt-1 text-xs text-neutral-400 dark:text-neutral-500"> <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-500">
{item.note} {item.note}
</p> </p>
)} )}
+14 -17
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useReducer, useMemo, useCallback } from "react"; import { useReducer, useMemo, useCallback } from "react";
import { useTrainerPhotos } from "@/hooks/useTrainerPhotos";
import { SignupModal } from "@/components/ui/SignupModal"; import { SignupModal } from "@/components/ui/SignupModal";
import { CalendarDays, Users, LayoutGrid, SlidersHorizontal, MapPin } from "lucide-react"; import { CalendarDays, Users, LayoutGrid, SlidersHorizontal, MapPin } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
@@ -135,15 +136,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]); const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]);
const trainerPhotos = useMemo(() => { const trainerPhotos = useTrainerPhotos(teamMembers);
const map: Record<string, string> = {};
if (teamMembers) {
for (const m of teamMembers) {
if (m.image) map[m.name] = m.image;
}
}
return map;
}, [teamMembers]);
// Build days: either from one location or merged from all // Build days: either from one location or merged from all
const activeDays: ScheduleDayMerged[] = useMemo(() => { const activeDays: ScheduleDayMerged[] = useMemo(() => {
@@ -251,14 +244,14 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
classes: day.classes.filter( classes: day.classes.filter(
(cls) => { (cls) => {
const clsStatus = cls.status || (cls.recruiting ? "recruiting" : cls.hasSlots ? "hasSlots" : ""); const clsStatus = cls.status || (cls.recruiting ? "recruiting" : cls.hasSlots ? "hasSlots" : "");
const matchesTime = !activeTimeRange || (
startTimeMinutes(cls.time) >= activeTimeRange[0] && startTimeMinutes(cls.time) < activeTimeRange[1]
);
return (filterTrainerSet.size === 0 || filterTrainerSet.has(cls.trainer)) && return (filterTrainerSet.size === 0 || filterTrainerSet.has(cls.trainer)) &&
(filterTypes.size === 0 || filterTypes.has(cls.type)) && (filterTypes.size === 0 || filterTypes.has(cls.type)) &&
(filterStatusSet.size === 0 || (clsStatus && filterStatusSet.has(clsStatus))) && (filterStatusSet.size === 0 || (clsStatus && filterStatusSet.has(clsStatus))) &&
(!filterLevel || cls.level === filterLevel) && (!filterLevel || cls.level === filterLevel) &&
(!activeTimeRange || (() => { matchesTime;
const m = startTimeMinutes(cls.time);
return m >= activeTimeRange[0] && m < activeTimeRange[1];
})());
}), }),
})) }))
.filter((day) => day.classes.length > 0); .filter((day) => day.classes.length > 0);
@@ -343,7 +336,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
<span className="block leading-tight">{loc.name}</span> <span className="block leading-tight">{loc.name}</span>
{loc.address && ( {loc.address && (
<span className={`block text-xs font-normal leading-tight mt-0.5 ${ <span className={`block text-xs font-normal leading-tight mt-0.5 ${
locationMode === i ? "text-black/60" : "text-neutral-400 dark:text-white/25" locationMode === i ? "text-black/60" : "text-neutral-500 dark:text-white/25"
}`}> }`}>
{shortAddress(loc.address)} {shortAddress(loc.address)}
</span> </span>
@@ -384,8 +377,10 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
{/* View mode toggle + filter button */} {/* View mode toggle + filter button */}
<Reveal> <Reveal>
<div className="mt-4 hidden sm:flex items-center justify-center"> <div className="mt-4 hidden sm:flex items-center justify-center">
<div className="inline-flex items-center rounded-xl border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.08] dark:bg-white/[0.04]"> <div className="inline-flex items-center rounded-xl border border-neutral-300 bg-neutral-200/60 p-1 dark:border-white/[0.08] dark:bg-white/[0.04]" role="tablist" aria-label="Режим отображения">
<button <button
role="tab"
aria-selected={viewMode === "days"}
onClick={() => dispatch({ type: "SET_VIEW", mode: "days" })} onClick={() => dispatch({ type: "SET_VIEW", mode: "days" })}
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${ className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
viewMode === "days" viewMode === "days"
@@ -397,6 +392,8 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
По дням По дням
</button> </button>
<button <button
role="tab"
aria-selected={viewMode === "groups"}
onClick={() => dispatch({ type: "SET_VIEW", mode: "groups" })} onClick={() => dispatch({ type: "SET_VIEW", mode: "groups" })}
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${ className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
viewMode === "groups" viewMode === "groups"
@@ -409,7 +406,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
</button> </button>
{/* Divider */} {/* Divider */}
<span className="mx-1 h-5 w-px bg-white/[0.08]" /> <span className="mx-1 h-5 w-px bg-neutral-200 dark:bg-white/[0.08]" />
<ScheduleFilters <ScheduleFilters
typeDots={typeDots} typeDots={typeDots}
@@ -473,7 +470,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
))} ))}
{filteredDays.length === 0 && ( {filteredDays.length === 0 && (
<div className="col-span-full py-12 text-center text-sm text-neutral-400 dark:text-white/30"> <div className="col-span-full py-12 text-center text-sm text-neutral-500 dark:text-white/30">
Нет занятий по выбранным фильтрам Нет занятий по выбранным фильтрам
</div> </div>
)} )}
+5 -5
View File
@@ -34,7 +34,7 @@ function ClassRow({
return ( return (
<div className="px-5 py-3.5"> <div className="px-5 py-3.5">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-neutral-500 dark:text-white/40"> <div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-white/40">
<Clock size={13} /> <Clock size={13} />
<span className="font-semibold">{cls.time}</span> <span className="font-semibold">{cls.time}</span>
</div> </div>
@@ -59,7 +59,7 @@ function ClassRow({
className="flex items-center gap-2 cursor-pointer active:opacity-60" className="flex items-center gap-2 cursor-pointer active:opacity-60"
> >
<span className={`h-2 w-2 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} /> <span className={`h-2 w-2 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
<span className="text-xs text-neutral-500 dark:text-white/40">{cls.type}</span> <span className="text-xs text-neutral-600 dark:text-white/40">{cls.type}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -82,7 +82,7 @@ export function DayCard({ day, typeDots, showLocation, filterTrainerSet, toggleF
: null; : null;
return ( return (
<div className="rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a] overflow-hidden"> <div className="rounded-2xl border border-neutral-200 bg-white shadow-sm shadow-gold/[0.04] dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:shadow-none overflow-hidden">
{/* Day header */} {/* Day header */}
<div className="border-b border-neutral-100 bg-neutral-50 px-5 py-4 dark:border-white/[0.04] dark:bg-white/[0.02]"> <div className="border-b border-neutral-100 bg-neutral-50 px-5 py-4 dark:border-white/[0.04] dark:bg-white/[0.02]">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -104,10 +104,10 @@ export function DayCard({ day, typeDots, showLocation, filterTrainerSet, toggleF
{/* Location sub-header */} {/* Location sub-header */}
<div className={`flex items-center gap-1.5 px-5 py-2 bg-gold/10 ${gi > 0 ? "border-t border-gold/10" : ""}`}> <div className={`flex items-center gap-1.5 px-5 py-2 bg-gold/10 ${gi > 0 ? "border-t border-gold/10" : ""}`}>
<MapPin size={11} className="shrink-0 text-gold" /> <MapPin size={11} className="shrink-0 text-gold" />
<span className="text-[11px] font-medium text-white"> <span className="text-[11px] font-medium text-neutral-800 dark:text-white">
{locName} {locName}
{address && shortAddress(address) !== locName && ( {address && shortAddress(address) !== locName && (
<span className="text-white/50"> · {shortAddress(address)}</span> <span className="text-neutral-500 dark:text-white/50"> · {shortAddress(address)}</span>
)} )}
</span> </span>
</div> </div>
@@ -163,7 +163,7 @@ export function GroupView({
if (groups.length === 0) { if (groups.length === 0) {
return ( return (
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30"> <div className="py-12 text-center text-sm text-neutral-600 dark:text-white/30">
Нет занятий по выбранным фильтрам Нет занятий по выбранным фильтрам
</div> </div>
); );
@@ -187,7 +187,7 @@ export function GroupView({
window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: trainer })); window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: trainer }));
}} }}
className={`relative flex items-center justify-center h-9 w-9 rounded-full overflow-hidden transition-all cursor-pointer ${ className={`relative flex items-center justify-center h-9 w-9 rounded-full overflow-hidden transition-all cursor-pointer ${
isActive ? "ring-2 ring-gold/50" : "ring-1 ring-white/10 hover:ring-gold/30" isActive ? "ring-2 ring-gold/50" : "ring-1 ring-neutral-200 hover:ring-gold/30 dark:ring-white/10"
}`} }`}
title={`Подробнее о ${trainer}`} title={`Подробнее о ${trainer}`}
> >
@@ -200,8 +200,8 @@ export function GroupView({
sizes="36px" sizes="36px"
/> />
) : ( ) : (
<div className={`flex items-center justify-center h-full w-full ${isActive ? "bg-gold/20" : "bg-white/[0.06]"}`}> <div className={`flex items-center justify-center h-full w-full ${isActive ? "bg-gold/20" : "bg-neutral-100 dark:bg-white/[0.06]"}`}>
<User size={14} className={isActive ? "text-gold" : "text-white/40"} /> <User size={14} className={isActive ? "text-gold" : "text-neutral-500 dark:text-white/40"} />
</div> </div>
)} )}
</button> </button>
@@ -211,7 +211,7 @@ export function GroupView({
className="cursor-pointer group" className="cursor-pointer group"
> >
<span className={`text-base font-semibold transition-colors ${ <span className={`text-base font-semibold transition-colors ${
isActive ? "text-gold" : "text-white/90 group-hover:text-gold" isActive ? "text-gold" : "text-neutral-900 group-hover:text-gold dark:text-white/90"
}`}> }`}>
{trainer} {trainer}
</span> </span>
@@ -236,7 +236,7 @@ export function GroupView({
className={`rounded-xl border transition-all ${ className={`rounded-xl border transition-all ${
false false
? "border-gold/20 bg-gold/[0.03] hover:border-gold/30 hover:bg-gold/[0.05]" ? "border-gold/20 bg-gold/[0.03] hover:border-gold/30 hover:bg-gold/[0.05]"
: "border-white/[0.06] bg-white/[0.02] hover:border-white/[0.12] hover:bg-white/[0.04]" : "border-neutral-200 bg-white hover:border-neutral-300 hover:bg-neutral-50 dark:border-white/[0.06] dark:bg-white/[0.02] dark:hover:border-white/[0.12] dark:hover:bg-white/[0.04]"
}`} }`}
> >
<div className="flex items-start gap-3 p-3 sm:p-4"> <div className="flex items-start gap-3 p-3 sm:p-4">
@@ -67,10 +67,10 @@ function ClassRow({
className={`flex items-center gap-1.5 active:opacity-60 ${filterTypes.has(cls.type) ? "opacity-100" : ""}`} className={`flex items-center gap-1.5 active:opacity-60 ${filterTypes.has(cls.type) ? "opacity-100" : ""}`}
> >
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} /> <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
<span className="text-[11px] text-neutral-400 dark:text-white/30">{cls.type}</span> <span className="text-[11px] text-neutral-600 dark:text-white/30">{cls.type}</span>
</button> </button>
{showLocation && cls.locationName && ( {showLocation && cls.locationName && (
<span className="flex items-center gap-0.5 text-[10px] text-neutral-400 dark:text-white/20"> <span className="flex items-center gap-0.5 text-[10px] text-neutral-500 dark:text-white/20">
<MapPin size={8} className="shrink-0" /> <MapPin size={8} className="shrink-0" />
{cls.locationName} {cls.locationName}
</span> </span>
@@ -160,9 +160,9 @@ export function MobileSchedule({
{/* Location sub-header */} {/* Location sub-header */}
<div className="ml-3 flex items-center gap-1 px-3 py-1.5"> <div className="ml-3 flex items-center gap-1 px-3 py-1.5">
<MapPin size={9} className="shrink-0 text-neutral-400 dark:text-white/20" /> <MapPin size={9} className="shrink-0 text-neutral-400 dark:text-white/20" />
<span className="text-[10px] font-medium text-neutral-400 dark:text-white/25"> <span className="text-[10px] font-medium text-neutral-500 dark:text-white/25">
{locName} {locName}
{address && <span className="text-neutral-300 dark:text-white/15"> · {shortAddress(address)}</span>} {address && <span className="text-neutral-400 dark:text-white/15"> · {shortAddress(address)}</span>}
</span> </span>
</div> </div>
{classes.map((cls, i) => ( {classes.map((cls, i) => (
@@ -200,7 +200,7 @@ export function MobileSchedule({
})} })}
</div> </div>
) : ( ) : (
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30"> <div className="py-12 text-center text-sm text-neutral-500 dark:text-white/30">
Нет занятий по выбранным фильтрам Нет занятий по выбранным фильтрам
</div> </div>
)} )}
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { X, SlidersHorizontal } from "lucide-react"; import { X, SlidersHorizontal } from "lucide-react";
import { useFocusTrap } from "@/hooks/useFocusTrap";
import { import {
pillBase, pillBase,
isTimeFilterActive, isTimeFilterActive,
@@ -85,7 +86,7 @@ export function ScheduleFilters({
className={`inline-flex items-center gap-1.5 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-200 cursor-pointer sm:rounded-lg sm:px-4 sm:py-2 sm:text-xs ${ className={`inline-flex items-center gap-1.5 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-200 cursor-pointer sm:rounded-lg sm:px-4 sm:py-2 sm:text-xs ${
totalActive > 0 totalActive > 0
? "border border-gold/40 bg-gold/10 text-gold sm:border-0 sm:bg-white sm:text-neutral-900 sm:shadow-sm dark:sm:bg-white/10 dark:sm:text-white" ? "border border-gold/40 bg-gold/10 text-gold sm:border-0 sm:bg-white sm:text-neutral-900 sm:shadow-sm dark:sm:bg-white/10 dark:sm:text-white"
: "border border-neutral-300 text-neutral-500 hover:text-neutral-700 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20 sm:border-0 sm:hover:text-neutral-700 dark:sm:text-white/35 dark:sm:hover:text-white/60" : "border border-neutral-300 text-neutral-600 hover:text-neutral-700 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20 sm:border-0 sm:hover:text-neutral-700 dark:sm:text-white/35 dark:sm:hover:text-white/60"
}`} }`}
> >
<SlidersHorizontal size={16} className="sm:hidden" /> <SlidersHorizontal size={16} className="sm:hidden" />
@@ -99,27 +100,14 @@ export function ScheduleFilters({
{/* Filter modal — Airbnb style */} {/* Filter modal — Airbnb style */}
{modalOpen && createPortal( {modalOpen && createPortal(
<div <ScheduleFilterModal modalOpen={modalOpen} onClose={() => setModalOpen(false)}>
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-label="Фильтры"
onClick={() => setModalOpen(false)}
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative w-full max-w-lg max-h-[85vh] flex flex-col rounded-2xl border border-white/[0.08] shadow-2xl overflow-hidden"
style={{ backgroundColor: "#171717" }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/[0.06]"> <div className="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-white/[0.06]">
<h3 className="text-base font-bold text-white">Фильтры</h3> <h3 className="text-base font-bold text-neutral-900 dark:text-white">Фильтры</h3>
<button <button
onClick={() => setModalOpen(false)} onClick={() => setModalOpen(false)}
aria-label="Закрыть" aria-label="Закрыть"
className="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:bg-white/[0.06] hover:text-white transition-colors cursor-pointer" className="flex h-11 w-11 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-white transition-colors cursor-pointer"
> >
<X size={18} /> <X size={18} />
</button> </button>
@@ -137,7 +125,7 @@ export function ScheduleFilters({
className={`${pillBase} ${ className={`${pillBase} ${
filterTypes.has(type) filterTypes.has(type)
? "bg-gold text-black border border-gold" ? "bg-gold text-black border border-gold"
: "border border-gold/30 text-white hover:border-gold/60 hover:bg-gold/10" : "border border-gold/30 text-neutral-700 hover:border-gold/60 hover:bg-gold/10 dark:text-white"
}`} }`}
> >
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[type] ?? "bg-white/30"}`} /> <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[type] ?? "bg-white/30"}`} />
@@ -171,8 +159,8 @@ export function ScheduleFilters({
onClick={() => toggleFilterStatus(statusKey)} onClick={() => toggleFilterStatus(statusKey)}
className={`w-full rounded-xl px-3 py-3 text-center text-xs font-semibold transition-all cursor-pointer border ${ className={`w-full rounded-xl px-3 py-3 text-center text-xs font-semibold transition-all cursor-pointer border ${
active active
? "border-gold bg-gold/10 text-white" ? "border-gold bg-gold/10 text-neutral-900 dark:text-white"
: "border-white/[0.08] bg-white/[0.02] text-neutral-400 hover:border-white/[0.15] hover:bg-white/[0.04]" : "border-neutral-200 bg-neutral-50 text-neutral-500 hover:border-neutral-300 hover:bg-neutral-100 dark:border-white/[0.08] dark:bg-white/[0.02] dark:text-neutral-400 dark:hover:border-white/[0.15] dark:hover:bg-white/[0.04]"
}`} }`}
> >
{label} {label}
@@ -192,7 +180,7 @@ export function ScheduleFilters({
{/* Level — radio list */} {/* Level — radio list */}
{levels.length > 0 && ( {levels.length > 0 && (
<FilterSection title="Опыт"> <FilterSection title="Опыт">
<div className="space-y-1"> <div className="space-y-1" role="radiogroup" aria-label="Уровень подготовки">
{levels.map((level) => { {levels.map((level) => {
const desc = scheduleConfig?.levels?.find((l) => l.value === level)?.description; const desc = scheduleConfig?.levels?.find((l) => l.value === level)?.description;
const active = filterLevel === level; const active = filterLevel === level;
@@ -202,7 +190,7 @@ export function ScheduleFilters({
className={`flex items-center gap-2.5 w-full rounded-lg px-3 py-2 transition-all cursor-pointer ${ className={`flex items-center gap-2.5 w-full rounded-lg px-3 py-2 transition-all cursor-pointer ${
active active
? "bg-gold/10" ? "bg-gold/10"
: "hover:bg-white/[0.03]" : "hover:bg-neutral-100 dark:hover:bg-white/[0.03]"
}`} }`}
onClick={() => setFilterLevel(active ? null : level)} onClick={() => setFilterLevel(active ? null : level)}
role="radio" role="radio"
@@ -211,11 +199,11 @@ export function ScheduleFilters({
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setFilterLevel(active ? null : level); } }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setFilterLevel(active ? null : level); } }}
> >
<span className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition-colors ${ <span className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition-colors ${
active ? "border-gold" : "border-white/20" active ? "border-gold" : "border-neutral-300 dark:border-white/20"
}`}> }`}>
{active && <span className="h-2 w-2 rounded-full bg-gold" />} {active && <span className="h-2 w-2 rounded-full bg-gold" />}
</span> </span>
<span className={`text-xs font-medium ${active ? "text-white" : "text-neutral-400"}`}> <span className={`text-xs font-medium ${active ? "text-neutral-900 dark:text-white" : "text-neutral-600 dark:text-neutral-400"}`}>
{level} {level}
</span> </span>
{desc && <InfoTip text={desc} />} {desc && <InfoTip text={desc} />}
@@ -226,47 +214,49 @@ export function ScheduleFilters({
</FilterSection> </FilterSection>
)} )}
{/* When — days + time inline */} {/* When — days + time on separate rows */}
<FilterSection title="Когда"> <FilterSection title="Когда">
<div className="flex items-center gap-2"> <div className="space-y-3">
<div className="flex gap-1"> <div className="flex gap-1 flex-wrap">
{availableDays.map(({ day, dayShort }) => ( {availableDays.map(({ day, dayShort }) => (
<button <button
key={day} key={day}
onClick={() => toggleDay(day)} onClick={() => toggleDay(day)}
className={`flex items-center justify-center rounded-md w-8 h-8 text-[10px] font-semibold transition-all cursor-pointer ${ className={`flex items-center justify-center rounded-md w-10 h-10 text-[11px] font-semibold transition-all cursor-pointer ${
filterDaySet.has(day) filterDaySet.has(day)
? "bg-gold text-black" ? "bg-gold text-black"
: "bg-white/[0.04] text-neutral-400 hover:bg-white/[0.08] hover:text-white" : "bg-neutral-100 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-900 dark:bg-white/[0.04] dark:text-neutral-400 dark:hover:bg-white/[0.08] dark:hover:text-white"
}`} }`}
> >
{dayShort} {dayShort}
</button> </button>
))} ))}
</div> </div>
<span className="h-6 w-px bg-white/[0.08] shrink-0" /> <div className="flex items-center gap-2">
<input <span className="text-[11px] text-neutral-500 shrink-0">Время</span>
type="time" <input
value={filterTime.from} type="time"
onChange={(e) => setFilterTime({ ...filterTime, from: e.target.value })} value={filterTime.from}
className="w-16 shrink-0 rounded-md border border-white/[0.08] bg-white/[0.04] px-1.5 py-1.5 text-[11px] text-white text-center outline-none focus:border-gold/40 transition-colors [color-scheme:dark]" onChange={(e) => setFilterTime({ ...filterTime, from: e.target.value })}
/> className="w-20 shrink-0 rounded-md border border-neutral-200 bg-neutral-100 px-2 py-1.5 text-[11px] text-neutral-900 text-center outline-none focus:border-gold/40 transition-colors dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white [color-scheme:light] dark:[color-scheme:dark]"
<span className="text-neutral-500 text-[10px]"></span> />
<input <span className="text-neutral-500 text-[10px]"></span>
type="time" <input
value={filterTime.to} type="time"
onChange={(e) => setFilterTime({ ...filterTime, to: e.target.value })} value={filterTime.to}
className="w-16 shrink-0 rounded-md border border-white/[0.08] bg-white/[0.04] px-1.5 py-1.5 text-[11px] text-white text-center outline-none focus:border-gold/40 transition-colors [color-scheme:dark]" onChange={(e) => setFilterTime({ ...filterTime, to: e.target.value })}
/> className="w-20 shrink-0 rounded-md border border-neutral-200 bg-neutral-100 px-2 py-1.5 text-[11px] text-neutral-900 text-center outline-none focus:border-gold/40 transition-colors dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white [color-scheme:light] dark:[color-scheme:dark]"
/>
</div>
</div> </div>
</FilterSection> </FilterSection>
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-white/[0.06]"> <div className="flex items-center justify-between px-6 py-4 border-t border-neutral-200 dark:border-white/[0.06]">
<button <button
onClick={() => { clearFilters(); setModalOpen(false); }} onClick={() => { clearFilters(); setModalOpen(false); }}
className="text-sm text-neutral-400 hover:text-white transition-colors cursor-pointer" className="text-sm text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors cursor-pointer"
> >
Сбросить всё Сбросить всё
</button> </button>
@@ -277,14 +267,36 @@ export function ScheduleFilters({
Показать Показать
</button> </button>
</div> </div>
</div> </ScheduleFilterModal>,
</div>,
document.body document.body
)} )}
</> </>
); );
} }
function ScheduleFilterModal({ modalOpen, onClose, children }: { modalOpen: boolean; onClose: () => void; children: React.ReactNode }) {
const focusTrapRef = useFocusTrap<HTMLDivElement>(modalOpen);
return (
<div
ref={focusTrapRef}
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-label="Фильтры"
onClick={onClose}
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative w-full max-w-lg max-h-[85vh] flex flex-col rounded-2xl border border-neutral-200 bg-white shadow-2xl overflow-hidden dark:border-white/[0.08] dark:bg-[#171717]"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
}
function FilterSection({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) { function FilterSection({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
const [showHint, setShowHint] = useState(false); const [showHint, setShowHint] = useState(false);
const hintRef = useRef<HTMLDivElement>(null); const hintRef = useRef<HTMLDivElement>(null);
@@ -301,18 +313,18 @@ function FilterSection({ title, hint, children }: { title: string; hint?: string
return ( return (
<div> <div>
<div className="flex items-center gap-1.5 mb-3"> <div className="flex items-center gap-1.5 mb-3">
<h4 className="text-sm font-semibold text-white">{title}</h4> <h4 className="text-sm font-semibold text-neutral-900 dark:text-white">{title}</h4>
{hint && ( {hint && (
<div ref={hintRef} className="relative"> <div ref={hintRef} className="relative">
<button <button
type="button" type="button"
onClick={() => setShowHint(!showHint)} onClick={() => setShowHint(!showHint)}
className="flex h-4 w-4 items-center justify-center rounded-full border border-white/15 text-[10px] text-neutral-500 hover:text-white hover:border-white/30 transition-colors cursor-pointer" 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 dark:border-white/15 dark:hover:text-white dark:hover:border-white/30 transition-colors cursor-pointer"
> >
? ?
</button> </button>
{showHint && ( {showHint && (
<div className="absolute left-6 top-1/2 -translate-y-1/2 z-10 w-56 rounded-lg border border-white/10 px-3 py-2 text-[11px] leading-relaxed text-neutral-300 shadow-xl" style={{ backgroundColor: "#1a1a1a" }}> <div className="absolute left-6 top-1/2 -translate-y-1/2 z-10 w-56 rounded-lg border border-neutral-200 bg-white px-3 py-2 text-[11px] leading-relaxed text-neutral-600 shadow-xl dark:border-white/10 dark:bg-[#1a1a1a] dark:text-neutral-300">
{hint} {hint}
</div> </div>
)} )}
@@ -343,14 +355,16 @@ function InfoTip({ text }: { text: string }) {
<button <button
type="button" type="button"
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
aria-label="Подсказка"
aria-expanded={open}
className={`flex h-4 w-4 items-center justify-center rounded-full border text-[10px] transition-colors cursor-pointer ${ className={`flex h-4 w-4 items-center justify-center rounded-full border text-[10px] transition-colors cursor-pointer ${
open ? "border-gold/40 text-gold" : "border-white/15 text-neutral-500 hover:text-white hover:border-white/30" open ? "border-gold/40 text-gold" : "border-neutral-300 text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 dark:border-white/15 dark:hover:text-white dark:hover:border-white/30"
}`} }`}
> >
? ?
</button> </button>
{open && ( {open && (
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-[60] w-52"> <div role="tooltip" className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-[60] w-52">
{/* Tail behind body — always centered */} {/* Tail behind body — always centered */}
<div className="absolute left-1/2 -translate-x-1/2 -bottom-[5px] w-2.5 h-2.5 rotate-45 bg-gold" /> <div className="absolute left-1/2 -translate-x-1/2 -bottom-[5px] w-2.5 h-2.5 rotate-45 bg-gold" />
{/* Body on top */} {/* Body on top */}
@@ -398,7 +412,7 @@ function TrainerMultiSelect({
<div <div
onClick={() => { setOpen(true); inputRef.current?.focus(); }} onClick={() => { setOpen(true); inputRef.current?.focus(); }}
className={`flex flex-wrap items-center gap-1.5 rounded-lg border px-3 py-2 min-h-[42px] cursor-text transition-colors ${ className={`flex flex-wrap items-center gap-1.5 rounded-lg border px-3 py-2 min-h-[42px] cursor-text transition-colors ${
open ? "border-gold bg-white/[0.06]" : "border-white/[0.08] bg-white/[0.04]" open ? "border-gold bg-neutral-50 dark:bg-white/[0.06]" : "border-neutral-200 bg-neutral-100 dark:border-white/[0.08] dark:bg-white/[0.04]"
}`} }`}
> >
{Array.from(selected).map((t) => ( {Array.from(selected).map((t) => (
@@ -422,12 +436,12 @@ function TrainerMultiSelect({
if (e.key === "Escape") { setOpen(false); setSearch(""); } if (e.key === "Escape") { setOpen(false); setSearch(""); }
}} }}
placeholder={selected.size === 0 ? "Все тренеры" : ""} placeholder={selected.size === 0 ? "Все тренеры" : ""}
className="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-neutral-500 outline-none" className="flex-1 min-w-[80px] bg-transparent text-sm text-neutral-900 placeholder-neutral-500 outline-none dark:text-white"
/> />
</div> </div>
{open && filtered.length > 0 && ( {open && filtered.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-lg border border-white/[0.08] shadow-xl overflow-hidden" style={{ backgroundColor: "#1a1a1a" }}> <div className="absolute z-10 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/[0.08] dark:bg-[#1a1a1a]">
<div className="max-h-48 overflow-y-auto styled-scrollbar"> <div className="max-h-48 overflow-y-auto styled-scrollbar">
{filtered.map((trainer) => ( {filtered.map((trainer) => (
<button <button
@@ -439,7 +453,7 @@ function TrainerMultiSelect({
setSearch(""); setSearch("");
inputRef.current?.focus(); inputRef.current?.focus();
}} }}
className="w-full px-4 py-2 text-left text-sm text-white transition-colors hover:bg-white/[0.05]" className="w-full px-4 py-2 text-left text-sm text-neutral-900 transition-colors hover:bg-neutral-100 dark:text-white dark:hover:bg-white/[0.05]"
> >
{trainer} {trainer}
</button> </button>
@@ -271,7 +271,7 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
{/* Cards */} {/* Cards */}
{/* Mobile swipe hint */} {/* Mobile swipe hint */}
<div <div
className={`absolute bottom-2 left-1/2 -translate-x-1/2 z-20 text-xs text-neutral-400 tracking-wide transition-opacity duration-1000 md:hidden ${ className={`absolute bottom-2 left-1/2 -translate-x-1/2 z-20 text-xs text-neutral-600 dark:text-neutral-400 tracking-wide transition-opacity duration-1000 md:hidden ${
swipeHintVisible ? "opacity-60" : "opacity-0 pointer-events-none" swipeHintVisible ? "opacity-60" : "opacity-0 pointer-events-none"
}`} }`}
> >
@@ -303,7 +303,7 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
filter: style.filter, filter: style.filter,
borderColor: style.isCenter ? "transparent" : style.borderColor, borderColor: style.isCenter ? "transparent" : style.borderColor,
boxShadow: style.isCenter boxShadow: style.isCenter
? "0 0 40px rgba(201,169,110,0.15), 0 0 80px rgba(201,169,110,0.08)" ? "0 0 40px rgba(201,169,110,0.25), 0 0 80px rgba(201,169,110,0.12), 0 8px 32px rgba(0,0,0,0.08)"
: style.boxShadow, : style.boxShadow,
transition: style.transition, transition: style.transition,
}} }}
+26 -15
View File
@@ -24,7 +24,7 @@ export function TeamMemberInfo({ members, activeIndex, onSelect, onOpenBio }: Te
href={member.instagram} href={member.instagram}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-white/40 transition-colors hover:text-gold-light" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 transition-colors hover:text-gold-dark dark:text-white/40 dark:hover:text-gold-light"
> >
<Instagram size={14} /> <Instagram size={14} />
{member.instagram.split("/").filter(Boolean).pop()} {member.instagram.split("/").filter(Boolean).pop()}
@@ -32,31 +32,42 @@ export function TeamMemberInfo({ members, activeIndex, onSelect, onOpenBio }: Te
)} )}
{(member.shortDescription || member.description) && ( {(member.shortDescription || member.description) && (
<p className="mt-3 text-sm leading-relaxed text-white/55 line-clamp-3"> <p className="mt-3 text-sm leading-relaxed text-neutral-600 dark:text-white/55 line-clamp-3">
{member.shortDescription || member.description} {member.shortDescription || member.description}
</p> </p>
)} )}
<button <button
onClick={onOpenBio} onClick={onOpenBio}
aria-label={`Подробнее о ${member.name}`}
className="mt-3 text-sm font-medium text-gold hover:text-gold-light transition-colors cursor-pointer" className="mt-3 text-sm font-medium text-gold hover:text-gold-light transition-colors cursor-pointer"
> >
Подробнее Подробнее
</button> </button>
{/* Progress dots */} {/* Progress dots — mobile only (desktop has carousel arrows) */}
<div className="mt-6 flex items-center justify-center gap-1.5"> <div className="mt-6 flex items-center justify-center gap-2 sm:hidden">
{members.map((_, i) => ( {members.map((m, i) => {
<button const isActive = i === activeIndex;
key={i} return (
onClick={() => onSelect(i)} <button
className={`h-1.5 rounded-full transition-all duration-500 cursor-pointer ${ key={i}
i === activeIndex onClick={() => onSelect(i)}
? "w-6 bg-gold" aria-label={`Перейти к ${m.name}`}
: "w-1.5 bg-white/15 hover:bg-white/30" className="relative flex items-center justify-center w-8 h-8 cursor-pointer group"
}`} >
/> {/* Glow ring on active */}
))} {isActive && (
<span className="absolute inset-0 rounded-full bg-gold/15 scale-100 animate-pulse" />
)}
<span className={`relative block rounded-full transition-all duration-500 ${
isActive
? "w-3 h-3 bg-gold shadow-[0_0_8px_rgba(201,169,110,0.5)]"
: "w-2 h-2 bg-neutral-400 group-hover:bg-neutral-500 group-hover:scale-125 dark:bg-white/20 dark:group-hover:bg-white/40"
}`} />
</button>
);
})}
</div> </div>
</div> </div>
); );
+60 -42
View File
@@ -4,6 +4,7 @@ import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Clock, Ma
import type { TeamMember, RichListItem, ScheduleLocation, SiteContent } from "@/types/content"; import type { TeamMember, RichListItem, ScheduleLocation, SiteContent } from "@/types/content";
import { findStatusConfig } from "@/components/sections/schedule/constants"; import { findStatusConfig } from "@/components/sections/schedule/constants";
import { SignupModal } from "@/components/ui/SignupModal"; import { SignupModal } from "@/components/ui/SignupModal";
import { useFocusTrap } from "@/hooks/useFocusTrap";
import { formatMarkup } from "@/lib/markup"; import { formatMarkup } from "@/lib/markup";
import { GroupCard } from "@/components/ui/GroupCard"; import { GroupCard } from "@/components/ui/GroupCard";
@@ -111,12 +112,12 @@ export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamPr
<div className="relative shrink-0 w-full sm:w-[380px] lg:w-[420px] sm:sticky sm:top-8"> <div className="relative shrink-0 w-full sm:w-[380px] lg:w-[420px] sm:sticky sm:top-8">
<button <button
onClick={onBack} onClick={onBack}
className="mb-3 inline-flex items-center gap-1.5 rounded-full bg-white/[0.06] px-3 py-1.5 text-sm text-white/50 transition-colors hover:text-white hover:bg-white/[0.1] cursor-pointer" className="mb-3 inline-flex items-center gap-1.5 rounded-full bg-neutral-100 px-3 py-1.5 text-sm text-neutral-500 transition-colors hover:text-neutral-900 hover:bg-neutral-200 cursor-pointer dark:bg-white/[0.06] dark:text-white/50 dark:hover:text-white dark:hover:bg-white/[0.1]"
> >
<ArrowLeft size={14} /> <ArrowLeft size={14} />
Назад Назад
</button> </button>
<div className="relative aspect-[3/4] overflow-hidden rounded-2xl border border-white/[0.06]"> <div className="relative aspect-[3/4] overflow-hidden rounded-2xl border border-neutral-200 dark:border-white/[0.06]">
<Image <Image
src={member.image} src={member.image}
alt={member.name} alt={member.name}
@@ -161,7 +162,7 @@ export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamPr
{/* Bio panel — overlaps photo edge on desktop */} {/* Bio panel — overlaps photo edge on desktop */}
<div className="relative sm:-ml-12 sm:mt-8 mt-0 flex-1 min-w-0 z-10"> <div className="relative sm:-ml-12 sm:mt-8 mt-0 flex-1 min-w-0 z-10">
<div className="relative rounded-2xl border border-white/[0.08] overflow-hidden shadow-2xl shadow-black/40"> <div className="relative rounded-2xl border border-neutral-200 overflow-hidden shadow-2xl shadow-neutral-300/50 dark:border-white/[0.08] dark:shadow-black/40">
{/* Ambient photo background */} {/* Ambient photo background */}
<div className="absolute inset-0"> <div className="absolute inset-0">
<Image <Image
@@ -171,20 +172,20 @@ export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamPr
sizes="600px" sizes="600px"
className="object-cover scale-150 blur-sm grayscale opacity-70 brightness-[0.6] contrast-[1.3]" className="object-cover scale-150 blur-sm grayscale opacity-70 brightness-[0.6] contrast-[1.3]"
/> />
<div className="absolute inset-0 bg-black/20 mix-blend-multiply" /> <div className="absolute inset-0 bg-white/80 dark:bg-black/20 dark:mix-blend-multiply" />
<div className="absolute inset-0 bg-gold/10 mix-blend-color" /> <div className="absolute inset-0 bg-gold/10 mix-blend-color" />
</div> </div>
<div className="relative p-5 sm:p-6 space-y-6"> <div className="relative p-5 sm:p-6 space-y-6 dark:bg-black/40">
{/* Groups — first, most actionable */} {/* Groups — first, most actionable */}
{hasGroups && ( {hasGroups && (
<div> <div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gold/70 flex items-center gap-2"> <h4 className="text-xs font-semibold uppercase tracking-wider text-gold-dark dark:text-gold/70 flex items-center gap-2">
<Clock size={12} /> <Clock size={12} />
Группы Группы
</h4> </h4>
<ScrollRow> <ScrollRow>
{uniqueGroups.map((g, i) => ( {uniqueGroups.map((g, i) => (
<div key={i} className="w-56 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3 flex flex-col"> <div key={i} className="w-56 shrink-0 rounded-xl border border-neutral-200 bg-neutral-50 p-3 flex flex-col dark:border-white/[0.12] dark:bg-white/[0.06]">
<GroupCard <GroupCard
compact compact
type={g.type} type={g.type}
@@ -206,7 +207,7 @@ export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamPr
{/* Description */} {/* Description */}
{member.description && ( {member.description && (
<div className="text-sm leading-relaxed text-white/50"> <div className="text-sm leading-relaxed text-neutral-600 dark:text-white/50">
{formatMarkup(member.description)} {formatMarkup(member.description)}
</div> </div>
)} )}
@@ -235,7 +236,7 @@ export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamPr
{/* Empty state */} {/* Empty state */}
{!hasBio && !member.description && ( {!hasBio && !member.description && (
<p className="text-sm text-white/30 italic"> <p className="text-sm text-neutral-400 italic dark:text-white/30">
Информация скоро появится Информация скоро появится
</p> </p>
)} )}
@@ -246,30 +247,7 @@ export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamPr
{/* Image lightbox */} {/* Image lightbox */}
{lightbox && ( {lightbox && (
<div <LightboxDialog src={lightbox} onClose={() => setLightbox(null)} />
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-label="Просмотр изображения"
onClick={() => setLightbox(null)}
>
<button
onClick={() => setLightbox(null)}
aria-label="Закрыть"
className="absolute top-4 right-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20 transition-colors"
>
<X size={20} />
</button>
<div className="relative max-h-[85vh] max-w-[90vw]">
<Image
src={lightbox}
alt="Достижение"
width={900}
height={900}
className="rounded-lg object-contain max-h-[85vh]"
/>
</div>
</div>
)} )}
<SignupModal <SignupModal
@@ -283,6 +261,43 @@ export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamPr
); );
} }
function LightboxDialog({ src, onClose }: { src: string; onClose: () => void }) {
const focusTrapRef = useFocusTrap<HTMLDivElement>(true);
useEffect(() => {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, []);
return (
<div
ref={focusTrapRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-label="Просмотр изображения"
onClick={onClose}
>
<button
onClick={onClose}
aria-label="Закрыть"
className="absolute top-4 right-4 rounded-full bg-neutral-200 p-2 text-neutral-700 hover:bg-neutral-300 dark:bg-white/10 dark:text-white dark:hover:bg-white/20 transition-colors"
>
<X size={20} />
</button>
<div className="relative max-h-[85vh] max-w-[90vw]" onClick={(e) => e.stopPropagation()}>
<Image
src={src}
alt="Достижение"
width={900}
height={900}
className="rounded-lg object-contain max-h-[85vh]"
/>
</div>
</div>
);
}
function CollapsibleSection({ icon: Icon, title, count, children }: { icon: React.ComponentType<{ size: number }>; title: string; count: number; children: React.ReactNode }) { function CollapsibleSection({ icon: Icon, title, count, children }: { icon: React.ComponentType<{ size: number }>; title: string; count: number; children: React.ReactNode }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -290,14 +305,15 @@ function CollapsibleSection({ icon: Icon, title, count, children }: { icon: Reac
<div> <div>
<button <button
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
aria-expanded={open}
className="flex items-center gap-2 w-full text-left cursor-pointer group" className="flex items-center gap-2 w-full text-left cursor-pointer group"
> >
<h4 className="text-xs font-semibold uppercase tracking-wider text-gold/70 flex items-center gap-2"> <h4 className="text-xs font-semibold uppercase tracking-wider text-gold-dark dark:text-gold/70 flex items-center gap-2">
<Icon size={12} /> <Icon size={12} />
{title} {title}
<span className="text-gold/40">{count}</span> <span className="text-gold-dark/50 dark:text-gold/40">{count}</span>
</h4> </h4>
<ChevronDown size={14} className={`text-gold/40 transition-transform duration-200 group-hover:text-gold/60 ${open ? "rotate-180" : ""}`} /> <ChevronDown size={14} className={`text-gold-dark/50 dark:text-gold/40 transition-transform duration-200 group-hover:text-gold dark:group-hover:text-gold/60 ${open ? "rotate-180" : ""}`} />
</button> </button>
<div <div
className="grid transition-[grid-template-rows] duration-300 ease-out" className="grid transition-[grid-template-rows] duration-300 ease-out"
@@ -377,7 +393,8 @@ function ScrollRow({ children }: { children: React.ReactNode }) {
{canScroll.left && ( {canScroll.left && (
<button <button
onClick={() => scrollBy(-1)} onClick={() => scrollBy(-1)}
className="absolute left-1 top-1/2 -translate-y-1/2 z-10 rounded-full bg-black/80 border border-white/10 p-1.5 text-white/60 hover:text-white hover:bg-black/90 transition-all cursor-pointer" aria-label="Прокрутить влево"
className="absolute left-1 top-1/2 -translate-y-1/2 z-10 rounded-full bg-white/90 border border-neutral-200 p-2.5 text-neutral-500 hover:text-neutral-900 hover:bg-white dark:bg-black/80 dark:border-white/10 dark:text-white/60 dark:hover:text-white dark:hover:bg-black/90 transition-all cursor-pointer"
> >
<ChevronLeft size={14} /> <ChevronLeft size={14} />
</button> </button>
@@ -385,7 +402,8 @@ function ScrollRow({ children }: { children: React.ReactNode }) {
{canScroll.right && ( {canScroll.right && (
<button <button
onClick={() => scrollBy(1)} onClick={() => scrollBy(1)}
className="absolute right-1 top-1/2 -translate-y-1/2 z-10 rounded-full bg-black/80 border border-white/10 p-1.5 text-white/60 hover:text-white hover:bg-black/90 transition-all cursor-pointer" aria-label="Прокрутить вправо"
className="absolute right-1 top-1/2 -translate-y-1/2 z-10 rounded-full bg-white/90 border border-neutral-200 p-2.5 text-neutral-500 hover:text-neutral-900 hover:bg-white dark:bg-black/80 dark:border-white/10 dark:text-white/60 dark:hover:text-white dark:hover:bg-black/90 transition-all cursor-pointer"
> >
<ChevronRight size={14} /> <ChevronRight size={14} />
</button> </button>
@@ -400,7 +418,7 @@ function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (s
if (hasImage) { if (hasImage) {
return ( return (
<div className="group w-60 shrink-0 flex rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03] transition-all duration-200 hover:border-gold/30 hover:bg-white/[0.06] hover:shadow-lg hover:shadow-gold/5"> <div className="group w-60 shrink-0 flex rounded-xl border border-neutral-200 overflow-hidden bg-white dark:border-white/[0.08] dark:bg-white/[0.03] transition-all duration-200 hover:border-gold/30 dark:hover:bg-white/[0.06] hover:shadow-lg hover:shadow-gold/5">
<button <button
onClick={() => onImageClick(item.image!)} onClick={() => onImageClick(item.image!)}
className="relative w-18 shrink-0 overflow-hidden cursor-pointer" className="relative w-18 shrink-0 overflow-hidden cursor-pointer"
@@ -414,7 +432,7 @@ function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (s
/> />
</button> </button>
<div className="flex-1 min-w-0 p-3"> <div className="flex-1 min-w-0 p-3">
<p className="text-sm text-white/70 group-hover:text-white/90 transition-colors">{item.text}</p> <p className="text-sm text-neutral-600 group-hover:text-neutral-900 dark:text-white/70 dark:group-hover:text-white/90 transition-colors">{item.text}</p>
{hasLink && ( {hasLink && (
<a <a
href={item.link} href={item.link}
@@ -432,9 +450,9 @@ function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (s
} }
return ( return (
<div className="group w-60 shrink-0 rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03] transition-all duration-200 hover:border-gold/30 hover:bg-white/[0.06] hover:shadow-lg hover:shadow-gold/5"> <div className="group w-60 shrink-0 rounded-xl border border-neutral-200 overflow-hidden bg-white dark:border-white/[0.08] dark:bg-white/[0.03] transition-all duration-200 hover:border-gold/30 dark:hover:bg-white/[0.06] hover:shadow-lg hover:shadow-gold/5">
<div className="p-3"> <div className="p-3">
<p className="text-sm text-white/60 group-hover:text-white/80 transition-colors">{item.text}</p> <p className="text-sm text-neutral-500 group-hover:text-neutral-800 dark:text-white/60 dark:group-hover:text-white/80 transition-colors">{item.text}</p>
{hasLink && ( {hasLink && (
<a <a
href={item.link} href={item.link}
+9 -2
View File
@@ -8,8 +8,15 @@ export function BackToTop() {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
useEffect(() => { useEffect(() => {
let ticking = false;
function handleScroll() { function handleScroll() {
setVisible(window.scrollY > UI_CONFIG.scrollThresholds.backToTop); if (!ticking) {
ticking = true;
requestAnimationFrame(() => {
setVisible(window.scrollY > UI_CONFIG.scrollThresholds.backToTop);
ticking = false;
});
}
} }
window.addEventListener("scroll", handleScroll, { passive: true }); window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll);
@@ -19,7 +26,7 @@ export function BackToTop() {
<button <button
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })} onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
aria-label="Наверх" aria-label="Наверх"
className={`fixed bottom-6 right-6 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-gold/30 bg-black/60 text-gold-light backdrop-blur-sm transition-all duration-300 hover:bg-gold/20 hover:border-gold/50 ${ className={`fixed bottom-6 right-6 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-gold/40 bg-white/80 text-gold-dark backdrop-blur-sm transition-all duration-300 hover:bg-gold/15 hover:border-gold/60 dark:border-gold/30 dark:bg-black/60 dark:text-gold-light dark:hover:bg-gold/20 dark:hover:border-gold/50 ${
visible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0 pointer-events-none" visible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0 pointer-events-none"
}`} }`}
> >
+15 -7
View File
@@ -12,14 +12,20 @@ export function FloatingContact() {
const { bookingOpen: modalOpen, openBooking, closeBooking } = useBooking(); const { bookingOpen: modalOpen, openBooking, closeBooking } = useBooking();
useEffect(() => { useEffect(() => {
let ticking = false;
function handleScroll() { function handleScroll() {
const contactEl = document.getElementById("contact"); if (!ticking) {
const pastHero = window.scrollY > window.innerHeight * 0.7; ticking = true;
const reachedContact = contactEl requestAnimationFrame(() => {
? window.scrollY + window.innerHeight > contactEl.offsetTop + 100 const contactEl = document.getElementById("contact");
: false; const pastHero = window.scrollY > window.innerHeight * 0.7;
const reachedContact = contactEl
setVisible(pastHero && !reachedContact); ? window.scrollY + window.innerHeight > contactEl.offsetTop + 100
: false;
setVisible(pastHero && !reachedContact);
ticking = false;
});
}
} }
window.addEventListener("scroll", handleScroll, { passive: true }); window.addEventListener("scroll", handleScroll, { passive: true });
@@ -51,6 +57,8 @@ export function FloatingContact() {
{/* Main toggle button */} {/* Main toggle button */}
<button <button
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
aria-label="Контакты"
aria-expanded={expanded}
className={`flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all duration-300 ${ className={`flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all duration-300 ${
expanded expanded
? "bg-neutral-700 rotate-0" ? "bg-neutral-700 rotate-0"
+1 -1
View File
@@ -34,7 +34,7 @@ export function FloatingHearts() {
{hearts.map((heart) => ( {hearts.map((heart) => (
<div <div
key={heart.id} key={heart.id}
className="absolute text-gold" className="absolute text-gold-dark dark:text-gold"
style={{ style={{
left: `${heart.left}%`, left: `${heart.left}%`,
bottom: "-20px", bottom: "-20px",
+3 -3
View File
@@ -61,7 +61,7 @@ export function GroupCard({
const typeContent = ( const typeContent = (
<> <>
<span className={`${dot} shrink-0 rounded-full ${dotColor}`} /> <span className={`${dot} shrink-0 rounded-full ${dotColor}`} />
<span className={`${typeCls} font-semibold text-white/90`}>{type}</span> <span className={`${typeCls} font-semibold text-neutral-900 dark:text-white/90`}>{type}</span>
</> </>
); );
@@ -77,7 +77,7 @@ export function GroupCard({
<span className="inline-flex items-center gap-1.5">{typeContent}</span> <span className="inline-flex items-center gap-1.5">{typeContent}</span>
)} )}
{showLocation && (address || location) && ( {showLocation && (address || location) && (
<span className={`inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/25 ${locSize} font-medium text-white`}> <span className={`inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/25 ${locSize} font-medium text-neutral-700 dark:text-white`}>
<MapPin size={locIcon} className="text-gold" /> <MapPin size={locIcon} className="text-gold" />
{shortAddress(address || location || "")} {shortAddress(address || location || "")}
</span> </span>
@@ -96,7 +96,7 @@ export function GroupCard({
<span className={`rounded-md bg-gold/10 ${dayPad} font-bold text-gold text-center`}> <span className={`rounded-md bg-gold/10 ${dayPad} font-bold text-gold text-center`}>
{m.days.join(", ")} {m.days.join(", ")}
</span> </span>
<span className={`${timeCls} tabular-nums text-white/60`}> <span className={`${timeCls} tabular-nums text-neutral-600 dark:text-white/60`}>
{m.times.join(", ")} {m.times.join(", ")}
</span> </span>
</div> </div>
+6 -6
View File
@@ -45,13 +45,13 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
<div <div
ref={focusTrapRef} ref={focusTrapRef}
className="modal-content relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl border border-white/[0.08] bg-neutral-950 shadow-2xl" className="modal-content relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl border border-neutral-200 bg-white shadow-2xl dark:border-white/[0.08] dark:bg-neutral-950"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<button <button
onClick={onClose} onClick={onClose}
aria-label="Закрыть" aria-label="Закрыть"
className="absolute right-4 top-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/50 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-white/[0.1] hover:text-white cursor-pointer" className="absolute right-4 top-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/50 text-white/70 backdrop-blur-sm transition-colors hover:bg-black/70 hover:text-white cursor-pointer"
> >
<X size={18} /> <X size={18} />
</button> </button>
@@ -69,21 +69,21 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
transform: `scale(${item.imageZoom ?? 1})`, transform: `scale(${item.imageZoom ?? 1})`,
}} }}
/> />
<div className="absolute inset-0 bg-gradient-to-t from-neutral-950 via-transparent to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
</div> </div>
)} )}
<div className={`p-6 sm:p-8 ${item.image ? "-mt-12 relative" : ""}`}> <div className={`p-6 sm:p-8 ${item.image ? "-mt-12 relative" : ""}`}>
<span className="inline-flex items-center gap-1.5 text-xs text-neutral-400"> <span className="inline-flex items-center gap-1.5 text-xs text-neutral-600 dark:text-neutral-400">
<Calendar size={12} /> <Calendar size={12} />
{formatDateRu(item.date)} {formatDateRu(item.date)}
</span> </span>
<h2 className="mt-2 text-xl sm:text-2xl font-bold text-white leading-tight"> <h2 className="mt-2 text-xl sm:text-2xl font-bold text-neutral-900 leading-tight dark:text-white">
{item.title} {item.title}
</h2> </h2>
<p className="mt-4 text-sm sm:text-base leading-relaxed text-neutral-300 whitespace-pre-line"> <p className="mt-4 text-sm sm:text-base leading-relaxed text-neutral-600 whitespace-pre-line dark:text-neutral-300">
{item.text} {item.text}
</p> </p>
+40 -12
View File
@@ -2,6 +2,31 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
/** Shared singleton IntersectionObserver for all Reveal instances */
const callbacks = new Map<Element, () => void>();
let sharedObserver: IntersectionObserver | null = null;
function getObserver(): IntersectionObserver {
if (!sharedObserver) {
sharedObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const cb = callbacks.get(entry.target);
if (cb) {
cb();
callbacks.delete(entry.target);
sharedObserver!.unobserve(entry.target);
}
}
}
},
{ threshold: 0.1, rootMargin: "0px 0px -50px 0px" },
);
}
return sharedObserver;
}
interface RevealProps { interface RevealProps {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
@@ -10,30 +35,33 @@ interface RevealProps {
export function Reveal({ children, className = "" }: RevealProps) { export function Reveal({ children, className = "" }: RevealProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [reducedMotion, setReducedMotion] = useState(false);
useEffect(() => { useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setReducedMotion(true);
setVisible(true);
return;
}
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
const observer = new IntersectionObserver( const observer = getObserver();
([entry]) => { callbacks.set(el, () => setVisible(true));
if (entry.isIntersecting) {
setVisible(true);
observer.unobserve(el);
}
},
{ threshold: 0.1, rootMargin: "0px 0px -50px 0px" },
);
observer.observe(el); observer.observe(el);
return () => observer.disconnect();
return () => {
callbacks.delete(el);
observer.unobserve(el);
};
}, []); }, []);
return ( return (
<div <div
ref={ref} ref={ref}
className={className} className={className}
style={{ style={reducedMotion ? undefined : {
opacity: visible ? 1 : 0, opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(30px)", transform: visible ? "translateY(0)" : "translateY(30px)",
transition: "opacity 0.7s ease-out, transform 0.7s ease-out", transition: "opacity 0.7s ease-out, transform 0.7s ease-out",
+1 -1
View File
@@ -205,7 +205,7 @@ export function ShowcaseLayout<T>({
className={`cursor-pointer rounded-xl border-2 text-left transition-all duration-300 ${ className={`cursor-pointer rounded-xl border-2 text-left transition-all duration-300 ${
i === activeIndex i === activeIndex
? "border-gold/60 bg-gold/10 dark:bg-gold/5" ? "border-gold/60 bg-gold/10 dark:bg-gold/5"
: "border-transparent bg-neutral-100 hover:bg-neutral-200 dark:bg-white/[0.03] dark:hover:bg-white/[0.06]" : "border-neutral-200 bg-white hover:border-neutral-300 hover:bg-neutral-50 dark:border-transparent dark:bg-white/[0.03] dark:hover:border-white/[0.06] dark:hover:bg-white/[0.06]"
}`} }`}
> >
{renderSelectorItem(item, i, i === activeIndex)} {renderSelectorItem(item, i, i === activeIndex)}
+19 -30
View File
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { X, CheckCircle, Send, Phone as PhoneIcon, Instagram } from "lucide-react"; import { X, CheckCircle, Send, Phone as PhoneIcon, Instagram } from "lucide-react";
import { BRAND } from "@/lib/constants"; import { BRAND } from "@/lib/constants";
import { useFocusTrap } from "@/hooks/useFocusTrap"; import { useFocusTrap } from "@/hooks/useFocusTrap";
import { formatBelarusPhone } from "@/lib/formatting";
interface SignupModalProps { interface SignupModalProps {
open: boolean; open: boolean;
@@ -51,19 +52,7 @@ export function SignupModal({
const focusTrapRef = useFocusTrap<HTMLDivElement>(open); const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
function handlePhoneChange(raw: string) { function handlePhoneChange(raw: string) {
let digits = raw.replace(/\D/g, ""); setPhone(formatBelarusPhone(raw));
if (!digits.startsWith("375")) {
digits = "375" + digits.replace(/^375?/, "");
}
digits = digits.slice(0, 12);
let formatted = "+375";
const rest = digits.slice(3);
if (rest.length > 0) formatted += " (" + rest.slice(0, 2);
if (rest.length >= 2) formatted += ") ";
if (rest.length > 2) formatted += rest.slice(2, 5);
if (rest.length > 5) formatted += "-" + rest.slice(5, 7);
if (rest.length > 7) formatted += "-" + rest.slice(7, 9);
setPhone(formatted);
} }
useEffect(() => { useEffect(() => {
@@ -86,7 +75,7 @@ export function SignupModal({
setError(""); setError("");
const cleanPhone = phone.replace(/\D/g, ""); const cleanPhone = phone.replace(/\D/g, "");
if (cleanPhone.length < 12) { if (!/^375(25|29|33|44)\d{7}$/.test(cleanPhone)) {
setError("Введите корректный номер телефона"); setError("Введите корректный номер телефона");
return; return;
} }
@@ -147,13 +136,13 @@ export function SignupModal({
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" /> <div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div <div
ref={focusTrapRef} ref={focusTrapRef}
className="modal-content relative w-full max-w-md rounded-2xl border border-white/[0.08] bg-neutral-950 p-6 sm:p-8 shadow-2xl" className="modal-content relative w-full max-w-md rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.08] dark:bg-neutral-950 p-6 sm:p-8 shadow-2xl"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<button <button
onClick={handleClose} onClick={handleClose}
aria-label="Закрыть" aria-label="Закрыть"
className="absolute right-4 top-4 flex h-11 w-11 items-center justify-center rounded-full text-neutral-500 transition-colors hover:bg-white/[0.06] hover:text-white cursor-pointer" className="absolute right-4 top-4 flex h-11 w-11 items-center justify-center rounded-full text-neutral-500 dark:text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white cursor-pointer"
> >
<X size={18} /> <X size={18} />
</button> </button>
@@ -165,8 +154,8 @@ export function SignupModal({
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-amber-500/10"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-amber-500/10">
<CheckCircle size={28} className="text-amber-500" /> <CheckCircle size={28} className="text-amber-500" />
</div> </div>
<h3 className="text-lg font-bold text-white">Вы в листе ожидания</h3> <h3 className="text-lg font-bold text-neutral-900 dark:text-white">Вы в листе ожидания</h3>
<p className="mt-2 text-sm text-neutral-400 leading-relaxed whitespace-pre-line"> <p className="mt-2 text-sm text-neutral-500 dark:text-neutral-400 leading-relaxed whitespace-pre-line">
{waitingMessage || "Все места заняты, но мы добавили вас в лист ожидания.\nЕсли кто-то откажется — мы предложим место вам."} {waitingMessage || "Все места заняты, но мы добавили вас в лист ожидания.\nЕсли кто-то откажется — мы предложим место вам."}
</p> </p>
<a <a
@@ -184,10 +173,10 @@ export function SignupModal({
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/10">
<CheckCircle size={28} className="text-emerald-500" /> <CheckCircle size={28} className="text-emerald-500" />
</div> </div>
<h3 className="text-lg font-bold text-white"> <h3 className="text-lg font-bold text-neutral-900 dark:text-white">
{successMessage || "Вы записаны!"} {successMessage || "Вы записаны!"}
</h3> </h3>
{subtitle && <p className="mt-1 text-sm text-neutral-400">{subtitle}</p>} {subtitle && <p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">{subtitle}</p>}
<a <a
href={BRAND.instagram} href={BRAND.instagram}
target="_blank" target="_blank"
@@ -206,8 +195,8 @@ export function SignupModal({
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-amber-500/10"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-amber-500/10">
<Instagram size={28} className="text-amber-400" /> <Instagram size={28} className="text-amber-400" />
</div> </div>
<h3 className="text-lg font-bold text-white">Что-то пошло не так</h3> <h3 className="text-lg font-bold text-neutral-900 dark:text-white">Что-то пошло не так</h3>
<p className="mt-2 text-sm text-neutral-400"> <p className="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
{errorMessage || "Не удалось отправить заявку. Свяжитесь с нами через Instagram — мы запишем вас!"} {errorMessage || "Не удалось отправить заявку. Свяжитесь с нами через Instagram — мы запишем вас!"}
</p> </p>
<button <button
@@ -219,7 +208,7 @@ export function SignupModal({
</button> </button>
<button <button
onClick={() => setError("")} onClick={() => setError("")}
className="mt-2 text-xs text-neutral-500 hover:text-white transition-colors cursor-pointer" className="mt-2 text-xs text-neutral-500 hover:text-neutral-900 dark:hover:text-white transition-colors cursor-pointer"
> >
Попробовать снова Попробовать снова
</button> </button>
@@ -227,8 +216,8 @@ export function SignupModal({
) : ( ) : (
<> <>
<div className="mb-6"> <div className="mb-6">
<h3 className="text-xl font-bold text-white">{title}</h3> <h3 className="text-xl font-bold text-neutral-900 dark:text-white">{title}</h3>
{subtitle && <p className="mt-1 text-sm text-neutral-400">{subtitle}</p>} {subtitle && <p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">{subtitle}</p>}
</div> </div>
<form onSubmit={handleSubmit} className="space-y-3"> <form onSubmit={handleSubmit} className="space-y-3">
@@ -242,7 +231,7 @@ export function SignupModal({
placeholder="Ваше имя" placeholder="Ваше имя"
required required
aria-required="true" aria-required="true"
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]" className="w-full rounded-xl border border-neutral-300 bg-neutral-50 px-4 py-3 text-sm text-neutral-900 placeholder-neutral-400 outline-none transition-colors focus:border-gold/60 focus:bg-white dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500 dark:focus:border-gold/40 dark:focus:bg-white/[0.06]"
/> />
</div> </div>
<div className="relative"> <div className="relative">
@@ -257,7 +246,7 @@ export function SignupModal({
required required
aria-required="true" aria-required="true"
aria-describedby={error && error !== "network" ? "error-phone" : undefined} aria-describedby={error && error !== "network" ? "error-phone" : undefined}
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-9 pr-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]" className="w-full rounded-xl border border-neutral-300 bg-neutral-50 pl-9 pr-4 py-3 text-sm text-neutral-900 placeholder-neutral-400 outline-none transition-colors focus:border-gold/60 focus:bg-white dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500 dark:focus:border-gold/40 dark:focus:bg-white/[0.06]"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -270,7 +259,7 @@ export function SignupModal({
value={instagram} value={instagram}
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))} onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
placeholder="Instagram" placeholder="Instagram"
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-7 pr-3 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]" className="w-full rounded-xl border border-neutral-300 bg-neutral-50 pl-7 pr-3 py-3 text-sm text-neutral-900 placeholder-neutral-400 outline-none transition-colors focus:border-gold/60 focus:bg-white dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500 dark:focus:border-gold/40 dark:focus:bg-white/[0.06]"
/> />
</div> </div>
<div className="relative"> <div className="relative">
@@ -282,13 +271,13 @@ export function SignupModal({
value={telegram} value={telegram}
onChange={(e) => setTelegram(e.target.value.replace(/^@/, ""))} onChange={(e) => setTelegram(e.target.value.replace(/^@/, ""))}
placeholder="Telegram" placeholder="Telegram"
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] pl-7 pr-3 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]" className="w-full rounded-xl border border-neutral-300 bg-neutral-50 pl-7 pr-3 py-3 text-sm text-neutral-900 placeholder-neutral-400 outline-none transition-colors focus:border-gold/60 focus:bg-white dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500 dark:focus:border-gold/40 dark:focus:bg-white/[0.06]"
/> />
</div> </div>
</div> </div>
{error && error !== "network" && ( {error && error !== "network" && (
<p id="error-phone" className="text-sm text-red-400">{error}</p> <p id="error-phone" role="alert" className="text-sm text-red-400">{error}</p>
)} )}
<button <button
+7 -5
View File
@@ -3,14 +3,15 @@
import { Moon, Sun } from "lucide-react"; import { Moon, Sun } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export function ThemeToggle() { export function ThemeToggle({ className = "" }: { className?: string }) {
const [dark, setDark] = useState(false); const [mounted, setMounted] = useState(false);
const [dark, setDark] = useState(true);
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem("theme"); const stored = localStorage.getItem("theme");
const isDark = stored !== "light"; const isDark = stored !== "light";
setDark(isDark); setDark(isDark);
document.documentElement.classList.toggle("dark", isDark); setMounted(true);
}, []); }, []);
function toggle() { function toggle() {
@@ -24,9 +25,10 @@ export function ThemeToggle() {
<button <button
onClick={toggle} onClick={toggle}
aria-label="Переключить тему" aria-label="Переключить тему"
className="rounded-full p-2 text-neutral-400 transition-all duration-300 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-500 dark:hover:bg-white/[0.05] dark:hover:text-white" suppressHydrationWarning
className={`rounded-full p-2 transition-all duration-300 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-900 dark:text-neutral-500 dark:hover:bg-white/[0.05] dark:hover:text-white ${className}`}
> >
{dark ? <Sun size={18} /> : <Moon size={18} />} {mounted ? (dark ? <Sun size={18} /> : <Moon size={18} />) : <span className="block w-[18px] h-[18px]" />}
</button> </button>
); );
} }
+14 -8
View File
@@ -28,21 +28,27 @@ export function YandexMap({ addresses, height = 380 }: YandexMapProps) {
let cancelled = false; let cancelled = false;
async function build() { async function build() {
const points: { lat: number; lon: number }[] = []; // Geocode all addresses in parallel
const results = await Promise.allSettled(
for (const addr of addresses) { addresses.map(async (addr) => {
try {
const cleaned = cleanAddress(addr); const cleaned = cleanAddress(addr);
const query = cleaned.toLowerCase().includes("минск") ? cleaned : `Минск ${cleaned}`; const query = cleaned.toLowerCase().includes("минск") ? cleaned : `Минск ${cleaned}`;
const res = await fetch( const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1&countrycodes=by` `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1&countrycodes=by`,
{ signal: AbortSignal.timeout(5000) }
); );
const data = await res.json(); const data = await res.json();
if (data.length > 0) { if (data.length > 0) {
points.push({ lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) }); return { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) };
} }
} catch { /* skip */ } return null;
} })
);
const points = results
.filter((r): r is PromiseFulfilledResult<{ lat: number; lon: number } | null> => r.status === "fulfilled")
.map((r) => r.value)
.filter((p): p is { lat: number; lon: number } => p !== null);
if (cancelled || points.length === 0) return; if (cancelled || points.length === 0) return;
+19
View File
@@ -0,0 +1,19 @@
import { useMemo } from "react";
interface TeamMemberLike {
name: string;
image?: string;
}
/** Build a name→photo map from team members. Shared across Schedule, OpenDay, etc. */
export function useTrainerPhotos(teamMembers?: TeamMemberLike[]): Record<string, string> {
return useMemo(() => {
const map: Record<string, string> = {};
if (teamMembers) {
for (const m of teamMembers) {
if (m.image) map[m.name] = m.image;
}
}
return map;
}, [teamMembers]);
}
+22 -3
View File
@@ -25,19 +25,38 @@ async function hmacSign(data: string, secret: string): Promise<string> {
encoder.encode(secret), encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" }, { name: "HMAC", hash: "SHA-256" },
false, false,
["sign"] ["sign", "verify"]
); );
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data)); const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
return base64urlEncode(sig); return base64urlEncode(sig);
} }
/** Constant-time comparison using Web Crypto verify */
async function hmacVerify(data: string, signature: string, secret: string): Promise<boolean> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
);
// Decode the base64url signature back to ArrayBuffer
const sigStr = signature.replace(/-/g, "+").replace(/_/g, "/");
const padded = sigStr + "=".repeat((4 - (sigStr.length % 4)) % 4);
const binary = atob(padded);
const sigBytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) sigBytes[i] = binary.charCodeAt(i);
return crypto.subtle.verify("HMAC", key, sigBytes, encoder.encode(data));
}
export async function verifyToken(token: string): Promise<boolean> { export async function verifyToken(token: string): Promise<boolean> {
try { try {
const [data, sig] = token.split("."); const [data, sig] = token.split(".");
if (!data || !sig) return false; if (!data || !sig) return false;
const expectedSig = await hmacSign(data, getSecret()); const valid = await hmacVerify(data, sig, getSecret());
if (sig !== expectedSig) return false; if (!valid) return false;
const payload = JSON.parse(atob(data.replace(/-/g, "+").replace(/_/g, "/"))) as { const payload = JSON.parse(atob(data.replace(/-/g, "+").replace(/_/g, "/"))) as {
role: string; role: string;
+49 -3
View File
@@ -634,6 +634,33 @@ export function addMcRegistration(
return result.lastInsertRowid as number; return result.lastInsertRowid as number;
} }
/** Atomic check-and-insert: counts confirmed registrations and inserts in a single transaction */
export function addMcRegistrationAtomic(
masterClassTitle: string,
name: string,
instagram: string,
telegram?: string,
phone?: string,
maxParticipants?: number
): { id: number; isWaiting: boolean } {
const db = getDb();
const txn = db.transaction(() => {
let isWaiting = false;
if (maxParticipants) {
const row = db.prepare(
"SELECT COUNT(*) as cnt FROM mc_registrations WHERE master_class_title = ? AND status = 'confirmed'"
).get(masterClassTitle) as { cnt: number };
isWaiting = row.cnt >= maxParticipants;
}
const result = db.prepare(
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram, phone, notes)
VALUES (?, ?, ?, ?, ?, ?)`
).run(masterClassTitle, name, instagram, telegram || null, phone || null, isWaiting ? "Лист ожидания" : null);
return { id: result.lastInsertRowid as number, isWaiting };
});
return txn();
}
export function getMcRegistrations(masterClassTitle: string): McRegistration[] { export function getMcRegistrations(masterClassTitle: string): McRegistration[] {
const db = getDb(); const db = getDb();
const rows = db const rows = db
@@ -652,6 +679,17 @@ export function getAllMcRegistrations(): McRegistration[] {
return rows.map(mapMcRow); return rows.map(mapMcRow);
} }
/** Efficient count of registrations grouped by master class title */
export function getMcRegistrationCounts(): Record<string, number> {
const db = getDb();
const rows = db
.prepare("SELECT master_class_title, COUNT(*) as cnt FROM mc_registrations GROUP BY master_class_title")
.all() as { master_class_title: string; cnt: number }[];
const result: Record<string, number> = {};
for (const r of rows) result[r.master_class_title] = r.cnt;
return result;
}
function mapMcRow(r: McRegistrationRow): McRegistration { function mapMcRow(r: McRegistrationRow): McRegistration {
return { return {
id: r.id, id: r.id,
@@ -686,11 +724,15 @@ export function updateMcRegistration(
).run(name, instagram, telegram || null, id); ).run(name, instagram, telegram || null, id);
} }
const VALID_NOTIFY_FIELDS = new Set(["notified_confirm", "notified_reminder"]);
const VALID_BOOKING_TABLES = new Set(["mc_registrations", "group_bookings", "open_day_bookings"]);
export function toggleMcNotification( export function toggleMcNotification(
id: number, id: number,
field: "notified_confirm" | "notified_reminder", field: "notified_confirm" | "notified_reminder",
value: boolean value: boolean
): void { ): void {
if (!VALID_NOTIFY_FIELDS.has(field)) throw new Error(`Invalid field: ${field}`);
const db = getDb(); const db = getDb();
db.prepare( db.prepare(
`UPDATE mc_registrations SET ${field} = ? WHERE id = ?` `UPDATE mc_registrations SET ${field} = ? WHERE id = ?`
@@ -823,6 +865,7 @@ export function toggleGroupBookingNotification(
field: "notified_confirm" | "notified_reminder", field: "notified_confirm" | "notified_reminder",
value: boolean value: boolean
): void { ): void {
if (!VALID_NOTIFY_FIELDS.has(field)) throw new Error(`Invalid field: ${field}`);
const db = getDb(); const db = getDb();
db.prepare(`UPDATE group_bookings SET ${field} = ? WHERE id = ?`).run( db.prepare(`UPDATE group_bookings SET ${field} = ? WHERE id = ?`).run(
value ? 1 : 0, value ? 1 : 0,
@@ -853,6 +896,7 @@ export function setReminderStatus(
id: number, id: number,
status: ReminderStatus | null status: ReminderStatus | null
): void { ): void {
if (!VALID_BOOKING_TABLES.has(table)) throw new Error(`Invalid table: ${table}`);
const db = getDb(); const db = getDb();
db.prepare(`UPDATE ${table} SET reminder_status = ? WHERE id = ?`).run(status, id); db.prepare(`UPDATE ${table} SET reminder_status = ? WHERE id = ?`).run(status, id);
} }
@@ -862,6 +906,7 @@ export function updateBookingNotes(
id: number, id: number,
notes: string notes: string
): void { ): void {
if (!VALID_BOOKING_TABLES.has(table)) throw new Error(`Invalid table: ${table}`);
const db = getDb(); const db = getDb();
db.prepare(`UPDATE ${table} SET notes = ? WHERE id = ?`).run(notes || null, id); db.prepare(`UPDATE ${table} SET notes = ? WHERE id = ?`).run(notes || null, id);
} }
@@ -934,7 +979,7 @@ export function getUpcomingReminders(): ReminderItem[] {
} }
} }
} }
} catch { /* ignore */ } } catch (err) { console.warn("[getUpcomingReminders] MC query error:", err); }
// Group bookings — confirmed with date today/tomorrow // Group bookings — confirmed with date today/tomorrow
try { try {
@@ -956,7 +1001,7 @@ export function getUpcomingReminders(): ReminderItem[] {
eventDate: r.confirmed_date!, eventDate: r.confirmed_date!,
}); });
} }
} catch { /* ignore */ } } catch (err) { console.warn("[getUpcomingReminders] group booking query error:", err); }
// Open Day bookings — check event date // Open Day bookings — check event date
try { try {
@@ -986,7 +1031,7 @@ export function getUpcomingReminders(): ReminderItem[] {
}); });
} }
} }
} catch { /* ignore */ } } catch (err) { console.warn("[getUpcomingReminders] open day query error:", err); }
return items; return items;
} }
@@ -1384,6 +1429,7 @@ export function toggleOpenDayNotification(
field: "notified_confirm" | "notified_reminder", field: "notified_confirm" | "notified_reminder",
value: boolean value: boolean
): void { ): void {
if (!VALID_NOTIFY_FIELDS.has(field)) throw new Error(`Invalid field: ${field}`);
const db = getDb(); const db = getDb();
db.prepare(`UPDATE open_day_bookings SET ${field} = ? WHERE id = ?`).run(value ? 1 : 0, id); db.prepare(`UPDATE open_day_bookings SET ${field} = ? WHERE id = ?`).run(value ? 1 : 0, id);
} }
+23
View File
@@ -52,4 +52,27 @@ export function parseDate(iso: string): Date {
return new Date(iso + "T00:00:00"); return new Date(iso + "T00:00:00");
} }
/** Russian weekday abbreviations */
export const SHORT_DAYS: Record<string, string> = {
"Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ",
"Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС",
};
/** Format a raw phone input into +375 (XX) XXX-XX-XX format */
export function formatBelarusPhone(raw: string): string {
let digits = raw.replace(/\D/g, "");
if (!digits.startsWith("375")) {
digits = "375" + digits.replace(/^375?/, "");
}
digits = digits.slice(0, 12);
let formatted = "+375";
const rest = digits.slice(3);
if (rest.length > 0) formatted += " (" + rest.slice(0, 2);
if (rest.length >= 2) formatted += ") ";
if (rest.length > 2) formatted += rest.slice(2, 5);
if (rest.length > 5) formatted += "-" + rest.slice(5, 7);
if (rest.length > 7) formatted += "-" + rest.slice(7, 9);
return formatted;
}
export { MONTHS_RU, WEEKDAYS_RU }; export { MONTHS_RU, WEEKDAYS_RU };
+14 -5
View File
@@ -52,11 +52,20 @@ export function checkRateLimit(
/** /**
* Extract client IP from request headers. * Extract client IP from request headers.
* Prefers Vercel's trusted header, falls back to last x-forwarded-for hop.
*/ */
export function getClientIp(request: Request): string { export function getClientIp(request: Request): string {
return ( // Vercel sets this header and it cannot be spoofed by the client
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || const vercelIp = request.headers.get("x-vercel-forwarded-for");
request.headers.get("x-real-ip") || if (vercelIp) return vercelIp.split(",")[0]?.trim() || "unknown";
"unknown"
); // For non-Vercel deployments, use the last (rightmost) IP from x-forwarded-for
// (the one added by the closest trusted reverse proxy)
const xff = request.headers.get("x-forwarded-for");
if (xff) {
const parts = xff.split(",").map((s) => s.trim()).filter(Boolean);
return parts[parts.length - 1] || "unknown";
}
return request.headers.get("x-real-ip") || "unknown";
} }
+1 -1
View File
@@ -40,7 +40,7 @@ export async function proxy(request: NextRequest) {
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, { response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
httpOnly: false, httpOnly: false,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
sameSite: "strict", sameSite: "lax",
path: "/", path: "/",
maxAge: 60 * 60 * 24, maxAge: 60 * 60 * 24,
}); });