feat: mobile UX, admin polish, rate limiting, and media assets
- Mobile responsiveness improvements across admin and public sections - Admin: bookings modal, open-day page, team page, layout polish - Added rate limiting, CSRF hardening, auth-edge improvements - Scroll reveal, floating contact, back-to-top, Yandex map fixes - Schedule filters refactor, team profile/info component updates - New useTrainerPhotos hook - Added class, team, master-class, and news images
@@ -1,7 +1,27 @@
|
|||||||
import type { NextConfig } from "next";
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
serverExternalPackages: ["better-sqlite3"],
|
serverExternalPackages: ["better-sqlite3"],
|
||||||
|
allowedDevOrigins: [
|
||||||
|
"black-heart.dolgolyov-family.by",
|
||||||
|
"192.168.2.56",
|
||||||
|
],
|
||||||
|
headers: async () => [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: securityHeaders,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -5,6 +5,8 @@ import { createPortal } from "react-dom";
|
|||||||
import { Plus, Trash2, GripVertical, ChevronDown, ChevronsUpDown } from "lucide-react";
|
import { Plus, Trash2, GripVertical, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||||
import { ConfirmDialog } from "./ConfirmDialog";
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
|
|
||||||
|
let nextItemId = 1;
|
||||||
|
|
||||||
interface ArrayEditorProps<T> {
|
interface ArrayEditorProps<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
onChange: (items: T[]) => void;
|
onChange: (items: T[]) => void;
|
||||||
@@ -50,6 +52,19 @@ export function ArrayEditor<T>({
|
|||||||
const [droppedIndex, setDroppedIndex] = useState<number | null>(null);
|
const [droppedIndex, setDroppedIndex] = useState<number | null>(null);
|
||||||
const [collapsed, setCollapsed] = useState<Set<number>>(() => collapsible ? new Set(items.map((_, i) => i)) : new Set());
|
const [collapsed, setCollapsed] = useState<Set<number>>(() => collapsible ? new Set(items.map((_, i) => i)) : new Set());
|
||||||
|
|
||||||
|
// Stable keys for items — avoids index-as-key issues during reorder
|
||||||
|
const stableKeysRef = useRef<number[]>([]);
|
||||||
|
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) {
|
function toggleCollapse(index: number) {
|
||||||
setCollapsed(prev => {
|
setCollapsed(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -76,6 +91,7 @@ export function ArrayEditor<T>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(index: number) {
|
function removeItem(index: number) {
|
||||||
|
stableKeysRef.current.splice(index, 1);
|
||||||
onChange(items.filter((_, i) => i !== index));
|
onChange(items.filter((_, i) => i !== index));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +158,11 @@ export function ArrayEditor<T>({
|
|||||||
const updated = [...items];
|
const updated = [...items];
|
||||||
const [moved] = updated.splice(capturedDrag, 1);
|
const [moved] = updated.splice(capturedDrag, 1);
|
||||||
updated.splice(targetIndex, 0, moved);
|
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);
|
onChange(updated);
|
||||||
setDroppedIndex(targetIndex);
|
setDroppedIndex(targetIndex);
|
||||||
setTimeout(() => setDroppedIndex(null), 1500);
|
setTimeout(() => setDroppedIndex(null), 1500);
|
||||||
@@ -167,7 +188,7 @@ export function ArrayEditor<T>({
|
|||||||
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={getStableKey(i)}
|
||||||
ref={(el) => { itemRefs.current[i] = el; }}
|
ref={(el) => { 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 ${
|
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"
|
newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
|
||||||
@@ -281,7 +302,7 @@ export function ArrayEditor<T>({
|
|||||||
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||||
elements.push(
|
elements.push(
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={getStableKey(i)}
|
||||||
ref={(el) => { itemRefs.current[i] = el; }}
|
ref={(el) => { itemRefs.current[i] = el; }}
|
||||||
className={`rounded-lg border bg-neutral-900/50 mb-3 transition-colors ${
|
className={`rounded-lg border bg-neutral-900/50 mb-3 transition-colors ${
|
||||||
"border-white/10"
|
"border-white/10"
|
||||||
@@ -385,6 +406,7 @@ export function ArrayEditor<T>({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
stableKeysRef.current = [nextItemId++, ...stableKeysRef.current];
|
||||||
onChange([createItem(), ...items]);
|
onChange([createItem(), ...items]);
|
||||||
setNewItemIndex(0);
|
setNewItemIndex(0);
|
||||||
// Shift collapsed indices and ensure new item is expanded
|
// Shift collapsed indices and ensure new item is expanded
|
||||||
@@ -409,6 +431,7 @@ export function ArrayEditor<T>({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
stableKeysRef.current.push(nextItemId++);
|
||||||
onChange([...items, createItem()]);
|
onChange([...items, createItem()]);
|
||||||
setNewItemIndex(items.length);
|
setNewItemIndex(items.length);
|
||||||
setCollapsed(prev => { const next = new Set(prev); next.delete(items.length); return next; });
|
setCollapsed(prev => { const next = new Set(prev); next.delete(items.length); return next; });
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { AlertTriangle, X } from "lucide-react";
|
import { AlertTriangle, X } from "lucide-react";
|
||||||
|
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -26,6 +27,7 @@ export function ConfirmDialog({
|
|||||||
destructive = true,
|
destructive = true,
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -41,6 +43,7 @@ export function ConfirmDialog({
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
|
ref={focusTrapRef}
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||||
role="alertdialog"
|
role="alertdialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|||||||
@@ -724,6 +724,7 @@ interface VictoryListFieldProps {
|
|||||||
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate, onUploadComplete }: VictoryListFieldProps) {
|
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate, onUploadComplete }: VictoryListFieldProps) {
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
||||||
|
const [uploadError, setUploadError] = useState("");
|
||||||
|
|
||||||
function add() {
|
function add() {
|
||||||
const val = draft.trim();
|
const val = draft.trim();
|
||||||
@@ -752,6 +753,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setUploadingIndex(index);
|
setUploadingIndex(index);
|
||||||
|
setUploadError("");
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("folder", "team");
|
formData.append("folder", "team");
|
||||||
@@ -761,8 +763,12 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
|||||||
if (result.path) {
|
if (result.path) {
|
||||||
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
|
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
|
||||||
onUploadComplete?.();
|
onUploadComplete?.();
|
||||||
|
} else {
|
||||||
|
setUploadError(result.error || "Ошибка загрузки");
|
||||||
}
|
}
|
||||||
} catch { /* upload failed */ } finally {
|
} catch {
|
||||||
|
setUploadError("Не удалось загрузить файл");
|
||||||
|
} finally {
|
||||||
setUploadingIndex(null);
|
setUploadingIndex(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -833,6 +839,9 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{uploadError && (
|
||||||
|
<p role="alert" className="mt-1.5 text-xs text-red-400">{uploadError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export function ImageCropField({
|
|||||||
label = "Фото",
|
label = "Фото",
|
||||||
}: ImageCropFieldProps) {
|
}: ImageCropFieldProps) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState("");
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const dragStartRef = useRef({ x: 0, y: 0, startFocalX: 0, startFocalY: 0 });
|
const dragStartRef = useRef({ x: 0, y: 0, startFocalX: 0, startFocalY: 0 });
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -42,6 +43,7 @@ export function ImageCropField({
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setUploadError("");
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("folder", folder);
|
formData.append("folder", folder);
|
||||||
@@ -53,8 +55,12 @@ export function ImageCropField({
|
|||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (result.path) {
|
if (result.path) {
|
||||||
onChange({ image: result.path, focalX: 50, focalY: 50, zoom: 1 });
|
onChange({ image: result.path, focalX: 50, focalY: 50, zoom: 1 });
|
||||||
|
} else {
|
||||||
|
setUploadError(result.error || "Ошибка загрузки");
|
||||||
}
|
}
|
||||||
} catch { /* upload failed */ } finally {
|
} catch {
|
||||||
|
setUploadError("Не удалось загрузить файл");
|
||||||
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,6 +176,9 @@ export function ImageCropField({
|
|||||||
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
{uploadError && (
|
||||||
|
<p role="alert" className="mt-1.5 text-xs text-red-400">{uploadError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ export function PriceField({ label, value, onChange, placeholder = "0" }: PriceF
|
|||||||
<div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors">
|
<div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
value={raw}
|
value={raw}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value.replace(/[^\d.,\s]/g, "");
|
||||||
onChange(v ? `${v} BYN` : "");
|
onChange(v ? `${v} BYN` : "");
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ export function SectionEditor<T>({
|
|||||||
}: SectionEditorProps<T>) {
|
}: SectionEditorProps<T>) {
|
||||||
const [data, setData] = useState<T | null>(null);
|
const [data, setData] = useState<T | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
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 [error, setError] = useState("");
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const initialLoadRef = useRef(true);
|
const initialLoadRef = useRef(true);
|
||||||
const pendingSaveRef = useRef(false);
|
const pendingSaveRef = useRef(false);
|
||||||
|
const defaultDataRef = useRef(defaultData);
|
||||||
|
defaultDataRef.current = defaultData;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminFetch(`/api/admin/sections/${sectionKey}`)
|
adminFetch(`/api/admin/sections/${sectionKey}`)
|
||||||
@@ -36,7 +38,7 @@ export function SectionEditor<T>({
|
|||||||
if (!r.ok) throw new Error("Failed to load");
|
if (!r.ok) throw new Error("Failed to load");
|
||||||
return r.json();
|
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("Не удалось загрузить данные"))
|
.catch(() => setError("Не удалось загрузить данные"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [sectionKey]);
|
}, [sectionKey]);
|
||||||
@@ -72,7 +74,10 @@ export function SectionEditor<T>({
|
|||||||
pendingSaveRef.current = true;
|
pendingSaveRef.current = true;
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
if (validate && !validate(data)) return;
|
if (validate && !validate(data)) {
|
||||||
|
setStatus("invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
save(data);
|
save(data);
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
@@ -134,7 +139,7 @@ export function SectionEditor<T>({
|
|||||||
<h1 className="text-2xl font-bold">{title}</h1>
|
<h1 className="text-2xl font-bold">{title}</h1>
|
||||||
|
|
||||||
{/* Fixed toast popup */}
|
{/* Fixed toast popup */}
|
||||||
{(status === "saved" || status === "error") && (
|
{(status === "saved" || status === "error" || status === "invalid") && (
|
||||||
<div role="status" aria-live="polite" className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
<div role="status" aria-live="polite" className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg animate-in slide-in-from-right ${
|
||||||
status === "saved"
|
status === "saved"
|
||||||
? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
|
? "bg-emerald-950/90 border-emerald-500/30 text-emerald-200"
|
||||||
@@ -142,6 +147,7 @@ export function SectionEditor<T>({
|
|||||||
}`}>
|
}`}>
|
||||||
{status === "saved" && <><Check size={14} /> Сохранено</>}
|
{status === "saved" && <><Check size={14} /> Сохранено</>}
|
||||||
{status === "error" && <><AlertCircle size={14} /> {error}</>}
|
{status === "error" && <><AlertCircle size={14} /> {error}</>}
|
||||||
|
{status === "invalid" && <><AlertCircle size={14} /> Не сохранено — исправьте ошибки</>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { X, ChevronDown } from "lucide-react";
|
import { X, ChevronDown } from "lucide-react";
|
||||||
import { adminFetch } from "@/lib/csrf";
|
import { adminFetch } from "@/lib/csrf";
|
||||||
|
import { formatBelarusPhone, SHORT_DAYS } from "@/lib/formatting";
|
||||||
|
|
||||||
type Tab = "classes" | "events";
|
type Tab = "classes" | "events";
|
||||||
type EventType = "master-class" | "open-day";
|
type EventType = "master-class" | "open-day";
|
||||||
@@ -11,7 +12,7 @@ type EventType = "master-class" | "open-day";
|
|||||||
interface McOption { title: string; date: string }
|
interface McOption { title: string; date: string }
|
||||||
interface OdClass { id: number; style: string; start_time: string; hall: string; trainer: string }
|
interface OdClass { id: number; style: string; start_time: string; hall: string; trainer: string }
|
||||||
interface OdEvent { id: number; date: string; title?: 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) {
|
function shortName(fullName: string) {
|
||||||
const parts = fullName.trim().split(/\s+/);
|
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];
|
return parts.length > 1 ? `${parts[1]} ${parts[0][0]}.` : parts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHORT_DAYS: Record<string, string> = {
|
|
||||||
"Понедельник": "Пн", "Вторник": "Вт", "Среда": "Ср",
|
|
||||||
"Четверг": "Чт", "Пятница": "Пт", "Суббота": "Сб", "Воскресенье": "Вс",
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Searchable dropdown ---
|
// --- Searchable dropdown ---
|
||||||
|
|
||||||
@@ -42,7 +39,13 @@ function SearchSelect({ options, value, onChange, placeholder }: {
|
|||||||
const selected = options.find((o) => o.value === value);
|
const selected = options.find((o) => o.value === value);
|
||||||
|
|
||||||
const filtered = search
|
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;
|
: options;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,7 +101,7 @@ function SearchSelect({ options, value, onChange, placeholder }: {
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute z-20 mt-1 w-full rounded-lg border border-white/[0.08] shadow-xl overflow-hidden" style={{ backgroundColor: "#141414" }}>
|
<div className="absolute z-20 mt-1 w-full rounded-lg border border-white/[0.08] shadow-xl overflow-hidden" style={{ backgroundColor: "#141414" }}>
|
||||||
<div className="max-h-48 overflow-y-auto styled-scrollbar">
|
<div className="max-h-48 overflow-y-scroll admin-scrollbar">
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<p className="px-3 py-2 text-xs text-neutral-500">Ничего не найдено</p>
|
<p className="px-3 py-2 text-xs text-neutral-500">Ничего не найдено</p>
|
||||||
)}
|
)}
|
||||||
@@ -145,32 +148,24 @@ export function AddBookingModal({
|
|||||||
const [odEventId, setOdEventId] = useState<number | null>(null);
|
const [odEventId, setOdEventId] = useState<number | null>(null);
|
||||||
const [odClassId, setOdClassId] = useState("");
|
const [odClassId, setOdClassId] = useState("");
|
||||||
const [scheduleClasses, setScheduleClasses] = useState<ScheduleClass[]>([]);
|
const [scheduleClasses, setScheduleClasses] = useState<ScheduleClass[]>([]);
|
||||||
const [classInfo, setClassInfo] = useState("");
|
const [classGroup, setClassGroup] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassInfo("");
|
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassGroup("");
|
||||||
|
|
||||||
// Fetch schedule classes
|
// 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[] = [];
|
const classes: ScheduleClass[] = [];
|
||||||
for (const loc of data.locations || []) {
|
for (const loc of data.locations || []) {
|
||||||
for (const day of loc.days) {
|
for (const day of loc.days) {
|
||||||
for (const cls of day.classes) {
|
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
|
setScheduleClasses(classes);
|
||||||
const seen = new Set<string>();
|
|
||||||
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);
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
// Fetch upcoming MCs
|
// Fetch upcoming MCs
|
||||||
@@ -209,27 +204,35 @@ export function AddBookingModal({
|
|||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
function handlePhoneChange(raw: string) {
|
function handlePhoneChange(raw: string) {
|
||||||
let digits = raw.replace(/\D/g, "");
|
setPhone(formatBelarusPhone(raw));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasUpcomingMc = mcOptions.length > 0;
|
const hasUpcomingMc = mcOptions.length > 0;
|
||||||
const hasOpenDay = odEventId !== null && odClasses.length > 0;
|
const hasOpenDay = odEventId !== null && odClasses.length > 0;
|
||||||
|
|
||||||
// Build options for each dropdown
|
// Flat group options: one searchable dropdown
|
||||||
const classOptions: SearchSelectOption[] = scheduleClasses.map((c, i) => ({
|
const classGroupOptions = useMemo((): SearchSelectOption[] => {
|
||||||
value: String(i),
|
const byKey = new Map<string, { type: string; trainer: string; hall: string; slots: { day: string; time: string }[]; id: string }>();
|
||||||
label: `${shortName(c.trainer)} — ${c.type} · ${SHORT_DAYS[c.day] || c.day} ${c.time} · ${c.hall}`,
|
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) => ({
|
const mcSelectOptions: SearchSelectOption[] = mcOptions.map((mc) => ({
|
||||||
value: mc.title,
|
value: mc.title,
|
||||||
@@ -246,9 +249,8 @@ export function AddBookingModal({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (tab === "classes") {
|
if (tab === "classes") {
|
||||||
const selectedClass = classInfo ? scheduleClasses[Number(classInfo)] : null;
|
const groupInfo = classGroup
|
||||||
const groupInfo = selectedClass
|
? classGroupOptions.find((o) => o.value === classGroup)?.label
|
||||||
? `${selectedClass.type}, ${shortName(selectedClass.trainer)}, ${SHORT_DAYS[selectedClass.day] || selectedClass.day} ${selectedClass.time}, ${selectedClass.hall}`
|
|
||||||
: undefined;
|
: undefined;
|
||||||
await adminFetch("/api/admin/group-bookings", {
|
await adminFetch("/api/admin/group-bookings", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -277,6 +279,8 @@ export function AddBookingModal({
|
|||||||
}
|
}
|
||||||
onAdded();
|
onAdded();
|
||||||
onClose();
|
onClose();
|
||||||
|
} catch {
|
||||||
|
alert("Не удалось создать запись. Попробуйте ещё раз.");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -335,12 +339,12 @@ export function AddBookingModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Class selector (optional for Занятие) */}
|
{/* Class selector (optional for Занятие) */}
|
||||||
{tab === "classes" && classOptions.length > 0 && (
|
{tab === "classes" && classGroupOptions.length > 0 && (
|
||||||
<SearchSelect
|
<SearchSelect
|
||||||
options={classOptions}
|
options={classGroupOptions}
|
||||||
value={classInfo}
|
value={classGroup}
|
||||||
onChange={setClassInfo}
|
onChange={setClassGroup}
|
||||||
placeholder="Выберите занятие (необязательно)"
|
placeholder="Группа (необязательно)"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
|
|||||||
...b, status: "confirmed" as BookingStatus,
|
...b, status: "confirmed" as BookingStatus,
|
||||||
confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes,
|
confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes,
|
||||||
} : b));
|
} : b));
|
||||||
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
adminFetch("/api/admin/group-bookings", {
|
adminFetch("/api/admin/group-bookings", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -295,6 +296,10 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
|
|||||||
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
|
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
|
||||||
}) : Promise.resolve(),
|
}) : Promise.resolve(),
|
||||||
]);
|
]);
|
||||||
|
} catch {
|
||||||
|
// Revert optimistic update on failure
|
||||||
|
setBookings((prev) => prev.map((b) => b.id === confirmingId ? { ...b, ...existing } : b));
|
||||||
|
}
|
||||||
setConfirmingId(null);
|
setConfirmingId(null);
|
||||||
onDataChange?.();
|
onDataChange?.();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { SHORT_DAYS } from "@/lib/formatting";
|
||||||
|
|
||||||
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
|
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
|
||||||
export type BookingFilter = "all" | BookingStatus;
|
export type BookingFilter = "all" | BookingStatus;
|
||||||
|
|
||||||
@@ -12,10 +14,7 @@ export interface BaseBooking {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SHORT_DAYS: Record<string, string> = {
|
export { SHORT_DAYS };
|
||||||
"Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ",
|
|
||||||
"Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BOOKING_STATUSES: { key: BookingStatus; label: string; color: string; bg: string; border: string }[] = [
|
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" },
|
{ 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 {
|
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<string, number> {
|
export function countStatuses(items: { status: string }[]): Record<string, number> {
|
||||||
|
|||||||
@@ -56,17 +56,19 @@ export default function AdminLayout({
|
|||||||
const [unreadTotal, setUnreadTotal] = useState(0);
|
const [unreadTotal, setUnreadTotal] = useState(0);
|
||||||
const isLoginPage = pathname === "/admin/login";
|
const isLoginPage = pathname === "/admin/login";
|
||||||
|
|
||||||
// Fetch unread counts — poll every 10s
|
// Fetch unread counts — poll every 10s, stop after 3 consecutive failures
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoginPage) return;
|
if (isLoginPage) return;
|
||||||
|
let failures = 0;
|
||||||
|
let interval: ReturnType<typeof setInterval>;
|
||||||
function fetchCounts() {
|
function fetchCounts() {
|
||||||
adminFetch("/api/admin/unread-counts")
|
adminFetch("/api/admin/unread-counts")
|
||||||
.then((r) => r.json())
|
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
||||||
.then((data: { total: number }) => setUnreadTotal(data.total))
|
.then((data: { total: number }) => { setUnreadTotal(data.total); failures = 0; })
|
||||||
.catch(() => {});
|
.catch(() => { failures++; if (failures >= 3 && interval) clearInterval(interval); });
|
||||||
}
|
}
|
||||||
fetchCounts();
|
fetchCounts();
|
||||||
const interval = setInterval(fetchCounts, 10000);
|
interval = setInterval(fetchCounts, 10000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isLoginPage]);
|
}, [isLoginPage]);
|
||||||
|
|
||||||
|
|||||||
@@ -411,30 +411,45 @@ function ScheduleGrid({
|
|||||||
const [creatingTime, setCreatingTime] = useState<string | null>(null);
|
const [creatingTime, setCreatingTime] = useState<string | null>(null);
|
||||||
|
|
||||||
async function confirmCreate(startTime: string, data: { trainer: string; style: string; endTime: string }) {
|
async function confirmCreate(startTime: string, data: { trainer: string; style: string; endTime: string }) {
|
||||||
await adminFetch("/api/admin/open-day/classes", {
|
try {
|
||||||
|
const res = await adminFetch("/api/admin/open-day/classes", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }),
|
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
setCreatingTime(null);
|
setCreatingTime(null);
|
||||||
onClassesChange();
|
onClassesChange();
|
||||||
|
} catch {
|
||||||
|
alert("Не удалось создать занятие");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateClass(id: number, data: Partial<OpenDayClass>) {
|
async function updateClass(id: number, data: Partial<OpenDayClass>) {
|
||||||
await adminFetch("/api/admin/open-day/classes", {
|
try {
|
||||||
|
const res = await adminFetch("/api/admin/open-day/classes", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ id, ...data }),
|
body: JSON.stringify({ id, ...data }),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
onClassesChange();
|
onClassesChange();
|
||||||
|
} catch {
|
||||||
|
alert("Не удалось обновить занятие");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteClass(id: number) {
|
function deleteClass(id: number) {
|
||||||
setConfirmAction({
|
setConfirmAction({
|
||||||
message: "Удалить занятие? Это действие нельзя отменить.",
|
message: "Удалить занятие? Это действие нельзя отменить.",
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
|
try {
|
||||||
|
const res = await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
onClassesChange();
|
onClassesChange();
|
||||||
|
} catch {
|
||||||
|
alert("Не удалось удалить занятие");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,9 +91,9 @@ export default function AdminDashboard() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminFetch("/api/admin/unread-counts")
|
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))
|
.then((data: UnreadCounts) => setCounts(data))
|
||||||
.catch(() => {});
|
.catch(() => { /* initial load — non-critical */ });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -81,19 +81,32 @@ export default function TeamEditorPage() {
|
|||||||
}, [sectionTitle]);
|
}, [sectionTitle]);
|
||||||
|
|
||||||
const saveOrder = useCallback(async (updated: Member[]) => {
|
const saveOrder = useCallback(async (updated: Member[]) => {
|
||||||
|
const previous = members;
|
||||||
setMembers(updated);
|
setMembers(updated);
|
||||||
|
try {
|
||||||
const res = await adminFetch("/api/admin/team/reorder", {
|
const res = await adminFetch("/api/admin/team/reorder", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
||||||
});
|
});
|
||||||
setSaveStatus(res.ok ? "saved" : "error");
|
setSaveStatus(res.ok ? "saved" : "error");
|
||||||
|
if (!res.ok) setMembers(previous);
|
||||||
|
} catch {
|
||||||
|
setSaveStatus("error");
|
||||||
|
setMembers(previous);
|
||||||
|
}
|
||||||
setTimeout(() => setSaveStatus("idle"), 2000);
|
setTimeout(() => setSaveStatus("idle"), 2000);
|
||||||
}, []);
|
}, [members]);
|
||||||
|
|
||||||
async function deleteMember(id: number) {
|
async function deleteMember(id: number) {
|
||||||
await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
|
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));
|
setMembers((prev) => prev.filter((m) => m.id !== id));
|
||||||
|
} catch {
|
||||||
|
setSaveStatus("error");
|
||||||
|
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getGroupBookings, addGroupBooking, toggleGroupBookingNotification, deleteGroupBooking, setGroupBookingStatus, updateBookingNotes } from "@/lib/db";
|
import { getGroupBookings, addGroupBooking, toggleGroupBookingNotification, deleteGroupBooking, setGroupBookingStatus, updateBookingNotes } from "@/lib/db";
|
||||||
import type { BookingStatus } 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() {
|
export async function GET() {
|
||||||
const bookings = getGroupBookings();
|
const bookings = getGroupBookings();
|
||||||
@@ -50,10 +50,12 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
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?.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 });
|
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 });
|
return NextResponse.json({ ok: true, id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[admin/group-bookings] POST error:", err);
|
console.error("[admin/group-bookings] POST error:", err);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration, setMcRegistrationStatus, updateBookingNotes } from "@/lib/db";
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
const title = request.nextUrl.searchParams.get("title");
|
const title = request.nextUrl.searchParams.get("title");
|
||||||
@@ -15,10 +15,13 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { masterClassTitle, name, instagram, telegram } = body;
|
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 });
|
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 });
|
return NextResponse.json({ ok: true, id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[admin/mc-registrations] error:", err);
|
console.error("[admin/mc-registrations] error:", err);
|
||||||
@@ -64,10 +67,12 @@ export async function PUT(request: NextRequest) {
|
|||||||
|
|
||||||
// Regular update
|
// Regular update
|
||||||
const { id, name, instagram, telegram } = body;
|
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 });
|
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 });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[admin/mc-registrations] error:", err);
|
console.error("[admin/mc-registrations] error:", err);
|
||||||
|
|||||||
@@ -22,13 +22,33 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Recursively sanitize string values: strip <script> and javascript: URIs */
|
||||||
|
function sanitizeValue(val: unknown): unknown {
|
||||||
|
if (typeof val === "string") {
|
||||||
|
let s = val.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
|
||||||
|
// Strip javascript: URIs in href/src-like values
|
||||||
|
s = s.replace(/javascript\s*:/gi, "");
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
if (Array.isArray(val)) return val.map(sanitizeValue);
|
||||||
|
if (val && typeof val === "object") {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
|
||||||
|
out[k] = sanitizeValue(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
export async function PUT(request: NextRequest, { params }: Params) {
|
export async function PUT(request: NextRequest, { params }: Params) {
|
||||||
const { key } = await params;
|
const { key } = await params;
|
||||||
if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) {
|
if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) {
|
||||||
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid section key" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await request.json();
|
const raw = await request.json();
|
||||||
|
const data = sanitizeValue(raw);
|
||||||
setSection(key, data);
|
setSection(key, data);
|
||||||
invalidateContentCache();
|
invalidateContentCache();
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getTeamMember, updateTeamMember, deleteTeamMember } from "@/lib/db";
|
import { getTeamMember, updateTeamMember, deleteTeamMember } from "@/lib/db";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { sanitizeName, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
@@ -28,7 +29,14 @@ export async function PUT(request: NextRequest, { params }: Params) {
|
|||||||
if (!numId) {
|
if (!numId) {
|
||||||
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid ID" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const data = await request.json();
|
const raw = await request.json();
|
||||||
|
// Sanitize string fields before storing
|
||||||
|
const data = {
|
||||||
|
...raw,
|
||||||
|
name: typeof raw.name === "string" ? sanitizeName(raw.name) ?? raw.name : raw.name,
|
||||||
|
instagram: typeof raw.instagram === "string" ? sanitizeHandle(raw.instagram) : raw.instagram,
|
||||||
|
bio: typeof raw.bio === "string" ? sanitizeText(raw.bio, 2000) : raw.bio,
|
||||||
|
};
|
||||||
updateTeamMember(numId, data);
|
updateTeamMember(numId, data);
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
@@ -6,6 +6,28 @@ const IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
|
|||||||
const VIDEO_TYPES = ["video/mp4", "video/webm"];
|
const VIDEO_TYPES = ["video/mp4", "video/webm"];
|
||||||
const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"];
|
const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"];
|
||||||
const VIDEO_EXTENSIONS = [".mp4", ".webm"];
|
const VIDEO_EXTENSIONS = [".mp4", ".webm"];
|
||||||
|
|
||||||
|
/** Magic byte signatures for allowed file types */
|
||||||
|
const MAGIC_BYTES: Record<string, number[][]> = {
|
||||||
|
"image/jpeg": [[0xFF, 0xD8, 0xFF]],
|
||||||
|
"image/png": [[0x89, 0x50, 0x4E, 0x47]],
|
||||||
|
"image/webp": [[0x52, 0x49, 0x46, 0x46]], // RIFF
|
||||||
|
"image/avif": [], // AVIF uses ISOBMFF (ftyp), checked separately
|
||||||
|
"video/mp4": [], // MP4 uses ISOBMFF (ftyp), checked separately
|
||||||
|
"video/webm": [[0x1A, 0x45, 0xDF, 0xA3]], // EBML
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateMagicBytes(buffer: Buffer, mimeType: string): boolean {
|
||||||
|
// ISOBMFF container (AVIF, MP4): check for 'ftyp' at offset 4
|
||||||
|
if (mimeType === "image/avif" || mimeType === "video/mp4") {
|
||||||
|
return buffer.length >= 8 && buffer.toString("ascii", 4, 8) === "ftyp";
|
||||||
|
}
|
||||||
|
const signatures = MAGIC_BYTES[mimeType];
|
||||||
|
if (!signatures || signatures.length === 0) return true;
|
||||||
|
return signatures.some((sig) =>
|
||||||
|
sig.every((byte, i) => buffer.length > i && buffer[i] === byte)
|
||||||
|
);
|
||||||
|
}
|
||||||
const IMAGE_FOLDERS = ["team", "master-classes", "news", "classes"];
|
const IMAGE_FOLDERS = ["team", "master-classes", "news", "classes"];
|
||||||
const VIDEO_FOLDERS = ["hero"];
|
const VIDEO_FOLDERS = ["hero"];
|
||||||
const ALL_FOLDERS = [...IMAGE_FOLDERS, ...VIDEO_FOLDERS];
|
const ALL_FOLDERS = [...IMAGE_FOLDERS, ...VIDEO_FOLDERS];
|
||||||
@@ -71,6 +93,15 @@ export async function POST(request: NextRequest) {
|
|||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
// Validate file content matches claimed MIME type
|
||||||
|
if (!validateMagicBytes(buffer, file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Содержимое файла не соответствует его типу" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const filePath = path.join(dir, fileName);
|
const filePath = path.join(dir, fileName);
|
||||||
await writeFile(filePath, buffer);
|
await writeFile(filePath, buffer);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const INSTAGRAM_USERNAME_RE = /^[a-zA-Z0-9_.]{1,30}$/;
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const username = request.nextUrl.searchParams.get("username")?.trim();
|
const username = request.nextUrl.searchParams.get("username")?.trim();
|
||||||
if (!username) {
|
if (!username) {
|
||||||
return NextResponse.json({ valid: false, error: "No username" });
|
return NextResponse.json({ valid: false, error: "No username" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!INSTAGRAM_USERNAME_RE.test(username)) {
|
||||||
|
return NextResponse.json({ valid: false, error: "Invalid username format" });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://www.instagram.com/${username}/`, {
|
const res = await fetch(`https://www.instagram.com/${username}/`, {
|
||||||
method: "HEAD",
|
method: "HEAD",
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { verifyPassword, signToken, generateCsrfToken, COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
import { verifyPassword, signToken, generateCsrfToken, COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
||||||
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 5, 5 * 60_000)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Слишком много попыток. Попробуйте через 5 минут." },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json() as { password?: string };
|
const body = await request.json() as { password?: string };
|
||||||
|
|
||||||
if (!body.password || !verifyPassword(body.password)) {
|
if (!body.password || !verifyPassword(body.password)) {
|
||||||
@@ -23,7 +32,7 @@ export async function POST(request: NextRequest) {
|
|||||||
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
|
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
|
||||||
httpOnly: false, // JS must read this to send as header
|
httpOnly: false, // JS must read this to send as header
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: "strict",
|
sameSite: "lax", // Match auth cookie; strict breaks admin access from external links
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 60 * 60 * 24,
|
maxAge: 60 * 60 * 24,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
import { COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST(request: NextRequest) {
|
||||||
|
// Verify auth cookie exists (basic protection against cross-site logout)
|
||||||
|
const token = request.cookies.get(COOKIE_NAME)?.value;
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||||
|
}
|
||||||
const response = NextResponse.json({ ok: true });
|
const response = NextResponse.json({ ok: true });
|
||||||
response.cookies.set(COOKIE_NAME, "", {
|
response.cookies.set(COOKIE_NAME, "", {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { addMcRegistration, getMcRegistrations, getSection } from "@/lib/db";
|
import { addMcRegistrationAtomic, getSection } from "@/lib/db";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||||
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||||
import type { MasterClassItem } from "@/types/content";
|
import type { MasterClassItem } from "@/types/content";
|
||||||
@@ -32,23 +32,20 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
return NextResponse.json({ error: "Телефон обязателен" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if MC is full — if so, booking goes to waiting list
|
// Determine max participants from section config
|
||||||
const mcSection = getSection("masterClasses") as { items?: MasterClassItem[] } | null;
|
const mcSection = getSection("masterClasses") as { items?: MasterClassItem[] } | null;
|
||||||
const mcItem = mcSection?.items?.find((mc) => mc.title === cleanTitle);
|
const mcItem = mcSection?.items?.find((mc) => mc.title === cleanTitle);
|
||||||
let isWaiting = false;
|
const maxParticipants = mcItem?.maxParticipants && mcItem.maxParticipants > 0
|
||||||
if (mcItem?.maxParticipants && mcItem.maxParticipants > 0) {
|
? mcItem.maxParticipants : undefined;
|
||||||
const currentRegs = getMcRegistrations(cleanTitle);
|
|
||||||
const confirmedCount = currentRegs.filter((r) => r.status === "confirmed").length;
|
|
||||||
isWaiting = confirmedCount >= mcItem.maxParticipants;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = addMcRegistration(
|
// Atomic check-and-insert inside a transaction to prevent race condition
|
||||||
|
const { id, isWaiting } = addMcRegistrationAtomic(
|
||||||
cleanTitle,
|
cleanTitle,
|
||||||
cleanName,
|
cleanName,
|
||||||
sanitizeHandle(instagram) ?? "",
|
sanitizeHandle(instagram) ?? "",
|
||||||
sanitizeHandle(telegram),
|
sanitizeHandle(telegram),
|
||||||
cleanPhone,
|
cleanPhone,
|
||||||
isWaiting ? "Лист ожидания" : undefined
|
maxParticipants
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, id, isWaiting });
|
return NextResponse.json({ ok: true, id, isWaiting });
|
||||||
|
|||||||
@@ -14,19 +14,14 @@ import { Header } from "@/components/layout/Header";
|
|||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import { ClientShell } from "@/components/layout/ClientShell";
|
import { ClientShell } from "@/components/layout/ClientShell";
|
||||||
import { getContent } from "@/lib/content";
|
import { getContent } from "@/lib/content";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
import { OpenDay } from "@/components/sections/OpenDay";
|
import { OpenDay } from "@/components/sections/OpenDay";
|
||||||
import { getActiveOpenDay } from "@/lib/openDay";
|
import { getActiveOpenDay } from "@/lib/openDay";
|
||||||
import { getAllMcRegistrations } from "@/lib/db";
|
import { getMcRegistrationCounts } from "@/lib/db";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const content = getContent();
|
const content = getContent();
|
||||||
const openDayData = getActiveOpenDay();
|
const openDayData = getActiveOpenDay();
|
||||||
// Count MC registrations per title for capacity check
|
const mcRegCounts = getMcRegistrationCounts();
|
||||||
const allMcRegs = getAllMcRegistrations();
|
|
||||||
const mcRegCounts: Record<string, number> = {};
|
|
||||||
for (const reg of allMcRegs) mcRegCounts[reg.masterClassTitle] = (mcRegCounts[reg.masterClassTitle] || 0) + 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -348,6 +348,14 @@
|
|||||||
background: linear-gradient(90deg, transparent, rgba(201, 169, 110, 0.15), transparent);
|
background: linear-gradient(90deg, transparent, rgba(201, 169, 110, 0.15), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== No-JS Fallback ===== */
|
||||||
|
/* When JS is disabled, ensure Reveal content is visible */
|
||||||
|
noscript ~ * [style*="opacity: 0"],
|
||||||
|
.no-js [style*="opacity: 0"] {
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Reduced Motion ===== */
|
/* ===== Reduced Motion ===== */
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export function Header() {
|
|||||||
|
|
||||||
{/* Mobile menu */}
|
{/* Mobile menu */}
|
||||||
<div
|
<div
|
||||||
|
aria-hidden={!menuOpen}
|
||||||
className={`overflow-hidden transition-all duration-300 lg:hidden ${
|
className={`overflow-hidden transition-all duration-300 lg:hidden ${
|
||||||
menuOpen ? "max-h-[80vh] opacity-100" : "max-h-0 opacity-0"
|
menuOpen ? "max-h-[80vh] opacity-100" : "max-h-0 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { icons } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Flame, Heart, HeartPulse, Star, Sparkles, Music, Zap, Crown,
|
||||||
|
Dumbbell, Wind, Moon, Sun, Ribbon, Gem, Feather, CircleDot,
|
||||||
|
Activity, Drama, PersonStanding, Footprints, PartyPopper, Flower2,
|
||||||
|
Waves, Eye, Orbit, Brush, Palette, HandMetal, Theater,
|
||||||
|
} from "lucide-react";
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
|
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
|
||||||
@@ -10,13 +16,20 @@ import type { ClassItem, SiteContent } from "@/types";
|
|||||||
import { UI_CONFIG } from "@/lib/config";
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
import { formatMarkup } from "@/lib/markup";
|
import { formatMarkup } from "@/lib/markup";
|
||||||
|
|
||||||
// kebab "heart-pulse" → PascalCase "HeartPulse"
|
/** Map of kebab-case icon keys to their components (curated for dance school) */
|
||||||
function toPascal(kebab: string) {
|
const ICON_MAP: Record<string, LucideIcon> = {
|
||||||
return kebab.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
"flame": Flame, "heart": Heart, "heart-pulse": HeartPulse, "star": Star,
|
||||||
}
|
"sparkles": Sparkles, "music": Music, "zap": Zap, "crown": Crown,
|
||||||
|
"dumbbell": Dumbbell, "wind": Wind, "moon": Moon, "sun": Sun,
|
||||||
|
"ribbon": Ribbon, "gem": Gem, "feather": Feather, "circle-dot": CircleDot,
|
||||||
|
"activity": Activity, "drama": Drama, "person-standing": PersonStanding,
|
||||||
|
"footprints": Footprints, "party-popper": PartyPopper, "flower-2": Flower2,
|
||||||
|
"waves": Waves, "eye": Eye, "orbit": Orbit, "brush": Brush,
|
||||||
|
"palette": Palette, "hand-metal": HandMetal, "theater": Theater,
|
||||||
|
};
|
||||||
|
|
||||||
function getIcon(key: string) {
|
function getIcon(key: string) {
|
||||||
const Icon = icons[toPascal(key) as keyof typeof icons];
|
const Icon = ICON_MAP[key];
|
||||||
return Icon ? <Icon size={20} /> : null;
|
return Icon ? <Icon size={20} /> : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export function FAQ({ data: faq }: FAQProps) {
|
|||||||
id={`faq-panel-${idx}`}
|
id={`faq-panel-${idx}`}
|
||||||
role="region"
|
role="region"
|
||||||
aria-labelledby={`faq-button-${idx}`}
|
aria-labelledby={`faq-button-${idx}`}
|
||||||
|
aria-hidden={!isOpen}
|
||||||
className={`grid transition-all duration-300 ease-out ${
|
className={`grid transition-all duration-300 ease-out ${
|
||||||
isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ export function Hero({ data: hero }: HeroProps) {
|
|||||||
}
|
}
|
||||||
}, [totalVideos]);
|
}, [totalVideos]);
|
||||||
|
|
||||||
|
// Fallback: fade overlay after 5s even if videos haven't loaded
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (overlayRef.current && overlayRef.current.style.opacity !== "0") {
|
||||||
|
overlayRef.current.style.opacity = "0";
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scrollToNext = useCallback(() => {
|
const scrollToNext = useCallback(() => {
|
||||||
const el = sectionRef.current;
|
const el = sectionRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -95,7 +105,7 @@ export function Hero({ data: hero }: HeroProps) {
|
|||||||
{/* Mobile: single centered video */}
|
{/* Mobile: single centered video */}
|
||||||
<div className="absolute inset-0 md:hidden">
|
<div className="absolute inset-0 md:hidden">
|
||||||
<video
|
<video
|
||||||
autoPlay muted loop playsInline preload="auto"
|
autoPlay muted loop playsInline preload="metadata"
|
||||||
onCanPlayThrough={handleVideoReady}
|
onCanPlayThrough={handleVideoReady}
|
||||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||||
>
|
>
|
||||||
@@ -129,7 +139,7 @@ export function Hero({ data: hero }: HeroProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
autoPlay muted loop playsInline preload="auto"
|
autoPlay muted loop playsInline preload="metadata"
|
||||||
onCanPlayThrough={handleVideoReady}
|
onCanPlayThrough={handleVideoReady}
|
||||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Calendar, Clock, User, MapPin, Instagram, X } from "lucide-react";
|
|||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { SignupModal } from "@/components/ui/SignupModal";
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
|
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||||
import type { SiteContent, MasterClassItem, MasterClassSlot, ScheduleLocation } from "@/types";
|
import type { SiteContent, MasterClassItem, MasterClassSlot, ScheduleLocation } from "@/types";
|
||||||
import { formatMarkup } from "@/lib/markup";
|
import { formatMarkup } from "@/lib/markup";
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@ function MasterClassDetail({
|
|||||||
const slots = item.slots ?? [];
|
const slots = item.slots ?? [];
|
||||||
const duration = slots[0] ? calcDuration(slots[0]) : "";
|
const duration = slots[0] ? calcDuration(slots[0]) : "";
|
||||||
const locAddress = locations?.find(l => l.name === item.location)?.address;
|
const locAddress = locations?.find(l => l.name === item.location)?.address;
|
||||||
|
const focusTrapRef = useFocusTrap<HTMLDivElement>(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
@@ -130,6 +132,7 @@ function MasterClassDetail({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={onClose}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
|
ref={focusTrapRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={item.title}
|
aria-label={item.title}
|
||||||
@@ -158,7 +161,7 @@ function MasterClassDetail({
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Закрыть"
|
aria-label="Закрыть"
|
||||||
className="h-9 w-9 flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors shrink-0 -mr-2"
|
className="h-11 w-11 flex items-center justify-center rounded-full text-white/50 hover:text-white hover:bg-white/10 transition-colors shrink-0 -mr-2"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -264,7 +267,13 @@ function MasterClassCard({
|
|||||||
const isFull = maxP > 0 && currentRegs >= maxP;
|
const isFull = maxP > 0 && currentRegs >= maxP;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex w-full max-w-sm flex-col overflow-hidden rounded-2xl bg-black cursor-pointer" onClick={onDetail}>
|
<div
|
||||||
|
className="group relative flex w-full max-w-sm flex-col overflow-hidden rounded-2xl bg-black cursor-pointer"
|
||||||
|
onClick={onDetail}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onDetail(); } }}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
{/* Full-bleed image or placeholder */}
|
{/* Full-bleed image or placeholder */}
|
||||||
<div className="relative aspect-[3/4] sm:aspect-[2/3] w-full overflow-hidden">
|
<div className="relative aspect-[3/4] sm:aspect-[2/3] w-full overflow-hidden">
|
||||||
{item.image ? (
|
{item.image ? (
|
||||||
@@ -425,9 +434,9 @@ export function MasterClasses({ data, regCounts = {}, popups, locations }: Maste
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mx-auto mt-10 flex max-w-5xl flex-wrap justify-center gap-5">
|
<div className="mx-auto mt-10 flex max-w-5xl flex-wrap justify-center gap-5">
|
||||||
{upcoming.map((item) => (
|
{upcoming.map((item, idx) => (
|
||||||
<MasterClassCard
|
<MasterClassCard
|
||||||
key={item.title}
|
key={`${item.title}-${idx}`}
|
||||||
item={item}
|
item={item}
|
||||||
currentRegs={regCounts[item.title] ?? 0}
|
currentRegs={regCounts[item.title] ?? 0}
|
||||||
onSignup={() => setSignupTitle(item.title)}
|
onSignup={() => setSignupTitle(item.title)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
import { useTrainerPhotos } from "@/hooks/useTrainerPhotos";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Calendar, Sparkles, User, MapPin } from "lucide-react";
|
import { Calendar, Sparkles, User, MapPin } from "lucide-react";
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
@@ -33,15 +34,7 @@ export function OpenDay({ data, popups, teamMembers, locations }: OpenDayProps)
|
|||||||
const { event, classes } = data;
|
const { event, classes } = data;
|
||||||
const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
|
const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
|
||||||
|
|
||||||
const trainerPhotos = useMemo(() => {
|
const trainerPhotos = useTrainerPhotos(teamMembers);
|
||||||
const map: Record<string, string> = {};
|
|
||||||
if (teamMembers) {
|
|
||||||
for (const m of teamMembers) {
|
|
||||||
if (m.image) map[m.name] = m.image;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [teamMembers]);
|
|
||||||
|
|
||||||
// Group classes by hall
|
// Group classes by hall
|
||||||
const hallGroups = useMemo(() => {
|
const hallGroups = useMemo(() => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useReducer, useMemo, useCallback } from "react";
|
import { useReducer, useMemo, useCallback } from "react";
|
||||||
|
import { useTrainerPhotos } from "@/hooks/useTrainerPhotos";
|
||||||
import { SignupModal } from "@/components/ui/SignupModal";
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
import { CalendarDays, Users, LayoutGrid, SlidersHorizontal, MapPin } from "lucide-react";
|
import { CalendarDays, Users, LayoutGrid, SlidersHorizontal, MapPin } from "lucide-react";
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
@@ -135,15 +136,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
|||||||
|
|
||||||
const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]);
|
const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]);
|
||||||
|
|
||||||
const trainerPhotos = useMemo(() => {
|
const trainerPhotos = useTrainerPhotos(teamMembers);
|
||||||
const map: Record<string, string> = {};
|
|
||||||
if (teamMembers) {
|
|
||||||
for (const m of teamMembers) {
|
|
||||||
if (m.image) map[m.name] = m.image;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [teamMembers]);
|
|
||||||
|
|
||||||
// Build days: either from one location or merged from all
|
// Build days: either from one location or merged from all
|
||||||
const activeDays: ScheduleDayMerged[] = useMemo(() => {
|
const activeDays: ScheduleDayMerged[] = useMemo(() => {
|
||||||
@@ -251,14 +244,14 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
|||||||
classes: day.classes.filter(
|
classes: day.classes.filter(
|
||||||
(cls) => {
|
(cls) => {
|
||||||
const clsStatus = cls.status || (cls.recruiting ? "recruiting" : cls.hasSlots ? "hasSlots" : "");
|
const clsStatus = cls.status || (cls.recruiting ? "recruiting" : cls.hasSlots ? "hasSlots" : "");
|
||||||
|
const matchesTime = !activeTimeRange || (
|
||||||
|
startTimeMinutes(cls.time) >= activeTimeRange[0] && startTimeMinutes(cls.time) < activeTimeRange[1]
|
||||||
|
);
|
||||||
return (filterTrainerSet.size === 0 || filterTrainerSet.has(cls.trainer)) &&
|
return (filterTrainerSet.size === 0 || filterTrainerSet.has(cls.trainer)) &&
|
||||||
(filterTypes.size === 0 || filterTypes.has(cls.type)) &&
|
(filterTypes.size === 0 || filterTypes.has(cls.type)) &&
|
||||||
(filterStatusSet.size === 0 || (clsStatus && filterStatusSet.has(clsStatus))) &&
|
(filterStatusSet.size === 0 || (clsStatus && filterStatusSet.has(clsStatus))) &&
|
||||||
(!filterLevel || cls.level === filterLevel) &&
|
(!filterLevel || cls.level === filterLevel) &&
|
||||||
(!activeTimeRange || (() => {
|
matchesTime;
|
||||||
const m = startTimeMinutes(cls.time);
|
|
||||||
return m >= activeTimeRange[0] && m < activeTimeRange[1];
|
|
||||||
})());
|
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
.filter((day) => day.classes.length > 0);
|
.filter((day) => day.classes.length > 0);
|
||||||
@@ -384,8 +377,10 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
|||||||
{/* View mode toggle + filter button */}
|
{/* View mode toggle + filter button */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="mt-4 hidden sm:flex items-center justify-center">
|
<div className="mt-4 hidden sm:flex items-center justify-center">
|
||||||
<div className="inline-flex items-center 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 items-center rounded-xl border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.08] dark:bg-white/[0.04]" role="tablist" aria-label="Режим отображения">
|
||||||
<button
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === "days"}
|
||||||
onClick={() => dispatch({ type: "SET_VIEW", mode: "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"
|
||||||
@@ -397,6 +392,8 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
|||||||
По дням
|
По дням
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === "groups"}
|
||||||
onClick={() => dispatch({ type: "SET_VIEW", mode: "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"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { X, SlidersHorizontal } from "lucide-react";
|
import { X, SlidersHorizontal } from "lucide-react";
|
||||||
|
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||||
import {
|
import {
|
||||||
pillBase,
|
pillBase,
|
||||||
isTimeFilterActive,
|
isTimeFilterActive,
|
||||||
@@ -99,27 +100,14 @@ export function ScheduleFilters({
|
|||||||
|
|
||||||
{/* Filter modal — Airbnb style */}
|
{/* Filter modal — Airbnb style */}
|
||||||
{modalOpen && createPortal(
|
{modalOpen && createPortal(
|
||||||
<div
|
<ScheduleFilterModal modalOpen={modalOpen} onClose={() => setModalOpen(false)}>
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Фильтры"
|
|
||||||
onClick={() => setModalOpen(false)}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="relative w-full max-w-lg max-h-[85vh] flex flex-col rounded-2xl border border-white/[0.08] shadow-2xl overflow-hidden"
|
|
||||||
style={{ backgroundColor: "#171717" }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/[0.06]">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/[0.06]">
|
||||||
<h3 className="text-base font-bold text-white">Фильтры</h3>
|
<h3 className="text-base font-bold text-white">Фильтры</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setModalOpen(false)}
|
onClick={() => setModalOpen(false)}
|
||||||
aria-label="Закрыть"
|
aria-label="Закрыть"
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full text-neutral-400 hover:bg-white/[0.06] hover:text-white transition-colors cursor-pointer"
|
className="flex h-11 w-11 items-center justify-center rounded-full text-neutral-400 hover:bg-white/[0.06] hover:text-white transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -192,7 +180,7 @@ export function ScheduleFilters({
|
|||||||
{/* Level — radio list */}
|
{/* Level — radio list */}
|
||||||
{levels.length > 0 && (
|
{levels.length > 0 && (
|
||||||
<FilterSection title="Опыт">
|
<FilterSection title="Опыт">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1" role="radiogroup" aria-label="Уровень подготовки">
|
||||||
{levels.map((level) => {
|
{levels.map((level) => {
|
||||||
const desc = scheduleConfig?.levels?.find((l) => l.value === level)?.description;
|
const desc = scheduleConfig?.levels?.find((l) => l.value === level)?.description;
|
||||||
const active = filterLevel === level;
|
const active = filterLevel === level;
|
||||||
@@ -226,15 +214,15 @@ export function ScheduleFilters({
|
|||||||
</FilterSection>
|
</FilterSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* When — days + time inline */}
|
{/* When — days + time on separate rows */}
|
||||||
<FilterSection title="Когда">
|
<FilterSection title="Когда">
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-3">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1 flex-wrap">
|
||||||
{availableDays.map(({ day, dayShort }) => (
|
{availableDays.map(({ day, dayShort }) => (
|
||||||
<button
|
<button
|
||||||
key={day}
|
key={day}
|
||||||
onClick={() => toggleDay(day)}
|
onClick={() => toggleDay(day)}
|
||||||
className={`flex items-center justify-center rounded-md w-8 h-8 text-[10px] font-semibold transition-all cursor-pointer ${
|
className={`flex items-center justify-center rounded-md w-10 h-10 text-[11px] font-semibold transition-all cursor-pointer ${
|
||||||
filterDaySet.has(day)
|
filterDaySet.has(day)
|
||||||
? "bg-gold text-black"
|
? "bg-gold text-black"
|
||||||
: "bg-white/[0.04] text-neutral-400 hover:bg-white/[0.08] hover:text-white"
|
: "bg-white/[0.04] text-neutral-400 hover:bg-white/[0.08] hover:text-white"
|
||||||
@@ -244,21 +232,23 @@ export function ScheduleFilters({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="h-6 w-px bg-white/[0.08] shrink-0" />
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] text-neutral-500 shrink-0">Время</span>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={filterTime.from}
|
value={filterTime.from}
|
||||||
onChange={(e) => setFilterTime({ ...filterTime, from: e.target.value })}
|
onChange={(e) => setFilterTime({ ...filterTime, from: e.target.value })}
|
||||||
className="w-16 shrink-0 rounded-md border border-white/[0.08] bg-white/[0.04] px-1.5 py-1.5 text-[11px] text-white text-center outline-none focus:border-gold/40 transition-colors [color-scheme:dark]"
|
className="w-20 shrink-0 rounded-md border border-white/[0.08] bg-white/[0.04] px-2 py-1.5 text-[11px] text-white text-center outline-none focus:border-gold/40 transition-colors [color-scheme:dark]"
|
||||||
/>
|
/>
|
||||||
<span className="text-neutral-500 text-[10px]">—</span>
|
<span className="text-neutral-500 text-[10px]">—</span>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={filterTime.to}
|
value={filterTime.to}
|
||||||
onChange={(e) => setFilterTime({ ...filterTime, to: e.target.value })}
|
onChange={(e) => setFilterTime({ ...filterTime, to: e.target.value })}
|
||||||
className="w-16 shrink-0 rounded-md border border-white/[0.08] bg-white/[0.04] px-1.5 py-1.5 text-[11px] text-white text-center outline-none focus:border-gold/40 transition-colors [color-scheme:dark]"
|
className="w-20 shrink-0 rounded-md border border-white/[0.08] bg-white/[0.04] px-2 py-1.5 text-[11px] text-white text-center outline-none focus:border-gold/40 transition-colors [color-scheme:dark]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</FilterSection>
|
</FilterSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -277,14 +267,37 @@ export function ScheduleFilters({
|
|||||||
Показать
|
Показать
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScheduleFilterModal>,
|
||||||
</div>,
|
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ScheduleFilterModal({ modalOpen, onClose, children }: { modalOpen: boolean; onClose: () => void; children: React.ReactNode }) {
|
||||||
|
const focusTrapRef = useFocusTrap<HTMLDivElement>(modalOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={focusTrapRef}
|
||||||
|
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Фильтры"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-lg max-h-[85vh] flex flex-col rounded-2xl border border-white/[0.08] shadow-2xl overflow-hidden"
|
||||||
|
style={{ backgroundColor: "#171717" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FilterSection({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
|
function FilterSection({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
|
||||||
const [showHint, setShowHint] = useState(false);
|
const [showHint, setShowHint] = useState(false);
|
||||||
const hintRef = useRef<HTMLDivElement>(null);
|
const hintRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -343,6 +356,8 @@ function InfoTip({ text }: { text: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
|
aria-label="Подсказка"
|
||||||
|
aria-expanded={open}
|
||||||
className={`flex h-4 w-4 items-center justify-center rounded-full border text-[10px] transition-colors cursor-pointer ${
|
className={`flex h-4 w-4 items-center justify-center rounded-full border text-[10px] transition-colors cursor-pointer ${
|
||||||
open ? "border-gold/40 text-gold" : "border-white/15 text-neutral-500 hover:text-white hover:border-white/30"
|
open ? "border-gold/40 text-gold" : "border-white/15 text-neutral-500 hover:text-white hover:border-white/30"
|
||||||
}`}
|
}`}
|
||||||
@@ -350,7 +365,7 @@ function InfoTip({ text }: { text: string }) {
|
|||||||
?
|
?
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-[60] w-52">
|
<div role="tooltip" className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-[60] w-52">
|
||||||
{/* Tail behind body — always centered */}
|
{/* Tail behind body — always centered */}
|
||||||
<div className="absolute left-1/2 -translate-x-1/2 -bottom-[5px] w-2.5 h-2.5 rotate-45 bg-gold" />
|
<div className="absolute left-1/2 -translate-x-1/2 -bottom-[5px] w-2.5 h-2.5 rotate-45 bg-gold" />
|
||||||
{/* Body on top */}
|
{/* Body on top */}
|
||||||
|
|||||||
@@ -39,24 +39,35 @@ export function TeamMemberInfo({ members, activeIndex, onSelect, onOpenBio }: Te
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onOpenBio}
|
onClick={onOpenBio}
|
||||||
|
aria-label={`Подробнее о ${member.name}`}
|
||||||
className="mt-3 text-sm font-medium text-gold hover:text-gold-light transition-colors cursor-pointer"
|
className="mt-3 text-sm font-medium text-gold hover:text-gold-light transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
Подробнее →
|
Подробнее →
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Progress dots */}
|
{/* Progress dots — mobile only (desktop has carousel arrows) */}
|
||||||
<div className="mt-6 flex items-center justify-center gap-1.5">
|
<div className="mt-6 flex items-center justify-center gap-2 sm:hidden">
|
||||||
{members.map((_, i) => (
|
{members.map((m, i) => {
|
||||||
|
const isActive = i === activeIndex;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => onSelect(i)}
|
onClick={() => onSelect(i)}
|
||||||
className={`h-1.5 rounded-full transition-all duration-500 cursor-pointer ${
|
aria-label={`Перейти к ${m.name}`}
|
||||||
i === activeIndex
|
className="relative flex items-center justify-center w-8 h-8 cursor-pointer group"
|
||||||
? "w-6 bg-gold"
|
>
|
||||||
: "w-1.5 bg-white/15 hover:bg-white/30"
|
{/* Glow ring on active */}
|
||||||
}`}
|
{isActive && (
|
||||||
/>
|
<span className="absolute inset-0 rounded-full bg-gold/15 scale-100 animate-pulse" />
|
||||||
))}
|
)}
|
||||||
|
<span className={`relative block rounded-full transition-all duration-500 ${
|
||||||
|
isActive
|
||||||
|
? "w-3 h-3 bg-gold shadow-[0_0_8px_rgba(201,169,110,0.5)]"
|
||||||
|
: "w-2 h-2 bg-white/20 group-hover:bg-white/40 group-hover:scale-125"
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Clock, Ma
|
|||||||
import type { TeamMember, RichListItem, ScheduleLocation, SiteContent } from "@/types/content";
|
import type { TeamMember, RichListItem, ScheduleLocation, SiteContent } from "@/types/content";
|
||||||
import { findStatusConfig } from "@/components/sections/schedule/constants";
|
import { findStatusConfig } from "@/components/sections/schedule/constants";
|
||||||
import { SignupModal } from "@/components/ui/SignupModal";
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
|
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||||
import { formatMarkup } from "@/lib/markup";
|
import { formatMarkup } from "@/lib/markup";
|
||||||
import { GroupCard } from "@/components/ui/GroupCard";
|
import { GroupCard } from "@/components/ui/GroupCard";
|
||||||
|
|
||||||
@@ -246,30 +247,7 @@ export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamPr
|
|||||||
|
|
||||||
{/* Image lightbox */}
|
{/* Image lightbox */}
|
||||||
{lightbox && (
|
{lightbox && (
|
||||||
<div
|
<LightboxDialog src={lightbox} onClose={() => setLightbox(null)} />
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Просмотр изображения"
|
|
||||||
onClick={() => setLightbox(null)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setLightbox(null)}
|
|
||||||
aria-label="Закрыть"
|
|
||||||
className="absolute top-4 right-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20 transition-colors"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
<div className="relative max-h-[85vh] max-w-[90vw]">
|
|
||||||
<Image
|
|
||||||
src={lightbox}
|
|
||||||
alt="Достижение"
|
|
||||||
width={900}
|
|
||||||
height={900}
|
|
||||||
className="rounded-lg object-contain max-h-[85vh]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SignupModal
|
<SignupModal
|
||||||
@@ -283,6 +261,43 @@ export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamPr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LightboxDialog({ src, onClose }: { src: string; onClose: () => void }) {
|
||||||
|
const focusTrapRef = useFocusTrap<HTMLDivElement>(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => { document.body.style.overflow = ""; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={focusTrapRef}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Просмотр изображения"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className="absolute top-4 right-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
<div className="relative max-h-[85vh] max-w-[90vw]" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt="Достижение"
|
||||||
|
width={900}
|
||||||
|
height={900}
|
||||||
|
className="rounded-lg object-contain max-h-[85vh]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CollapsibleSection({ icon: Icon, title, count, children }: { icon: React.ComponentType<{ size: number }>; title: string; count: number; children: React.ReactNode }) {
|
function CollapsibleSection({ icon: Icon, title, count, children }: { icon: React.ComponentType<{ size: number }>; title: string; count: number; children: React.ReactNode }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@@ -290,6 +305,7 @@ function CollapsibleSection({ icon: Icon, title, count, children }: { icon: Reac
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
|
aria-expanded={open}
|
||||||
className="flex items-center gap-2 w-full text-left cursor-pointer group"
|
className="flex items-center gap-2 w-full text-left cursor-pointer group"
|
||||||
>
|
>
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gold/70 flex items-center gap-2">
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-gold/70 flex items-center gap-2">
|
||||||
@@ -377,7 +393,8 @@ function ScrollRow({ children }: { children: React.ReactNode }) {
|
|||||||
{canScroll.left && (
|
{canScroll.left && (
|
||||||
<button
|
<button
|
||||||
onClick={() => scrollBy(-1)}
|
onClick={() => scrollBy(-1)}
|
||||||
className="absolute left-1 top-1/2 -translate-y-1/2 z-10 rounded-full bg-black/80 border border-white/10 p-1.5 text-white/60 hover:text-white hover:bg-black/90 transition-all cursor-pointer"
|
aria-label="Прокрутить влево"
|
||||||
|
className="absolute left-1 top-1/2 -translate-y-1/2 z-10 rounded-full bg-black/80 border border-white/10 p-2.5 text-white/60 hover:text-white hover:bg-black/90 transition-all cursor-pointer"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={14} />
|
<ChevronLeft size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -385,7 +402,8 @@ function ScrollRow({ children }: { children: React.ReactNode }) {
|
|||||||
{canScroll.right && (
|
{canScroll.right && (
|
||||||
<button
|
<button
|
||||||
onClick={() => scrollBy(1)}
|
onClick={() => scrollBy(1)}
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 z-10 rounded-full bg-black/80 border border-white/10 p-1.5 text-white/60 hover:text-white hover:bg-black/90 transition-all cursor-pointer"
|
aria-label="Прокрутить вправо"
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 z-10 rounded-full bg-black/80 border border-white/10 p-2.5 text-white/60 hover:text-white hover:bg-black/90 transition-all cursor-pointer"
|
||||||
>
|
>
|
||||||
<ChevronRight size={14} />
|
<ChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -8,8 +8,15 @@ export function BackToTop() {
|
|||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let ticking = false;
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
|
if (!ticking) {
|
||||||
|
ticking = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
setVisible(window.scrollY > UI_CONFIG.scrollThresholds.backToTop);
|
setVisible(window.scrollY > UI_CONFIG.scrollThresholds.backToTop);
|
||||||
|
ticking = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
|||||||
@@ -12,14 +12,20 @@ export function FloatingContact() {
|
|||||||
const { bookingOpen: modalOpen, openBooking, closeBooking } = useBooking();
|
const { bookingOpen: modalOpen, openBooking, closeBooking } = useBooking();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let ticking = false;
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
|
if (!ticking) {
|
||||||
|
ticking = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
const contactEl = document.getElementById("contact");
|
const contactEl = document.getElementById("contact");
|
||||||
const pastHero = window.scrollY > window.innerHeight * 0.7;
|
const pastHero = window.scrollY > window.innerHeight * 0.7;
|
||||||
const reachedContact = contactEl
|
const reachedContact = contactEl
|
||||||
? window.scrollY + window.innerHeight > contactEl.offsetTop + 100
|
? window.scrollY + window.innerHeight > contactEl.offsetTop + 100
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
setVisible(pastHero && !reachedContact);
|
setVisible(pastHero && !reachedContact);
|
||||||
|
ticking = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
@@ -51,6 +57,8 @@ export function FloatingContact() {
|
|||||||
{/* Main toggle button */}
|
{/* Main toggle button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
aria-label="Контакты"
|
||||||
|
aria-expanded={expanded}
|
||||||
className={`flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all duration-300 ${
|
className={`flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all duration-300 ${
|
||||||
expanded
|
expanded
|
||||||
? "bg-neutral-700 rotate-0"
|
? "bg-neutral-700 rotate-0"
|
||||||
|
|||||||
@@ -2,6 +2,31 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/** Shared singleton IntersectionObserver for all Reveal instances */
|
||||||
|
const callbacks = new Map<Element, () => void>();
|
||||||
|
let sharedObserver: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
function getObserver(): IntersectionObserver {
|
||||||
|
if (!sharedObserver) {
|
||||||
|
sharedObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const cb = callbacks.get(entry.target);
|
||||||
|
if (cb) {
|
||||||
|
cb();
|
||||||
|
callbacks.delete(entry.target);
|
||||||
|
sharedObserver!.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1, rootMargin: "0px 0px -50px 0px" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sharedObserver;
|
||||||
|
}
|
||||||
|
|
||||||
interface RevealProps {
|
interface RevealProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -10,30 +35,33 @@ interface RevealProps {
|
|||||||
export function Reveal({ children, className = "" }: RevealProps) {
|
export function Reveal({ children, className = "" }: RevealProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [reducedMotion, setReducedMotion] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||||
|
setReducedMotion(true);
|
||||||
|
setVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = getObserver();
|
||||||
([entry]) => {
|
callbacks.set(el, () => setVisible(true));
|
||||||
if (entry.isIntersecting) {
|
|
||||||
setVisible(true);
|
|
||||||
observer.unobserve(el);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.1, rootMargin: "0px 0px -50px 0px" },
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
return () => observer.disconnect();
|
|
||||||
|
return () => {
|
||||||
|
callbacks.delete(el);
|
||||||
|
observer.unobserve(el);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={className}
|
className={className}
|
||||||
style={{
|
style={reducedMotion ? undefined : {
|
||||||
opacity: visible ? 1 : 0,
|
opacity: visible ? 1 : 0,
|
||||||
transform: visible ? "translateY(0)" : "translateY(30px)",
|
transform: visible ? "translateY(0)" : "translateY(30px)",
|
||||||
transition: "opacity 0.7s ease-out, transform 0.7s ease-out",
|
transition: "opacity 0.7s ease-out, transform 0.7s ease-out",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
|
|||||||
import { X, CheckCircle, Send, Phone as PhoneIcon, Instagram } from "lucide-react";
|
import { X, CheckCircle, Send, Phone as PhoneIcon, Instagram } from "lucide-react";
|
||||||
import { BRAND } from "@/lib/constants";
|
import { BRAND } from "@/lib/constants";
|
||||||
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||||
|
import { formatBelarusPhone } from "@/lib/formatting";
|
||||||
|
|
||||||
interface SignupModalProps {
|
interface SignupModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -51,19 +52,7 @@ export function SignupModal({
|
|||||||
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
|
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
|
||||||
|
|
||||||
function handlePhoneChange(raw: string) {
|
function handlePhoneChange(raw: string) {
|
||||||
let digits = raw.replace(/\D/g, "");
|
setPhone(formatBelarusPhone(raw));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,7 +75,7 @@ export function SignupModal({
|
|||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
const cleanPhone = phone.replace(/\D/g, "");
|
const cleanPhone = phone.replace(/\D/g, "");
|
||||||
if (cleanPhone.length < 12) {
|
if (!/^375(25|29|33|44)\d{7}$/.test(cleanPhone)) {
|
||||||
setError("Введите корректный номер телефона");
|
setError("Введите корректный номер телефона");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -288,7 +277,7 @@ export function SignupModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && error !== "network" && (
|
{error && error !== "network" && (
|
||||||
<p id="error-phone" className="text-sm text-red-400">{error}</p>
|
<p id="error-phone" role="alert" className="text-sm text-red-400">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -28,21 +28,27 @@ export function YandexMap({ addresses, height = 380 }: YandexMapProps) {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function build() {
|
async function build() {
|
||||||
const points: { lat: number; lon: number }[] = [];
|
// Geocode all addresses in parallel
|
||||||
|
const results = await Promise.allSettled(
|
||||||
for (const addr of addresses) {
|
addresses.map(async (addr) => {
|
||||||
try {
|
|
||||||
const cleaned = cleanAddress(addr);
|
const cleaned = cleanAddress(addr);
|
||||||
const query = cleaned.toLowerCase().includes("минск") ? cleaned : `Минск ${cleaned}`;
|
const query = cleaned.toLowerCase().includes("минск") ? cleaned : `Минск ${cleaned}`;
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1&countrycodes=by`
|
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1&countrycodes=by`,
|
||||||
|
{ signal: AbortSignal.timeout(5000) }
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
points.push({ lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) });
|
return { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) };
|
||||||
}
|
|
||||||
} catch { /* skip */ }
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const points = results
|
||||||
|
.filter((r): r is PromiseFulfilledResult<{ lat: number; lon: number } | null> => r.status === "fulfilled")
|
||||||
|
.map((r) => r.value)
|
||||||
|
.filter((p): p is { lat: number; lon: number } => p !== null);
|
||||||
|
|
||||||
if (cancelled || points.length === 0) return;
|
if (cancelled || points.length === 0) return;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
interface TeamMemberLike {
|
||||||
|
name: string;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a name→photo map from team members. Shared across Schedule, OpenDay, etc. */
|
||||||
|
export function useTrainerPhotos(teamMembers?: TeamMemberLike[]): Record<string, string> {
|
||||||
|
return useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
if (teamMembers) {
|
||||||
|
for (const m of teamMembers) {
|
||||||
|
if (m.image) map[m.name] = m.image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [teamMembers]);
|
||||||
|
}
|
||||||
@@ -25,19 +25,38 @@ async function hmacSign(data: string, secret: string): Promise<string> {
|
|||||||
encoder.encode(secret),
|
encoder.encode(secret),
|
||||||
{ name: "HMAC", hash: "SHA-256" },
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
false,
|
false,
|
||||||
["sign"]
|
["sign", "verify"]
|
||||||
);
|
);
|
||||||
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
||||||
return base64urlEncode(sig);
|
return base64urlEncode(sig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Constant-time comparison using Web Crypto verify */
|
||||||
|
async function hmacVerify(data: string, signature: string, secret: string): Promise<boolean> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["verify"]
|
||||||
|
);
|
||||||
|
// Decode the base64url signature back to ArrayBuffer
|
||||||
|
const sigStr = signature.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = sigStr + "=".repeat((4 - (sigStr.length % 4)) % 4);
|
||||||
|
const binary = atob(padded);
|
||||||
|
const sigBytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) sigBytes[i] = binary.charCodeAt(i);
|
||||||
|
return crypto.subtle.verify("HMAC", key, sigBytes, encoder.encode(data));
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifyToken(token: string): Promise<boolean> {
|
export async function verifyToken(token: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const [data, sig] = token.split(".");
|
const [data, sig] = token.split(".");
|
||||||
if (!data || !sig) return false;
|
if (!data || !sig) return false;
|
||||||
|
|
||||||
const expectedSig = await hmacSign(data, getSecret());
|
const valid = await hmacVerify(data, sig, getSecret());
|
||||||
if (sig !== expectedSig) return false;
|
if (!valid) return false;
|
||||||
|
|
||||||
const payload = JSON.parse(atob(data.replace(/-/g, "+").replace(/_/g, "/"))) as {
|
const payload = JSON.parse(atob(data.replace(/-/g, "+").replace(/_/g, "/"))) as {
|
||||||
role: string;
|
role: string;
|
||||||
|
|||||||
@@ -634,6 +634,33 @@ export function addMcRegistration(
|
|||||||
return result.lastInsertRowid as number;
|
return result.lastInsertRowid as number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Atomic check-and-insert: counts confirmed registrations and inserts in a single transaction */
|
||||||
|
export function addMcRegistrationAtomic(
|
||||||
|
masterClassTitle: string,
|
||||||
|
name: string,
|
||||||
|
instagram: string,
|
||||||
|
telegram?: string,
|
||||||
|
phone?: string,
|
||||||
|
maxParticipants?: number
|
||||||
|
): { id: number; isWaiting: boolean } {
|
||||||
|
const db = getDb();
|
||||||
|
const txn = db.transaction(() => {
|
||||||
|
let isWaiting = false;
|
||||||
|
if (maxParticipants) {
|
||||||
|
const row = db.prepare(
|
||||||
|
"SELECT COUNT(*) as cnt FROM mc_registrations WHERE master_class_title = ? AND status = 'confirmed'"
|
||||||
|
).get(masterClassTitle) as { cnt: number };
|
||||||
|
isWaiting = row.cnt >= maxParticipants;
|
||||||
|
}
|
||||||
|
const result = db.prepare(
|
||||||
|
`INSERT INTO mc_registrations (master_class_title, name, instagram, telegram, phone, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(masterClassTitle, name, instagram, telegram || null, phone || null, isWaiting ? "Лист ожидания" : null);
|
||||||
|
return { id: result.lastInsertRowid as number, isWaiting };
|
||||||
|
});
|
||||||
|
return txn();
|
||||||
|
}
|
||||||
|
|
||||||
export function getMcRegistrations(masterClassTitle: string): McRegistration[] {
|
export function getMcRegistrations(masterClassTitle: string): McRegistration[] {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const rows = db
|
const rows = db
|
||||||
@@ -652,6 +679,17 @@ export function getAllMcRegistrations(): McRegistration[] {
|
|||||||
return rows.map(mapMcRow);
|
return rows.map(mapMcRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Efficient count of registrations grouped by master class title */
|
||||||
|
export function getMcRegistrationCounts(): Record<string, number> {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db
|
||||||
|
.prepare("SELECT master_class_title, COUNT(*) as cnt FROM mc_registrations GROUP BY master_class_title")
|
||||||
|
.all() as { master_class_title: string; cnt: number }[];
|
||||||
|
const result: Record<string, number> = {};
|
||||||
|
for (const r of rows) result[r.master_class_title] = r.cnt;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function mapMcRow(r: McRegistrationRow): McRegistration {
|
function mapMcRow(r: McRegistrationRow): McRegistration {
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -686,11 +724,15 @@ export function updateMcRegistration(
|
|||||||
).run(name, instagram, telegram || null, id);
|
).run(name, instagram, telegram || null, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_NOTIFY_FIELDS = new Set(["notified_confirm", "notified_reminder"]);
|
||||||
|
const VALID_BOOKING_TABLES = new Set(["mc_registrations", "group_bookings", "open_day_bookings"]);
|
||||||
|
|
||||||
export function toggleMcNotification(
|
export function toggleMcNotification(
|
||||||
id: number,
|
id: number,
|
||||||
field: "notified_confirm" | "notified_reminder",
|
field: "notified_confirm" | "notified_reminder",
|
||||||
value: boolean
|
value: boolean
|
||||||
): void {
|
): void {
|
||||||
|
if (!VALID_NOTIFY_FIELDS.has(field)) throw new Error(`Invalid field: ${field}`);
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE mc_registrations SET ${field} = ? WHERE id = ?`
|
`UPDATE mc_registrations SET ${field} = ? WHERE id = ?`
|
||||||
@@ -823,6 +865,7 @@ export function toggleGroupBookingNotification(
|
|||||||
field: "notified_confirm" | "notified_reminder",
|
field: "notified_confirm" | "notified_reminder",
|
||||||
value: boolean
|
value: boolean
|
||||||
): void {
|
): void {
|
||||||
|
if (!VALID_NOTIFY_FIELDS.has(field)) throw new Error(`Invalid field: ${field}`);
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
db.prepare(`UPDATE group_bookings SET ${field} = ? WHERE id = ?`).run(
|
db.prepare(`UPDATE group_bookings SET ${field} = ? WHERE id = ?`).run(
|
||||||
value ? 1 : 0,
|
value ? 1 : 0,
|
||||||
@@ -853,6 +896,7 @@ export function setReminderStatus(
|
|||||||
id: number,
|
id: number,
|
||||||
status: ReminderStatus | null
|
status: ReminderStatus | null
|
||||||
): void {
|
): void {
|
||||||
|
if (!VALID_BOOKING_TABLES.has(table)) throw new Error(`Invalid table: ${table}`);
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
db.prepare(`UPDATE ${table} SET reminder_status = ? WHERE id = ?`).run(status, id);
|
db.prepare(`UPDATE ${table} SET reminder_status = ? WHERE id = ?`).run(status, id);
|
||||||
}
|
}
|
||||||
@@ -862,6 +906,7 @@ export function updateBookingNotes(
|
|||||||
id: number,
|
id: number,
|
||||||
notes: string
|
notes: string
|
||||||
): void {
|
): void {
|
||||||
|
if (!VALID_BOOKING_TABLES.has(table)) throw new Error(`Invalid table: ${table}`);
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
db.prepare(`UPDATE ${table} SET notes = ? WHERE id = ?`).run(notes || null, id);
|
db.prepare(`UPDATE ${table} SET notes = ? WHERE id = ?`).run(notes || null, id);
|
||||||
}
|
}
|
||||||
@@ -934,7 +979,7 @@ export function getUpcomingReminders(): ReminderItem[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch (err) { console.warn("[getUpcomingReminders] MC query error:", err); }
|
||||||
|
|
||||||
// Group bookings — confirmed with date today/tomorrow
|
// Group bookings — confirmed with date today/tomorrow
|
||||||
try {
|
try {
|
||||||
@@ -956,7 +1001,7 @@ export function getUpcomingReminders(): ReminderItem[] {
|
|||||||
eventDate: r.confirmed_date!,
|
eventDate: r.confirmed_date!,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch (err) { console.warn("[getUpcomingReminders] group booking query error:", err); }
|
||||||
|
|
||||||
// Open Day bookings — check event date
|
// Open Day bookings — check event date
|
||||||
try {
|
try {
|
||||||
@@ -986,7 +1031,7 @@ export function getUpcomingReminders(): ReminderItem[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch (err) { console.warn("[getUpcomingReminders] open day query error:", err); }
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
@@ -1384,6 +1429,7 @@ export function toggleOpenDayNotification(
|
|||||||
field: "notified_confirm" | "notified_reminder",
|
field: "notified_confirm" | "notified_reminder",
|
||||||
value: boolean
|
value: boolean
|
||||||
): void {
|
): void {
|
||||||
|
if (!VALID_NOTIFY_FIELDS.has(field)) throw new Error(`Invalid field: ${field}`);
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
db.prepare(`UPDATE open_day_bookings SET ${field} = ? WHERE id = ?`).run(value ? 1 : 0, id);
|
db.prepare(`UPDATE open_day_bookings SET ${field} = ? WHERE id = ?`).run(value ? 1 : 0, id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,4 +52,27 @@ export function parseDate(iso: string): Date {
|
|||||||
return new Date(iso + "T00:00:00");
|
return new Date(iso + "T00:00:00");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Russian weekday abbreviations */
|
||||||
|
export const SHORT_DAYS: Record<string, string> = {
|
||||||
|
"Понедельник": "ПН", "Вторник": "ВТ", "Среда": "СР", "Четверг": "ЧТ",
|
||||||
|
"Пятница": "ПТ", "Суббота": "СБ", "Воскресенье": "ВС",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Format a raw phone input into +375 (XX) XXX-XX-XX format */
|
||||||
|
export function formatBelarusPhone(raw: string): 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);
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
export { MONTHS_RU, WEEKDAYS_RU };
|
export { MONTHS_RU, WEEKDAYS_RU };
|
||||||
|
|||||||
@@ -52,11 +52,20 @@ export function checkRateLimit(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract client IP from request headers.
|
* Extract client IP from request headers.
|
||||||
|
* Prefers Vercel's trusted header, falls back to last x-forwarded-for hop.
|
||||||
*/
|
*/
|
||||||
export function getClientIp(request: Request): string {
|
export function getClientIp(request: Request): string {
|
||||||
return (
|
// Vercel sets this header and it cannot be spoofed by the client
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
const vercelIp = request.headers.get("x-vercel-forwarded-for");
|
||||||
request.headers.get("x-real-ip") ||
|
if (vercelIp) return vercelIp.split(",")[0]?.trim() || "unknown";
|
||||||
"unknown"
|
|
||||||
);
|
// For non-Vercel deployments, use the last (rightmost) IP from x-forwarded-for
|
||||||
|
// (the one added by the closest trusted reverse proxy)
|
||||||
|
const xff = request.headers.get("x-forwarded-for");
|
||||||
|
if (xff) {
|
||||||
|
const parts = xff.split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
return parts[parts.length - 1] || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.headers.get("x-real-ip") || "unknown";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function proxy(request: NextRequest) {
|
|||||||
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
|
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: "strict",
|
sameSite: "lax",
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 60 * 60 * 24,
|
maxAge: 60 * 60 * 24,
|
||||||
});
|
});
|
||||||
|
|||||||