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
+78 -63
View File
@@ -9,7 +9,7 @@ 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 { 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";
@@ -19,7 +19,7 @@ type LocationMode = "all" | number;
interface ScheduleState {
locationMode: LocationMode;
viewMode: ViewMode;
filterTrainer: string | null;
filterTrainerSet: Set<string>;
filterTypes: Set<string>;
filterStatusSet: Set<StatusTag>;
filterLevel: string | null;
@@ -31,7 +31,7 @@ interface ScheduleState {
type ScheduleAction =
| { type: "SET_LOCATION"; mode: LocationMode }
| { type: "SET_VIEW"; mode: ViewMode }
| { type: "SET_TRAINER"; value: string | null }
| { type: "TOGGLE_TRAINER"; value: string }
| { type: "TOGGLE_TYPE"; value: string }
| { type: "TOGGLE_STATUS"; value: StatusTag }
| { type: "SET_LEVEL"; value: string | null }
@@ -43,11 +43,11 @@ type ScheduleAction =
const initialState: ScheduleState = {
locationMode: "all",
viewMode: "groups",
filterTrainer: null,
filterTrainerSet: new Set(),
filterTypes: new Set(),
filterStatusSet: new Set(),
filterLevel: null,
filterTime: "all",
filterTime: TIME_FILTER_EMPTY,
filterDaySet: new Set(),
bookingGroup: null,
};
@@ -58,8 +58,12 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
return { ...initialState, viewMode: state.viewMode, locationMode: action.mode };
case "SET_VIEW":
return { ...state, viewMode: action.mode };
case "SET_TRAINER":
return { ...state, filterTrainer: action.value };
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);
@@ -85,19 +89,20 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
case "SET_BOOKING":
return { ...state, bookingGroup: action.value };
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 {
data: SiteContent["schedule"];
scheduleConfig?: SiteContent["scheduleConfig"];
classItems?: { name: string; color?: 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 { 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";
@@ -106,15 +111,17 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
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 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 setFilterTrainerFromCard = useCallback((trainer: string | null) => {
dispatch({ type: "SET_TRAINER", value: trainer });
if (trainer) scrollToSchedule();
const toggleFilterTrainerFromCard = useCallback((trainer: string | null) => {
if (trainer) {
dispatch({ type: "TOGGLE_TRAINER", value: trainer });
scrollToSchedule();
}
}, [scrollToSchedule]);
const toggleFilterTypeFromCard = useCallback((type: string) => {
@@ -176,34 +183,39 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
.map((d) => dayMap.get(d)!);
}, [locationMode, schedule.locations]);
const { types, hasAnySlots, hasAnyRecruiting, levels } = useMemo(() => {
const { types, availableStatuses, levels, trainerNames } = useMemo(() => {
const typeSet = new Set<string>();
const levelSet = new Set<string>();
let slots = false;
let recruiting = false;
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);
if (cls.hasSlots) slots = true;
if (cls.recruiting) recruiting = true;
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(),
hasAnySlots: slots,
hasAnyRecruiting: recruiting,
availableStatuses: Array.from(statusSet),
levels: Array.from(levelSet).sort(),
trainerNames: Array.from(trainerSet).sort(),
};
}, [activeDays]);
// Get the time range for the active time filter
const activeTimeRange = filterTime !== "all"
? TIME_PRESETS.find((p) => p.value === filterTime)?.range
// 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 = !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;
// First filter by day names if any selected
@@ -216,11 +228,12 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
...day,
classes: day.classes.filter(
(cls) =>
(!filterTrainer || cls.trainer === filterTrainer) &&
(filterTrainerSet.size === 0 || filterTrainerSet.has(cls.trainer)) &&
(filterTypes.size === 0 || filterTypes.has(cls.type)) &&
(filterStatusSet.size === 0 ||
(filterStatusSet.has("hasSlots") && cls.hasSlots) ||
(filterStatusSet.has("recruiting") && cls.recruiting)) &&
(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);
@@ -229,9 +242,9 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
),
}))
.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() {
dispatch({ type: "CLEAR_FILTERS" });
@@ -319,10 +332,10 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
</div>
</Reveal>
{/* View mode toggle */}
{/* View mode toggle + filter button */}
<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]">
<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 ${
@@ -345,34 +358,36 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
<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>
{/* 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>
{viewMode === "days" ? (
@@ -384,8 +399,8 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
filteredDays={filteredDays}
filterTypes={filterTypes}
toggleFilterType={toggleFilterTypeFromCard}
filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainerFromCard}
filterTrainerSet={filterTrainerSet}
toggleFilterTrainer={toggleFilterTrainerFromCard}
hasActiveFilter={hasActiveFilter}
clearFilters={clearFilters}
showLocation={isAllMode}
@@ -403,7 +418,7 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
key={day.day}
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>
))}
@@ -423,8 +438,8 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
filteredDays={filteredDays}
filterTypes={filterTypes}
toggleFilterType={toggleFilterTypeFromCard}
filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainerFromCard}
filterTrainerSet={filterTrainerSet}
toggleFilterTrainer={toggleFilterTrainerFromCard}
showLocation={isAllMode}
onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })}
trainerPhotos={trainerPhotos}
+10 -10
View File
@@ -6,8 +6,8 @@ interface DayCardProps {
day: ScheduleDayMerged;
typeDots: Record<string, string>;
showLocation?: boolean;
filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void;
filterTrainerSet: Set<string>;
toggleFilterTrainer: (trainer: string | null) => void;
filterTypes: Set<string>;
toggleFilterType: (type: string) => void;
}
@@ -15,15 +15,15 @@ interface DayCardProps {
function ClassRow({
cls,
typeDots,
filterTrainer,
setFilterTrainer,
filterTrainerSet,
toggleFilterTrainer,
filterTypes,
toggleFilterType,
}: {
cls: ScheduleClassWithLocation;
typeDots: Record<string, string>;
filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void;
filterTrainerSet: Set<string>;
toggleFilterTrainer: (trainer: string | null) => void;
filterTypes: Set<string>;
toggleFilterType: (type: string) => void;
}) {
@@ -46,7 +46,7 @@ function ClassRow({
)}
</div>
<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"
>
<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
const locationGroups = showLocation
? Array.from(
@@ -115,7 +115,7 @@ export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterT
</div>
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
{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>
@@ -125,7 +125,7 @@ export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterT
// Single location — no sub-headers
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
{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>
)}
@@ -121,8 +121,8 @@ interface GroupViewProps {
filteredDays: ScheduleDayMerged[];
filterTypes: Set<string>;
toggleFilterType: (type: string) => void;
filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void;
filterTrainerSet: Set<string>;
toggleFilterTrainer: (trainer: string | null) => void;
showLocation?: boolean;
onBook?: (groupInfo: string) => void;
trainerPhotos?: Record<string, string>;
@@ -135,8 +135,8 @@ export function GroupView({
filteredDays,
filterTypes,
toggleFilterType,
filterTrainer,
setFilterTrainer,
filterTrainerSet,
toggleFilterTrainer,
showLocation,
onBook,
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">
{Array.from(byTrainer.entries()).map(([trainer, trainerGroups]) => {
const byType = groupByType(trainerGroups);
const isActive = filterTrainer === trainer;
const isActive = filterTrainerSet.has(trainer);
return (
<div key={trainer} className="space-y-2">
@@ -190,7 +190,7 @@ export function GroupView({
</button>
{/* Name — clicks to filter */}
<button
onClick={() => setFilterTrainer(isActive ? null : trainer)}
onClick={() => toggleFilterTrainer(trainer)}
className="cursor-pointer group"
>
<span className={`text-base font-semibold transition-colors ${
@@ -9,8 +9,8 @@ interface MobileScheduleProps {
filteredDays: ScheduleDayMerged[];
filterTypes: Set<string>;
toggleFilterType: (type: string) => void;
filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void;
filterTrainerSet: Set<string>;
toggleFilterTrainer: (trainer: string | null) => void;
hasActiveFilter: boolean;
clearFilters: () => void;
showLocation?: boolean;
@@ -21,16 +21,16 @@ function ClassRow({
typeDots,
filterTypes,
toggleFilterType,
filterTrainer,
setFilterTrainer,
filterTrainerSet,
toggleFilterTrainer,
showLocation,
}: {
cls: ScheduleClassWithLocation;
typeDots: Record<string, string>;
filterTypes: Set<string>;
toggleFilterType: (type: string) => void;
filterTrainer: string | null;
setFilterTrainer: (trainer: string | null) => void;
filterTrainerSet: Set<string>;
toggleFilterTrainer: (trainer: string | null) => void;
showLocation?: boolean;
}) {
return (
@@ -46,7 +46,7 @@ function ClassRow({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<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"
>
{cls.trainer}
@@ -92,8 +92,8 @@ export function MobileSchedule({
filteredDays,
filterTypes,
toggleFilterType,
filterTrainer,
setFilterTrainer,
filterTrainerSet,
toggleFilterTrainer,
hasActiveFilter,
clearFilters,
showLocation,
@@ -104,10 +104,10 @@ export function MobileSchedule({
{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="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">
<User size={11} />
{filterTrainer}
{filterTrainerSet.size === 1 ? Array.from(filterTrainerSet)[0] : `${filterTrainerSet.size} тренеров`}
</span>
)}
{filterTypes.size > 0 && Array.from(filterTypes).map((type) => (
@@ -177,8 +177,8 @@ export function MobileSchedule({
typeDots={typeDots}
filterTypes={filterTypes}
toggleFilterType={toggleFilterType}
filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainer}
filterTrainerSet={filterTrainerSet}
toggleFilterTrainer={toggleFilterTrainer}
/>
))}
</div>
@@ -192,8 +192,8 @@ export function MobileSchedule({
typeDots={typeDots}
filterTypes={filterTypes}
toggleFilterType={toggleFilterType}
filterTrainer={filterTrainer}
setFilterTrainer={setFilterTrainer}
filterTrainerSet={filterTrainerSet}
toggleFilterTrainer={toggleFilterTrainer}
/>
))
)}
@@ -1,12 +1,15 @@
"use client";
import { useState } from "react";
import { User, X, ChevronDown, Clock, Calendar } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { User, X, Clock, SlidersHorizontal } from "lucide-react";
import {
pillBase,
pillActive,
pillInactive,
TIME_PRESETS,
isTimeFilterActive,
TIME_FILTER_EMPTY,
type StatusTag,
type TimeFilter,
} from "./constants";
@@ -14,12 +17,12 @@ import {
interface ScheduleFiltersProps {
typeDots: Record<string, string>;
types: string[];
hasAnySlots: boolean;
hasAnyRecruiting: boolean;
availableStatuses: string[];
levels: string[];
filterTypes: Set<string>;
toggleFilterType: (type: string) => void;
filterTrainer: string | null;
filterTrainerSet: Set<string>;
toggleFilterTrainer: (trainer: string) => void;
filterStatusSet: Set<StatusTag>;
toggleFilterStatus: (status: StatusTag) => void;
filterLevel: string | null;
@@ -29,19 +32,23 @@ interface ScheduleFiltersProps {
availableDays: { day: string; dayShort: string }[];
filterDaySet: Set<string>;
toggleDay: (day: string) => void;
trainerNames: string[];
scheduleConfig?: { levels: { value: string; description: string }[]; statuses: { key: string; label: string; description: string }[] };
hasActiveFilter: boolean;
clearFilters: () => void;
}
const divider = <span className="mx-0.5 h-3.5 w-px shrink-0 bg-white/[0.06]" />;
export function ScheduleFilters({
typeDots,
types,
hasAnySlots,
hasAnyRecruiting,
availableStatuses,
levels,
filterTypes,
toggleFilterType,
filterTrainer,
filterTrainerSet,
toggleFilterTrainer,
filterStatusSet,
toggleFilterStatus,
filterLevel,
@@ -51,127 +58,392 @@ export function ScheduleFilters({
availableDays,
filterDaySet,
toggleDay,
trainerNames,
scheduleConfig,
hasActiveFilter,
clearFilters,
}: ScheduleFiltersProps) {
const [showWhen, setShowWhen] = useState(false);
const hasTimeFilter = filterDaySet.size > 0 || filterTime !== "all";
const [modalOpen, setModalOpen] = useState(false);
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 (
<>
{/* Single row: type + status + when + active trainer indicator + clear */}
<div className="mt-5 hidden sm:flex items-center justify-center gap-1.5 flex-wrap">
{/* Class types */}
{types.map((type) => (
<button
key={type}
onClick={() => toggleFilterType(type)}
className={`${pillBase} ${filterTypes.has(type) ? pillActive : pillInactive}`}
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[type] ?? "bg-white/30"}`} />
{type}
</button>
))}
{/* 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}
{/* Filter button — same style as По дням / По группам buttons */}
<button
onClick={() => setModalOpen(true)}
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
totalActive > 0
? "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"
}`}
>
<SlidersHorizontal size={13} />
{totalActive > 0 && (
<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}
</span>
)}
</button>
{/* Clear */}
{hasActiveFilter && (
<button
onClick={clearFilters}
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"
{/* Filter modal — Airbnb style */}
{modalOpen && createPortal(
<div
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
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} />
</button>
)}
</div>
{/* Header */}
<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>
<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 */}
{showWhen && (
<div className="mt-2 hidden sm:flex items-center justify-center gap-1.5 flex-wrap">
<Calendar size={11} className="text-neutral-400 dark:text-white/25" />
{availableDays.map(({ day, dayShort }) => (
<button
key={day}
onClick={() => toggleDay(day)}
className={`${pillBase} ${filterDaySet.has(day) ? pillActive : pillInactive}`}
>
{dayShort}
</button>
))}
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto p-6 space-y-7">
{/* Class types — gold border, white text; gold bg when active */}
<FilterSection title="Направления">
<div className="flex flex-wrap gap-2">
{types.map((type) => (
<button
key={type}
onClick={() => toggleFilterType(type)}
className={`${pillBase} ${
filterTypes.has(type)
? "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) => (
<button
key={preset.value}
onClick={() => setFilterTime(filterTime === preset.value ? "all" : preset.value)}
className={`${pillBase} ${filterTime === preset.value ? pillActive : pillInactive}`}
>
{preset.label}
</button>
))}
</div>
{/* Status — gold tags */}
{availableStatuses.length > 0 && (
<FilterSection title="Статус">
<div className="flex flex-wrap gap-2">
{availableStatuses.map((statusKey) => {
const cfg = scheduleConfig?.statuses?.find((s) => s.key === statusKey);
const label = cfg?.label || statusKey;
const desc = cfg?.description;
const active = filterStatusSet.has(statusKey);
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;
}
export type StatusTag = "hasSlots" | "recruiting";
export type StatusTag = string;
/** @deprecated Use Set<StatusTag> instead */
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] }[] = [
{ value: "morning", label: "Утро", range: [0, 12 * 60] },
{ value: "afternoon", label: "День", range: [12 * 60, 18 * 60] },
{ value: "evening", label: "Вечер", range: [18 * 60, 24 * 60] },
export const TIME_FILTER_EMPTY: TimeFilter = { from: "", to: "" };
export function isTimeFilterActive(t: TimeFilter): boolean {
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 */
@@ -89,6 +98,7 @@ export interface ScheduleClassWithLocation {
level?: string;
hasSlots?: boolean;
recruiting?: boolean;
status?: string;
groupId?: string;
locationName?: string;
locationAddress?: string;