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
+8
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>;
}
+3 -11
View File
@@ -7,12 +7,13 @@ import { BRAND, NAV_LINKS } from "@/lib/constants";
import { UI_CONFIG } from "@/lib/config";
import { HeroLogo } from "@/components/ui/HeroLogo";
import { SignupModal } from "@/components/ui/SignupModal";
import { useBooking } from "@/contexts/BookingContext";
export function Header() {
const [menuOpen, setMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [activeSection, setActiveSection] = useState("");
const [bookingOpen, setBookingOpen] = useState(false);
const { bookingOpen, openBooking, closeBooking } = useBooking();
useEffect(() => {
let ticking = false;
@@ -29,15 +30,6 @@ export function Header() {
return () => window.removeEventListener("scroll", handleScroll);
}, []);
// Listen for booking open events from other components
useEffect(() => {
function onOpenBooking() {
setBookingOpen(true);
}
window.addEventListener("open-booking", onOpenBooking);
return () => window.removeEventListener("open-booking", onOpenBooking);
}, []);
// Filter out nav links whose target section doesn't exist on the page
const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS);
useEffect(() => {
@@ -166,7 +158,7 @@ export function Header() {
</div>
<SignupModal open={bookingOpen} onClose={() => setBookingOpen(false)} endpoint="/api/group-booking" />
<SignupModal open={bookingOpen} onClose={closeBooking} endpoint="/api/group-booking" />
</header>
);
}
+3 -2
View File
@@ -25,7 +25,7 @@ export function FAQ({ data: faq }: FAQProps) {
const hasMore = faq.items.length > VISIBLE_COUNT;
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-container">
<Reveal>
@@ -41,11 +41,12 @@ export function FAQ({ data: faq }: FAQProps) {
className={`rounded-xl border transition-all duration-300 ${
isOpen
? "border-gold/30 bg-gradient-to-br from-gold/[0.06] via-transparent to-gold/[0.03] shadow-md shadow-gold/5"
: "border-neutral-200 bg-white hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]"
: "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
onClick={() => toggle(idx)}
aria-expanded={isOpen}
className="flex w-full items-center gap-3 px-5 py-4 text-left cursor-pointer"
>
{/* Number badge */}
+6 -4
View File
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/Button";
import { FloatingHearts } from "@/components/ui/FloatingHearts";
import { HeroLogo } from "@/components/ui/HeroLogo";
import type { SiteContent } from "@/types/content";
import { useBooking } from "@/contexts/BookingContext";
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) {
const { openBooking } = useBooking();
const sectionRef = useRef<HTMLElement>(null);
const scrolledRef = useRef(false);
const overlayRef = useRef<HTMLDivElement>(null);
@@ -78,7 +80,7 @@ export function Hero({ data: hero }: HeroProps) {
}, [scrollToNext]);
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 */}
{mounted && (
<>
@@ -140,7 +142,7 @@ export function Hero({ data: hero }: HeroProps) {
{/* Loading overlay — covers videos but not content */}
<div
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 */}
@@ -162,12 +164,12 @@ export function Hero({ data: hero }: HeroProps) {
<span className="gradient-text">{hero.headline}</span>
</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}
</p>
<div className="hero-cta mt-12">
<Button size="lg" onClick={() => window.dispatchEvent(new Event("open-booking"))}>
<Button size="lg" onClick={openBooking}>
{hero.ctaText}
</Button>
</div>
+3 -21
View File
@@ -7,30 +7,12 @@ import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { NewsModal } from "@/components/ui/NewsModal";
import type { SiteContent, NewsItem } from "@/types/content";
import { formatDateRu } from "@/lib/formatting";
interface NewsProps {
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({
item,
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">
<Calendar size={12} />
{formatDate(item.date)}
{formatDateRu(item.date)}
</span>
<h3 className="mt-3 text-xl sm:text-2xl font-bold text-white leading-tight">
{item.title}
@@ -108,7 +90,7 @@ function CompactArticle({
)}
<div className="flex-1 min-w-0">
<span className="text-xs text-neutral-400 dark:text-white/30">
{formatDate(item.date)}
{formatDateRu(item.date)}
</span>
<h3 className="mt-1 text-sm sm:text-base font-bold text-neutral-900 dark:text-white leading-snug line-clamp-2 group-hover:text-gold transition-colors">
{item.title}
+1
View File
@@ -204,6 +204,7 @@ function ClassCard({
onClick={() => {
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"
title={`Подробнее о ${cls.trainer}`}
>
+6 -6
View File
@@ -25,7 +25,7 @@ export function Pricing({ data: pricing }: PricingProps) {
const regularItems = pricing.items.filter((item) => !item.featured);
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-container">
<Reveal>
@@ -69,7 +69,7 @@ export function Pricing({ data: pricing }: PricingProps) {
className={`group relative rounded-2xl border p-5 transition-all duration-300 ${
isPopular
? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10"
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a]"
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-neutral-950"
}`}
>
{/* Popular badge */}
@@ -96,7 +96,7 @@ export function Pricing({ data: pricing }: PricingProps) {
)}
{/* 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}
</p>
</div>
@@ -122,7 +122,7 @@ export function Pricing({ data: pricing }: PricingProps) {
</p>
)}
</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}
</p>
</div>
@@ -137,7 +137,7 @@ export function Pricing({ data: pricing }: PricingProps) {
{pricing.rentalItems.map((item, i) => (
<div
key={i}
className="flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
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>
<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) => (
<div
key={i}
className="flex gap-4 rounded-2xl border border-neutral-200 bg-white px-5 py-4 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
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">
{i + 1}
+58 -56
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 { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Clock, MapPin, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
import type { TeamMember, RichListItem, ScheduleLocation } from "@/types/content";
@@ -28,64 +28,66 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
const hasEducation = member.education && member.education.length > 0;
// Extract trainer's groups from schedule using groupId
const groupMap = new Map<string, { type: string; location: string; address: string; slots: { day: string; dayShort: string; time: string }[]; level?: string; recruiting?: boolean }>();
schedule?.forEach(location => {
location.days.forEach(day => {
day.classes
.filter(c => c.trainer === member.name)
.forEach(c => {
const key = c.groupId
? `${c.groupId}||${location.name}`
: `${c.trainer}||${c.type}||${location.name}`;
const existing = groupMap.get(key);
if (existing) {
existing.slots.push({ day: day.day, dayShort: day.dayShort, time: c.time });
if (c.level && !existing.level) existing.level = c.level;
if (c.recruiting) existing.recruiting = true;
} else {
groupMap.set(key, {
type: c.type,
location: location.name,
address: location.address,
slots: [{ day: day.day, dayShort: day.dayShort, time: c.time }],
level: c.level,
recruiting: c.recruiting,
});
}
});
const uniqueGroups = useMemo(() => {
const groupMap = new Map<string, { type: string; location: string; address: string; slots: { day: string; dayShort: string; time: string }[]; level?: string; recruiting?: boolean }>();
schedule?.forEach(location => {
location.days.forEach(day => {
day.classes
.filter(c => c.trainer === member.name)
.forEach(c => {
const key = c.groupId
? `${c.groupId}||${location.name}`
: `${c.trainer}||${c.type}||${location.name}`;
const existing = groupMap.get(key);
if (existing) {
existing.slots.push({ day: day.day, dayShort: day.dayShort, time: c.time });
if (c.level && !existing.level) existing.level = c.level;
if (c.recruiting) existing.recruiting = true;
} else {
groupMap.set(key, {
type: c.type,
location: location.name,
address: location.address,
slots: [{ day: day.day, dayShort: day.dayShort, time: c.time }],
level: c.level,
recruiting: c.recruiting,
});
}
});
});
});
});
const uniqueGroups = Array.from(groupMap.values()).map(g => {
// Merge slots by day, then merge days with identical time sets
const dayMap = new Map<string, { dayShort: string; times: string[] }>();
const dayOrder: string[] = [];
for (const s of g.slots) {
const existing = dayMap.get(s.day);
if (existing) {
if (!existing.times.includes(s.time)) existing.times.push(s.time);
} else {
dayMap.set(s.day, { dayShort: s.dayShort, times: [s.time] });
dayOrder.push(s.day);
return Array.from(groupMap.values()).map(g => {
// Merge slots by day, then merge days with identical time sets
const dayMap = new Map<string, { dayShort: string; times: string[] }>();
const dayOrder: string[] = [];
for (const s of g.slots) {
const existing = dayMap.get(s.day);
if (existing) {
if (!existing.times.includes(s.time)) existing.times.push(s.time);
} else {
dayMap.set(s.day, { dayShort: s.dayShort, times: [s.time] });
dayOrder.push(s.day);
}
}
}
for (const entry of dayMap.values()) entry.times.sort();
const merged: { days: string[]; times: string[] }[] = [];
const used = new Set<string>();
for (const day of dayOrder) {
if (used.has(day)) continue;
const entry = dayMap.get(day)!;
const timeKey = entry.times.join("|");
const days = [entry.dayShort];
used.add(day);
for (const other of dayOrder) {
if (used.has(other)) continue;
const o = dayMap.get(other)!;
if (o.times.join("|") === timeKey) { days.push(o.dayShort); used.add(other); }
for (const entry of dayMap.values()) entry.times.sort();
const merged: { days: string[]; times: string[] }[] = [];
const used = new Set<string>();
for (const day of dayOrder) {
if (used.has(day)) continue;
const entry = dayMap.get(day)!;
const timeKey = entry.times.join("|");
const days = [entry.dayShort];
used.add(day);
for (const other of dayOrder) {
if (used.has(other)) continue;
const o = dayMap.get(other)!;
if (o.times.join("|") === timeKey) { days.push(o.dayShort); used.add(other); }
}
merged.push({ days, times: entry.times });
}
merged.push({ days, times: entry.times });
}
return { ...g, merged };
});
return { ...g, merged };
});
}, [member.name, schedule]);
const hasGroups = uniqueGroups.length > 0;
const hasBio = hasVictories || hasEducation || hasGroups;
+7 -6
View File
@@ -4,11 +4,12 @@ import { useState, useEffect } from "react";
import { Phone, X, Mail, Instagram } from "lucide-react";
import { BRAND } from "@/lib/constants";
import { SignupModal } from "./SignupModal";
import { useBooking } from "@/contexts/BookingContext";
export function FloatingContact() {
const [visible, setVisible] = useState(false);
const [expanded, setExpanded] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const { bookingOpen: modalOpen, openBooking, closeBooking } = useBooking();
useEffect(() => {
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 ${
expanded
? "bg-neutral-700 rotate-0"
: "bg-[#c9a96e] hover:bg-[#d4b87a] shadow-[#c9a96e]/30"
: "bg-gold hover:bg-gold-light shadow-gold/30"
}`}
>
{expanded ? (
@@ -72,7 +73,7 @@ export function FloatingContact() {
{/* Phone */}
<a
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="Позвонить"
>
<Phone size={20} className="text-white" />
@@ -91,8 +92,8 @@ export function FloatingContact() {
{/* Записаться */}
<button
onClick={() => { setModalOpen(true); 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"
onClick={() => { openBooking(); setExpanded(false); }}
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="Записаться"
>
<span className="text-xs font-bold text-black leading-tight text-center">
@@ -104,7 +105,7 @@ export function FloatingContact() {
<SignupModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onClose={closeBooking}
title="Записаться на занятие"
subtitle="Оставьте контактные данные и мы свяжемся с вами"
endpoint="/api/group-booking"
+9 -22
View File
@@ -5,31 +5,17 @@ import { createPortal } from "react-dom";
import Image from "next/image";
import { X, Calendar, ExternalLink } from "lucide-react";
import type { NewsItem } from "@/types/content";
import { formatDateRu } from "@/lib/formatting";
import { useFocusTrap } from "@/hooks/useFocusTrap";
interface NewsModalProps {
item: NewsItem | null;
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) {
const focusTrapRef = useFocusTrap<HTMLDivElement>(!!item);
useEffect(() => {
if (!item) return;
function onKey(e: KeyboardEvent) {
@@ -49,7 +35,7 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
return createPortal(
<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"
aria-modal="true"
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="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()}
>
<button
@@ -82,14 +69,14 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
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 className={`p-6 sm:p-8 ${item.image ? "-mt-12 relative" : ""}`}>
<span className="inline-flex items-center gap-1.5 text-xs text-neutral-400">
<Calendar size={12} />
{formatDate(item.date)}
{formatDateRu(item.date)}
</span>
<h2 className="mt-2 text-xl sm:text-2xl font-bold text-white leading-tight">
+5 -2
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { X, CheckCircle, Send, Phone as PhoneIcon, Instagram } from "lucide-react";
import { BRAND } from "@/lib/constants";
import { useFocusTrap } from "@/hooks/useFocusTrap";
interface SignupModalProps {
open: boolean;
@@ -47,6 +48,7 @@ export function SignupModal({
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [successData, setSuccessData] = useState<Record<string, unknown> | null>(null);
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
function handlePhoneChange(raw: string) {
let digits = raw.replace(/\D/g, "");
@@ -141,10 +143,11 @@ export function SignupModal({
if (!open) return null;
return createPortal(
<div className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-label={title} onClick={handleClose}>
<div className="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="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()}
>
<button