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) {