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

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getSection, setSection, SECTION_KEYS } from "@/lib/db";
import { siteContent } from "@/data/content";
import { revalidatePath } from "next/cache";
import { invalidateContentCache } from "@/lib/content";
type Params = { params: Promise<{ key: string }> };
@@ -34,6 +35,7 @@ export async function PUT(request: NextRequest, { params }: Params) {
const data = await request.json();
setSection(key, data);
invalidateContentCache();
revalidatePath("/");
return NextResponse.json({ ok: true });

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { addGroupBooking } from "@/lib/db";
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
export async function POST(request: NextRequest) {
const ip = getClientIp(request);
@@ -15,17 +16,23 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const { name, phone, groupInfo, instagram, telegram } = body;
if (!name || typeof name !== "string" || !phone || typeof phone !== "string") {
return NextResponse.json({ error: "name and phone are required" }, { status: 400 });
const cleanName = sanitizeName(name);
if (!cleanName) {
return NextResponse.json({ error: "Имя обязательно" }, { status: 400 });
}
const cleanName = name.trim().slice(0, 100);
const cleanPhone = phone.trim().slice(0, 30);
const cleanGroup = typeof groupInfo === "string" ? groupInfo.trim().slice(0, 200) : undefined;
const cleanIg = typeof instagram === "string" ? instagram.trim().slice(0, 100) : undefined;
const cleanTg = typeof telegram === "string" ? telegram.trim().slice(0, 100) : undefined;
const cleanPhone = sanitizePhone(phone);
if (!cleanPhone) {
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
}
const id = addGroupBooking(cleanName, cleanPhone, cleanGroup, cleanIg, cleanTg);
const id = addGroupBooking(
cleanName,
cleanPhone,
sanitizeText(groupInfo),
sanitizeHandle(instagram),
sanitizeHandle(telegram)
);
return NextResponse.json({ ok: true, id });
} catch (err) {
console.error("[group-booking] POST error:", err);

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { addMcRegistration } from "@/lib/db";
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
export async function POST(request: Request) {
const ip = getClientIp(request);
@@ -15,25 +16,26 @@ export async function POST(request: Request) {
const body = await request.json();
const { masterClassTitle, name, phone, instagram, telegram } = body;
if (!masterClassTitle || typeof masterClassTitle !== "string" || masterClassTitle.length > 200) {
const cleanTitle = sanitizeText(masterClassTitle, 200);
if (!cleanTitle) {
return NextResponse.json({ error: "masterClassTitle is required" }, { status: 400 });
}
if (!name || typeof name !== "string" || !name.trim() || name.length > 100) {
return NextResponse.json({ error: "name is required (max 100 chars)" }, { status: 400 });
const cleanName = sanitizeName(name);
if (!cleanName) {
return NextResponse.json({ error: "Имя обязательно" }, { status: 400 });
}
if (!phone || typeof phone !== "string" || !phone.trim()) {
const cleanPhone = sanitizePhone(phone);
if (!cleanPhone) {
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
}
const cleanIg = instagram && typeof instagram === "string" ? instagram.trim().slice(0, 100) : "";
const cleanTg = telegram && typeof telegram === "string" ? telegram.trim().slice(0, 100) : undefined;
const cleanPhone = phone.trim().slice(0, 30);
const id = addMcRegistration(
masterClassTitle.trim().slice(0, 200),
name.trim().slice(0, 100),
cleanIg,
cleanTg,
cleanTitle,
cleanName,
sanitizeHandle(instagram) ?? "",
sanitizeHandle(telegram),
cleanPhone
);

View File

@@ -6,6 +6,7 @@ import {
getOpenDayEvent,
} from "@/lib/db";
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
import { sanitizeName, sanitizePhone, sanitizeHandle } from "@/lib/validation";
export async function POST(request: NextRequest) {
const ip = getClientIp(request);
@@ -20,17 +21,18 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const { classId, eventId, name, phone, instagram, telegram } = body;
if (!classId || !eventId || !name || !phone) {
return NextResponse.json({ error: "classId, eventId, name, phone are required" }, { status: 400 });
if (!classId || !eventId) {
return NextResponse.json({ error: "classId and eventId are required" }, { status: 400 });
}
const cleanName = (name as string).trim().slice(0, 100);
const cleanPhone = (phone as string).replace(/\D/g, "").slice(0, 15);
const cleanIg = instagram ? (instagram as string).trim().slice(0, 100) : undefined;
const cleanTg = telegram ? (telegram as string).trim().slice(0, 100) : undefined;
const cleanName = sanitizeName(name);
if (!cleanName) {
return NextResponse.json({ error: "Имя обязательно" }, { status: 400 });
}
const cleanPhone = sanitizePhone(phone);
if (!cleanPhone) {
return NextResponse.json({ error: "Invalid phone" }, { status: 400 });
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
}
// Check not already booked
@@ -41,8 +43,8 @@ export async function POST(request: NextRequest) {
const id = addOpenDayBooking(classId, eventId, {
name: cleanName,
phone: cleanPhone,
instagram: cleanIg,
telegram: cleanTg,
instagram: sanitizeHandle(instagram),
telegram: sanitizeHandle(telegram),
});
// Return total bookings for this person (for discount calculation)

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

View File

@@ -2,12 +2,28 @@ import { getSiteContent } from "@/lib/db";
import { siteContent as fallback } from "@/data/content";
import type { SiteContent } from "@/types/content";
let cached: { data: SiteContent; expiresAt: number } | null = null;
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export function getContent(): SiteContent {
const now = Date.now();
if (cached && now < cached.expiresAt) {
return cached.data;
}
try {
const content = getSiteContent();
if (content) return content;
if (content) {
cached = { data: content, expiresAt: now + CACHE_TTL };
return content;
}
return fallback;
} catch {
return fallback;
}
}
/** Invalidate the content cache (call after admin edits). */
export function invalidateContentCache() {
cached = null;
}

27
src/lib/validation.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* Shared input sanitization for public registration endpoints.
*/
export function sanitizeName(name: unknown): string | null {
if (!name || typeof name !== "string") return null;
const clean = name.trim().slice(0, 100);
return clean || null;
}
export function sanitizePhone(phone: unknown): string | null {
if (!phone || typeof phone !== "string") return null;
const clean = phone.replace(/\D/g, "").slice(0, 15);
return clean.length >= 9 ? clean : null;
}
export function sanitizeHandle(value: unknown): string | undefined {
if (!value || typeof value !== "string") return undefined;
const clean = value.trim().slice(0, 100);
return clean || undefined;
}
export function sanitizeText(value: unknown, maxLength: number = 200): string | undefined {
if (!value || typeof value !== "string") return undefined;
const clean = value.trim().slice(0, maxLength);
return clean || undefined;
}