diff --git a/src/app/api/admin/sections/[key]/route.ts b/src/app/api/admin/sections/[key]/route.ts
index 33d5b3e..5b5a17e 100644
--- a/src/app/api/admin/sections/[key]/route.ts
+++ b/src/app/api/admin/sections/[key]/route.ts
@@ -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 });
diff --git a/src/app/api/group-booking/route.ts b/src/app/api/group-booking/route.ts
index a31ce7f..7f6906c 100644
--- a/src/app/api/group-booking/route.ts
+++ b/src/app/api/group-booking/route.ts
@@ -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);
diff --git a/src/app/api/master-class-register/route.ts b/src/app/api/master-class-register/route.ts
index 2c461d8..9d37990 100644
--- a/src/app/api/master-class-register/route.ts
+++ b/src/app/api/master-class-register/route.ts
@@ -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
);
diff --git a/src/app/api/open-day-register/route.ts b/src/app/api/open-day-register/route.ts
index 66a2a22..0e889c6 100644
--- a/src/app/api/open-day-register/route.ts
+++ b/src/app/api/open-day-register/route.ts
@@ -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)
diff --git a/src/components/sections/About.tsx b/src/components/sections/About.tsx
index 4e21817..146c5e9 100644
--- a/src/components/sections/About.tsx
+++ b/src/components/sections/About.tsx
@@ -30,8 +30,8 @@ export function About({ data: about, stats }: AboutProps) {
- {about.paragraphs.map((text, i) => (
-
+ {about.paragraphs.map((text) => (
+
{text}
diff --git a/src/components/sections/News.tsx b/src/components/sections/News.tsx
index 71e9f83..6c1b845 100644
--- a/src/components/sections/News.tsx
+++ b/src/components/sections/News.tsx
@@ -132,9 +132,9 @@ export function News({ data }: NewsProps) {
{rest.length > 0 && (
- {rest.map((item, i) => (
+ {rest.map((item) => (
setSelected(item)}
/>
diff --git a/src/components/sections/Schedule.tsx b/src/components/sections/Schedule.tsx
index 2edb93c..b90cade 100644
--- a/src/components/sections/Schedule.tsx
+++ b/src/components/sections/Schedule.tsx
@@ -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;
+ 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("all");
- const [viewMode, setViewMode] = useState("days");
- const [filterTrainer, setFilterTrainer] = useState(null);
- const [filterType, setFilterType] = useState(null);
- const [filterStatus, setFilterStatus] = useState("all");
- const [filterTime, setFilterTime] = useState("all");
- const [filterDaySet, setFilterDaySet] = useState>(new Set());
- const [bookingGroup, setBookingGroup] = useState(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) {