feat: upgrade schedule with cross-location views, day/time filters, and clickable trainers

- Add "Все студии" tab merging all locations by weekday with location sub-headers
- Location tabs show hall name + address subtitle for clarity
- Add day multi-select and time-of-day preset filters (Утро/День/Вечер) behind collapsible "Когда" button
- Make trainer and type names clickable in day cards for inline filtering
- Add group view clustering classes by trainer+type+location
- Remove trainer dropdown from filter bar — filter by clicking names in schedule
- Add searchable icon picker and lucide-react icon rendering for classes admin/section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 21:25:11 +03:00
parent 8ff7713cf2
commit 46ad10e8a0
8 changed files with 891 additions and 222 deletions

View File

@@ -1,13 +1,127 @@
"use client"; "use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField, TextareaField } from "../_components/FormField"; import { InputField, TextareaField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor"; import { ArrayEditor } from "../_components/ArrayEditor";
import { icons, type LucideIcon } from "lucide-react";
const ICON_OPTIONS = [ // PascalCase "HeartPulse" → kebab "heart-pulse"
"sparkles", "flame", "wind", "zap", "star", "monitor", function toKebab(name: string) {
"heart", "music", "dumbbell", "trophy", return name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
]; }
// All icons as { key: kebab-name, Icon: component, label: PascalCase }
const ALL_ICONS = Object.entries(icons).map(([name, Icon]) => ({
key: toKebab(name),
Icon: Icon as LucideIcon,
label: name,
}));
const ICON_BY_KEY = Object.fromEntries(ALL_ICONS.map((i) => [i.key, i]));
function IconPicker({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selected = ICON_BY_KEY[value];
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [open]);
const filtered = useMemo(() => {
if (!search) return ALL_ICONS.slice(0, 60);
const q = search.toLowerCase();
return ALL_ICONS.filter((i) => i.label.toLowerCase().includes(q)).slice(0, 60);
}, [search]);
const SelectedIcon = selected?.Icon;
return (
<div ref={ref} className="relative">
<label className="block text-sm text-neutral-400 mb-1.5">Иконка</label>
<button
type="button"
onClick={() => {
setOpen(!open);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}}
className={`w-full flex items-center gap-2.5 rounded-lg border bg-neutral-800 px-4 py-2.5 text-left text-white outline-none transition-colors ${
open ? "border-gold" : "border-white/10"
}`}
>
{SelectedIcon ? (
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-gold/20 text-gold-light">
<SelectedIcon size={16} />
</span>
) : (
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-white/10 text-neutral-500">?</span>
)}
<span className="text-sm">{selected?.label || value}</span>
</button>
{open && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
<div className="p-2 pb-0">
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск иконки... (flame, heart, star...)"
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
/>
</div>
<div className="p-2 max-h-56 overflow-y-auto">
{filtered.length === 0 ? (
<div className="py-3 text-center text-sm text-neutral-500">Ничего не найдено</div>
) : (
<div className="grid grid-cols-6 gap-1">
{filtered.map(({ key, Icon, label }) => (
<button
key={key}
type="button"
title={label}
onClick={() => {
onChange(key);
setOpen(false);
setSearch("");
}}
className={`flex flex-col items-center gap-0.5 rounded-lg p-2 transition-colors ${
key === value
? "bg-gold/20 text-gold-light"
: "text-neutral-400 hover:bg-white/5 hover:text-white"
}`}
>
<Icon size={20} />
<span className="text-[10px] leading-tight truncate w-full text-center">{label}</span>
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}
const COLOR_SWATCHES: { value: string; bg: string }[] = [ const COLOR_SWATCHES: { value: string; bg: string }[] = [
{ value: "rose", bg: "bg-rose-500" }, { value: "rose", bg: "bg-rose-500" },
@@ -63,24 +177,10 @@ export default function ClassesEditorPage() {
value={item.name} value={item.name}
onChange={(v) => updateItem({ ...item, name: v })} onChange={(v) => updateItem({ ...item, name: v })}
/> />
<div> <IconPicker
<label className="block text-sm text-neutral-400 mb-1.5"> value={item.icon}
Иконка onChange={(v) => updateItem({ ...item, icon: v })}
</label> />
<select
value={item.icon}
onChange={(e) =>
updateItem({ ...item, icon: e.target.value })
}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
>
{ICON_OPTIONS.map((icon) => (
<option key={icon} value={icon}>
{icon}
</option>
))}
</select>
</div>
</div> </div>
<div> <div>
<label className="block text-sm text-neutral-400 mb-1.5"> <label className="block text-sm text-neutral-400 mb-1.5">

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { Flame, Sparkles, Wind, Zap, Star, Monitor } from "lucide-react"; import { icons } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout"; import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
@@ -9,14 +9,15 @@ import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
import type { ClassItem, SiteContent } from "@/types"; import type { ClassItem, SiteContent } from "@/types";
import { UI_CONFIG } from "@/lib/config"; import { UI_CONFIG } from "@/lib/config";
const iconMap: Record<string, React.ReactNode> = { // kebab "heart-pulse" → PascalCase "HeartPulse"
flame: <Flame size={20} />, function toPascal(kebab: string) {
sparkles: <Sparkles size={20} />, return kebab.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
wind: <Wind size={20} />, }
zap: <Zap size={20} />,
star: <Star size={20} />, function getIcon(key: string) {
monitor: <Monitor size={20} />, const Icon = icons[toPascal(key) as keyof typeof icons];
}; return Icon ? <Icon size={20} /> : null;
}
interface ClassesProps { interface ClassesProps {
data: SiteContent["classes"]; data: SiteContent["classes"];
@@ -60,7 +61,7 @@ export function Classes({ data: classes }: ClassesProps) {
{/* Icon + name overlay */} {/* Icon + name overlay */}
<div className="absolute bottom-0 left-0 right-0 p-6"> <div className="absolute bottom-0 left-0 right-0 p-6">
<div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-gold/20 text-gold-light backdrop-blur-sm"> <div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-gold/20 text-gold-light backdrop-blur-sm">
{iconMap[item.icon]} {getIcon(item.icon)}
</div> </div>
<h3 className="text-2xl font-bold text-white"> <h3 className="text-2xl font-bold text-white">
{item.name} {item.name}
@@ -87,7 +88,7 @@ export function Classes({ data: classes }: ClassesProps) {
: "bg-neutral-200/50 text-neutral-500 dark:bg-white/[0.06] dark:text-neutral-400" : "bg-neutral-200/50 text-neutral-500 dark:bg-white/[0.06] dark:text-neutral-400"
}`} }`}
> >
{iconMap[item.icon]} {getIcon(item.icon)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p <p

View File

@@ -1,56 +1,113 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { MapPin } from "lucide-react"; import { CalendarDays, Users, LayoutGrid } from "lucide-react";
import { SectionHeading } from "@/components/ui/SectionHeading"; import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal"; import { Reveal } from "@/components/ui/Reveal";
import { DayCard } from "./schedule/DayCard"; import { DayCard } from "./schedule/DayCard";
import { ScheduleFilters } from "./schedule/ScheduleFilters"; import { ScheduleFilters } from "./schedule/ScheduleFilters";
import { MobileSchedule } from "./schedule/MobileSchedule"; import { MobileSchedule } from "./schedule/MobileSchedule";
import { buildTypeDots } from "./schedule/constants"; import { GroupView } from "./schedule/GroupView";
import type { StatusFilter } from "./schedule/constants"; import { buildTypeDots, shortAddress, startTimeMinutes, TIME_PRESETS } from "./schedule/constants";
import type { StatusFilter, TimeFilter, ScheduleDayMerged, ScheduleClassWithLocation } from "./schedule/constants";
import type { SiteContent } from "@/types/content"; import type { SiteContent } from "@/types/content";
type ViewMode = "days" | "groups";
type LocationMode = "all" | number;
interface ScheduleProps { interface ScheduleProps {
data: SiteContent["schedule"]; data: SiteContent["schedule"];
classItems?: { name: string; color?: string }[]; classItems?: { name: string; color?: string }[];
} }
export function Schedule({ data: schedule, classItems }: ScheduleProps) { export function Schedule({ data: schedule, classItems }: ScheduleProps) {
const [locationIndex, setLocationIndex] = useState(0); const [locationMode, setLocationMode] = useState<LocationMode>("all");
const [viewMode, setViewMode] = useState<ViewMode>("days");
const [filterTrainer, setFilterTrainer] = useState<string | null>(null); const [filterTrainer, setFilterTrainer] = useState<string | null>(null);
const [filterType, setFilterType] = useState<string | null>(null); const [filterType, setFilterType] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<StatusFilter>("all"); const [filterStatus, setFilterStatus] = useState<StatusFilter>("all");
const [showTrainers, setShowTrainers] = useState(false); const [filterTime, setFilterTime] = useState<TimeFilter>("all");
const location = schedule.locations[locationIndex]; const [filterDaySet, setFilterDaySet] = useState<Set<string>>(new Set());
const isAllMode = locationMode === "all";
const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]); const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]);
const { trainers, types, hasAnySlots, hasAnyRecruiting } = useMemo(() => { // Build days: either from one location or merged from all
const trainerSet = new Set<string>(); const activeDays: ScheduleDayMerged[] = useMemo(() => {
if (locationMode !== "all") {
const loc = schedule.locations[locationMode];
if (!loc) return [];
return loc.days.map((day) => ({
...day,
classes: day.classes.map((cls) => ({ ...cls })),
}));
}
// Merge all locations by weekday
const dayOrder = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"];
const dayMap = new Map<string, ScheduleDayMerged>();
for (const loc of schedule.locations) {
for (const day of loc.days) {
const existing = dayMap.get(day.day);
const taggedClasses: ScheduleClassWithLocation[] = day.classes.map((cls) => ({
...cls,
locationName: loc.name,
locationAddress: loc.address,
}));
if (existing) {
existing.classes = [...existing.classes, ...taggedClasses];
} else {
dayMap.set(day.day, {
day: day.day,
dayShort: day.dayShort,
classes: taggedClasses,
});
}
}
}
// Sort by weekday order
return dayOrder
.filter((d) => dayMap.has(d))
.map((d) => dayMap.get(d)!);
}, [locationMode, schedule.locations]);
const { types, hasAnySlots, hasAnyRecruiting } = useMemo(() => {
const typeSet = new Set<string>(); const typeSet = new Set<string>();
let slots = false; let slots = false;
let recruiting = false; let recruiting = false;
for (const day of location.days) { for (const day of activeDays) {
for (const cls of day.classes) { for (const cls of day.classes) {
trainerSet.add(cls.trainer);
typeSet.add(cls.type); typeSet.add(cls.type);
if (cls.hasSlots) slots = true; if (cls.hasSlots) slots = true;
if (cls.recruiting) recruiting = true; if (cls.recruiting) recruiting = true;
} }
} }
return { return {
trainers: Array.from(trainerSet).sort(),
types: Array.from(typeSet).sort(), types: Array.from(typeSet).sort(),
hasAnySlots: slots, hasAnySlots: slots,
hasAnyRecruiting: recruiting, hasAnyRecruiting: recruiting,
}; };
}, [location]); }, [activeDays]);
const filteredDays = useMemo(() => { // Get the time range for the active time filter
const noFilter = !filterTrainer && !filterType && filterStatus === "all"; const activeTimeRange = filterTime !== "all"
if (noFilter) return location.days; ? TIME_PRESETS.find((p) => p.value === filterTime)?.range
return location.days : null;
const filteredDays: ScheduleDayMerged[] = useMemo(() => {
const noFilter = !filterTrainer && !filterType && filterStatus === "all" && filterTime === "all" && filterDaySet.size === 0;
if (noFilter) return activeDays;
// First filter by day names if any selected
const dayFiltered = filterDaySet.size > 0
? activeDays.filter((day) => filterDaySet.has(day.day))
: activeDays;
return dayFiltered
.map((day) => ({ .map((day) => ({
...day, ...day,
classes: day.classes.filter( classes: day.classes.filter(
@@ -59,20 +116,49 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
(!filterType || cls.type === filterType) && (!filterType || cls.type === filterType) &&
(filterStatus === "all" || (filterStatus === "all" ||
(filterStatus === "hasSlots" && cls.hasSlots) || (filterStatus === "hasSlots" && cls.hasSlots) ||
(filterStatus === "recruiting" && cls.recruiting)) (filterStatus === "recruiting" && cls.recruiting)) &&
(!activeTimeRange || (() => {
const m = startTimeMinutes(cls.time);
return m >= activeTimeRange[0] && m < activeTimeRange[1];
})())
), ),
})) }))
.filter((day) => day.classes.length > 0); .filter((day) => day.classes.length > 0);
}, [location.days, filterTrainer, filterType, filterStatus]); }, [activeDays, filterTrainer, filterType, filterStatus, filterTime, activeTimeRange, filterDaySet]);
const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all"); const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all" || filterTime !== "all" || filterDaySet.size > 0);
function clearFilters() { function clearFilters() {
setFilterTrainer(null); setFilterTrainer(null);
setFilterType(null); setFilterType(null);
setFilterStatus("all"); setFilterStatus("all");
setFilterTime("all");
setFilterDaySet(new Set());
} }
// Available days for the day filter
const availableDays = useMemo(() =>
activeDays.map((d) => ({ day: d.day, dayShort: d.dayShort })),
[activeDays]
);
function toggleDay(day: string) {
setFilterDaySet((prev) => {
const next = new Set(prev);
if (next.has(day)) next.delete(day);
else next.add(day);
return next;
});
}
function switchLocation(mode: LocationMode) {
setLocationMode(mode);
clearFilters();
}
const activeTabClass = "bg-gold text-black shadow-[0_0_20px_rgba(201,169,110,0.3)]";
const inactiveTabClass = "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";
return ( return (
<section <section
id="schedule" id="schedule"
@@ -87,87 +173,151 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
{/* Location tabs */} {/* Location tabs */}
<Reveal> <Reveal>
<div className="mt-8 flex justify-center gap-2"> <div className="mt-8 flex justify-center gap-2 flex-wrap">
{/* "All studios" tab */}
<button
onClick={() => switchLocation("all")}
className={`inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
isAllMode ? activeTabClass : inactiveTabClass
}`}
>
<LayoutGrid size={14} />
<span className="hidden sm:inline">Все студии</span>
<span className="sm:hidden">Все</span>
</button>
{/* Per-location tabs */}
{schedule.locations.map((loc, i) => ( {schedule.locations.map((loc, i) => (
<button <button
key={loc.name} key={loc.name}
onClick={() => { onClick={() => switchLocation(i)}
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 ${ 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 locationMode === i ? activeTabClass : inactiveTabClass
? "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} /> <span className="text-center">
{loc.name} <span className="block leading-tight">{loc.name}</span>
{loc.address && (
<span className={`block text-[10px] font-normal leading-tight mt-0.5 ${
locationMode === i ? "text-black/60" : "text-neutral-400 dark:text-white/25"
}`}>
{shortAddress(loc.address)}
</span>
)}
</span>
</button> </button>
))} ))}
</div> </div>
</Reveal> </Reveal>
{/* View mode toggle */}
<Reveal>
<div className="mt-4 flex justify-center">
<div className="inline-flex rounded-xl border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.08] dark:bg-white/[0.04]">
<button
onClick={() => setViewMode("days")}
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
viewMode === "days"
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
: "text-neutral-500 hover:text-neutral-700 dark:text-white/35 dark:hover:text-white/60"
}`}
>
<CalendarDays size={13} />
По дням
</button>
<button
onClick={() => setViewMode("groups")}
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
viewMode === "groups"
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
: "text-neutral-500 hover:text-neutral-700 dark:text-white/35 dark:hover:text-white/60"
}`}
>
<Users size={13} />
По группам
</button>
</div>
</div>
</Reveal>
{/* Compact filters — desktop only */} {/* Compact filters — desktop only */}
<Reveal> <Reveal>
<ScheduleFilters <ScheduleFilters
typeDots={typeDots} typeDots={typeDots}
types={types} types={types}
trainers={trainers}
hasAnySlots={hasAnySlots} hasAnySlots={hasAnySlots}
hasAnyRecruiting={hasAnyRecruiting} hasAnyRecruiting={hasAnyRecruiting}
filterType={filterType} filterType={filterType}
setFilterType={setFilterType} setFilterType={setFilterType}
filterTrainer={filterTrainer} filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainer}
filterStatus={filterStatus} filterStatus={filterStatus}
setFilterStatus={setFilterStatus} setFilterStatus={setFilterStatus}
showTrainers={showTrainers} filterTime={filterTime}
setShowTrainers={setShowTrainers} setFilterTime={setFilterTime}
availableDays={availableDays}
filterDaySet={filterDaySet}
toggleDay={toggleDay}
hasActiveFilter={hasActiveFilter} hasActiveFilter={hasActiveFilter}
clearFilters={clearFilters} clearFilters={clearFilters}
/> />
</Reveal> </Reveal>
</div> </div>
{/* Mobile: compact agenda list with tap-to-filter */} {viewMode === "days" ? (
<Reveal> <>
<MobileSchedule {/* Mobile: compact agenda list with tap-to-filter */}
typeDots={typeDots} <Reveal>
filteredDays={filteredDays} <MobileSchedule
filterType={filterType} typeDots={typeDots}
setFilterType={setFilterType} filteredDays={filteredDays}
filterTrainer={filterTrainer} filterType={filterType}
setFilterTrainer={setFilterTrainer} setFilterType={setFilterType}
hasActiveFilter={hasActiveFilter} filterTrainer={filterTrainer}
clearFilters={clearFilters} setFilterTrainer={setFilterTrainer}
/> hasActiveFilter={hasActiveFilter}
</Reveal> clearFilters={clearFilters}
showLocation={isAllMode}
/>
</Reveal>
{/* Desktop: grid layout */} {/* Desktop: grid layout */}
<Reveal> <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 <div
key={day.day} key={`${locationMode}-${filterTrainer}-${filterType}-${filterStatus}`}
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""} 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}
> >
<DayCard day={day} typeDots={typeDots} /> {filteredDays.map((day) => (
</div> <div
))} key={day.day}
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""}
>
<DayCard day={day} typeDots={typeDots} showLocation={isAllMode} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterType={filterType} setFilterType={setFilterType} />
</div>
))}
{filteredDays.length === 0 && ( {filteredDays.length === 0 && (
<div className="col-span-full py-12 text-center text-sm text-neutral-400 dark:text-white/30"> <div className="col-span-full py-12 text-center text-sm text-neutral-400 dark:text-white/30">
Нет занятий по выбранным фильтрам Нет занятий по выбранным фильтрам
</div>
)}
</div> </div>
)} </Reveal>
</div> </>
</Reveal> ) : (
/* Group view: classes clustered by trainer+type */
<Reveal>
<GroupView
typeDots={typeDots}
filteredDays={filteredDays}
filterType={filterType}
setFilterType={setFilterType}
filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainer}
showLocation={isAllMode}
/>
</Reveal>
)}
</section> </section>
); );
} }

View File

@@ -1,7 +1,98 @@
import { Clock, User } from "lucide-react"; import { Clock, User, MapPin } from "lucide-react";
import type { ScheduleDay } from "@/types/content"; import { shortAddress } from "./constants";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
interface DayCardProps {
day: ScheduleDayMerged;
typeDots: Record<string, string>;
showLocation?: boolean;
filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void;
filterType: string | null;
setFilterType: (type: string | null) => void;
}
function ClassRow({
cls,
typeDots,
filterTrainer,
setFilterTrainer,
filterType,
setFilterType,
}: {
cls: ScheduleClassWithLocation;
typeDots: Record<string, string>;
filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void;
filterType: string | null;
setFilterType: (type: string | null) => void;
}) {
return (
<div 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>
<button
onClick={() => setFilterTrainer(filterTrainer === cls.trainer ? null : cls.trainer)}
className={`mt-1.5 flex items-center gap-2 text-sm font-medium cursor-pointer active:opacity-60 ${
filterTrainer === cls.trainer
? "text-gold underline underline-offset-2"
: "text-neutral-800 dark:text-white/80"
}`}
>
<User size={13} className="shrink-0 text-neutral-400 dark:text-white/30" />
{cls.trainer}
</button>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<button
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)}
className="flex items-center gap-2 cursor-pointer active:opacity-60"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
<span className={`text-xs ${
filterType === cls.type
? "text-gold underline underline-offset-2"
: "text-neutral-500 dark:text-white/40"
}`}>{cls.type}</span>
</button>
{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>
);
}
export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterTrainer, filterType, setFilterType }: DayCardProps) {
// Group classes by location when showLocation is true
const locationGroups = showLocation
? Array.from(
day.classes.reduce((map, cls) => {
const loc = cls.locationName ?? "";
if (!map.has(loc)) {
map.set(loc, { address: cls.locationAddress, classes: [] });
}
map.get(loc)!.classes.push(cls);
return map;
}, new Map<string, { address?: string; classes: ScheduleClassWithLocation[] }>())
)
: null;
export function DayCard({ day, typeDots }: { day: ScheduleDay; typeDots: Record<string, string> }) {
return ( return (
<div className="rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a] overflow-hidden"> <div className="rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a] overflow-hidden">
{/* Day header */} {/* Day header */}
@@ -17,43 +108,35 @@ export function DayCard({ day, typeDots }: { day: ScheduleDay; typeDots: Record<
</div> </div>
{/* Classes */} {/* Classes */}
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]"> {locationGroups ? (
{day.classes.map((cls, i) => ( // Split by location
<div key={i} className={`px-5 py-3.5 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}> <div>
<div className="flex items-center justify-between gap-2"> {locationGroups.map(([locName, { address, classes }], gi) => (
<div className="flex items-center gap-2 text-sm text-neutral-500 dark:text-white/40"> <div key={locName}>
<Clock size={13} /> {/* Location sub-header */}
<span className="font-semibold">{cls.time}</span> <div className={`flex items-center gap-1.5 px-5 py-2 bg-neutral-100/60 dark:bg-white/[0.03] ${gi > 0 ? "border-t border-neutral-200 dark:border-white/[0.06]" : ""}`}>
<MapPin size={11} className="shrink-0 text-neutral-400 dark:text-white/25" />
<span className="text-[11px] font-medium text-neutral-400 dark:text-white/30">
{locName}
{address && <span className="text-neutral-300 dark:text-white/15"> · {shortAddress(address)}</span>}
</span>
</div> </div>
{cls.hasSlots && ( <div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
<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"> {classes.map((cls, i) => (
есть места <ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterType={filterType} setFilterType={setFilterType} />
</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 ${typeDots[cls.type] ?? "bg-white/30"}`} />
<span className="text-xs text-neutral-500 dark:text-white/40">{cls.type}</span>
</div> </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>
</div> ) : (
// Single location — no sub-headers
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
{day.classes.map((cls, i) => (
<ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterType={filterType} setFilterType={setFilterType} />
))}
</div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,186 @@
"use client";
import { User, Clock, CalendarDays, MapPin } from "lucide-react";
import { shortAddress } from "./constants";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
interface ScheduleGroup {
trainer: string;
type: string;
level?: string;
hasSlots: boolean;
recruiting: boolean;
location?: string;
locationAddress?: string;
slots: { day: string; dayShort: string; time: string }[];
}
function buildGroups(days: ScheduleDayMerged[]): ScheduleGroup[] {
const map = new Map<string, ScheduleGroup>();
for (const day of days) {
for (const cls of day.classes as ScheduleClassWithLocation[]) {
// Include location in key so same trainer+type at different locations = separate groups
const locPart = cls.locationName ?? "";
const key = `${cls.trainer}||${cls.type}||${locPart}`;
const existing = map.get(key);
if (existing) {
existing.slots.push({ day: day.day, dayShort: day.dayShort, time: cls.time });
if (cls.hasSlots) existing.hasSlots = true;
if (cls.recruiting) existing.recruiting = true;
if (cls.level && !existing.level) existing.level = cls.level;
} else {
map.set(key, {
trainer: cls.trainer,
type: cls.type,
level: cls.level,
hasSlots: !!cls.hasSlots,
recruiting: !!cls.recruiting,
location: cls.locationName,
locationAddress: cls.locationAddress,
slots: [{ day: day.day, dayShort: day.dayShort, time: cls.time }],
});
}
}
}
return Array.from(map.values());
}
interface GroupViewProps {
typeDots: Record<string, string>;
filteredDays: ScheduleDayMerged[];
filterType: string | null;
setFilterType: (type: string | null) => void;
filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void;
showLocation?: boolean;
}
export function GroupView({
typeDots,
filteredDays,
filterType,
setFilterType,
filterTrainer,
setFilterTrainer,
showLocation,
}: GroupViewProps) {
const groups = buildGroups(filteredDays);
if (groups.length === 0) {
return (
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">
Нет занятий по выбранным фильтрам
</div>
);
}
return (
<div className="mt-8 grid grid-cols-1 gap-3 px-4 sm:grid-cols-2 lg:grid-cols-3 sm:px-6 lg:px-8 xl:px-6">
{groups.map((group) => {
const dotColor = typeDots[group.type] ?? "bg-white/30";
return (
<div
key={`${group.trainer}||${group.type}||${group.location ?? ""}`}
className={`rounded-2xl border overflow-hidden transition-colors ${
group.hasSlots
? "border-emerald-500/25 bg-white dark:border-emerald-500/15 dark:bg-[#0a0a0a]"
: group.recruiting
? "border-sky-500/25 bg-white dark:border-sky-500/15 dark:bg-[#0a0a0a]"
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a]"
}`}
>
{/* Header */}
<div className={`px-5 py-4 border-b ${
group.hasSlots
? "border-emerald-500/15 bg-emerald-500/5 dark:border-emerald-500/10 dark:bg-emerald-500/[0.03]"
: group.recruiting
? "border-sky-500/15 bg-sky-500/5 dark:border-sky-500/10 dark:bg-sky-500/[0.03]"
: "border-neutral-100 bg-neutral-50 dark:border-white/[0.04] dark:bg-white/[0.02]"
}`}>
{/* Type + badges */}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setFilterType(filterType === group.type ? null : group.type)}
className={`flex items-center gap-2 active:opacity-60 ${
filterType === group.type ? "opacity-100" : ""
}`}
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`} />
<span className="text-base font-semibold text-neutral-900 dark:text-white/90">
{group.type}
</span>
</button>
{group.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">
{group.level}
</span>
)}
{group.hasSlots && (
<span className="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>
)}
{group.recruiting && (
<span className="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>
{/* Trainer */}
<button
onClick={() => setFilterTrainer(filterTrainer === group.trainer ? null : group.trainer)}
className={`mt-2 flex items-center gap-1.5 text-sm active:opacity-60 ${
filterTrainer === group.trainer
? "text-gold underline underline-offset-2"
: "text-neutral-500 dark:text-white/50"
}`}
>
<User size={13} className="shrink-0" />
{group.trainer}
</button>
{/* Location badge — only in "all" mode */}
{showLocation && group.location && (
<div className="mt-2 flex items-center gap-1 text-[11px] text-neutral-400 dark:text-white/30">
<MapPin size={10} className="shrink-0" />
{group.location}
{group.locationAddress && (
<span className="text-neutral-300 dark:text-white/15"> · {shortAddress(group.locationAddress)}</span>
)}
</div>
)}
</div>
{/* Schedule slots */}
<div className="px-5 py-3.5">
<div className="flex items-center gap-1.5 mb-2.5 text-[11px] font-medium text-neutral-400 dark:text-white/25 uppercase tracking-wider">
<CalendarDays size={12} />
Расписание
</div>
<div className="space-y-1.5">
{group.slots.map((slot, i) => (
<div
key={i}
className="flex items-center gap-3 rounded-lg bg-neutral-50 px-3 py-2 dark:bg-white/[0.03]"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-gold/10 text-[10px] font-bold text-gold-dark dark:text-gold-light">
{slot.dayShort}
</span>
<div className="flex items-center gap-1.5 text-sm text-neutral-600 dark:text-white/60">
<Clock size={12} className="shrink-0 text-neutral-400 dark:text-white/30" />
<span className="font-medium tabular-nums">{slot.time}</span>
</div>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -1,17 +1,90 @@
"use client"; "use client";
import { User, X } from "lucide-react"; import { User, X, MapPin } from "lucide-react";
import type { ScheduleDay } from "@/types/content"; import { shortAddress } from "./constants";
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
interface MobileScheduleProps { interface MobileScheduleProps {
typeDots: Record<string, string>; typeDots: Record<string, string>;
filteredDays: ScheduleDay[]; filteredDays: ScheduleDayMerged[];
filterType: string | null; filterType: string | null;
setFilterType: (type: string | null) => void; setFilterType: (type: string | null) => void;
filterTrainer: string | null; filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void; setFilterTrainer: (trainer: string | null) => void;
hasActiveFilter: boolean; hasActiveFilter: boolean;
clearFilters: () => void; clearFilters: () => void;
showLocation?: boolean;
}
function ClassRow({
cls,
typeDots,
filterType,
setFilterType,
filterTrainer,
setFilterTrainer,
showLocation,
}: {
cls: ScheduleClassWithLocation;
typeDots: Record<string, string>;
filterType: string | null;
setFilterType: (type: string | null) => void;
filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void;
showLocation?: boolean;
}) {
return (
<div
className={`ml-3 flex items-start gap-3 rounded-lg px-3 py-2 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}
>
{/* Time */}
<span className="shrink-0 w-[72px] text-xs font-semibold tabular-nums text-neutral-500 dark:text-white/40 pt-0.5">
{cls.time}
</span>
{/* Info — tappable trainer & type */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<button
onClick={() => setFilterTrainer(filterTrainer === cls.trainer ? null : cls.trainer)}
className={`truncate text-sm font-medium text-left active:opacity-60 ${filterTrainer === cls.trainer ? "text-gold underline underline-offset-2" : "text-neutral-800 dark:text-white/80"}`}
>
{cls.trainer}
</button>
{cls.hasSlots && (
<span className="shrink-0 rounded-full bg-emerald-500/15 border border-emerald-500/25 px-1.5 py-px text-[9px] 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-1.5 py-px text-[9px] font-semibold text-sky-600 dark:text-sky-400">
набор
</span>
)}
{cls.level && (
<span className="shrink-0 rounded-full bg-rose-500/15 border border-rose-500/25 px-1.5 py-px text-[9px] font-semibold text-rose-600 dark:text-rose-400">
{cls.level}
</span>
)}
</div>
<div className="mt-0.5 flex items-center gap-2">
<button
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)}
className={`flex items-center gap-1.5 active:opacity-60 ${filterType === cls.type ? "opacity-100" : ""}`}
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
<span className={`text-[11px] ${filterType === cls.type ? "text-gold underline underline-offset-2" : "text-neutral-400 dark:text-white/30"}`}>{cls.type}</span>
</button>
{showLocation && cls.locationName && (
<span className="flex items-center gap-0.5 text-[10px] text-neutral-400 dark:text-white/20">
<MapPin size={8} className="shrink-0" />
{cls.locationName}
</span>
)}
</div>
</div>
</div>
);
} }
export function MobileSchedule({ export function MobileSchedule({
@@ -23,6 +96,7 @@ export function MobileSchedule({
setFilterTrainer, setFilterTrainer,
hasActiveFilter, hasActiveFilter,
clearFilters, clearFilters,
showLocation,
}: MobileScheduleProps) { }: MobileScheduleProps) {
return ( return (
<div className="mt-6 px-4 sm:hidden"> <div className="mt-6 px-4 sm:hidden">
@@ -55,68 +129,78 @@ export function MobileSchedule({
{filteredDays.length > 0 ? ( {filteredDays.length > 0 ? (
<div className="space-y-1"> <div className="space-y-1">
{filteredDays.map((day) => ( {filteredDays.map((day) => {
<div key={day.day}> // Group classes by location when showLocation is true
{/* Day header */} const locationGroups = showLocation
<div className="flex items-center gap-2.5 py-2.5"> ? Array.from(
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light"> day.classes.reduce((map, cls) => {
{day.dayShort} const loc = cls.locationName ?? "";
</span> if (!map.has(loc)) {
<span className="text-sm font-semibold text-neutral-900 dark:text-white/90"> map.set(loc, { address: cls.locationAddress, classes: [] });
{day.day} }
</span> map.get(loc)!.classes.push(cls);
</div> return map;
}, new Map<string, { address?: string; classes: ScheduleClassWithLocation[] }>())
)
: null;
{/* Class rows */} return (
<div className="ml-1 border-l-2 border-neutral-200 dark:border-white/[0.08]"> <div key={day.day}>
{day.classes.map((cls, i) => ( {/* Day header */}
<div <div className="flex items-center gap-2.5 py-2.5">
key={i} <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
className={`ml-3 flex items-start gap-3 rounded-lg px-3 py-2 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`} {day.dayShort}
> </span>
{/* Time */} <span className="text-sm font-semibold text-neutral-900 dark:text-white/90">
<span className="shrink-0 w-[72px] text-xs font-semibold tabular-nums text-neutral-500 dark:text-white/40 pt-0.5"> {day.day}
{cls.time} </span>
</span> </div>
{/* Info — tappable trainer & type */} {/* Class rows */}
<div className="min-w-0 flex-1"> <div className="ml-1 border-l-2 border-neutral-200 dark:border-white/[0.08]">
<div className="flex items-center gap-1.5"> {locationGroups ? (
<button // Split by location
onClick={() => setFilterTrainer(filterTrainer === cls.trainer ? null : cls.trainer)} locationGroups.map(([locName, { address, classes }]) => (
className={`truncate text-sm font-medium text-left active:opacity-60 ${filterTrainer === cls.trainer ? "text-gold underline underline-offset-2" : "text-neutral-800 dark:text-white/80"}`} <div key={locName}>
> {/* Location sub-header */}
{cls.trainer} <div className="ml-3 flex items-center gap-1 px-3 py-1.5">
</button> <MapPin size={9} className="shrink-0 text-neutral-400 dark:text-white/20" />
{cls.hasSlots && ( <span className="text-[10px] font-medium text-neutral-400 dark:text-white/25">
<span className="shrink-0 rounded-full bg-emerald-500/15 border border-emerald-500/25 px-1.5 py-px text-[9px] font-semibold text-emerald-600 dark:text-emerald-400"> {locName}
места {address && <span className="text-neutral-300 dark:text-white/15"> · {shortAddress(address)}</span>}
</span> </span>
)} </div>
{cls.recruiting && ( {classes.map((cls, i) => (
<span className="shrink-0 rounded-full bg-sky-500/15 border border-sky-500/25 px-1.5 py-px text-[9px] font-semibold text-sky-600 dark:text-sky-400"> <ClassRow
набор key={i}
</span> cls={cls}
)} typeDots={typeDots}
{cls.level && ( filterType={filterType}
<span className="shrink-0 rounded-full bg-rose-500/15 border border-rose-500/25 px-1.5 py-px text-[9px] font-semibold text-rose-600 dark:text-rose-400"> setFilterType={setFilterType}
{cls.level} filterTrainer={filterTrainer}
</span> setFilterTrainer={setFilterTrainer}
)} />
))}
</div> </div>
<button ))
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)} ) : (
className={`mt-0.5 flex items-center gap-1.5 active:opacity-60 ${filterType === cls.type ? "opacity-100" : ""}`} // Single location — no sub-headers
> day.classes.map((cls, i) => (
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} /> <ClassRow
<span className={`text-[11px] ${filterType === cls.type ? "text-gold underline underline-offset-2" : "text-neutral-400 dark:text-white/30"}`}>{cls.type}</span> key={i}
</button> cls={cls}
</div> typeDots={typeDots}
</div> filterType={filterType}
))} setFilterType={setFilterType}
filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainer}
/>
))
)}
</div>
</div> </div>
</div> );
))} })}
</div> </div>
) : ( ) : (
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30"> <div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">

View File

@@ -1,27 +1,31 @@
"use client"; "use client";
import { User, X, ChevronDown } from "lucide-react"; import { useState } from "react";
import { User, X, ChevronDown, Clock, Calendar } from "lucide-react";
import { import {
pillBase, pillBase,
pillActive, pillActive,
pillInactive, pillInactive,
TIME_PRESETS,
type StatusFilter, type StatusFilter,
type TimeFilter,
} from "./constants"; } from "./constants";
interface ScheduleFiltersProps { interface ScheduleFiltersProps {
typeDots: Record<string, string>; typeDots: Record<string, string>;
types: string[]; types: string[];
trainers: string[];
hasAnySlots: boolean; hasAnySlots: boolean;
hasAnyRecruiting: boolean; hasAnyRecruiting: boolean;
filterType: string | null; filterType: string | null;
setFilterType: (type: string | null) => void; setFilterType: (type: string | null) => void;
filterTrainer: string | null; filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void;
filterStatus: StatusFilter; filterStatus: StatusFilter;
setFilterStatus: (status: StatusFilter) => void; setFilterStatus: (status: StatusFilter) => void;
showTrainers: boolean; filterTime: TimeFilter;
setShowTrainers: (show: boolean) => void; setFilterTime: (time: TimeFilter) => void;
availableDays: { day: string; dayShort: string }[];
filterDaySet: Set<string>;
toggleDay: (day: string) => void;
hasActiveFilter: boolean; hasActiveFilter: boolean;
clearFilters: () => void; clearFilters: () => void;
} }
@@ -29,22 +33,27 @@ interface ScheduleFiltersProps {
export function ScheduleFilters({ export function ScheduleFilters({
typeDots, typeDots,
types, types,
trainers,
hasAnySlots, hasAnySlots,
hasAnyRecruiting, hasAnyRecruiting,
filterType, filterType,
setFilterType, setFilterType,
filterTrainer, filterTrainer,
setFilterTrainer,
filterStatus, filterStatus,
setFilterStatus, setFilterStatus,
showTrainers, filterTime,
setShowTrainers, setFilterTime,
availableDays,
filterDaySet,
toggleDay,
hasActiveFilter, hasActiveFilter,
clearFilters, clearFilters,
}: ScheduleFiltersProps) { }: ScheduleFiltersProps) {
const [showWhen, setShowWhen] = useState(false);
const hasTimeFilter = filterDaySet.size > 0 || filterTime !== "all";
return ( return (
<> <>
{/* Single row: type + status + when + active trainer indicator + clear */}
<div className="mt-5 hidden sm:flex items-center justify-center gap-1.5 flex-wrap"> <div className="mt-5 hidden sm:flex items-center justify-center gap-1.5 flex-wrap">
{/* Class types */} {/* Class types */}
{types.map((type) => ( {types.map((type) => (
@@ -84,16 +93,24 @@ export function ScheduleFilters({
{/* Divider */} {/* Divider */}
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" /> <span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
{/* Trainer dropdown toggle */} {/* When dropdown toggle */}
<button <button
onClick={() => setShowTrainers(!showTrainers)} onClick={() => setShowWhen(!showWhen)}
className={`${pillBase} ${filterTrainer ? pillActive : pillInactive}`} className={`${pillBase} ${hasTimeFilter ? pillActive : pillInactive}`}
> >
<User size={11} /> <Clock size={11} />
{filterTrainer ?? "Тренер"} Когда
<ChevronDown size={10} className={`transition-transform duration-200 ${showTrainers ? "rotate-180" : ""}`} /> <ChevronDown size={10} className={`transition-transform duration-200 ${showWhen ? "rotate-180" : ""}`} />
</button> </button>
{/* Active trainer indicator (set by clicking trainer in cards) */}
{filterTrainer && (
<span className={`${pillBase} ${pillActive}`}>
<User size={11} />
{filterTrainer}
</span>
)}
{/* Clear */} {/* Clear */}
{hasActiveFilter && ( {hasActiveFilter && (
<button <button
@@ -105,19 +122,29 @@ export function ScheduleFilters({
)} )}
</div> </div>
{/* Trainer pills — expandable */} {/* When panel — expandable: days + time presets */}
{showTrainers && ( {showWhen && (
<div className="mt-2 flex flex-wrap items-center justify-center gap-1.5"> <div className="mt-2 hidden sm:flex items-center justify-center gap-1.5 flex-wrap">
{trainers.map((trainer) => ( <Calendar size={11} className="text-neutral-400 dark:text-white/25" />
{availableDays.map(({ day, dayShort }) => (
<button <button
key={trainer} key={day}
onClick={() => { onClick={() => toggleDay(day)}
setFilterTrainer(filterTrainer === trainer ? null : trainer); className={`${pillBase} ${filterDaySet.has(day) ? pillActive : pillInactive}`}
setShowTrainers(false);
}}
className={`${pillBase} ${filterTrainer === trainer ? pillActive : pillInactive}`}
> >
{trainer} {dayShort}
</button>
))}
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
{TIME_PRESETS.map((preset) => (
<button
key={preset.value}
onClick={() => setFilterTime(filterTime === preset.value ? "all" : preset.value)}
className={`${pillBase} ${filterTime === preset.value ? pillActive : pillInactive}`}
>
{preset.label}
</button> </button>
))} ))}
</div> </div>

View File

@@ -63,6 +63,44 @@ export function buildTypeDots(
} }
export type StatusFilter = "all" | "hasSlots" | "recruiting"; export type StatusFilter = "all" | "hasSlots" | "recruiting";
export type TimeFilter = "all" | "morning" | "afternoon" | "evening";
export const TIME_PRESETS: { value: TimeFilter; label: string; range: [number, number] }[] = [
{ value: "morning", label: "Утро", range: [0, 12 * 60] },
{ value: "afternoon", label: "День", range: [12 * 60, 18 * 60] },
{ value: "evening", label: "Вечер", range: [18 * 60, 24 * 60] },
];
/** Parse start time from "HH:MMHH:MM" to minutes since midnight */
export function startTimeMinutes(time: string): number {
const start = time.split("")[0]?.trim() ?? "";
const [h, m] = start.split(":").map(Number);
if (isNaN(h) || isNaN(m)) return 0;
return h * 60 + m;
}
/** Extended class type with location info for cross-location views */
export interface ScheduleClassWithLocation {
time: string;
trainer: string;
type: string;
level?: string;
hasSlots?: boolean;
recruiting?: boolean;
locationName?: string;
locationAddress?: string;
}
/** Strip "г. Минск, " prefix from address for compact display */
export function shortAddress(address: string): string {
return address.replace(/^г\.\s*Минск,?\s*/i, "").trim();
}
export interface ScheduleDayMerged {
day: string;
dayShort: string;
classes: ScheduleClassWithLocation[];
}
export const pillBase = export 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 whitespace-nowrap"; "inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-medium transition-all duration-200 cursor-pointer whitespace-nowrap";