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,56 +1,113 @@
"use client";
import { useState, useMemo } from "react";
import { MapPin } from "lucide-react";
import { CalendarDays, Users, LayoutGrid } from "lucide-react";
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 { buildTypeDots } from "./schedule/constants";
import type { StatusFilter } from "./schedule/constants";
import { GroupView } from "./schedule/GroupView";
import { buildTypeDots, shortAddress, startTimeMinutes, TIME_PRESETS } from "./schedule/constants";
import type { StatusFilter, TimeFilter, ScheduleDayMerged, ScheduleClassWithLocation } from "./schedule/constants";
import type { SiteContent } from "@/types/content";
type ViewMode = "days" | "groups";
type LocationMode = "all" | number;
interface ScheduleProps {
data: SiteContent["schedule"];
classItems?: { name: string; color?: string }[];
}
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 [filterType, setFilterType] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<StatusFilter>("all");
const [showTrainers, setShowTrainers] = useState(false);
const location = schedule.locations[locationIndex];
const [filterTime, setFilterTime] = useState<TimeFilter>("all");
const [filterDaySet, setFilterDaySet] = useState<Set<string>>(new Set());
const isAllMode = locationMode === "all";
const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]);
const { trainers, types, hasAnySlots, hasAnyRecruiting } = useMemo(() => {
const trainerSet = new Set<string>();
// Build days: either from one location or merged from all
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>();
let slots = false;
let recruiting = false;
for (const day of location.days) {
for (const day of activeDays) {
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]);
}, [activeDays]);
const filteredDays = useMemo(() => {
const noFilter = !filterTrainer && !filterType && filterStatus === "all";
if (noFilter) return location.days;
return location.days
// Get the time range for the active time filter
const activeTimeRange = filterTime !== "all"
? TIME_PRESETS.find((p) => p.value === filterTime)?.range
: 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) => ({
...day,
classes: day.classes.filter(
@@ -59,20 +116,49 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
(!filterType || cls.type === filterType) &&
(filterStatus === "all" ||
(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);
}, [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() {
setFilterTrainer(null);
setFilterType(null);
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 (
<section
id="schedule"
@@ -87,87 +173,151 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
{/* Location tabs */}
<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) => (
<button
key={loc.name}
onClick={() => {
setLocationIndex(i);
clearFilters();
setShowTrainers(false);
}}
onClick={() => switchLocation(i)}
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"
locationMode === i ? activeTabClass : inactiveTabClass
}`}
>
<MapPin size={14} />
{loc.name}
<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 ${
locationMode === i ? "text-black/60" : "text-neutral-400 dark:text-white/25"
}`}>
{shortAddress(loc.address)}
</span>
)}
</span>
</button>
))}
</div>
</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 */}
<Reveal>
<ScheduleFilters
typeDots={typeDots}
types={types}
trainers={trainers}
hasAnySlots={hasAnySlots}
hasAnyRecruiting={hasAnyRecruiting}
filterType={filterType}
setFilterType={setFilterType}
filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainer}
filterStatus={filterStatus}
setFilterStatus={setFilterStatus}
showTrainers={showTrainers}
setShowTrainers={setShowTrainers}
filterTime={filterTime}
setFilterTime={setFilterTime}
availableDays={availableDays}
filterDaySet={filterDaySet}
toggleDay={toggleDay}
hasActiveFilter={hasActiveFilter}
clearFilters={clearFilters}
/>
</Reveal>
</div>
{/* Mobile: compact agenda list with tap-to-filter */}
<Reveal>
<MobileSchedule
typeDots={typeDots}
filteredDays={filteredDays}
filterType={filterType}
setFilterType={setFilterType}
filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainer}
hasActiveFilter={hasActiveFilter}
clearFilters={clearFilters}
/>
</Reveal>
{viewMode === "days" ? (
<>
{/* Mobile: compact agenda list with tap-to-filter */}
<Reveal>
<MobileSchedule
typeDots={typeDots}
filteredDays={filteredDays}
filterType={filterType}
setFilterType={setFilterType}
filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainer}
hasActiveFilter={hasActiveFilter}
clearFilters={clearFilters}
showLocation={isAllMode}
/>
</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) => (
{/* Desktop: grid layout */}
<Reveal>
<div
key={day.day}
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""}
key={`${locationMode}-${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}
>
<DayCard day={day} typeDots={typeDots} />
</div>
))}
{filteredDays.map((day) => (
<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 && (
<div className="col-span-full py-12 text-center text-sm text-neutral-400 dark:text-white/30">
Нет занятий по выбранным фильтрам
{filteredDays.length === 0 && (
<div className="col-span-full py-12 text-center text-sm text-neutral-400 dark:text-white/30">
Нет занятий по выбранным фильтрам
</div>
)}
</div>
)}
</div>
</Reveal>
</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>
);
}