"use client"; import { useReducer, useMemo, useCallback } from "react"; import { useTrainerPhotos } from "@/hooks/useTrainerPhotos"; import { SignupModal } from "@/components/ui/SignupModal"; import { CalendarDays, Users, LayoutGrid, SlidersHorizontal, MapPin } 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; filterTypes: Set; filterStatusSet: Set; filterLevel: string | null; filterTime: TimeFilter; filterDaySet: Set; 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) { if (!schedule?.locations?.length) return null; const [state, dispatch] = useReducer(scheduleReducer, { ...initialState, locationMode: schedule.locations.length === 1 ? 0 : "all", }); 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 = useTrainerPhotos(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(); 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(); const levelSet = new Set(); const trainerSet = new Set(); const statusSet = new Set(); for (const day of activeDays) { for (const cls of day.classes) { typeSet.add(cls.type); trainerSet.add(cls.trainer); const clsStatus = cls.status || (cls.recruiting ? "recruiting" : cls.hasSlots ? "hasSlots" : ""); if (clsStatus) statusSet.add(clsStatus); if (cls.level) levelSet.add(cls.level); } } // Also include all configured statuses/levels so they appear in filters if (scheduleConfig?.statuses) { for (const s of scheduleConfig.statuses) if (s.key) statusSet.add(s.key); } if (scheduleConfig?.levels) { for (const l of scheduleConfig.levels) if (l.value) levelSet.add(l.value); } // Order statuses by config order, then any extras from data const configStatusOrder = (scheduleConfig?.statuses ?? []).map((s) => s.key).filter(Boolean); const orderedStatuses = [ ...configStatusOrder.filter((k) => statusSet.has(k)), ...Array.from(statusSet).filter((k) => !configStatusOrder.includes(k)), ]; // Order levels by config order const configLevelOrder = (scheduleConfig?.levels ?? []).map((l) => l.value).filter(Boolean); const orderedLevels = [ ...configLevelOrder.filter((v) => levelSet.has(v)), ...Array.from(levelSet).filter((v) => !configLevelOrder.includes(v)), ]; return { types: Array.from(typeSet).sort(), availableStatuses: orderedStatuses, levels: orderedLevels, trainerNames: Array.from(trainerSet).sort(), }; }, [activeDays, scheduleConfig]); // 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) => { const clsStatus = cls.status || (cls.recruiting ? "recruiting" : cls.hasSlots ? "hasSlots" : ""); const matchesTime = !activeTimeRange || ( startTimeMinutes(cls.time) >= activeTimeRange[0] && startTimeMinutes(cls.time) < activeTimeRange[1] ); return (filterTrainerSet.size === 0 || filterTrainerSet.has(cls.trainer)) && (filterTypes.size === 0 || filterTypes.has(cls.type)) && (filterStatusSet.size === 0 || (clsStatus && filterStatusSet.has(clsStatus))) && (!filterLevel || cls.level === filterLevel) && matchesTime; }), })) .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 (
{schedule.title} {/* Location tabs + mobile filter */}
{/* "All studios" tab — only when multiple locations */} {schedule.locations.length > 1 && ( )} {/* Per-location tabs */} {schedule.locations.map((loc, i) => ( ))} {/* Mobile filter — inline with hall tabs */}
{/* View mode toggle + filter button */}
{/* Divider */}
{viewMode === "days" ? ( <> {/* Mobile: compact agenda list with tap-to-filter */} {/* Desktop: grid layout */} ) : ( /* Group view: classes clustered by trainer+type */ dispatch({ type: "SET_BOOKING", value: v })} trainerPhotos={trainerPhotos} scheduleConfig={scheduleConfig} /> )} dispatch({ type: "SET_BOOKING", value: null })} subtitle={bookingGroup ?? undefined} endpoint="/api/group-booking" extraBody={{ groupInfo: bookingGroup }} />
); }