diff --git a/next.config.ts b/next.config.ts index 1ff136e..0f00702 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,27 @@ import type { NextConfig } from "next"; +const securityHeaders = [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + ...(process.env.NODE_ENV === "production" + ? [{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }] + : []), +]; + const nextConfig: NextConfig = { serverExternalPackages: ["better-sqlite3"], + allowedDevOrigins: [ + "black-heart.dolgolyov-family.by", + "192.168.2.56", + ], + headers: async () => [ + { + source: "/(.*)", + headers: securityHeaders, + }, + ], }; export default nextConfig; diff --git a/public/images/classes/4809527c-f7c4-43d3-b-1774816466290.webp b/public/images/classes/4809527c-f7c4-43d3-b-1774816466290.webp new file mode 100644 index 0000000..f00ce16 Binary files /dev/null and b/public/images/classes/4809527c-f7c4-43d3-b-1774816466290.webp differ diff --git a/public/images/classes/saveclip-app-500209964-18278728816269159-142807041-1774880563621.jpg b/public/images/classes/saveclip-app-500209964-18278728816269159-142807041-1774880563621.jpg new file mode 100644 index 0000000..c67525e Binary files /dev/null and b/public/images/classes/saveclip-app-500209964-18278728816269159-142807041-1774880563621.jpg differ diff --git a/public/images/classes/saveclip-app-502604478-1048668553553166-8536520541-1774817057387.jpg b/public/images/classes/saveclip-app-502604478-1048668553553166-8536520541-1774817057387.jpg new file mode 100644 index 0000000..4c1b672 Binary files /dev/null and b/public/images/classes/saveclip-app-502604478-1048668553553166-8536520541-1774817057387.jpg differ diff --git a/public/images/classes/saveclip-app-503147861-1270982914702677-6270191634-1774815794713.jpg b/public/images/classes/saveclip-app-503147861-1270982914702677-6270191634-1774815794713.jpg new file mode 100644 index 0000000..2bb3ecf Binary files /dev/null and b/public/images/classes/saveclip-app-503147861-1270982914702677-6270191634-1774815794713.jpg differ diff --git a/public/images/classes/saveclip-app-503147861-1270982914702677-6270191634-1774816149297.jpg b/public/images/classes/saveclip-app-503147861-1270982914702677-6270191634-1774816149297.jpg new file mode 100644 index 0000000..2bb3ecf Binary files /dev/null and b/public/images/classes/saveclip-app-503147861-1270982914702677-6270191634-1774816149297.jpg differ diff --git a/public/images/classes/saveclip-app-503421835-693695423582707-16314680784-1774817045695.jpg b/public/images/classes/saveclip-app-503421835-693695423582707-16314680784-1774817045695.jpg new file mode 100644 index 0000000..7eafc9c Binary files /dev/null and b/public/images/classes/saveclip-app-503421835-693695423582707-16314680784-1774817045695.jpg differ diff --git a/public/images/classes/saveclip-app-503421835-693695423582707-16314680784-1774817067243.jpg b/public/images/classes/saveclip-app-503421835-693695423582707-16314680784-1774817067243.jpg new file mode 100644 index 0000000..7eafc9c Binary files /dev/null and b/public/images/classes/saveclip-app-503421835-693695423582707-16314680784-1774817067243.jpg differ diff --git a/public/images/classes/saveclip-app-606713698-3860217900943948-5480349122-1774814329349.jpg b/public/images/classes/saveclip-app-606713698-3860217900943948-5480349122-1774814329349.jpg new file mode 100644 index 0000000..e4573dc Binary files /dev/null and b/public/images/classes/saveclip-app-606713698-3860217900943948-5480349122-1774814329349.jpg differ diff --git a/public/images/classes/saveclip-app-619962412-18307782367269159-526848263-1774883533162.jpg b/public/images/classes/saveclip-app-619962412-18307782367269159-526848263-1774883533162.jpg new file mode 100644 index 0000000..e90ce1e Binary files /dev/null and b/public/images/classes/saveclip-app-619962412-18307782367269159-526848263-1774883533162.jpg differ diff --git a/public/images/master-classes/saveclip-app-587287096-18333625606241432-486357363-1775129850880.jpg b/public/images/master-classes/saveclip-app-587287096-18333625606241432-486357363-1775129850880.jpg new file mode 100644 index 0000000..e596a1f Binary files /dev/null and b/public/images/master-classes/saveclip-app-587287096-18333625606241432-486357363-1775129850880.jpg differ diff --git a/public/images/master-classes/saveclip-app-622435830-899645712606249-45020509606-1775129907665.jpg b/public/images/master-classes/saveclip-app-622435830-899645712606249-45020509606-1775129907665.jpg new file mode 100644 index 0000000..eddd0b9 Binary files /dev/null and b/public/images/master-classes/saveclip-app-622435830-899645712606249-45020509606-1775129907665.jpg differ diff --git a/public/images/master-classes/saveclip-app-623258046-17984966921836922-856232533-1775127702803.jpg b/public/images/master-classes/saveclip-app-623258046-17984966921836922-856232533-1775127702803.jpg new file mode 100644 index 0000000..aa1249b Binary files /dev/null and b/public/images/master-classes/saveclip-app-623258046-17984966921836922-856232533-1775127702803.jpg differ diff --git a/public/images/master-classes/saveclip-app-631872513-17986311782836922-297126240-1775128000217.jpg b/public/images/master-classes/saveclip-app-631872513-17986311782836922-297126240-1775128000217.jpg new file mode 100644 index 0000000..3a49cf4 Binary files /dev/null and b/public/images/master-classes/saveclip-app-631872513-17986311782836922-297126240-1775128000217.jpg differ diff --git a/public/images/news/5rphggile4ovko7y0w4sizrum-xjzu2ncw6a3nh-gepzp6gv-w-1775132335496.jpg b/public/images/news/5rphggile4ovko7y0w4sizrum-xjzu2ncw6a3nh-gepzp6gv-w-1775132335496.jpg new file mode 100644 index 0000000..aa15b7e Binary files /dev/null and b/public/images/news/5rphggile4ovko7y0w4sizrum-xjzu2ncw6a3nh-gepzp6gv-w-1775132335496.jpg differ diff --git a/public/images/news/saveclip-app-648350400-18121242328608541-370357132-1775131537010.jpg b/public/images/news/saveclip-app-648350400-18121242328608541-370357132-1775131537010.jpg new file mode 100644 index 0000000..17b8e7f Binary files /dev/null and b/public/images/news/saveclip-app-648350400-18121242328608541-370357132-1775131537010.jpg differ diff --git a/public/images/news/saveclip-app-650597719-17989027061836922-422661903-1775131408880.jpg b/public/images/news/saveclip-app-650597719-17989027061836922-422661903-1775131408880.jpg new file mode 100644 index 0000000..84709ed Binary files /dev/null and b/public/images/news/saveclip-app-650597719-17989027061836922-422661903-1775131408880.jpg differ diff --git a/public/images/news/saveclip-app-658751600-18313798510265536-337520394-1775131645536.jpg b/public/images/news/saveclip-app-658751600-18313798510265536-337520394-1775131645536.jpg new file mode 100644 index 0000000..975b0e2 Binary files /dev/null and b/public/images/news/saveclip-app-658751600-18313798510265536-337520394-1775131645536.jpg differ diff --git a/public/images/team/5f74a51d-3b8c-4572-8-1774817605116.webp b/public/images/team/5f74a51d-3b8c-4572-8-1774817605116.webp new file mode 100644 index 0000000..2989d5a Binary files /dev/null and b/public/images/team/5f74a51d-3b8c-4572-8-1774817605116.webp differ diff --git a/public/images/team/beeeff66-2e0c-491a-b-1774818726212.webp b/public/images/team/beeeff66-2e0c-491a-b-1774818726212.webp new file mode 100644 index 0000000..a16b1ac Binary files /dev/null and b/public/images/team/beeeff66-2e0c-491a-b-1774818726212.webp differ diff --git a/public/images/team/saveclip-app-484343945-18271306234269159-495910680-1774818892040.jpg b/public/images/team/saveclip-app-484343945-18271306234269159-495910680-1774818892040.jpg new file mode 100644 index 0000000..0fc2c24 Binary files /dev/null and b/public/images/team/saveclip-app-484343945-18271306234269159-495910680-1774818892040.jpg differ diff --git a/public/images/team/saveclip-app-486035409-18358031629182843-914375891-1774819323260.jpg b/public/images/team/saveclip-app-486035409-18358031629182843-914375891-1774819323260.jpg new file mode 100644 index 0000000..7fc0f09 Binary files /dev/null and b/public/images/team/saveclip-app-486035409-18358031629182843-914375891-1774819323260.jpg differ diff --git a/public/images/team/saveclip-app-499941378-18278728804269159-248633617-1774818974861.jpg b/public/images/team/saveclip-app-499941378-18278728804269159-248633617-1774818974861.jpg new file mode 100644 index 0000000..7c7adca Binary files /dev/null and b/public/images/team/saveclip-app-499941378-18278728804269159-248633617-1774818974861.jpg differ diff --git a/public/images/team/saveclip-app-500209964-18278728816269159-142807041-1774818964245.jpg b/public/images/team/saveclip-app-500209964-18278728816269159-142807041-1774818964245.jpg new file mode 100644 index 0000000..c67525e Binary files /dev/null and b/public/images/team/saveclip-app-500209964-18278728816269159-142807041-1774818964245.jpg differ diff --git a/public/images/team/saveclip-app-502604478-1048668553553166-8536520541-1774879364696.jpg b/public/images/team/saveclip-app-502604478-1048668553553166-8536520541-1774879364696.jpg new file mode 100644 index 0000000..4c1b672 Binary files /dev/null and b/public/images/team/saveclip-app-502604478-1048668553553166-8536520541-1774879364696.jpg differ diff --git a/public/images/team/saveclip-app-587287096-18333625606241432-486357363-1774818566580.jpg b/public/images/team/saveclip-app-587287096-18333625606241432-486357363-1774818566580.jpg new file mode 100644 index 0000000..e596a1f Binary files /dev/null and b/public/images/team/saveclip-app-587287096-18333625606241432-486357363-1774818566580.jpg differ diff --git a/public/images/team/saveclip-app-610744718-18547850167048311-585739292-1774881034547.jpg b/public/images/team/saveclip-app-610744718-18547850167048311-585739292-1774881034547.jpg new file mode 100644 index 0000000..3e323af Binary files /dev/null and b/public/images/team/saveclip-app-610744718-18547850167048311-585739292-1774881034547.jpg differ diff --git a/public/images/team/saveclip-app-641190101-18568310365046885-906257494-1774818435607.jpg b/public/images/team/saveclip-app-641190101-18568310365046885-906257494-1774818435607.jpg new file mode 100644 index 0000000..282acac Binary files /dev/null and b/public/images/team/saveclip-app-641190101-18568310365046885-906257494-1774818435607.jpg differ diff --git a/public/images/team/saveclip-app-645816388-18309776902265536-121065395-1774818172986.jpg b/public/images/team/saveclip-app-645816388-18309776902265536-121065395-1774818172986.jpg new file mode 100644 index 0000000..23b6fc2 Binary files /dev/null and b/public/images/team/saveclip-app-645816388-18309776902265536-121065395-1774818172986.jpg differ diff --git a/src/app/admin/_components/ArrayEditor.tsx b/src/app/admin/_components/ArrayEditor.tsx index 28fc1a7..4eec143 100644 --- a/src/app/admin/_components/ArrayEditor.tsx +++ b/src/app/admin/_components/ArrayEditor.tsx @@ -5,6 +5,8 @@ import { createPortal } from "react-dom"; import { Plus, Trash2, GripVertical, ChevronDown, ChevronsUpDown } from "lucide-react"; import { ConfirmDialog } from "./ConfirmDialog"; +let nextItemId = 1; + interface ArrayEditorProps { items: T[]; onChange: (items: T[]) => void; @@ -50,6 +52,19 @@ export function ArrayEditor({ const [droppedIndex, setDroppedIndex] = useState(null); const [collapsed, setCollapsed] = useState>(() => collapsible ? new Set(items.map((_, i) => i)) : new Set()); + // Stable keys for items — avoids index-as-key issues during reorder + const stableKeysRef = useRef([]); + if (stableKeysRef.current.length < items.length) { + while (stableKeysRef.current.length < items.length) { + stableKeysRef.current.push(nextItemId++); + } + } else if (stableKeysRef.current.length > items.length) { + stableKeysRef.current = stableKeysRef.current.slice(0, items.length); + } + function getStableKey(index: number): number { + return stableKeysRef.current[index]; + } + function toggleCollapse(index: number) { setCollapsed(prev => { const next = new Set(prev); @@ -76,6 +91,7 @@ export function ArrayEditor({ } function removeItem(index: number) { + stableKeysRef.current.splice(index, 1); onChange(items.filter((_, i) => i !== index)); } @@ -142,6 +158,11 @@ export function ArrayEditor({ const updated = [...items]; const [moved] = updated.splice(capturedDrag, 1); updated.splice(targetIndex, 0, moved); + // Sync stable keys + const keys = [...stableKeysRef.current]; + const [movedKey] = keys.splice(capturedDrag, 1); + keys.splice(targetIndex, 0, movedKey); + stableKeysRef.current = keys; onChange(updated); setDroppedIndex(targetIndex); setTimeout(() => setDroppedIndex(null), 1500); @@ -167,7 +188,7 @@ export function ArrayEditor({ const title = getItemTitle?.(item, i) || `#${i + 1}`; return (
{ itemRefs.current[i] = el; }} className={`rounded-lg border bg-neutral-900/50 mb-3 hover:border-white/25 hover:bg-neutral-800/50 focus-within:border-gold/50 focus-within:bg-neutral-800 transition-all ${ newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10" @@ -281,7 +302,7 @@ export function ArrayEditor({ const title = getItemTitle?.(item, i) || `#${i + 1}`; elements.push(
{ itemRefs.current[i] = el; }} className={`rounded-lg border bg-neutral-900/50 mb-3 transition-colors ${ "border-white/10" @@ -385,6 +406,7 @@ export function ArrayEditor({
+ {uploadError && ( +

{uploadError}

+ )} ); } diff --git a/src/app/admin/_components/ImageCropField.tsx b/src/app/admin/_components/ImageCropField.tsx index 1fa8618..eb16c83 100644 --- a/src/app/admin/_components/ImageCropField.tsx +++ b/src/app/admin/_components/ImageCropField.tsx @@ -34,6 +34,7 @@ export function ImageCropField({ label = "Фото", }: ImageCropFieldProps) { const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(""); const [dragging, setDragging] = useState(false); const dragStartRef = useRef({ x: 0, y: 0, startFocalX: 0, startFocalY: 0 }); const containerRef = useRef(null); @@ -42,6 +43,7 @@ export function ImageCropField({ const file = e.target.files?.[0]; if (!file) return; setUploading(true); + setUploadError(""); const formData = new FormData(); formData.append("file", file); formData.append("folder", folder); @@ -53,8 +55,12 @@ export function ImageCropField({ const result = await res.json(); if (result.path) { onChange({ image: result.path, focalX: 50, focalY: 50, zoom: 1 }); + } else { + setUploadError(result.error || "Ошибка загрузки"); } - } catch { /* upload failed */ } finally { + } catch { + setUploadError("Не удалось загрузить файл"); + } finally { setUploading(false); } } @@ -170,6 +176,9 @@ export function ImageCropField({ )} + {uploadError && ( +

{uploadError}

+ )} ); } diff --git a/src/app/admin/_components/PriceField.tsx b/src/app/admin/_components/PriceField.tsx index b669ff2..460e506 100644 --- a/src/app/admin/_components/PriceField.tsx +++ b/src/app/admin/_components/PriceField.tsx @@ -16,9 +16,10 @@ export function PriceField({ label, value, onChange, placeholder = "0" }: PriceF
{ - const v = e.target.value; + const v = e.target.value.replace(/[^\d.,\s]/g, ""); onChange(v ? `${v} BYN` : ""); }} placeholder={placeholder} diff --git a/src/app/admin/_components/SectionEditor.tsx b/src/app/admin/_components/SectionEditor.tsx index 30632ff..19688c7 100644 --- a/src/app/admin/_components/SectionEditor.tsx +++ b/src/app/admin/_components/SectionEditor.tsx @@ -24,11 +24,13 @@ export function SectionEditor({ }: SectionEditorProps) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); - const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle"); + const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error" | "invalid">("idle"); const [error, setError] = useState(""); const timerRef = useRef | null>(null); const initialLoadRef = useRef(true); const pendingSaveRef = useRef(false); + const defaultDataRef = useRef(defaultData); + defaultDataRef.current = defaultData; useEffect(() => { adminFetch(`/api/admin/sections/${sectionKey}`) @@ -36,7 +38,7 @@ export function SectionEditor({ if (!r.ok) throw new Error("Failed to load"); return r.json(); }) - .then((loaded) => setData(defaultData ? { ...defaultData, ...loaded } as T : loaded)) + .then((loaded) => setData(defaultDataRef.current ? { ...defaultDataRef.current, ...loaded } as T : loaded)) .catch(() => setError("Не удалось загрузить данные")) .finally(() => setLoading(false)); }, [sectionKey]); @@ -72,7 +74,10 @@ export function SectionEditor({ pendingSaveRef.current = true; if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { - if (validate && !validate(data)) return; + if (validate && !validate(data)) { + setStatus("invalid"); + return; + } save(data); }, DEBOUNCE_MS); @@ -134,7 +139,7 @@ export function SectionEditor({

{title}

{/* Fixed toast popup */} - {(status === "saved" || status === "error") && ( + {(status === "saved" || status === "error" || status === "invalid") && (
({ }`}> {status === "saved" && <> Сохранено} {status === "error" && <> {error}} + {status === "invalid" && <> Не сохранено — исправьте ошибки}
)} diff --git a/src/app/admin/bookings/AddBookingModal.tsx b/src/app/admin/bookings/AddBookingModal.tsx index 06a2c21..fa586a7 100644 --- a/src/app/admin/bookings/AddBookingModal.tsx +++ b/src/app/admin/bookings/AddBookingModal.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useMemo } from "react"; import { createPortal } from "react-dom"; import { X, ChevronDown } from "lucide-react"; import { adminFetch } from "@/lib/csrf"; +import { formatBelarusPhone, SHORT_DAYS } from "@/lib/formatting"; type Tab = "classes" | "events"; type EventType = "master-class" | "open-day"; @@ -11,7 +12,7 @@ type EventType = "master-class" | "open-day"; interface McOption { title: string; date: string } interface OdClass { id: number; style: string; start_time: string; hall: string; trainer: string } interface OdEvent { id: number; date: string; title?: string } -interface ScheduleClass { type: string; trainer: string; time: string; day: string; hall: string } +interface ScheduleClass { type: string; trainer: string; time: string; day: string; hall: string; groupId?: string } function shortName(fullName: string) { const parts = fullName.trim().split(/\s+/); @@ -19,10 +20,6 @@ function shortName(fullName: string) { return parts.length > 1 ? `${parts[1]} ${parts[0][0]}.` : parts[0]; } -const SHORT_DAYS: Record = { - "Понедельник": "Пн", "Вторник": "Вт", "Среда": "Ср", - "Четверг": "Чт", "Пятница": "Пт", "Суббота": "Сб", "Воскресенье": "Вс", -}; // --- Searchable dropdown --- @@ -42,7 +39,13 @@ function SearchSelect({ options, value, onChange, placeholder }: { const selected = options.find((o) => o.value === value); const filtered = search - ? options.filter((o) => o.label.toLowerCase().includes(search.toLowerCase())) + ? (() => { + const tokens = search.toLowerCase().split(/\s+/).filter(Boolean); + return options.filter((o) => { + const label = o.label.toLowerCase(); + return tokens.every((t) => label.includes(t)); + }); + })() : options; useEffect(() => { @@ -98,7 +101,7 @@ function SearchSelect({ options, value, onChange, placeholder }: { {open && (
-
+
{filtered.length === 0 && (

Ничего не найдено

)} @@ -145,32 +148,24 @@ export function AddBookingModal({ const [odEventId, setOdEventId] = useState(null); const [odClassId, setOdClassId] = useState(""); const [scheduleClasses, setScheduleClasses] = useState([]); - const [classInfo, setClassInfo] = useState(""); + const [classGroup, setClassGroup] = useState(""); const [saving, setSaving] = useState(false); useEffect(() => { if (!open) return; - setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassInfo(""); + setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassGroup(""); // Fetch schedule classes - adminFetch("/api/admin/sections/schedule").then((r) => r.json()).then((data: { locations?: { name: string; days: { day: string; classes: { type: string; trainer: string; time: string }[] }[] }[] }) => { + adminFetch("/api/admin/sections/schedule").then((r) => r.json()).then((data: { locations?: { name: string; days: { day: string; classes: { type: string; trainer: string; time: string; groupId?: string }[] }[] }[] }) => { const classes: ScheduleClass[] = []; for (const loc of data.locations || []) { for (const day of loc.days) { for (const cls of day.classes) { - classes.push({ type: cls.type, trainer: cls.trainer, time: cls.time, day: day.day, hall: loc.name }); + classes.push({ type: cls.type, trainer: cls.trainer, time: cls.time, day: day.day, hall: loc.name, groupId: cls.groupId }); } } } - // Deduplicate by type+trainer+time+day+hall - const seen = new Set(); - const unique = classes.filter((c) => { - const key = `${c.type}|${c.trainer}|${c.time}|${c.day}|${c.hall}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - setScheduleClasses(unique); + setScheduleClasses(classes); }).catch(() => {}); // Fetch upcoming MCs @@ -209,27 +204,35 @@ export function AddBookingModal({ }, [open, onClose]); function handlePhoneChange(raw: string) { - let digits = raw.replace(/\D/g, ""); - if (!digits.startsWith("375")) digits = "375" + digits.replace(/^375?/, ""); - digits = digits.slice(0, 12); - let formatted = "+375"; - const rest = digits.slice(3); - if (rest.length > 0) formatted += " (" + rest.slice(0, 2); - if (rest.length >= 2) formatted += ") "; - if (rest.length > 2) formatted += rest.slice(2, 5); - if (rest.length > 5) formatted += "-" + rest.slice(5, 7); - if (rest.length > 7) formatted += "-" + rest.slice(7, 9); - setPhone(formatted); + setPhone(formatBelarusPhone(raw)); } const hasUpcomingMc = mcOptions.length > 0; const hasOpenDay = odEventId !== null && odClasses.length > 0; - // Build options for each dropdown - const classOptions: SearchSelectOption[] = scheduleClasses.map((c, i) => ({ - value: String(i), - label: `${shortName(c.trainer)} — ${c.type} · ${SHORT_DAYS[c.day] || c.day} ${c.time} · ${c.hall}`, - })); + // Flat group options: one searchable dropdown + const classGroupOptions = useMemo((): SearchSelectOption[] => { + const byKey = new Map(); + for (const c of scheduleClasses) { + const id = c.groupId || `${c.type}|${c.trainer}|${c.time}|${c.hall}`; + const existing = byKey.get(id); + if (existing) { + if (!existing.slots.some((s) => s.day === c.day)) existing.slots.push({ day: c.day, time: c.time }); + } else { + byKey.set(id, { type: c.type, trainer: c.trainer, hall: c.hall, slots: [{ day: c.day, time: c.time }], id }); + } + } + return [...byKey.values()].map((g) => { + const sameTime = g.slots.every((s) => s.time === g.slots[0].time); + const days = sameTime + ? `${g.slots.map((s) => SHORT_DAYS[s.day] || s.day.slice(0, 2)).join("/")} ${g.slots[0].time}` + : g.slots.map((s) => `${SHORT_DAYS[s.day] || s.day.slice(0, 2)} ${s.time}`).join(", "); + return { + value: g.id, + label: `${shortName(g.trainer)} · ${g.type} · ${days} · ${g.hall}`, + }; + }).sort((a, b) => a.label.localeCompare(b.label)); + }, [scheduleClasses]); const mcSelectOptions: SearchSelectOption[] = mcOptions.map((mc) => ({ value: mc.title, @@ -246,9 +249,8 @@ export function AddBookingModal({ setSaving(true); try { if (tab === "classes") { - const selectedClass = classInfo ? scheduleClasses[Number(classInfo)] : null; - const groupInfo = selectedClass - ? `${selectedClass.type}, ${shortName(selectedClass.trainer)}, ${SHORT_DAYS[selectedClass.day] || selectedClass.day} ${selectedClass.time}, ${selectedClass.hall}` + const groupInfo = classGroup + ? classGroupOptions.find((o) => o.value === classGroup)?.label : undefined; await adminFetch("/api/admin/group-bookings", { method: "POST", @@ -277,6 +279,8 @@ export function AddBookingModal({ } onAdded(); onClose(); + } catch { + alert("Не удалось создать запись. Попробуйте ещё раз."); } finally { setSaving(false); } @@ -335,12 +339,12 @@ export function AddBookingModal({
{/* Class selector (optional for Занятие) */} - {tab === "classes" && classOptions.length > 0 && ( + {tab === "classes" && classGroupOptions.length > 0 && ( )} diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx index aaeaed1..6d596ed 100644 --- a/src/app/admin/bookings/page.tsx +++ b/src/app/admin/bookings/page.tsx @@ -283,18 +283,23 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD ...b, status: "confirmed" as BookingStatus, confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes, } : b)); - await Promise.all([ - adminFetch("/api/admin/group-bookings", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }), - }), - data.comment ? adminFetch("/api/admin/group-bookings", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }), - }) : Promise.resolve(), - ]); + try { + await Promise.all([ + adminFetch("/api/admin/group-bookings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }), + }), + data.comment ? adminFetch("/api/admin/group-bookings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }), + }) : Promise.resolve(), + ]); + } catch { + // Revert optimistic update on failure + setBookings((prev) => prev.map((b) => b.id === confirmingId ? { ...b, ...existing } : b)); + } setConfirmingId(null); onDataChange?.(); } diff --git a/src/app/admin/bookings/types.ts b/src/app/admin/bookings/types.ts index a2b4692..5d330d5 100644 --- a/src/app/admin/bookings/types.ts +++ b/src/app/admin/bookings/types.ts @@ -1,3 +1,5 @@ +import { SHORT_DAYS } from "@/lib/formatting"; + export type BookingStatus = "new" | "contacted" | "confirmed" | "declined"; export type BookingFilter = "all" | BookingStatus; @@ -12,10 +14,7 @@ export interface BaseBooking { createdAt: string; } -export const SHORT_DAYS: Record = { - "Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ", - "Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС", -}; +export { SHORT_DAYS }; export const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [ { key: "new", label: "Новая", color: "text-gold", bg: "bg-gold/10", border: "border-gold/30" }, @@ -25,7 +24,16 @@ export const BOOKING_STATUSES: { key: BookingStatus; label: string; color: strin ]; export function fmtDate(iso: string): string { - return new Date(iso).toLocaleDateString("ru-RU"); + const d = new Date(iso); + const now = new Date(); + const sameYear = d.getFullYear() === now.getFullYear(); + const date = d.toLocaleDateString("ru-RU", { + day: "numeric", + month: "short", + ...(sameYear ? {} : { year: "numeric" }), + }); + const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); + return `${date}, ${time}`; } export function countStatuses(items: { status: string }[]): Record { diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 314d90d..1e9fd7f 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -56,17 +56,19 @@ export default function AdminLayout({ const [unreadTotal, setUnreadTotal] = useState(0); const isLoginPage = pathname === "/admin/login"; - // Fetch unread counts — poll every 10s + // Fetch unread counts — poll every 10s, stop after 3 consecutive failures useEffect(() => { if (isLoginPage) return; + let failures = 0; + let interval: ReturnType; function fetchCounts() { adminFetch("/api/admin/unread-counts") - .then((r) => r.json()) - .then((data: { total: number }) => setUnreadTotal(data.total)) - .catch(() => {}); + .then((r) => { if (!r.ok) throw new Error(); return r.json(); }) + .then((data: { total: number }) => { setUnreadTotal(data.total); failures = 0; }) + .catch(() => { failures++; if (failures >= 3 && interval) clearInterval(interval); }); } fetchCounts(); - const interval = setInterval(fetchCounts, 10000); + interval = setInterval(fetchCounts, 10000); return () => clearInterval(interval); }, [isLoginPage]); diff --git a/src/app/admin/open-day/page.tsx b/src/app/admin/open-day/page.tsx index 4f3cd47..9a374da 100644 --- a/src/app/admin/open-day/page.tsx +++ b/src/app/admin/open-day/page.tsx @@ -411,30 +411,45 @@ function ScheduleGrid({ const [creatingTime, setCreatingTime] = useState(null); async function confirmCreate(startTime: string, data: { trainer: string; style: string; endTime: string }) { - await adminFetch("/api/admin/open-day/classes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }), - }); - setCreatingTime(null); - onClassesChange(); + try { + const res = await adminFetch("/api/admin/open-day/classes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }), + }); + if (!res.ok) throw new Error(); + setCreatingTime(null); + onClassesChange(); + } catch { + alert("Не удалось создать занятие"); + } } async function updateClass(id: number, data: Partial) { - await adminFetch("/api/admin/open-day/classes", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id, ...data }), - }); - onClassesChange(); + try { + const res = await adminFetch("/api/admin/open-day/classes", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, ...data }), + }); + if (!res.ok) throw new Error(); + onClassesChange(); + } catch { + alert("Не удалось обновить занятие"); + } } function deleteClass(id: number) { setConfirmAction({ message: "Удалить занятие? Это действие нельзя отменить.", onConfirm: async () => { - await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" }); - onClassesChange(); + try { + const res = await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(); + onClassesChange(); + } catch { + alert("Не удалось удалить занятие"); + } }, }); } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index fdf958d..9b098b3 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -91,9 +91,9 @@ export default function AdminDashboard() { useEffect(() => { adminFetch("/api/admin/unread-counts") - .then((r) => r.json()) + .then((r) => { if (!r.ok) throw new Error(); return r.json(); }) .then((data: UnreadCounts) => setCounts(data)) - .catch(() => {}); + .catch(() => { /* initial load — non-critical */ }); }, []); return ( diff --git a/src/app/admin/team/page.tsx b/src/app/admin/team/page.tsx index 27847c2..d48b8bf 100644 --- a/src/app/admin/team/page.tsx +++ b/src/app/admin/team/page.tsx @@ -81,19 +81,32 @@ export default function TeamEditorPage() { }, [sectionTitle]); const saveOrder = useCallback(async (updated: Member[]) => { + const previous = members; setMembers(updated); - const res = await adminFetch("/api/admin/team/reorder", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ids: updated.map((m) => m.id) }), - }); - setSaveStatus(res.ok ? "saved" : "error"); + try { + const res = await adminFetch("/api/admin/team/reorder", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: updated.map((m) => m.id) }), + }); + setSaveStatus(res.ok ? "saved" : "error"); + if (!res.ok) setMembers(previous); + } catch { + setSaveStatus("error"); + setMembers(previous); + } setTimeout(() => setSaveStatus("idle"), 2000); - }, []); + }, [members]); async function deleteMember(id: number) { - await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" }); - setMembers((prev) => prev.filter((m) => m.id !== id)); + try { + const res = await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error(); + setMembers((prev) => prev.filter((m) => m.id !== id)); + } catch { + setSaveStatus("error"); + setTimeout(() => setSaveStatus("idle"), 3000); + } } if (loading) { diff --git a/src/app/api/admin/group-bookings/route.ts b/src/app/api/admin/group-bookings/route.ts index 97d1abb..e6960ce 100644 --- a/src/app/api/admin/group-bookings/route.ts +++ b/src/app/api/admin/group-bookings/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getGroupBookings, addGroupBooking, toggleGroupBookingNotification, deleteGroupBooking, setGroupBookingStatus, updateBookingNotes } from "@/lib/db"; import type { BookingStatus } from "@/lib/db"; -import { sanitizeText } from "@/lib/validation"; +import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation"; export async function GET() { const bookings = getGroupBookings(); @@ -50,10 +50,12 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); const { name, phone, groupInfo, instagram, telegram } = body; - if (!name?.trim() || !phone?.trim()) { + const cleanName = sanitizeName(name); + const cleanPhone = sanitizePhone(phone); + if (!cleanName || !cleanPhone) { return NextResponse.json({ error: "name and phone are required" }, { status: 400 }); } - const id = addGroupBooking(name.trim(), phone.trim(), groupInfo?.trim() || undefined, instagram?.trim() || undefined, telegram?.trim() || undefined); + const id = addGroupBooking(cleanName, cleanPhone, sanitizeText(groupInfo, 500), sanitizeHandle(instagram), sanitizeHandle(telegram)); return NextResponse.json({ ok: true, id }); } catch (err) { console.error("[admin/group-bookings] POST error:", err); diff --git a/src/app/api/admin/mc-registrations/route.ts b/src/app/api/admin/mc-registrations/route.ts index 453b2b4..e8f48b9 100644 --- a/src/app/api/admin/mc-registrations/route.ts +++ b/src/app/api/admin/mc-registrations/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration, setMcRegistrationStatus, updateBookingNotes } from "@/lib/db"; -import { sanitizeText } from "@/lib/validation"; +import { sanitizeName, sanitizeHandle, sanitizeText } from "@/lib/validation"; export async function GET(request: NextRequest) { const title = request.nextUrl.searchParams.get("title"); @@ -15,10 +15,13 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); const { masterClassTitle, name, instagram, telegram } = body; - if (!masterClassTitle || !name || !instagram) { + const cleanTitle = sanitizeText(masterClassTitle, 200); + const cleanName = sanitizeName(name); + const cleanInstagram = sanitizeHandle(instagram); + if (!cleanTitle || !cleanName || !cleanInstagram) { return NextResponse.json({ error: "masterClassTitle, name, instagram are required" }, { status: 400 }); } - const id = addMcRegistration(masterClassTitle.trim(), name.trim(), instagram.trim(), telegram?.trim() || undefined); + const id = addMcRegistration(cleanTitle, cleanName, cleanInstagram, sanitizeHandle(telegram)); return NextResponse.json({ ok: true, id }); } catch (err) { console.error("[admin/mc-registrations] error:", err); @@ -64,10 +67,12 @@ export async function PUT(request: NextRequest) { // Regular update const { id, name, instagram, telegram } = body; - if (!id || !name || !instagram) { + const cleanName = sanitizeName(name); + const cleanInstagram = sanitizeHandle(instagram); + if (!id || !cleanName || !cleanInstagram) { return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 }); } - updateMcRegistration(id, name.trim(), instagram.trim(), telegram?.trim() || undefined); + updateMcRegistration(id, cleanName, cleanInstagram, sanitizeHandle(telegram)); return NextResponse.json({ ok: true }); } catch (err) { console.error("[admin/mc-registrations] error:", err); diff --git a/src/app/api/admin/sections/[key]/route.ts b/src/app/api/admin/sections/[key]/route.ts index 2bd8832..8cd3bde 100644 --- a/src/app/api/admin/sections/[key]/route.ts +++ b/src/app/api/admin/sections/[key]/route.ts @@ -22,13 +22,33 @@ export async function GET(_request: NextRequest, { params }: Params) { }); } +/** Recursively sanitize string values: strip