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 { getSection, setSection, SECTION_KEYS } from "@/lib/db";
|
||||||
import { siteContent } from "@/data/content";
|
import { siteContent } from "@/data/content";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { invalidateContentCache } from "@/lib/content";
|
||||||
|
|
||||||
type Params = { params: Promise<{ key: string }> };
|
type Params = { params: Promise<{ key: string }> };
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ export async function PUT(request: NextRequest, { params }: Params) {
|
|||||||
|
|
||||||
const data = await request.json();
|
const data = await request.json();
|
||||||
setSection(key, data);
|
setSection(key, data);
|
||||||
|
invalidateContentCache();
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { addGroupBooking } from "@/lib/db";
|
import { addGroupBooking } from "@/lib/db";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
|
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const ip = getClientIp(request);
|
const ip = getClientIp(request);
|
||||||
@@ -15,17 +16,23 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, phone, groupInfo, instagram, telegram } = body;
|
const { name, phone, groupInfo, instagram, telegram } = body;
|
||||||
|
|
||||||
if (!name || typeof name !== "string" || !phone || typeof phone !== "string") {
|
const cleanName = sanitizeName(name);
|
||||||
return NextResponse.json({ error: "name and phone are required" }, { status: 400 });
|
if (!cleanName) {
|
||||||
|
return NextResponse.json({ error: "Имя обязательно" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanName = name.trim().slice(0, 100);
|
const cleanPhone = sanitizePhone(phone);
|
||||||
const cleanPhone = phone.trim().slice(0, 30);
|
if (!cleanPhone) {
|
||||||
const cleanGroup = typeof groupInfo === "string" ? groupInfo.trim().slice(0, 200) : undefined;
|
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
||||||
const cleanIg = typeof instagram === "string" ? instagram.trim().slice(0, 100) : undefined;
|
}
|
||||||
const cleanTg = typeof telegram === "string" ? telegram.trim().slice(0, 100) : undefined;
|
|
||||||
|
|
||||||
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 });
|
return NextResponse.json({ ok: true, id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[group-booking] POST error:", err);
|
console.error("[group-booking] POST error:", err);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { addMcRegistration } from "@/lib/db";
|
import { addMcRegistration } from "@/lib/db";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
|
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const ip = getClientIp(request);
|
const ip = getClientIp(request);
|
||||||
@@ -15,25 +16,26 @@ export async function POST(request: Request) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { masterClassTitle, name, phone, instagram, telegram } = body;
|
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 });
|
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 });
|
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(
|
const id = addMcRegistration(
|
||||||
masterClassTitle.trim().slice(0, 200),
|
cleanTitle,
|
||||||
name.trim().slice(0, 100),
|
cleanName,
|
||||||
cleanIg,
|
sanitizeHandle(instagram) ?? "",
|
||||||
cleanTg,
|
sanitizeHandle(telegram),
|
||||||
cleanPhone
|
cleanPhone
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getOpenDayEvent,
|
getOpenDayEvent,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
|
import { sanitizeName, sanitizePhone, sanitizeHandle } from "@/lib/validation";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const ip = getClientIp(request);
|
const ip = getClientIp(request);
|
||||||
@@ -20,17 +21,18 @@ export async function POST(request: NextRequest) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { classId, eventId, name, phone, instagram, telegram } = body;
|
const { classId, eventId, name, phone, instagram, telegram } = body;
|
||||||
|
|
||||||
if (!classId || !eventId || !name || !phone) {
|
if (!classId || !eventId) {
|
||||||
return NextResponse.json({ error: "classId, eventId, name, phone are required" }, { status: 400 });
|
return NextResponse.json({ error: "classId and eventId are required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanName = (name as string).trim().slice(0, 100);
|
const cleanName = sanitizeName(name);
|
||||||
const cleanPhone = (phone as string).replace(/\D/g, "").slice(0, 15);
|
if (!cleanName) {
|
||||||
const cleanIg = instagram ? (instagram as string).trim().slice(0, 100) : undefined;
|
return NextResponse.json({ error: "Имя обязательно" }, { status: 400 });
|
||||||
const cleanTg = telegram ? (telegram as string).trim().slice(0, 100) : undefined;
|
}
|
||||||
|
|
||||||
|
const cleanPhone = sanitizePhone(phone);
|
||||||
if (!cleanPhone) {
|
if (!cleanPhone) {
|
||||||
return NextResponse.json({ error: "Invalid phone" }, { status: 400 });
|
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check not already booked
|
// Check not already booked
|
||||||
@@ -41,8 +43,8 @@ export async function POST(request: NextRequest) {
|
|||||||
const id = addOpenDayBooking(classId, eventId, {
|
const id = addOpenDayBooking(classId, eventId, {
|
||||||
name: cleanName,
|
name: cleanName,
|
||||||
phone: cleanPhone,
|
phone: cleanPhone,
|
||||||
instagram: cleanIg,
|
instagram: sanitizeHandle(instagram),
|
||||||
telegram: cleanTg,
|
telegram: sanitizeHandle(telegram),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return total bookings for this person (for discount calculation)
|
// Return total bookings for this person (for discount calculation)
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export function About({ data: about, stats }: AboutProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div className="mt-14 mx-auto max-w-2xl space-y-8 text-center">
|
<div className="mt-14 mx-auto max-w-2xl space-y-8 text-center">
|
||||||
{about.paragraphs.map((text, i) => (
|
{about.paragraphs.map((text) => (
|
||||||
<Reveal key={i}>
|
<Reveal key={text}>
|
||||||
<p className="text-xl leading-relaxed text-neutral-600 dark:text-neutral-300 sm:text-2xl">
|
<p className="text-xl leading-relaxed text-neutral-600 dark:text-neutral-300 sm:text-2xl">
|
||||||
{text}
|
{text}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -132,9 +132,9 @@ export function News({ data }: NewsProps) {
|
|||||||
{rest.length > 0 && (
|
{rest.length > 0 && (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="rounded-2xl bg-neutral-50/80 px-5 sm:px-6 dark:bg-white/[0.02]">
|
<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
|
<CompactArticle
|
||||||
key={i}
|
key={item.title}
|
||||||
item={item}
|
item={item}
|
||||||
onClick={() => setSelected(item)}
|
onClick={() => setSelected(item)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useCallback } from "react";
|
import { useReducer, useMemo, useCallback } from "react";
|
||||||
import { SignupModal } from "@/components/ui/SignupModal";
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
import { CalendarDays, Users, LayoutGrid } from "lucide-react";
|
import { CalendarDays, Users, LayoutGrid } from "lucide-react";
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
@@ -16,20 +16,74 @@ import type { SiteContent } from "@/types/content";
|
|||||||
type ViewMode = "days" | "groups";
|
type ViewMode = "days" | "groups";
|
||||||
type LocationMode = "all" | number;
|
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 {
|
interface ScheduleProps {
|
||||||
data: SiteContent["schedule"];
|
data: SiteContent["schedule"];
|
||||||
classItems?: { name: string; color?: string }[];
|
classItems?: { name: string; color?: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
||||||
const [locationMode, setLocationMode] = useState<LocationMode>("all");
|
const [state, dispatch] = useReducer(scheduleReducer, initialState);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("days");
|
const { locationMode, viewMode, filterTrainer, filterType, filterStatus, filterTime, filterDaySet, bookingGroup } = state;
|
||||||
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 isAllMode = locationMode === "all";
|
const isAllMode = locationMode === "all";
|
||||||
|
|
||||||
@@ -38,13 +92,18 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
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) => {
|
const setFilterTrainerFromCard = useCallback((trainer: string | null) => {
|
||||||
setFilterTrainer(trainer);
|
dispatch({ type: "SET_TRAINER", value: trainer });
|
||||||
if (trainer) scrollToSchedule();
|
if (trainer) scrollToSchedule();
|
||||||
}, [scrollToSchedule]);
|
}, [scrollToSchedule]);
|
||||||
|
|
||||||
const setFilterTypeFromCard = useCallback((type: string | null) => {
|
const setFilterTypeFromCard = useCallback((type: string | null) => {
|
||||||
setFilterType(type);
|
dispatch({ type: "SET_TYPE", value: type });
|
||||||
if (type) scrollToSchedule();
|
if (type) scrollToSchedule();
|
||||||
}, [scrollToSchedule]);
|
}, [scrollToSchedule]);
|
||||||
|
|
||||||
@@ -146,11 +205,7 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all" || filterTime !== "all" || filterDaySet.size > 0);
|
const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all" || filterTime !== "all" || filterDaySet.size > 0);
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
setFilterTrainer(null);
|
dispatch({ type: "CLEAR_FILTERS" });
|
||||||
setFilterType(null);
|
|
||||||
setFilterStatus("all");
|
|
||||||
setFilterTime("all");
|
|
||||||
setFilterDaySet(new Set());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Available days for the day filter
|
// Available days for the day filter
|
||||||
@@ -160,17 +215,11 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
function toggleDay(day: string) {
|
function toggleDay(day: string) {
|
||||||
setFilterDaySet((prev) => {
|
dispatch({ type: "TOGGLE_DAY", day });
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(day)) next.delete(day);
|
|
||||||
else next.add(day);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchLocation(mode: LocationMode) {
|
function switchLocation(mode: LocationMode) {
|
||||||
setLocationMode(mode);
|
dispatch({ type: "SET_LOCATION", mode });
|
||||||
clearFilters();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTabClass = "bg-gold text-black shadow-[0_0_20px_rgba(201,169,110,0.3)]";
|
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="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]">
|
<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
|
<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 ${
|
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"
|
viewMode === "days"
|
||||||
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
|
? "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>
|
||||||
<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 ${
|
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"
|
viewMode === "groups"
|
||||||
? "bg-white text-neutral-900 shadow-sm dark:bg-white/10 dark:text-white"
|
? "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}
|
filterTrainer={filterTrainer}
|
||||||
setFilterTrainer={setFilterTrainerFromCard}
|
setFilterTrainer={setFilterTrainerFromCard}
|
||||||
showLocation={isAllMode}
|
showLocation={isAllMode}
|
||||||
onBook={setBookingGroup}
|
onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })}
|
||||||
/>
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
<SignupModal
|
<SignupModal
|
||||||
open={bookingGroup !== null}
|
open={bookingGroup !== null}
|
||||||
onClose={() => setBookingGroup(null)}
|
onClose={() => dispatch({ type: "SET_BOOKING", value: null })}
|
||||||
subtitle={bookingGroup ?? undefined}
|
subtitle={bookingGroup ?? undefined}
|
||||||
endpoint="/api/group-booking"
|
endpoint="/api/group-booking"
|
||||||
extraBody={{ groupInfo: bookingGroup }}
|
extraBody={{ groupInfo: bookingGroup }}
|
||||||
|
|||||||
@@ -2,12 +2,28 @@ import { getSiteContent } from "@/lib/db";
|
|||||||
import { siteContent as fallback } from "@/data/content";
|
import { siteContent as fallback } from "@/data/content";
|
||||||
import type { SiteContent } from "@/types/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 {
|
export function getContent(): SiteContent {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cached && now < cached.expiresAt) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = getSiteContent();
|
const content = getSiteContent();
|
||||||
if (content) return content;
|
if (content) {
|
||||||
|
cached = { data: content, expiresAt: now + CACHE_TTL };
|
||||||
|
return content;
|
||||||
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
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