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.
+35 -20
View File
@@ -159,6 +159,7 @@ interface SelectFieldProps {
onChange: (value: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
hint?: string;
}
export function SelectField({
@@ -167,6 +168,7 @@ export function SelectField({
onChange,
options,
placeholder,
hint,
}: SelectFieldProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -181,6 +183,8 @@ export function SelectField({
})
: options;
const showSearch = options.length > 3;
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
@@ -195,43 +199,54 @@ export function SelectField({
return (
<div ref={containerRef} className="relative">
{label && <label className="block text-sm text-neutral-400 mb-1.5">{label}</label>}
{label && (
<label className="flex items-center gap-1.5 text-sm text-neutral-400 mb-1.5">
{label}
{hint && (
<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-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}
</span>
</span>
)}
</label>
)}
{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);
setSearch("");
setTimeout(() => inputRef.current?.focus(), 0);
}}
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 && (
<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">
{filtered.length === 0 && (
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
)}
{filtered.map((opt) => (
{filtered.map((opt, idx) => (
<button
key={opt.value}
key={opt.value || `opt-${idx}`}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange(opt.value);
setOpen(false);
+159 -16
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { SectionEditor } from "../_components/SectionEditor";
import { InputField, SelectField, TimeRangeField } from "../_components/FormField";
import { ArrayEditor } from "../_components/ArrayEditor";
import { Plus, X, Trash2 } from "lucide-react";
import { adminFetch } from "@/lib/csrf";
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])
);
const LEVELS = [
{ value: "", label: "Без уровня" },
{ value: "Начинающий/Без опыта", label: "Начинающий/Без опыта" },
{ value: "Продвинутый", label: "Продвинутый" },
];
interface ScheduleConfig {
levels: { value: string; description: string }[];
statuses: { key: string; label: string; description: string }[];
}
const STATUS_OPTIONS = [
{ value: "", label: "Без статуса" },
{ value: "hasSlots", label: "Есть места" },
{ value: "recruiting", label: "Набор открыт" },
{ value: "both", label: "Есть места + Набор" },
const DEFAULT_CONFIG: ScheduleConfig = {
levels: [
{ value: "Начинающий/Без опыта", description: "Для тех, кто только начинает" },
{ value: "Продвинутый", description: "Для учеников с опытом от 6 месяцев" },
],
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 = [
"bg-rose-500/80 border-rose-400",
@@ -238,6 +257,10 @@ function ClassModal({
onClose,
allDays,
currentDay,
levelOptions,
levelHint,
statusOptions,
statusHint,
}: {
cls: ScheduleClass;
trainers: string[];
@@ -247,6 +270,10 @@ function ClassModal({
onClose: () => void;
allDays: ScheduleDay[];
currentDay: string;
levelOptions: { value: string; label: string }[];
levelHint: string;
statusOptions: { value: string; label: string }[];
statusHint: string;
}) {
const [draft, setDraft] = useState<ScheduleClass>(cls);
const trainerOptions = trainers.map((t) => ({ value: t, label: t }));
@@ -479,20 +506,23 @@ function ClassModal({
placeholder="Выберите тип"
/>
<SelectField
label="Уровень"
label="Опыт"
value={draft.level || ""}
onChange={(v) => setDraft({ ...draft, level: v || undefined })}
options={LEVELS}
options={levelOptions}
hint={levelHint}
/>
<SelectField
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({
...draft,
hasSlots: v === "hasSlots" || v === "both",
recruiting: v === "recruiting" || v === "both",
status: v || undefined,
hasSlots: v === "hasSlots",
recruiting: v === "recruiting",
})}
options={STATUS_OPTIONS}
options={statusOptions}
hint={statusHint}
/>
</div>
@@ -559,12 +589,20 @@ function CalendarGrid({
addresses,
classTypes,
onChange,
levelOptions,
levelHint,
statusOptions,
statusHint,
}: {
location: ScheduleLocation;
trainers: string[];
addresses: string[];
classTypes: string[];
onChange: (loc: ScheduleLocation) => void;
levelOptions: { value: string; label: string }[];
levelHint: string;
statusOptions: { value: string; label: string }[];
statusHint: string;
}) {
const [editingClass, setEditingClass] = useState<{
dayIndex: number;
@@ -1084,6 +1122,10 @@ function CalendarGrid({
onChange({ ...location, days: updatedDays });
}}
onClose={() => setEditingClass(null)}
levelOptions={levelOptions}
levelHint={levelHint}
statusOptions={statusOptions}
statusHint={statusHint}
/>
)}
@@ -1105,6 +1147,10 @@ function CalendarGrid({
onChange({ ...location, days: updatedDays });
}}
onClose={() => setNewClass(null)}
levelOptions={levelOptions}
levelHint={levelHint}
statusOptions={statusOptions}
statusHint={statusHint}
/>
)}
</div>
@@ -1112,11 +1158,85 @@ function CalendarGrid({
}
// ---------- 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() {
const [activeLocation, setActiveLocation] = useState(0);
const [trainers, setTrainers] = useState<string[]>([]);
const [addresses, setAddresses] = useState<string[]>([]);
const [classTypes, setClassTypes] = useState<string[]>([]);
const [config, setConfig] = useState<ScheduleConfig>(DEFAULT_CONFIG);
useEffect(() => {
adminFetch("/api/admin/team")
@@ -1139,9 +1259,19 @@ export default function ScheduleEditorPage() {
setClassTypes((classes.items ?? []).map((c) => c.name));
})
.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 (
<>
<SectionEditor<ScheduleData> sectionKey="schedule" title="Расписание">
{(data, update) => {
const location = data.locations[activeLocation];
@@ -1222,11 +1352,24 @@ export default function ScheduleEditorPage() {
addresses={addresses}
classTypes={classTypes}
onChange={updateLocation}
levelOptions={levelOptions}
levelHint={levelHint}
statusOptions={statusOptions}
statusHint={(config.statuses ?? DEFAULT_CONFIG.statuses).map((s) => `${s.label}${s.description.toLowerCase()}`).join(". ") + "."}
/>
)}
</>
);
}}
</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 { Inter, Oswald } from "next/font/google";
import localFont from "next/font/local";
import { getContent } from "@/lib/content";
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",
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",
subsets: ["latin", "cyrillic"],
display: "swap",
});
export function generateMetadata(): Metadata {
+1 -1
View File
@@ -45,7 +45,7 @@ export default function HomePage() {
<Classes data={content.classes} />
<Team data={content.team} schedule={content.schedule.locations} />
{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} />
<MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} />
<News data={content.news} />
+61 -46
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,21 +358,19 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
<Users size={13} />
По группам
</button>
</div>
</div>
</Reveal>
{/* Compact filters — desktop only */}
<Reveal>
{/* Divider */}
<span className="mx-1 h-5 w-px bg-white/[0.08]" />
<ScheduleFilters
typeDots={typeDots}
types={types}
hasAnySlots={hasAnySlots}
hasAnyRecruiting={hasAnyRecruiting}
availableStatuses={availableStatuses}
levels={levels}
filterTypes={filterTypes}
toggleFilterType={toggleFilterType}
filterTrainer={filterTrainer}
filterTrainerSet={filterTrainerSet}
toggleFilterTrainer={toggleFilterTrainer}
filterStatusSet={filterStatusSet}
toggleFilterStatus={toggleFilterStatus}
filterLevel={filterLevel}
@@ -371,7 +382,11 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
toggleDay={toggleDay}
hasActiveFilter={hasActiveFilter}
clearFilters={clearFilters}
trainerNames={trainerNames}
scheduleConfig={scheduleConfig}
/>
</div>
</div>
</Reveal>
</div>
@@ -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 */}
{/* 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>
{/* 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()}
>
{/* 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>
{/* 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) ? pillActive : pillInactive}`}
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>
{/* Divider */}
<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>
{/* Status filters */}
{hasAnySlots && (
{/* 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("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}`}
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"
}`}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
Есть места
{label}
</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}
{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>
)}
{/* 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"
>
<X size={11} />
</button>
)}
</span>
);
})}
</div>
</FilterSection>
)}
{/* 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" />
{/* 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={`${pillBase} ${filterDaySet.has(day) ? pillActive : pillInactive}`}
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>
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
{TIME_PRESETS.map((preset) => (
{/* 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.value}
onClick={() => setFilterTime(filterTime === preset.value ? "all" : preset.value)}
className={`${pillBase} ${filterTime === preset.value ? pillActive : pillInactive}`}
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;
+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: {
title: "Новости",
items: [],
+11
View File
@@ -520,6 +520,7 @@ const SECTION_KEYS = [
"news",
"contact",
"popups",
"scheduleConfig",
] as const;
export function getSiteContent(): SiteContent | null {
@@ -557,6 +558,16 @@ export function getSiteContent(): SiteContent | null {
instagramHint: "По вопросам пишите в Instagram",
},
contact: sections.contact,
scheduleConfig: sections.scheduleConfig ?? {
levels: [
{ value: "Начинающий/Без опыта", description: "Для тех, кто только начинает" },
{ value: "Продвинутый", description: "Для учеников с опытом от 6 месяцев" },
],
statuses: [
{ key: "hasSlots", label: "Есть места", description: "В группе есть свободные места" },
{ key: "recruiting", label: "Набор открыт", description: "Идёт набор в новую группу" },
],
},
team: {
title: teamSection.title || "",
members: members.map(({ id, ...rest }) => rest),
+5
View File
@@ -44,6 +44,7 @@ export interface ScheduleClass {
level?: string;
hasSlots?: boolean;
recruiting?: boolean;
status?: string;
groupId?: string;
}
@@ -150,6 +151,10 @@ export interface SiteContent {
title: string;
locations: ScheduleLocation[];
};
scheduleConfig: {
levels: { value: string; description: string }[];
statuses: { key: string; label: string; description: string }[];
};
news: {
title: string;
items: NewsItem[];