Files
blackheart-website/src/components/sections/Schedule.tsx
diana.dolgolyova d5afaf92ba refactor: centralize gold tokens, extract sub-components, clean up unused code
- 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>
2026-03-11 14:57:39 +03:00

165 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}