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:
2026-03-19 14:17:24 +03:00
parent e63b902081
commit b1adbbfe3d
9 changed files with 167 additions and 62 deletions

View File

@@ -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>

View File

@@ -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)}
/>

View File

@@ -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 }}