diff --git a/public/fonts/inter-cyrillic.woff2 b/public/fonts/inter-cyrillic.woff2 new file mode 100644 index 0000000..d750914 Binary files /dev/null and b/public/fonts/inter-cyrillic.woff2 differ diff --git a/public/fonts/inter-latin.woff2 b/public/fonts/inter-latin.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/public/fonts/inter-latin.woff2 differ diff --git a/public/fonts/oswald-cyrillic.woff2 b/public/fonts/oswald-cyrillic.woff2 new file mode 100644 index 0000000..c35f37a Binary files /dev/null and b/public/fonts/oswald-cyrillic.woff2 differ diff --git a/public/fonts/oswald-latin.woff2 b/public/fonts/oswald-latin.woff2 new file mode 100644 index 0000000..7b2a550 Binary files /dev/null and b/public/fonts/oswald-latin.woff2 differ diff --git a/src/app/admin/_components/FormField.tsx b/src/app/admin/_components/FormField.tsx index 7178b7e..37b6f4a 100644 --- a/src/app/admin/_components/FormField.tsx +++ b/src/app/admin/_components/FormField.tsx @@ -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 (
- {label && } - + {label && ( + + )} + {showSearch ? ( + { 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`} + /> + ) : ( + + )} {open && (
- {options.length > 3 && ( -
- 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" - /> -
- )}
{filtered.length === 0 && (
Ничего не найдено
)} - {filtered.map((opt) => ( + {filtered.map((opt, idx) => (
@@ -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} /> )}
@@ -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(() => ({ + levels: cfg.levels ?? DEFAULT_CONFIG.levels, + statuses: cfg.statuses ?? DEFAULT_CONFIG.statuses, + }), [cfg]); + + useEffect(() => { onSync(normalized); }, [normalized, onSync]); + + return ( +
+ {/* Levels — collapsible + drag-and-drop via ArrayEditor */} + updateCfg({ ...normalized, levels })} + createItem={() => ({ value: "", description: "" })} + label="Уровни опыта" + addLabel="Добавить уровень" + collapsible + getItemTitle={(item) => item.value || "Новый уровень"} + renderItem={(item, _i, update) => ( +
+ update({ ...item, value: v })} + placeholder="Название уровня" + /> + update({ ...item, description: v })} + placeholder="Описание (для подсказки)" + /> +
+ )} + /> + + {/* Statuses — collapsible + drag-and-drop */} + updateCfg({ ...normalized, statuses })} + createItem={() => ({ key: `status_${Date.now()}`, label: "", description: "" })} + label="Статусы групп" + addLabel="Добавить статус" + collapsible + getItemTitle={(item) => item.label || "Новый статус"} + renderItem={(item, _i, update) => ( +
+ update({ ...item, key: v.replace(/[^a-zA-Z0-9_]/g, "") })} + placeholder="например: intensive" + /> + update({ ...item, label: v })} + placeholder="Название статуса" + /> + update({ ...item, description: v })} + placeholder="Описание (для подсказки)" + /> +
+ )} + /> +
+ ); +} + export default function ScheduleEditorPage() { const [activeLocation, setActiveLocation] = useState(0); const [trainers, setTrainers] = useState([]); const [addresses, setAddresses] = useState([]); const [classTypes, setClassTypes] = useState([]); + const [config, setConfig] = useState(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 ( + <> 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(". ") + "."} /> )} ); }} + +
+ sectionKey="scheduleConfig" title="Настройки фильтров"> + {(cfg, updateCfg) => ( + + )} + +
+ ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e69c1bf..00f7903 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 { diff --git a/src/app/page.tsx b/src/app/page.tsx index 680d88b..fea595c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -45,7 +45,7 @@ export default function HomePage() { {openDayData && } - + diff --git a/src/components/sections/Schedule.tsx b/src/components/sections/Schedule.tsx index b3ea41f..68476e7 100644 --- a/src/components/sections/Schedule.tsx +++ b/src/components/sections/Schedule.tsx @@ -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; filterTypes: Set; filterStatusSet: Set; 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(); const levelSet = new Set(); - let slots = false; - let recruiting = false; + const trainerSet = new Set(); + const statusSet = new Set(); 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
- {/* View mode toggle */} + {/* View mode toggle + filter button */} -
-
+
+
+ + {/* Divider */} + + +
- - {/* Compact filters — desktop only */} - - -
{viewMode === "days" ? ( @@ -384,8 +399,8 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr filteredDays={filteredDays} filterTypes={filterTypes} toggleFilterType={toggleFilterTypeFromCard} - filterTrainer={filterTrainer} - setFilterTrainer={setFilterTrainerFromCard} + filterTrainerSet={filterTrainerSet} + toggleFilterTrainer={toggleFilterTrainerFromCard} hasActiveFilter={hasActiveFilter} clearFilters={clearFilters} showLocation={isAllMode} @@ -403,7 +418,7 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr key={day.day} className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""} > - +
))} @@ -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} diff --git a/src/components/sections/schedule/DayCard.tsx b/src/components/sections/schedule/DayCard.tsx index d228bbb..b1155c9 100644 --- a/src/components/sections/schedule/DayCard.tsx +++ b/src/components/sections/schedule/DayCard.tsx @@ -6,8 +6,8 @@ interface DayCardProps { day: ScheduleDayMerged; typeDots: Record; showLocation?: boolean; - filterTrainer: string | null; - setFilterTrainer: (trainer: string | null) => void; + filterTrainerSet: Set; + toggleFilterTrainer: (trainer: string | null) => void; filterTypes: Set; toggleFilterType: (type: string) => void; } @@ -15,15 +15,15 @@ interface DayCardProps { function ClassRow({ cls, typeDots, - filterTrainer, - setFilterTrainer, + filterTrainerSet, + toggleFilterTrainer, filterTypes, toggleFilterType, }: { cls: ScheduleClassWithLocation; typeDots: Record; - filterTrainer: string | null; - setFilterTrainer: (trainer: string | null) => void; + filterTrainerSet: Set; + toggleFilterTrainer: (trainer: string | null) => void; filterTypes: Set; toggleFilterType: (type: string) => void; }) { @@ -46,7 +46,7 @@ function ClassRow({ )} {/* Name — clicks to filter */} - ))} - - {/* Divider */} - - - {/* Status filters */} - {hasAnySlots && ( - - )} - {hasAnyRecruiting && ( - - )} - - {/* Level filters */} - {levels.length > 0 && ( - <> - - {levels.map((level) => ( - - ))} - - )} - - {/* Divider */} - - - {/* When dropdown toggle */} - - - {/* Active trainer indicator (set by clicking trainer in cards) */} - {filterTrainer && ( - - - {filterTrainer} + {/* Filter button — same style as По дням / По группам buttons */} + - {/* Clear */} - {hasActiveFilter && ( - - )} - + {/* Header */} +
+

Фильтры

+ +
- {/* When panel — expandable: days + time presets */} - {showWhen && ( -
- - {availableDays.map(({ day, dayShort }) => ( - - ))} + {/* Scrollable content */} +
+ {/* Class types — gold border, white text; gold bg when active */} + +
+ {types.map((type) => ( + + ))} +
+
- + {/* Trainer — search multi-select */} + + + - {TIME_PRESETS.map((preset) => ( - - ))} -
+ {/* Status — gold tags */} + {availableStatuses.length > 0 && ( + +
+ {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 ( + + + {desc && ( + + {desc} + + )} + + ); + })} +
+
+ )} + + {/* Level — gold tags with hover hints */} + {levels.length > 0 && ( + +
+ {levels.map((level) => { + const desc = scheduleConfig?.levels?.find((l) => l.value === level)?.description; + const active = filterLevel === level; + return ( + + + {desc && ( + + {desc} + + )} + + ); + })} +
+
+ )} + + {/* Days — calendar grid */} + +
+ {availableDays.map(({ day, dayShort }) => ( + + ))} +
+
+ + {/* Time — from/to inputs + preset shortcuts */} + +
+
+ + 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]" + /> +
+ +
+ + 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]" + /> +
+
+
+ {TIME_PRESETS.map((preset) => { + const isActive = filterTime.from === preset.from && filterTime.to === preset.to; + return ( + + ); + })} +
+
+
+ + {/* Footer */} +
+ + +
+ + , + document.body )} ); } + +function FilterSection({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) { + const [showHint, setShowHint] = useState(false); + const hintRef = useRef(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 ( +
+
+

{title}

+ {hint && ( +
+ + {showHint && ( +
+ {hint} +
+ )} +
+ )} +
+ {children} +
+ ); +} + +function HintBubble({ text }: { text: string }) { + return ( + + + ? + + + {text} + + + ); +} + +function TrainerMultiSelect({ + trainers, + selected, + onToggle, +}: { + trainers: string[]; + selected: Set; + onToggle: (trainer: string) => void; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const containerRef = useRef(null); + const inputRef = useRef(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 ( +
+
{ 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) => ( + + {t} + + + ))} + { 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" + /> +
+ + {open && filtered.length > 0 && ( +
+
+ {filtered.map((trainer) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/sections/schedule/constants.ts b/src/components/sections/schedule/constants.ts index 67cb260..5a88718 100644 --- a/src/components/sections/schedule/constants.ts +++ b/src/components/sections/schedule/constants.ts @@ -62,15 +62,24 @@ export function buildTypeDots( return map; } -export type StatusTag = "hasSlots" | "recruiting"; +export type StatusTag = string; /** @deprecated Use Set 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; diff --git a/src/data/content.ts b/src/data/content.ts index 39e1f32..ed07cc5 100644 --- a/src/data/content.ts +++ b/src/data/content.ts @@ -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: [], diff --git a/src/lib/db.ts b/src/lib/db.ts index 7feb0a6..a130c07 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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), diff --git a/src/types/content.ts b/src/types/content.ts index 5129b8a..073df5a 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -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[];