- BookingModal now accepts optional groupInfo for pre-filled message - Trainer profile: each group card has Записаться button - Schedule group view: each group card has Записаться button - Message includes class type, trainer, days, and time Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
346 lines
13 KiB
TypeScript
346 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useMemo, useCallback } from "react";
|
||
import { BookingModal } from "@/components/ui/BookingModal";
|
||
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_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 [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 [filterTime, setFilterTime] = useState<TimeFilter>("all");
|
||
const [filterDaySet, setFilterDaySet] = useState<Set<string>>(new Set());
|
||
const [bookingGroup, setBookingGroup] = useState<string | null>(null);
|
||
|
||
const isAllMode = locationMode === "all";
|
||
|
||
const scrollToSchedule = useCallback(() => {
|
||
const el = document.getElementById("schedule");
|
||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
}, []);
|
||
|
||
const setFilterTrainerFromCard = useCallback((trainer: string | null) => {
|
||
setFilterTrainer(trainer);
|
||
if (trainer) scrollToSchedule();
|
||
}, [scrollToSchedule]);
|
||
|
||
const setFilterTypeFromCard = useCallback((type: string | null) => {
|
||
setFilterType(type);
|
||
if (type) scrollToSchedule();
|
||
}, [scrollToSchedule]);
|
||
|
||
const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]);
|
||
|
||
// 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 activeDays) {
|
||
for (const cls of day.classes) {
|
||
typeSet.add(cls.type);
|
||
if (cls.hasSlots) slots = true;
|
||
if (cls.recruiting) recruiting = true;
|
||
}
|
||
}
|
||
return {
|
||
types: Array.from(typeSet).sort(),
|
||
hasAnySlots: slots,
|
||
hasAnyRecruiting: recruiting,
|
||
};
|
||
}, [activeDays]);
|
||
|
||
// 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(
|
||
(cls) =>
|
||
(!filterTrainer || cls.trainer === filterTrainer) &&
|
||
(!filterType || cls.type === filterType) &&
|
||
(filterStatus === "all" ||
|
||
(filterStatus === "hasSlots" && cls.hasSlots) ||
|
||
(filterStatus === "recruiting" && cls.recruiting)) &&
|
||
(!activeTimeRange || (() => {
|
||
const m = startTimeMinutes(cls.time);
|
||
return m >= activeTimeRange[0] && m < activeTimeRange[1];
|
||
})())
|
||
),
|
||
}))
|
||
.filter((day) => day.classes.length > 0);
|
||
}, [activeDays, filterTrainer, filterType, filterStatus, filterTime, activeTimeRange, filterDaySet]);
|
||
|
||
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"
|
||
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 */}
|
||
<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}
|
||
hasAnySlots={hasAnySlots}
|
||
hasAnyRecruiting={hasAnyRecruiting}
|
||
filterType={filterType}
|
||
setFilterType={setFilterType}
|
||
filterTrainer={filterTrainer}
|
||
filterStatus={filterStatus}
|
||
setFilterStatus={setFilterStatus}
|
||
filterTime={filterTime}
|
||
setFilterTime={setFilterTime}
|
||
availableDays={availableDays}
|
||
filterDaySet={filterDaySet}
|
||
toggleDay={toggleDay}
|
||
hasActiveFilter={hasActiveFilter}
|
||
clearFilters={clearFilters}
|
||
/>
|
||
</Reveal>
|
||
</div>
|
||
|
||
{viewMode === "days" ? (
|
||
<>
|
||
{/* Mobile: compact agenda list with tap-to-filter */}
|
||
<Reveal>
|
||
<MobileSchedule
|
||
typeDots={typeDots}
|
||
filteredDays={filteredDays}
|
||
filterType={filterType}
|
||
setFilterType={setFilterTypeFromCard}
|
||
filterTrainer={filterTrainer}
|
||
setFilterTrainer={setFilterTrainerFromCard}
|
||
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 ${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
|
||
key={day.day}
|
||
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""}
|
||
>
|
||
<DayCard day={day} typeDots={typeDots} showLocation={isAllMode} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainerFromCard} filterType={filterType} setFilterType={setFilterTypeFromCard} />
|
||
</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}
|
||
filterType={filterType}
|
||
setFilterType={setFilterTypeFromCard}
|
||
filterTrainer={filterTrainer}
|
||
setFilterTrainer={setFilterTrainerFromCard}
|
||
showLocation={isAllMode}
|
||
onBook={setBookingGroup}
|
||
/>
|
||
</Reveal>
|
||
)}
|
||
<BookingModal
|
||
open={bookingGroup !== null}
|
||
onClose={() => setBookingGroup(null)}
|
||
groupInfo={bookingGroup ?? undefined}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|