feat: schedule filters overhaul, local fonts, configurable statuses/levels

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
This commit is contained in:
2026-03-27 19:13:43 +03:00
parent d5541a8bc9
commit a69c08482f
17 changed files with 755 additions and 266 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+43 -28
View File
@@ -159,6 +159,7 @@ interface SelectFieldProps {
onChange: (value: string) => void; onChange: (value: string) => void;
options: { value: string; label: string }[]; options: { value: string; label: string }[];
placeholder?: string; placeholder?: string;
hint?: string;
} }
export function SelectField({ export function SelectField({
@@ -167,6 +168,7 @@ export function SelectField({
onChange, onChange,
options, options,
placeholder, placeholder,
hint,
}: SelectFieldProps) { }: SelectFieldProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -181,6 +183,8 @@ export function SelectField({
}) })
: options; : options;
const showSearch = options.length > 3;
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
function handle(e: MouseEvent) { function handle(e: MouseEvent) {
@@ -195,43 +199,54 @@ export function SelectField({
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
{label && <label className="block text-sm text-neutral-400 mb-1.5">{label}</label>} {label && (
<button <label className="flex items-center gap-1.5 text-sm text-neutral-400 mb-1.5">
type="button" {label}
onClick={() => { {hint && (
setOpen(!open); <span className="group relative">
setSearch(""); <span className="flex h-4 w-4 items-center justify-center rounded-full border border-white/15 text-[10px] text-neutral-500 hover:text-white hover:border-white/30 transition-colors cursor-help">?</span>
setTimeout(() => inputRef.current?.focus(), 0); <span className="absolute left-6 top-1/2 -translate-y-1/2 z-50 w-52 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-[11px] leading-relaxed text-neutral-300 shadow-xl opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity">
}} {hint}
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${ </span>
label ? "px-4 py-2.5" : "px-2 py-1 text-xs" </span>
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`} )}
> </label>
{selectedLabel || placeholder || "Выберите..."} )}
</button> {showSearch ? (
<input
ref={inputRef}
type="text"
value={open ? search : selectedLabel}
onChange={(e) => { setSearch(e.target.value); if (!open) setOpen(true); }}
onFocus={() => { setOpen(true); setSearch(""); }}
placeholder={placeholder || "Выберите..."}
className={`w-full rounded-lg border bg-neutral-800 outline-none transition-colors ${
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
} ${open ? "border-gold" : "border-white/10"} ${!open && value ? "text-white" : "text-white"} placeholder-neutral-500`}
/>
) : (
<button
type="button"
onClick={() => setOpen(!open)}
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`}
>
{selectedLabel || placeholder || "Выберите..."}
</button>
)}
{open && ( {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="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
{options.length > 3 && (
<div className="p-1.5">
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск..."
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="max-h-48 overflow-y-auto"> <div className="max-h-48 overflow-y-auto">
{filtered.length === 0 && ( {filtered.length === 0 && (
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div> <div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
)} )}
{filtered.map((opt) => ( {filtered.map((opt, idx) => (
<button <button
key={opt.value} key={opt.value || `opt-${idx}`}
type="button" type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => { onClick={() => {
onChange(opt.value); onChange(opt.value);
setOpen(false); setOpen(false);
+160 -17
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor"; import { SectionEditor } from "../_components/SectionEditor";
import { InputField, SelectField, TimeRangeField } from "../_components/FormField"; import { InputField, SelectField, TimeRangeField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { Plus, X, Trash2 } from "lucide-react"; import { Plus, X, Trash2 } from "lucide-react";
import { adminFetch } from "@/lib/csrf"; import { adminFetch } from "@/lib/csrf";
import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content"; import type { ScheduleLocation, ScheduleDay, ScheduleClass } from "@/types/content";
@@ -26,18 +27,36 @@ const DAY_ORDER: Record<string, number> = Object.fromEntries(
DAYS.map((d, i) => [d.day, i]) DAYS.map((d, i) => [d.day, i])
); );
const LEVELS = [ interface ScheduleConfig {
{ value: "", label: "Без уровня" }, levels: { value: string; description: string }[];
{ value: "Начинающий/Без опыта", label: "Начинающий/Без опыта" }, statuses: { key: string; label: string; description: string }[];
{ value: "Продвинутый", label: "Продвинутый" }, }
];
const STATUS_OPTIONS = [ const DEFAULT_CONFIG: ScheduleConfig = {
{ value: "", label: "Без статуса" }, levels: [
{ value: "hasSlots", label: "Есть места" }, { value: "Начинающий/Без опыта", description: "Для тех, кто только начинает" },
{ value: "recruiting", label: "Набор открыт" }, { value: "Продвинутый", description: "Для учеников с опытом от 6 месяцев" },
{ value: "both", label: "Есть места + Набор" }, ],
]; statuses: [
{ key: "hasSlots", label: "Есть места", description: "В группе есть свободные места" },
{ key: "recruiting", label: "Набор открыт", description: "Идёт набор в новую группу" },
],
};
function buildLevelOptions(config: ScheduleConfig) {
return [
{ value: "", label: "Без уровня" },
...config.levels.map((l) => ({ value: l.value, label: l.value })),
];
}
function buildStatusOptions(config: ScheduleConfig) {
const statuses = config.statuses ?? DEFAULT_CONFIG.statuses;
return [
{ value: "", label: "Без статуса" },
...statuses.map((s) => ({ value: s.key, label: s.label })),
];
}
const GROUP_PALETTE = [ const GROUP_PALETTE = [
"bg-rose-500/80 border-rose-400", "bg-rose-500/80 border-rose-400",
@@ -238,6 +257,10 @@ function ClassModal({
onClose, onClose,
allDays, allDays,
currentDay, currentDay,
levelOptions,
levelHint,
statusOptions,
statusHint,
}: { }: {
cls: ScheduleClass; cls: ScheduleClass;
trainers: string[]; trainers: string[];
@@ -247,6 +270,10 @@ function ClassModal({
onClose: () => void; onClose: () => void;
allDays: ScheduleDay[]; allDays: ScheduleDay[];
currentDay: string; currentDay: string;
levelOptions: { value: string; label: string }[];
levelHint: string;
statusOptions: { value: string; label: string }[];
statusHint: string;
}) { }) {
const [draft, setDraft] = useState<ScheduleClass>(cls); const [draft, setDraft] = useState<ScheduleClass>(cls);
const trainerOptions = trainers.map((t) => ({ value: t, label: t })); const trainerOptions = trainers.map((t) => ({ value: t, label: t }));
@@ -479,20 +506,23 @@ function ClassModal({
placeholder="Выберите тип" placeholder="Выберите тип"
/> />
<SelectField <SelectField
label="Уровень" label="Опыт"
value={draft.level || ""} value={draft.level || ""}
onChange={(v) => setDraft({ ...draft, level: v || undefined })} onChange={(v) => setDraft({ ...draft, level: v || undefined })}
options={LEVELS} options={levelOptions}
hint={levelHint}
/> />
<SelectField <SelectField
label="Статус" label="Статус"
value={draft.hasSlots && draft.recruiting ? "both" : draft.recruiting ? "recruiting" : draft.hasSlots ? "hasSlots" : ""} value={draft.status || (draft.recruiting ? "recruiting" : draft.hasSlots ? "hasSlots" : "")}
onChange={(v) => setDraft({ onChange={(v) => setDraft({
...draft, ...draft,
hasSlots: v === "hasSlots" || v === "both", status: v || undefined,
recruiting: v === "recruiting" || v === "both", hasSlots: v === "hasSlots",
recruiting: v === "recruiting",
})} })}
options={STATUS_OPTIONS} options={statusOptions}
hint={statusHint}
/> />
</div> </div>
@@ -559,12 +589,20 @@ function CalendarGrid({
addresses, addresses,
classTypes, classTypes,
onChange, onChange,
levelOptions,
levelHint,
statusOptions,
statusHint,
}: { }: {
location: ScheduleLocation; location: ScheduleLocation;
trainers: string[]; trainers: string[];
addresses: string[]; addresses: string[];
classTypes: string[]; classTypes: string[];
onChange: (loc: ScheduleLocation) => void; onChange: (loc: ScheduleLocation) => void;
levelOptions: { value: string; label: string }[];
levelHint: string;
statusOptions: { value: string; label: string }[];
statusHint: string;
}) { }) {
const [editingClass, setEditingClass] = useState<{ const [editingClass, setEditingClass] = useState<{
dayIndex: number; dayIndex: number;
@@ -1084,6 +1122,10 @@ function CalendarGrid({
onChange({ ...location, days: updatedDays }); onChange({ ...location, days: updatedDays });
}} }}
onClose={() => setEditingClass(null)} onClose={() => setEditingClass(null)}
levelOptions={levelOptions}
levelHint={levelHint}
statusOptions={statusOptions}
statusHint={statusHint}
/> />
)} )}
@@ -1105,6 +1147,10 @@ function CalendarGrid({
onChange({ ...location, days: updatedDays }); onChange({ ...location, days: updatedDays });
}} }}
onClose={() => setNewClass(null)} onClose={() => setNewClass(null)}
levelOptions={levelOptions}
levelHint={levelHint}
statusOptions={statusOptions}
statusHint={statusHint}
/> />
)} )}
</div> </div>
@@ -1112,11 +1158,85 @@ function CalendarGrid({
} }
// ---------- Main Page ---------- // ---------- Main Page ----------
function ConfigEditor({ cfg, updateCfg, onSync }: { cfg: ScheduleConfig; updateCfg: (c: ScheduleConfig) => void; onSync: (c: ScheduleConfig) => void }) {
const normalized = useMemo<ScheduleConfig>(() => ({
levels: cfg.levels ?? DEFAULT_CONFIG.levels,
statuses: cfg.statuses ?? DEFAULT_CONFIG.statuses,
}), [cfg]);
useEffect(() => { onSync(normalized); }, [normalized, onSync]);
return (
<div className="space-y-6">
{/* Levels — collapsible + drag-and-drop via ArrayEditor */}
<ArrayEditor
items={normalized.levels}
onChange={(levels) => updateCfg({ ...normalized, levels })}
createItem={() => ({ value: "", description: "" })}
label="Уровни опыта"
addLabel="Добавить уровень"
collapsible
getItemTitle={(item) => item.value || "Новый уровень"}
renderItem={(item, _i, update) => (
<div className="space-y-2">
<InputField
label="Название"
value={item.value}
onChange={(v) => update({ ...item, value: v })}
placeholder="Название уровня"
/>
<InputField
label="Описание"
value={item.description}
onChange={(v) => update({ ...item, description: v })}
placeholder="Описание (для подсказки)"
/>
</div>
)}
/>
{/* Statuses — collapsible + drag-and-drop */}
<ArrayEditor
items={normalized.statuses}
onChange={(statuses) => updateCfg({ ...normalized, statuses })}
createItem={() => ({ key: `status_${Date.now()}`, label: "", description: "" })}
label="Статусы групп"
addLabel="Добавить статус"
collapsible
getItemTitle={(item) => item.label || "Новый статус"}
renderItem={(item, _i, update) => (
<div className="space-y-2">
<InputField
label="Ключ (латиницей)"
value={item.key}
onChange={(v) => update({ ...item, key: v.replace(/[^a-zA-Z0-9_]/g, "") })}
placeholder="например: intensive"
/>
<InputField
label="Название"
value={item.label}
onChange={(v) => update({ ...item, label: v })}
placeholder="Название статуса"
/>
<InputField
label="Описание"
value={item.description}
onChange={(v) => update({ ...item, description: v })}
placeholder="Описание (для подсказки)"
/>
</div>
)}
/>
</div>
);
}
export default function ScheduleEditorPage() { export default function ScheduleEditorPage() {
const [activeLocation, setActiveLocation] = useState(0); const [activeLocation, setActiveLocation] = useState(0);
const [trainers, setTrainers] = useState<string[]>([]); const [trainers, setTrainers] = useState<string[]>([]);
const [addresses, setAddresses] = useState<string[]>([]); const [addresses, setAddresses] = useState<string[]>([]);
const [classTypes, setClassTypes] = useState<string[]>([]); const [classTypes, setClassTypes] = useState<string[]>([]);
const [config, setConfig] = useState<ScheduleConfig>(DEFAULT_CONFIG);
useEffect(() => { useEffect(() => {
adminFetch("/api/admin/team") adminFetch("/api/admin/team")
@@ -1139,9 +1259,19 @@ export default function ScheduleEditorPage() {
setClassTypes((classes.items ?? []).map((c) => c.name)); setClassTypes((classes.items ?? []).map((c) => c.name));
}) })
.catch(() => {}); .catch(() => {});
adminFetch("/api/admin/sections/scheduleConfig")
.then((r) => r.json())
.then((c: ScheduleConfig) => { if (c?.levels) setConfig(c); })
.catch(() => {});
}, []); }, []);
const levelOptions = buildLevelOptions(config);
const levelHint = config.levels.map((l) => `${l.value}${l.description}`).join(". ") + ".";
const statusOptions = useMemo(() => buildStatusOptions(config), [config]);
return ( return (
<>
<SectionEditor<ScheduleData> sectionKey="schedule" title="Расписание"> <SectionEditor<ScheduleData> sectionKey="schedule" title="Расписание">
{(data, update) => { {(data, update) => {
const location = data.locations[activeLocation]; const location = data.locations[activeLocation];
@@ -1222,11 +1352,24 @@ export default function ScheduleEditorPage() {
addresses={addresses} addresses={addresses}
classTypes={classTypes} classTypes={classTypes}
onChange={updateLocation} onChange={updateLocation}
levelOptions={levelOptions}
levelHint={levelHint}
statusOptions={statusOptions}
statusHint={(config.statuses ?? DEFAULT_CONFIG.statuses).map((s) => `${s.label}${s.description.toLowerCase()}`).join(". ") + "."}
/> />
)} )}
</> </>
); );
}} }}
</SectionEditor> </SectionEditor>
<div className="mt-10">
<SectionEditor<ScheduleConfig> sectionKey="scheduleConfig" title="Настройки фильтров">
{(cfg, updateCfg) => (
<ConfigEditor cfg={cfg} updateCfg={updateCfg} onSync={setConfig} />
)}
</SectionEditor>
</div>
</>
); );
} }
+13 -5
View File
@@ -1,16 +1,24 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter, Oswald } from "next/font/google"; import localFont from "next/font/local";
import { getContent } from "@/lib/content"; import { getContent } from "@/lib/content";
import "./globals.css"; import "./globals.css";
const inter = Inter({ const inter = localFont({
src: [
{ path: "../../public/fonts/inter-latin.woff2", weight: "100 900" },
{ path: "../../public/fonts/inter-cyrillic.woff2", weight: "100 900" },
],
variable: "--font-inter", variable: "--font-inter",
subsets: ["latin", "cyrillic"], display: "swap",
}); });
const oswald = Oswald({ const oswald = localFont({
src: [
{ path: "../../public/fonts/oswald-latin.woff2", weight: "200 700" },
{ path: "../../public/fonts/oswald-cyrillic.woff2", weight: "200 700" },
],
variable: "--font-oswald", variable: "--font-oswald",
subsets: ["latin", "cyrillic"], display: "swap",
}); });
export function generateMetadata(): Metadata { export function generateMetadata(): Metadata {
+1 -1
View File
@@ -45,7 +45,7 @@ export default function HomePage() {
<Classes data={content.classes} /> <Classes data={content.classes} />
<Team data={content.team} schedule={content.schedule.locations} /> <Team data={content.team} schedule={content.schedule.locations} />
{openDayData && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team.members} />} {openDayData && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team.members} />}
<Schedule data={content.schedule} classItems={content.classes.items} teamMembers={content.team.members} /> <Schedule data={content.schedule} scheduleConfig={content.scheduleConfig} classItems={content.classes.items} teamMembers={content.team.members} />
<Pricing data={content.pricing} /> <Pricing data={content.pricing} />
<MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} /> <MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} />
<News data={content.news} /> <News data={content.news} />
+78 -63
View File
@@ -9,7 +9,7 @@ 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 { GroupView } from "./schedule/GroupView"; import { GroupView } from "./schedule/GroupView";
import { buildTypeDots, shortAddress, startTimeMinutes, TIME_PRESETS } from "./schedule/constants"; import { buildTypeDots, shortAddress, startTimeMinutes, TIME_FILTER_EMPTY, isTimeFilterActive } from "./schedule/constants";
import type { StatusTag, TimeFilter, ScheduleDayMerged, ScheduleClassWithLocation } from "./schedule/constants"; import type { StatusTag, TimeFilter, ScheduleDayMerged, ScheduleClassWithLocation } from "./schedule/constants";
import type { SiteContent } from "@/types/content"; import type { SiteContent } from "@/types/content";
@@ -19,7 +19,7 @@ type LocationMode = "all" | number;
interface ScheduleState { interface ScheduleState {
locationMode: LocationMode; locationMode: LocationMode;
viewMode: ViewMode; viewMode: ViewMode;
filterTrainer: string | null; filterTrainerSet: Set<string>;
filterTypes: Set<string>; filterTypes: Set<string>;
filterStatusSet: Set<StatusTag>; filterStatusSet: Set<StatusTag>;
filterLevel: string | null; filterLevel: string | null;
@@ -31,7 +31,7 @@ interface ScheduleState {
type ScheduleAction = type ScheduleAction =
| { type: "SET_LOCATION"; mode: LocationMode } | { type: "SET_LOCATION"; mode: LocationMode }
| { type: "SET_VIEW"; mode: ViewMode } | { type: "SET_VIEW"; mode: ViewMode }
| { type: "SET_TRAINER"; value: string | null } | { type: "TOGGLE_TRAINER"; value: string }
| { type: "TOGGLE_TYPE"; value: string } | { type: "TOGGLE_TYPE"; value: string }
| { type: "TOGGLE_STATUS"; value: StatusTag } | { type: "TOGGLE_STATUS"; value: StatusTag }
| { type: "SET_LEVEL"; value: string | null } | { type: "SET_LEVEL"; value: string | null }
@@ -43,11 +43,11 @@ type ScheduleAction =
const initialState: ScheduleState = { const initialState: ScheduleState = {
locationMode: "all", locationMode: "all",
viewMode: "groups", viewMode: "groups",
filterTrainer: null, filterTrainerSet: new Set(),
filterTypes: new Set(), filterTypes: new Set(),
filterStatusSet: new Set(), filterStatusSet: new Set(),
filterLevel: null, filterLevel: null,
filterTime: "all", filterTime: TIME_FILTER_EMPTY,
filterDaySet: new Set(), filterDaySet: new Set(),
bookingGroup: null, bookingGroup: null,
}; };
@@ -58,8 +58,12 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
return { ...initialState, viewMode: state.viewMode, locationMode: action.mode }; return { ...initialState, viewMode: state.viewMode, locationMode: action.mode };
case "SET_VIEW": case "SET_VIEW":
return { ...state, viewMode: action.mode }; return { ...state, viewMode: action.mode };
case "SET_TRAINER": case "TOGGLE_TRAINER": {
return { ...state, filterTrainer: action.value }; 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": { case "TOGGLE_TYPE": {
const next = new Set(state.filterTypes); const next = new Set(state.filterTypes);
if (next.has(action.value)) next.delete(action.value); if (next.has(action.value)) next.delete(action.value);
@@ -85,19 +89,20 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
case "SET_BOOKING": case "SET_BOOKING":
return { ...state, bookingGroup: action.value }; return { ...state, bookingGroup: action.value };
case "CLEAR_FILTERS": case "CLEAR_FILTERS":
return { ...state, filterTrainer: null, filterTypes: new Set(), filterStatusSet: new Set(), filterLevel: null, filterTime: "all", filterDaySet: new Set() }; return { ...state, filterTrainerSet: new Set(), filterTypes: new Set(), filterStatusSet: new Set(), filterLevel: null, filterTime: TIME_FILTER_EMPTY, filterDaySet: new Set() };
} }
} }
interface ScheduleProps { interface ScheduleProps {
data: SiteContent["schedule"]; data: SiteContent["schedule"];
scheduleConfig?: SiteContent["scheduleConfig"];
classItems?: { name: string; color?: string }[]; classItems?: { name: string; color?: string }[];
teamMembers?: { name: string; image: string }[]; teamMembers?: { name: string; image: string }[];
} }
export function Schedule({ data: schedule, classItems, teamMembers }: ScheduleProps) { export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembers }: ScheduleProps) {
const [state, dispatch] = useReducer(scheduleReducer, initialState); const [state, dispatch] = useReducer(scheduleReducer, initialState);
const { locationMode, viewMode, filterTrainer, filterTypes, filterStatusSet, filterLevel, filterTime, filterDaySet, bookingGroup } = state; const { locationMode, viewMode, filterTrainerSet, filterTypes, filterStatusSet, filterLevel, filterTime, filterDaySet, bookingGroup } = state;
const isAllMode = locationMode === "all"; const isAllMode = locationMode === "all";
@@ -106,15 +111,17 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}, []); }, []);
const setFilterTrainer = useCallback((value: string | null) => dispatch({ type: "SET_TRAINER", value }), []); const toggleFilterTrainer = useCallback((value: string) => dispatch({ type: "TOGGLE_TRAINER", value }), []);
const toggleFilterType = useCallback((value: string) => dispatch({ type: "TOGGLE_TYPE", value }), []); const toggleFilterType = useCallback((value: string) => dispatch({ type: "TOGGLE_TYPE", value }), []);
const toggleFilterStatus = useCallback((value: StatusTag) => dispatch({ type: "TOGGLE_STATUS", value }), []); const toggleFilterStatus = useCallback((value: StatusTag) => dispatch({ type: "TOGGLE_STATUS", value }), []);
const setFilterLevel = useCallback((value: string | null) => dispatch({ type: "SET_LEVEL", value }), []); const setFilterLevel = useCallback((value: string | null) => dispatch({ type: "SET_LEVEL", value }), []);
const setFilterTime = useCallback((value: TimeFilter) => dispatch({ type: "SET_TIME", value }), []); const setFilterTime = useCallback((value: TimeFilter) => dispatch({ type: "SET_TIME", value }), []);
const setFilterTrainerFromCard = useCallback((trainer: string | null) => { const toggleFilterTrainerFromCard = useCallback((trainer: string | null) => {
dispatch({ type: "SET_TRAINER", value: trainer }); if (trainer) {
if (trainer) scrollToSchedule(); dispatch({ type: "TOGGLE_TRAINER", value: trainer });
scrollToSchedule();
}
}, [scrollToSchedule]); }, [scrollToSchedule]);
const toggleFilterTypeFromCard = useCallback((type: string) => { const toggleFilterTypeFromCard = useCallback((type: string) => {
@@ -176,34 +183,39 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
.map((d) => dayMap.get(d)!); .map((d) => dayMap.get(d)!);
}, [locationMode, schedule.locations]); }, [locationMode, schedule.locations]);
const { types, hasAnySlots, hasAnyRecruiting, levels } = useMemo(() => { const { types, availableStatuses, levels, trainerNames } = useMemo(() => {
const typeSet = new Set<string>(); const typeSet = new Set<string>();
const levelSet = new Set<string>(); const levelSet = new Set<string>();
let slots = false; const trainerSet = new Set<string>();
let recruiting = false; const statusSet = new Set<string>();
for (const day of activeDays) { for (const day of activeDays) {
for (const cls of day.classes) { for (const cls of day.classes) {
typeSet.add(cls.type); typeSet.add(cls.type);
if (cls.hasSlots) slots = true; trainerSet.add(cls.trainer);
if (cls.recruiting) recruiting = true; 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); if (cls.level) levelSet.add(cls.level);
} }
} }
return { return {
types: Array.from(typeSet).sort(), types: Array.from(typeSet).sort(),
hasAnySlots: slots, availableStatuses: Array.from(statusSet),
hasAnyRecruiting: recruiting,
levels: Array.from(levelSet).sort(), levels: Array.from(levelSet).sort(),
trainerNames: Array.from(trainerSet).sort(),
}; };
}, [activeDays]); }, [activeDays]);
// Get the time range for the active time filter // Parse time range for filtering
const activeTimeRange = filterTime !== "all" const activeTimeRange = isTimeFilterActive(filterTime)
? TIME_PRESETS.find((p) => p.value === filterTime)?.range ? [
filterTime.from ? startTimeMinutes(filterTime.from) : 0,
filterTime.to ? startTimeMinutes(filterTime.to) : 24 * 60,
] as const
: null; : null;
const filteredDays: ScheduleDayMerged[] = useMemo(() => { const filteredDays: ScheduleDayMerged[] = useMemo(() => {
const noFilter = !filterTrainer && filterTypes.size === 0 && filterStatusSet.size === 0 && !filterLevel && filterTime === "all" && filterDaySet.size === 0; const noFilter = filterTrainerSet.size === 0 && filterTypes.size === 0 && filterStatusSet.size === 0 && !filterLevel && !isTimeFilterActive(filterTime) && filterDaySet.size === 0;
if (noFilter) return activeDays; if (noFilter) return activeDays;
// First filter by day names if any selected // First filter by day names if any selected
@@ -216,11 +228,12 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
...day, ...day,
classes: day.classes.filter( classes: day.classes.filter(
(cls) => (cls) =>
(!filterTrainer || cls.trainer === filterTrainer) && (filterTrainerSet.size === 0 || filterTrainerSet.has(cls.trainer)) &&
(filterTypes.size === 0 || filterTypes.has(cls.type)) && (filterTypes.size === 0 || filterTypes.has(cls.type)) &&
(filterStatusSet.size === 0 || (filterStatusSet.size === 0 ||
(filterStatusSet.has("hasSlots") && cls.hasSlots) || (cls.status && filterStatusSet.has(cls.status as StatusTag)) ||
(filterStatusSet.has("recruiting") && cls.recruiting)) && (filterStatusSet.has("hasSlots" as StatusTag) && cls.hasSlots) ||
(filterStatusSet.has("recruiting" as StatusTag) && cls.recruiting)) &&
(!filterLevel || cls.level === filterLevel) && (!filterLevel || cls.level === filterLevel) &&
(!activeTimeRange || (() => { (!activeTimeRange || (() => {
const m = startTimeMinutes(cls.time); const m = startTimeMinutes(cls.time);
@@ -229,9 +242,9 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
), ),
})) }))
.filter((day) => day.classes.length > 0); .filter((day) => day.classes.length > 0);
}, [activeDays, filterTrainer, filterTypes, filterStatusSet, filterLevel, filterTime, activeTimeRange, filterDaySet]); }, [activeDays, filterTrainerSet, filterTypes, filterStatusSet, filterLevel, filterTime, activeTimeRange, filterDaySet]);
const hasActiveFilter = !!(filterTrainer || filterTypes.size > 0 || filterStatusSet.size > 0 || filterLevel || filterTime !== "all" || filterDaySet.size > 0); const hasActiveFilter = !!(filterTrainerSet.size > 0 || filterTypes.size > 0 || filterStatusSet.size > 0 || filterLevel || isTimeFilterActive(filterTime) || filterDaySet.size > 0);
function clearFilters() { function clearFilters() {
dispatch({ type: "CLEAR_FILTERS" }); dispatch({ type: "CLEAR_FILTERS" });
@@ -319,10 +332,10 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
</div> </div>
</Reveal> </Reveal>
{/* View mode toggle */} {/* View mode toggle + filter button */}
<Reveal> <Reveal>
<div className="mt-4 flex justify-center"> <div className="mt-4 hidden sm:flex items-center 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]"> <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 <button
onClick={() => dispatch({ type: "SET_VIEW", mode: "days" })} 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 ${ className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
@@ -345,34 +358,36 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
<Users size={13} /> <Users size={13} />
По группам По группам
</button> </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>
</div> </div>
</Reveal> </Reveal>
{/* Compact filters — desktop only */}
<Reveal>
<ScheduleFilters
typeDots={typeDots}
types={types}
hasAnySlots={hasAnySlots}
hasAnyRecruiting={hasAnyRecruiting}
levels={levels}
filterTypes={filterTypes}
toggleFilterType={toggleFilterType}
filterTrainer={filterTrainer}
filterStatusSet={filterStatusSet}
toggleFilterStatus={toggleFilterStatus}
filterLevel={filterLevel}
setFilterLevel={setFilterLevel}
filterTime={filterTime}
setFilterTime={setFilterTime}
availableDays={availableDays}
filterDaySet={filterDaySet}
toggleDay={toggleDay}
hasActiveFilter={hasActiveFilter}
clearFilters={clearFilters}
/>
</Reveal>
</div> </div>
{viewMode === "days" ? ( {viewMode === "days" ? (
@@ -384,8 +399,8 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
filteredDays={filteredDays} filteredDays={filteredDays}
filterTypes={filterTypes} filterTypes={filterTypes}
toggleFilterType={toggleFilterTypeFromCard} toggleFilterType={toggleFilterTypeFromCard}
filterTrainer={filterTrainer} filterTrainerSet={filterTrainerSet}
setFilterTrainer={setFilterTrainerFromCard} toggleFilterTrainer={toggleFilterTrainerFromCard}
hasActiveFilter={hasActiveFilter} hasActiveFilter={hasActiveFilter}
clearFilters={clearFilters} clearFilters={clearFilters}
showLocation={isAllMode} showLocation={isAllMode}
@@ -403,7 +418,7 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
key={day.day} key={day.day}
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""} className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""}
> >
<DayCard day={day} typeDots={typeDots} showLocation={isAllMode} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainerFromCard} filterTypes={filterTypes} toggleFilterType={toggleFilterTypeFromCard} /> <DayCard day={day} typeDots={typeDots} showLocation={isAllMode} filterTrainerSet={filterTrainerSet} toggleFilterTrainer={toggleFilterTrainerFromCard} filterTypes={filterTypes} toggleFilterType={toggleFilterTypeFromCard} />
</div> </div>
))} ))}
@@ -423,8 +438,8 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
filteredDays={filteredDays} filteredDays={filteredDays}
filterTypes={filterTypes} filterTypes={filterTypes}
toggleFilterType={toggleFilterTypeFromCard} toggleFilterType={toggleFilterTypeFromCard}
filterTrainer={filterTrainer} filterTrainerSet={filterTrainerSet}
setFilterTrainer={setFilterTrainerFromCard} toggleFilterTrainer={toggleFilterTrainerFromCard}
showLocation={isAllMode} showLocation={isAllMode}
onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })} onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })}
trainerPhotos={trainerPhotos} trainerPhotos={trainerPhotos}
+10 -10
View File
@@ -6,8 +6,8 @@ interface DayCardProps {
day: ScheduleDayMerged; day: ScheduleDayMerged;
typeDots: Record<string, string>; typeDots: Record<string, string>;
showLocation?: boolean; showLocation?: boolean;
filterTrainer: string | null; filterTrainerSet: Set<string>;
setFilterTrainer: (trainer: string | null) => void; toggleFilterTrainer: (trainer: string | null) => void;
filterTypes: Set<string>; filterTypes: Set<string>;
toggleFilterType: (type: string) => void; toggleFilterType: (type: string) => void;
} }
@@ -15,15 +15,15 @@ interface DayCardProps {
function ClassRow({ function ClassRow({
cls, cls,
typeDots, typeDots,
filterTrainer, filterTrainerSet,
setFilterTrainer, toggleFilterTrainer,
filterTypes, filterTypes,
toggleFilterType, toggleFilterType,
}: { }: {
cls: ScheduleClassWithLocation; cls: ScheduleClassWithLocation;
typeDots: Record<string, string>; typeDots: Record<string, string>;
filterTrainer: string | null; filterTrainerSet: Set<string>;
setFilterTrainer: (trainer: string | null) => void; toggleFilterTrainer: (trainer: string | null) => void;
filterTypes: Set<string>; filterTypes: Set<string>;
toggleFilterType: (type: string) => void; toggleFilterType: (type: string) => void;
}) { }) {
@@ -46,7 +46,7 @@ function ClassRow({
)} )}
</div> </div>
<button <button
onClick={() => setFilterTrainer(filterTrainer === cls.trainer ? null : cls.trainer)} onClick={() => toggleFilterTrainer(cls.trainer)}
className="mt-1.5 flex items-center gap-2 text-sm font-medium cursor-pointer active:opacity-60 text-neutral-800 dark:text-white/80" className="mt-1.5 flex items-center gap-2 text-sm font-medium cursor-pointer active:opacity-60 text-neutral-800 dark:text-white/80"
> >
<User size={13} className="shrink-0 text-neutral-400 dark:text-white/30" /> <User size={13} className="shrink-0 text-neutral-400 dark:text-white/30" />
@@ -70,7 +70,7 @@ function ClassRow({
); );
} }
export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterTrainer, filterTypes, toggleFilterType }: DayCardProps) { export function DayCard({ day, typeDots, showLocation, filterTrainerSet, toggleFilterTrainer, filterTypes, toggleFilterType }: DayCardProps) {
// Group classes by location when showLocation is true // Group classes by location when showLocation is true
const locationGroups = showLocation const locationGroups = showLocation
? Array.from( ? Array.from(
@@ -115,7 +115,7 @@ export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterT
</div> </div>
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]"> <div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
{classes.map((cls, i) => ( {classes.map((cls, i) => (
<ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterTypes={filterTypes} toggleFilterType={toggleFilterType} /> <ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainerSet={filterTrainerSet} toggleFilterTrainer={toggleFilterTrainer} filterTypes={filterTypes} toggleFilterType={toggleFilterType} />
))} ))}
</div> </div>
</div> </div>
@@ -125,7 +125,7 @@ export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterT
// Single location — no sub-headers // Single location — no sub-headers
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]"> <div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
{day.classes.map((cls, i) => ( {day.classes.map((cls, i) => (
<ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterTypes={filterTypes} toggleFilterType={toggleFilterType} /> <ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainerSet={filterTrainerSet} toggleFilterTrainer={toggleFilterTrainer} filterTypes={filterTypes} toggleFilterType={toggleFilterType} />
))} ))}
</div> </div>
)} )}
@@ -121,8 +121,8 @@ interface GroupViewProps {
filteredDays: ScheduleDayMerged[]; filteredDays: ScheduleDayMerged[];
filterTypes: Set<string>; filterTypes: Set<string>;
toggleFilterType: (type: string) => void; toggleFilterType: (type: string) => void;
filterTrainer: string | null; filterTrainerSet: Set<string>;
setFilterTrainer: (trainer: string | null) => void; toggleFilterTrainer: (trainer: string | null) => void;
showLocation?: boolean; showLocation?: boolean;
onBook?: (groupInfo: string) => void; onBook?: (groupInfo: string) => void;
trainerPhotos?: Record<string, string>; trainerPhotos?: Record<string, string>;
@@ -135,8 +135,8 @@ export function GroupView({
filteredDays, filteredDays,
filterTypes, filterTypes,
toggleFilterType, toggleFilterType,
filterTrainer, filterTrainerSet,
setFilterTrainer, toggleFilterTrainer,
showLocation, showLocation,
onBook, onBook,
trainerPhotos = {}, trainerPhotos = {},
@@ -158,7 +158,7 @@ export function GroupView({
<div className="mt-8 space-y-4 px-4 sm:px-6 lg:px-8 xl:px-6 max-w-4xl mx-auto"> <div className="mt-8 space-y-4 px-4 sm:px-6 lg:px-8 xl:px-6 max-w-4xl mx-auto">
{Array.from(byTrainer.entries()).map(([trainer, trainerGroups]) => { {Array.from(byTrainer.entries()).map(([trainer, trainerGroups]) => {
const byType = groupByType(trainerGroups); const byType = groupByType(trainerGroups);
const isActive = filterTrainer === trainer; const isActive = filterTrainerSet.has(trainer);
return ( return (
<div key={trainer} className="space-y-2"> <div key={trainer} className="space-y-2">
@@ -190,7 +190,7 @@ export function GroupView({
</button> </button>
{/* Name — clicks to filter */} {/* Name — clicks to filter */}
<button <button
onClick={() => setFilterTrainer(isActive ? null : trainer)} onClick={() => toggleFilterTrainer(trainer)}
className="cursor-pointer group" className="cursor-pointer group"
> >
<span className={`text-base font-semibold transition-colors ${ <span className={`text-base font-semibold transition-colors ${
@@ -9,8 +9,8 @@ interface MobileScheduleProps {
filteredDays: ScheduleDayMerged[]; filteredDays: ScheduleDayMerged[];
filterTypes: Set<string>; filterTypes: Set<string>;
toggleFilterType: (type: string) => void; toggleFilterType: (type: string) => void;
filterTrainer: string | null; filterTrainerSet: Set<string>;
setFilterTrainer: (trainer: string | null) => void; toggleFilterTrainer: (trainer: string | null) => void;
hasActiveFilter: boolean; hasActiveFilter: boolean;
clearFilters: () => void; clearFilters: () => void;
showLocation?: boolean; showLocation?: boolean;
@@ -21,16 +21,16 @@ function ClassRow({
typeDots, typeDots,
filterTypes, filterTypes,
toggleFilterType, toggleFilterType,
filterTrainer, filterTrainerSet,
setFilterTrainer, toggleFilterTrainer,
showLocation, showLocation,
}: { }: {
cls: ScheduleClassWithLocation; cls: ScheduleClassWithLocation;
typeDots: Record<string, string>; typeDots: Record<string, string>;
filterTypes: Set<string>; filterTypes: Set<string>;
toggleFilterType: (type: string) => void; toggleFilterType: (type: string) => void;
filterTrainer: string | null; filterTrainerSet: Set<string>;
setFilterTrainer: (trainer: string | null) => void; toggleFilterTrainer: (trainer: string | null) => void;
showLocation?: boolean; showLocation?: boolean;
}) { }) {
return ( return (
@@ -46,7 +46,7 @@ function ClassRow({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button <button
onClick={() => setFilterTrainer(filterTrainer === cls.trainer ? null : cls.trainer)} onClick={() => toggleFilterTrainer(cls.trainer)}
className="truncate text-sm font-medium text-left active:opacity-60 text-neutral-800 dark:text-white/80" className="truncate text-sm font-medium text-left active:opacity-60 text-neutral-800 dark:text-white/80"
> >
{cls.trainer} {cls.trainer}
@@ -92,8 +92,8 @@ export function MobileSchedule({
filteredDays, filteredDays,
filterTypes, filterTypes,
toggleFilterType, toggleFilterType,
filterTrainer, filterTrainerSet,
setFilterTrainer, toggleFilterTrainer,
hasActiveFilter, hasActiveFilter,
clearFilters, clearFilters,
showLocation, showLocation,
@@ -104,10 +104,10 @@ export function MobileSchedule({
{hasActiveFilter && ( {hasActiveFilter && (
<div className="mb-3 flex items-center justify-between rounded-xl bg-gold/10 px-4 py-2.5 dark:bg-gold/5"> <div className="mb-3 flex items-center justify-between rounded-xl bg-gold/10 px-4 py-2.5 dark:bg-gold/5">
<div className="flex items-center gap-2 text-xs font-medium text-gold-dark dark:text-gold-light"> <div className="flex items-center gap-2 text-xs font-medium text-gold-dark dark:text-gold-light">
{filterTrainer && ( {filterTrainerSet.size > 0 && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<User size={11} /> <User size={11} />
{filterTrainer} {filterTrainerSet.size === 1 ? Array.from(filterTrainerSet)[0] : `${filterTrainerSet.size} тренеров`}
</span> </span>
)} )}
{filterTypes.size > 0 && Array.from(filterTypes).map((type) => ( {filterTypes.size > 0 && Array.from(filterTypes).map((type) => (
@@ -177,8 +177,8 @@ export function MobileSchedule({
typeDots={typeDots} typeDots={typeDots}
filterTypes={filterTypes} filterTypes={filterTypes}
toggleFilterType={toggleFilterType} toggleFilterType={toggleFilterType}
filterTrainer={filterTrainer} filterTrainerSet={filterTrainerSet}
setFilterTrainer={setFilterTrainer} toggleFilterTrainer={toggleFilterTrainer}
/> />
))} ))}
</div> </div>
@@ -192,8 +192,8 @@ export function MobileSchedule({
typeDots={typeDots} typeDots={typeDots}
filterTypes={filterTypes} filterTypes={filterTypes}
toggleFilterType={toggleFilterType} toggleFilterType={toggleFilterType}
filterTrainer={filterTrainer} filterTrainerSet={filterTrainerSet}
setFilterTrainer={setFilterTrainer} toggleFilterTrainer={toggleFilterTrainer}
/> />
)) ))
)} )}
@@ -1,12 +1,15 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { User, X, ChevronDown, Clock, Calendar } from "lucide-react"; import { createPortal } from "react-dom";
import { User, X, Clock, SlidersHorizontal } from "lucide-react";
import { import {
pillBase, pillBase,
pillActive, pillActive,
pillInactive, pillInactive,
TIME_PRESETS, TIME_PRESETS,
isTimeFilterActive,
TIME_FILTER_EMPTY,
type StatusTag, type StatusTag,
type TimeFilter, type TimeFilter,
} from "./constants"; } from "./constants";
@@ -14,12 +17,12 @@ import {
interface ScheduleFiltersProps { interface ScheduleFiltersProps {
typeDots: Record<string, string>; typeDots: Record<string, string>;
types: string[]; types: string[];
hasAnySlots: boolean; availableStatuses: string[];
hasAnyRecruiting: boolean;
levels: string[]; levels: string[];
filterTypes: Set<string>; filterTypes: Set<string>;
toggleFilterType: (type: string) => void; toggleFilterType: (type: string) => void;
filterTrainer: string | null; filterTrainerSet: Set<string>;
toggleFilterTrainer: (trainer: string) => void;
filterStatusSet: Set<StatusTag>; filterStatusSet: Set<StatusTag>;
toggleFilterStatus: (status: StatusTag) => void; toggleFilterStatus: (status: StatusTag) => void;
filterLevel: string | null; filterLevel: string | null;
@@ -29,19 +32,23 @@ interface ScheduleFiltersProps {
availableDays: { day: string; dayShort: string }[]; availableDays: { day: string; dayShort: string }[];
filterDaySet: Set<string>; filterDaySet: Set<string>;
toggleDay: (day: string) => void; toggleDay: (day: string) => void;
trainerNames: string[];
scheduleConfig?: { levels: { value: string; description: string }[]; statuses: { key: string; label: string; description: string }[] };
hasActiveFilter: boolean; hasActiveFilter: boolean;
clearFilters: () => void; clearFilters: () => void;
} }
const divider = <span className="mx-0.5 h-3.5 w-px shrink-0 bg-white/[0.06]" />;
export function ScheduleFilters({ export function ScheduleFilters({
typeDots, typeDots,
types, types,
hasAnySlots, availableStatuses,
hasAnyRecruiting,
levels, levels,
filterTypes, filterTypes,
toggleFilterType, toggleFilterType,
filterTrainer, filterTrainerSet,
toggleFilterTrainer,
filterStatusSet, filterStatusSet,
toggleFilterStatus, toggleFilterStatus,
filterLevel, filterLevel,
@@ -51,127 +58,392 @@ export function ScheduleFilters({
availableDays, availableDays,
filterDaySet, filterDaySet,
toggleDay, toggleDay,
trainerNames,
scheduleConfig,
hasActiveFilter, hasActiveFilter,
clearFilters, clearFilters,
}: ScheduleFiltersProps) { }: ScheduleFiltersProps) {
const [showWhen, setShowWhen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const hasTimeFilter = filterDaySet.size > 0 || filterTime !== "all";
const totalActive = filterTypes.size + filterTrainerSet.size + filterStatusSet.size + (filterLevel ? 1 : 0) + filterDaySet.size + (isTimeFilterActive(filterTime) ? 1 : 0);
useEffect(() => {
if (modalOpen) document.body.style.overflow = "hidden";
else document.body.style.overflow = "";
return () => { document.body.style.overflow = ""; };
}, [modalOpen]);
useEffect(() => {
if (!modalOpen) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setModalOpen(false);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [modalOpen]);
return ( return (
<> <>
{/* Single row: type + status + when + active trainer indicator + clear */} {/* Filter button — same style as По дням / По группам buttons */}
<div className="mt-5 hidden sm:flex items-center justify-center gap-1.5 flex-wrap"> <button
{/* Class types */} onClick={() => setModalOpen(true)}
{types.map((type) => ( className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
<button totalActive > 0
key={type} ? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
onClick={() => toggleFilterType(type)} : "text-neutral-500 hover:text-neutral-700 dark:text-white/35 dark:hover:text-white/60"
className={`${pillBase} ${filterTypes.has(type) ? pillActive : pillInactive}`} }`}
> >
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[type] ?? "bg-white/30"}`} /> <SlidersHorizontal size={13} />
{type} {totalActive > 0 && (
</button> <span className="flex h-4 w-4 items-center justify-center rounded-full bg-gold text-[9px] font-bold text-black -mr-0.5">
))} {totalActive}
{/* Divider */}
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
{/* Status filters */}
{hasAnySlots && (
<button
onClick={() => toggleFilterStatus("hasSlots")}
className={`${pillBase} ${filterStatusSet.has("hasSlots") ? "bg-emerald-500/20 text-emerald-700 border border-emerald-500/40 dark:text-emerald-400 dark:border-emerald-500/30" : pillInactive}`}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
Есть места
</button>
)}
{hasAnyRecruiting && (
<button
onClick={() => toggleFilterStatus("recruiting")}
className={`${pillBase} ${filterStatusSet.has("recruiting") ? "bg-sky-500/20 text-sky-700 border border-sky-500/40 dark:text-sky-400 dark:border-sky-500/30" : pillInactive}`}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
Набор
</button>
)}
{/* Level filters */}
{levels.length > 0 && (
<>
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
{levels.map((level) => (
<button
key={level}
onClick={() => setFilterLevel(filterLevel === level ? null : level)}
className={`${pillBase} ${filterLevel === level ? "bg-rose-500/20 text-rose-700 border border-rose-500/40 dark:text-rose-400 dark:border-rose-500/30" : pillInactive}`}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-rose-500" />
{level}
</button>
))}
</>
)}
{/* Divider */}
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
{/* When dropdown toggle */}
<button
onClick={() => setShowWhen(!showWhen)}
className={`${pillBase} ${hasTimeFilter ? pillActive : pillInactive}`}
>
<Clock size={11} />
Когда
<ChevronDown size={10} className={`transition-transform duration-200 ${showWhen ? "rotate-180" : ""}`} />
</button>
{/* Active trainer indicator (set by clicking trainer in cards) */}
{filterTrainer && (
<span className={`${pillBase} ${pillActive}`}>
<User size={11} />
{filterTrainer}
</span> </span>
)} )}
</button>
{/* Clear */} {/* Filter modal — Airbnb style */}
{hasActiveFilter && ( {modalOpen && createPortal(
<button <div
onClick={clearFilters} className="fixed inset-0 z-[60] flex items-center justify-center p-4"
className="inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[11px] text-neutral-400 hover:text-neutral-600 dark:text-white/25 dark:hover:text-white/50 transition-colors cursor-pointer" role="dialog"
aria-modal="true"
aria-label="Фильтры"
onClick={() => setModalOpen(false)}
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative w-full max-w-lg max-h-[85vh] flex flex-col rounded-2xl border border-white/[0.08] shadow-2xl overflow-hidden"
style={{ backgroundColor: "#171717" }}
onClick={(e) => e.stopPropagation()}
> >
<X size={11} /> {/* Header */}
</button> <div className="flex items-center justify-between px-6 py-4 border-b border-white/[0.06]">
)} <h3 className="text-base font-bold text-white">Фильтры</h3>
</div> <button
onClick={() => setModalOpen(false)}
aria-label="Закрыть"
className="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:bg-white/[0.06] hover:text-white transition-colors cursor-pointer"
>
<X size={18} />
</button>
</div>
{/* When panel — expandable: days + time presets */} {/* Scrollable content */}
{showWhen && ( <div className="flex-1 overflow-y-auto p-6 space-y-7">
<div className="mt-2 hidden sm:flex items-center justify-center gap-1.5 flex-wrap"> {/* Class types — gold border, white text; gold bg when active */}
<Calendar size={11} className="text-neutral-400 dark:text-white/25" /> <FilterSection title="Направления">
{availableDays.map(({ day, dayShort }) => ( <div className="flex flex-wrap gap-2">
<button {types.map((type) => (
key={day} <button
onClick={() => toggleDay(day)} key={type}
className={`${pillBase} ${filterDaySet.has(day) ? pillActive : pillInactive}`} onClick={() => toggleFilterType(type)}
> className={`${pillBase} ${
{dayShort} filterTypes.has(type)
</button> ? "bg-gold text-black border border-gold"
))} : "border border-gold/30 text-white hover:border-gold/60 hover:bg-gold/10"
}`}
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[type] ?? "bg-white/30"}`} />
{type}
</button>
))}
</div>
</FilterSection>
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" /> {/* Trainer — search multi-select */}
<FilterSection title="Тренер">
<TrainerMultiSelect
trainers={trainerNames}
selected={filterTrainerSet}
onToggle={toggleFilterTrainer}
/>
</FilterSection>
{TIME_PRESETS.map((preset) => ( {/* Status — gold tags */}
<button {availableStatuses.length > 0 && (
key={preset.value} <FilterSection title="Статус">
onClick={() => setFilterTime(filterTime === preset.value ? "all" : preset.value)} <div className="flex flex-wrap gap-2">
className={`${pillBase} ${filterTime === preset.value ? pillActive : pillInactive}`} {availableStatuses.map((statusKey) => {
> const cfg = scheduleConfig?.statuses?.find((s) => s.key === statusKey);
{preset.label} const label = cfg?.label || statusKey;
</button> const desc = cfg?.description;
))} const active = filterStatusSet.has(statusKey);
</div> return (
<span key={statusKey} className="relative group">
<button
onClick={() => toggleFilterStatus(statusKey)}
className={`${pillBase} ${
active
? "bg-gold text-black border border-gold"
: "border border-gold/30 text-white hover:border-gold/60 hover:bg-gold/10"
}`}
>
{label}
</button>
{desc && (
<span className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-10 w-48 rounded-lg border border-white/10 px-3 py-2 text-[11px] leading-relaxed text-neutral-300 text-center shadow-xl opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity" style={{ backgroundColor: "#1a1a1a" }}>
{desc}
</span>
)}
</span>
);
})}
</div>
</FilterSection>
)}
{/* Level — gold tags with hover hints */}
{levels.length > 0 && (
<FilterSection title="Опыт">
<div className="flex flex-wrap gap-2">
{levels.map((level) => {
const desc = scheduleConfig?.levels?.find((l) => l.value === level)?.description;
const active = filterLevel === level;
return (
<span key={level} className="relative group">
<button
onClick={() => setFilterLevel(active ? null : level)}
className={`${pillBase} ${
active
? "bg-gold text-black border border-gold"
: "border border-gold/30 text-white hover:border-gold/60 hover:bg-gold/10"
}`}
>
{level}
</button>
{desc && (
<span className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-10 w-48 rounded-lg border border-white/10 px-3 py-2 text-[11px] leading-relaxed text-neutral-300 text-center shadow-xl opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity" style={{ backgroundColor: "#1a1a1a" }}>
{desc}
</span>
)}
</span>
);
})}
</div>
</FilterSection>
)}
{/* Days — calendar grid */}
<FilterSection title="День недели">
<div className="grid grid-cols-7 gap-1.5">
{availableDays.map(({ day, dayShort }) => (
<button
key={day}
onClick={() => toggleDay(day)}
className={`flex items-center justify-center rounded-lg py-2.5 text-xs font-semibold transition-all cursor-pointer ${
filterDaySet.has(day)
? "bg-gold text-black"
: "bg-white/[0.04] text-neutral-400 hover:bg-white/[0.08] hover:text-white"
}`}
>
{dayShort}
</button>
))}
</div>
</FilterSection>
{/* Time — from/to inputs + preset shortcuts */}
<FilterSection title="Время">
<div className="flex items-center gap-2 mb-3">
<div className="flex-1">
<label className="block text-[10px] text-neutral-500 mb-1">С</label>
<input
type="time"
value={filterTime.from}
onChange={(e) => setFilterTime({ ...filterTime, from: e.target.value })}
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 transition-colors [color-scheme:dark]"
/>
</div>
<span className="text-neutral-500 mt-5"></span>
<div className="flex-1">
<label className="block text-[10px] text-neutral-500 mb-1">До</label>
<input
type="time"
value={filterTime.to}
onChange={(e) => setFilterTime({ ...filterTime, to: e.target.value })}
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 transition-colors [color-scheme:dark]"
/>
</div>
</div>
<div className="flex gap-1.5">
{TIME_PRESETS.map((preset) => {
const isActive = filterTime.from === preset.from && filterTime.to === preset.to;
return (
<button
key={preset.label}
onClick={() => setFilterTime(isActive ? TIME_FILTER_EMPTY : { from: preset.from, to: preset.to })}
className={`${pillBase} ${
isActive ? "bg-gold/15 text-gold border border-gold/30" : pillInactive
}`}
>
{preset.label}
</button>
);
})}
</div>
</FilterSection>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-white/[0.06]">
<button
onClick={() => { clearFilters(); setModalOpen(false); }}
className="text-sm text-neutral-400 hover:text-white transition-colors cursor-pointer"
>
Сбросить всё
</button>
<button
onClick={() => setModalOpen(false)}
className="rounded-xl bg-gold px-6 py-2.5 text-sm font-semibold text-black hover:bg-gold-light transition-colors cursor-pointer"
>
Показать
</button>
</div>
</div>
</div>,
document.body
)} )}
</> </>
); );
} }
function FilterSection({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
const [showHint, setShowHint] = useState(false);
const hintRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showHint) return;
function handle(e: MouseEvent) {
if (hintRef.current && !hintRef.current.contains(e.target as Node)) setShowHint(false);
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [showHint]);
return (
<div>
<div className="flex items-center gap-1.5 mb-3">
<h4 className="text-sm font-semibold text-white">{title}</h4>
{hint && (
<div ref={hintRef} className="relative">
<button
type="button"
onClick={() => setShowHint(!showHint)}
className="flex h-4 w-4 items-center justify-center rounded-full border border-white/15 text-[10px] text-neutral-500 hover:text-white hover:border-white/30 transition-colors cursor-pointer"
>
?
</button>
{showHint && (
<div className="absolute left-6 top-1/2 -translate-y-1/2 z-10 w-56 rounded-lg border border-white/10 px-3 py-2 text-[11px] leading-relaxed text-neutral-300 shadow-xl" style={{ backgroundColor: "#1a1a1a" }}>
{hint}
</div>
)}
</div>
)}
</div>
{children}
</div>
);
}
function HintBubble({ text }: { text: string }) {
return (
<span className="group relative">
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-white/15 text-[10px] text-neutral-500 hover:text-white hover:border-white/30 transition-colors cursor-help">
?
</span>
<span className="absolute left-6 top-1/2 -translate-y-1/2 z-10 w-48 rounded-lg border border-white/10 px-3 py-2 text-[11px] leading-relaxed text-neutral-300 shadow-xl opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" style={{ backgroundColor: "#1a1a1a" }}>
{text}
</span>
</span>
);
}
function TrainerMultiSelect({
trainers,
selected,
onToggle,
}: {
trainers: string[];
selected: Set<string>;
onToggle: (trainer: string) => void;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = search
? trainers.filter((t) => !selected.has(t) && t.toLowerCase().includes(search.toLowerCase()))
: trainers.filter((t) => !selected.has(t));
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [open]);
return (
<div ref={containerRef} className="relative">
<div
onClick={() => { setOpen(true); inputRef.current?.focus(); }}
className={`flex flex-wrap items-center gap-1.5 rounded-lg border px-3 py-2 min-h-[42px] cursor-text transition-colors ${
open ? "border-gold bg-white/[0.06]" : "border-white/[0.08] bg-white/[0.04]"
}`}
>
{Array.from(selected).map((t) => (
<span key={t} className="inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/30 px-2.5 py-0.5 text-xs font-medium text-gold">
{t}
<button type="button" onClick={(e) => { e.stopPropagation(); onToggle(t); }} className="text-gold/60 hover:text-gold transition-colors">
<X size={10} />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Backspace" && !search && selected.size > 0) {
onToggle(Array.from(selected).pop()!);
}
if (e.key === "Escape") { setOpen(false); setSearch(""); }
}}
placeholder={selected.size === 0 ? "Все тренеры" : ""}
className="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-neutral-500 outline-none"
/>
</div>
{open && filtered.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-lg border border-white/[0.08] shadow-xl overflow-hidden" style={{ backgroundColor: "#1a1a1a" }}>
<div className="max-h-48 overflow-y-auto styled-scrollbar">
{filtered.map((trainer) => (
<button
key={trainer}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onToggle(trainer);
setSearch("");
inputRef.current?.focus();
}}
className="w-full px-4 py-2 text-left text-sm text-white transition-colors hover:bg-white/[0.05]"
>
{trainer}
</button>
))}
</div>
</div>
)}
</div>
);
}
+16 -6
View File
@@ -62,15 +62,24 @@ export function buildTypeDots(
return map; return map;
} }
export type StatusTag = "hasSlots" | "recruiting"; export type StatusTag = string;
/** @deprecated Use Set<StatusTag> instead */ /** @deprecated Use Set<StatusTag> instead */
export type StatusFilter = "all" | "hasSlots" | "recruiting"; export type StatusFilter = "all" | "hasSlots" | "recruiting";
export type TimeFilter = "all" | "morning" | "afternoon" | "evening"; export interface TimeFilter {
from: string; // "HH:MM" or ""
to: string; // "HH:MM" or ""
}
export const TIME_PRESETS: { value: TimeFilter; label: string; range: [number, number] }[] = [ export const TIME_FILTER_EMPTY: TimeFilter = { from: "", to: "" };
{ value: "morning", label: "Утро", range: [0, 12 * 60] },
{ value: "afternoon", label: "День", range: [12 * 60, 18 * 60] }, export function isTimeFilterActive(t: TimeFilter): boolean {
{ value: "evening", label: "Вечер", range: [18 * 60, 24 * 60] }, return t.from !== "" || t.to !== "";
}
export const TIME_PRESETS: { label: string; from: string; to: string }[] = [
{ label: "Утро", from: "06:00", to: "12:00" },
{ label: "День", from: "12:00", to: "18:00" },
{ label: "Вечер", from: "18:00", to: "23:00" },
]; ];
/** Parse start time from "HH:MMHH:MM" to minutes since midnight */ /** Parse start time from "HH:MMHH:MM" to minutes since midnight */
@@ -89,6 +98,7 @@ export interface ScheduleClassWithLocation {
level?: string; level?: string;
hasSlots?: boolean; hasSlots?: boolean;
recruiting?: boolean; recruiting?: boolean;
status?: string;
groupId?: string; groupId?: string;
locationName?: string; locationName?: string;
locationAddress?: string; locationAddress?: string;
+10
View File
@@ -449,6 +449,16 @@ export const siteContent: SiteContent = {
}, },
], ],
}, },
scheduleConfig: {
levels: [
{ value: "Начинающий/Без опыта", description: "Для тех, кто только начинает заниматься" },
{ value: "Продвинутый", description: "Для учеников с опытом от 6 месяцев" },
],
statuses: [
{ key: "hasSlots", label: "Есть места", description: "В группе есть свободные места" },
{ key: "recruiting", label: "Набор открыт", description: "Идёт набор в новую группу" },
],
},
news: { news: {
title: "Новости", title: "Новости",
items: [], items: [],
+11
View File
@@ -520,6 +520,7 @@ const SECTION_KEYS = [
"news", "news",
"contact", "contact",
"popups", "popups",
"scheduleConfig",
] as const; ] as const;
export function getSiteContent(): SiteContent | null { export function getSiteContent(): SiteContent | null {
@@ -557,6 +558,16 @@ export function getSiteContent(): SiteContent | null {
instagramHint: "По вопросам пишите в Instagram", instagramHint: "По вопросам пишите в Instagram",
}, },
contact: sections.contact, contact: sections.contact,
scheduleConfig: sections.scheduleConfig ?? {
levels: [
{ value: "Начинающий/Без опыта", description: "Для тех, кто только начинает" },
{ value: "Продвинутый", description: "Для учеников с опытом от 6 месяцев" },
],
statuses: [
{ key: "hasSlots", label: "Есть места", description: "В группе есть свободные места" },
{ key: "recruiting", label: "Набор открыт", description: "Идёт набор в новую группу" },
],
},
team: { team: {
title: teamSection.title || "", title: teamSection.title || "",
members: members.map(({ id, ...rest }) => rest), members: members.map(({ id, ...rest }) => rest),
+5
View File
@@ -44,6 +44,7 @@ export interface ScheduleClass {
level?: string; level?: string;
hasSlots?: boolean; hasSlots?: boolean;
recruiting?: boolean; recruiting?: boolean;
status?: string;
groupId?: string; groupId?: string;
} }
@@ -150,6 +151,10 @@ export interface SiteContent {
title: string; title: string;
locations: ScheduleLocation[]; locations: ScheduleLocation[];
}; };
scheduleConfig: {
levels: { value: string; description: string }[];
statuses: { key: string; label: string; description: string }[];
};
news: { news: {
title: string; title: string;
items: NewsItem[]; items: NewsItem[];