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)