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:
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
27
src/lib/validation.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user