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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:MM–HH: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;
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user