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:
@@ -9,7 +9,7 @@ import { DayCard } from "./schedule/DayCard";
|
||||
import { ScheduleFilters } from "./schedule/ScheduleFilters";
|
||||
import { MobileSchedule } from "./schedule/MobileSchedule";
|
||||
import { GroupView } from "./schedule/GroupView";
|
||||
import { buildTypeDots, shortAddress, startTimeMinutes, TIME_PRESETS } from "./schedule/constants";
|
||||
import { buildTypeDots, shortAddress, startTimeMinutes, TIME_FILTER_EMPTY, isTimeFilterActive } from "./schedule/constants";
|
||||
import type { StatusTag, TimeFilter, ScheduleDayMerged, ScheduleClassWithLocation } from "./schedule/constants";
|
||||
import type { SiteContent } from "@/types/content";
|
||||
|
||||
@@ -19,7 +19,7 @@ type LocationMode = "all" | number;
|
||||
interface ScheduleState {
|
||||
locationMode: LocationMode;
|
||||
viewMode: ViewMode;
|
||||
filterTrainer: string | null;
|
||||
filterTrainerSet: Set<string>;
|
||||
filterTypes: Set<string>;
|
||||
filterStatusSet: Set<StatusTag>;
|
||||
filterLevel: string | null;
|
||||
@@ -31,7 +31,7 @@ interface ScheduleState {
|
||||
type ScheduleAction =
|
||||
| { type: "SET_LOCATION"; mode: LocationMode }
|
||||
| { type: "SET_VIEW"; mode: ViewMode }
|
||||
| { type: "SET_TRAINER"; value: string | null }
|
||||
| { type: "TOGGLE_TRAINER"; value: string }
|
||||
| { type: "TOGGLE_TYPE"; value: string }
|
||||
| { type: "TOGGLE_STATUS"; value: StatusTag }
|
||||
| { type: "SET_LEVEL"; value: string | null }
|
||||
@@ -43,11 +43,11 @@ type ScheduleAction =
|
||||
const initialState: ScheduleState = {
|
||||
locationMode: "all",
|
||||
viewMode: "groups",
|
||||
filterTrainer: null,
|
||||
filterTrainerSet: new Set(),
|
||||
filterTypes: new Set(),
|
||||
filterStatusSet: new Set(),
|
||||
filterLevel: null,
|
||||
filterTime: "all",
|
||||
filterTime: TIME_FILTER_EMPTY,
|
||||
filterDaySet: new Set(),
|
||||
bookingGroup: null,
|
||||
};
|
||||
@@ -58,8 +58,12 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
|
||||
return { ...initialState, viewMode: state.viewMode, locationMode: action.mode };
|
||||
case "SET_VIEW":
|
||||
return { ...state, viewMode: action.mode };
|
||||
case "SET_TRAINER":
|
||||
return { ...state, filterTrainer: action.value };
|
||||
case "TOGGLE_TRAINER": {
|
||||
const next = new Set(state.filterTrainerSet);
|
||||
if (next.has(action.value)) next.delete(action.value);
|
||||
else next.add(action.value);
|
||||
return { ...state, filterTrainerSet: next };
|
||||
}
|
||||
case "TOGGLE_TYPE": {
|
||||
const next = new Set(state.filterTypes);
|
||||
if (next.has(action.value)) next.delete(action.value);
|
||||
@@ -85,19 +89,20 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
|
||||
case "SET_BOOKING":
|
||||
return { ...state, bookingGroup: action.value };
|
||||
case "CLEAR_FILTERS":
|
||||
return { ...state, filterTrainer: null, filterTypes: new Set(), filterStatusSet: new Set(), filterLevel: null, filterTime: "all", filterDaySet: new Set() };
|
||||
return { ...state, filterTrainerSet: new Set(), filterTypes: new Set(), filterStatusSet: new Set(), filterLevel: null, filterTime: TIME_FILTER_EMPTY, filterDaySet: new Set() };
|
||||
}
|
||||
}
|
||||
|
||||
interface ScheduleProps {
|
||||
data: SiteContent["schedule"];
|
||||
scheduleConfig?: SiteContent["scheduleConfig"];
|
||||
classItems?: { name: string; color?: string }[];
|
||||
teamMembers?: { name: string; image: string }[];
|
||||
}
|
||||
|
||||
export function Schedule({ data: schedule, classItems, teamMembers }: ScheduleProps) {
|
||||
export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembers }: ScheduleProps) {
|
||||
const [state, dispatch] = useReducer(scheduleReducer, initialState);
|
||||
const { locationMode, viewMode, filterTrainer, filterTypes, filterStatusSet, filterLevel, filterTime, filterDaySet, bookingGroup } = state;
|
||||
const { locationMode, viewMode, filterTrainerSet, filterTypes, filterStatusSet, filterLevel, filterTime, filterDaySet, bookingGroup } = state;
|
||||
|
||||
const isAllMode = locationMode === "all";
|
||||
|
||||
@@ -106,15 +111,17 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, []);
|
||||
|
||||
const setFilterTrainer = useCallback((value: string | null) => dispatch({ type: "SET_TRAINER", value }), []);
|
||||
const toggleFilterTrainer = useCallback((value: string) => dispatch({ type: "TOGGLE_TRAINER", value }), []);
|
||||
const toggleFilterType = useCallback((value: string) => dispatch({ type: "TOGGLE_TYPE", value }), []);
|
||||
const toggleFilterStatus = useCallback((value: StatusTag) => dispatch({ type: "TOGGLE_STATUS", value }), []);
|
||||
const setFilterLevel = useCallback((value: string | null) => dispatch({ type: "SET_LEVEL", value }), []);
|
||||
const setFilterTime = useCallback((value: TimeFilter) => dispatch({ type: "SET_TIME", value }), []);
|
||||
|
||||
const setFilterTrainerFromCard = useCallback((trainer: string | null) => {
|
||||
dispatch({ type: "SET_TRAINER", value: trainer });
|
||||
if (trainer) scrollToSchedule();
|
||||
const toggleFilterTrainerFromCard = useCallback((trainer: string | null) => {
|
||||
if (trainer) {
|
||||
dispatch({ type: "TOGGLE_TRAINER", value: trainer });
|
||||
scrollToSchedule();
|
||||
}
|
||||
}, [scrollToSchedule]);
|
||||
|
||||
const toggleFilterTypeFromCard = useCallback((type: string) => {
|
||||
@@ -176,34 +183,39 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
|
||||
.map((d) => dayMap.get(d)!);
|
||||
}, [locationMode, schedule.locations]);
|
||||
|
||||
const { types, hasAnySlots, hasAnyRecruiting, levels } = useMemo(() => {
|
||||
const { types, availableStatuses, levels, trainerNames } = useMemo(() => {
|
||||
const typeSet = new Set<string>();
|
||||
const levelSet = new Set<string>();
|
||||
let slots = false;
|
||||
let recruiting = false;
|
||||
const trainerSet = new Set<string>();
|
||||
const statusSet = new Set<string>();
|
||||
for (const day of activeDays) {
|
||||
for (const cls of day.classes) {
|
||||
typeSet.add(cls.type);
|
||||
if (cls.hasSlots) slots = true;
|
||||
if (cls.recruiting) recruiting = true;
|
||||
trainerSet.add(cls.trainer);
|
||||
if (cls.status) statusSet.add(cls.status);
|
||||
if (cls.hasSlots) statusSet.add("hasSlots");
|
||||
if (cls.recruiting) statusSet.add("recruiting");
|
||||
if (cls.level) levelSet.add(cls.level);
|
||||
}
|
||||
}
|
||||
return {
|
||||
types: Array.from(typeSet).sort(),
|
||||
hasAnySlots: slots,
|
||||
hasAnyRecruiting: recruiting,
|
||||
availableStatuses: Array.from(statusSet),
|
||||
levels: Array.from(levelSet).sort(),
|
||||
trainerNames: Array.from(trainerSet).sort(),
|
||||
};
|
||||
}, [activeDays]);
|
||||
|
||||
// Get the time range for the active time filter
|
||||
const activeTimeRange = filterTime !== "all"
|
||||
? TIME_PRESETS.find((p) => p.value === filterTime)?.range
|
||||
// Parse time range for filtering
|
||||
const activeTimeRange = isTimeFilterActive(filterTime)
|
||||
? [
|
||||
filterTime.from ? startTimeMinutes(filterTime.from) : 0,
|
||||
filterTime.to ? startTimeMinutes(filterTime.to) : 24 * 60,
|
||||
] as const
|
||||
: null;
|
||||
|
||||
const filteredDays: ScheduleDayMerged[] = useMemo(() => {
|
||||
const noFilter = !filterTrainer && filterTypes.size === 0 && filterStatusSet.size === 0 && !filterLevel && filterTime === "all" && filterDaySet.size === 0;
|
||||
const noFilter = filterTrainerSet.size === 0 && filterTypes.size === 0 && filterStatusSet.size === 0 && !filterLevel && !isTimeFilterActive(filterTime) && filterDaySet.size === 0;
|
||||
if (noFilter) return activeDays;
|
||||
|
||||
// First filter by day names if any selected
|
||||
@@ -216,11 +228,12 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
|
||||
...day,
|
||||
classes: day.classes.filter(
|
||||
(cls) =>
|
||||
(!filterTrainer || cls.trainer === filterTrainer) &&
|
||||
(filterTrainerSet.size === 0 || filterTrainerSet.has(cls.trainer)) &&
|
||||
(filterTypes.size === 0 || filterTypes.has(cls.type)) &&
|
||||
(filterStatusSet.size === 0 ||
|
||||
(filterStatusSet.has("hasSlots") && cls.hasSlots) ||
|
||||
(filterStatusSet.has("recruiting") && cls.recruiting)) &&
|
||||
(cls.status && filterStatusSet.has(cls.status as StatusTag)) ||
|
||||
(filterStatusSet.has("hasSlots" as StatusTag) && cls.hasSlots) ||
|
||||
(filterStatusSet.has("recruiting" as StatusTag) && cls.recruiting)) &&
|
||||
(!filterLevel || cls.level === filterLevel) &&
|
||||
(!activeTimeRange || (() => {
|
||||
const m = startTimeMinutes(cls.time);
|
||||
@@ -229,9 +242,9 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
|
||||
),
|
||||
}))
|
||||
.filter((day) => day.classes.length > 0);
|
||||
}, [activeDays, filterTrainer, filterTypes, filterStatusSet, filterLevel, filterTime, activeTimeRange, filterDaySet]);
|
||||
}, [activeDays, filterTrainerSet, filterTypes, filterStatusSet, filterLevel, filterTime, activeTimeRange, filterDaySet]);
|
||||
|
||||
const hasActiveFilter = !!(filterTrainer || filterTypes.size > 0 || filterStatusSet.size > 0 || filterLevel || filterTime !== "all" || filterDaySet.size > 0);
|
||||
const hasActiveFilter = !!(filterTrainerSet.size > 0 || filterTypes.size > 0 || filterStatusSet.size > 0 || filterLevel || isTimeFilterActive(filterTime) || filterDaySet.size > 0);
|
||||
|
||||
function clearFilters() {
|
||||
dispatch({ type: "CLEAR_FILTERS" });
|
||||
@@ -319,10 +332,10 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* View mode toggle */}
|
||||
{/* View mode toggle + filter button */}
|
||||
<Reveal>
|
||||
<div className="mt-4 flex justify-center">
|
||||
<div className="inline-flex rounded-xl border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.08] dark:bg-white/[0.04]">
|
||||
<div className="mt-4 hidden sm:flex items-center justify-center">
|
||||
<div className="inline-flex items-center rounded-xl border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.08] dark:bg-white/[0.04]">
|
||||
<button
|
||||
onClick={() => dispatch({ type: "SET_VIEW", mode: "days" })}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
|
||||
@@ -345,34 +358,36 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
|
||||
<Users size={13} />
|
||||
По группам
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<span className="mx-1 h-5 w-px bg-white/[0.08]" />
|
||||
|
||||
<ScheduleFilters
|
||||
typeDots={typeDots}
|
||||
types={types}
|
||||
availableStatuses={availableStatuses}
|
||||
levels={levels}
|
||||
filterTypes={filterTypes}
|
||||
toggleFilterType={toggleFilterType}
|
||||
filterTrainerSet={filterTrainerSet}
|
||||
toggleFilterTrainer={toggleFilterTrainer}
|
||||
filterStatusSet={filterStatusSet}
|
||||
toggleFilterStatus={toggleFilterStatus}
|
||||
filterLevel={filterLevel}
|
||||
setFilterLevel={setFilterLevel}
|
||||
filterTime={filterTime}
|
||||
setFilterTime={setFilterTime}
|
||||
availableDays={availableDays}
|
||||
filterDaySet={filterDaySet}
|
||||
toggleDay={toggleDay}
|
||||
hasActiveFilter={hasActiveFilter}
|
||||
clearFilters={clearFilters}
|
||||
trainerNames={trainerNames}
|
||||
scheduleConfig={scheduleConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Compact filters — desktop only */}
|
||||
<Reveal>
|
||||
<ScheduleFilters
|
||||
typeDots={typeDots}
|
||||
types={types}
|
||||
hasAnySlots={hasAnySlots}
|
||||
hasAnyRecruiting={hasAnyRecruiting}
|
||||
levels={levels}
|
||||
filterTypes={filterTypes}
|
||||
toggleFilterType={toggleFilterType}
|
||||
filterTrainer={filterTrainer}
|
||||
filterStatusSet={filterStatusSet}
|
||||
toggleFilterStatus={toggleFilterStatus}
|
||||
filterLevel={filterLevel}
|
||||
setFilterLevel={setFilterLevel}
|
||||
filterTime={filterTime}
|
||||
setFilterTime={setFilterTime}
|
||||
availableDays={availableDays}
|
||||
filterDaySet={filterDaySet}
|
||||
toggleDay={toggleDay}
|
||||
hasActiveFilter={hasActiveFilter}
|
||||
clearFilters={clearFilters}
|
||||
/>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
{viewMode === "days" ? (
|
||||
@@ -384,8 +399,8 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
|
||||
filteredDays={filteredDays}
|
||||
filterTypes={filterTypes}
|
||||
toggleFilterType={toggleFilterTypeFromCard}
|
||||
filterTrainer={filterTrainer}
|
||||
setFilterTrainer={setFilterTrainerFromCard}
|
||||
filterTrainerSet={filterTrainerSet}
|
||||
toggleFilterTrainer={toggleFilterTrainerFromCard}
|
||||
hasActiveFilter={hasActiveFilter}
|
||||
clearFilters={clearFilters}
|
||||
showLocation={isAllMode}
|
||||
@@ -403,7 +418,7 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
|
||||
key={day.day}
|
||||
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""}
|
||||
>
|
||||
<DayCard day={day} typeDots={typeDots} showLocation={isAllMode} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainerFromCard} filterTypes={filterTypes} toggleFilterType={toggleFilterTypeFromCard} />
|
||||
<DayCard day={day} typeDots={typeDots} showLocation={isAllMode} filterTrainerSet={filterTrainerSet} toggleFilterTrainer={toggleFilterTrainerFromCard} filterTypes={filterTypes} toggleFilterType={toggleFilterTypeFromCard} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -423,8 +438,8 @@ export function Schedule({ data: schedule, classItems, teamMembers }: SchedulePr
|
||||
filteredDays={filteredDays}
|
||||
filterTypes={filterTypes}
|
||||
toggleFilterType={toggleFilterTypeFromCard}
|
||||
filterTrainer={filterTrainer}
|
||||
setFilterTrainer={setFilterTrainerFromCard}
|
||||
filterTrainerSet={filterTrainerSet}
|
||||
toggleFilterTrainer={toggleFilterTrainerFromCard}
|
||||
showLocation={isAllMode}
|
||||
onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })}
|
||||
trainerPhotos={trainerPhotos}
|
||||
|
||||
Reference in New Issue
Block a user