a69c08482f
Schedule filters: - Airbnb-style filter modal with sections: directions, trainer, status, level, days, time - Multi-select trainer filter with search input - Custom time range (from-to) with preset shortcuts - Gold tag design for class types, statuses, and levels - Hover tooltips on level/status options with descriptions from config - Filter icon button inline with view toggle (По дням / По группам) Admin schedule: - Configurable experience levels and statuses (add/edit/reorder/delete) - New scheduleConfig DB section with auto-save - Status/level dropdowns in class editor read from config - Status select built dynamically from config - New status field on ScheduleClass for custom statuses Other: - Local fonts (Inter + Oswald) bundled in public/fonts — no Google Fonts dependency - SelectField combobox: search in main input field, no separate search inside dropdown - Fix carousel trainer label flash on drag release
459 lines
18 KiB
TypeScript
459 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useReducer, useMemo, useCallback } from "react";
|
|
import { SignupModal } from "@/components/ui/SignupModal";
|
|
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 { GroupView } from "./schedule/GroupView";
|
|
import { buildTypeDots, shortAddress, startTimeMinutes, TIME_FILTER_EMPTY, isTimeFilterActive } from "./schedule/constants";
|
|
import type { StatusTag, TimeFilter, ScheduleDayMerged, ScheduleClassWithLocation } from "./schedule/constants";
|
|
import type { SiteContent } from "@/types/content";
|
|
|
|
type ViewMode = "days" | "groups";
|
|
type LocationMode = "all" | number;
|
|
|
|
interface ScheduleState {
|
|
locationMode: LocationMode;
|
|
viewMode: ViewMode;
|
|
filterTrainerSet: Set<string>;
|
|
filterTypes: Set<string>;
|
|
filterStatusSet: Set<StatusTag>;
|
|
filterLevel: string | null;
|
|
filterTime: TimeFilter;
|
|
filterDaySet: Set<string>;
|
|
bookingGroup: string | null;
|
|
}
|
|
|
|
type ScheduleAction =
|
|
| { type: "SET_LOCATION"; mode: LocationMode }
|
|
| { type: "SET_VIEW"; mode: ViewMode }
|
|
| { type: "TOGGLE_TRAINER"; value: string }
|
|
| { type: "TOGGLE_TYPE"; value: string }
|
|
| { type: "TOGGLE_STATUS"; value: StatusTag }
|
|
| { type: "SET_LEVEL"; value: string | null }
|
|
| { type: "SET_TIME"; value: TimeFilter }
|
|
| { type: "TOGGLE_DAY"; day: string }
|
|
| { type: "SET_BOOKING"; value: string | null }
|
|
| { type: "CLEAR_FILTERS" };
|
|
|
|
const initialState: ScheduleState = {
|
|
locationMode: "all",
|
|
viewMode: "groups",
|
|
filterTrainerSet: new Set(),
|
|
filterTypes: new Set(),
|
|
filterStatusSet: new Set(),
|
|
filterLevel: null,
|
|
filterTime: TIME_FILTER_EMPTY,
|
|
filterDaySet: new Set(),
|
|
bookingGroup: null,
|
|
};
|
|
|
|
function scheduleReducer(state: ScheduleState, action: ScheduleAction): ScheduleState {
|
|
switch (action.type) {
|
|
case "SET_LOCATION":
|
|
return { ...initialState, viewMode: state.viewMode, locationMode: action.mode };
|
|
case "SET_VIEW":
|
|
return { ...state, viewMode: action.mode };
|
|
case "TOGGLE_TRAINER": {
|
|
const next = new Set(state.filterTrainerSet);
|
|
if (next.has(action.value)) next.delete(action.value);
|
|
else next.add(action.value);
|
|
return { ...state, filterTrainerSet: next };
|
|
}
|
|
case "TOGGLE_TYPE": {
|
|
const next = new Set(state.filterTypes);
|
|
if (next.has(action.value)) next.delete(action.value);
|
|
else next.add(action.value);
|
|
return { ...state, filterTypes: next };
|
|
}
|
|
case "TOGGLE_STATUS": {
|
|
const next = new Set(state.filterStatusSet);
|
|
if (next.has(action.value)) next.delete(action.value);
|
|
else next.add(action.value);
|
|
return { ...state, filterStatusSet: next };
|
|
}
|
|
case "SET_LEVEL":
|
|
return { ...state, filterLevel: action.value };
|
|
case "SET_TIME":
|
|
return { ...state, filterTime: action.value };
|
|
case "TOGGLE_DAY": {
|
|
const next = new Set(state.filterDaySet);
|
|
if (next.has(action.day)) next.delete(action.day);
|
|
else next.add(action.day);
|
|
return { ...state, filterDaySet: next };
|
|
}
|
|
case "SET_BOOKING":
|
|
return { ...state, bookingGroup: action.value };
|
|
case "CLEAR_FILTERS":
|
|
return { ...state, filterTrainerSet: new Set(), filterTypes: new Set(), filterStatusSet: new Set(), filterLevel: null, filterTime: TIME_FILTER_EMPTY, filterDaySet: new Set() };
|
|
}
|
|
}
|
|
|
|
interface ScheduleProps {
|
|
data: SiteContent["schedule"];
|
|
scheduleConfig?: SiteContent["scheduleConfig"];
|
|
classItems?: { name: string; color?: string }[];
|
|
teamMembers?: { name: string; image: string }[];
|
|
}
|
|
|
|
export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembers }: ScheduleProps) {
|
|
const [state, dispatch] = useReducer(scheduleReducer, initialState);
|
|
const { locationMode, viewMode, filterTrainerSet, filterTypes, filterStatusSet, filterLevel, filterTime, filterDaySet, bookingGroup } = state;
|
|
|
|
const isAllMode = locationMode === "all";
|
|
|
|
const scrollToSchedule = useCallback(() => {
|
|
const el = document.getElementById("schedule");
|
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}, []);
|
|
|
|
const toggleFilterTrainer = useCallback((value: string) => dispatch({ type: "TOGGLE_TRAINER", value }), []);
|
|
const toggleFilterType = useCallback((value: string) => dispatch({ type: "TOGGLE_TYPE", value }), []);
|
|
const toggleFilterStatus = useCallback((value: StatusTag) => dispatch({ type: "TOGGLE_STATUS", value }), []);
|
|
const setFilterLevel = useCallback((value: string | null) => dispatch({ type: "SET_LEVEL", value }), []);
|
|
const setFilterTime = useCallback((value: TimeFilter) => dispatch({ type: "SET_TIME", value }), []);
|
|
|
|
const toggleFilterTrainerFromCard = useCallback((trainer: string | null) => {
|
|
if (trainer) {
|
|
dispatch({ type: "TOGGLE_TRAINER", value: trainer });
|
|
scrollToSchedule();
|
|
}
|
|
}, [scrollToSchedule]);
|
|
|
|
const toggleFilterTypeFromCard = useCallback((type: string) => {
|
|
dispatch({ type: "TOGGLE_TYPE", value: type });
|
|
scrollToSchedule();
|
|
}, [scrollToSchedule]);
|
|
|
|
const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]);
|
|
|
|
const trainerPhotos = useMemo(() => {
|
|
const map: Record<string, string> = {};
|
|
if (teamMembers) {
|
|
for (const m of teamMembers) {
|
|
if (m.image) map[m.name] = m.image;
|
|
}
|
|
}
|
|
return map;
|
|
}, [teamMembers]);
|
|
|
|
// 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, availableStatuses, levels, trainerNames } = useMemo(() => {
|
|
const typeSet = new Set<string>();
|
|
const levelSet = new Set<string>();
|
|
const trainerSet = new Set<string>();
|
|
const statusSet = new Set<string>();
|
|
for (const day of activeDays) {
|
|
for (const cls of day.classes) {
|
|
typeSet.add(cls.type);
|
|
trainerSet.add(cls.trainer);
|
|
if (cls.status) statusSet.add(cls.status);
|
|
if (cls.hasSlots) statusSet.add("hasSlots");
|
|
if (cls.recruiting) statusSet.add("recruiting");
|
|
if (cls.level) levelSet.add(cls.level);
|
|
}
|
|
}
|
|
return {
|
|
types: Array.from(typeSet).sort(),
|
|
availableStatuses: Array.from(statusSet),
|
|
levels: Array.from(levelSet).sort(),
|
|
trainerNames: Array.from(trainerSet).sort(),
|
|
};
|
|
}, [activeDays]);
|
|
|
|
// Parse time range for filtering
|
|
const activeTimeRange = isTimeFilterActive(filterTime)
|
|
? [
|
|
filterTime.from ? startTimeMinutes(filterTime.from) : 0,
|
|
filterTime.to ? startTimeMinutes(filterTime.to) : 24 * 60,
|
|
] as const
|
|
: null;
|
|
|
|
const filteredDays: ScheduleDayMerged[] = useMemo(() => {
|
|
const noFilter = filterTrainerSet.size === 0 && filterTypes.size === 0 && filterStatusSet.size === 0 && !filterLevel && !isTimeFilterActive(filterTime) && 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(
|
|
(cls) =>
|
|
(filterTrainerSet.size === 0 || filterTrainerSet.has(cls.trainer)) &&
|
|
(filterTypes.size === 0 || filterTypes.has(cls.type)) &&
|
|
(filterStatusSet.size === 0 ||
|
|
(cls.status && filterStatusSet.has(cls.status as StatusTag)) ||
|
|
(filterStatusSet.has("hasSlots" as StatusTag) && cls.hasSlots) ||
|
|
(filterStatusSet.has("recruiting" as StatusTag) && cls.recruiting)) &&
|
|
(!filterLevel || cls.level === filterLevel) &&
|
|
(!activeTimeRange || (() => {
|
|
const m = startTimeMinutes(cls.time);
|
|
return m >= activeTimeRange[0] && m < activeTimeRange[1];
|
|
})())
|
|
),
|
|
}))
|
|
.filter((day) => day.classes.length > 0);
|
|
}, [activeDays, filterTrainerSet, filterTypes, filterStatusSet, filterLevel, filterTime, activeTimeRange, filterDaySet]);
|
|
|
|
const hasActiveFilter = !!(filterTrainerSet.size > 0 || filterTypes.size > 0 || filterStatusSet.size > 0 || filterLevel || isTimeFilterActive(filterTime) || filterDaySet.size > 0);
|
|
|
|
function clearFilters() {
|
|
dispatch({ type: "CLEAR_FILTERS" });
|
|
}
|
|
|
|
// Available days for the day filter
|
|
const availableDays = useMemo(() =>
|
|
activeDays.map((d) => ({ day: d.day, dayShort: d.dayShort })),
|
|
[activeDays]
|
|
);
|
|
|
|
function toggleDay(day: string) {
|
|
dispatch({ type: "TOGGLE_DAY", day });
|
|
}
|
|
|
|
function switchLocation(mode: LocationMode) {
|
|
dispatch({ type: "SET_LOCATION", mode });
|
|
}
|
|
|
|
const gridLayout = useMemo(() => {
|
|
const len = filteredDays.length;
|
|
const cls = len >= 7 ? "sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7"
|
|
: len >= 6 ? "sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"
|
|
: len >= 4 ? "sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5"
|
|
: len === 3 ? "sm:grid-cols-2 lg:grid-cols-3"
|
|
: len === 2 ? "sm:grid-cols-2"
|
|
: "justify-items-center";
|
|
const style = len === 1 ? undefined
|
|
: len <= 3 && len > 0 ? { maxWidth: len * 340 + (len - 1) * 12, marginInline: "auto" as const }
|
|
: undefined;
|
|
return { cls, style };
|
|
}, [filteredDays.length]);
|
|
|
|
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"
|
|
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 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={() => 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 ${
|
|
locationMode === i ? activeTabClass : inactiveTabClass
|
|
}`}
|
|
>
|
|
<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 + filter button */}
|
|
<Reveal>
|
|
<div className="mt-4 hidden sm:flex items-center justify-center">
|
|
<div className="inline-flex items-center rounded-xl border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.08] dark:bg-white/[0.04]">
|
|
<button
|
|
onClick={() => dispatch({ type: "SET_VIEW", mode: "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={() => dispatch({ type: "SET_VIEW", mode: "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>
|
|
|
|
{/* Divider */}
|
|
<span className="mx-1 h-5 w-px bg-white/[0.08]" />
|
|
|
|
<ScheduleFilters
|
|
typeDots={typeDots}
|
|
types={types}
|
|
availableStatuses={availableStatuses}
|
|
levels={levels}
|
|
filterTypes={filterTypes}
|
|
toggleFilterType={toggleFilterType}
|
|
filterTrainerSet={filterTrainerSet}
|
|
toggleFilterTrainer={toggleFilterTrainer}
|
|
filterStatusSet={filterStatusSet}
|
|
toggleFilterStatus={toggleFilterStatus}
|
|
filterLevel={filterLevel}
|
|
setFilterLevel={setFilterLevel}
|
|
filterTime={filterTime}
|
|
setFilterTime={setFilterTime}
|
|
availableDays={availableDays}
|
|
filterDaySet={filterDaySet}
|
|
toggleDay={toggleDay}
|
|
hasActiveFilter={hasActiveFilter}
|
|
clearFilters={clearFilters}
|
|
trainerNames={trainerNames}
|
|
scheduleConfig={scheduleConfig}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Reveal>
|
|
</div>
|
|
|
|
{viewMode === "days" ? (
|
|
<>
|
|
{/* Mobile: compact agenda list with tap-to-filter */}
|
|
<Reveal>
|
|
<MobileSchedule
|
|
typeDots={typeDots}
|
|
filteredDays={filteredDays}
|
|
filterTypes={filterTypes}
|
|
toggleFilterType={toggleFilterTypeFromCard}
|
|
filterTrainerSet={filterTrainerSet}
|
|
toggleFilterTrainer={toggleFilterTrainerFromCard}
|
|
hasActiveFilter={hasActiveFilter}
|
|
clearFilters={clearFilters}
|
|
showLocation={isAllMode}
|
|
/>
|
|
</Reveal>
|
|
|
|
{/* Desktop: grid layout */}
|
|
<Reveal>
|
|
<div
|
|
className={`mt-8 hidden sm:grid grid-cols-1 gap-3 px-4 sm:px-6 lg:px-8 xl:px-6 ${gridLayout.cls}`}
|
|
style={gridLayout.style}
|
|
>
|
|
{filteredDays.map((day) => (
|
|
<div
|
|
key={day.day}
|
|
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""}
|
|
>
|
|
<DayCard day={day} typeDots={typeDots} showLocation={isAllMode} filterTrainerSet={filterTrainerSet} toggleFilterTrainer={toggleFilterTrainerFromCard} filterTypes={filterTypes} toggleFilterType={toggleFilterTypeFromCard} />
|
|
</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>
|
|
</>
|
|
) : (
|
|
/* Group view: classes clustered by trainer+type */
|
|
<Reveal>
|
|
<GroupView
|
|
typeDots={typeDots}
|
|
filteredDays={filteredDays}
|
|
filterTypes={filterTypes}
|
|
toggleFilterType={toggleFilterTypeFromCard}
|
|
filterTrainerSet={filterTrainerSet}
|
|
toggleFilterTrainer={toggleFilterTrainerFromCard}
|
|
showLocation={isAllMode}
|
|
onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })}
|
|
trainerPhotos={trainerPhotos}
|
|
/>
|
|
</Reveal>
|
|
)}
|
|
<SignupModal
|
|
open={bookingGroup !== null}
|
|
onClose={() => dispatch({ type: "SET_BOOKING", value: null })}
|
|
subtitle={bookingGroup ?? undefined}
|
|
endpoint="/api/group-booking"
|
|
extraBody={{ groupInfo: bookingGroup }}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|