fix: comprehensive UI/UX accessibility and usability improvements

Public site: skip-to-content link, mobile menu focus trap + Escape key,
aria-current on nav, keyboard navigation for carousels/tabs/articles,
ARIA roles (tablist/tab/tabpanel, combobox/listbox, region, dialog),
form labels + aria-describedby, 44px touch targets, semantic HTML
(<time>, <del>), prefers-reduced-motion on Hero scroll hijack,
mobile schedule filters, URL hash sync on scroll for correct refresh.

Admin panel: password toggle aria-label, toast aria-live regions,
SelectField keyboard navigation (Arrow/Enter/Escape), aria-invalid
on validation errors, sidebar hamburger aria-label/expanded,
nav aria-label, ArrayEditor aria-expanded on collapsible items.
This commit is contained in:
2026-03-29 20:42:14 +03:00
parent 024424c578
commit 77ad2a6b68
30 changed files with 538 additions and 418 deletions
+49 -6
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import { Menu, X } from "lucide-react";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { BRAND, NAV_LINKS } from "@/lib/constants";
import { UI_CONFIG } from "@/lib/config";
import { HeroLogo } from "@/components/ui/HeroLogo";
@@ -14,6 +14,8 @@ export function Header() {
const [scrolled, setScrolled] = useState(false);
const [activeSection, setActiveSection] = useState("");
const { bookingOpen, openBooking, closeBooking } = useBooking();
const menuButtonRef = useRef<HTMLButtonElement>(null);
const firstNavLinkRef = useRef<HTMLAnchorElement>(null);
useEffect(() => {
let ticking = false;
@@ -30,6 +32,33 @@ export function Header() {
return () => window.removeEventListener("scroll", handleScroll);
}, []);
// Close mobile menu on Escape key
useEffect(() => {
if (!menuOpen) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
setMenuOpen(false);
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [menuOpen]);
// Focus management: focus first nav link when menu opens, return focus to button when it closes
const prevMenuOpenRef = useRef(false);
useEffect(() => {
if (menuOpen && !prevMenuOpenRef.current) {
// Menu just opened — focus first nav link
requestAnimationFrame(() => {
firstNavLinkRef.current?.focus();
});
} else if (!menuOpen && prevMenuOpenRef.current) {
// Menu just closed — return focus to menu button
menuButtonRef.current?.focus();
}
prevMenuOpenRef.current = menuOpen;
}, [menuOpen]);
// Filter out nav links whose target section doesn't exist on the page
const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS);
useEffect(() => {
@@ -45,7 +74,12 @@ export function Header() {
if (hero) {
const heroObserver = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setActiveSection("");
if (entry.isIntersecting) {
setActiveSection("");
if (window.location.hash) {
history.replaceState(null, "", window.location.pathname);
}
}
},
{ rootMargin: "-20% 0px -70% 0px" },
);
@@ -61,6 +95,10 @@ export function Header() {
([entry]) => {
if (entry.isIntersecting) {
setActiveSection(id);
// Sync URL hash so refresh returns to the current section
if (window.location.hash !== `#${id}`) {
history.replaceState(null, "", `#${id}`);
}
}
},
{ rootMargin: "-40% 0px -55% 0px" },
@@ -80,6 +118,7 @@ export function Header() {
: "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>
<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">
<div className="relative flex h-8 w-8 items-center justify-center">
@@ -99,14 +138,15 @@ export function Header() {
</span>
</Link>
<nav className="hidden items-center gap-3 lg:gap-5 xl:gap-6 lg:flex">
<nav className="hidden items-center gap-3 lg:gap-5 xl:gap-6 lg:flex" aria-label="Основная навигация">
{visibleLinks.map((link) => {
const isActive = activeSection === link.href.replace("#", "");
return (
<a
key={link.href}
href={link.href}
className={`relative whitespace-nowrap py-1 text-xs lg: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 ${
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 ${
isActive
? "text-gold-light after:w-full"
: "text-neutral-400 after:w-0 hover:text-white hover:after:w-full"
@@ -120,6 +160,7 @@ export function Header() {
<div className="flex items-center gap-2 lg:hidden">
<button
ref={menuButtonRef}
onClick={() => setMenuOpen(!menuOpen)}
aria-label={menuOpen ? "Закрыть меню" : "Открыть меню"}
aria-expanded={menuOpen}
@@ -136,14 +177,16 @@ export function Header() {
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">
{visibleLinks.map((link) => {
<nav className="border-t border-white/[0.06] px-6 py-4 text-center sm:px-8" aria-label="Основная навигация">
{visibleLinks.map((link, index) => {
const isActive = activeSection === link.href.replace("#", "");
return (
<a
key={link.href}
ref={index === 0 ? firstNavLinkRef : undefined}
href={link.href}
onClick={() => setMenuOpen(false)}
aria-current={isActive ? "page" : undefined}
className={`block py-3 text-base transition-colors ${
isActive
? "text-gold-light"
+5 -4
View File
@@ -16,9 +16,9 @@ interface AboutProps {
export function About({ data: about, stats }: AboutProps) {
const statItems = [
{ icon: <Users size={22} />, value: String(stats.trainers), label: "тренеров" },
{ icon: <Layers size={22} />, value: String(stats.classes), label: "направлений" },
{ icon: <MapPin size={22} />, value: String(stats.locations), label: "зала в Минске" },
{ icon: <Users size={22} />, value: String(stats.trainers), label: "тренеров", ariaLabel: `${stats.trainers} тренеров` },
{ icon: <Layers size={22} />, value: String(stats.classes), label: "направлений", ariaLabel: `${stats.classes} направлений` },
{ icon: <MapPin size={22} />, value: String(stats.locations), label: "зала в Минске", ariaLabel: `${stats.locations} зала в Минске` },
];
return (
@@ -45,9 +45,10 @@ export function About({ data: about, stats }: AboutProps) {
{statItems.map((stat, i) => (
<div
key={i}
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"
>
<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">
<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}
</div>
<span className="font-display text-3xl font-bold text-neutral-900 sm:text-4xl dark:text-white">
+1
View File
@@ -44,6 +44,7 @@ export function Classes({ data: classes }: ClassesProps) {
activeIndex={activeIndex}
onSelect={select}
onHoverChange={setHovering}
getItemLabel={(item) => item.name}
renderDetail={(item) => (
<div>
{/* Hero image */}
+2 -2
View File
@@ -20,7 +20,7 @@ export function Contact({ data: contact }: ContactProps) {
<div className="mt-10 space-y-5">
{contact.addresses.map((address, i) => (
<div key={i} className="group flex items-center gap-4">
<div key={i} className="group flex items-center gap-4" aria-label="Наш адрес">
<IconBadge><MapPin size={18} /></IconBadge>
<p className="body-text">{address}</p>
</div>
@@ -38,7 +38,7 @@ export function Contact({ data: contact }: ContactProps) {
<div className="group flex items-center gap-4">
<IconBadge><Clock size={18} /></IconBadge>
<p className="body-text">{contact.workingHours}</p>
<p className="body-text"><time>{contact.workingHours}</time></p>
</div>
<div className="border-t border-neutral-200 pt-5 dark:border-white/[0.08]">
+5
View File
@@ -45,8 +45,10 @@ export function FAQ({ data: faq }: FAQProps) {
}`}
>
<button
id={`faq-button-${idx}`}
onClick={() => toggle(idx)}
aria-expanded={isOpen}
aria-controls={`faq-panel-${idx}`}
className="flex w-full items-center gap-3 px-5 py-4 text-left cursor-pointer"
>
{/* Number badge */}
@@ -73,6 +75,9 @@ export function FAQ({ data: faq }: FAQProps) {
</button>
<div
id={`faq-panel-${idx}`}
role="region"
aria-labelledby={`faq-button-${idx}`}
className={`grid transition-all duration-300 ease-out ${
isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
}`}
+9 -2
View File
@@ -24,7 +24,12 @@ export function Hero({ data: hero }: HeroProps) {
const centerVideo = videos[Math.floor(videos.length / 2)] || videos[0];
const totalVideos = videos.slice(0, 3).length + 1; // desktop (3) + mobile (1)
useEffect(() => { setMounted(true); }, []);
const prefersReducedMotion = useRef(false);
useEffect(() => {
setMounted(true);
prefersReducedMotion.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}, []);
const handleVideoReady = useCallback(() => {
readyCount.current += 1;
@@ -48,6 +53,7 @@ export function Hero({ data: hero }: HeroProps) {
if (!el) return;
function handleWheel(e: WheelEvent) {
if (prefersReducedMotion.current) return;
if (e.deltaY <= 0 || scrolledRef.current) return;
if (window.scrollY > 10) return;
scrolledRef.current = true;
@@ -60,6 +66,7 @@ export function Hero({ data: hero }: HeroProps) {
}
function handleTouchEnd(e: TouchEvent) {
if (prefersReducedMotion.current) return;
const startY = Number((el as HTMLElement).dataset.touchY);
const endY = e.changedTouches[0].clientY;
if (startY - endY > 50 && !scrolledRef.current && window.scrollY < 10) {
@@ -80,7 +87,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-neutral-950">
<section id="hero" ref={sectionRef} aria-label="Главный баннер" 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 && (
<>
+4 -3
View File
@@ -127,11 +127,11 @@ function MasterClassCard({
<div className="absolute inset-x-0 bottom-0 flex flex-col p-5 sm:p-6">
{/* Tags row */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<span className="inline-flex items-center gap-1 rounded-full border border-gold/40 bg-black/40 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-gold backdrop-blur-md">
<span className="inline-flex items-center gap-1 rounded-full border border-gold/40 bg-black/40 px-2.5 py-0.5 text-xs font-semibold uppercase tracking-wider text-gold backdrop-blur-md">
{item.style}
</span>
{duration && (
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-0.5 text-[11px] text-white/60 backdrop-blur-md">
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-0.5 text-xs text-white/60 backdrop-blur-md">
<Clock size={10} />
{duration}
</span>
@@ -168,7 +168,7 @@ function MasterClassCard({
{/* Spots info */}
{(maxP > 0 || (item.minParticipants && item.minParticipants > 0)) && (
<div className="mb-3 flex items-center gap-3 text-[11px]">
<div className="mb-3 flex items-center gap-3 text-xs">
{maxP > 0 && (
<span className={isFull ? "text-amber-400" : "text-white/40"}>
{currentRegs}/{maxP} мест
@@ -186,6 +186,7 @@ function MasterClassCard({
<div className="flex items-center gap-3">
<button
onClick={onSignup}
aria-label={`Записаться на ${item.title}`}
className={`flex-1 rounded-xl py-3 text-sm font-bold uppercase tracking-wide transition-all cursor-pointer ${
isFull
? "bg-amber-500/15 text-amber-400 hover:bg-amber-500/25"
+28 -4
View File
@@ -20,8 +20,18 @@ function FeaturedArticle({
item: NewsItem;
onClick: () => void;
}) {
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}
return (
<article
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
className="group relative overflow-hidden rounded-3xl cursor-pointer"
onClick={onClick}
>
@@ -47,7 +57,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} />
{formatDateRu(item.date)}
<time dateTime={item.date}>{formatDateRu(item.date)}</time>
</span>
<h3 className="mt-3 text-xl sm:text-2xl font-bold text-white leading-tight">
{item.title}
@@ -67,8 +77,18 @@ function CompactArticle({
item: NewsItem;
onClick: () => void;
}) {
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}
return (
<article
role="button"
tabIndex={0}
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"
onClick={onClick}
>
@@ -89,9 +109,9 @@ function CompactArticle({
</div>
)}
<div className="flex-1 min-w-0">
<span className="text-xs text-neutral-400 dark:text-white/30">
<time dateTime={item.date} className="text-xs text-neutral-400 dark:text-white/30">
{formatDateRu(item.date)}
</span>
</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">
{item.title}
</h3>
@@ -153,6 +173,7 @@ export function News({ data }: NewsProps) {
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button
aria-label="Предыдущая страница"
onClick={() => {
setPage((p) => Math.max(0, p - 1));
const el = document.getElementById("news");
@@ -171,7 +192,9 @@ export function News({ data }: NewsProps) {
const el = document.getElementById("news");
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}}
className={`h-8 w-8 rounded-full text-sm font-medium transition-colors cursor-pointer ${
aria-label={`Страница ${i + 1}`}
aria-current={i === page ? "page" : undefined}
className={`h-10 w-10 rounded-full text-sm font-medium transition-colors cursor-pointer ${
i === page
? "bg-gold text-black"
: "border border-white/10 text-neutral-400 hover:text-white hover:border-white/25"
@@ -181,6 +204,7 @@ export function News({ data }: NewsProps) {
</button>
))}
<button
aria-label="Следующая страница"
onClick={() => {
setPage((p) => Math.min(totalPages - 1, p + 1));
const el = document.getElementById("news");
+11 -11
View File
@@ -71,7 +71,7 @@ export function OpenDay({ data, popups, teamMembers }: OpenDayProps) {
<div className="mt-4 text-center">
<div className="inline-flex items-center gap-2 rounded-full bg-gold/10 border border-gold/20 px-5 py-2.5 text-sm font-medium text-gold">
<Calendar size={16} />
{formatDateRu(event.date)}
<time dateTime={event.date}>{formatDateRu(event.date)}</time>
</div>
</div>
</Reveal>
@@ -177,12 +177,12 @@ function ClassCard({
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3 sm:p-4 opacity-50">
<div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0 space-y-1">
<span className="rounded-md bg-neutral-800 px-2 py-0.5 text-[11px] font-bold text-neutral-500">
{cls.startTime}{cls.endTime}
<span className="rounded-md bg-neutral-800 px-2 py-0.5 text-xs font-bold text-neutral-500">
<time dateTime={`${cls.startTime}-${cls.endTime}`}>{cls.startTime}{cls.endTime}</time>
</span>
<p className="text-sm text-neutral-500 line-through">{cls.trainer} · {cls.style}</p>
<p className="text-sm text-neutral-500"><del>{cls.trainer} · {cls.style}</del></p>
</div>
<span className="text-[10px] 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-800 rounded-full px-2.5 py-0.5 font-medium">
Отменено
</span>
</div>
@@ -205,11 +205,11 @@ function ClassCard({
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-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"
title={`Подробнее о ${cls.trainer}`}
>
{trainerPhoto ? (
<Image src={trainerPhoto} alt={cls.trainer} fill className="object-cover" sizes="40px" />
<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]">
<User size={16} className="text-white/40" />
@@ -231,8 +231,8 @@ function ClassCard({
{/* Time + style */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="rounded-md bg-gold/10 px-2 py-0.5 text-[11px] font-bold text-gold min-w-[80px] text-center">
{cls.startTime}{cls.endTime}
<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>
</span>
<span className="text-sm font-medium text-white/60">{cls.style}</span>
</div>
@@ -241,7 +241,7 @@ function ClassCard({
{/* Badges */}
<div className="flex items-center gap-1.5 flex-wrap">
{maxParticipants > 0 && (
<span className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${
<span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold ${
isFull
? "bg-amber-500/15 border border-amber-500/25 text-amber-400"
: "bg-white/[0.04] border border-white/[0.08] text-white/45"
@@ -255,7 +255,7 @@ function ClassCard({
{/* Book button */}
<button
onClick={() => onSignup({ classId: cls.id, label })}
className={`shrink-0 self-center rounded-xl px-4 py-2 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
? "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"
+28 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useRef } from "react";
import { CreditCard, Building2, ScrollText, Crown, Sparkles } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
@@ -14,12 +14,27 @@ interface PricingProps {
export function Pricing({ data: pricing }: PricingProps) {
const [activeTab, setActiveTab] = useState<Tab>("prices");
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "prices", label: "Абонементы", icon: <CreditCard size={16} /> },
{ id: "rental", label: "Аренда зала", icon: <Building2 size={16} /> },
{ id: "rules", label: "Правила", icon: <ScrollText size={16} /> },
];
function handleTabKeyDown(e: React.KeyboardEvent, index: number) {
let nextIndex: number | null = null;
if (e.key === "ArrowRight") {
nextIndex = (index + 1) % tabs.length;
} else if (e.key === "ArrowLeft") {
nextIndex = (index - 1 + tabs.length) % tabs.length;
}
if (nextIndex !== null) {
e.preventDefault();
setActiveTab(tabs[nextIndex].id);
tabRefs.current[nextIndex]?.focus();
}
}
// Split items: featured (big card) vs regular
const featuredItem = pricing.items.find((item) => item.featured);
const regularItems = pricing.items.filter((item) => !item.featured);
@@ -34,11 +49,18 @@ export function Pricing({ data: pricing }: PricingProps) {
{/* Tabs */}
<Reveal>
<div className="mt-12 flex flex-wrap justify-center gap-2">
{tabs.map((tab) => (
<div role="tablist" aria-label="Разделы цен" className="mt-12 flex flex-wrap justify-center gap-2">
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(el) => { tabRefs.current[index] = el; }}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`tabpanel-${tab.id}`}
id={`tab-${tab.id}`}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
onKeyDown={(e) => handleTabKeyDown(e, index)}
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
? "bg-gold text-black shadow-lg shadow-gold/25"
@@ -53,7 +75,7 @@ export function Pricing({ data: pricing }: PricingProps) {
</Reveal>
{/* Prices tab */}
<div className={activeTab === "prices" ? "block" : "hidden"}>
<div id="tabpanel-prices" role="tabpanel" aria-labelledby="tab-prices" className={activeTab === "prices" ? "block" : "hidden"}>
<div className="mx-auto mt-10 max-w-4xl">
<p className="mb-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
{pricing.subtitle}
@@ -132,7 +154,7 @@ export function Pricing({ data: pricing }: PricingProps) {
</div>
{/* Rental tab */}
<div className={activeTab === "rental" ? "block" : "hidden"}>
<div id="tabpanel-rental" role="tabpanel" aria-labelledby="tab-rental" className={activeTab === "rental" ? "block" : "hidden"}>
<div className="mx-auto mt-10 max-w-2xl space-y-3">
{pricing.rentalItems.map((item, i) => (
<div
@@ -158,7 +180,7 @@ export function Pricing({ data: pricing }: PricingProps) {
</div>
{/* Rules tab */}
<div className={activeTab === "rules" ? "block" : "hidden"}>
<div id="tabpanel-rules" role="tabpanel" aria-labelledby="tab-rules" className={activeTab === "rules" ? "block" : "hidden"}>
<div className="mx-auto mt-10 max-w-2xl space-y-3">
{pricing.rules.map((rule, i) => (
<div
+31 -2
View File
@@ -2,7 +2,7 @@
import { useReducer, useMemo, useCallback } from "react";
import { SignupModal } from "@/components/ui/SignupModal";
import { CalendarDays, Users, LayoutGrid } from "lucide-react";
import { CalendarDays, Users, LayoutGrid, SlidersHorizontal } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
import { DayCard } from "./schedule/DayCard";
@@ -336,7 +336,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
<span className="text-center">
<span className="block leading-tight">{loc.name}</span>
{loc.address && (
<span className={`block text-[10px] 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"
}`}>
{shortAddress(loc.address)}
@@ -348,6 +348,35 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
</div>
</Reveal>
{/* Mobile filter button — visible only on small screens */}
<Reveal>
<div className="mt-4 flex sm:hidden justify-center">
<ScheduleFilters
typeDots={typeDots}
types={types}
availableStatuses={availableStatuses}
levels={levels}
filterTypes={filterTypes}
toggleFilterType={toggleFilterType}
filterTrainerSet={filterTrainerSet}
toggleFilterTrainer={toggleFilterTrainer}
filterStatusSet={filterStatusSet}
toggleFilterStatus={toggleFilterStatus}
filterLevel={filterLevel}
setFilterLevel={setFilterLevel}
filterTime={filterTime}
setFilterTime={setFilterTime}
availableDays={availableDays}
filterDaySet={filterDaySet}
toggleDay={toggleDay}
hasActiveFilter={hasActiveFilter}
clearFilters={clearFilters}
trainerNames={trainerNames}
scheduleConfig={scheduleConfig}
/>
</div>
</Reveal>
{/* View mode toggle + filter button */}
<Reveal>
<div className="mt-4 hidden sm:flex items-center justify-center">
+43 -2
View File
@@ -52,6 +52,8 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
const wasDragRef = useRef(false);
const pausedUntilRef = useRef(0);
const dragStartRef = useRef<{ x: number; startIndex: number } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [swipeHintVisible, setSwipeHintVisible] = useState(true);
// Pause auto-rotation when activeIndex changes externally (e.g. dot click)
const prevIndexRef = useRef(activeIndex);
@@ -72,6 +74,27 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
return () => clearInterval(id);
}, [total, activeIndex, onActiveChange]);
// Keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
pausedUntilRef.current = Date.now() + PAUSE_MS;
onActiveChange(wrapIndex(activeIndex - 1, total));
} else if (e.key === "ArrowRight") {
e.preventDefault();
pausedUntilRef.current = Date.now() + PAUSE_MS;
onActiveChange(wrapIndex(activeIndex + 1, total));
}
},
[activeIndex, total, onActiveChange],
);
// Hide swipe hint after first interaction
const hideSwipeHint = useCallback(() => {
if (swipeHintVisible) setSwipeHintVisible(false);
}, [swipeHintVisible]);
// Pointer handlers
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
@@ -80,8 +103,9 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
wasDragRef.current = false;
dragStartRef.current = { x: e.clientX, startIndex: activeIndex };
setDragOffset(0);
hideSwipeHint();
},
[activeIndex]
[activeIndex, hideSwipeHint]
);
const onPointerMove = useCallback(
@@ -164,13 +188,19 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
return (
<div
className="relative mx-auto flex items-end justify-center cursor-grab select-none active:cursor-grabbing touch-pan-y"
ref={containerRef}
role="region"
aria-label="Карусель команды"
aria-roledescription="carousel"
tabIndex={0}
className="relative mx-auto flex items-end justify-center cursor-grab select-none active:cursor-grabbing touch-pan-y focus:outline-none focus-visible:ring-2 focus-visible:ring-gold/50 focus-visible:rounded-2xl"
style={{ height: UI_CONFIG.team.stageHeight }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onLostPointerCapture={onPointerUp}
onKeyDown={handleKeyDown}
>
{/* Spotlight cone */}
<div
@@ -184,6 +214,15 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
/>
{/* Cards */}
{/* Mobile swipe hint */}
<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 ${
swipeHintVisible ? "opacity-60" : "opacity-0 pointer-events-none"
}`}
>
свайп
</div>
{members.map((m, i) => {
const style = getCardStyle(i);
if (!style) return null;
@@ -191,6 +230,8 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
return (
<div
key={m.name}
role="group"
aria-label={m.name}
onClick={() => {
if (!style.isCenter && !wasDragRef.current) {
onActiveChange(i);
+4 -2
View File
@@ -6,6 +6,7 @@ interface ButtonProps {
children: React.ReactNode;
className?: string;
onClick?: () => void;
disabled?: boolean;
}
const sizes = {
@@ -20,8 +21,9 @@ export function Button({
children,
className = "",
onClick,
disabled,
}: ButtonProps) {
const classes = `btn-primary ${sizes[size]} ${className}`;
const classes = `btn-primary ${sizes[size]} disabled:opacity-50 disabled:cursor-not-allowed ${className}`;
if (href) {
return (
@@ -32,7 +34,7 @@ export function Button({
}
return (
<button onClick={onClick} className={classes}>
<button onClick={onClick} className={classes} disabled={disabled}>
{children}
</button>
);
+1 -1
View File
@@ -51,7 +51,7 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
<button
onClick={onClose}
aria-label="Закрыть"
className="absolute right-4 top-4 z-10 flex h-8 w-8 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-neutral-400 backdrop-blur-sm transition-colors hover:bg-white/[0.1] hover:text-white cursor-pointer"
>
<X size={18} />
</button>
+20
View File
@@ -11,6 +11,7 @@ interface ShowcaseLayoutProps<T> {
renderDetail: (item: T, index: number) => React.ReactNode;
renderSelectorItem: (item: T, index: number, isActive: boolean) => React.ReactNode;
counter?: boolean;
getItemLabel?: (item: T, index: number) => string;
}
export function ShowcaseLayout<T>({
@@ -21,6 +22,7 @@ export function ShowcaseLayout<T>({
renderDetail,
renderSelectorItem,
counter = false,
getItemLabel,
}: ShowcaseLayoutProps<T>) {
const selectorRef = useRef<HTMLDivElement>(null);
const activeItemRef = useRef<HTMLButtonElement>(null);
@@ -121,6 +123,20 @@ export function ShowcaseLayout<T>({
[activeIndex, items.length, onSelect],
);
// Keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") {
e.preventDefault();
if (activeIndex > 0) onSelect(activeIndex - 1);
} else if (e.key === "ArrowRight") {
e.preventDefault();
if (activeIndex < items.length - 1) onSelect(activeIndex + 1);
}
},
[activeIndex, items.length, onSelect],
);
function handleMouseEnter() {
setIsUserInteracting(true);
onHoverChange?.(true);
@@ -136,12 +152,14 @@ export function ShowcaseLayout<T>({
className="flex flex-col-reverse gap-6 lg:flex-row lg:gap-8"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onKeyDown={handleKeyDown}
>
{/* Detail area */}
<div className="lg:w-[60%]">
<div
ref={detailWrapRef}
style={minHeight != null ? { minHeight } : undefined}
aria-live="polite"
>
<div
ref={detailRef}
@@ -182,6 +200,8 @@ export function ShowcaseLayout<T>({
key={i}
ref={i === activeIndex ? activeItemRef : null}
onClick={() => onSelect(i)}
aria-label={getItemLabel ? getItemLabel(item, i) : `Элемент ${i + 1}`}
aria-pressed={i === activeIndex}
className={`cursor-pointer rounded-xl border-2 text-left transition-all duration-300 ${
i === activeIndex
? "border-gold/60 bg-gold/10 dark:bg-gold/5"
+23 -10
View File
@@ -153,7 +153,7 @@ export function SignupModal({
<button
onClick={handleClose}
aria-label="Закрыть"
className="absolute right-4 top-4 flex h-8 w-8 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 transition-colors hover:bg-white/[0.06] hover:text-white cursor-pointer"
>
<X size={18} />
</button>
@@ -223,29 +223,40 @@ export function SignupModal({
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ваше имя"
required
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]"
/>
<div>
<label htmlFor="signup-name" className="sr-only">Ваше имя</label>
<input
id="signup-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ваше имя"
required
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]"
/>
</div>
<div className="relative">
<label htmlFor="signup-phone" className="sr-only">Телефон</label>
<PhoneIcon size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
id="signup-phone"
type="tel"
value={phone}
onChange={(e) => handlePhoneChange(e.target.value)}
placeholder="+375 (__) ___-__-__"
required
aria-required="true"
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]"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="relative">
<label htmlFor="signup-instagram" className="sr-only">Instagram (необязательно)</label>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
<input
id="signup-instagram"
type="text"
value={instagram}
onChange={(e) => setInstagram(e.target.value.replace(/^@/, ""))}
@@ -254,8 +265,10 @@ export function SignupModal({
/>
</div>
<div className="relative">
<label htmlFor="signup-telegram" className="sr-only">Telegram (необязательно)</label>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 text-xs">@</span>
<input
id="signup-telegram"
type="text"
value={telegram}
onChange={(e) => setTelegram(e.target.value.replace(/^@/, ""))}
@@ -266,7 +279,7 @@ export function SignupModal({
</div>
{error && error !== "network" && (
<p className="text-sm text-red-400">{error}</p>
<p id="error-phone" className="text-sm text-red-400">{error}</p>
)}
<button