feat: add schedule section with location tabs, filters, and status badges

Two locations (Притыцкого 62/М, Машерова 17/4) with day-grid layout,
class type/trainer/status filters, and badges for availability and recruiting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 18:47:59 +03:00
parent 233c117afa
commit 8d2f482e99
5 changed files with 429 additions and 0 deletions

View File

@@ -0,0 +1,270 @@
"use client";
import { useState, useMemo } from "react";
import { MapPin, Clock, User, X, ChevronDown } from "lucide-react";
import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
const TYPE_DOT: Record<string, string> = {
"Exotic Pole Dance": "bg-[#c9a96e]",
"Pole Dance": "bg-rose-500",
"Body Plastic": "bg-purple-500",
"Трюковые комбинации с пилоном": "bg-amber-500",
};
type StatusFilter = "all" | "hasSlots" | "recruiting";
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");
}
const pillBase = "inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-medium transition-all duration-200 cursor-pointer";
const pillActive = "bg-[#c9a96e]/20 text-[#a08050] border border-[#c9a96e]/40 dark:text-[#d4b87a] dark:border-[#c9a96e]/30";
const pillInactive = "border border-neutral-200 text-neutral-500 hover:border-neutral-300 dark:border-white/[0.08] dark:text-white/35 dark:hover:border-white/15";
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-[#c9a96e] 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 */}
<Reveal>
<div className="mt-5 flex flex-wrap items-center justify-center gap-1.5">
{/* Class types */}
{types.map((type) => (
<button
key={type}
onClick={() => setFilterType(filterType === type ? null : type)}
className={`${pillBase} ${filterType === type ? pillActive : pillInactive}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${TYPE_DOT[type] ?? "bg-white/30"}`} />
{type}
</button>
))}
{/* Divider */}
<span className="mx-1 h-4 w-px bg-neutral-200 dark:bg-white/10" />
{/* Status filters */}
{hasAnySlots && (
<button
onClick={() => setFilterStatus(filterStatus === "hasSlots" ? "all" : "hasSlots")}
className={`${pillBase} ${filterStatus === "hasSlots" ? "bg-emerald-500/20 text-emerald-700 border border-emerald-500/40 dark:text-emerald-400 dark:border-emerald-500/30" : pillInactive}`}
>
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Есть места
</button>
)}
{hasAnyRecruiting && (
<button
onClick={() => setFilterStatus(filterStatus === "recruiting" ? "all" : "recruiting")}
className={`${pillBase} ${filterStatus === "recruiting" ? "bg-sky-500/20 text-sky-700 border border-sky-500/40 dark:text-sky-400 dark:border-sky-500/30" : pillInactive}`}
>
<span className="h-1.5 w-1.5 rounded-full bg-sky-500" />
Набор
</button>
)}
{/* Divider */}
<span className="mx-1 h-4 w-px bg-neutral-200 dark:bg-white/10" />
{/* Trainer dropdown toggle */}
<button
onClick={() => setShowTrainers(!showTrainers)}
className={`${pillBase} ${filterTrainer ? pillActive : pillInactive}`}
>
<User size={11} />
{filterTrainer ?? "Тренер"}
<ChevronDown size={10} className={`transition-transform duration-200 ${showTrainers ? "rotate-180" : ""}`} />
</button>
{/* Clear */}
{hasActiveFilter && (
<button
onClick={clearFilters}
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] text-neutral-400 hover:text-neutral-600 dark:text-white/25 dark:hover:text-white/50 transition-colors cursor-pointer"
>
<X size={11} />
</button>
)}
</div>
{/* Trainer pills — expandable */}
{showTrainers && (
<div className="mt-2 flex flex-wrap items-center justify-center gap-1.5">
{trainers.map((trainer) => (
<button
key={trainer}
onClick={() => {
setFilterTrainer(filterTrainer === trainer ? null : trainer);
setShowTrainers(false);
}}
className={`${pillBase} ${filterTrainer === trainer ? pillActive : pillInactive}`}
>
{trainer}
</button>
))}
</div>
)}
</Reveal>
</div>
{/* Day columns — full width */}
<Reveal>
<div
key={`${locationIndex}-${filterTrainer}-${filterType}-${filterStatus}`}
className={`mt-8 grid grid-cols-1 gap-3 px-4 sm:grid-cols-2 sm:px-6 lg:grid-cols-3 lg:px-8 xl:px-6 ${filteredDays.length >= 7 ? "xl:grid-cols-7" : filteredDays.length >= 4 ? "xl:grid-cols-" + filteredDays.length : "xl:grid-cols-4"}`}
style={filteredDays.length < 4 && filteredDays.length > 0 ? { maxWidth: filteredDays.length * 320 + (filteredDays.length - 1) * 12, marginInline: "auto" } : undefined}
>
{filteredDays.map((day) => (
<div
key={day.day}
className="rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a] overflow-hidden"
>
{/* Day header */}
<div className="border-b border-neutral-100 bg-neutral-50 px-5 py-4 dark:border-white/[0.04] dark:bg-white/[0.02]">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#c9a96e]/10 text-sm font-bold text-[#a08050] dark:bg-[#c9a96e]/10 dark:text-[#d4b87a]">
{day.dayShort}
</span>
<span className="text-base font-semibold text-neutral-900 dark:text-white/90">
{day.day}
</span>
</div>
</div>
{/* Classes */}
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
{day.classes.map((cls, i) => (
<div key={i} className={`px-5 py-3.5 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-neutral-500 dark:text-white/40">
<Clock size={13} />
<span className="font-semibold">{cls.time}</span>
</div>
{cls.hasSlots && (
<span className="shrink-0 rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
есть места
</span>
)}
{cls.recruiting && (
<span className="shrink-0 rounded-full bg-sky-500/15 border border-sky-500/25 px-2 py-0.5 text-[10px] font-semibold text-sky-600 dark:text-sky-400">
набор
</span>
)}
</div>
<div className="mt-1.5 flex items-center gap-2 text-sm font-medium text-neutral-800 dark:text-white/80">
<User size={13} className="shrink-0 text-neutral-400 dark:text-white/30" />
{cls.trainer}
</div>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${TYPE_DOT[cls.type] ?? "bg-white/30"}`} />
<span className="text-xs text-neutral-500 dark:text-white/40">{cls.type}</span>
</div>
{cls.level && (
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-0.5 text-[10px] font-semibold text-rose-600 dark:text-rose-400">
{cls.level}
</span>
)}
</div>
</div>
))}
</div>
</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>
);
}