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:
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user