- Replace hardcoded hex colors with gold/gold-light/gold-dark Tailwind tokens - Extract Schedule into DayCard, ScheduleFilters, MobileSchedule sub-components - Extract Team into TeamCarousel, TeamMemberInfo sub-components - Add UI_CONFIG for centralized magic numbers (timings, thresholds) - Add reusable IconBadge component, simplify Contact section - Convert Pricing clickable divs to semantic buttons for a11y - Remove unused SocialLinks, btn-outline, btn-ghost, nav-link CSS classes - Fix React setState-during-render error in TeamCarousel (deferred update pattern) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
165 lines
6.2 KiB
TypeScript
165 lines
6.2 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useMemo } from "react";
|
||
import { MapPin } from "lucide-react";
|
||
import { siteContent } from "@/data/content";
|
||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||
import { Reveal } from "@/components/ui/Reveal";
|
||
import { DayCard } from "./schedule/DayCard";
|
||
import { ScheduleFilters } from "./schedule/ScheduleFilters";
|
||
import { MobileSchedule } from "./schedule/MobileSchedule";
|
||
import type { StatusFilter } from "./schedule/constants";
|
||
|
||
export function Schedule() {
|
||
const { schedule } = siteContent;
|
||
const [locationIndex, setLocationIndex] = useState(0);
|
||
const [filterTrainer, setFilterTrainer] = useState<string | null>(null);
|
||
const [filterType, setFilterType] = useState<string | null>(null);
|
||
const [filterStatus, setFilterStatus] = useState<StatusFilter>("all");
|
||
const [showTrainers, setShowTrainers] = useState(false);
|
||
const location = schedule.locations[locationIndex];
|
||
|
||
const { trainers, types, hasAnySlots, hasAnyRecruiting } = useMemo(() => {
|
||
const trainerSet = new Set<string>();
|
||
const typeSet = new Set<string>();
|
||
let slots = false;
|
||
let recruiting = false;
|
||
for (const day of location.days) {
|
||
for (const cls of day.classes) {
|
||
trainerSet.add(cls.trainer);
|
||
typeSet.add(cls.type);
|
||
if (cls.hasSlots) slots = true;
|
||
if (cls.recruiting) recruiting = true;
|
||
}
|
||
}
|
||
return {
|
||
trainers: Array.from(trainerSet).sort(),
|
||
types: Array.from(typeSet).sort(),
|
||
hasAnySlots: slots,
|
||
hasAnyRecruiting: recruiting,
|
||
};
|
||
}, [location]);
|
||
|
||
const filteredDays = useMemo(() => {
|
||
const noFilter = !filterTrainer && !filterType && filterStatus === "all";
|
||
if (noFilter) return location.days;
|
||
return location.days
|
||
.map((day) => ({
|
||
...day,
|
||
classes: day.classes.filter(
|
||
(cls) =>
|
||
(!filterTrainer || cls.trainer === filterTrainer) &&
|
||
(!filterType || cls.type === filterType) &&
|
||
(filterStatus === "all" ||
|
||
(filterStatus === "hasSlots" && cls.hasSlots) ||
|
||
(filterStatus === "recruiting" && cls.recruiting))
|
||
),
|
||
}))
|
||
.filter((day) => day.classes.length > 0);
|
||
}, [location.days, filterTrainer, filterType, filterStatus]);
|
||
|
||
const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all");
|
||
|
||
function clearFilters() {
|
||
setFilterTrainer(null);
|
||
setFilterType(null);
|
||
setFilterStatus("all");
|
||
}
|
||
|
||
return (
|
||
<section
|
||
id="schedule"
|
||
className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505] overflow-hidden"
|
||
>
|
||
<div className="section-divider absolute top-0 left-0 right-0" />
|
||
|
||
<div className="section-container">
|
||
<Reveal>
|
||
<SectionHeading centered>{schedule.title}</SectionHeading>
|
||
</Reveal>
|
||
|
||
{/* Location tabs */}
|
||
<Reveal>
|
||
<div className="mt-8 flex justify-center gap-2">
|
||
{schedule.locations.map((loc, i) => (
|
||
<button
|
||
key={loc.name}
|
||
onClick={() => {
|
||
setLocationIndex(i);
|
||
clearFilters();
|
||
setShowTrainers(false);
|
||
}}
|
||
className={`inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
|
||
i === locationIndex
|
||
? "bg-gold text-black shadow-[0_0_20px_rgba(201,169,110,0.3)]"
|
||
: "border border-neutral-300 text-neutral-500 hover:border-neutral-400 hover:text-neutral-700 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20"
|
||
}`}
|
||
>
|
||
<MapPin size={14} />
|
||
{loc.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Reveal>
|
||
|
||
{/* Compact filters — desktop only */}
|
||
<Reveal>
|
||
<ScheduleFilters
|
||
types={types}
|
||
trainers={trainers}
|
||
hasAnySlots={hasAnySlots}
|
||
hasAnyRecruiting={hasAnyRecruiting}
|
||
filterType={filterType}
|
||
setFilterType={setFilterType}
|
||
filterTrainer={filterTrainer}
|
||
setFilterTrainer={setFilterTrainer}
|
||
filterStatus={filterStatus}
|
||
setFilterStatus={setFilterStatus}
|
||
showTrainers={showTrainers}
|
||
setShowTrainers={setShowTrainers}
|
||
hasActiveFilter={hasActiveFilter}
|
||
clearFilters={clearFilters}
|
||
/>
|
||
</Reveal>
|
||
</div>
|
||
|
||
{/* Mobile: compact agenda list with tap-to-filter */}
|
||
<Reveal>
|
||
<MobileSchedule
|
||
filteredDays={filteredDays}
|
||
filterType={filterType}
|
||
setFilterType={setFilterType}
|
||
filterTrainer={filterTrainer}
|
||
setFilterTrainer={setFilterTrainer}
|
||
hasActiveFilter={hasActiveFilter}
|
||
clearFilters={clearFilters}
|
||
/>
|
||
</Reveal>
|
||
|
||
{/* Desktop: grid layout */}
|
||
<Reveal>
|
||
<div
|
||
key={`${locationIndex}-${filterTrainer}-${filterType}-${filterStatus}`}
|
||
className={`mt-8 hidden sm:grid grid-cols-1 gap-3 px-4 sm:px-6 lg:px-8 xl:px-6 ${filteredDays.length >= 7 ? "sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7" : filteredDays.length >= 6 ? "sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6" : filteredDays.length >= 4 ? "sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5" : filteredDays.length === 3 ? "sm:grid-cols-2 lg:grid-cols-3" : filteredDays.length === 2 ? "sm:grid-cols-2" : "justify-items-center"}`}
|
||
style={filteredDays.length === 1 ? undefined : filteredDays.length <= 3 && filteredDays.length > 0 ? { maxWidth: filteredDays.length * 340 + (filteredDays.length - 1) * 12, marginInline: "auto" } : undefined}
|
||
>
|
||
{filteredDays.map((day) => (
|
||
<div
|
||
key={day.day}
|
||
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""}
|
||
>
|
||
<DayCard day={day} />
|
||
</div>
|
||
))}
|
||
|
||
{filteredDays.length === 0 && (
|
||
<div className="col-span-full py-12 text-center text-sm text-neutral-400 dark:text-white/30">
|
||
Нет занятий по выбранным фильтрам
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Reveal>
|
||
</section>
|
||
);
|
||
}
|