fix: MEDIUM priority — shared validation, content caching, Schedule useReducer, stable keys
- Extract shared sanitization to src/lib/validation.ts, apply to all 3 registration routes (#2) - Replace key={index} with stable keys in About and News (#4) - Add 5-min in-memory content cache in content.ts, invalidate on admin section save (#6) - Refactor Schedule from 8 useState calls to useReducer — single dispatch, fewer re-renders (#8) - Remove Hero scroll indicator, add auto-scroll to next section on wheel/swipe Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,8 +30,8 @@ export function About({ data: about, stats }: AboutProps) {
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-14 mx-auto max-w-2xl space-y-8 text-center">
|
||||
{about.paragraphs.map((text, i) => (
|
||||
<Reveal key={i}>
|
||||
{about.paragraphs.map((text) => (
|
||||
<Reveal key={text}>
|
||||
<p className="text-xl leading-relaxed text-neutral-600 dark:text-neutral-300 sm:text-2xl">
|
||||
{text}
|
||||
</p>
|
||||
|
||||
@@ -132,9 +132,9 @@ export function News({ data }: NewsProps) {
|
||||
{rest.length > 0 && (
|
||||
<Reveal>
|
||||
<div className="rounded-2xl bg-neutral-50/80 px-5 sm:px-6 dark:bg-white/[0.02]">
|
||||
{rest.map((item, i) => (
|
||||
{rest.map((item) => (
|
||||
<CompactArticle
|
||||
key={i}
|
||||
key={item.title}
|
||||
item={item}
|
||||
onClick={() => setSelected(item)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useReducer, useMemo, useCallback } from "react";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
import { CalendarDays, Users, LayoutGrid } from "lucide-react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
@@ -16,20 +16,74 @@ import type { SiteContent } from "@/types/content";
|
||||
type ViewMode = "days" | "groups";
|
||||
type LocationMode = "all" | number;
|
||||
|
||||
interface ScheduleState {
|
||||
locationMode: LocationMode;
|
||||
viewMode: ViewMode;
|
||||
filterTrainer: string | null;
|
||||
filterType: string | null;
|
||||
filterStatus: StatusFilter;
|
||||
filterTime: TimeFilter;
|
||||
filterDaySet: Set<string>;
|
||||
bookingGroup: string | null;
|
||||
}
|
||||
|
||||
type ScheduleAction =
|
||||
| { type: "SET_LOCATION"; mode: LocationMode }
|
||||
| { type: "SET_VIEW"; mode: ViewMode }
|
||||
| { type: "SET_TRAINER"; value: string | null }
|
||||
| { type: "SET_TYPE"; value: string | null }
|
||||
| { type: "SET_STATUS"; value: StatusFilter }
|
||||
| { type: "SET_TIME"; value: TimeFilter }
|
||||
| { type: "TOGGLE_DAY"; day: string }
|
||||
| { type: "SET_BOOKING"; value: string | null }
|
||||
| { type: "CLEAR_FILTERS" };
|
||||
|
||||
const initialState: ScheduleState = {
|
||||
locationMode: "all",
|
||||
viewMode: "days",
|
||||
filterTrainer: null,
|
||||
filterType: null,
|
||||
filterStatus: "all",
|
||||
filterTime: "all",
|
||||
filterDaySet: new Set(),
|
||||
bookingGroup: null,
|
||||
};
|
||||
|
||||
function scheduleReducer(state: ScheduleState, action: ScheduleAction): ScheduleState {
|
||||
switch (action.type) {
|
||||
case "SET_LOCATION":
|
||||
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 "SET_TYPE":
|
||||
return { ...state, filterType: action.value };
|
||||
case "SET_STATUS":
|
||||
return { ...state, filterStatus: action.value };
|
||||
case "SET_TIME":
|
||||
return { ...state, filterTime: action.value };
|
||||
case "TOGGLE_DAY": {
|
||||
const next = new Set(state.filterDaySet);
|
||||
if (next.has(action.day)) next.delete(action.day);
|
||||
else next.add(action.day);
|
||||
return { ...state, filterDaySet: next };
|
||||
}
|
||||
case "SET_BOOKING":
|
||||
return { ...state, bookingGroup: action.value };
|
||||
case "CLEAR_FILTERS":
|
||||
return { ...state, filterTrainer: null, filterType: null, filterStatus: "all", filterTime: "all", filterDaySet: new Set() };
|
||||
}
|
||||
}
|
||||
|
||||
interface ScheduleProps {
|
||||
data: SiteContent["schedule"];
|
||||
classItems?: { name: string; color?: string }[];
|
||||
}
|
||||
|
||||
export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
||||
const [locationMode, setLocationMode] = useState<LocationMode>("all");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("days");
|
||||
const [filterTrainer, setFilterTrainer] = useState<string | null>(null);
|
||||
const [filterType, setFilterType] = useState<string | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState<StatusFilter>("all");
|
||||
const [filterTime, setFilterTime] = useState<TimeFilter>("all");
|
||||
const [filterDaySet, setFilterDaySet] = useState<Set<string>>(new Set());
|
||||
const [bookingGroup, setBookingGroup] = useState<string | null>(null);
|
||||
const [state, dispatch] = useReducer(scheduleReducer, initialState);
|
||||
const { locationMode, viewMode, filterTrainer, filterType, filterStatus, filterTime, filterDaySet, bookingGroup } = state;
|
||||
|
||||
const isAllMode = locationMode === "all";
|
||||
|
||||
@@ -38,13 +92,18 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, []);
|
||||
|
||||
const setFilterTrainer = useCallback((value: string | null) => dispatch({ type: "SET_TRAINER", value }), []);
|
||||
const setFilterType = useCallback((value: string | null) => dispatch({ type: "SET_TYPE", value }), []);
|
||||
const setFilterStatus = useCallback((value: StatusFilter) => dispatch({ type: "SET_STATUS", value }), []);
|
||||
const setFilterTime = useCallback((value: TimeFilter) => dispatch({ type: "SET_TIME", value }), []);
|
||||
|
||||
const setFilterTrainerFromCard = useCallback((trainer: string | null) => {
|
||||
setFilterTrainer(trainer);
|
||||
dispatch({ type: "SET_TRAINER", value: trainer });
|
||||
if (trainer) scrollToSchedule();
|
||||
}, [scrollToSchedule]);
|
||||
|
||||
const setFilterTypeFromCard = useCallback((type: string | null) => {
|
||||
setFilterType(type);
|
||||
dispatch({ type: "SET_TYPE", value: type });
|
||||
if (type) scrollToSchedule();
|
||||
}, [scrollToSchedule]);
|
||||
|
||||
@@ -146,11 +205,7 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
||||
const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all" || filterTime !== "all" || filterDaySet.size > 0);
|
||||
|
||||
function clearFilters() {
|
||||
setFilterTrainer(null);
|
||||
setFilterType(null);
|
||||
setFilterStatus("all");
|
||||
setFilterTime("all");
|
||||
setFilterDaySet(new Set());
|
||||
dispatch({ type: "CLEAR_FILTERS" });
|
||||
}
|
||||
|
||||
// Available days for the day filter
|
||||
@@ -160,17 +215,11 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
||||
);
|
||||
|
||||
function toggleDay(day: string) {
|
||||
setFilterDaySet((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(day)) next.delete(day);
|
||||
else next.add(day);
|
||||
return next;
|
||||
});
|
||||
dispatch({ type: "TOGGLE_DAY", day });
|
||||
}
|
||||
|
||||
function switchLocation(mode: LocationMode) {
|
||||
setLocationMode(mode);
|
||||
clearFilters();
|
||||
dispatch({ type: "SET_LOCATION", mode });
|
||||
}
|
||||
|
||||
const activeTabClass = "bg-gold text-black shadow-[0_0_20px_rgba(201,169,110,0.3)]";
|
||||
@@ -232,7 +281,7 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
||||
<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]">
|
||||
<button
|
||||
onClick={() => setViewMode("days")}
|
||||
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 ${
|
||||
viewMode === "days"
|
||||
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
|
||||
@@ -243,7 +292,7 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
||||
По дням
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("groups")}
|
||||
onClick={() => dispatch({ type: "SET_VIEW", mode: "groups" })}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200 cursor-pointer ${
|
||||
viewMode === "groups"
|
||||
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
|
||||
@@ -331,13 +380,13 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
||||
filterTrainer={filterTrainer}
|
||||
setFilterTrainer={setFilterTrainerFromCard}
|
||||
showLocation={isAllMode}
|
||||
onBook={setBookingGroup}
|
||||
onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })}
|
||||
/>
|
||||
</Reveal>
|
||||
)}
|
||||
<SignupModal
|
||||
open={bookingGroup !== null}
|
||||
onClose={() => setBookingGroup(null)}
|
||||
onClose={() => dispatch({ type: "SET_BOOKING", value: null })}
|
||||
subtitle={bookingGroup ?? undefined}
|
||||
endpoint="/api/group-booking"
|
||||
extraBody={{ groupInfo: bookingGroup }}
|
||||
|
||||
Reference in New Issue
Block a user