refactor: comprehensive frontend review — consistency, a11y, code quality

- Replace event dispatchers with BookingContext (Hero, Header, FloatingContact)
- Add focus trap hook for modals (SignupModal, NewsModal)
- Extract shared components: CollapsibleSection, ConfirmDialog, PriceField, AdminSkeleton
- Add delete confirmation dialog to ArrayEditor
- Replace hardcoded colors (#050505, #0a0a0a, #c9a96e, #2ecc71) with theme tokens
- Add CSS variables --color-surface-deep/dark for consistent dark surfaces
- Improve contrast: muted text neutral-500 → neutral-400 in dark mode
- Fix modal z-index hierarchy (modals z-60, header z-50, floats z-40)
- Consolidate duplicate formatDate → shared formatting.ts
- Add useMemo to TeamProfile groupMap computation
- Fix typography: responsive price text in Pricing section
- Add ARIA labels/expanded to FAQ, OpenDay, ArrayEditor grip handles
- Hide number input spinners globally
- Reorder admin sidebar: Dashboard → SEO → Bookings → site section order
- Use shared PriceField in Open Day editor
- Fix schedule grid first time slot (09:00) clipped by container
- Fix pre-existing type errors (bookings, hero, db interfaces)
This commit is contained in:
2026-03-26 19:45:37 +03:00
parent ec08f8e8d5
commit 76307e298b
32 changed files with 613 additions and 319 deletions

View File

@@ -0,0 +1,18 @@
"use client";
/** Reusable loading skeleton for admin pages */
export function AdminSkeleton({ rows = 3 }: { rows?: number }) {
return (
<div className="space-y-6 animate-pulse">
{/* Title skeleton */}
<div className="h-8 w-48 rounded-lg bg-neutral-800" />
{/* Content skeletons */}
{Array.from({ length: rows }, (_, i) => (
<div key={i} className="space-y-3">
<div className="h-4 w-24 rounded bg-neutral-800" />
<div className="h-10 w-full rounded-lg bg-neutral-800" />
</div>
))}
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { useState, useRef, useCallback, useEffect } from "react"; import { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom"; 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";
interface ArrayEditorProps<T> { interface ArrayEditorProps<T> {
items: T[]; items: T[];
@@ -31,6 +32,7 @@ export function ArrayEditor<T>({
hiddenItems, hiddenItems,
addPosition = "bottom", addPosition = "bottom",
}: ArrayEditorProps<T>) { }: ArrayEditorProps<T>) {
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null); const [dragIndex, setDragIndex] = useState<number | null>(null);
const [insertAt, setInsertAt] = useState<number | null>(null); const [insertAt, setInsertAt] = useState<number | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
@@ -170,6 +172,8 @@ export function ArrayEditor<T>({
<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-white transition-colors select-none"
onMouseDown={(e) => handleGripMouseDown(e, i)} onMouseDown={(e) => handleGripMouseDown(e, i)}
aria-label="Перетащить для сортировки"
role="button"
> >
<GripVertical size={16} /> <GripVertical size={16} />
</div> </div>
@@ -187,7 +191,8 @@ export function ArrayEditor<T>({
</div> </div>
<button <button
type="button" type="button"
onClick={() => removeItem(i)} onClick={() => setConfirmDelete(i)}
aria-label="Удалить элемент"
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0" className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
> >
<Trash2 size={16} /> <Trash2 size={16} />
@@ -257,12 +262,15 @@ export function ArrayEditor<T>({
<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-white transition-colors select-none"
onMouseDown={(e) => handleGripMouseDown(e, i)} onMouseDown={(e) => handleGripMouseDown(e, i)}
aria-label="Перетащить для сортировки"
role="button"
> >
<GripVertical size={16} /> <GripVertical size={16} />
</div> </div>
<button <button
type="button" type="button"
onClick={() => removeItem(i)} onClick={() => removeItem(i)}
aria-label="Удалить элемент"
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors" className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
> >
<Trash2 size={16} /> <Trash2 size={16} />
@@ -370,6 +378,14 @@ export function ArrayEditor<T>({
</div>, </div>,
document.body document.body
)} )}
<ConfirmDialog
open={confirmDelete !== null}
title="Удалить элемент?"
message="Это действие нельзя отменить."
onConfirm={() => { if (confirmDelete !== null) removeItem(confirmDelete); setConfirmDelete(null); }}
onCancel={() => setConfirmDelete(null)}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
interface CollapsibleSectionProps {
title: string;
count?: number;
defaultOpen?: boolean;
isOpen?: boolean;
onToggle?: () => void;
children: React.ReactNode;
}
/**
* Shared collapsible section for admin pages.
* Supports both controlled (isOpen/onToggle) and uncontrolled (defaultOpen) modes.
*/
export function CollapsibleSection({
title,
count,
defaultOpen = true,
isOpen: controlledOpen,
onToggle,
children,
}: CollapsibleSectionProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
const toggle = onToggle ?? (() => setInternalOpen((v) => !v));
return (
<div className="rounded-xl border border-white/10 bg-neutral-900/30 overflow-hidden">
<button
type="button"
onClick={toggle}
aria-expanded={open}
className="flex items-center justify-between w-full px-5 py-3.5 text-left cursor-pointer group hover:bg-white/[0.02] transition-colors"
>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-neutral-200 group-hover:text-white transition-colors">
{title}
</h3>
{count !== undefined && (
<span className="text-xs text-neutral-500">{count}</span>
)}
</div>
<ChevronDown
size={16}
className={`text-neutral-500 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</button>
<div
className="grid transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
<div className="px-5 pb-5 space-y-4">{children}</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { AlertTriangle, X } from "lucide-react";
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
destructive?: boolean;
}
export function ConfirmDialog({
open,
title,
message,
confirmLabel = "Удалить",
cancelLabel = "Отмена",
onConfirm,
onCancel,
destructive = true,
}: ConfirmDialogProps) {
const cancelRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
cancelRef.current?.focus();
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onCancel();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onCancel]);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
role="alertdialog"
aria-modal="true"
aria-label={title}
onClick={onCancel}
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-neutral-900 p-6 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onCancel}
aria-label="Закрыть"
className="absolute right-3 top-3 rounded-full p-1 text-neutral-500 hover:text-white transition-colors"
>
<X size={16} />
</button>
<div className="flex items-start gap-3">
{destructive && (
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-500/10">
<AlertTriangle size={20} className="text-red-400" />
</div>
)}
<div>
<h3 className="text-base font-bold text-white">{title}</h3>
<p className="mt-1.5 text-sm text-neutral-400">{message}</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<button
ref={cancelRef}
onClick={onCancel}
className="rounded-lg px-4 py-2 text-sm font-medium text-neutral-300 hover:bg-white/[0.06] transition-colors cursor-pointer"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition-colors cursor-pointer ${
destructive
? "bg-red-600 text-white hover:bg-red-500"
: "bg-gold text-black hover:bg-gold-light"
}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1,33 @@
"use client";
interface PriceFieldProps {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
}
export function PriceField({ label, value, onChange, placeholder = "0" }: PriceFieldProps) {
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors">
<input
type="text"
value={raw}
onChange={(e) => {
const v = e.target.value;
onChange(v ? `${v} BYN` : "");
}}
placeholder={placeholder}
className="flex-1 bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none min-w-0"
/>
<span className="flex items-center pr-4 text-sm font-medium text-gold select-none">
BYN
</span>
</div>
</div>
);
}

View File

@@ -28,6 +28,7 @@ interface GroupBooking {
status: BookingStatus; status: BookingStatus;
confirmedDate?: string; confirmedDate?: string;
confirmedGroup?: string; confirmedGroup?: string;
confirmedHall?: string;
confirmedComment?: string; confirmedComment?: string;
notes?: string; notes?: string;
createdAt: string; createdAt: string;

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { ChevronDown, Plus, X, AlertCircle, Check } from "lucide-react"; import { Plus, X, AlertCircle, Check } from "lucide-react";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField } from "../_components/FormField"; import { InputField } from "../_components/FormField";
import { CollapsibleSection } from "../_components/CollapsibleSection";
import type { ContactInfo } from "@/types/content"; import type { ContactInfo } from "@/types/content";
// --- Phone input with mask --- // --- Phone input with mask ---
@@ -160,45 +161,6 @@ function AddressList({ items, onChange }: { items: string[]; onChange: (items: s
); );
} }
// --- Collapsible section ---
function CollapsibleSection({
title,
defaultOpen = true,
children,
}: {
title: string;
defaultOpen?: boolean;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="rounded-xl border border-white/10 bg-neutral-900/30 overflow-hidden">
<button
type="button"
onClick={() => setOpen(!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"
>
<h3 className="text-sm font-semibold text-neutral-200 group-hover:text-white transition-colors">{title}</h3>
<ChevronDown
size={16}
className={`text-neutral-500 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</button>
<div
className="grid transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
<div className="px-5 pb-5 space-y-4">
{children}
</div>
</div>
</div>
</div>
);
}
export default function ContactEditorPage() { export default function ContactEditorPage() {
return ( return (
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты"> <SectionEditor<ContactInfo> sectionKey="contact" title="Контакты">

View File

@@ -232,7 +232,7 @@ function VideoManager({
}); });
}, [slots]); }, [slots]);
const totalSize = fileSizes.reduce((sum, s) => sum + (s || 0), 0); const totalSize = fileSizes.reduce((sum: number, s) => sum + (s || 0), 0);
const totalMb = totalSize / (1024 * 1024); const totalMb = totalSize / (1024 * 1024);
function getLoadRating(mb: number): { label: string; color: string } { function getLoadRating(mb: number): { label: string; color: string } {

View File

@@ -29,13 +29,14 @@ import {
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: "/admin", label: "Дашборд", icon: LayoutDashboard }, { href: "/admin", label: "Дашборд", icon: LayoutDashboard },
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe }, { href: "/admin/meta", label: "SEO / Мета", icon: Globe },
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
// Sections follow user-side order: Hero → About → Classes → Team → OpenDay → Schedule → Pricing → MC → News → FAQ → Contact
{ href: "/admin/hero", label: "Главный экран", icon: Sparkles }, { href: "/admin/hero", label: "Главный экран", icon: Sparkles },
{ href: "/admin/about", label: "О студии", icon: FileText }, { href: "/admin/about", label: "О студии", icon: FileText },
{ href: "/admin/classes", label: "Направления", icon: BookOpen }, { href: "/admin/classes", label: "Направления", icon: BookOpen },
{ href: "/admin/team", label: "Команда", icon: Users }, { href: "/admin/team", label: "Команда", icon: Users },
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen }, { href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen },
{ href: "/admin/schedule", label: "Расписание", icon: Calendar }, { href: "/admin/schedule", label: "Расписание", icon: Calendar },
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
{ href: "/admin/pricing", label: "Цены", icon: DollarSign }, { href: "/admin/pricing", label: "Цены", icon: DollarSign },
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star }, { href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
{ href: "/admin/news", label: "Новости", icon: Newspaper }, { href: "/admin/news", label: "Новости", icon: Newspaper },

View File

@@ -5,6 +5,7 @@ import Image from "next/image";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField, ParticipantLimits, AutocompleteMulti } from "../_components/FormField"; import { InputField, TextareaField, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor"; import { ArrayEditor } from "../_components/ArrayEditor";
import { PriceField } from "../_components/PriceField";
import { Plus, X, Upload, Loader2, AlertCircle, Check, Search } from "lucide-react"; import { Plus, X, Upload, Loader2, AlertCircle, Check, Search } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import type { MasterClassItem, MasterClassSlot } from "@/types/content"; import type { MasterClassItem, MasterClassSlot } from "@/types/content";
@@ -38,32 +39,6 @@ function itemMatchesLocation(item: MasterClassItem, locationFilter: string): boo
return (item.location || "") === locationFilter; return (item.location || "") === locationFilter;
} }
// --- Price Field ---
function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors">
<input
type="text"
value={raw}
onChange={(e) => {
const v = e.target.value;
onChange(v ? `${v} BYN` : "");
}}
placeholder={placeholder ?? "0"}
className="flex-1 bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none min-w-0"
/>
<span className="flex items-center pr-4 text-sm font-medium text-gold select-none">
BYN
</span>
</div>
</div>
);
}
interface MasterClassesData { interface MasterClassesData {
title: string; title: string;
items: MasterClassItem[]; items: MasterClassItem[];

View File

@@ -6,6 +6,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import { ParticipantLimits, SelectField } from "../_components/FormField"; import { ParticipantLimits, SelectField } from "../_components/FormField";
import { PriceField } from "../_components/PriceField";
// --- Types --- // --- Types ---
@@ -100,13 +101,11 @@ function EventSettings({
/> />
</div> </div>
<div> <div className="sm:max-w-xs">
<label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label> <PriceField
<input label="Цена за занятие"
type="number" value={event.pricePerClass ? `${event.pricePerClass} BYN` : ""}
value={event.pricePerClass} onChange={(v) => onChange({ pricePerClass: parseInt(v) || 0 })}
onChange={(e) => onChange({ pricePerClass: parseInt(e.target.value) || 0 })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors sm:max-w-xs"
/> />
</div> </div>
@@ -130,12 +129,10 @@ function EventSettings({
{event.discountPrice > 0 && ( {event.discountPrice > 0 && (
<div className="grid gap-4 sm:grid-cols-2 mt-3"> <div className="grid gap-4 sm:grid-cols-2 mt-3">
<div> <div>
<label className="block text-sm text-neutral-400 mb-1.5">Цена со скидкой (BYN)</label> <PriceField
<input label="Цена со скидкой"
type="number" value={event.discountPrice ? `${event.discountPrice} BYN` : ""}
value={event.discountPrice} onChange={(v) => onChange({ discountPrice: parseInt(v) || 0 })}
onChange={(e) => onChange({ discountPrice: parseInt(e.target.value) || 0 })}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
/> />
</div> </div>
<div> <div>

View File

@@ -1,10 +1,12 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { ChevronDown, ChevronsUpDown } from "lucide-react"; import { ChevronsUpDown } from "lucide-react";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField, SelectField } from "../_components/FormField"; import { InputField, SelectField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor"; import { ArrayEditor } from "../_components/ArrayEditor";
import { CollapsibleSection } from "../_components/CollapsibleSection";
import { PriceField } from "../_components/PriceField";
interface PricingItem { interface PricingItem {
name: string; name: string;
@@ -24,76 +26,6 @@ interface PricingData {
showContactHint?: boolean; showContactHint?: boolean;
} }
function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
return (
<div>
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
<div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors">
<input
type="text"
value={raw}
onChange={(e) => {
const v = e.target.value;
onChange(v ? `${v} BYN` : "");
}}
placeholder="0"
className="flex-1 bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none min-w-0"
/>
<span className="flex items-center pr-4 text-sm font-medium text-gold select-none">
BYN
</span>
</div>
</div>
);
}
function CollapsibleSection({
title,
count,
isOpen,
onToggle,
children,
}: {
title: string;
count?: number;
isOpen: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="rounded-xl border border-white/10 bg-neutral-900/30 overflow-hidden">
<button
type="button"
onClick={onToggle}
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"
>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-neutral-200 group-hover:text-white transition-colors">{title}</h3>
{count !== undefined && (
<span className="text-xs text-neutral-500">{count}</span>
)}
</div>
<ChevronDown
size={16}
className={`text-neutral-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
/>
</button>
<div
className="grid transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: isOpen ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
<div className="px-5 pb-5 space-y-4">
{children}
</div>
</div>
</div>
</div>
);
}
function PricingContent({ data, update }: { data: PricingData; update: (d: PricingData) => void }) { function PricingContent({ data, update }: { data: PricingData; update: (d: PricingData) => void }) {
const [sections, setSections] = useState({ subscriptions: true, rental: true, rules: false }); const [sections, setSections] = useState({ subscriptions: true, rental: true, rules: false });
const allOpen = sections.subscriptions && sections.rental && sections.rules; const allOpen = sections.subscriptions && sections.rental && sections.rules;

View File

@@ -95,8 +95,10 @@ function timeToMinutes(timeStr: string): number {
return t.h * 60 + t.m; return t.h * 60 + t.m;
} }
const GRID_TOP_PAD = 8; // px — space so the first time label isn't clipped
function minutesToY(minutes: number): number { function minutesToY(minutes: number): number {
return ((minutes - HOUR_START * 60) / 60) * HOUR_HEIGHT; return ((minutes - HOUR_START * 60) / 60) * HOUR_HEIGHT + GRID_TOP_PAD;
} }
function yToMinutes(y: number): number { function yToMinutes(y: number): number {
@@ -927,12 +929,12 @@ function CalendarGrid({
{/* Time grid */} {/* Time grid */}
<div className="flex"> <div className="flex">
{/* Time labels */} {/* Time labels */}
<div className="w-14 shrink-0 relative" style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT}px` }}> <div className="w-14 shrink-0 relative" style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT + GRID_TOP_PAD}px` }}>
{hours.slice(0, -1).map((h) => ( {hours.slice(0, -1).map((h) => (
<div <div
key={h} key={h}
className="absolute left-0 right-0 text-right pr-2 text-xs text-neutral-500" className="absolute left-0 right-0 text-right pr-2 text-xs text-neutral-500"
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT - 6}px` }} style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT + GRID_TOP_PAD - 6}px` }}
> >
{String(h).padStart(2, "0")}:00 {String(h).padStart(2, "0")}:00
</div> </div>
@@ -951,7 +953,7 @@ function CalendarGrid({
key={day.day} key={day.day}
ref={(el) => { columnRefs.current[di] = el; }} ref={(el) => { columnRefs.current[di] = el; }}
className={`flex-1 border-l border-white/10 relative ${drag ? "cursor-grabbing" : "cursor-pointer"}`} className={`flex-1 border-l border-white/10 relative ${drag ? "cursor-grabbing" : "cursor-pointer"}`}
style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT}px` }} style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT + GRID_TOP_PAD}px` }}
onMouseMove={(e) => { onMouseMove={(e) => {
if (drag) return; if (drag) return;
// Ignore if hovering over a class block // Ignore if hovering over a class block
@@ -978,7 +980,7 @@ function CalendarGrid({
<div <div
key={h} key={h}
className="absolute left-0 right-0 border-t border-white/5" className="absolute left-0 right-0 border-t border-white/5"
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT}px` }} style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT + GRID_TOP_PAD}px` }}
/> />
))} ))}
{/* Half-hour lines */} {/* Half-hour lines */}
@@ -986,7 +988,7 @@ function CalendarGrid({
<div <div
key={`${h}-30`} key={`${h}-30`}
className="absolute left-0 right-0 border-t border-white/[0.02]" className="absolute left-0 right-0 border-t border-white/[0.02]"
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT + HOUR_HEIGHT / 2}px` }} style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT + HOUR_HEIGHT / 2 + GRID_TOP_PAD}px` }}
/> />
))} ))}

View File

@@ -11,6 +11,8 @@
--color-gold: #c9a96e; --color-gold: #c9a96e;
--color-gold-light: #d4b87a; --color-gold-light: #d4b87a;
--color-gold-dark: #a08050; --color-gold-dark: #a08050;
--color-surface-deep: #050505;
--color-surface-dark: #0a0a0a;
} }
/* ===== Base ===== */ /* ===== Base ===== */
@@ -85,11 +87,23 @@ body {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
/* ===== Hide number input spinners ===== */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
/* ===== Global page scrollbar ===== */ /* ===== Global page scrollbar ===== */
html { html {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(201, 169, 110, 0.3) #0a0a0a; scrollbar-color: rgba(201, 169, 110, 0.3) var(--color-surface-dark);
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
@@ -97,7 +111,7 @@ html {
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #0a0a0a; background: var(--color-surface-dark);
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {

View File

@@ -12,6 +12,7 @@ import { BackToTop } from "@/components/ui/BackToTop";
import { FloatingContact } from "@/components/ui/FloatingContact"; import { FloatingContact } from "@/components/ui/FloatingContact";
import { Header } from "@/components/layout/Header"; 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 { getContent } from "@/lib/content"; import { getContent } from "@/lib/content";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -29,6 +30,7 @@ export default function HomePage() {
return ( return (
<> <>
<ClientShell>
<Header /> <Header />
<main> <main>
<Hero data={content.hero} /> <Hero data={content.hero} />
@@ -53,6 +55,7 @@ export default function HomePage() {
<FloatingContact /> <FloatingContact />
</main> </main>
<Footer /> <Footer />
</ClientShell>
</> </>
); );
} }

View File

@@ -2,12 +2,12 @@
.surface-base { .surface-base {
@apply bg-neutral-50 text-neutral-900; @apply bg-neutral-50 text-neutral-900;
@apply dark:bg-[#050505] dark:text-neutral-100; @apply dark:bg-[var(--color-surface-deep)] dark:text-neutral-100;
} }
.surface-muted { .surface-muted {
@apply bg-neutral-100; @apply bg-neutral-100;
@apply dark:bg-[#080808]; @apply dark:bg-[var(--color-surface-deep)];
} }
.surface-glass { .surface-glass {
@@ -17,7 +17,7 @@
.surface-card { .surface-card {
@apply bg-white/80 backdrop-blur-sm; @apply bg-white/80 backdrop-blur-sm;
@apply dark:bg-[#111] dark:backdrop-blur-sm; @apply dark:bg-neutral-900 dark:backdrop-blur-sm;
} }
/* ===== Borders ===== */ /* ===== Borders ===== */
@@ -41,7 +41,7 @@
.muted-text { .muted-text {
@apply text-neutral-500; @apply text-neutral-500;
@apply dark:text-neutral-500; @apply dark:text-neutral-400;
} }
.accent-text { .accent-text {

View File

@@ -0,0 +1,8 @@
"use client";
import { BookingProvider } from "@/contexts/BookingContext";
/** Client boundary wrapper that provides shared contexts to the page. */
export function ClientShell({ children }: { children: React.ReactNode }) {
return <BookingProvider>{children}</BookingProvider>;
}

View File

@@ -7,12 +7,13 @@ 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 { useBooking } from "@/contexts/BookingContext";
export function Header() { export function Header() {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [activeSection, setActiveSection] = useState(""); const [activeSection, setActiveSection] = useState("");
const [bookingOpen, setBookingOpen] = useState(false); const { bookingOpen, openBooking, closeBooking } = useBooking();
useEffect(() => { useEffect(() => {
let ticking = false; let ticking = false;
@@ -29,15 +30,6 @@ export function Header() {
return () => window.removeEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll);
}, []); }, []);
// Listen for booking open events from other components
useEffect(() => {
function onOpenBooking() {
setBookingOpen(true);
}
window.addEventListener("open-booking", onOpenBooking);
return () => window.removeEventListener("open-booking", onOpenBooking);
}, []);
// Filter out nav links whose target section doesn't exist on the page // Filter out nav links whose target section doesn't exist on the page
const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS); const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS);
useEffect(() => { useEffect(() => {
@@ -166,7 +158,7 @@ export function Header() {
</div> </div>
<SignupModal open={bookingOpen} onClose={() => setBookingOpen(false)} endpoint="/api/group-booking" /> <SignupModal open={bookingOpen} onClose={closeBooking} endpoint="/api/group-booking" />
</header> </header>
); );
} }

View File

@@ -25,7 +25,7 @@ export function FAQ({ data: faq }: FAQProps) {
const hasMore = faq.items.length > VISIBLE_COUNT; const hasMore = faq.items.length > VISIBLE_COUNT;
return ( return (
<section id="faq" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]"> <section id="faq" className="section-glow relative section-padding bg-neutral-100 dark:bg-neutral-950">
<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>
@@ -41,11 +41,12 @@ 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-[#0a0a0a] dark:hover:border-white/[0.12]" : "border-neutral-200 bg-white hover:border-neutral-300 dark:border-white/[0.06] dark:bg-neutral-950 dark:hover:border-white/[0.12]"
}`} }`}
> >
<button <button
onClick={() => toggle(idx)} onClick={() => toggle(idx)}
aria-expanded={isOpen}
className="flex w-full items-center gap-3 px-5 py-4 text-left cursor-pointer" className="flex w-full items-center gap-3 px-5 py-4 text-left cursor-pointer"
> >
{/* Number badge */} {/* Number badge */}

View File

@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/Button";
import { FloatingHearts } from "@/components/ui/FloatingHearts"; import { FloatingHearts } from "@/components/ui/FloatingHearts";
import { HeroLogo } from "@/components/ui/HeroLogo"; import { HeroLogo } from "@/components/ui/HeroLogo";
import type { SiteContent } from "@/types/content"; import type { SiteContent } from "@/types/content";
import { useBooking } from "@/contexts/BookingContext";
const DEFAULT_VIDEOS = ["/video/ira.mp4", "/video/nadezda.mp4", "/video/nastya-2.mp4"]; const DEFAULT_VIDEOS = ["/video/ira.mp4", "/video/nadezda.mp4", "/video/nastya-2.mp4"];
@@ -13,6 +14,7 @@ interface HeroProps {
} }
export function Hero({ data: hero }: HeroProps) { export function Hero({ data: hero }: HeroProps) {
const { openBooking } = useBooking();
const sectionRef = useRef<HTMLElement>(null); const sectionRef = useRef<HTMLElement>(null);
const scrolledRef = useRef(false); const scrolledRef = useRef(false);
const overlayRef = useRef<HTMLDivElement>(null); const overlayRef = useRef<HTMLDivElement>(null);
@@ -78,7 +80,7 @@ export function Hero({ data: hero }: HeroProps) {
}, [scrollToNext]); }, [scrollToNext]);
return ( return (
<section id="hero" ref={sectionRef} className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]"> <section id="hero" ref={sectionRef} className="relative flex min-h-svh items-center justify-center overflow-hidden bg-neutral-950">
{/* Videos render only after hydration to avoid SSR mismatch */} {/* Videos render only after hydration to avoid SSR mismatch */}
{mounted && ( {mounted && (
<> <>
@@ -140,7 +142,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-[#050505] pointer-events-none transition-opacity duration-1000" className="absolute inset-0 z-[5] bg-neutral-950 pointer-events-none transition-opacity duration-1000"
/> />
{/* Floating hearts */} {/* Floating hearts */}
@@ -162,12 +164,12 @@ export function Hero({ data: hero }: HeroProps) {
<span className="gradient-text">{hero.headline}</span> <span className="gradient-text">{hero.headline}</span>
</h1> </h1>
<p className="hero-subtitle mx-auto mt-6 max-w-lg text-lg text-[#b8a080] sm:text-xl"> <p className="hero-subtitle mx-auto mt-6 max-w-lg text-lg text-gold/70 sm:text-xl">
{hero.subheadline} {hero.subheadline}
</p> </p>
<div className="hero-cta mt-12"> <div className="hero-cta mt-12">
<Button size="lg" onClick={() => window.dispatchEvent(new Event("open-booking"))}> <Button size="lg" onClick={openBooking}>
{hero.ctaText} {hero.ctaText}
</Button> </Button>
</div> </div>

View File

@@ -7,30 +7,12 @@ import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { NewsModal } from "@/components/ui/NewsModal"; import { NewsModal } from "@/components/ui/NewsModal";
import type { SiteContent, NewsItem } from "@/types/content"; import type { SiteContent, NewsItem } from "@/types/content";
import { formatDateRu } from "@/lib/formatting";
interface NewsProps { interface NewsProps {
data: SiteContent["news"]; data: SiteContent["news"];
} }
function formatDate(iso: string): string {
try {
const d = new Date(iso);
const date = d.toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
});
// Show time only if it's a full ISO timestamp (not just date)
if (iso.includes("T")) {
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
return `${date}, ${time}`;
}
return date;
} catch {
return iso;
}
}
function FeaturedArticle({ function FeaturedArticle({
item, item,
onClick, onClick,
@@ -65,7 +47,7 @@ function FeaturedArticle({
> >
<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 bg-white/15 px-3 py-1 text-xs font-medium text-white/80 backdrop-blur-sm">
<Calendar size={12} /> <Calendar size={12} />
{formatDate(item.date)} {formatDateRu(item.date)}
</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 text-white leading-tight">
{item.title} {item.title}
@@ -108,7 +90,7 @@ function CompactArticle({
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<span className="text-xs text-neutral-400 dark:text-white/30"> <span className="text-xs text-neutral-400 dark:text-white/30">
{formatDate(item.date)} {formatDateRu(item.date)}
</span> </span>
<h3 className="mt-1 text-sm sm:text-base font-bold text-neutral-900 dark:text-white leading-snug line-clamp-2 group-hover:text-gold transition-colors"> <h3 className="mt-1 text-sm sm:text-base font-bold text-neutral-900 dark:text-white leading-snug line-clamp-2 group-hover:text-gold transition-colors">
{item.title} {item.title}

View File

@@ -204,6 +204,7 @@ function ClassCard({
onClick={() => { onClick={() => {
window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer })); window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer }));
}} }}
aria-label={`Профиль тренера: ${cls.trainer}`}
className="relative flex items-center justify-center h-10 w-10 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-10 w-10 rounded-full overflow-hidden shrink-0 ring-1 ring-white/10 hover:ring-gold/30 transition-all cursor-pointer mt-0.5"
title={`Подробнее о ${cls.trainer}`} title={`Подробнее о ${cls.trainer}`}
> >

View File

@@ -25,7 +25,7 @@ export function Pricing({ data: pricing }: PricingProps) {
const regularItems = pricing.items.filter((item) => !item.featured); const regularItems = pricing.items.filter((item) => !item.featured);
return ( return (
<section id="pricing" className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505]"> <section id="pricing" className="section-glow relative section-padding bg-neutral-50 dark:bg-neutral-950">
<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>
@@ -69,7 +69,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-[#0a0a0a]" : "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-neutral-950"
}`} }`}
> >
{/* Popular badge */} {/* Popular badge */}
@@ -96,7 +96,7 @@ export function Pricing({ data: pricing }: PricingProps) {
)} )}
{/* Price */} {/* Price */}
<p className={`mt-3 font-display text-2xl font-bold ${isPopular ? "text-gold" : "text-neutral-900 dark:text-white"}`}> <p className={`mt-3 font-display text-xl sm:text-2xl font-bold ${isPopular ? "text-gold" : "text-neutral-900 dark:text-white"}`}>
{item.price} {item.price}
</p> </p>
</div> </div>
@@ -122,7 +122,7 @@ export function Pricing({ data: pricing }: PricingProps) {
</p> </p>
)} )}
</div> </div>
<p className="shrink-0 font-display text-3xl font-bold text-gold"> <p className="shrink-0 font-display text-2xl sm:text-3xl font-bold text-gold">
{featuredItem.price} {featuredItem.price}
</p> </p>
</div> </div>
@@ -137,7 +137,7 @@ export function Pricing({ data: pricing }: PricingProps) {
{pricing.rentalItems.map((item, i) => ( {pricing.rentalItems.map((item, i) => (
<div <div
key={i} key={i}
className="flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 dark:border-white/[0.06] dark:bg-[#0a0a0a]" className="flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 dark:border-white/[0.06] dark:bg-neutral-950"
> >
<div> <div>
<p className="font-medium text-neutral-900 dark:text-white"> <p className="font-medium text-neutral-900 dark:text-white">
@@ -163,7 +163,7 @@ export function Pricing({ data: pricing }: PricingProps) {
{pricing.rules.map((rule, i) => ( {pricing.rules.map((rule, i) => (
<div <div
key={i} key={i}
className="flex gap-4 rounded-2xl border border-neutral-200 bg-white px-5 py-4 dark:border-white/[0.06] dark:bg-[#0a0a0a]" className="flex gap-4 rounded-2xl border border-neutral-200 bg-white px-5 py-4 dark:border-white/[0.06] dark:bg-neutral-950"
> >
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light"> <span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
{i + 1} {i + 1}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import Image from "next/image"; import Image from "next/image";
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Clock, MapPin, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Clock, MapPin, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
import type { TeamMember, RichListItem, ScheduleLocation } from "@/types/content"; import type { TeamMember, RichListItem, ScheduleLocation } from "@/types/content";
@@ -28,6 +28,7 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
const hasEducation = member.education && member.education.length > 0; const hasEducation = member.education && member.education.length > 0;
// Extract trainer's groups from schedule using groupId // Extract trainer's groups from schedule using groupId
const uniqueGroups = useMemo(() => {
const groupMap = new Map<string, { type: string; location: string; address: string; slots: { day: string; dayShort: string; time: string }[]; level?: string; recruiting?: boolean }>(); const groupMap = new Map<string, { type: string; location: string; address: string; slots: { day: string; dayShort: string; time: string }[]; level?: string; recruiting?: boolean }>();
schedule?.forEach(location => { schedule?.forEach(location => {
location.days.forEach(day => { location.days.forEach(day => {
@@ -55,7 +56,7 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
}); });
}); });
}); });
const uniqueGroups = Array.from(groupMap.values()).map(g => { return Array.from(groupMap.values()).map(g => {
// Merge slots by day, then merge days with identical time sets // Merge slots by day, then merge days with identical time sets
const dayMap = new Map<string, { dayShort: string; times: string[] }>(); const dayMap = new Map<string, { dayShort: string; times: string[] }>();
const dayOrder: string[] = []; const dayOrder: string[] = [];
@@ -86,6 +87,7 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
} }
return { ...g, merged }; return { ...g, merged };
}); });
}, [member.name, schedule]);
const hasGroups = uniqueGroups.length > 0; const hasGroups = uniqueGroups.length > 0;
const hasBio = hasVictories || hasEducation || hasGroups; const hasBio = hasVictories || hasEducation || hasGroups;

View File

@@ -4,11 +4,12 @@ import { useState, useEffect } from "react";
import { Phone, X, Mail, Instagram } from "lucide-react"; import { Phone, X, Mail, Instagram } from "lucide-react";
import { BRAND } from "@/lib/constants"; import { BRAND } from "@/lib/constants";
import { SignupModal } from "./SignupModal"; import { SignupModal } from "./SignupModal";
import { useBooking } from "@/contexts/BookingContext";
export function FloatingContact() { export function FloatingContact() {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [modalOpen, setModalOpen] = useState(false); const { bookingOpen: modalOpen, openBooking, closeBooking } = useBooking();
useEffect(() => { useEffect(() => {
function handleScroll() { function handleScroll() {
@@ -53,7 +54,7 @@ export function FloatingContact() {
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"
: "bg-[#c9a96e] hover:bg-[#d4b87a] shadow-[#c9a96e]/30" : "bg-gold hover:bg-gold-light shadow-gold/30"
}`} }`}
> >
{expanded ? ( {expanded ? (
@@ -72,7 +73,7 @@ export function FloatingContact() {
{/* Phone */} {/* Phone */}
<a <a
href="tel:+375293897001" href="tel:+375293897001"
className="flex h-12 w-12 items-center justify-center rounded-full bg-[#2ecc71] shadow-lg shadow-[#2ecc71]/20 transition-transform hover:scale-110" className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/20 transition-transform hover:scale-110"
aria-label="Позвонить" aria-label="Позвонить"
> >
<Phone size={20} className="text-white" /> <Phone size={20} className="text-white" />
@@ -91,8 +92,8 @@ export function FloatingContact() {
{/* Записаться */} {/* Записаться */}
<button <button
onClick={() => { setModalOpen(true); setExpanded(false); }} onClick={() => { openBooking(); setExpanded(false); }}
className="flex h-12 w-12 items-center justify-center rounded-full bg-[#c9a96e] shadow-lg shadow-[#c9a96e]/20 transition-transform hover:scale-110" className="flex h-12 w-12 items-center justify-center rounded-full bg-gold shadow-lg shadow-gold/20 transition-transform hover:scale-110"
aria-label="Записаться" aria-label="Записаться"
> >
<span className="text-xs font-bold text-black leading-tight text-center"> <span className="text-xs font-bold text-black leading-tight text-center">
@@ -104,7 +105,7 @@ export function FloatingContact() {
<SignupModal <SignupModal
open={modalOpen} open={modalOpen}
onClose={() => setModalOpen(false)} onClose={closeBooking}
title="Записаться на занятие" title="Записаться на занятие"
subtitle="Оставьте контактные данные и мы свяжемся с вами" subtitle="Оставьте контактные данные и мы свяжемся с вами"
endpoint="/api/group-booking" endpoint="/api/group-booking"

View File

@@ -5,31 +5,17 @@ import { createPortal } from "react-dom";
import Image from "next/image"; import Image from "next/image";
import { X, Calendar, ExternalLink } from "lucide-react"; import { X, Calendar, ExternalLink } from "lucide-react";
import type { NewsItem } from "@/types/content"; import type { NewsItem } from "@/types/content";
import { formatDateRu } from "@/lib/formatting";
import { useFocusTrap } from "@/hooks/useFocusTrap";
interface NewsModalProps { interface NewsModalProps {
item: NewsItem | null; item: NewsItem | null;
onClose: () => void; onClose: () => void;
} }
function formatDate(iso: string): string {
try {
const d = new Date(iso);
const date = d.toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
});
if (iso.includes("T")) {
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
return `${date}, ${time}`;
}
return date;
} catch {
return iso;
}
}
export function NewsModal({ item, onClose }: NewsModalProps) { export function NewsModal({ item, onClose }: NewsModalProps) {
const focusTrapRef = useFocusTrap<HTMLDivElement>(!!item);
useEffect(() => { useEffect(() => {
if (!item) return; if (!item) return;
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
@@ -49,7 +35,7 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
return createPortal( return createPortal(
<div <div
className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4" className="modal-overlay fixed inset-0 z-[60] flex items-center justify-center p-4"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={item.title} aria-label={item.title}
@@ -58,7 +44,8 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" /> <div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div <div
className="modal-content relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl border border-white/[0.08] bg-[#0a0a0a] shadow-2xl" 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"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<button <button
@@ -82,14 +69,14 @@ 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-[#0a0a0a] via-transparent to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-neutral-950 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-400">
<Calendar size={12} /> <Calendar size={12} />
{formatDate(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-white leading-tight">

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom"; 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";
interface SignupModalProps { interface SignupModalProps {
open: boolean; open: boolean;
@@ -47,6 +48,7 @@ export function SignupModal({
const [error, setError] = useState(""); const [error, setError] = useState("");
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [successData, setSuccessData] = useState<Record<string, unknown> | null>(null); const [successData, setSuccessData] = useState<Record<string, unknown> | null>(null);
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
function handlePhoneChange(raw: string) { function handlePhoneChange(raw: string) {
let digits = raw.replace(/\D/g, ""); let digits = raw.replace(/\D/g, "");
@@ -141,10 +143,11 @@ export function SignupModal({
if (!open) return null; if (!open) return null;
return createPortal( return createPortal(
<div className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-label={title} onClick={handleClose}> <div className="modal-overlay fixed inset-0 z-[60] flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-label={title} onClick={handleClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" /> <div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div <div
className="modal-content relative w-full max-w-md rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 sm:p-8 shadow-2xl" 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"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<button <button

View File

@@ -0,0 +1,31 @@
"use client";
import { createContext, useContext, useState, useCallback } from "react";
interface BookingContextValue {
bookingOpen: boolean;
openBooking: () => void;
closeBooking: () => void;
}
const BookingContext = createContext<BookingContextValue | null>(null);
export function BookingProvider({ children }: { children: React.ReactNode }) {
const [bookingOpen, setBookingOpen] = useState(false);
const openBooking = useCallback(() => setBookingOpen(true), []);
const closeBooking = useCallback(() => setBookingOpen(false), []);
return (
<BookingContext.Provider value={{ bookingOpen, openBooking, closeBooking }}>
{children}
</BookingContext.Provider>
);
}
const NOOP = () => {};
const FALLBACK: BookingContextValue = { bookingOpen: false, openBooking: NOOP, closeBooking: NOOP };
/** Returns booking context. Safe to use outside BookingProvider (returns inert fallback). */
export function useBooking(): BookingContextValue {
return useContext(BookingContext) ?? FALLBACK;
}

66
src/hooks/useFocusTrap.ts Normal file
View File

@@ -0,0 +1,66 @@
import { useEffect, useRef } from "react";
/**
* Traps keyboard focus within a container while active.
* Returns a ref to attach to the modal/dialog container element.
*/
export function useFocusTrap<T extends HTMLElement>(active: boolean) {
const containerRef = useRef<T>(null);
const previousFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!active) return;
previousFocus.current = document.activeElement as HTMLElement;
const container = containerRef.current;
if (!container) return;
// Focus the first focusable element
const focusableSelector =
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
function getFocusable() {
return Array.from(
container!.querySelectorAll<HTMLElement>(focusableSelector)
).filter((el) => el.offsetParent !== null);
}
// Delay to let modal animate in
const timer = setTimeout(() => {
const elements = getFocusable();
if (elements.length > 0) elements[0].focus();
}, 50);
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const elements = getFocusable();
if (elements.length === 0) return;
const first = elements[0];
const last = elements[elements.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
clearTimeout(timer);
document.removeEventListener("keydown", handleKeyDown);
// Restore focus to the element that triggered the modal
previousFocus.current?.focus();
};
}, [active]);
return containerRef;
}

View File

@@ -864,6 +864,7 @@ export interface ReminderItem {
telegram?: string; telegram?: string;
reminderStatus?: string; reminderStatus?: string;
eventLabel: string; eventLabel: string;
eventHall?: string;
eventDate: string; eventDate: string;
} }

55
src/lib/formatting.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* Shared date/text formatting utilities.
* Replaces duplicate formatDate implementations across News, NewsModal, MasterClasses, etc.
*/
const MONTHS_RU = [
"января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря",
];
const WEEKDAYS_RU = [
"воскресенье", "понедельник", "вторник", "среда",
"четверг", "пятница", "суббота",
];
/** Format ISO date string to Russian locale (e.g. "15 марта 2026, 19:00") */
export function formatDateRu(iso: string): string {
try {
const d = new Date(iso);
const date = d.toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
});
if (iso.includes("T")) {
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
return `${date}, ${time}`;
}
return date;
} catch {
return iso;
}
}
/** Format ISO date to short Russian date (e.g. "15.03.2026") */
export function formatDateShort(iso: string): string {
return new Date(iso).toLocaleDateString("ru-RU");
}
/** Format ISO date with weekday (e.g. "понедельник, 15 марта") */
export function formatDateWithWeekday(dateStr: string): string {
const d = new Date(dateStr + "T12:00:00");
return d.toLocaleDateString("ru-RU", {
weekday: "long",
day: "numeric",
month: "long",
});
}
/** Parse date-only ISO string safely */
export function parseDate(iso: string): Date {
return new Date(iso + "T00:00:00");
}
export { MONTHS_RU, WEEKDAYS_RU };

48
src/lib/ui-constants.ts Normal file
View File

@@ -0,0 +1,48 @@
/**
* UI constants: z-index layers, timing, border-radius tokens.
* Centralizes magic numbers that were scattered across components.
*/
// --- Z-Index System (ordered layers) ---
export const Z = {
/** Below normal content */
below: 0,
/** Floating buttons (BackToTop, FloatingContact) */
float: 40,
/** Sticky header */
header: 50,
/** Modal overlays */
modal: 60,
/** Drag-and-drop ghost elements */
drag: 9999,
} as const;
// --- Timing (ms) ---
export const TIMING = {
/** Auto-save debounce in admin editors */
autoSaveDebounce: 800,
/** Toast visibility after save */
toastSuccess: 2000,
/** Toast visibility after error */
toastError: 4000,
/** Scroll debounce reset */
scrollDebounce: 1000,
/** DnD highlight duration after drop */
dropHighlight: 1500,
/** Instagram validation debounce */
igValidation: 800,
/** Open Day polling interval */
openDayPoll: 10000,
} as const;
// --- Border Radius Tokens ---
export const RADIUS = {
/** Small elements: badges, chips */
sm: "rounded-lg",
/** Cards, panels, modals */
card: "rounded-2xl",
/** Pill buttons, tags */
full: "rounded-full",
/** Form inputs */
input: "rounded-lg",
} as const;