Compare commits
17 Commits
bbe485d8fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1571b63ec3 | |||
| 8c84da279e | |||
| 03d3cad0a7 | |||
| 89f132634d | |||
| b7eacce479 | |||
| a832af9344 | |||
| b738976111 | |||
| a00fdaa760 | |||
| 9f86bcbce9 | |||
| 28afcc18bc | |||
| b9510213d7 | |||
| bac46aeb34 | |||
| 3621503470 | |||
| a080ef5a8e | |||
| 97663c514e | |||
| 0e626451e7 | |||
| a587736dd3 |
@@ -7,7 +7,7 @@ Content language: Russian
|
||||
|
||||
## Tech Stack
|
||||
- **Next.js 16** (App Router, TypeScript, Turbopack)
|
||||
- **Tailwind CSS v4** (dark mode only, gold/black theme)
|
||||
- **Tailwind CSS v4** (dual theme: dark default + light, gold accent)
|
||||
- **lucide-react** for icons
|
||||
- **better-sqlite3** for SQLite database
|
||||
- **Fonts**: Inter (body) + Oswald (headings) via `next/font`
|
||||
@@ -111,8 +111,9 @@ src/
|
||||
|
||||
## Brand / Styling
|
||||
- **Accent**: gold (`#c9a96e` / `hsl(37, 42%, 61%)`)
|
||||
- **Background**: `#050505` – `#0a0a0a` (dark only)
|
||||
- **Surface**: `#171717` dark cards
|
||||
- **Dark theme** (default): background `#050505`–`#0a0a0a`, surface `#171717`, text `neutral-100`
|
||||
- **Light theme**: background `white`/`neutral-50`, surface `white`, text `neutral-900`
|
||||
- Theme toggle via `ThemeToggle` component, `.dark` class on `<html>`, stored in `localStorage`
|
||||
- Logo: transparent PNG heart with gold glow, uses `unoptimized`
|
||||
|
||||
## Content Data
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const securityHeaders = [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||
...(process.env.NODE_ENV === "production"
|
||||
? [{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }]
|
||||
: []),
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["better-sqlite3"],
|
||||
allowedDevOrigins: [
|
||||
"black-heart.dolgolyov-family.by",
|
||||
"192.168.2.56",
|
||||
],
|
||||
headers: async () => [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
|
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 { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
let nextItemId = 1;
|
||||
|
||||
interface ArrayEditorProps<T> {
|
||||
items: T[];
|
||||
onChange: (items: T[]) => void;
|
||||
@@ -50,6 +52,19 @@ export function ArrayEditor<T>({
|
||||
const [droppedIndex, setDroppedIndex] = useState<number | null>(null);
|
||||
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) {
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -76,6 +91,7 @@ export function ArrayEditor<T>({
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
stableKeysRef.current.splice(index, 1);
|
||||
onChange(items.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
@@ -142,6 +158,11 @@ export function ArrayEditor<T>({
|
||||
const updated = [...items];
|
||||
const [moved] = updated.splice(capturedDrag, 1);
|
||||
updated.splice(targetIndex, 0, moved);
|
||||
// Sync stable keys
|
||||
const keys = [...stableKeysRef.current];
|
||||
const [movedKey] = keys.splice(capturedDrag, 1);
|
||||
keys.splice(targetIndex, 0, movedKey);
|
||||
stableKeysRef.current = keys;
|
||||
onChange(updated);
|
||||
setDroppedIndex(targetIndex);
|
||||
setTimeout(() => setDroppedIndex(null), 1500);
|
||||
@@ -167,17 +188,17 @@ export function ArrayEditor<T>({
|
||||
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
key={getStableKey(i)}
|
||||
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 ${
|
||||
newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-white/10"
|
||||
className={`rounded-lg border bg-neutral-100/80 mb-3 hover:border-neutral-300 dark:hover:border-white/25 hover:bg-neutral-200/50 focus-within:border-gold/50 focus-within:bg-neutral-200 transition-all dark:bg-neutral-900/50 dark:hover:bg-neutral-800/50 dark:focus-within:bg-neutral-800 ${
|
||||
newItemIndex === i || droppedIndex === i ? "border-gold/40 ring-1 ring-gold/20" : "border-neutral-200 dark:border-white/10"
|
||||
} ${isHidden ? "hidden" : ""}`}
|
||||
>
|
||||
{inline ? (
|
||||
/* Inline: grip + content + delete on one row */
|
||||
<div className="flex items-start gap-1.5 p-1.5">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 mt-1.5 text-neutral-500 hover:text-white transition-colors select-none shrink-0"
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 mt-1.5 text-neutral-500 hover:text-neutral-900 transition-colors select-none shrink-0 dark:hover:text-white"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
aria-label="Перетащить для сортировки"
|
||||
role="button"
|
||||
@@ -201,7 +222,7 @@ export function ArrayEditor<T>({
|
||||
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-neutral-900 transition-colors select-none dark:hover:text-white"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
aria-label="Перетащить для сортировки"
|
||||
role="button"
|
||||
@@ -215,7 +236,7 @@ export function ArrayEditor<T>({
|
||||
aria-expanded={!isCollapsed}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group"
|
||||
>
|
||||
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
|
||||
<span className="text-sm font-medium text-neutral-700 truncate group-hover:text-neutral-900 transition-colors dark:text-neutral-300 dark:group-hover:text-white">{title}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
||||
</button>
|
||||
@@ -281,15 +302,13 @@ export function ArrayEditor<T>({
|
||||
const title = getItemTitle?.(item, i) || `#${i + 1}`;
|
||||
elements.push(
|
||||
<div
|
||||
key={i}
|
||||
key={getStableKey(i)}
|
||||
ref={(el) => { itemRefs.current[i] = el; }}
|
||||
className={`rounded-lg border bg-neutral-900/50 mb-3 transition-colors ${
|
||||
"border-white/10"
|
||||
}`}
|
||||
className="rounded-lg border border-neutral-200 bg-neutral-100/80 mb-3 transition-colors dark:border-white/10 dark:bg-neutral-900/50"
|
||||
>
|
||||
{inline ? (
|
||||
<div className="flex items-start gap-1.5 p-1.5">
|
||||
<div className="cursor-grab active:cursor-grabbing rounded p-1 mt-1.5 text-neutral-500 hover:text-white transition-colors select-none shrink-0"
|
||||
<div className="cursor-grab active:cursor-grabbing rounded p-1 mt-1.5 text-neutral-500 hover:text-neutral-900 transition-colors select-none shrink-0 dark:hover:text-white"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)} aria-label="Перетащить для сортировки" role="button">
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
@@ -306,7 +325,7 @@ export function ArrayEditor<T>({
|
||||
<div className={`flex items-center justify-between gap-2 p-4 ${isCollapsed ? "" : "pb-0 mb-3"}`}>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-neutral-900 transition-colors select-none dark:hover:text-white"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
aria-label="Перетащить для сортировки"
|
||||
role="button"
|
||||
@@ -315,7 +334,7 @@ export function ArrayEditor<T>({
|
||||
</div>
|
||||
{collapsible && (
|
||||
<button type="button" onClick={() => toggleCollapse(i)} className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group">
|
||||
<span className="text-sm font-medium text-neutral-300 truncate group-hover:text-white transition-colors">{title}</span>
|
||||
<span className="text-sm font-medium text-neutral-700 truncate group-hover:text-neutral-900 transition-colors dark:text-neutral-300 dark:group-hover:text-white">{title}</span>
|
||||
{getItemBadge?.(item, i)}
|
||||
<ChevronDown size={14} className={`text-neutral-500 transition-transform duration-200 shrink-0 ${isCollapsed ? "" : "rotate-180"}`} />
|
||||
</button>
|
||||
@@ -363,14 +382,14 @@ export function ArrayEditor<T>({
|
||||
<div>
|
||||
{(label || (collapsible && items.length > 1)) && (
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
{label ? <h3 className="text-sm font-medium text-neutral-300">{label}</h3> : <div />}
|
||||
{label ? <h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{label}</h3> : <div />}
|
||||
{collapsible && items.length > 1 && (() => {
|
||||
const allCollapsed = collapsed.size >= items.length;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => allCollapsed ? setCollapsed(new Set()) : setCollapsed(new Set(items.map((_, i) => i)))}
|
||||
className="rounded p-1 text-neutral-500 hover:text-white transition-colors"
|
||||
className="rounded p-1 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
|
||||
title={allCollapsed ? "Развернуть все" : "Свернуть все"}
|
||||
aria-label={allCollapsed ? "Развернуть все" : "Свернуть все"}
|
||||
>
|
||||
@@ -385,6 +404,7 @@ export function ArrayEditor<T>({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
stableKeysRef.current = [nextItemId++, ...stableKeysRef.current];
|
||||
onChange([createItem(), ...items]);
|
||||
setNewItemIndex(0);
|
||||
// Shift collapsed indices and ensure new item is expanded
|
||||
@@ -394,7 +414,7 @@ export function ArrayEditor<T>({
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="mb-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
|
||||
className="mb-3 flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors dark:border-white/20 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/40"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{addLabel}
|
||||
@@ -409,11 +429,12 @@ export function ArrayEditor<T>({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
stableKeysRef.current.push(nextItemId++);
|
||||
onChange([...items, createItem()]);
|
||||
setNewItemIndex(items.length);
|
||||
setCollapsed(prev => { const next = new Set(prev); next.delete(items.length); return next; });
|
||||
}}
|
||||
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-white/20 px-4 py-2.5 text-sm text-neutral-400 hover:text-white hover:border-white/40 transition-colors"
|
||||
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors dark:border-white/20 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/40"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{addLabel}
|
||||
@@ -432,9 +453,9 @@ export function ArrayEditor<T>({
|
||||
height: dragSize.h,
|
||||
}}
|
||||
>
|
||||
<div className="h-full rounded-lg border-2 border-gold/60 bg-neutral-900/95 shadow-2xl shadow-gold/20 flex items-center gap-3 px-4">
|
||||
<div className="h-full rounded-lg border-2 border-gold/60 bg-white/95 shadow-2xl shadow-gold/20 flex items-center gap-3 px-4 dark:bg-neutral-900/95">
|
||||
<GripVertical size={16} className="text-gold shrink-0" />
|
||||
<span className="text-sm text-neutral-300">{collapsible && dragIndex !== null ? (getItemTitle?.(items[dragIndex], dragIndex) || "Перемещение...") : "Перемещение элемента..."}</span>
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">{collapsible && dragIndex !== null ? (getItemTitle?.(items[dragIndex], dragIndex) || "Перемещение...") : "Перемещение элемента..."}</span>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
@@ -29,15 +29,15 @@ export function CollapsibleSection({
|
||||
const toggle = onToggle ?? (() => setInternalOpen((v) => !v));
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-neutral-900/30 overflow-hidden">
|
||||
<div className="rounded-xl border border-neutral-200 bg-neutral-100/50 overflow-hidden dark:border-white/10 dark:bg-neutral-800/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
className="flex items-center justify-between w-full px-5 py-3.5 text-left cursor-pointer group hover:bg-white/[0.02] transition-colors"
|
||||
className="flex items-center justify-between w-full px-5 py-3.5 text-left cursor-pointer group hover:bg-neutral-100 transition-colors dark:hover:bg-white/[0.02]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-neutral-200 group-hover:text-white transition-colors">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 group-hover:text-neutral-900 transition-colors dark:text-neutral-200 dark:group-hover:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
{count !== undefined && (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
@@ -26,6 +27,7 @@ export function ConfirmDialog({
|
||||
destructive = true,
|
||||
}: ConfirmDialogProps) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -41,6 +43,7 @@ export function ConfirmDialog({
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={focusTrapRef}
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
@@ -49,13 +52,13 @@ export function ConfirmDialog({
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-neutral-900 p-6 shadow-2xl"
|
||||
className="relative w-full max-w-sm rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-neutral-900"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
aria-label="Закрыть"
|
||||
className="absolute right-3 top-3 rounded-full p-1 text-neutral-500 hover:text-white transition-colors"
|
||||
className="absolute right-3 top-3 rounded-full p-1 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -67,8 +70,8 @@ export function ConfirmDialog({
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">{title}</h3>
|
||||
<p className="mt-1.5 text-sm text-neutral-400">{message}</p>
|
||||
<h3 className="text-base font-bold text-neutral-900 dark:text-white">{title}</h3>
|
||||
<p className="mt-1.5 text-sm text-neutral-600 dark:text-neutral-400">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +79,7 @@ export function ConfirmDialog({
|
||||
<button
|
||||
ref={cancelRef}
|
||||
onClick={onCancel}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-neutral-300 hover:bg-white/[0.06] transition-colors cursor-pointer"
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 transition-colors cursor-pointer dark:text-neutral-300 dark:hover:bg-white/[0.06]"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
|
||||
@@ -12,10 +12,10 @@ interface InputFieldProps {
|
||||
type?: "text" | "url" | "tel";
|
||||
}
|
||||
|
||||
const baseInput = "w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none hover:border-gold/30 focus:border-gold transition-colors";
|
||||
const baseInput = "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500";
|
||||
const textAreaInput = `${baseInput} resize-none overflow-hidden`;
|
||||
const smallInput = "rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white placeholder-neutral-600 outline-none hover:border-gold/30 focus:border-gold transition-colors";
|
||||
const dashedInput = "flex-1 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-4 py-2 text-sm text-white placeholder-neutral-600 outline-none hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors";
|
||||
const smallInput = "rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-600";
|
||||
const dashedInput = "flex-1 rounded-lg border border-dashed border-neutral-200 bg-neutral-100/50 px-4 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors dark:border-white/10 dark:bg-neutral-800/50 dark:text-white dark:placeholder-neutral-600";
|
||||
const inputCls = baseInput;
|
||||
|
||||
export function InputField({
|
||||
@@ -311,7 +311,7 @@ export function RichTextarea({
|
||||
`rounded p-1.5 transition-colors ${
|
||||
active
|
||||
? "text-gold bg-gold/15"
|
||||
: "text-neutral-500 hover:text-white hover:bg-white/10"
|
||||
: "text-neutral-500 hover:text-neutral-900 hover:bg-neutral-200 dark:hover:text-white dark:hover:bg-white/10"
|
||||
}`;
|
||||
|
||||
// Preview mode: show rendered markup
|
||||
@@ -324,9 +324,9 @@ export function RichTextarea({
|
||||
setEditing(true);
|
||||
requestAnimationFrame(() => ref.current?.focus());
|
||||
}}
|
||||
className="group rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 cursor-text hover:border-gold/30 transition-colors relative"
|
||||
className="group rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 cursor-text hover:border-gold/30 transition-colors relative dark:border-white/10 dark:bg-neutral-800"
|
||||
>
|
||||
<div className="text-sm leading-relaxed text-neutral-300">
|
||||
<div className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
|
||||
{formatMarkup(value)}
|
||||
</div>
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
@@ -343,9 +343,9 @@ export function RichTextarea({
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<div className="rounded-lg border border-white/10 bg-neutral-800 overflow-hidden hover:border-gold/30 focus-within:border-gold transition-colors">
|
||||
<div className="rounded-lg border border-neutral-200 bg-neutral-100 overflow-hidden hover:border-gold/30 focus-within:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-0.5 px-2 py-1 border-b border-white/5">
|
||||
<div className="flex items-center gap-0.5 px-2 py-1 border-b border-neutral-200 dark:border-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
@@ -396,7 +396,7 @@ export function RichTextarea({
|
||||
onBlur={() => setEditing(false)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none resize-none"
|
||||
className="w-full bg-transparent px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none resize-none dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -480,8 +480,8 @@ export function SelectField({
|
||||
{label}
|
||||
{hint && (
|
||||
<span className="group relative">
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-white/15 text-[10px] text-neutral-500 hover:text-white hover:border-white/30 transition-colors cursor-help">?</span>
|
||||
<span className="absolute left-6 top-1/2 -translate-y-1/2 z-50 w-52 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-[11px] leading-relaxed text-neutral-300 shadow-xl opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity">
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-neutral-300 text-[10px] text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors cursor-help dark:border-white/15 dark:hover:text-white dark:hover:border-white/30">?</span>
|
||||
<span className="absolute left-6 top-1/2 -translate-y-1/2 z-50 w-52 rounded-lg border border-neutral-200 bg-white px-3 py-2 text-[11px] leading-relaxed text-neutral-700 shadow-xl opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
{hint}
|
||||
</span>
|
||||
</span>
|
||||
@@ -500,9 +500,9 @@ export function SelectField({
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
placeholder={placeholder || "Выберите..."}
|
||||
className={`w-full rounded-lg border bg-neutral-800 outline-none transition-colors ${
|
||||
className={`w-full rounded-lg border bg-neutral-100 text-neutral-900 outline-none transition-colors dark:bg-neutral-800 dark:text-white ${
|
||||
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||
} ${open ? "border-gold" : "border-white/10"} ${!open && value ? "text-white" : "text-white"} placeholder-neutral-500`}
|
||||
} ${open ? "border-gold" : "border-neutral-200 dark:border-white/10"} placeholder-neutral-400 dark:placeholder-neutral-500`}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
@@ -511,16 +511,16 @@ export function SelectField({
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className={`w-full rounded-lg border bg-neutral-800 text-left outline-none transition-colors ${
|
||||
className={`w-full rounded-lg border bg-neutral-100 text-left outline-none transition-colors dark:bg-neutral-800 ${
|
||||
label ? "px-4 py-2.5" : "px-2 py-1 text-xs"
|
||||
} ${open ? "border-gold" : "border-white/10"} ${value ? "text-white" : "text-neutral-500"}`}
|
||||
} ${open ? "border-gold" : "border-neutral-200 dark:border-white/10"} ${value ? "text-neutral-900 dark:text-white" : "text-neutral-500"}`}
|
||||
>
|
||||
{selectedLabel || placeholder || "Выберите..."}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div role="listbox" className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||
<div role="listbox" className="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/10 dark:bg-neutral-800">
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-4 py-2 text-sm text-neutral-500">Ничего не найдено</div>
|
||||
@@ -541,8 +541,8 @@ export function SelectField({
|
||||
inputRef.current?.blur();
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left text-sm transition-colors ${
|
||||
idx === highlightIndex ? "bg-white/10" : "hover:bg-white/5"
|
||||
} ${opt.value === value ? "text-gold bg-gold/5" : "text-white"}`}
|
||||
idx === highlightIndex ? "bg-neutral-100 dark:bg-white/10" : "hover:bg-neutral-50 dark:hover:bg-white/5"
|
||||
} ${opt.value === value ? "text-gold bg-gold/5" : "text-neutral-900 dark:text-white"}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
@@ -598,7 +598,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
|
||||
value={start}
|
||||
onChange={(e) => handleStartChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2.5 text-neutral-900 outline-none focus:border-gold transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]"
|
||||
/>
|
||||
<span className="text-neutral-500">–</span>
|
||||
<input
|
||||
@@ -606,7 +606,7 @@ export function TimeRangeField({ label, value, onChange, onBlur }: TimeRangeFiel
|
||||
value={end}
|
||||
onChange={(e) => handleEndChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-3 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2.5 text-neutral-900 outline-none focus:border-gold transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -677,7 +677,7 @@ export function ListField({ label, items, onChange, placeholder }: ListFieldProp
|
||||
type="text"
|
||||
value={item}
|
||||
onChange={(e) => update(i, e.target.value)}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-4 py-2 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2 text-sm text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -724,6 +724,7 @@ interface VictoryListFieldProps {
|
||||
export function VictoryListField({ label, items, onChange, placeholder, onLinkValidate, onUploadComplete }: VictoryListFieldProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
|
||||
function add() {
|
||||
const val = draft.trim();
|
||||
@@ -752,6 +753,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploadingIndex(index);
|
||||
setUploadError("");
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("folder", "team");
|
||||
@@ -761,8 +763,12 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
if (result.path) {
|
||||
onChange(items.map((item, i) => (i === index ? { ...item, image: result.path } : item)));
|
||||
onUploadComplete?.();
|
||||
} else {
|
||||
setUploadError(result.error || "Ошибка загрузки");
|
||||
}
|
||||
} catch { /* upload failed */ } finally {
|
||||
} catch {
|
||||
setUploadError("Не удалось загрузить файл");
|
||||
} finally {
|
||||
setUploadingIndex(null);
|
||||
}
|
||||
}
|
||||
@@ -772,13 +778,13 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<div className="space-y-2">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="rounded-lg border border-white/10 bg-neutral-800/50 p-2.5 space-y-1.5 transition-colors hover:border-gold/30 hover:bg-neutral-800/80 focus-within:border-gold/50 focus-within:bg-neutral-800">
|
||||
<div key={i} className="rounded-lg border border-neutral-200 bg-neutral-100/80 p-2.5 space-y-1.5 transition-colors hover:border-gold/30 hover:bg-neutral-200/80 focus-within:border-gold/50 focus-within:bg-neutral-200 dark:border-white/10 dark:bg-neutral-800/50 dark:hover:bg-neutral-800/80 dark:focus-within:bg-neutral-800">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={item.text}
|
||||
onChange={(e) => updateText(i, e.target.value)}
|
||||
className="flex-1 rounded-md border border-white/10 bg-neutral-800 px-2.5 py-1.5 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||
className="flex-1 rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -790,7 +796,7 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.image ? (
|
||||
<div className="flex items-center gap-1 rounded bg-neutral-700/50 px-1.5 py-0.5 text-[11px] text-neutral-300">
|
||||
<div className="flex items-center gap-1 rounded bg-neutral-200 px-1.5 py-0.5 text-[11px] text-neutral-700 dark:bg-neutral-700/50 dark:text-neutral-300">
|
||||
<ImageIcon size={10} className="text-gold" />
|
||||
<span className="max-w-[80px] truncate">{item.image.split("/").pop()}</span>
|
||||
<button type="button" onClick={() => removeImage(i)} className="text-neutral-500 hover:text-red-400">
|
||||
@@ -833,6 +839,9 @@ export function VictoryListField({ label, items, onChange, placeholder, onLinkVa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{uploadError && (
|
||||
<p role="alert" className="mt-1.5 text-xs text-red-400">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -877,8 +886,8 @@ export function ValidatedLinkField({ value, onChange, onValidate, validationKey,
|
||||
validate(e.target.value);
|
||||
}}
|
||||
placeholder={placeholder || "Ссылка..."}
|
||||
className={`w-full rounded-md border bg-neutral-800 px-2 py-1 text-xs text-white placeholder-neutral-600 outline-none transition-colors ${
|
||||
error ? "border-red-500/50" : "border-white/5 focus:border-gold/50"
|
||||
className={`w-full rounded-md border bg-neutral-100 px-2 py-1 text-xs text-neutral-900 placeholder-neutral-400 outline-none transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-600 ${
|
||||
error ? "border-red-500/50" : "border-neutral-200 focus:border-gold/50 dark:border-white/5"
|
||||
}`}
|
||||
/>
|
||||
{error && (
|
||||
@@ -956,8 +965,8 @@ export function AutocompleteMulti({
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<div
|
||||
onClick={() => { setOpen(true); inputRef.current?.focus(); }}
|
||||
className={`flex flex-wrap items-center gap-1.5 rounded-lg border bg-neutral-800 px-3 py-2 min-h-[42px] cursor-text transition-colors ${
|
||||
open ? "border-gold" : "border-white/10 hover:border-gold/30"
|
||||
className={`flex flex-wrap items-center gap-1.5 rounded-lg border bg-neutral-100 px-3 py-2 min-h-[42px] cursor-text transition-colors dark:bg-neutral-800 ${
|
||||
open ? "border-gold" : "border-neutral-200 hover:border-gold/30 dark:border-white/10"
|
||||
}`}
|
||||
>
|
||||
{selected.map((item) => (
|
||||
@@ -976,14 +985,14 @@ export function AutocompleteMulti({
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={selected.length === 0 ? placeholder : ""}
|
||||
className="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-neutral-500 outline-none"
|
||||
className="flex-1 min-w-[80px] bg-transparent text-sm text-neutral-900 placeholder-neutral-400 outline-none dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden max-h-48 overflow-y-auto">
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden max-h-48 overflow-y-auto dark:border-white/10 dark:bg-neutral-800">
|
||||
{filtered.map((opt) => (
|
||||
<button key={opt} type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => addItem(opt)}
|
||||
className="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/5 transition-colors">
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-900 hover:bg-neutral-50 transition-colors dark:text-white dark:hover:bg-white/5">
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -34,6 +34,7 @@ export function ImageCropField({
|
||||
label = "Фото",
|
||||
}: ImageCropFieldProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragStartRef = useRef({ x: 0, y: 0, startFocalX: 0, startFocalY: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -42,6 +43,7 @@ export function ImageCropField({
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setUploadError("");
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("folder", folder);
|
||||
@@ -53,8 +55,12 @@ export function ImageCropField({
|
||||
const result = await res.json();
|
||||
if (result.path) {
|
||||
onChange({ image: result.path, focalX: 50, focalY: 50, zoom: 1 });
|
||||
} else {
|
||||
setUploadError(result.error || "Ошибка загрузки");
|
||||
}
|
||||
} catch { /* upload failed */ } finally {
|
||||
} catch {
|
||||
setUploadError("Не удалось загрузить файл");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
@@ -100,14 +106,14 @@ export function ImageCropField({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">
|
||||
{label} <span className="text-neutral-600">(перетащите · Ctrl+колёсико для масштаба)</span>
|
||||
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">
|
||||
{label} <span className="text-neutral-400 dark:text-neutral-600">(перетащите · Ctrl+колёсико для масштаба)</span>
|
||||
</label>
|
||||
{image ? (
|
||||
<div className={`${maxWidth} space-y-2`}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative ${aspect} overflow-hidden rounded-lg border border-white/10 cursor-grab active:cursor-grabbing select-none`}
|
||||
className={`relative ${aspect} overflow-hidden rounded-lg border border-neutral-200 cursor-grab active:cursor-grabbing select-none dark:border-white/10`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
@@ -149,7 +155,7 @@ export function ImageCropField({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-white/10 px-2.5 py-1 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-neutral-200 px-2.5 py-1 text-xs text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25">
|
||||
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||||
Заменить
|
||||
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
@@ -164,12 +170,15 @@ export function ImageCropField({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-white/15 px-4 py-2.5 text-neutral-500 hover:border-gold/30 hover:text-neutral-300 transition-colors">
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-neutral-500 hover:border-gold/30 hover:text-neutral-700 transition-colors dark:border-white/15 dark:hover:text-neutral-300">
|
||||
{uploading ? <Loader2 size={14} className="animate-spin" /> : <ImageIcon size={14} />}
|
||||
<span className="text-xs">{uploading ? "Загрузка..." : "Загрузить фото"}</span>
|
||||
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
</label>
|
||||
)}
|
||||
{uploadError && (
|
||||
<p role="alert" className="mt-1.5 text-xs text-red-400">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,17 +12,18 @@ export function PriceField({ label, value, onChange, placeholder = "0" }: PriceF
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
||||
<div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors">
|
||||
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">{label}</label>
|
||||
<div className="flex rounded-lg border border-neutral-200 bg-neutral-100 focus-within:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={raw}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
const v = e.target.value.replace(/[^\d.,\s]/g, "");
|
||||
onChange(v ? `${v} BYN` : "");
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none min-w-0"
|
||||
className="flex-1 bg-transparent px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none min-w-0 dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
<span className="flex items-center pr-4 text-sm font-medium text-gold select-none">
|
||||
BYN
|
||||
|
||||
@@ -24,11 +24,13 @@ export function SectionEditor<T>({
|
||||
}: SectionEditorProps<T>) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error" | "invalid">("idle");
|
||||
const [error, setError] = useState("");
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const initialLoadRef = useRef(true);
|
||||
const pendingSaveRef = useRef(false);
|
||||
const defaultDataRef = useRef(defaultData);
|
||||
defaultDataRef.current = defaultData;
|
||||
|
||||
useEffect(() => {
|
||||
adminFetch(`/api/admin/sections/${sectionKey}`)
|
||||
@@ -36,7 +38,7 @@ export function SectionEditor<T>({
|
||||
if (!r.ok) throw new Error("Failed to load");
|
||||
return r.json();
|
||||
})
|
||||
.then((loaded) => setData(defaultData ? { ...defaultData, ...loaded } as T : loaded))
|
||||
.then((loaded) => setData(defaultDataRef.current ? { ...defaultDataRef.current, ...loaded } as T : loaded))
|
||||
.catch(() => setError("Не удалось загрузить данные"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [sectionKey]);
|
||||
@@ -72,7 +74,10 @@ export function SectionEditor<T>({
|
||||
pendingSaveRef.current = true;
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (validate && !validate(data)) return;
|
||||
if (validate && !validate(data)) {
|
||||
setStatus("invalid");
|
||||
return;
|
||||
}
|
||||
save(data);
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
@@ -134,7 +139,7 @@ export function SectionEditor<T>({
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
|
||||
{/* 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 ${
|
||||
status === "saved"
|
||||
? "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 === "error" && <><AlertCircle size={14} /> {error}</>}
|
||||
{status === "invalid" && <><AlertCircle size={14} /> Не сохранено — исправьте ошибки</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Admin UI Primitives
|
||||
*
|
||||
* Single source of truth for admin panel styling.
|
||||
* Every input, select, button, badge, card, and modal in /admin should use these.
|
||||
*/
|
||||
|
||||
import { type ComponentPropsWithoutRef, forwardRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||
|
||||
/* ============================== */
|
||||
/* Style tokens */
|
||||
/* ============================== */
|
||||
|
||||
export const adminStyles = {
|
||||
/** Standard input — full width, rounded-lg */
|
||||
input:
|
||||
"w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500",
|
||||
|
||||
/** Compact input — smaller padding, text-sm */
|
||||
inputSm:
|
||||
"rounded-md border border-neutral-200 bg-neutral-100 px-2.5 py-1.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-600",
|
||||
|
||||
/** Dashed input — for "add new" fields */
|
||||
inputDashed:
|
||||
"flex-1 rounded-lg border border-dashed border-neutral-200 bg-neutral-100/50 px-4 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 hover:placeholder-neutral-500 focus:border-gold/50 transition-colors dark:border-white/10 dark:bg-neutral-800/50 dark:text-white dark:placeholder-neutral-600",
|
||||
|
||||
/** Textarea — same as input + resize-none */
|
||||
textarea:
|
||||
"w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors resize-none dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500",
|
||||
|
||||
/** Native select */
|
||||
select:
|
||||
"w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 transition-colors [color-scheme:light] dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark]",
|
||||
|
||||
/** Select option */
|
||||
option: "bg-white dark:bg-neutral-900",
|
||||
|
||||
/** Primary button — gold solid */
|
||||
btnPrimary:
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg bg-gold px-4 py-2.5 text-sm font-medium text-black transition-all hover:bg-gold-light hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
|
||||
/** Secondary button — outline */
|
||||
btnSecondary:
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-200 dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700",
|
||||
|
||||
/** Small gold accent button */
|
||||
btnGoldSm:
|
||||
"rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-amber-700 hover:bg-gold/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed dark:text-gold",
|
||||
|
||||
/** Cancel/muted small button */
|
||||
btnCancelSm:
|
||||
"rounded-md border border-neutral-200 px-3 py-1 text-xs text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25",
|
||||
|
||||
/** Danger button */
|
||||
btnDanger:
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg bg-red-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-red-500",
|
||||
|
||||
/** Card container */
|
||||
card:
|
||||
"rounded-xl border border-neutral-200 bg-white p-5 dark:border-white/10 dark:bg-neutral-900",
|
||||
|
||||
/** Modal overlay */
|
||||
modalOverlay:
|
||||
"fixed inset-0 z-50 flex items-center justify-center p-4",
|
||||
|
||||
/** Modal backdrop */
|
||||
modalBackdrop:
|
||||
"absolute inset-0 bg-black/70 backdrop-blur-sm",
|
||||
|
||||
/** Modal content */
|
||||
modalContent:
|
||||
"relative w-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]",
|
||||
|
||||
/** Modal close button */
|
||||
modalClose:
|
||||
"absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 transition-colors cursor-pointer dark:hover:bg-white/[0.06] dark:hover:text-white",
|
||||
|
||||
/** Label */
|
||||
label:
|
||||
"block text-xs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400",
|
||||
|
||||
/** Section heading in admin */
|
||||
sectionTitle:
|
||||
"text-lg font-bold text-neutral-900 dark:text-white",
|
||||
|
||||
/** Dashed add-item button */
|
||||
addButton:
|
||||
"flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 px-4 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 transition-colors dark:border-white/20 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/40",
|
||||
} as const;
|
||||
|
||||
/* ============================== */
|
||||
/* Input components */
|
||||
/* ============================== */
|
||||
|
||||
interface AdminInputProps extends ComponentPropsWithoutRef<"input"> {
|
||||
variant?: "default" | "sm" | "dashed";
|
||||
}
|
||||
|
||||
export const AdminInput = forwardRef<HTMLInputElement, AdminInputProps>(
|
||||
function AdminInput({ variant = "default", className = "", ...props }, ref) {
|
||||
const base =
|
||||
variant === "sm" ? adminStyles.inputSm
|
||||
: variant === "dashed" ? adminStyles.inputDashed
|
||||
: adminStyles.input;
|
||||
return <input ref={ref} className={`${base} ${className}`} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
interface AdminTextareaProps extends ComponentPropsWithoutRef<"textarea"> {
|
||||
autoResize?: boolean;
|
||||
}
|
||||
|
||||
export const AdminTextarea = forwardRef<HTMLTextAreaElement, AdminTextareaProps>(
|
||||
function AdminTextarea({ autoResize, className = "", ...props }, ref) {
|
||||
function handleInput(e: React.FormEvent<HTMLTextAreaElement>) {
|
||||
if (autoResize) {
|
||||
const el = e.currentTarget;
|
||||
el.style.height = "auto";
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}
|
||||
}
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={`${adminStyles.textarea} ${className}`}
|
||||
{...props}
|
||||
{...(autoResize ? { onInput: handleInput } : {})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface AdminSelectProps extends ComponentPropsWithoutRef<"select"> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AdminSelect = forwardRef<HTMLSelectElement, AdminSelectProps>(
|
||||
function AdminSelect({ className = "", children, ...props }, ref) {
|
||||
return (
|
||||
<select ref={ref} className={`${adminStyles.select} ${className}`} {...props}>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/* ============================== */
|
||||
/* Button components */
|
||||
/* ============================== */
|
||||
|
||||
interface AdminButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
variant?: "primary" | "secondary" | "danger" | "goldSm" | "cancelSm";
|
||||
}
|
||||
|
||||
export function AdminButton({ variant = "primary", className = "", ...props }: AdminButtonProps) {
|
||||
const base =
|
||||
variant === "secondary" ? adminStyles.btnSecondary
|
||||
: variant === "danger" ? adminStyles.btnDanger
|
||||
: variant === "goldSm" ? adminStyles.btnGoldSm
|
||||
: variant === "cancelSm" ? adminStyles.btnCancelSm
|
||||
: adminStyles.btnPrimary;
|
||||
return <button className={`${base} ${className}`} {...props} />;
|
||||
}
|
||||
|
||||
/* ============================== */
|
||||
/* Modal component */
|
||||
/* ============================== */
|
||||
|
||||
interface AdminModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
maxWidth?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AdminModal({ open, onClose, title, maxWidth = "max-w-sm", children }: AdminModalProps) {
|
||||
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className={adminStyles.modalOverlay} onClick={onClose}>
|
||||
<div className={adminStyles.modalBackdrop} />
|
||||
<div
|
||||
ref={focusTrapRef}
|
||||
className={`${adminStyles.modalContent} ${maxWidth}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<button onClick={onClose} className={adminStyles.modalClose} aria-label="Закрыть">
|
||||
<X size={16} />
|
||||
</button>
|
||||
{title && <h3 className="text-sm font-bold text-neutral-900 dark:text-white mb-4">{title}</h3>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export default function AboutEditorPage() {
|
||||
value={text}
|
||||
onChange={(e) => updateItem(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none hover:border-gold/30 focus:border-gold transition-colors resize-none"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 focus:border-gold transition-colors resize-none dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
|
||||
placeholder="Текст параграфа..."
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, ChevronDown } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { formatBelarusPhone, SHORT_DAYS } from "@/lib/formatting";
|
||||
|
||||
type Tab = "classes" | "events";
|
||||
type EventType = "master-class" | "open-day";
|
||||
@@ -11,7 +12,7 @@ type EventType = "master-class" | "open-day";
|
||||
interface McOption { title: string; date: string }
|
||||
interface OdClass { id: number; style: string; start_time: string; hall: string; trainer: string }
|
||||
interface OdEvent { id: number; date: string; title?: string }
|
||||
interface ScheduleClass { type: string; trainer: string; time: string; day: string; hall: string }
|
||||
interface ScheduleClass { type: string; trainer: string; time: string; day: string; hall: string; groupId?: string }
|
||||
|
||||
function shortName(fullName: string) {
|
||||
const parts = fullName.trim().split(/\s+/);
|
||||
@@ -19,10 +20,6 @@ function shortName(fullName: string) {
|
||||
return parts.length > 1 ? `${parts[1]} ${parts[0][0]}.` : parts[0];
|
||||
}
|
||||
|
||||
const SHORT_DAYS: Record<string, string> = {
|
||||
"Понедельник": "Пн", "Вторник": "Вт", "Среда": "Ср",
|
||||
"Четверг": "Чт", "Пятница": "Пт", "Суббота": "Сб", "Воскресенье": "Вс",
|
||||
};
|
||||
|
||||
// --- Searchable dropdown ---
|
||||
|
||||
@@ -42,7 +39,13 @@ function SearchSelect({ options, value, onChange, placeholder }: {
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
const filtered = search
|
||||
? options.filter((o) => o.label.toLowerCase().includes(search.toLowerCase()))
|
||||
? (() => {
|
||||
const tokens = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return options.filter((o) => {
|
||||
const label = o.label.toLowerCase();
|
||||
return tokens.every((t) => label.includes(t));
|
||||
});
|
||||
})()
|
||||
: options;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,7 +65,7 @@ function SearchSelect({ options, value, onChange, placeholder }: {
|
||||
<div
|
||||
onClick={() => { setOpen(true); setTimeout(() => inputRef.current?.focus(), 0); }}
|
||||
className={`flex items-center gap-2 w-full rounded-lg border px-3 py-2 text-sm cursor-text transition-colors ${
|
||||
open ? "border-gold/40 bg-white/[0.06]" : "border-white/[0.08] bg-white/[0.04]"
|
||||
open ? "border-gold/40 bg-neutral-200/60 dark:bg-white/[0.06]" : "border-neutral-200 bg-neutral-100 dark:border-white/[0.08] dark:bg-white/[0.04]"
|
||||
}`}
|
||||
>
|
||||
{open ? (
|
||||
@@ -72,14 +75,14 @@ function SearchSelect({ options, value, onChange, placeholder }: {
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={selected ? selected.label : placeholder}
|
||||
className="flex-1 bg-transparent text-white placeholder-neutral-500 outline-none text-sm"
|
||||
className="flex-1 bg-transparent text-neutral-900 placeholder-neutral-400 outline-none text-sm dark:text-white dark:placeholder-neutral-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") { setOpen(false); setSearch(""); }
|
||||
if (e.key === "Backspace" && !search && value) { onChange(""); }
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className={`flex-1 truncate ${selected ? "text-white" : "text-neutral-500"}`}>
|
||||
<span className={`flex-1 truncate ${selected ? "text-neutral-900 dark:text-white" : "text-neutral-500"}`}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
)}
|
||||
@@ -97,8 +100,8 @@ function SearchSelect({ options, value, onChange, placeholder }: {
|
||||
</div>
|
||||
|
||||
{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="max-h-48 overflow-y-auto styled-scrollbar">
|
||||
<div className="absolute z-20 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/[0.08] dark:bg-[#141414]">
|
||||
<div className="max-h-48 overflow-y-scroll admin-scrollbar">
|
||||
{filtered.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-neutral-500">Ничего не найдено</p>
|
||||
)}
|
||||
@@ -109,7 +112,7 @@ function SearchSelect({ options, value, onChange, placeholder }: {
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => { onChange(o.value); setOpen(false); setSearch(""); }}
|
||||
className={`w-full px-3 py-2 text-left text-sm transition-colors ${
|
||||
o.value === value ? "bg-gold/10 text-gold" : "text-white hover:bg-white/[0.05]"
|
||||
o.value === value ? "bg-gold/10 text-gold" : "text-neutral-900 hover:bg-neutral-50 dark:text-white dark:hover:bg-white/[0.05]"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
@@ -145,32 +148,24 @@ export function AddBookingModal({
|
||||
const [odEventId, setOdEventId] = useState<number | null>(null);
|
||||
const [odClassId, setOdClassId] = useState("");
|
||||
const [scheduleClasses, setScheduleClasses] = useState<ScheduleClass[]>([]);
|
||||
const [classInfo, setClassInfo] = useState("");
|
||||
const [classGroup, setClassGroup] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassInfo("");
|
||||
setName(""); setPhone("+375 "); setInstagram(""); setTelegram(""); setMcTitle(""); setOdClassId(""); setClassGroup("");
|
||||
|
||||
// Fetch schedule classes
|
||||
adminFetch("/api/admin/sections/schedule").then((r) => r.json()).then((data: { locations?: { name: string; days: { day: string; classes: { type: string; trainer: string; time: string }[] }[] }[] }) => {
|
||||
adminFetch("/api/admin/sections/schedule").then((r) => r.json()).then((data: { locations?: { name: string; days: { day: string; classes: { type: string; trainer: string; time: string; groupId?: string }[] }[] }[] }) => {
|
||||
const classes: ScheduleClass[] = [];
|
||||
for (const loc of data.locations || []) {
|
||||
for (const day of loc.days) {
|
||||
for (const cls of day.classes) {
|
||||
classes.push({ type: cls.type, trainer: cls.trainer, time: cls.time, day: day.day, hall: loc.name });
|
||||
classes.push({ type: cls.type, trainer: cls.trainer, time: cls.time, day: day.day, hall: loc.name, groupId: cls.groupId });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deduplicate by type+trainer+time+day+hall
|
||||
const seen = new Set<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);
|
||||
setScheduleClasses(classes);
|
||||
}).catch(() => {});
|
||||
|
||||
// Fetch upcoming MCs
|
||||
@@ -209,27 +204,35 @@ export function AddBookingModal({
|
||||
}, [open, onClose]);
|
||||
|
||||
function handlePhoneChange(raw: string) {
|
||||
let digits = raw.replace(/\D/g, "");
|
||||
if (!digits.startsWith("375")) digits = "375" + digits.replace(/^375?/, "");
|
||||
digits = digits.slice(0, 12);
|
||||
let formatted = "+375";
|
||||
const rest = digits.slice(3);
|
||||
if (rest.length > 0) formatted += " (" + rest.slice(0, 2);
|
||||
if (rest.length >= 2) formatted += ") ";
|
||||
if (rest.length > 2) formatted += rest.slice(2, 5);
|
||||
if (rest.length > 5) formatted += "-" + rest.slice(5, 7);
|
||||
if (rest.length > 7) formatted += "-" + rest.slice(7, 9);
|
||||
setPhone(formatted);
|
||||
setPhone(formatBelarusPhone(raw));
|
||||
}
|
||||
|
||||
const hasUpcomingMc = mcOptions.length > 0;
|
||||
const hasOpenDay = odEventId !== null && odClasses.length > 0;
|
||||
|
||||
// Build options for each dropdown
|
||||
const classOptions: SearchSelectOption[] = scheduleClasses.map((c, i) => ({
|
||||
value: String(i),
|
||||
label: `${shortName(c.trainer)} — ${c.type} · ${SHORT_DAYS[c.day] || c.day} ${c.time} · ${c.hall}`,
|
||||
}));
|
||||
// Flat group options: one searchable dropdown
|
||||
const classGroupOptions = useMemo((): SearchSelectOption[] => {
|
||||
const byKey = new Map<string, { type: string; trainer: string; hall: string; slots: { day: string; time: string }[]; id: string }>();
|
||||
for (const c of scheduleClasses) {
|
||||
const id = c.groupId || `${c.type}|${c.trainer}|${c.time}|${c.hall}`;
|
||||
const existing = byKey.get(id);
|
||||
if (existing) {
|
||||
if (!existing.slots.some((s) => s.day === c.day)) existing.slots.push({ day: c.day, time: c.time });
|
||||
} else {
|
||||
byKey.set(id, { type: c.type, trainer: c.trainer, hall: c.hall, slots: [{ day: c.day, time: c.time }], id });
|
||||
}
|
||||
}
|
||||
return [...byKey.values()].map((g) => {
|
||||
const sameTime = g.slots.every((s) => s.time === g.slots[0].time);
|
||||
const days = sameTime
|
||||
? `${g.slots.map((s) => SHORT_DAYS[s.day] || s.day.slice(0, 2)).join("/")} ${g.slots[0].time}`
|
||||
: g.slots.map((s) => `${SHORT_DAYS[s.day] || s.day.slice(0, 2)} ${s.time}`).join(", ");
|
||||
return {
|
||||
value: g.id,
|
||||
label: `${shortName(g.trainer)} · ${g.type} · ${days} · ${g.hall}`,
|
||||
};
|
||||
}).sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [scheduleClasses]);
|
||||
|
||||
const mcSelectOptions: SearchSelectOption[] = mcOptions.map((mc) => ({
|
||||
value: mc.title,
|
||||
@@ -246,9 +249,8 @@ export function AddBookingModal({
|
||||
setSaving(true);
|
||||
try {
|
||||
if (tab === "classes") {
|
||||
const selectedClass = classInfo ? scheduleClasses[Number(classInfo)] : null;
|
||||
const groupInfo = selectedClass
|
||||
? `${selectedClass.type}, ${shortName(selectedClass.trainer)}, ${SHORT_DAYS[selectedClass.day] || selectedClass.day} ${selectedClass.time}, ${selectedClass.hall}`
|
||||
const groupInfo = classGroup
|
||||
? classGroupOptions.find((o) => o.value === classGroup)?.label
|
||||
: undefined;
|
||||
await adminFetch("/api/admin/group-bookings", {
|
||||
method: "POST",
|
||||
@@ -277,6 +279,8 @@ export function AddBookingModal({
|
||||
}
|
||||
onAdded();
|
||||
onClose();
|
||||
} catch {
|
||||
alert("Не удалось создать запись. Попробуйте ещё раз.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -284,7 +288,7 @@ export function AddBookingModal({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const inputClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 placeholder-neutral-500";
|
||||
const inputClass = "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 placeholder-neutral-400 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500";
|
||||
|
||||
const canSubmit = name.trim() && phone.trim() && !saving
|
||||
&& (tab === "classes" || (tab === "events" && eventType === "master-class" && hasUpcomingMc)
|
||||
@@ -293,21 +297,21 @@ export function AddBookingModal({
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={onClose} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
|
||||
<div className="relative w-full max-w-sm rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={onClose} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white">
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
<h3 className="text-base font-bold text-white">Добавить запись</h3>
|
||||
<p className="mt-1 text-xs text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
|
||||
<h3 className="text-base font-bold text-neutral-900 dark:text-white">Добавить запись</h3>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Ручная запись (Instagram, звонок, лично)</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Type selector — single row */}
|
||||
<div className="flex rounded-lg border border-white/[0.08] bg-white/[0.03] p-0.5">
|
||||
<div className="flex rounded-lg border border-neutral-200 bg-neutral-100 p-0.5 dark:border-white/[0.08] dark:bg-white/[0.03]">
|
||||
<button
|
||||
onClick={() => setTab("classes")}
|
||||
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
|
||||
tab === "classes" ? "bg-gold/20 text-gold shadow-sm" : "text-neutral-400 hover:text-white"
|
||||
tab === "classes" ? "bg-gold/20 text-gold shadow-sm" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Занятие
|
||||
@@ -316,7 +320,7 @@ export function AddBookingModal({
|
||||
<button
|
||||
onClick={() => { setTab("events"); setEventType("master-class"); }}
|
||||
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
|
||||
tab === "events" && eventType === "master-class" ? "bg-purple-500/15 text-purple-400 shadow-sm" : "text-neutral-400 hover:text-white"
|
||||
tab === "events" && eventType === "master-class" ? "bg-purple-500/15 text-purple-400 shadow-sm" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Мастер-класс
|
||||
@@ -326,7 +330,7 @@ export function AddBookingModal({
|
||||
<button
|
||||
onClick={() => { setTab("events"); setEventType("open-day"); }}
|
||||
className={`flex-1 rounded-md py-2 text-xs font-medium transition-all ${
|
||||
tab === "events" && eventType === "open-day" ? "bg-blue-500/15 text-blue-400 shadow-sm" : "text-neutral-400 hover:text-white"
|
||||
tab === "events" && eventType === "open-day" ? "bg-blue-500/15 text-blue-400 shadow-sm" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Open Day
|
||||
@@ -335,12 +339,12 @@ export function AddBookingModal({
|
||||
</div>
|
||||
|
||||
{/* Class selector (optional for Занятие) */}
|
||||
{tab === "classes" && classOptions.length > 0 && (
|
||||
{tab === "classes" && classGroupOptions.length > 0 && (
|
||||
<SearchSelect
|
||||
options={classOptions}
|
||||
value={classInfo}
|
||||
onChange={setClassInfo}
|
||||
placeholder="Выберите занятие (необязательно)"
|
||||
options={classGroupOptions}
|
||||
value={classGroup}
|
||||
onChange={setClassGroup}
|
||||
placeholder="Группа (необязательно)"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -47,17 +47,17 @@ export function DeleteBtn({ onClick, name }: { onClick: () => void; name?: strin
|
||||
{confirming && createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirming(false)}>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
<div className="relative w-full max-w-xs rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => setConfirming(false)} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
|
||||
<div className="relative w-full max-w-xs rounded-2xl border border-neutral-200 bg-white p-5 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => setConfirming(false)} className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white">
|
||||
<X size={16} />
|
||||
</button>
|
||||
<h3 className="text-sm font-bold text-white">Удалить запись?</h3>
|
||||
{name && <p className="mt-1 text-xs text-neutral-400">{name}</p>}
|
||||
<p className="mt-2 text-xs text-neutral-500">Это действие нельзя отменить.</p>
|
||||
<h3 className="text-sm font-bold text-neutral-900 dark:text-white">Удалить запись?</h3>
|
||||
{name && <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{name}</p>}
|
||||
<p className="mt-2 text-xs text-neutral-400 dark:text-neutral-500">Это действие нельзя отменить.</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 py-2 text-xs font-medium text-neutral-300 hover:bg-neutral-700 transition-colors"
|
||||
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 py-2 text-xs font-medium text-neutral-700 hover:bg-neutral-200 transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
@@ -80,17 +80,17 @@ export function ContactLinks({ phone, instagram, telegram }: { phone?: string; i
|
||||
return (
|
||||
<>
|
||||
{phone && (
|
||||
<a href={`tel:${phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||
<a href={`tel:${phone.replace(/\D/g, "")}`} className="inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-500 dark:text-emerald-400 dark:hover:text-emerald-300 text-xs">
|
||||
<Phone size={10} />{phone}
|
||||
</a>
|
||||
)}
|
||||
{instagram && (
|
||||
<a href={`https://ig.me/m/${instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
|
||||
<a href={`https://ig.me/m/${instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-600 hover:text-pink-500 dark:text-pink-400 dark:hover:text-pink-300 text-xs">
|
||||
<Instagram size={10} />{instagram}
|
||||
</a>
|
||||
)}
|
||||
{telegram && (
|
||||
<a href={`https://t.me/${telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
|
||||
<a href={`https://t.me/${telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 text-xs">
|
||||
<Send size={10} />{telegram}
|
||||
</a>
|
||||
)}
|
||||
@@ -109,7 +109,7 @@ export function FilterTabs({ filter, counts, total, onFilter }: {
|
||||
<button
|
||||
onClick={() => onFilter("all")}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||
filter === "all" ? "bg-gold/20 text-gold border border-gold/40" : "bg-neutral-100 text-neutral-500 border border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Все <span className="text-neutral-500 ml-1">{total}</span>
|
||||
@@ -119,7 +119,7 @@ export function FilterTabs({ filter, counts, total, onFilter }: {
|
||||
key={s.key}
|
||||
onClick={() => onFilter(s.key)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||
filter === s.key ? `${s.bg} ${s.color} border ${s.border}` : "bg-neutral-100 text-neutral-500 border border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
@@ -147,14 +147,14 @@ export function StatusActions({ status, onStatus }: { status: BookingStatus; onS
|
||||
);
|
||||
return (
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-400 border border-blue-500/30 hover:bg-blue-500/20")}
|
||||
{status === "new" && actionBtn("Связались →", () => onStatus("contacted"), "bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/30 hover:bg-blue-500/20")}
|
||||
{status === "contacted" && (
|
||||
<>
|
||||
{actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20")}
|
||||
{actionBtn("Отказ", () => onStatus("declined"), "bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20")}
|
||||
{actionBtn("Подтвердить", () => onStatus("confirmed"), "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/20")}
|
||||
{actionBtn("Отказ", () => onStatus("declined"), "bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/30 hover:bg-red-500/20")}
|
||||
</>
|
||||
)}
|
||||
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", () => onStatus("contacted"), "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300")}
|
||||
{(status === "confirmed" || status === "declined") && actionBtn("Вернуть", () => onStatus("contacted"), "bg-neutral-100 text-neutral-600 border border-neutral-300 hover:border-neutral-400 hover:text-neutral-800 dark:bg-neutral-800/50 dark:text-neutral-500 dark:border-transparent dark:hover:border-white/10 dark:hover:text-neutral-300")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -163,10 +163,10 @@ export function BookingCard({ status, highlight, children }: { status: BookingSt
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border p-3 transition-all duration-200 cursor-default ${
|
||||
status === "declined" ? "border-red-500/15 bg-red-500/[0.02] opacity-50 hover:opacity-70 hover:border-red-500/30"
|
||||
: status === "confirmed" ? "border-emerald-500/15 bg-emerald-500/[0.02] hover:border-emerald-500/30 hover:bg-emerald-500/[0.05]"
|
||||
: status === "new" ? "border-gold/20 bg-gold/[0.03] hover:border-gold/40 hover:bg-gold/[0.06]"
|
||||
: "border-white/10 bg-neutral-800/30 hover:border-white/20 hover:bg-neutral-800/50"
|
||||
status === "declined" ? "border-red-500/20 bg-red-500/[0.04] opacity-50 hover:opacity-70 hover:border-red-500/30 dark:border-red-500/15 dark:bg-red-500/[0.02]"
|
||||
: status === "confirmed" ? "border-emerald-500/20 bg-emerald-500/[0.04] hover:border-emerald-500/30 hover:bg-emerald-500/[0.08] dark:border-emerald-500/15 dark:bg-emerald-500/[0.02] dark:hover:bg-emerald-500/[0.05]"
|
||||
: status === "new" ? "border-gold/30 bg-gold/[0.06] hover:border-gold/50 hover:bg-gold/[0.1] dark:border-gold/20 dark:bg-gold/[0.03] dark:hover:border-gold/40 dark:hover:bg-gold/[0.06]"
|
||||
: "border-neutral-200 bg-neutral-50 hover:border-neutral-300 hover:bg-neutral-100 dark:border-white/10 dark:bg-neutral-800/30 dark:hover:border-white/20 dark:hover:bg-neutral-800/50"
|
||||
}${highlight ? " ring-2 ring-gold/40 animate-[pulse_1s_ease-in-out_1]" : ""}`}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -109,7 +109,7 @@ export function GenericBookingsList<T extends BaseBooking>({
|
||||
<BookingCard status={item.status} highlight={isHighlighted}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
|
||||
<span className="font-medium text-white truncate max-w-[200px]">{item.name}</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-white truncate max-w-[200px]">{item.name}</span>
|
||||
<ContactLinks phone={item.phone} instagram={item.instagram} telegram={item.telegram} />
|
||||
{renderExtra?.(item)}
|
||||
</div>
|
||||
@@ -144,32 +144,32 @@ export function GenericBookingsList<T extends BaseBooking>({
|
||||
const groupCounts = { new: 0, contacted: 0, confirmed: 0, declined: 0 };
|
||||
for (const item of group.items) groupCounts[item.status] = (groupCounts[item.status] || 0) + 1;
|
||||
return (
|
||||
<div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-white/5 opacity-60" : "border-white/10"}`}>
|
||||
<div key={group.key} className={`rounded-xl border overflow-hidden ${group.isArchived ? "border-neutral-100 opacity-60 dark:border-white/5" : "border-neutral-200 dark:border-white/10"}`}>
|
||||
<button
|
||||
onClick={() => setExpanded((p) => ({ ...p, [group.key]: !isOpen }))}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 transition-colors text-left ${group.isArchived ? "bg-neutral-900/50 hover:bg-neutral-800/50" : "bg-neutral-900 hover:bg-neutral-800/80"}`}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 transition-colors text-left ${group.isArchived ? "bg-neutral-100/50 hover:bg-neutral-200/50 dark:bg-neutral-900/50 dark:hover:bg-neutral-800/50" : "bg-neutral-50 hover:bg-neutral-200/80 dark:bg-neutral-900 dark:hover:bg-neutral-800/80"}`}
|
||||
>
|
||||
{isOpen ? <ChevronDown size={14} className="text-neutral-500 shrink-0" /> : <ChevronRight size={14} className="text-neutral-500 shrink-0" />}
|
||||
{group.sublabel && (
|
||||
<span className={`text-xs font-medium shrink-0 ${group.isArchived ? "text-neutral-500" : "text-gold"}`}>{group.sublabel}</span>
|
||||
)}
|
||||
<span className={`font-medium text-sm truncate ${group.isArchived ? "text-neutral-400" : "text-white"}`}>{group.label}</span>
|
||||
<span className={`font-medium text-sm truncate ${group.isArchived ? "text-neutral-500 dark:text-neutral-400" : "text-neutral-900 dark:text-white"}`}>{group.label}</span>
|
||||
{group.dateBadge && (
|
||||
<span className={`text-[10px] rounded-full px-2 py-0.5 shrink-0 ${
|
||||
group.isArchived ? "text-neutral-600 bg-neutral-800 line-through" : "text-gold bg-gold/10"
|
||||
group.isArchived ? "text-neutral-600 bg-neutral-800 line-through" : "text-amber-700 dark:text-gold bg-gold/10"
|
||||
}`}>
|
||||
{group.dateBadge}
|
||||
</span>
|
||||
)}
|
||||
{group.isArchived && (
|
||||
<span className="text-[10px] text-neutral-600 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">архив</span>
|
||||
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 shrink-0 dark:text-neutral-600 dark:bg-neutral-800">архив</span>
|
||||
)}
|
||||
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5 shrink-0">{group.items.length} чел.</span>
|
||||
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 shrink-0 dark:bg-neutral-800">{group.items.length} чел.</span>
|
||||
{!group.isArchived && (
|
||||
<div className="flex gap-2 ml-auto text-[10px]">
|
||||
{groupCounts.new > 0 && <span className="text-gold">{groupCounts.new} новых</span>}
|
||||
{groupCounts.contacted > 0 && <span className="text-blue-400">{groupCounts.contacted} связ.</span>}
|
||||
{groupCounts.confirmed > 0 && <span className="text-emerald-400">{groupCounts.confirmed} подтв.</span>}
|
||||
{groupCounts.new > 0 && <span className="text-amber-700 dark:text-gold">{groupCounts.new} новых</span>}
|
||||
{groupCounts.contacted > 0 && <span className="text-blue-600 dark:text-blue-400">{groupCounts.contacted} связ.</span>}
|
||||
{groupCounts.confirmed > 0 && <span className="text-emerald-600 dark:text-emerald-400">{groupCounts.confirmed} подтв.</span>}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -43,10 +43,10 @@ export function SearchBar({
|
||||
value={query}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="Поиск по имени или телефону..."
|
||||
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] py-2 pl-9 pr-8 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 py-2 pl-9 pr-8 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold/40 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
{query && (
|
||||
<button onClick={clear} className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white">
|
||||
<button onClick={clear} className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-900 dark:hover:text-white">
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -161,43 +161,43 @@ function ConfirmModal({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const selectClass = "w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white outline-none focus:border-gold/40 [color-scheme:dark] disabled:opacity-30 disabled:cursor-not-allowed";
|
||||
const selectClass = "w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none focus:border-gold/40 [color-scheme:light] disabled:opacity-30 disabled:cursor-not-allowed dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:[color-scheme:dark]";
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" onClick={onClose}>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
<div className="relative w-full max-w-sm rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={onClose} aria-label="Закрыть" className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-white/[0.06] hover:text-white">
|
||||
<div className="relative w-full max-w-sm rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/[0.08] dark:bg-[#0a0a0a]" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={onClose} aria-label="Закрыть" className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white">
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
<h3 className="text-base font-bold text-white">Подтвердить запись</h3>
|
||||
<p className="mt-1 text-xs text-neutral-400">{bookingName}</p>
|
||||
<h3 className="text-base font-bold text-neutral-900 dark:text-white">Подтвердить запись</h3>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{bookingName}</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Зал</label>
|
||||
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Зал</label>
|
||||
<select value={hall} onChange={(e) => setHall(e.target.value)} className={selectClass}>
|
||||
<option value="" className="bg-neutral-900">Выберите зал</option>
|
||||
{halls.map((h) => <option key={h} value={h} className="bg-neutral-900">{h}</option>)}
|
||||
<option value="" className="bg-white dark:bg-neutral-900">Выберите зал</option>
|
||||
{halls.map((h) => <option key={h} value={h} className="bg-white dark:bg-neutral-900">{h}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Тренер</label>
|
||||
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Тренер</label>
|
||||
<select value={trainer} onChange={(e) => setTrainer(e.target.value)} disabled={!hall} className={selectClass}>
|
||||
<option value="" className="bg-neutral-900">Выберите тренера</option>
|
||||
{trainers.map((t) => <option key={t} value={t} className="bg-neutral-900">{t}</option>)}
|
||||
<option value="" className="bg-white dark:bg-neutral-900">Выберите тренера</option>
|
||||
{trainers.map((t) => <option key={t} value={t} className="bg-white dark:bg-neutral-900">{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Группа</label>
|
||||
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Группа</label>
|
||||
<select value={group} onChange={(e) => setGroup(e.target.value)} disabled={!trainer} className={selectClass}>
|
||||
<option value="" className="bg-neutral-900">Выберите группу</option>
|
||||
{groups.map((g) => <option key={g.value} value={g.value} className="bg-neutral-900">{g.label}</option>)}
|
||||
<option value="" className="bg-white dark:bg-neutral-900">Выберите группу</option>
|
||||
{groups.map((g) => <option key={g.value} value={g.value} className="bg-white dark:bg-neutral-900">{g.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Дата занятия</label>
|
||||
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Дата занятия</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
@@ -212,14 +212,14 @@ function ConfirmModal({
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] font-medium text-neutral-400 mb-1 block">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
|
||||
<label className="text-[11px] font-medium text-neutral-500 mb-1 block dark:text-neutral-400">Комментарий <span className="text-neutral-600">(необязательно)</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={comment}
|
||||
disabled={!group}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Первое занятие, пробный"
|
||||
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/40 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold/40 disabled:opacity-30 disabled:cursor-not-allowed dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,18 +283,23 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
|
||||
...b, status: "confirmed" as BookingStatus,
|
||||
confirmedDate: data.date, confirmedGroup: data.group, confirmedHall: data.hall, notes,
|
||||
} : b));
|
||||
await Promise.all([
|
||||
adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }),
|
||||
}),
|
||||
data.comment ? adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
|
||||
}) : Promise.resolve(),
|
||||
]);
|
||||
try {
|
||||
await Promise.all([
|
||||
adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-status", id: confirmingId, status: "confirmed", confirmation: { group: data.group, hall: data.hall, date: data.date } }),
|
||||
}),
|
||||
data.comment ? adminFetch("/api/admin/group-bookings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "set-notes", id: confirmingId, notes: notes ?? "" }),
|
||||
}) : Promise.resolve(),
|
||||
]);
|
||||
} catch {
|
||||
// Revert optimistic update on failure
|
||||
setBookings((prev) => prev.map((b) => b.id === confirmingId ? { ...b, ...existing } : b));
|
||||
}
|
||||
setConfirmingId(null);
|
||||
onDataChange?.();
|
||||
}
|
||||
@@ -313,8 +318,8 @@ function GroupBookingsTab({ filter, onDataChange }: { filter: BookingFilter; onD
|
||||
onConfirm={(id) => setConfirmingId(id)}
|
||||
renderExtra={(b) => (
|
||||
<>
|
||||
{b.groupInfo && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{b.groupInfo}</span>}
|
||||
{b.confirmedHall && <span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{b.confirmedHall}</span>}
|
||||
{b.groupInfo && <span className="text-xs text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:text-neutral-400 dark:bg-neutral-800">{b.groupInfo}</span>}
|
||||
{b.confirmedHall && <span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:bg-neutral-800">{b.confirmedHall}</span>}
|
||||
{(b.confirmedGroup || b.confirmedDate) && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmingId(b.id); }}
|
||||
@@ -466,11 +471,11 @@ function RemindersTab() {
|
||||
: currentStatus === "coming" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
|
||||
: currentStatus === "cancelled" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
|
||||
: currentStatus === "pending" ? "border-amber-500/15 bg-amber-500/[0.02]"
|
||||
: "border-white/5 bg-neutral-800/30"
|
||||
: "border-neutral-200 bg-neutral-100/30 dark:border-white/5 dark:bg-neutral-800/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap text-sm">
|
||||
<span className="font-medium text-white">{item.name}</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-white">{item.name}</span>
|
||||
{item.phone && (
|
||||
<a href={`tel:${item.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||
<Phone size={10} />{item.phone}
|
||||
@@ -537,11 +542,11 @@ function RemindersTab() {
|
||||
const TypeIcon = typeConf.icon;
|
||||
const egStats = countByStatus(eg.items);
|
||||
return (
|
||||
<div key={eg.label} className="rounded-xl border border-white/10 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-900">
|
||||
<div key={eg.label} className="rounded-xl border border-neutral-200 overflow-hidden dark:border-white/10">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-50 dark:bg-neutral-900">
|
||||
<TypeIcon size={13} className={typeConf.color} />
|
||||
<span className="text-sm font-medium text-white">{eg.label}{eg.items[0]?.eventHall ? ` · ${eg.items[0].eventHall}` : ""}</span>
|
||||
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{eg.items.length} чел.</span>
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-white">{eg.label}{eg.items[0]?.eventHall ? ` · ${eg.items[0].eventHall}` : ""}</span>
|
||||
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:bg-neutral-800">{eg.items.length} чел.</span>
|
||||
<div className="flex gap-2 ml-auto text-[10px]">
|
||||
{egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>}
|
||||
{egStats.cancelled > 0 && <span className="text-red-400">{egStats.cancelled} не придёт</span>}
|
||||
@@ -667,15 +672,15 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter, activeTab, act
|
||||
if (c.tab === "reminders") {
|
||||
const total = counts.remindersToday + counts.remindersTomorrow;
|
||||
if (total === 0) return (
|
||||
<div key={c.tab} className="rounded-xl border border-white/5 bg-neutral-900/50 p-3 opacity-40">
|
||||
<div key={c.tab} className="rounded-xl border border-neutral-100 bg-neutral-50 p-3 opacity-40 dark:border-white/5 dark:bg-neutral-900/50">
|
||||
<p className="text-xs text-neutral-500">{c.label}</p>
|
||||
<p className="text-lg font-bold text-neutral-600 mt-1">—</p>
|
||||
<p className="text-lg font-bold text-neutral-400 mt-1 dark:text-neutral-600">—</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<button key={c.tab} onClick={() => onNavigate(c.tab)}
|
||||
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}>
|
||||
<p className="text-xs text-neutral-400">{c.label}</p>
|
||||
className={`rounded-xl border ${c.color} bg-neutral-50 p-3 text-left transition-all hover:bg-neutral-100 hover:scale-[1.02] dark:bg-neutral-900 dark:hover:bg-neutral-800/80`}>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{c.label}</p>
|
||||
<div className="flex items-baseline gap-2 mt-1 flex-wrap">
|
||||
{counts.remindersNotAsked > 0 && (
|
||||
<span className="inline-flex items-baseline gap-1 cursor-pointer hover:underline decoration-neutral-500 underline-offset-2 transition-all"
|
||||
@@ -713,20 +718,20 @@ function DashboardSummary({ refreshTrigger, onNavigate, onFilter, activeTab, act
|
||||
const tc = c.counts!;
|
||||
const total = tc.new + tc.contacted + tc.confirmed + tc.declined;
|
||||
if (total === 0) return (
|
||||
<div key={c.tab} className="rounded-xl border border-white/5 bg-neutral-900/50 p-3 opacity-40">
|
||||
<div key={c.tab} className="rounded-xl border border-neutral-100 bg-neutral-50 p-3 opacity-40 dark:border-white/5 dark:bg-neutral-900/50">
|
||||
<p className="text-xs text-neutral-500">{c.label}</p>
|
||||
<p className="text-lg font-bold text-neutral-600 mt-1">—</p>
|
||||
<p className="text-lg font-bold text-neutral-400 mt-1 dark:text-neutral-600">—</p>
|
||||
</div>
|
||||
);
|
||||
const isActiveCard = activeTab === c.tab;
|
||||
const hl = (status: BookingFilter) =>
|
||||
isActiveCard && activeFilter === status
|
||||
? "rounded-md bg-white/10 px-1.5 -mx-1.5 py-0.5 -my-0.5 ring-1 ring-white/20"
|
||||
? "rounded-md bg-neutral-200 px-1.5 -mx-1.5 py-0.5 -my-0.5 ring-1 ring-neutral-300 dark:bg-white/10 dark:ring-white/20"
|
||||
: "";
|
||||
return (
|
||||
<button key={c.tab} onClick={() => { onNavigate(c.tab); onFilter("all"); }}
|
||||
className={`rounded-xl border ${c.color} bg-neutral-900 p-3 text-left transition-all hover:bg-neutral-800/80 hover:scale-[1.02]`}>
|
||||
<p className="text-xs text-neutral-400">{c.label}</p>
|
||||
className={`rounded-xl border ${c.color} bg-neutral-50 p-3 text-left transition-all hover:bg-neutral-100 hover:scale-[1.02] dark:bg-neutral-900 dark:hover:bg-neutral-800/80`}>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{c.label}</p>
|
||||
<div className="flex items-baseline gap-2 mt-1 flex-wrap">
|
||||
{tc.new > 0 && (
|
||||
<>
|
||||
@@ -892,7 +897,7 @@ function BookingsPageInner() {
|
||||
<button
|
||||
onClick={() => setHallFilter("all")}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
|
||||
hallFilter === "all" ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-white border border-transparent"
|
||||
hallFilter === "all" ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-neutral-900 border border-transparent dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Все залы
|
||||
@@ -902,7 +907,7 @@ function BookingsPageInner() {
|
||||
key={hall}
|
||||
onClick={() => setHallFilter(hallFilter === hall ? "all" : hall)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs transition-colors ${
|
||||
hallFilter === hall ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-white border border-transparent"
|
||||
hallFilter === hall ? "bg-gold/15 text-gold border border-gold/30" : "text-neutral-500 hover:text-neutral-900 border border-transparent dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{hall}
|
||||
@@ -924,10 +929,10 @@ function BookingsPageInner() {
|
||||
<BookingCard key={`${r.type}-${r.id}`} status={r.status as BookingStatus}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap text-sm min-w-0">
|
||||
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{TYPE_LABELS[r.type] || r.type}</span>
|
||||
<span className="font-medium text-white">{r.name}</span>
|
||||
<span className="text-[10px] text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:bg-neutral-800">{TYPE_LABELS[r.type] || r.type}</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-white">{r.name}</span>
|
||||
<ContactLinks phone={r.phone} instagram={r.instagram} telegram={r.telegram} />
|
||||
{r.groupLabel && <span className="text-xs text-neutral-400 bg-neutral-800 rounded-full px-2 py-0.5">{r.groupLabel}</span>}
|
||||
{r.groupLabel && <span className="text-xs text-neutral-500 bg-neutral-200 rounded-full px-2 py-0.5 dark:text-neutral-400 dark:bg-neutral-800">{r.groupLabel}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-neutral-600 text-xs">{fmtDate(r.createdAt)}</span>
|
||||
@@ -955,20 +960,20 @@ function BookingsPageInner() {
|
||||
<select
|
||||
value={tab}
|
||||
onChange={(e) => setTab(e.target.value as Tab)}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white outline-none focus:border-gold/40 transition-colors [color-scheme:dark]"
|
||||
className="w-full rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-900 outline-none focus:border-gold/40 transition-colors dark:border-white/10 dark:bg-neutral-900 dark:text-white dark:[color-scheme:dark]"
|
||||
>
|
||||
{TABS.map((t) => (
|
||||
<option key={t.key} value={t.key}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-5 hidden sm:flex border-b border-white/10">
|
||||
<div className="mt-5 hidden sm:flex border-b border-neutral-200 dark:border-white/10">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`shrink-0 px-4 py-2.5 text-sm font-medium transition-colors relative whitespace-nowrap ${
|
||||
tab === t.key ? "text-gold" : "text-neutral-400 hover:text-white"
|
||||
tab === t.key ? "text-gold" : "text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SHORT_DAYS } from "@/lib/formatting";
|
||||
|
||||
export type BookingStatus = "new" | "contacted" | "confirmed" | "declined";
|
||||
export type BookingFilter = "all" | BookingStatus;
|
||||
|
||||
@@ -12,20 +14,26 @@ export interface BaseBooking {
|
||||
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 }[] = [
|
||||
{ key: "new", label: "Новая", color: "text-gold", bg: "bg-gold/10", border: "border-gold/30" },
|
||||
{ key: "contacted", label: "Связались", color: "text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" },
|
||||
{ key: "confirmed", label: "Подтверждено", color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/30" },
|
||||
{ key: "declined", label: "Отказ", color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
|
||||
{ key: "new", label: "Новая", color: "text-amber-700 dark:text-gold", bg: "bg-gold/10", border: "border-gold/30" },
|
||||
{ key: "contacted", label: "Связались", color: "text-blue-600 dark:text-blue-400", bg: "bg-blue-500/10", border: "border-blue-500/30" },
|
||||
{ key: "confirmed", label: "Подтверждено", color: "text-emerald-600 dark:text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/30" },
|
||||
{ key: "declined", label: "Отказ", color: "text-red-600 dark:text-red-400", bg: "bg-red-500/10", border: "border-red-500/30" },
|
||||
];
|
||||
|
||||
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> {
|
||||
|
||||
@@ -109,8 +109,8 @@ function IconPicker({
|
||||
setSearch("");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2.5 rounded-lg border bg-neutral-800 px-4 py-2.5 text-left text-white outline-none transition-colors ${
|
||||
open ? "border-gold" : "border-white/10"
|
||||
className={`w-full flex items-center gap-2.5 rounded-lg border bg-neutral-100 px-4 py-2.5 text-left text-neutral-900 outline-none transition-colors dark:bg-neutral-800 dark:text-white ${
|
||||
open ? "border-gold" : "border-neutral-200 dark:border-white/10"
|
||||
}`}
|
||||
>
|
||||
{SelectedIcon ? (
|
||||
@@ -118,13 +118,13 @@ function IconPicker({
|
||||
<SelectedIcon size={16} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-white/10 text-neutral-500">?</span>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-neutral-200 text-neutral-500 dark:bg-white/10">?</span>
|
||||
)}
|
||||
<span className="text-sm">{selected?.label || value}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-white/10 bg-neutral-800 shadow-xl overflow-hidden">
|
||||
<div className="absolute z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/10 dark:bg-neutral-800">
|
||||
<div className="p-2 pb-0">
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -132,7 +132,7 @@ function IconPicker({
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск..."
|
||||
className="w-full rounded-md border border-white/10 bg-neutral-900 px-3 py-1.5 text-sm text-white outline-none focus:border-gold/50 placeholder:text-neutral-600"
|
||||
className="w-full rounded-md border border-neutral-200 bg-neutral-100 px-3 py-1.5 text-sm text-neutral-900 outline-none focus:border-gold/50 placeholder:text-neutral-400 dark:border-white/10 dark:bg-neutral-900 dark:text-white dark:placeholder:text-neutral-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 max-h-56 overflow-y-auto">
|
||||
@@ -153,7 +153,7 @@ function IconPicker({
|
||||
className={`flex flex-col items-center gap-0.5 rounded-lg p-2 transition-colors ${
|
||||
key === value
|
||||
? "bg-gold/20 text-gold-light"
|
||||
: "text-neutral-400 hover:bg-white/5 hover:text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Icon size={20} />
|
||||
|
||||
@@ -41,8 +41,8 @@ function PhoneField({ value, onChange }: { value: string; onChange: (v: string)
|
||||
value={value ?? ""}
|
||||
onChange={handleChange}
|
||||
placeholder="+375 (XX) XXX-XX-XX"
|
||||
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
|
||||
value && !isComplete ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||
className={`w-full rounded-lg border bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 ${
|
||||
value && !isComplete ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
{isComplete && (
|
||||
@@ -106,12 +106,12 @@ function InstagramField({ value, onChange }: { value: string; onChange: (v: stri
|
||||
validateUsername(username);
|
||||
}}
|
||||
placeholder="blackheartdancehouse"
|
||||
className={`w-full rounded-lg border bg-neutral-800 pl-8 pr-10 py-2.5 text-white placeholder-neutral-500 outline-none hover:border-gold/30 transition-colors ${
|
||||
className={`w-full rounded-lg border bg-neutral-100 pl-8 pr-10 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none hover:border-gold/30 transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 ${
|
||||
status === "invalid"
|
||||
? "border-red-500 focus:border-red-500"
|
||||
: status === "valid"
|
||||
? "border-green-500/50 focus:border-green-500"
|
||||
: "border-white/10 focus:border-gold"
|
||||
: "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
@@ -164,7 +164,7 @@ function AddressList({ items, onChange }: { items: string[]; onChange: (items: s
|
||||
type="text"
|
||||
value={addr}
|
||||
onChange={(e) => update(i, e.target.value)}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-sm text-white outline-none focus:border-gold transition-colors"
|
||||
className="flex-1 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-sm text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -183,7 +183,7 @@ function AddressList({ items, onChange }: { items: string[]; onChange: (items: s
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
||||
onBlur={add}
|
||||
placeholder="Добавить адрес..."
|
||||
className="flex-1 rounded-lg border border-dashed border-white/15 bg-neutral-800/50 px-4 py-2.5 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold/50 transition-colors"
|
||||
className="flex-1 rounded-lg border border-dashed border-neutral-300 bg-neutral-100/50 px-4 py-2.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold/50 transition-colors dark:border-white/15 dark:bg-neutral-800/50 dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -67,7 +67,7 @@ function VideoSlot({
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-neutral-300">{label}</span>
|
||||
{isCenter && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[#c9a96e]/15 px-2 py-0.5 text-[10px] font-medium text-[#c9a96e]">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gold/15 px-2 py-0.5 text-[10px] font-medium text-gold">
|
||||
<Smartphone size={10} />
|
||||
мобильная версия
|
||||
</span>
|
||||
@@ -79,7 +79,7 @@ function VideoSlot({
|
||||
{src ? (
|
||||
<div
|
||||
className={`group relative overflow-hidden rounded-lg border ${
|
||||
isCenter ? "border-[#c9a96e]/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700"
|
||||
isCenter ? "border-gold/40 ring-1 ring-[#c9a96e]/20" : "border-neutral-700"
|
||||
}`}
|
||||
onMouseEnter={() => videoRef.current?.play()}
|
||||
onMouseLeave={() => { videoRef.current?.pause(); }}
|
||||
@@ -104,7 +104,7 @@ function VideoSlot({
|
||||
)}
|
||||
</div>
|
||||
{isCenter && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-[#c9a96e]/90 px-2 py-0.5 text-[10px] font-bold text-black">
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-gold/90 px-2 py-0.5 text-[10px] font-bold text-black">
|
||||
<Star size={10} fill="currentColor" />
|
||||
MAIN
|
||||
</div>
|
||||
@@ -128,7 +128,7 @@ function VideoSlot({
|
||||
disabled={uploading}
|
||||
className={`flex aspect-[9/16] w-full items-center justify-center rounded-lg border-2 border-dashed transition-colors disabled:opacity-50 ${
|
||||
isCenter
|
||||
? "border-[#c9a96e]/30 text-[#c9a96e]/50 hover:border-[#c9a96e]/60 hover:text-[#c9a96e]"
|
||||
? "border-gold/30 text-gold/50 hover:border-gold/60 hover:text-gold"
|
||||
: "border-neutral-700 text-neutral-500 hover:border-neutral-500 hover:text-neutral-300"
|
||||
}`}
|
||||
>
|
||||
@@ -164,7 +164,7 @@ function VideoSizeInfo({ totalSize, totalMb, rating }: { totalSize: number; tota
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full text-left rounded-lg bg-neutral-800/50 px-3 py-2 transition-colors hover:bg-neutral-800/80"
|
||||
className="w-full text-left rounded-lg bg-neutral-100/80 px-3 py-2 transition-colors hover:bg-neutral-200/80 dark:bg-neutral-800/50 dark:hover:bg-neutral-800/80"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-neutral-400">Общий вес: <span className={`font-medium ${rating.color}`}>{formatFileSize(totalSize)}</span></span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Sparkles,
|
||||
@@ -56,17 +57,19 @@ export default function AdminLayout({
|
||||
const [unreadTotal, setUnreadTotal] = useState(0);
|
||||
const isLoginPage = pathname === "/admin/login";
|
||||
|
||||
// Fetch unread counts — poll every 10s
|
||||
// Fetch unread counts — poll every 10s, stop after 3 consecutive failures
|
||||
useEffect(() => {
|
||||
if (isLoginPage) return;
|
||||
let failures = 0;
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
function fetchCounts() {
|
||||
adminFetch("/api/admin/unread-counts")
|
||||
.then((r) => r.json())
|
||||
.then((data: { total: number }) => setUnreadTotal(data.total))
|
||||
.catch(() => {});
|
||||
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
||||
.then((data: { total: number }) => { setUnreadTotal(data.total); failures = 0; })
|
||||
.catch(() => { failures++; if (failures >= 3 && interval) clearInterval(interval); });
|
||||
}
|
||||
fetchCounts();
|
||||
const interval = setInterval(fetchCounts, 10000);
|
||||
interval = setInterval(fetchCounts, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isLoginPage]);
|
||||
|
||||
@@ -86,7 +89,7 @@ export default function AdminLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-neutral-950 text-white">
|
||||
<div className="flex min-h-screen bg-neutral-50 text-neutral-900 dark:bg-neutral-950 dark:text-white">
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
@@ -97,18 +100,18 @@ export default function AdminLayout({
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-white/10 bg-neutral-900 transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
|
||||
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-neutral-200 bg-white dark:border-white/10 dark:bg-neutral-900 transition-transform lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-white/10 px-5 py-4">
|
||||
<Link href="/admin" className="text-lg font-bold">
|
||||
BLACK HEART
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-label="Закрыть меню"
|
||||
className="lg:hidden text-neutral-400 hover:text-white"
|
||||
className="lg:hidden text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -126,7 +129,7 @@ export default function AdminLayout({
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors ${
|
||||
active
|
||||
? "bg-gold/10 text-gold font-medium"
|
||||
: "text-neutral-400 hover:text-white hover:bg-white/5"
|
||||
: "text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
@@ -141,18 +144,22 @@ export default function AdminLayout({
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-white/10 p-3 space-y-1">
|
||||
<div className="border-t border-neutral-200 dark:border-white/10 p-3 space-y-1">
|
||||
<div className="flex items-center justify-between px-3 py-1">
|
||||
<span className="text-xs text-neutral-400 dark:text-neutral-500">Тема</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
target="_blank"
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-white hover:bg-white/5 transition-colors"
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
Открыть сайт
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-400 hover:text-red-400 hover:bg-white/5 transition-colors"
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-neutral-500 hover:text-red-500 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:text-red-400 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Выйти
|
||||
@@ -163,16 +170,17 @@ export default function AdminLayout({
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Top bar (mobile) */}
|
||||
<header className="sticky top-0 z-30 flex items-center gap-3 border-b border-white/10 bg-neutral-950 px-4 py-3 lg:hidden">
|
||||
<header className="sticky top-0 z-30 flex items-center gap-3 border-b border-neutral-200 bg-white dark:border-white/10 dark:bg-neutral-950 px-4 py-3 lg:hidden">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Открыть меню"
|
||||
aria-expanded={sidebarOpen}
|
||||
className="text-neutral-400 hover:text-white"
|
||||
className="text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
<a href="/admin" className="font-bold hover:text-gold transition-colors">BLACK HEART</a>
|
||||
<a href="/admin" className="font-bold hover:text-gold transition-colors flex-1">BLACK HEART</a>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-4 sm:p-6 lg:p-8">{children}</main>
|
||||
|
||||
@@ -36,18 +36,18 @@ export default function AdminLoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-neutral-950 px-4">
|
||||
<div className="flex min-h-screen items-center justify-center bg-neutral-50 px-4 dark:bg-neutral-950">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-sm space-y-6 rounded-2xl border border-white/10 bg-neutral-900 p-8"
|
||||
className="w-full max-w-sm space-y-6 rounded-2xl border border-neutral-200 bg-white p-8 dark:border-white/10 dark:bg-neutral-900"
|
||||
>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-white">BLACK HEART</h1>
|
||||
<p className="mt-1 text-sm text-neutral-400">Панель управления</p>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">BLACK HEART</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">Панель управления</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm text-neutral-400 mb-2">
|
||||
<label htmlFor="password" className="block text-sm text-neutral-500 mb-2 dark:text-neutral-400">
|
||||
Пароль
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -56,7 +56,7 @@ export default function AdminLoginPage() {
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-3 pr-11 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-3 pr-11 text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
|
||||
placeholder="Введите пароль"
|
||||
autoFocus
|
||||
aria-describedby={error ? "login-error" : undefined}
|
||||
@@ -65,7 +65,7 @@ export default function AdminLoginPage() {
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label={showPassword ? "Скрыть пароль" : "Показать пароль"}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
|
||||
@@ -68,7 +68,7 @@ function LocationSelect({
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
active
|
||||
? "bg-gold/20 text-gold border border-gold/40"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
|
||||
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:border-white/25 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{active && <Check size={10} className="inline mr-1" />}
|
||||
@@ -144,16 +144,16 @@ function SlotsField({
|
||||
type="date"
|
||||
value={slot.date}
|
||||
onChange={(e) => updateSlot(i, { date: e.target.value })}
|
||||
className={`w-[140px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
|
||||
!slot.date ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||
className={`w-[140px] rounded-lg border bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
|
||||
!slot.date ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={slot.startTime}
|
||||
onChange={(e) => updateSlot(i, { startTime: e.target.value })}
|
||||
className={`w-[100px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
|
||||
timeError ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||
className={`w-[100px] rounded-lg border bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
|
||||
timeError ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-neutral-500 text-xs">–</span>
|
||||
@@ -161,12 +161,12 @@ function SlotsField({
|
||||
type="time"
|
||||
value={slot.endTime}
|
||||
onChange={(e) => updateSlot(i, { endTime: e.target.value })}
|
||||
className={`w-[100px] rounded-lg border bg-neutral-800 px-3 py-2 text-sm text-white outline-none transition-colors [color-scheme:dark] ${
|
||||
timeError ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||
className={`w-[100px] rounded-lg border bg-neutral-100 px-3 py-2 text-sm text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
|
||||
timeError ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
{dur && (
|
||||
<span className="text-[11px] text-neutral-500 bg-neutral-800/50 rounded-full px-2 py-0.5">
|
||||
<span className="text-[11px] text-neutral-500 bg-neutral-200/50 rounded-full px-2 py-0.5 dark:bg-neutral-800/50">
|
||||
{dur}
|
||||
</span>
|
||||
)}
|
||||
@@ -190,7 +190,7 @@ function SlotsField({
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSlot}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-white/10 bg-neutral-800/50 px-3 py-1.5 text-xs text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors"
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-neutral-200 bg-neutral-100/50 px-3 py-1.5 text-xs text-neutral-500 hover:text-gold hover:border-gold/30 transition-colors dark:border-white/10 dark:bg-neutral-800/50"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Добавить дату
|
||||
@@ -223,8 +223,8 @@ function InstagramLinkField({
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="https://instagram.com/p/... или /reel/..."
|
||||
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none transition-colors ${
|
||||
error ? "border-red-500/50" : "border-white/10 focus:border-gold"
|
||||
className={`w-full rounded-lg border bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none transition-colors dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 ${
|
||||
error ? "border-red-500/50" : "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
{value && !error && (
|
||||
@@ -318,13 +318,13 @@ function FilterBar({
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Поиск по названию или тренеру..."
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 pl-10 pr-4 py-2.5 text-sm text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 pl-10 pr-4 py-2.5 text-sm text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-white transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-900 transition-colors dark:hover:text-white"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -340,7 +340,7 @@ function FilterBar({
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||
dateFilter === key
|
||||
? "bg-gold/20 text-gold border border-gold/40"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
|
||||
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:border-white/25 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{DATE_FILTER_LABELS[key]}
|
||||
@@ -359,7 +359,7 @@ function FilterBar({
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||
locationFilter === loc.name
|
||||
? "bg-gold/20 text-gold border border-gold/40"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:border-white/25 hover:text-white"
|
||||
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:border-neutral-300 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:border-white/25 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{loc.name}
|
||||
|
||||
@@ -63,7 +63,7 @@ function EventSettings({
|
||||
onChange: (patch: Partial<OpenDayEvent>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-4">
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-5 space-y-4 dark:border-white/10 dark:bg-neutral-900">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<Calendar size={18} className="text-gold" />
|
||||
Настройки мероприятия
|
||||
@@ -71,16 +71,16 @@ function EventSettings({
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Название</label>
|
||||
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
value={event.title}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white placeholder-neutral-500 outline-none focus:border-gold transition-colors"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 placeholder-neutral-400 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Дата</label>
|
||||
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">Дата</label>
|
||||
<input
|
||||
type="date"
|
||||
value={event.date}
|
||||
@@ -89,10 +89,10 @@ function EventSettings({
|
||||
const isPast = newDate && newDate < new Date().toISOString().slice(0, 10);
|
||||
onChange({ date: newDate, ...(isPast || !newDate ? { active: false } : {}) });
|
||||
}}
|
||||
className={`w-full rounded-lg border bg-neutral-800 px-4 py-2.5 text-white outline-none transition-colors [color-scheme:dark] ${
|
||||
className={`w-full rounded-lg border bg-neutral-100 px-4 py-2.5 text-neutral-900 outline-none transition-colors [color-scheme:light] dark:bg-neutral-800 dark:text-white dark:[color-scheme:dark] ${
|
||||
event.date && event.date < new Date().toISOString().slice(0, 10)
|
||||
? "border-amber-500/50"
|
||||
: "border-white/10 focus:border-gold"
|
||||
: "border-neutral-200 focus:border-gold dark:border-white/10"
|
||||
}`}
|
||||
/>
|
||||
{!event.date && (
|
||||
@@ -131,7 +131,7 @@ function EventSettings({
|
||||
className={`flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all ${
|
||||
event.discountPrice > 0
|
||||
? "bg-gold/15 text-gold border border-gold/30"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
@@ -147,12 +147,12 @@ function EventSettings({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">От N занятий</label>
|
||||
<label className="block text-sm text-neutral-500 mb-1.5 dark:text-neutral-400">От N занятий</label>
|
||||
<input
|
||||
type="number"
|
||||
value={event.discountThreshold || ""}
|
||||
onChange={(e) => onChange({ discountThreshold: parseInt(e.target.value) || 0 })}
|
||||
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5 text-white outline-none focus:border-gold transition-colors"
|
||||
className="w-full rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-2.5 text-neutral-900 outline-none focus:border-gold transition-colors dark:border-white/10 dark:bg-neutral-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,8 +178,8 @@ function EventSettings({
|
||||
event.active
|
||||
? "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 cursor-pointer"
|
||||
: !event.date || event.date < new Date().toISOString().slice(0, 10)
|
||||
? "bg-neutral-800 text-neutral-500 border border-white/5 opacity-50 cursor-not-allowed"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10 cursor-pointer hover:border-gold/40 hover:text-gold hover:bg-gold/5"
|
||||
? "bg-neutral-100 text-neutral-400 border border-neutral-200 opacity-50 cursor-not-allowed dark:bg-neutral-800 dark:text-neutral-500 dark:border-white/5"
|
||||
: "bg-neutral-100 text-neutral-500 border border-neutral-200 cursor-pointer hover:border-gold/40 hover:text-gold hover:bg-gold/5 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10"
|
||||
}`}
|
||||
>
|
||||
{event.active ? (
|
||||
@@ -242,7 +242,7 @@ function NewClassForm({
|
||||
<SelectField label="" value={style} onChange={setStyle} options={styles.map((s) => ({ value: s, label: s }))} placeholder="Стиль..." />
|
||||
<SelectField label="" value={trainer} onChange={setTrainer} options={trainers.map((t) => ({ value: t, label: t }))} placeholder="Тренер..." />
|
||||
<div className="flex gap-2 justify-end mt-2">
|
||||
<button onClick={onCancel} className="rounded-md border border-white/10 px-3 py-1 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">Отмена</button>
|
||||
<button onClick={onCancel} className="rounded-md border border-neutral-200 px-3 py-1 text-xs text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25">Отмена</button>
|
||||
<button onClick={() => canSave && onSave({ trainer, style, endTime })} disabled={!canSave}
|
||||
className="rounded-md bg-gold/20 border border-gold/30 px-3 py-1 text-xs font-medium text-gold hover:bg-gold/30 transition-colors disabled:opacity-30 disabled:cursor-not-allowed">Сохранить</button>
|
||||
</div>
|
||||
@@ -306,15 +306,15 @@ function ClassCell({
|
||||
<div
|
||||
className={`group relative p-2 rounded-lg cursor-pointer transition-all ${
|
||||
cls.cancelled
|
||||
? "bg-neutral-800/30 opacity-50"
|
||||
? "bg-neutral-200/50 opacity-50 dark:bg-neutral-800/30"
|
||||
: atRisk
|
||||
? "bg-red-500/5 border border-red-500/20"
|
||||
: "bg-gold/5 border border-gold/15 hover:border-gold/30"
|
||||
: "bg-gold/10 border border-gold/25 dark:bg-gold/5 dark:border-gold/15 hover:border-gold/30"
|
||||
}`}
|
||||
onClick={() => onEdit(cls.id)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-white truncate">{cls.style}</span>
|
||||
<span className="text-xs font-medium text-neutral-900 truncate dark:text-white">{cls.style}</span>
|
||||
<span className="text-[10px] text-neutral-500">{cls.startTime}–{cls.endTime}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-neutral-400 truncate">{cls.trainer}</div>
|
||||
@@ -411,30 +411,45 @@ function ScheduleGrid({
|
||||
const [creatingTime, setCreatingTime] = useState<string | null>(null);
|
||||
|
||||
async function confirmCreate(startTime: string, data: { trainer: string; style: string; endTime: string }) {
|
||||
await adminFetch("/api/admin/open-day/classes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }),
|
||||
});
|
||||
setCreatingTime(null);
|
||||
onClassesChange();
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/open-day/classes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ eventId, hall: selectedHall, startTime, endTime: data.endTime, trainer: data.trainer, style: data.style }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
setCreatingTime(null);
|
||||
onClassesChange();
|
||||
} catch {
|
||||
alert("Не удалось создать занятие");
|
||||
}
|
||||
}
|
||||
|
||||
async function updateClass(id: number, data: Partial<OpenDayClass>) {
|
||||
await adminFetch("/api/admin/open-day/classes", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ...data }),
|
||||
});
|
||||
onClassesChange();
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/open-day/classes", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ...data }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
onClassesChange();
|
||||
} catch {
|
||||
alert("Не удалось обновить занятие");
|
||||
}
|
||||
}
|
||||
|
||||
function deleteClass(id: number) {
|
||||
setConfirmAction({
|
||||
message: "Удалить занятие? Это действие нельзя отменить.",
|
||||
onConfirm: async () => {
|
||||
await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
|
||||
onClassesChange();
|
||||
try {
|
||||
const res = await adminFetch(`/api/admin/open-day/classes?id=${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error();
|
||||
onClassesChange();
|
||||
} catch {
|
||||
alert("Не удалось удалить занятие");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -453,7 +468,7 @@ function ScheduleGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={gridRef} className="rounded-xl border border-white/10 bg-neutral-900 p-5 space-y-3">
|
||||
<div ref={gridRef} className="rounded-xl border border-neutral-200 bg-white p-5 space-y-3 dark:border-white/10 dark:bg-neutral-900">
|
||||
<h2 className="text-lg font-bold">Расписание</h2>
|
||||
|
||||
{halls.length === 0 ? (
|
||||
@@ -469,7 +484,7 @@ function ScheduleGrid({
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
selectedHall === hall
|
||||
? "bg-gold/20 text-gold border border-gold/40"
|
||||
: "bg-neutral-800 text-neutral-400 border border-white/10 hover:text-white"
|
||||
: "bg-neutral-100 text-neutral-500 border border-neutral-200 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:border-white/10 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{hall}
|
||||
@@ -487,8 +502,8 @@ function ScheduleGrid({
|
||||
{timeSlots.map((time) => {
|
||||
const cls = hallClasses[time];
|
||||
return (
|
||||
<div key={time} className="flex items-start gap-3 border-t border-white/5 py-1.5">
|
||||
<span className="text-xs text-neutral-500 w-12 pt-1.5 shrink-0">{time}</span>
|
||||
<div key={time} className="flex items-start gap-3 border-t border-neutral-200 py-1.5 dark:border-white/5">
|
||||
<span className="text-xs text-neutral-400 w-12 pt-1.5 shrink-0 dark:text-neutral-500">{time}</span>
|
||||
<div className="flex-1">
|
||||
{cls ? (
|
||||
<ClassCell
|
||||
@@ -513,7 +528,7 @@ function ScheduleGrid({
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setCreatingTime(time); setEditingClassId(null); }}
|
||||
className="w-full rounded-lg border border-dashed border-white/5 p-2 text-neutral-600 hover:text-gold hover:border-gold/20 transition-colors"
|
||||
className="w-full rounded-lg border border-dashed border-neutral-200 p-2 text-neutral-400 hover:text-gold hover:border-gold/20 transition-colors dark:border-white/5 dark:text-neutral-600"
|
||||
>
|
||||
<Plus size={12} className="mx-auto" />
|
||||
</button>
|
||||
@@ -530,12 +545,12 @@ function ScheduleGrid({
|
||||
{confirmAction && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setConfirmAction(null)}>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div className="relative w-full max-w-xs rounded-xl border border-white/[0.08] bg-[#141414] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="text-sm text-white text-center">{confirmAction.message}</p>
|
||||
<div className="relative w-full max-w-xs rounded-xl border border-neutral-200 bg-white p-5 shadow-2xl dark:border-white/[0.08] dark:bg-[#141414]" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="text-sm text-neutral-900 text-center dark:text-white">{confirmAction.message}</p>
|
||||
<div className="mt-4 flex gap-2 justify-center">
|
||||
<button
|
||||
onClick={() => setConfirmAction(null)}
|
||||
className="rounded-lg border border-white/10 px-4 py-2 text-xs font-medium text-neutral-400 hover:text-white hover:border-white/25 transition-colors"
|
||||
className="rounded-lg border border-neutral-200 px-4 py-2 text-xs font-medium text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25"
|
||||
>
|
||||
Нет
|
||||
</button>
|
||||
|
||||
@@ -63,13 +63,13 @@ function UnreadWidget({ counts }: { counts: UnreadCounts }) {
|
||||
<UserPlus size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-medium text-white">
|
||||
<h2 className="font-medium text-neutral-900 dark:text-white">
|
||||
Новые записи
|
||||
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-red-500 text-white text-[11px] font-bold min-w-[20px] h-[20px] px-1.5">
|
||||
{counts.total}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-400">Не подтверждённые заявки</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">Не подтверждённые заявки</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -78,7 +78,7 @@ function UnreadWidget({ counts }: { counts: UnreadCounts }) {
|
||||
<span className="rounded-full bg-gold/15 text-gold font-medium px-2 py-0.5">
|
||||
{item.count}
|
||||
</span>
|
||||
<span className="text-neutral-400">{item.label}</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -91,15 +91,15 @@ export default function AdminDashboard() {
|
||||
|
||||
useEffect(() => {
|
||||
adminFetch("/api/admin/unread-counts")
|
||||
.then((r) => r.json())
|
||||
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
||||
.then((data: UnreadCounts) => setCounts(data))
|
||||
.catch(() => {});
|
||||
.catch(() => { /* initial load — non-critical */ });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Панель управления</h1>
|
||||
<p className="mt-1 text-neutral-400">Выберите раздел для редактирования</p>
|
||||
<p className="mt-1 text-neutral-500 dark:text-neutral-400">Выберите раздел для редактирования</p>
|
||||
|
||||
{/* Unread bookings widget */}
|
||||
{counts && counts.total > 0 && (
|
||||
@@ -116,14 +116,14 @@ export default function AdminDashboard() {
|
||||
<Link
|
||||
key={card.href}
|
||||
href={card.href}
|
||||
className="group rounded-xl border border-white/10 bg-neutral-900 p-5 transition-all hover:border-gold/30 hover:bg-neutral-900/80"
|
||||
className="group rounded-xl border border-neutral-200 bg-white p-5 transition-all hover:border-gold/30 hover:bg-neutral-50 dark:border-white/10 dark:bg-neutral-900 dark:hover:bg-neutral-900/80"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gold/10 text-gold">
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="font-medium text-white group-hover:text-gold transition-colors flex items-center gap-2">
|
||||
<h2 className="font-medium text-neutral-900 group-hover:text-gold transition-colors flex items-center gap-2 dark:text-white">
|
||||
{card.label}
|
||||
{isBookings && counts && counts.total > 0 && (
|
||||
<span className="rounded-full bg-red-500 text-white text-[10px] font-bold min-w-[18px] h-[18px] flex items-center justify-center px-1">
|
||||
@@ -131,7 +131,7 @@ export default function AdminDashboard() {
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-500">{card.desc}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-500">{card.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -222,7 +222,7 @@ function ClassBlock({
|
||||
: {}),
|
||||
}}
|
||||
className={`absolute left-1 right-1 rounded-md border border-white/20 border-l-3 px-2 py-0.5 text-left text-xs text-white cursor-grab active:cursor-grabbing overflow-hidden select-none ${colors} ${
|
||||
isOverlapping ? "ring-2 ring-red-500 ring-offset-1 ring-offset-neutral-900" : ""
|
||||
isOverlapping ? "ring-2 ring-red-500 ring-offset-1 ring-offset-white dark:ring-offset-neutral-900" : ""
|
||||
} ${isDragging ? "opacity-30" : "hover:opacity-90 hover:border-white/40"}`}
|
||||
title={`${cls.time}\n${cls.type}\n${cls.trainer}${cls.level ? ` · ${cls.level}` : ""}${cls.status ? ` · ${cls.status}` : ""}`}
|
||||
>
|
||||
@@ -415,14 +415,14 @@ function ClassModal({
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl border border-white/10 bg-neutral-900 p-6 shadow-2xl"
|
||||
className="w-full max-w-md rounded-xl border border-neutral-200 bg-white p-6 shadow-2xl dark:border-white/10 dark:bg-neutral-900"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
<h3 className="text-lg font-bold text-neutral-900 dark:text-white">
|
||||
{isNew ? "Новое занятие" : "Редактировать занятие"}
|
||||
</h3>
|
||||
<button type="button" onClick={onClose} className="text-neutral-400 hover:text-white">
|
||||
<button type="button" onClick={onClose} className="text-neutral-400 hover:text-neutral-900 transition-colors dark:hover:text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -431,7 +431,7 @@ function ClassModal({
|
||||
{/* Day selector */}
|
||||
{allDays.length > 1 && (
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-2">Дни</label>
|
||||
<label className="block text-sm text-neutral-500 mb-2 dark:text-neutral-400">Дни</label>
|
||||
|
||||
{/* Day toggle buttons */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -445,7 +445,7 @@ function ClassModal({
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
active
|
||||
? "bg-gold/20 text-gold border border-gold/40"
|
||||
: "border border-white/10 text-neutral-500 hover:text-white hover:border-white/20"
|
||||
: "border border-neutral-200 text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 dark:border-white/10 dark:hover:text-white dark:hover:border-white/20"
|
||||
}`}
|
||||
>
|
||||
{d.dayShort}
|
||||
@@ -473,10 +473,10 @@ function ClassModal({
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm text-neutral-300 select-none"
|
||||
className="flex items-center gap-2 text-sm text-neutral-600 select-none dark:text-neutral-300"
|
||||
>
|
||||
<span className={`inline-flex items-center justify-center w-4 h-4 rounded border transition-colors ${
|
||||
sameTime ? "bg-gold border-gold" : "border-white/20 bg-neutral-800"
|
||||
sameTime ? "bg-gold border-gold" : "border-neutral-300 bg-neutral-100 dark:border-white/20 dark:bg-neutral-800"
|
||||
}`}>
|
||||
{sameTime && <span className="text-black text-xs font-bold leading-none">✓</span>}
|
||||
</span>
|
||||
@@ -492,10 +492,10 @@ function ClassModal({
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm text-neutral-400">Время по дням</label>
|
||||
<label className="block text-sm text-neutral-500 dark:text-neutral-400">Время по дням</label>
|
||||
{allDays.filter((d) => selectedDays.has(d.day)).map((d) => (
|
||||
<div key={d.day} className="flex items-center gap-2">
|
||||
<span className="shrink-0 text-xs font-medium text-neutral-400 min-w-[28px]">
|
||||
<span className="shrink-0 text-xs font-medium text-neutral-500 min-w-[28px] dark:text-neutral-400">
|
||||
{d.dayShort}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
@@ -576,7 +576,7 @@ function ClassModal({
|
||||
}}
|
||||
className={`flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-opacity ${
|
||||
touched && !isValid
|
||||
? "bg-neutral-700 text-neutral-400 cursor-not-allowed"
|
||||
? "bg-neutral-200 text-neutral-400 cursor-not-allowed dark:bg-neutral-700"
|
||||
: "bg-gold text-black hover:opacity-90"
|
||||
}`}
|
||||
>
|
||||
@@ -973,18 +973,18 @@ function CalendarGrid({
|
||||
|
||||
{/* Calendar */}
|
||||
{sortedDays.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-lg border border-white/10" ref={gridRef}>
|
||||
<div className="overflow-x-auto rounded-lg border border-neutral-200 dark:border-white/10" ref={gridRef}>
|
||||
<div className="min-w-[600px]">
|
||||
{/* Day headers */}
|
||||
<div className="flex border-b border-white/10 bg-neutral-800/50">
|
||||
<div className="w-14 shrink-0 bg-neutral-900" />
|
||||
<div className="flex border-b border-neutral-200 bg-neutral-100 dark:border-white/10 dark:bg-neutral-800/50">
|
||||
<div className="w-14 shrink-0 bg-neutral-50 dark:bg-neutral-900" />
|
||||
{sortedDays.map((day, di) => (
|
||||
<div
|
||||
key={day.day}
|
||||
className="flex-1 border-l border-white/10 px-2 py-2 text-center"
|
||||
className="flex-1 border-l border-neutral-200 px-2 py-2 text-center dark:border-white/10"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="text-sm font-medium text-white">{day.dayShort}</span>
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-white">{day.dayShort}</span>
|
||||
<span className="text-xs text-neutral-500">({day.classes.length})</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1017,7 +1017,7 @@ function CalendarGrid({
|
||||
<div
|
||||
key={day.day}
|
||||
ref={(el) => { columnRefs.current[di] = el; }}
|
||||
className={`flex-1 border-l border-white/10 relative ${drag ? "cursor-grabbing" : "cursor-pointer"}`}
|
||||
className={`flex-1 border-l border-neutral-200 relative dark:border-white/10 ${drag ? "cursor-grabbing" : "cursor-pointer"}`}
|
||||
style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT}px` }}
|
||||
onMouseMove={(e) => {
|
||||
if (drag) return;
|
||||
@@ -1044,7 +1044,7 @@ function CalendarGrid({
|
||||
{hours.slice(0, -1).map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="absolute left-0 right-0 border-t border-white/5"
|
||||
className="absolute left-0 right-0 border-t border-neutral-200/60 dark:border-white/5"
|
||||
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT}px` }}
|
||||
/>
|
||||
))}
|
||||
@@ -1052,7 +1052,7 @@ function CalendarGrid({
|
||||
{hours.slice(0, -1).map((h) => (
|
||||
<div
|
||||
key={`${h}-30`}
|
||||
className="absolute left-0 right-0 border-t border-white/[0.02]"
|
||||
className="absolute left-0 right-0 border-t border-neutral-200/30 dark:border-white/[0.02]"
|
||||
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT + HOUR_HEIGHT / 2}px` }}
|
||||
/>
|
||||
))}
|
||||
@@ -1330,7 +1330,7 @@ export default function ScheduleEditorPage() {
|
||||
className={`flex items-center gap-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
i === activeLocation
|
||||
? "bg-gold/10 text-gold border border-gold/30"
|
||||
: "border border-white/10 text-neutral-400 hover:text-white"
|
||||
: "border border-neutral-200 text-neutral-500 hover:text-neutral-900 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
@@ -1362,7 +1362,7 @@ export default function ScheduleEditorPage() {
|
||||
update({ ...data, locations: newLocations });
|
||||
setActiveLocation(newLocations.length - 1);
|
||||
}}
|
||||
className="rounded-lg border border-dashed border-white/20 px-4 py-2 text-sm text-neutral-500 hover:text-white transition-colors"
|
||||
className="rounded-lg border border-dashed border-neutral-300 px-4 py-2 text-sm text-neutral-500 hover:text-neutral-900 transition-colors dark:border-white/20 dark:hover:text-white"
|
||||
>
|
||||
<Plus size={14} className="inline" /> Локация
|
||||
</button>
|
||||
|
||||
@@ -81,19 +81,32 @@ export default function TeamEditorPage() {
|
||||
}, [sectionTitle]);
|
||||
|
||||
const saveOrder = useCallback(async (updated: Member[]) => {
|
||||
const previous = members;
|
||||
setMembers(updated);
|
||||
const res = await adminFetch("/api/admin/team/reorder", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
||||
});
|
||||
setSaveStatus(res.ok ? "saved" : "error");
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/team/reorder", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids: updated.map((m) => m.id) }),
|
||||
});
|
||||
setSaveStatus(res.ok ? "saved" : "error");
|
||||
if (!res.ok) setMembers(previous);
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
setMembers(previous);
|
||||
}
|
||||
setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
}, []);
|
||||
}, [members]);
|
||||
|
||||
async function deleteMember(id: number) {
|
||||
await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
|
||||
setMembers((prev) => prev.filter((m) => m.id !== id));
|
||||
try {
|
||||
const res = await adminFetch(`/api/admin/team/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error();
|
||||
setMembers((prev) => prev.filter((m) => m.id !== id));
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -152,18 +165,18 @@ export default function TeamEditorPage() {
|
||||
renderItem={(member) => (
|
||||
<Link
|
||||
href={`/admin/team/${member.id}`}
|
||||
className="flex items-center gap-4 flex-1 min-w-0 rounded-lg px-2 py-1.5 -my-1.5 hover:bg-white/[0.04] transition-colors"
|
||||
className="flex items-center gap-4 flex-1 min-w-0 rounded-lg px-2 py-1.5 -my-1.5 hover:bg-neutral-100 transition-colors dark:hover:bg-white/[0.04]"
|
||||
>
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg">
|
||||
{member.image ? (
|
||||
<Image src={member.image} alt={member.name} fill className="object-cover" sizes="56px" />
|
||||
) : (
|
||||
<div className="h-full w-full bg-neutral-800" />
|
||||
<div className="h-full w-full bg-neutral-200 dark:bg-neutral-800" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-white truncate">{member.name}</p>
|
||||
<p className="text-sm text-neutral-400 truncate">{member.role}</p>
|
||||
<p className="font-medium text-neutral-900 truncate dark:text-white">{member.name}</p>
|
||||
<p className="text-sm text-neutral-500 truncate dark:text-neutral-400">{member.role}</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getGroupBookings, addGroupBooking, toggleGroupBookingNotification, deleteGroupBooking, setGroupBookingStatus, updateBookingNotes } from "@/lib/db";
|
||||
import type { BookingStatus } from "@/lib/db";
|
||||
import { sanitizeText } from "@/lib/validation";
|
||||
import { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||
|
||||
export async function GET() {
|
||||
const bookings = getGroupBookings();
|
||||
@@ -50,10 +50,12 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, phone, groupInfo, instagram, telegram } = body;
|
||||
if (!name?.trim() || !phone?.trim()) {
|
||||
const cleanName = sanitizeName(name);
|
||||
const cleanPhone = sanitizePhone(phone);
|
||||
if (!cleanName || !cleanPhone) {
|
||||
return NextResponse.json({ error: "name and phone are required" }, { status: 400 });
|
||||
}
|
||||
const id = addGroupBooking(name.trim(), phone.trim(), groupInfo?.trim() || undefined, instagram?.trim() || undefined, telegram?.trim() || undefined);
|
||||
const id = addGroupBooking(cleanName, cleanPhone, sanitizeText(groupInfo, 500), sanitizeHandle(instagram), sanitizeHandle(telegram));
|
||||
return NextResponse.json({ ok: true, id });
|
||||
} catch (err) {
|
||||
console.error("[admin/group-bookings] POST error:", err);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getMcRegistrations, getAllMcRegistrations, addMcRegistration, updateMcRegistration, toggleMcNotification, deleteMcRegistration, setMcRegistrationStatus, updateBookingNotes } from "@/lib/db";
|
||||
import { sanitizeText } from "@/lib/validation";
|
||||
import { sanitizeName, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const title = request.nextUrl.searchParams.get("title");
|
||||
@@ -15,10 +15,13 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { masterClassTitle, name, instagram, telegram } = body;
|
||||
if (!masterClassTitle || !name || !instagram) {
|
||||
const cleanTitle = sanitizeText(masterClassTitle, 200);
|
||||
const cleanName = sanitizeName(name);
|
||||
const cleanInstagram = sanitizeHandle(instagram);
|
||||
if (!cleanTitle || !cleanName || !cleanInstagram) {
|
||||
return NextResponse.json({ error: "masterClassTitle, name, instagram are required" }, { status: 400 });
|
||||
}
|
||||
const id = addMcRegistration(masterClassTitle.trim(), name.trim(), instagram.trim(), telegram?.trim() || undefined);
|
||||
const id = addMcRegistration(cleanTitle, cleanName, cleanInstagram, sanitizeHandle(telegram));
|
||||
return NextResponse.json({ ok: true, id });
|
||||
} catch (err) {
|
||||
console.error("[admin/mc-registrations] error:", err);
|
||||
@@ -64,10 +67,12 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
// Regular update
|
||||
const { id, name, instagram, telegram } = body;
|
||||
if (!id || !name || !instagram) {
|
||||
const cleanName = sanitizeName(name);
|
||||
const cleanInstagram = sanitizeHandle(instagram);
|
||||
if (!id || !cleanName || !cleanInstagram) {
|
||||
return NextResponse.json({ error: "id, name, instagram are required" }, { status: 400 });
|
||||
}
|
||||
updateMcRegistration(id, name.trim(), instagram.trim(), telegram?.trim() || undefined);
|
||||
updateMcRegistration(id, cleanName, cleanInstagram, sanitizeHandle(telegram));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("[admin/mc-registrations] error:", err);
|
||||
|
||||
@@ -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) {
|
||||
const { key } = await params;
|
||||
if (!SECTION_KEYS.includes(key as typeof SECTION_KEYS[number])) {
|
||||
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);
|
||||
invalidateContentCache();
|
||||
revalidatePath("/");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getTeamMember, updateTeamMember, deleteTeamMember } from "@/lib/db";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { sanitizeName, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -28,7 +29,14 @@ export async function PUT(request: NextRequest, { params }: Params) {
|
||||
if (!numId) {
|
||||
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);
|
||||
revalidatePath("/");
|
||||
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 IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"];
|
||||
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 VIDEO_FOLDERS = ["hero"];
|
||||
const ALL_FOLDERS = [...IMAGE_FOLDERS, ...VIDEO_FOLDERS];
|
||||
@@ -71,6 +93,15 @@ export async function POST(request: NextRequest) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
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);
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const INSTAGRAM_USERNAME_RE = /^[a-zA-Z0-9_.]{1,30}$/;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = request.nextUrl.searchParams.get("username")?.trim();
|
||||
if (!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 {
|
||||
const res = await fetch(`https://www.instagram.com/${username}/`, {
|
||||
method: "HEAD",
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { verifyPassword, signToken, generateCsrfToken, COOKIE_NAME, CSRF_COOKIE_NAME } from "@/lib/auth";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/rateLimit";
|
||||
|
||||
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 };
|
||||
|
||||
if (!body.password || !verifyPassword(body.password)) {
|
||||
@@ -23,7 +32,7 @@ export async function POST(request: NextRequest) {
|
||||
response.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
|
||||
httpOnly: false, // JS must read this to send as header
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
sameSite: "lax", // Match auth cookie; strict breaks admin access from external links
|
||||
path: "/",
|
||||
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";
|
||||
|
||||
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 });
|
||||
response.cookies.set(COOKIE_NAME, "", {
|
||||
httpOnly: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { sanitizeName, sanitizePhone, sanitizeHandle, sanitizeText } from "@/lib/validation";
|
||||
import type { MasterClassItem } from "@/types/content";
|
||||
@@ -32,23 +32,20 @@ export async function POST(request: Request) {
|
||||
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 mcItem = mcSection?.items?.find((mc) => mc.title === cleanTitle);
|
||||
let isWaiting = false;
|
||||
if (mcItem?.maxParticipants && mcItem.maxParticipants > 0) {
|
||||
const currentRegs = getMcRegistrations(cleanTitle);
|
||||
const confirmedCount = currentRegs.filter((r) => r.status === "confirmed").length;
|
||||
isWaiting = confirmedCount >= mcItem.maxParticipants;
|
||||
}
|
||||
const maxParticipants = mcItem?.maxParticipants && mcItem.maxParticipants > 0
|
||||
? mcItem.maxParticipants : undefined;
|
||||
|
||||
const id = addMcRegistration(
|
||||
// Atomic check-and-insert inside a transaction to prevent race condition
|
||||
const { id, isWaiting } = addMcRegistrationAtomic(
|
||||
cleanTitle,
|
||||
cleanName,
|
||||
sanitizeHandle(instagram) ?? "",
|
||||
sanitizeHandle(telegram),
|
||||
cleanPhone,
|
||||
isWaiting ? "Лист ожидания" : undefined
|
||||
maxParticipants
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, id, isWaiting });
|
||||
|
||||
@@ -26,6 +26,7 @@ body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* ===== Selection ===== */
|
||||
|
||||
::selection {
|
||||
@@ -103,6 +104,10 @@ input[type="number"] {
|
||||
|
||||
html {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 160, 160, 0.5) #f5f5f5;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
scrollbar-color: rgba(201, 169, 110, 0.3) var(--color-surface-dark);
|
||||
}
|
||||
|
||||
@@ -111,14 +116,26 @@ html {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
html.dark ::-webkit-scrollbar-track {
|
||||
background: var(--color-surface-dark);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(201, 169, 110, 0.3);
|
||||
background: rgba(160, 160, 160, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
html.dark ::-webkit-scrollbar-thumb {
|
||||
background: rgba(201, 169, 110, 0.3);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(120, 120, 120, 0.6);
|
||||
}
|
||||
|
||||
html.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(201, 169, 110, 0.5);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,14 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ru" className="dark">
|
||||
<html lang="ru" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var t=localStorage.getItem('theme');if(t==='light'){document.documentElement.classList.remove('dark')}else{document.documentElement.classList.add('dark')}}catch(e){}})();`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${inter.variable} ${oswald.variable} surface-base font-sans antialiased`}
|
||||
>
|
||||
|
||||
@@ -1,37 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import { Hero } from "@/components/sections/Hero";
|
||||
import { Team } from "@/components/sections/Team";
|
||||
import { About } from "@/components/sections/About";
|
||||
import { Classes } from "@/components/sections/Classes";
|
||||
import { TeamPreview } from "@/components/sections/TeamPreview";
|
||||
import { MasterClasses } from "@/components/sections/MasterClasses";
|
||||
import { Schedule } from "@/components/sections/Schedule";
|
||||
import { Pricing } from "@/components/sections/Pricing";
|
||||
import { News } from "@/components/sections/News";
|
||||
import { FAQ } from "@/components/sections/FAQ";
|
||||
import { Contact } from "@/components/sections/Contact";
|
||||
import { BackToTop } from "@/components/ui/BackToTop";
|
||||
import { FloatingContact } from "@/components/ui/FloatingContact";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { ClientShell } from "@/components/layout/ClientShell";
|
||||
import { getContent } from "@/lib/content";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
import { OpenDay } from "@/components/sections/OpenDay";
|
||||
import { getActiveOpenDay } from "@/lib/openDay";
|
||||
import { getAllMcRegistrations } from "@/lib/db";
|
||||
import { getMcRegistrationCounts } from "@/lib/db";
|
||||
|
||||
export default function HomePage() {
|
||||
const content = getContent();
|
||||
const openDayData = getActiveOpenDay();
|
||||
// Count MC registrations per title for capacity check
|
||||
const allMcRegs = getAllMcRegistrations();
|
||||
const mcRegCounts: Record<string, number> = {};
|
||||
for (const reg of allMcRegs) mcRegCounts[reg.masterClassTitle] = (mcRegCounts[reg.masterClassTitle] || 0) + 1;
|
||||
const mcRegCounts = getMcRegistrationCounts();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ClientShell>
|
||||
<Header />
|
||||
<Header popups={content?.popups} />
|
||||
<main id="main-content">
|
||||
{content?.hero && <Hero data={content.hero} />}
|
||||
{content?.about && (
|
||||
@@ -45,16 +41,29 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
{content?.classes && <Classes data={content.classes} />}
|
||||
{content?.team && <Team data={content.team} schedule={content.schedule?.locations} scheduleConfig={content.scheduleConfig} />}
|
||||
{content?.team && <TeamPreview title={content.team.title} members={content.team.members} schedule={content.schedule?.locations} scheduleConfig={content.scheduleConfig} />}
|
||||
{openDayData && content?.popups && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team?.members ?? []} locations={content.schedule?.locations} />}
|
||||
{content?.schedule && <Schedule data={content.schedule} scheduleConfig={content.scheduleConfig} classItems={content.classes?.items ?? []} teamMembers={content.team?.members ?? []} />}
|
||||
{content?.schedule && (
|
||||
<section id="schedule" className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505] overflow-hidden">
|
||||
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||
<div className="section-container text-center">
|
||||
<SectionHeading centered>{content.schedule.title}</SectionHeading>
|
||||
<p className="mt-4 text-neutral-500 dark:text-neutral-400">
|
||||
{content.schedule.locations.length} студии · {content.schedule.locations.reduce((sum, loc) => sum + loc.days.reduce((s, d) => s + d.classes.length, 0), 0)} занятий в неделю
|
||||
</p>
|
||||
<Link href="/schedule" className="mt-6 inline-flex items-center gap-2 rounded-full bg-gold px-8 py-3 text-sm font-semibold text-black transition-all hover:shadow-[0_0_24px_rgba(201,169,110,0.4)]">
|
||||
Смотреть расписание
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{content?.pricing && <Pricing data={content.pricing} />}
|
||||
{content?.masterClasses && <MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} locations={content.schedule?.locations} />}
|
||||
{content?.news && <News data={content.news} />}
|
||||
{content?.faq && <FAQ data={content.faq} />}
|
||||
{content?.contact && <Contact data={content.contact} />}
|
||||
<BackToTop />
|
||||
<FloatingContact />
|
||||
<FloatingContact popups={content?.popups} contactInstagram={content?.contact?.instagram} contactPhone={content?.contact?.phone} />
|
||||
</main>
|
||||
<Footer />
|
||||
</ClientShell>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Metadata } from "next";
|
||||
import { getContent } from "@/lib/content";
|
||||
import { Schedule } from "@/components/sections/Schedule";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { ClientShell } from "@/components/layout/ClientShell";
|
||||
import { BackToTop } from "@/components/ui/BackToTop";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Расписание | BLACK HEART DANCE HOUSE",
|
||||
};
|
||||
|
||||
export default function SchedulePage() {
|
||||
const content = getContent();
|
||||
|
||||
if (!content?.schedule) {
|
||||
return (
|
||||
<ClientShell>
|
||||
<Header popups={content?.popups} />
|
||||
<main id="main-content" className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-neutral-400">Расписание не найдено.</p>
|
||||
</main>
|
||||
<Footer />
|
||||
</ClientShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientShell>
|
||||
<Header popups={content.popups} />
|
||||
<main id="main-content">
|
||||
<Schedule
|
||||
data={content.schedule}
|
||||
scheduleConfig={content.scheduleConfig}
|
||||
classItems={content.classes?.items ?? []}
|
||||
teamMembers={content.team?.members ?? []}
|
||||
/>
|
||||
<BackToTop />
|
||||
</main>
|
||||
<Footer />
|
||||
</ClientShell>
|
||||
);
|
||||
}
|
||||
@@ -136,12 +136,12 @@
|
||||
.gradient-text {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#8a6f3e 0%,
|
||||
#c9a96e 20%,
|
||||
#8a6f3e 40%,
|
||||
#c9a96e 60%,
|
||||
#6b5530 80%,
|
||||
#8a6f3e 100%
|
||||
#c9a96e 0%,
|
||||
#e2c97e 20%,
|
||||
#d4b87a 40%,
|
||||
#e2c97e 60%,
|
||||
#c9a96e 80%,
|
||||
#d4b87a 100%
|
||||
);
|
||||
background-size: 200% 200%;
|
||||
-webkit-background-clip: text;
|
||||
@@ -150,7 +150,12 @@
|
||||
animation: gradient-shift 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Light mode gradient text */
|
||||
/* Auto-switch gradient text for light mode */
|
||||
html:not(.dark) .gradient-text {
|
||||
background-image: linear-gradient(135deg, #a08050 0%, #c9a96e 25%, #8a6f3e 50%, #c9a96e 75%, #a08050 100%);
|
||||
}
|
||||
|
||||
/* Explicit light mode gradient text class (legacy) */
|
||||
.gradient-text-light {
|
||||
background: linear-gradient(135deg, #171717 0%, #c9a96e 50%, #171717 100%);
|
||||
background-size: 200% 200%;
|
||||
@@ -172,7 +177,7 @@
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(201, 169, 110, 0.3), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.15));
|
||||
background: linear-gradient(135deg, rgba(201, 169, 110, 0.5), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.3));
|
||||
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
@@ -180,8 +185,16 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
:is(.dark) .animated-border::before {
|
||||
background: linear-gradient(135deg, rgba(201, 169, 110, 0.3), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.15));
|
||||
}
|
||||
|
||||
.animated-border:hover::before {
|
||||
opacity: 1;
|
||||
background: linear-gradient(135deg, rgba(201, 169, 110, 0.7), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.5));
|
||||
}
|
||||
|
||||
:is(.dark) .animated-border:hover::before {
|
||||
background: linear-gradient(135deg, rgba(201, 169, 110, 0.6), transparent 40%, transparent 60%, rgba(201, 169, 110, 0.4));
|
||||
}
|
||||
|
||||
@@ -192,10 +205,14 @@
|
||||
}
|
||||
|
||||
.glow-hover:hover {
|
||||
box-shadow: 0 0 30px rgba(201, 169, 110, 0.1), 0 0 60px rgba(201, 169, 110, 0.05);
|
||||
box-shadow: 0 0 20px rgba(201, 169, 110, 0.25), 0 0 50px rgba(201, 169, 110, 0.12), 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
:is(.dark) .glow-hover:hover {
|
||||
box-shadow: 0 0 30px rgba(201, 169, 110, 0.1), 0 0 60px rgba(201, 169, 110, 0.05);
|
||||
}
|
||||
|
||||
/* ===== Scroll Reveal ===== */
|
||||
|
||||
.reveal {
|
||||
@@ -271,6 +288,31 @@
|
||||
animation: modal-fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ===== Team Marquee ===== */
|
||||
|
||||
@keyframes team-marquee-left {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
@keyframes team-marquee-right {
|
||||
from { transform: translateX(-50%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* ===== Team Grid Card Entrance ===== */
|
||||
|
||||
@keyframes team-grid-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Team Info Fade ===== */
|
||||
|
||||
@keyframes team-info-in {
|
||||
@@ -345,7 +387,19 @@
|
||||
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(201, 169, 110, 0.15), transparent);
|
||||
background: linear-gradient(90deg, transparent 5%, rgba(201, 169, 110, 0.4) 30%, rgba(201, 169, 110, 0.5) 50%, rgba(201, 169, 110, 0.4) 70%, transparent 95%);
|
||||
}
|
||||
|
||||
:is(.dark) .section-divider {
|
||||
background: linear-gradient(90deg, transparent 5%, rgba(201, 169, 110, 0.12) 30%, rgba(201, 169, 110, 0.2) 50%, rgba(201, 169, 110, 0.12) 70%, transparent 95%);
|
||||
}
|
||||
|
||||
/* ===== 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 ===== */
|
||||
@@ -393,4 +447,10 @@
|
||||
.team-card-glitter::before {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
[style*="team-grid-card-in"] {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
@apply hover:bg-gold-light hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
|
||||
@apply dark:bg-gold dark:text-black;
|
||||
@apply dark:hover:bg-gold-light dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
|
||||
@apply focus:outline-none focus-visible:ring-2 focus-visible:ring-gold-light focus-visible:ring-offset-2 focus-visible:ring-offset-black;
|
||||
@apply active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none;
|
||||
@apply focus:outline-none focus-visible:ring-2 focus-visible:ring-gold-light focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-black;
|
||||
}
|
||||
|
||||
/* ===== Scrollbar ===== */
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
}
|
||||
|
||||
.surface-muted {
|
||||
@apply bg-neutral-100;
|
||||
@apply dark:bg-[var(--color-surface-deep)];
|
||||
@apply bg-neutral-100 text-neutral-900;
|
||||
@apply dark:bg-[var(--color-surface-deep)] dark:text-neutral-100;
|
||||
}
|
||||
|
||||
.surface-glass {
|
||||
@@ -16,8 +16,8 @@
|
||||
}
|
||||
|
||||
.surface-card {
|
||||
@apply bg-white/80 backdrop-blur-sm;
|
||||
@apply dark:bg-neutral-900 dark:backdrop-blur-sm;
|
||||
@apply bg-white shadow-sm backdrop-blur-sm;
|
||||
@apply dark:bg-neutral-900 dark:shadow-none dark:backdrop-blur-sm;
|
||||
}
|
||||
|
||||
/* ===== Borders ===== */
|
||||
@@ -52,11 +52,11 @@
|
||||
/* ===== Layout ===== */
|
||||
|
||||
.section-padding {
|
||||
@apply py-20 sm:py-32;
|
||||
@apply py-20 sm:py-28;
|
||||
}
|
||||
|
||||
.section-container {
|
||||
@apply mx-auto max-w-6xl px-6 sm:px-8;
|
||||
@apply mx-auto max-w-7xl px-6 sm:px-10;
|
||||
}
|
||||
|
||||
/* ===== Section Glow Backgrounds ===== */
|
||||
@@ -65,28 +65,17 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.section-glow::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(600px, 100%);
|
||||
height: 400px;
|
||||
background: radial-gradient(ellipse, rgba(201, 169, 110, 0.05), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== Glass Card ===== */
|
||||
|
||||
.glass-card {
|
||||
@apply rounded-2xl border backdrop-blur-sm transition-all duration-300;
|
||||
@apply border-neutral-200/80 bg-white/90;
|
||||
@apply dark:border-white/[0.06] dark:bg-white/[0.04];
|
||||
@apply rounded-2xl border backdrop-blur-md transition-all duration-300;
|
||||
@apply border-neutral-200/80 bg-white/90 shadow-sm shadow-gold/[0.04];
|
||||
@apply dark:border-white/[0.08] dark:bg-white/[0.05] dark:backdrop-blur-lg dark:shadow-none;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
@apply dark:border-gold/15 dark:bg-white/[0.06];
|
||||
@apply border-gold/30 bg-white shadow-md shadow-gold/[0.08];
|
||||
@apply dark:border-gold/20 dark:bg-white/[0.08] dark:shadow-[0_0_30px_rgba(201,169,110,0.06)];
|
||||
}
|
||||
|
||||
/* ===== Photo Filter ===== */
|
||||
@@ -99,10 +88,43 @@
|
||||
filter: saturate(0.6) sepia(0.2) brightness(0.9) contrast(1.1);
|
||||
}
|
||||
|
||||
/* ===== Modal Surface ===== */
|
||||
|
||||
.modal-surface {
|
||||
@apply bg-white dark:bg-neutral-950;
|
||||
}
|
||||
|
||||
/* ===== Theme Input ===== */
|
||||
|
||||
.theme-input {
|
||||
@apply border-neutral-300 bg-neutral-50 text-neutral-900 placeholder-neutral-400;
|
||||
@apply focus:border-gold/60 focus:bg-white;
|
||||
@apply dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white dark:placeholder-neutral-500;
|
||||
@apply dark:focus:border-gold/40 dark:focus:bg-white/[0.06];
|
||||
}
|
||||
|
||||
/* ===== Admin Surface ===== */
|
||||
|
||||
.admin-surface {
|
||||
@apply bg-white text-neutral-900 dark:bg-neutral-950 dark:text-white;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
@apply bg-neutral-100 border-neutral-200 dark:bg-neutral-900 dark:border-white/10;
|
||||
}
|
||||
|
||||
.admin-nav-item {
|
||||
@apply text-neutral-500 hover:text-neutral-900 hover:bg-neutral-200/60 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-white/5;
|
||||
}
|
||||
|
||||
/* ===== Custom Scrollbar ===== */
|
||||
|
||||
.styled-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(160, 160, 160, 0.5) transparent;
|
||||
}
|
||||
|
||||
:is(.dark) .styled-scrollbar {
|
||||
scrollbar-color: rgba(201, 169, 110, 0.25) transparent;
|
||||
}
|
||||
|
||||
@@ -116,10 +138,18 @@
|
||||
}
|
||||
|
||||
.styled-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(201, 169, 110, 0.25);
|
||||
background: rgba(160, 160, 160, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:is(.dark) .styled-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(201, 169, 110, 0.25);
|
||||
}
|
||||
|
||||
.styled-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(120, 120, 120, 0.6);
|
||||
}
|
||||
|
||||
:is(.dark) .styled-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(201, 169, 110, 0.4);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { ClientShell } from "@/components/layout/ClientShell";
|
||||
import { BackToTop } from "@/components/ui/BackToTop";
|
||||
import { TeamGrid } from "@/components/sections/team/TeamGrid";
|
||||
import { getContent } from "@/lib/content";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Команда | BLACK HEART DANCE HOUSE",
|
||||
};
|
||||
|
||||
export default function TeamPage() {
|
||||
const content = getContent();
|
||||
|
||||
if (!content?.team) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientShell>
|
||||
<Header popups={content?.popups} />
|
||||
<main id="main-content" className="pt-16">
|
||||
<TeamGrid
|
||||
data={content.team}
|
||||
schedule={content.schedule?.locations}
|
||||
scheduleConfig={content.scheduleConfig}
|
||||
/>
|
||||
<BackToTop />
|
||||
</main>
|
||||
<Footer />
|
||||
</ClientShell>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,11 @@ export function Footer() {
|
||||
<footer className="relative border-t border-neutral-200 bg-neutral-100 dark:border-white/[0.08] dark:bg-[#050505]">
|
||||
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||
<div className="section-container flex flex-col items-center gap-4 py-10 sm:flex-row sm:justify-between">
|
||||
<p className="text-sm text-neutral-500">
|
||||
© {year} {BRAND.name}
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-500">
|
||||
{/* © {year} {BRAND.name} — commented out for portfolio version */}
|
||||
© {year} Dance Studio
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-sm text-neutral-500">
|
||||
<div className="flex items-center gap-1.5 text-sm text-neutral-600 dark:text-neutral-500">
|
||||
<span>Made with</span>
|
||||
<Heart size={14} className="fill-gold text-gold" />
|
||||
<span>by Diana Dolgolyova</span>
|
||||
|
||||
@@ -7,9 +7,15 @@ import { BRAND, NAV_LINKS } from "@/lib/constants";
|
||||
import { UI_CONFIG } from "@/lib/config";
|
||||
import { HeroLogo } from "@/components/ui/HeroLogo";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||
import { useBooking } from "@/contexts/BookingContext";
|
||||
import type { SiteContent } from "@/types/content";
|
||||
|
||||
export function Header() {
|
||||
interface HeaderProps {
|
||||
popups?: SiteContent["popups"];
|
||||
}
|
||||
|
||||
export function Header({ popups }: HeaderProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("");
|
||||
@@ -59,13 +65,33 @@ export function Header() {
|
||||
prevMenuOpenRef.current = menuOpen;
|
||||
}, [menuOpen]);
|
||||
|
||||
// Detect if we're on a sub-page (not the landing page)
|
||||
const [currentPath, setCurrentPath] = useState("/");
|
||||
useEffect(() => {
|
||||
setCurrentPath(window.location.pathname);
|
||||
}, []);
|
||||
const isSubPage = currentPath !== "/";
|
||||
|
||||
// Filter out nav links whose target section doesn't exist on the page
|
||||
// On sub-pages, show all links — hash links point back to landing (e.g. /#about)
|
||||
const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS);
|
||||
useEffect(() => {
|
||||
const path = window.location.pathname;
|
||||
if (path !== "/") {
|
||||
// Sub-page: show all links, prefix hash links with /
|
||||
setVisibleLinks(
|
||||
NAV_LINKS.map((l) =>
|
||||
l.href.startsWith("#") ? { ...l, href: `/${l.href}` } : l
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Landing page: filter by existing DOM sections
|
||||
setVisibleLinks(
|
||||
NAV_LINKS
|
||||
.filter((l) => document.getElementById(l.href.replace("#", "")))
|
||||
.filter((l) => l.href.startsWith("/") || document.getElementById(l.href.replace("#", "")))
|
||||
.map((l) => {
|
||||
if (l.href.startsWith("/")) return l;
|
||||
const section = document.getElementById(l.href.replace("#", ""));
|
||||
const heading = section?.querySelector("h2");
|
||||
if (heading?.textContent && l.href !== "#hero") {
|
||||
@@ -77,7 +103,7 @@ export function Header() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const sectionIds = visibleLinks.map((l) => l.href.replace("#", ""));
|
||||
const sectionIds = visibleLinks.filter((l) => l.href.startsWith("#")).map((l) => l.href.replace("#", ""));
|
||||
const observers: IntersectionObserver[] = [];
|
||||
|
||||
// Observe hero — when visible, clear active section
|
||||
@@ -124,14 +150,15 @@ export function Header() {
|
||||
return (
|
||||
<header
|
||||
className={`fixed top-0 z-50 w-full transition-all duration-500 ${
|
||||
scrolled || menuOpen
|
||||
? "bg-black/40 shadow-none backdrop-blur-xl"
|
||||
scrolled || menuOpen || isSubPage
|
||||
? "backdrop-blur-xl border-b border-white/[0.08]"
|
||||
: "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[60] focus:px-4 focus:py-2 focus:bg-gold focus:text-black focus:rounded font-medium">Перейти к содержимому</a>
|
||||
<div className="flex h-16 items-center justify-between px-6 sm:px-10 lg:px-16">
|
||||
<Link href="/" className="group flex items-center gap-2.5">
|
||||
{/* Blackheart logotype — commented out for portfolio version
|
||||
<div className="relative flex h-8 w-8 items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full transition-all duration-300 group-hover:scale-125"
|
||||
@@ -144,14 +171,16 @@ export function Header() {
|
||||
className="relative text-black transition-transform duration-300 drop-shadow-[0_0_3px_rgba(201,169,110,0.5)] group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
*/}
|
||||
{/* {BRAND.shortName} — commented out for portfolio version */}
|
||||
<span className="font-display text-lg font-bold tracking-tight text-gold">
|
||||
{BRAND.shortName}
|
||||
Dance Studio
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-3 lg:gap-5 xl:gap-6 lg:flex" aria-label="Основная навигация">
|
||||
{visibleLinks.map((link) => {
|
||||
const isActive = activeSection === link.href.replace("#", "");
|
||||
const isActive = link.href.startsWith("/") ? currentPath === link.href : activeSection === link.href.replace("#", "");
|
||||
return (
|
||||
<a
|
||||
key={link.href}
|
||||
@@ -159,23 +188,27 @@ export function Header() {
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={`relative whitespace-nowrap py-1 text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${
|
||||
isActive
|
||||
? "text-gold-light after:w-full"
|
||||
: "text-neutral-400 after:w-0 hover:text-white hover:after:w-full"
|
||||
? "text-gold after:w-full"
|
||||
: scrolled || isSubPage
|
||||
? "text-neutral-600 after:w-0 hover:text-neutral-900 hover:after:w-full dark:text-neutral-400 dark:hover:text-white"
|
||||
: "text-white/80 after:w-0 hover:text-white hover:after:w-full"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
<div className="flex items-center gap-1 lg:hidden">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
aria-label={menuOpen ? "Закрыть меню" : "Открыть меню"}
|
||||
aria-expanded={menuOpen}
|
||||
className="rounded-lg p-2 text-neutral-400 transition-colors hover:text-white"
|
||||
className="rounded-lg p-2 text-neutral-500 transition-colors hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
>
|
||||
{menuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
@@ -184,13 +217,14 @@ export function Header() {
|
||||
|
||||
{/* Mobile menu */}
|
||||
<div
|
||||
aria-hidden={!menuOpen}
|
||||
className={`overflow-hidden transition-all duration-300 lg:hidden ${
|
||||
menuOpen ? "max-h-[80vh] opacity-100" : "max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<nav className="border-t border-white/[0.06] px-6 py-4 text-center sm:px-8" aria-label="Основная навигация">
|
||||
<nav className="border-t border-neutral-200/60 dark:border-white/[0.06] px-6 py-4 text-center sm:px-8" aria-label="Основная навигация">
|
||||
{visibleLinks.map((link, index) => {
|
||||
const isActive = activeSection === link.href.replace("#", "");
|
||||
const isActive = link.href.startsWith("/") ? currentPath === link.href : activeSection === link.href.replace("#", "");
|
||||
return (
|
||||
<a
|
||||
key={link.href}
|
||||
@@ -200,8 +234,8 @@ export function Header() {
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={`block py-3 text-base transition-colors ${
|
||||
isActive
|
||||
? "text-gold-light"
|
||||
: "text-neutral-400 hover:text-white"
|
||||
? "text-gold"
|
||||
: "text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
@@ -212,7 +246,17 @@ export function Header() {
|
||||
</div>
|
||||
|
||||
|
||||
<SignupModal open={bookingOpen} onClose={closeBooking} endpoint="/api/group-booking" />
|
||||
<SignupModal
|
||||
open={bookingOpen}
|
||||
onClose={closeBooking}
|
||||
subtitle={popups?.bookingSubtitle || undefined}
|
||||
endpoint="/api/group-booking"
|
||||
successMessage={popups?.successMessage}
|
||||
waitingMessage={popups?.waitingListText}
|
||||
errorMessage={popups?.errorMessage}
|
||||
instagramHint={popups?.instagramHint}
|
||||
instagramUrl={popups?.contactInstagram}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function About({ data: about, stats }: AboutProps) {
|
||||
<div
|
||||
key={i}
|
||||
aria-label={stat.ariaLabel}
|
||||
className="group flex flex-col items-center gap-3 rounded-2xl border border-neutral-200 bg-white/50 p-6 transition-all duration-300 hover:border-gold/30 sm:p-8 dark:border-white/[0.06] dark:bg-white/[0.02] dark:hover:border-gold/20"
|
||||
className="group flex flex-col items-center gap-3 rounded-2xl border border-neutral-200 bg-white/80 p-6 shadow-sm shadow-gold/[0.06] transition-all duration-300 hover:border-gold/30 hover:shadow-md hover:shadow-gold/[0.1] sm:p-8 dark:border-white/[0.08] dark:bg-white/[0.04] dark:backdrop-blur-md dark:shadow-none dark:hover:border-gold/20 dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.06)]"
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gold/10 text-gold-dark transition-colors group-hover:bg-gold/20 dark:text-gold-light" aria-hidden="true">
|
||||
{stat.icon}
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
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,
|
||||
ArrowUpRight, X,
|
||||
} from "lucide-react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
|
||||
import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
|
||||
import type { ClassItem, SiteContent } from "@/types";
|
||||
import { UI_CONFIG } from "@/lib/config";
|
||||
import { formatMarkup } from "@/lib/markup";
|
||||
|
||||
// kebab "heart-pulse" → PascalCase "HeartPulse"
|
||||
function toPascal(kebab: string) {
|
||||
return kebab.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
||||
}
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
"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) {
|
||||
const Icon = icons[toPascal(key) as keyof typeof icons];
|
||||
return Icon ? <Icon size={20} /> : null;
|
||||
const Icon = ICON_MAP[key];
|
||||
return Icon ? <Icon size={16} /> : null;
|
||||
}
|
||||
|
||||
interface ClassesProps {
|
||||
@@ -25,100 +36,138 @@ interface ClassesProps {
|
||||
}
|
||||
|
||||
export function Classes({ data: classes }: ClassesProps) {
|
||||
const [selected, setSelected] = useState<ClassItem | null>(null);
|
||||
|
||||
if (!classes?.items?.length) return null;
|
||||
const { activeIndex, select, setHovering } = useShowcaseRotation({
|
||||
totalItems: classes.items.length,
|
||||
autoPlayInterval: UI_CONFIG.showcase.autoPlayInterval,
|
||||
});
|
||||
|
||||
return (
|
||||
<section id="classes" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]">
|
||||
<section id="classes" className="section-glow relative section-padding bg-neutral-50 dark:bg-[#080808]">
|
||||
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||
<div className="section-container">
|
||||
<div className="mx-auto max-w-[96rem] px-4 sm:px-6">
|
||||
<Reveal>
|
||||
<SectionHeading centered>{classes.title}</SectionHeading>
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-14">
|
||||
<Reveal>
|
||||
<ShowcaseLayout<ClassItem>
|
||||
items={classes.items}
|
||||
activeIndex={activeIndex}
|
||||
onSelect={select}
|
||||
onHoverChange={setHovering}
|
||||
getItemLabel={(item) => item.name}
|
||||
renderDetail={(item) => (
|
||||
<div>
|
||||
{/* Hero image */}
|
||||
{item.images && item.images[0] && (
|
||||
<div className="team-card-glitter relative aspect-[16/9] w-full overflow-hidden rounded-2xl">
|
||||
<Image
|
||||
src={item.images[0]}
|
||||
alt={item.name}
|
||||
fill
|
||||
loading="lazy"
|
||||
sizes="(min-width: 1024px) 60vw, 100vw"
|
||||
className="object-cover"
|
||||
style={{
|
||||
objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%`,
|
||||
transform: item.imageZoom && item.imageZoom > 1 ? `scale(${item.imageZoom})` : undefined,
|
||||
}}
|
||||
/>
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||
|
||||
{/* Icon + name overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 flex items-center gap-3">
|
||||
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gold/20 text-gold-light backdrop-blur-sm">
|
||||
{getIcon(item.icon)}
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-white">
|
||||
{item.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{item.detailedDescription && (
|
||||
<div className="mt-5 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
|
||||
{formatMarkup(item.detailedDescription)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
renderSelectorItem={(item, _i, isActive) => (
|
||||
<div className="flex items-center gap-2 px-3 py-2 lg:gap-3 lg:p-3">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`flex h-7 w-7 lg:h-9 lg:w-9 shrink-0 items-center justify-center rounded-lg transition-colors ${
|
||||
isActive
|
||||
? "bg-gold/20 text-gold-light"
|
||||
: "bg-neutral-200/50 text-neutral-500 dark:bg-white/[0.06] dark:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{getIcon(item.icon)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={`text-xs lg:text-sm font-semibold truncate transition-colors ${
|
||||
isActive
|
||||
? "text-gold"
|
||||
: "text-neutral-700 dark:text-neutral-300"
|
||||
}`}
|
||||
>
|
||||
<div className="mt-14 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{classes.items.map((item, i) => (
|
||||
<Reveal key={i}>
|
||||
<button
|
||||
onClick={() => setSelected(item)}
|
||||
className="group relative w-full text-left rounded-2xl border border-neutral-200 bg-white p-5 transition-all duration-300 hover:border-gold/30 hover:shadow-lg hover:shadow-gold/[0.08] cursor-pointer dark:border-white/[0.08] dark:bg-white/[0.03] dark:hover:border-gold/20 dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.06)]"
|
||||
>
|
||||
{/* Header: icon + name + arrow */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gold/10 text-gold-dark dark:text-gold-light">
|
||||
{getIcon(item.icon)}
|
||||
</span>
|
||||
<h3 className="text-base font-semibold text-neutral-900 dark:text-white">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="hidden lg:block text-xs text-neutral-500 dark:text-neutral-500 truncate">
|
||||
{item.description}
|
||||
</p>
|
||||
</h3>
|
||||
</div>
|
||||
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-neutral-200 text-neutral-400 transition-all group-hover:border-gold/40 group-hover:text-gold dark:border-white/10 dark:text-neutral-500">
|
||||
<ArrowUpRight size={14} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
{/* Description */}
|
||||
<p className="mt-2.5 text-sm leading-relaxed text-neutral-500 dark:text-neutral-400 line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
|
||||
{/* Photo */}
|
||||
{item.images?.[0] && (
|
||||
<div className="relative mt-4 aspect-[4/3] overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src={item.images[0]}
|
||||
alt={item.name}
|
||||
fill
|
||||
loading="lazy"
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
style={{
|
||||
objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%`,
|
||||
transform: item.imageZoom && item.imageZoom > 1 ? `scale(${item.imageZoom})` : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail modal */}
|
||||
{selected && (
|
||||
<ClassDetailModal item={selected} onClose={() => setSelected(null)} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassDetailModal({ item, onClose }: { item: ClassItem; onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={item.name}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
<div
|
||||
className="modal-content relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.08] dark:bg-neutral-950 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Hero image */}
|
||||
{item.images?.[0] && (
|
||||
<div className="relative aspect-[16/9] overflow-hidden rounded-t-2xl">
|
||||
<Image
|
||||
src={item.images[0]}
|
||||
alt={item.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 672px, 100vw"
|
||||
className="object-cover"
|
||||
style={{
|
||||
objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gold/20 text-gold-light backdrop-blur-sm">
|
||||
{getIcon(item.icon)}
|
||||
</span>
|
||||
<h3 className="text-2xl font-bold text-white drop-shadow-[0_2px_8px_rgba(0,0,0,0.5)]">
|
||||
{item.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
className="absolute right-4 top-4 flex h-11 w-11 items-center justify-center rounded-full bg-black/40 text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white cursor-pointer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 sm:p-8">
|
||||
{item.detailedDescription && (
|
||||
<div className="text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
|
||||
{formatMarkup(item.detailedDescription)}
|
||||
</div>
|
||||
)}
|
||||
{!item.detailedDescription && item.description && (
|
||||
<p className="text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function FAQ({ data: faq }: FAQProps) {
|
||||
className={`rounded-xl border transition-all duration-300 ${
|
||||
isOpen
|
||||
? "border-gold/30 bg-gradient-to-br from-gold/[0.06] via-transparent to-gold/[0.03] shadow-md shadow-gold/5"
|
||||
: "border-neutral-200 bg-white hover:border-neutral-300 dark:border-white/[0.06] dark:bg-neutral-950 dark:hover:border-white/[0.12]"
|
||||
: "border-neutral-200 bg-white shadow-sm shadow-gold/[0.03] hover:border-neutral-300 hover:shadow-md hover:shadow-gold/[0.06] dark:border-white/[0.08] dark:bg-white/[0.03] dark:backdrop-blur-md dark:shadow-none dark:hover:border-white/[0.15] dark:hover:shadow-[0_0_20px_rgba(201,169,110,0.05)]"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
@@ -79,6 +79,7 @@ export function FAQ({ data: faq }: FAQProps) {
|
||||
id={`faq-panel-${idx}`}
|
||||
role="region"
|
||||
aria-labelledby={`faq-button-${idx}`}
|
||||
aria-hidden={!isOpen}
|
||||
className={`grid transition-all duration-300 ease-out ${
|
||||
isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||||
}`}
|
||||
|
||||
@@ -39,6 +39,16 @@ export function Hero({ data: hero }: HeroProps) {
|
||||
}
|
||||
}, [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 el = sectionRef.current;
|
||||
if (!el) return;
|
||||
@@ -95,7 +105,7 @@ export function Hero({ data: hero }: HeroProps) {
|
||||
{/* Mobile: single centered video */}
|
||||
<div className="absolute inset-0 md:hidden">
|
||||
<video
|
||||
autoPlay muted loop playsInline preload="auto"
|
||||
autoPlay muted loop playsInline preload="metadata"
|
||||
onCanPlayThrough={handleVideoReady}
|
||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||
>
|
||||
@@ -129,7 +139,7 @@ export function Hero({ data: hero }: HeroProps) {
|
||||
}}
|
||||
>
|
||||
<video
|
||||
autoPlay muted loop playsInline preload="auto"
|
||||
autoPlay muted loop playsInline preload="metadata"
|
||||
onCanPlayThrough={handleVideoReady}
|
||||
className="absolute inset-0 h-full w-full object-cover object-center"
|
||||
>
|
||||
@@ -164,6 +174,7 @@ export function Hero({ data: hero }: HeroProps) {
|
||||
|
||||
{/* Content */}
|
||||
<div className="section-container relative z-10 text-center" style={{ textShadow: "0 1px 0 rgba(201,169,110,0.3), 0 2px 0 rgba(201,169,110,0.2), 0 4px 8px rgba(0,0,0,0.4), 0 8px 20px rgba(0,0,0,0.3)" }}>
|
||||
{/* Blackheart logotype — commented out for portfolio version
|
||||
<div className="hero-logo relative mx-auto mb-6 sm:mb-12 flex items-center justify-center" style={{ width: 160, height: 132 }}>
|
||||
<div className="absolute -inset-10 rounded-full blur-[80px]" style={{ background: "radial-gradient(circle, rgba(201,169,110,0.25), transparent 70%)" }} />
|
||||
<div className="hero-logo-heartbeat relative">
|
||||
@@ -173,19 +184,20 @@ export function Hero({ data: hero }: HeroProps) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
*/}
|
||||
|
||||
<h1 className="hero-title font-display text-4xl font-bold tracking-tight sm:text-6xl lg:text-8xl">
|
||||
<span className="gradient-text">{hero.headline}</span>
|
||||
<h1 className="hero-title font-display text-4xl font-bold tracking-tight text-gold sm:text-6xl lg:text-8xl">
|
||||
{hero.headline}
|
||||
</h1>
|
||||
|
||||
<p className="hero-subtitle mx-auto mt-5 max-w-xl text-lg text-gold/80 sm:mt-8 sm:text-2xl">
|
||||
<p className="hero-subtitle mx-auto mt-5 max-w-xl text-lg text-white/80 sm:mt-8 sm:text-2xl">
|
||||
{hero.subheadline}
|
||||
</p>
|
||||
|
||||
<div className="hero-cta mt-8 sm:mt-14">
|
||||
<button
|
||||
onClick={openBooking}
|
||||
className="group relative rounded-full border border-gold/60 bg-gold/15 px-8 py-4 text-base font-semibold text-gold backdrop-blur-md transition-all duration-300 hover:bg-gold/25 hover:border-gold hover:shadow-[0_0_40px_rgba(201,169,110,0.35)] cursor-pointer sm:px-10 sm:py-5 sm:text-lg"
|
||||
className="group relative rounded-full border border-gold bg-gold/20 px-8 py-4 text-base font-semibold text-gold-light backdrop-blur-md shadow-[0_0_30px_rgba(201,169,110,0.25)] transition-all duration-300 hover:bg-gold/30 hover:shadow-[0_0_50px_rgba(201,169,110,0.45)] cursor-pointer sm:px-10 sm:py-5 sm:text-lg"
|
||||
>
|
||||
<span className="relative z-10">{hero.ctaText}</span>
|
||||
{/* Pulse glow on hover */}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Calendar, Clock, User, MapPin, Instagram, X } from "lucide-react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||
import type { SiteContent, MasterClassItem, MasterClassSlot, ScheduleLocation } from "@/types";
|
||||
import { formatMarkup } from "@/lib/markup";
|
||||
|
||||
@@ -119,6 +120,7 @@ function MasterClassDetail({
|
||||
const slots = item.slots ?? [];
|
||||
const duration = slots[0] ? calcDuration(slots[0]) : "";
|
||||
const locAddress = locations?.find(l => l.name === item.location)?.address;
|
||||
const focusTrapRef = useFocusTrap<HTMLDivElement>(true);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
@@ -130,10 +132,11 @@ function MasterClassDetail({
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm" onClick={onClose}>
|
||||
<div
|
||||
ref={focusTrapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={item.title}
|
||||
className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border border-white/10 bg-neutral-950 shadow-2xl"
|
||||
className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border border-neutral-200 bg-white dark:border-white/10 dark:bg-neutral-950 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Content */}
|
||||
@@ -144,7 +147,7 @@ function MasterClassDetail({
|
||||
{item.style}
|
||||
</span>
|
||||
{duration && (
|
||||
<span className="flex items-center gap-1 text-xs text-white/50">
|
||||
<span className="flex items-center gap-1 text-xs text-neutral-500 dark:text-white/50">
|
||||
<Clock size={11} />
|
||||
{duration}
|
||||
</span>
|
||||
@@ -158,19 +161,19 @@ function MasterClassDetail({
|
||||
<button
|
||||
onClick={onClose}
|
||||
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-neutral-400 hover:text-neutral-900 hover:bg-neutral-100 dark:text-white/50 dark:hover:text-white dark:hover:bg-white/10 transition-colors shrink-0 -mr-2"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-2xl font-bold text-white">{item.title}</h2>
|
||||
<h2 className="text-2xl font-bold text-neutral-900 dark:text-white">{item.title}</h2>
|
||||
|
||||
{/* Trainer */}
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: item.trainer.split(" · ")[0] }))}
|
||||
className="flex items-center gap-2 text-sm text-white/80 hover:text-gold transition-colors"
|
||||
className="flex items-center gap-2 text-sm text-neutral-700 hover:text-gold dark:text-white/80 transition-colors"
|
||||
>
|
||||
<User size={14} />
|
||||
{item.trainer}
|
||||
@@ -178,14 +181,14 @@ function MasterClassDetail({
|
||||
|
||||
{/* Description */}
|
||||
{item.description && (
|
||||
<div className="text-sm leading-relaxed text-white/60">
|
||||
<div className="text-sm leading-relaxed text-neutral-600 dark:text-white/60">
|
||||
{formatMarkup(item.description)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All dates */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-white/40 uppercase tracking-wider">Даты</h3>
|
||||
<h3 className="text-sm font-medium text-neutral-400 dark:text-white/40 uppercase tracking-wider">Даты</h3>
|
||||
{slots.length === 0 ? (
|
||||
<p className="text-sm text-gold">Скоро — дата уточняется</p>
|
||||
) : (
|
||||
@@ -195,11 +198,11 @@ function MasterClassDetail({
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-3 text-sm">
|
||||
<Calendar size={13} className="shrink-0 text-gold/60" />
|
||||
<span className="text-white/80">
|
||||
<span className="text-neutral-700 dark:text-white/80">
|
||||
{d.getDate()} {MONTHS_RU[d.getMonth()]} ({WEEKDAYS_RU[d.getDay()]})
|
||||
</span>
|
||||
{slot.startTime && (
|
||||
<span className="text-white/50">
|
||||
<span className="text-neutral-500 dark:text-white/50">
|
||||
{slot.startTime}–{slot.endTime}
|
||||
</span>
|
||||
)}
|
||||
@@ -212,7 +215,7 @@ function MasterClassDetail({
|
||||
|
||||
{/* Location */}
|
||||
{item.location && (
|
||||
<div className="flex items-center gap-2 text-sm text-white/60">
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-500 dark:text-white/60">
|
||||
<MapPin size={13} className="text-gold/60" />
|
||||
<span>{item.location}{locAddress ? ` · ${locAddress}` : ""}</span>
|
||||
</div>
|
||||
@@ -224,7 +227,7 @@ function MasterClassDetail({
|
||||
href={item.instagramUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-gold transition-colors"
|
||||
className="inline-flex items-center gap-2 text-sm text-neutral-400 hover:text-gold dark:text-white/50 transition-colors"
|
||||
>
|
||||
<Instagram size={14} />
|
||||
Подробнее в Instagram
|
||||
@@ -264,7 +267,13 @@ function MasterClassCard({
|
||||
const isFull = maxP > 0 && currentRegs >= maxP;
|
||||
|
||||
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 */}
|
||||
<div className="relative aspect-[3/4] sm:aspect-[2/3] w-full overflow-hidden">
|
||||
{item.image ? (
|
||||
@@ -425,9 +434,9 @@ export function MasterClasses({ data, regCounts = {}, popups, locations }: Maste
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto mt-10 flex max-w-5xl flex-wrap justify-center gap-5">
|
||||
{upcoming.map((item) => (
|
||||
{upcoming.map((item, idx) => (
|
||||
<MasterClassCard
|
||||
key={item.title}
|
||||
key={`${item.title}-${idx}`}
|
||||
item={item}
|
||||
currentRegs={regCounts[item.title] ?? 0}
|
||||
onSignup={() => setSignupTitle(item.title)}
|
||||
|
||||
@@ -32,7 +32,7 @@ function FeaturedArticle({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="group relative overflow-hidden rounded-3xl cursor-pointer"
|
||||
className="group relative overflow-hidden rounded-3xl shadow-md shadow-gold/[0.06] dark:shadow-none cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
{item.image && (
|
||||
@@ -53,16 +53,16 @@ function FeaturedArticle({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`${item.image ? "absolute bottom-0 left-0 right-0 p-6 sm:p-8" : "p-6 sm:p-8 bg-neutral-900 rounded-3xl"}`}
|
||||
className={`${item.image ? "absolute bottom-0 left-0 right-0 p-6 sm:p-8" : "p-6 sm:p-8 bg-neutral-100 dark:bg-neutral-900 rounded-3xl"}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1 text-xs font-medium text-white/80 backdrop-blur-sm">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium backdrop-blur-sm ${item.image ? "bg-white/15 text-white/80" : "bg-neutral-200 text-neutral-600 dark:bg-white/15 dark:text-white/80"}`}>
|
||||
<Calendar size={12} />
|
||||
<time dateTime={item.date}>{formatDateRu(item.date)}</time>
|
||||
</span>
|
||||
<h3 className="mt-3 text-xl sm:text-2xl font-bold text-white leading-tight">
|
||||
<h3 className={`mt-3 text-xl sm:text-2xl font-bold leading-tight ${item.image ? "text-white" : "text-neutral-900 dark:text-white"}`}>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-white/70 line-clamp-3">
|
||||
<p className={`mt-2 max-w-2xl text-sm leading-relaxed line-clamp-3 ${item.image ? "text-white/70" : "text-neutral-600 dark:text-white/70"}`}>
|
||||
{item.text}
|
||||
</p>
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@ function CompactArticle({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="group flex gap-4 items-start py-5 border-b border-neutral-200/60 last:border-0 dark:border-white/[0.06] cursor-pointer"
|
||||
className="group flex gap-4 items-start py-5 border-b border-neutral-200 last:border-0 dark:border-white/[0.06] cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
{item.image && (
|
||||
@@ -109,7 +109,7 @@ function CompactArticle({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<time dateTime={item.date} className="text-xs text-neutral-400 dark:text-white/30">
|
||||
<time dateTime={item.date} className="text-xs text-neutral-500 dark:text-white/30">
|
||||
{formatDateRu(item.date)}
|
||||
</time>
|
||||
<h3 className="mt-1 text-sm sm:text-base font-bold text-neutral-900 dark:text-white leading-snug line-clamp-2 group-hover:text-gold transition-colors">
|
||||
@@ -180,7 +180,7 @@ export function News({ data }: NewsProps) {
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}}
|
||||
disabled={page === 0}
|
||||
className="rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-medium text-neutral-400 hover:text-white hover:border-white/25 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="rounded-full border border-neutral-200 bg-neutral-100 px-4 py-2 text-sm font-medium text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
@@ -197,7 +197,7 @@ export function News({ data }: NewsProps) {
|
||||
className={`h-10 w-10 rounded-full text-sm font-medium transition-colors cursor-pointer ${
|
||||
i === page
|
||||
? "bg-gold text-black"
|
||||
: "border border-white/10 text-neutral-400 hover:text-white hover:border-white/25"
|
||||
: "border border-neutral-200 text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
@@ -211,7 +211,7 @@ export function News({ data }: NewsProps) {
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}}
|
||||
disabled={page === totalPages - 1}
|
||||
className="rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm font-medium text-neutral-400 hover:text-white hover:border-white/25 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="rounded-full border border-neutral-200 bg-neutral-100 px-4 py-2 text-sm font-medium text-neutral-500 hover:text-neutral-900 hover:border-neutral-300 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/25"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTrainerPhotos } from "@/hooks/useTrainerPhotos";
|
||||
import Image from "next/image";
|
||||
import { Calendar, Sparkles, User, MapPin } from "lucide-react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
@@ -33,15 +34,7 @@ export function OpenDay({ data, popups, teamMembers, locations }: OpenDayProps)
|
||||
const { event, classes } = data;
|
||||
const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
|
||||
|
||||
const trainerPhotos = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
if (teamMembers) {
|
||||
for (const m of teamMembers) {
|
||||
if (m.image) map[m.name] = m.image;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [teamMembers]);
|
||||
const trainerPhotos = useTrainerPhotos(teamMembers);
|
||||
|
||||
// Group classes by hall
|
||||
const hallGroups = useMemo(() => {
|
||||
@@ -92,8 +85,8 @@ export function OpenDay({ data, popups, teamMembers, locations }: OpenDayProps)
|
||||
{/* Pricing info */}
|
||||
<Reveal>
|
||||
<div className="mt-6 text-center space-y-1">
|
||||
<p className="text-lg font-semibold text-white">
|
||||
{event.pricePerClass} BYN <span className="text-neutral-400 font-normal text-sm">за занятие</span>
|
||||
<p className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
{event.pricePerClass} BYN <span className="text-neutral-500 dark:text-neutral-400 font-normal text-sm">за занятие</span>
|
||||
</p>
|
||||
{event.discountPrice > 0 && event.discountThreshold > 0 && (
|
||||
<p className="text-sm text-gold">
|
||||
@@ -106,7 +99,7 @@ export function OpenDay({ data, popups, teamMembers, locations }: OpenDayProps)
|
||||
|
||||
{event.description && (
|
||||
<Reveal>
|
||||
<div className="mt-4 text-center text-sm text-neutral-400 max-w-2xl mx-auto">
|
||||
<div className="mt-4 text-center text-sm text-neutral-500 dark:text-neutral-400 max-w-2xl mx-auto">
|
||||
{formatMarkup(event.description)}
|
||||
</div>
|
||||
</Reveal>
|
||||
@@ -119,7 +112,7 @@ export function OpenDay({ data, popups, teamMembers, locations }: OpenDayProps)
|
||||
<Reveal>
|
||||
<div className="max-w-lg mx-auto space-y-3">
|
||||
<div className="text-center mb-4">
|
||||
<h3 className="text-base font-semibold text-white">{halls[0]}</h3>
|
||||
<h3 className="text-base font-semibold text-neutral-900 dark:text-white">{halls[0]}</h3>
|
||||
{hallAddress[halls[0]] && (
|
||||
<p className="text-sm text-gold/70 mt-0.5 flex items-center justify-center gap-1.5">
|
||||
<MapPin size={13} />
|
||||
@@ -144,8 +137,8 @@ export function OpenDay({ data, popups, teamMembers, locations }: OpenDayProps)
|
||||
{halls.map((hall) => (
|
||||
<Reveal key={hall}>
|
||||
<div>
|
||||
<div className="text-center mb-4 rounded-lg bg-white/[0.03] border border-white/[0.06] py-3 px-4">
|
||||
<h3 className="text-base font-semibold text-white">{hall}</h3>
|
||||
<div className="text-center mb-4 rounded-lg bg-neutral-50 border border-neutral-200 py-3 px-4 dark:bg-white/[0.03] dark:border-white/[0.06]">
|
||||
<h3 className="text-base font-semibold text-neutral-900 dark:text-white">{hall}</h3>
|
||||
{hallAddress[hall] && (
|
||||
<p className="text-sm text-gold/70 mt-0.5 flex items-center justify-center gap-1.5">
|
||||
<MapPin size={13} />
|
||||
@@ -203,15 +196,15 @@ function ClassCard({
|
||||
|
||||
if (cls.cancelled) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3 sm:p-4 opacity-50">
|
||||
<div className="rounded-xl border border-neutral-200 bg-neutral-50 p-3 sm:p-4 opacity-50 dark:border-white/[0.06] dark:bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<span className="rounded-md bg-neutral-800 px-2 py-0.5 text-xs font-bold text-neutral-500">
|
||||
<span className="rounded-md bg-neutral-200 px-2 py-0.5 text-xs font-bold text-neutral-500 dark:bg-neutral-800">
|
||||
<time dateTime={`${cls.startTime}-${cls.endTime}`}>{cls.startTime}–{cls.endTime}</time>
|
||||
</span>
|
||||
<p className="text-sm text-neutral-500"><del>{cls.trainer} · {cls.style}</del></p>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500 bg-neutral-800 rounded-full px-2.5 py-0.5 font-medium">
|
||||
<span className="text-xs text-neutral-500 bg-neutral-200 rounded-full px-2.5 py-0.5 font-medium dark:bg-neutral-800">
|
||||
Отменено
|
||||
</span>
|
||||
</div>
|
||||
@@ -224,8 +217,8 @@ function ClassCard({
|
||||
return (
|
||||
<div className={`rounded-xl border transition-all ${
|
||||
isFull
|
||||
? "border-white/[0.04] bg-white/[0.01]"
|
||||
: "border-white/[0.06] bg-white/[0.02] hover:border-white/[0.12] hover:bg-white/[0.04]"
|
||||
? "border-neutral-200 bg-neutral-50/50 dark:border-white/[0.04] dark:bg-white/[0.01]"
|
||||
: "border-neutral-200 bg-white hover:border-neutral-300 hover:bg-neutral-50 dark:border-white/[0.06] dark:bg-white/[0.02] dark:hover:border-white/[0.12] dark:hover:bg-white/[0.04]"
|
||||
}`}>
|
||||
<div className="flex items-start gap-3 p-3 sm:p-4">
|
||||
{/* Trainer photo */}
|
||||
@@ -234,14 +227,14 @@ function ClassCard({
|
||||
window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer }));
|
||||
}}
|
||||
aria-label={`Профиль тренера: ${cls.trainer}`}
|
||||
className="relative flex items-center justify-center h-11 w-11 rounded-full overflow-hidden shrink-0 ring-1 ring-white/10 hover:ring-gold/30 transition-all cursor-pointer mt-0.5"
|
||||
className="relative flex items-center justify-center h-11 w-11 rounded-full overflow-hidden shrink-0 ring-1 ring-neutral-200 hover:ring-gold/30 transition-all cursor-pointer mt-0.5 dark:ring-white/10"
|
||||
title={`Подробнее о ${cls.trainer}`}
|
||||
>
|
||||
{trainerPhoto ? (
|
||||
<Image src={trainerPhoto} alt={cls.trainer} fill className="object-cover" sizes="44px" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full w-full bg-white/[0.06]">
|
||||
<User size={16} className="text-white/40" />
|
||||
<div className="flex items-center justify-center h-full w-full bg-neutral-100 dark:bg-white/[0.06]">
|
||||
<User size={16} className="text-neutral-400 dark:text-white/40" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
@@ -252,7 +245,7 @@ function ClassCard({
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer }));
|
||||
}}
|
||||
className="text-sm font-semibold text-white/90 hover:text-gold transition-colors cursor-pointer"
|
||||
className="text-sm font-semibold text-neutral-900 hover:text-gold transition-colors cursor-pointer dark:text-white/90"
|
||||
>
|
||||
{cls.trainer}
|
||||
</button>
|
||||
@@ -263,7 +256,7 @@ function ClassCard({
|
||||
<span className="rounded-md bg-gold/10 px-2 py-0.5 text-xs font-bold text-gold min-w-[80px] text-center">
|
||||
<time dateTime={`${cls.startTime}-${cls.endTime}`}>{cls.startTime}–{cls.endTime}</time>
|
||||
</span>
|
||||
<span className="text-sm font-medium text-white/60">{cls.style}</span>
|
||||
<span className="text-sm font-medium text-neutral-500 dark:text-white/60">{cls.style}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -272,8 +265,8 @@ function ClassCard({
|
||||
{maxParticipants > 0 && (
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
||||
isFull
|
||||
? "bg-amber-500/15 border border-amber-500/25 text-amber-400"
|
||||
: "bg-white/[0.04] border border-white/[0.08] text-white/45"
|
||||
? "bg-amber-500/15 border border-amber-500/25 text-amber-500 dark:text-amber-400"
|
||||
: "bg-neutral-100 border border-neutral-200 text-neutral-600 dark:bg-white/[0.04] dark:border-white/[0.08] dark:text-white/45"
|
||||
}`}>
|
||||
{cls.bookingCount}/{maxParticipants} мест
|
||||
</span>
|
||||
@@ -287,7 +280,7 @@ function ClassCard({
|
||||
className={`shrink-0 self-center rounded-xl px-4 py-2.5 text-xs font-semibold transition-all cursor-pointer ${
|
||||
isFull
|
||||
? "bg-amber-500/10 border border-amber-500/25 text-amber-400 hover:bg-amber-500/20 hover:border-amber-500/40"
|
||||
: "bg-gold/10 border border-gold/25 text-gold hover:bg-gold/20 hover:border-gold/40"
|
||||
: "bg-gold/10 border border-gold/25 text-gold hover:bg-gold/20 hover:border-gold/40 dark:bg-gold/5 dark:border-gold/15"
|
||||
}`}
|
||||
>
|
||||
{isFull ? "Лист ожидания" : "Записаться"}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
className={`inline-flex items-center gap-2 rounded-full px-6 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
|
||||
activeTab === tab.id
|
||||
? "bg-gold text-black shadow-lg shadow-gold/25"
|
||||
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-white/[0.06] dark:text-neutral-300 dark:hover:bg-white/[0.1]"
|
||||
: "bg-white border border-neutral-300 text-neutral-600 hover:bg-neutral-50 hover:border-neutral-400 dark:border-transparent dark:bg-white/[0.06] dark:text-neutral-300 dark:hover:bg-white/[0.1]"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
@@ -92,7 +92,7 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
className={`group relative rounded-2xl border p-5 transition-all duration-300 ${
|
||||
isPopular
|
||||
? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10"
|
||||
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-neutral-950"
|
||||
: "border-neutral-200 bg-white shadow-sm shadow-gold/[0.04] hover:shadow-md hover:shadow-gold/[0.08] dark:border-white/[0.08] dark:bg-white/[0.03] dark:backdrop-blur-md dark:shadow-none dark:hover:border-white/[0.15] dark:hover:shadow-[0_0_20px_rgba(201,169,110,0.05)]"
|
||||
}`}
|
||||
>
|
||||
{/* Popular badge */}
|
||||
@@ -113,7 +113,7 @@ export function Pricing({ data: pricing }: PricingProps) {
|
||||
|
||||
{/* Note */}
|
||||
{item.note && (
|
||||
<p className="mt-1 text-xs text-neutral-400 dark:text-neutral-500">
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-500">
|
||||
{item.note}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useReducer, useMemo, useCallback } from "react";
|
||||
import { useTrainerPhotos } from "@/hooks/useTrainerPhotos";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
import { CalendarDays, Users, LayoutGrid, SlidersHorizontal, MapPin } from "lucide-react";
|
||||
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 trainerPhotos = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
if (teamMembers) {
|
||||
for (const m of teamMembers) {
|
||||
if (m.image) map[m.name] = m.image;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [teamMembers]);
|
||||
const trainerPhotos = useTrainerPhotos(teamMembers);
|
||||
|
||||
// Build days: either from one location or merged from all
|
||||
const activeDays: ScheduleDayMerged[] = useMemo(() => {
|
||||
@@ -251,14 +244,14 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
||||
classes: day.classes.filter(
|
||||
(cls) => {
|
||||
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)) &&
|
||||
(filterTypes.size === 0 || filterTypes.has(cls.type)) &&
|
||||
(filterStatusSet.size === 0 || (clsStatus && filterStatusSet.has(clsStatus))) &&
|
||||
(!filterLevel || cls.level === filterLevel) &&
|
||||
(!activeTimeRange || (() => {
|
||||
const m = startTimeMinutes(cls.time);
|
||||
return m >= activeTimeRange[0] && m < activeTimeRange[1];
|
||||
})());
|
||||
matchesTime;
|
||||
}),
|
||||
}))
|
||||
.filter((day) => day.classes.length > 0);
|
||||
@@ -343,7 +336,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
||||
<span className="block leading-tight">{loc.name}</span>
|
||||
{loc.address && (
|
||||
<span className={`block text-xs font-normal leading-tight mt-0.5 ${
|
||||
locationMode === i ? "text-black/60" : "text-neutral-400 dark:text-white/25"
|
||||
locationMode === i ? "text-black/60" : "text-neutral-500 dark:text-white/25"
|
||||
}`}>
|
||||
{shortAddress(loc.address)}
|
||||
</span>
|
||||
@@ -384,8 +377,10 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
||||
{/* View mode toggle + filter button */}
|
||||
<Reveal>
|
||||
<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-300 bg-neutral-200/60 p-1 dark:border-white/[0.08] dark:bg-white/[0.04]" role="tablist" aria-label="Режим отображения">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={viewMode === "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 ${
|
||||
viewMode === "days"
|
||||
@@ -397,6 +392,8 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
||||
По дням
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={viewMode === "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 ${
|
||||
viewMode === "groups"
|
||||
@@ -409,7 +406,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<span className="mx-1 h-5 w-px bg-white/[0.08]" />
|
||||
<span className="mx-1 h-5 w-px bg-neutral-200 dark:bg-white/[0.08]" />
|
||||
|
||||
<ScheduleFilters
|
||||
typeDots={typeDots}
|
||||
@@ -473,7 +470,7 @@ export function Schedule({ data: schedule, scheduleConfig, classItems, teamMembe
|
||||
))}
|
||||
|
||||
{filteredDays.length === 0 && (
|
||||
<div className="col-span-full py-12 text-center text-sm text-neutral-400 dark:text-white/30">
|
||||
<div className="col-span-full py-12 text-center text-sm text-neutral-500 dark:text-white/30">
|
||||
Нет занятий по выбранным фильтрам
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, X } from "lucide-react";
|
||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||
import { Reveal } from "@/components/ui/Reveal";
|
||||
import { TeamProfile } from "@/components/sections/team/TeamProfile";
|
||||
import type { TeamMember, ScheduleLocation, SiteContent } from "@/types/content";
|
||||
|
||||
interface TeamPreviewProps {
|
||||
title: string;
|
||||
members: TeamMember[];
|
||||
schedule?: ScheduleLocation[];
|
||||
scheduleConfig?: SiteContent["scheduleConfig"];
|
||||
}
|
||||
|
||||
export function TeamPreview({ title, members, schedule, scheduleConfig }: TeamPreviewProps) {
|
||||
if (!members.length) return null;
|
||||
|
||||
const [activeMember, setActiveMember] = useState<TeamMember | null>(null);
|
||||
|
||||
const openMember = useCallback((member: TeamMember) => {
|
||||
setActiveMember(member);
|
||||
document.body.style.overflow = "hidden";
|
||||
}, []);
|
||||
|
||||
const closeMember = useCallback(() => {
|
||||
setActiveMember(null);
|
||||
document.body.style.overflow = "";
|
||||
}, []);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!activeMember) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") closeMember();
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [activeMember, closeMember]);
|
||||
|
||||
// Close on browser back
|
||||
useEffect(() => {
|
||||
if (!activeMember) return;
|
||||
history.pushState({ trainerModal: true }, "");
|
||||
function onPop() { closeMember(); }
|
||||
window.addEventListener("popstate", onPop);
|
||||
return () => window.removeEventListener("popstate", onPop);
|
||||
}, [activeMember, closeMember]);
|
||||
|
||||
// Double the list for seamless infinite scroll
|
||||
const strip = [...members, ...members];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
id="team"
|
||||
className="section-glow relative py-16 sm:py-24 bg-neutral-50 dark:bg-[#050505] overflow-hidden"
|
||||
>
|
||||
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||
|
||||
<Reveal>
|
||||
<div className="text-center mb-10 sm:mb-14 px-6">
|
||||
<SectionHeading centered>{title}</SectionHeading>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* ── Photo Marquee ── */}
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-16 sm:w-32 z-10 bg-gradient-to-r from-neutral-50 dark:from-[#050505] to-transparent" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-16 sm:w-32 z-10 bg-gradient-to-l from-neutral-50 dark:from-[#050505] to-transparent" />
|
||||
|
||||
<MarqueeStrip members={strip} onMemberClick={openMember} />
|
||||
</div>
|
||||
|
||||
{/* ── CTA to full team page ── */}
|
||||
<Reveal>
|
||||
<div className="mt-10 sm:mt-14 text-center px-6">
|
||||
<Link
|
||||
href="/team"
|
||||
className="group inline-flex items-center gap-3 text-sm font-medium text-gold transition-all duration-300 hover:gap-4"
|
||||
>
|
||||
<span className="relative">
|
||||
Познакомиться с командой
|
||||
<span className="absolute -bottom-1 left-0 w-full h-px bg-gold/30 group-hover:bg-gold transition-colors duration-300" />
|
||||
</span>
|
||||
<ArrowRight size={15} className="transition-transform duration-300 group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* ── Trainer Profile Modal ── */}
|
||||
{activeMember && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-neutral-50/95 dark:bg-[#050505]/95 backdrop-blur-sm overflow-y-auto"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) closeMember(); }}
|
||||
>
|
||||
<button
|
||||
onClick={closeMember}
|
||||
aria-label="Закрыть"
|
||||
className="fixed top-5 right-5 z-50 rounded-full bg-neutral-200 p-2.5 text-neutral-600 hover:text-neutral-900 hover:bg-neutral-300 transition-colors cursor-pointer
|
||||
dark:bg-white/10 dark:text-white/60 dark:hover:text-white dark:hover:bg-white/20"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<div className="px-4 sm:px-6 lg:px-8 pt-16 pb-10 min-h-screen"
|
||||
style={{ animation: "modal-content-in 0.4s cubic-bezier(0.16, 1, 0.3, 1)" }}>
|
||||
<TeamProfile
|
||||
member={activeMember}
|
||||
onBack={closeMember}
|
||||
schedule={schedule}
|
||||
scheduleConfig={scheduleConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Marquee Strip ─────────────────────────────────────────────────── */
|
||||
|
||||
function MarqueeStrip({ members, onMemberClick }: { members: TeamMember[]; onMemberClick: (m: TeamMember) => void }) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
function pause() { track!.style.animationPlayState = "paused"; }
|
||||
function play() { track!.style.animationPlayState = "running"; }
|
||||
track.addEventListener("mouseenter", pause);
|
||||
track.addEventListener("mouseleave", play);
|
||||
return () => {
|
||||
track.removeEventListener("mouseenter", pause);
|
||||
track.removeEventListener("mouseleave", play);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const duration = `${members.length * 2.5}s`;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="flex gap-3 sm:gap-4 w-max"
|
||||
style={{ animation: `team-marquee-left ${duration} linear infinite` }}
|
||||
>
|
||||
{members.map((m, i) => (
|
||||
<button
|
||||
key={`${m.name}-${i}`}
|
||||
onClick={() => onMemberClick(m)}
|
||||
className="group relative flex-shrink-0 w-36 sm:w-44 lg:w-52 overflow-hidden rounded-xl cursor-pointer text-left"
|
||||
>
|
||||
<div className="relative aspect-[3/4]">
|
||||
<Image
|
||||
src={m.image}
|
||||
alt={m.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 208px, (min-width: 640px) 176px, 144px"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent
|
||||
opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3
|
||||
translate-y-1 group-hover:translate-y-0
|
||||
opacity-0 group-hover:opacity-100
|
||||
transition-all duration-300">
|
||||
<p className="font-display text-xs sm:text-sm font-semibold text-white uppercase tracking-wide leading-tight">
|
||||
{m.name}
|
||||
</p>
|
||||
<p className="text-[10px] sm:text-[11px] text-gold-light/80 mt-0.5">{m.role}</p>
|
||||
</div>
|
||||
<div className="absolute inset-0 rounded-xl border border-transparent group-hover:border-gold/25 transition-colors duration-300" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,12 +33,12 @@ function ClassRow({
|
||||
}) {
|
||||
return (
|
||||
<div className="px-5 py-3.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-500 dark:text-white/40">
|
||||
<Clock size={13} />
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-white/40">
|
||||
<Clock size={13} className="shrink-0" />
|
||||
<span className="font-semibold">{cls.time}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-wrap justify-end gap-1.5">
|
||||
{cls.status && (() => {
|
||||
const cfg = findStatusConfig(statuses, cls.status);
|
||||
return <ScheduleBadge>{cfg?.label || cls.status}</ScheduleBadge>;
|
||||
@@ -59,7 +59,7 @@ function ClassRow({
|
||||
className="flex items-center gap-2 cursor-pointer active:opacity-60"
|
||||
>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
|
||||
<span className="text-xs text-neutral-500 dark:text-white/40">{cls.type}</span>
|
||||
<span className="text-xs text-neutral-600 dark:text-white/40">{cls.type}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@ export function DayCard({ day, typeDots, showLocation, filterTrainerSet, toggleF
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a] overflow-hidden">
|
||||
<div className="rounded-2xl border border-neutral-200 bg-white shadow-sm shadow-gold/[0.04] dark:border-white/[0.08] dark:bg-white/[0.03] dark:backdrop-blur-md dark:shadow-none overflow-hidden">
|
||||
{/* Day header */}
|
||||
<div className="border-b border-neutral-100 bg-neutral-50 px-5 py-4 dark:border-white/[0.04] dark:bg-white/[0.02]">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -104,10 +104,10 @@ export function DayCard({ day, typeDots, showLocation, filterTrainerSet, toggleF
|
||||
{/* Location sub-header */}
|
||||
<div className={`flex items-center gap-1.5 px-5 py-2 bg-gold/10 ${gi > 0 ? "border-t border-gold/10" : ""}`}>
|
||||
<MapPin size={11} className="shrink-0 text-gold" />
|
||||
<span className="text-[11px] font-medium text-white">
|
||||
<span className="text-[11px] font-medium text-neutral-800 dark:text-white">
|
||||
{locName}
|
||||
{address && shortAddress(address) !== locName && (
|
||||
<span className="text-white/50"> · {shortAddress(address)}</span>
|
||||
<span className="text-neutral-500 dark:text-white/50"> · {shortAddress(address)}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -163,7 +163,7 @@ export function GroupView({
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">
|
||||
<div className="py-12 text-center text-sm text-neutral-600 dark:text-white/30">
|
||||
Нет занятий по выбранным фильтрам
|
||||
</div>
|
||||
);
|
||||
@@ -187,7 +187,7 @@ export function GroupView({
|
||||
window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: trainer }));
|
||||
}}
|
||||
className={`relative flex items-center justify-center h-9 w-9 rounded-full overflow-hidden transition-all cursor-pointer ${
|
||||
isActive ? "ring-2 ring-gold/50" : "ring-1 ring-white/10 hover:ring-gold/30"
|
||||
isActive ? "ring-2 ring-gold/50" : "ring-1 ring-neutral-200 hover:ring-gold/30 dark:ring-white/10"
|
||||
}`}
|
||||
title={`Подробнее о ${trainer}`}
|
||||
>
|
||||
@@ -200,8 +200,8 @@ export function GroupView({
|
||||
sizes="36px"
|
||||
/>
|
||||
) : (
|
||||
<div className={`flex items-center justify-center h-full w-full ${isActive ? "bg-gold/20" : "bg-white/[0.06]"}`}>
|
||||
<User size={14} className={isActive ? "text-gold" : "text-white/40"} />
|
||||
<div className={`flex items-center justify-center h-full w-full ${isActive ? "bg-gold/20" : "bg-neutral-100 dark:bg-white/[0.06]"}`}>
|
||||
<User size={14} className={isActive ? "text-gold" : "text-neutral-500 dark:text-white/40"} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
@@ -211,7 +211,7 @@ export function GroupView({
|
||||
className="cursor-pointer group"
|
||||
>
|
||||
<span className={`text-base font-semibold transition-colors ${
|
||||
isActive ? "text-gold" : "text-white/90 group-hover:text-gold"
|
||||
isActive ? "text-gold" : "text-neutral-900 group-hover:text-gold dark:text-white/90"
|
||||
}`}>
|
||||
{trainer}
|
||||
</span>
|
||||
@@ -236,7 +236,7 @@ export function GroupView({
|
||||
className={`rounded-xl border transition-all ${
|
||||
false
|
||||
? "border-gold/20 bg-gold/[0.03] hover:border-gold/30 hover:bg-gold/[0.05]"
|
||||
: "border-white/[0.06] bg-white/[0.02] hover:border-white/[0.12] hover:bg-white/[0.04]"
|
||||
: "border-neutral-200 bg-white hover:border-neutral-300 hover:bg-neutral-50 dark:border-white/[0.06] dark:bg-white/[0.02] dark:hover:border-white/[0.12] dark:hover:bg-white/[0.04]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-3 sm:p-4">
|
||||
|
||||
@@ -67,10 +67,10 @@ function ClassRow({
|
||||
className={`flex items-center gap-1.5 active:opacity-60 ${filterTypes.has(cls.type) ? "opacity-100" : ""}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
|
||||
<span className="text-[11px] text-neutral-400 dark:text-white/30">{cls.type}</span>
|
||||
<span className="text-[11px] text-neutral-600 dark:text-white/30">{cls.type}</span>
|
||||
</button>
|
||||
{showLocation && cls.locationName && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-neutral-400 dark:text-white/20">
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-neutral-500 dark:text-white/20">
|
||||
<MapPin size={8} className="shrink-0" />
|
||||
{cls.locationName}
|
||||
</span>
|
||||
@@ -160,9 +160,9 @@ export function MobileSchedule({
|
||||
{/* Location sub-header */}
|
||||
<div className="ml-3 flex items-center gap-1 px-3 py-1.5">
|
||||
<MapPin size={9} className="shrink-0 text-neutral-400 dark:text-white/20" />
|
||||
<span className="text-[10px] font-medium text-neutral-400 dark:text-white/25">
|
||||
<span className="text-[10px] font-medium text-neutral-500 dark:text-white/25">
|
||||
{locName}
|
||||
{address && <span className="text-neutral-300 dark:text-white/15"> · {shortAddress(address)}</span>}
|
||||
{address && <span className="text-neutral-400 dark:text-white/15"> · {shortAddress(address)}</span>}
|
||||
</span>
|
||||
</div>
|
||||
{classes.map((cls, i) => (
|
||||
@@ -200,7 +200,7 @@ export function MobileSchedule({
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">
|
||||
<div className="py-12 text-center text-sm text-neutral-500 dark:text-white/30">
|
||||
Нет занятий по выбранным фильтрам
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, SlidersHorizontal } from "lucide-react";
|
||||
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||
import {
|
||||
pillBase,
|
||||
isTimeFilterActive,
|
||||
@@ -85,7 +86,7 @@ export function ScheduleFilters({
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-200 cursor-pointer sm:rounded-lg sm:px-4 sm:py-2 sm:text-xs ${
|
||||
totalActive > 0
|
||||
? "border border-gold/40 bg-gold/10 text-gold sm:border-0 sm:bg-white sm:text-neutral-900 sm:shadow-sm dark:sm:bg-white/10 dark:sm:text-white"
|
||||
: "border border-neutral-300 text-neutral-500 hover:text-neutral-700 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20 sm:border-0 sm:hover:text-neutral-700 dark:sm:text-white/35 dark:sm:hover:text-white/60"
|
||||
: "border border-neutral-300 text-neutral-600 hover:text-neutral-700 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20 sm:border-0 sm:hover:text-neutral-700 dark:sm:text-white/35 dark:sm:hover:text-white/60"
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal size={16} className="sm:hidden" />
|
||||
@@ -99,27 +100,14 @@ export function ScheduleFilters({
|
||||
|
||||
{/* Filter modal — Airbnb style */}
|
||||
{modalOpen && createPortal(
|
||||
<div
|
||||
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()}
|
||||
>
|
||||
<ScheduleFilterModal modalOpen={modalOpen} onClose={() => setModalOpen(false)}>
|
||||
{/* Header */}
|
||||
<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>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-white/[0.06]">
|
||||
<h3 className="text-base font-bold text-neutral-900 dark:text-white">Фильтры</h3>
|
||||
<button
|
||||
onClick={() => setModalOpen(false)}
|
||||
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-500 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
@@ -137,7 +125,7 @@ export function ScheduleFilters({
|
||||
className={`${pillBase} ${
|
||||
filterTypes.has(type)
|
||||
? "bg-gold text-black border border-gold"
|
||||
: "border border-gold/30 text-white hover:border-gold/60 hover:bg-gold/10"
|
||||
: "border border-gold/30 text-neutral-700 hover:border-gold/60 hover:bg-gold/10 dark:text-white"
|
||||
}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[type] ?? "bg-white/30"}`} />
|
||||
@@ -171,8 +159,8 @@ export function ScheduleFilters({
|
||||
onClick={() => toggleFilterStatus(statusKey)}
|
||||
className={`w-full rounded-xl px-3 py-3 text-center text-xs font-semibold transition-all cursor-pointer border ${
|
||||
active
|
||||
? "border-gold bg-gold/10 text-white"
|
||||
: "border-white/[0.08] bg-white/[0.02] text-neutral-400 hover:border-white/[0.15] hover:bg-white/[0.04]"
|
||||
? "border-gold bg-gold/10 text-neutral-900 dark:text-white"
|
||||
: "border-neutral-200 bg-neutral-50 text-neutral-500 hover:border-neutral-300 hover:bg-neutral-100 dark:border-white/[0.08] dark:bg-white/[0.02] dark:text-neutral-400 dark:hover:border-white/[0.15] dark:hover:bg-white/[0.04]"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@@ -192,7 +180,7 @@ export function ScheduleFilters({
|
||||
{/* Level — radio list */}
|
||||
{levels.length > 0 && (
|
||||
<FilterSection title="Опыт">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1" role="radiogroup" aria-label="Уровень подготовки">
|
||||
{levels.map((level) => {
|
||||
const desc = scheduleConfig?.levels?.find((l) => l.value === level)?.description;
|
||||
const active = filterLevel === level;
|
||||
@@ -202,7 +190,7 @@ export function ScheduleFilters({
|
||||
className={`flex items-center gap-2.5 w-full rounded-lg px-3 py-2 transition-all cursor-pointer ${
|
||||
active
|
||||
? "bg-gold/10"
|
||||
: "hover:bg-white/[0.03]"
|
||||
: "hover:bg-neutral-100 dark:hover:bg-white/[0.03]"
|
||||
}`}
|
||||
onClick={() => setFilterLevel(active ? null : level)}
|
||||
role="radio"
|
||||
@@ -211,11 +199,11 @@ export function ScheduleFilters({
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setFilterLevel(active ? null : level); } }}
|
||||
>
|
||||
<span className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition-colors ${
|
||||
active ? "border-gold" : "border-white/20"
|
||||
active ? "border-gold" : "border-neutral-300 dark:border-white/20"
|
||||
}`}>
|
||||
{active && <span className="h-2 w-2 rounded-full bg-gold" />}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${active ? "text-white" : "text-neutral-400"}`}>
|
||||
<span className={`text-xs font-medium ${active ? "text-neutral-900 dark:text-white" : "text-neutral-600 dark:text-neutral-400"}`}>
|
||||
{level}
|
||||
</span>
|
||||
{desc && <InfoTip text={desc} />}
|
||||
@@ -226,47 +214,49 @@ export function ScheduleFilters({
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{/* When — days + time inline */}
|
||||
{/* When — days + time on separate rows */}
|
||||
<FilterSection title="Когда">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{availableDays.map(({ day, dayShort }) => (
|
||||
<button
|
||||
key={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)
|
||||
? "bg-gold text-black"
|
||||
: "bg-white/[0.04] text-neutral-400 hover:bg-white/[0.08] hover:text-white"
|
||||
: "bg-neutral-100 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-900 dark:bg-white/[0.04] dark:text-neutral-400 dark:hover:bg-white/[0.08] dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{dayShort}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="h-6 w-px bg-white/[0.08] shrink-0" />
|
||||
<input
|
||||
type="time"
|
||||
value={filterTime.from}
|
||||
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]"
|
||||
/>
|
||||
<span className="text-neutral-500 text-[10px]">—</span>
|
||||
<input
|
||||
type="time"
|
||||
value={filterTime.to}
|
||||
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]"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-neutral-500 shrink-0">Время</span>
|
||||
<input
|
||||
type="time"
|
||||
value={filterTime.from}
|
||||
onChange={(e) => setFilterTime({ ...filterTime, from: e.target.value })}
|
||||
className="w-20 shrink-0 rounded-md border border-neutral-200 bg-neutral-100 px-2 py-1.5 text-[11px] text-neutral-900 text-center outline-none focus:border-gold/40 transition-colors dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white [color-scheme:light] dark:[color-scheme:dark]"
|
||||
/>
|
||||
<span className="text-neutral-500 text-[10px]">—</span>
|
||||
<input
|
||||
type="time"
|
||||
value={filterTime.to}
|
||||
onChange={(e) => setFilterTime({ ...filterTime, to: e.target.value })}
|
||||
className="w-20 shrink-0 rounded-md border border-neutral-200 bg-neutral-100 px-2 py-1.5 text-[11px] text-neutral-900 text-center outline-none focus:border-gold/40 transition-colors dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-white [color-scheme:light] dark:[color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FilterSection>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-white/[0.06]">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-neutral-200 dark:border-white/[0.06]">
|
||||
<button
|
||||
onClick={() => { clearFilters(); setModalOpen(false); }}
|
||||
className="text-sm text-neutral-400 hover:text-white transition-colors cursor-pointer"
|
||||
className="text-sm text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Сбросить всё
|
||||
</button>
|
||||
@@ -277,14 +267,36 @@ export function ScheduleFilters({
|
||||
Показать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
</ScheduleFilterModal>,
|
||||
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-neutral-200 bg-white shadow-2xl overflow-hidden dark:border-white/[0.08] dark:bg-[#171717]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSection({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const hintRef = useRef<HTMLDivElement>(null);
|
||||
@@ -301,18 +313,18 @@ function FilterSection({ title, hint, children }: { title: string; hint?: string
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<h4 className="text-sm font-semibold text-white">{title}</h4>
|
||||
<h4 className="text-sm font-semibold text-neutral-900 dark:text-white">{title}</h4>
|
||||
{hint && (
|
||||
<div ref={hintRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHint(!showHint)}
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full border border-white/15 text-[10px] text-neutral-500 hover:text-white hover:border-white/30 transition-colors cursor-pointer"
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full border border-neutral-300 text-[10px] text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 dark:border-white/15 dark:hover:text-white dark:hover:border-white/30 transition-colors cursor-pointer"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
{showHint && (
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 z-10 w-56 rounded-lg border border-white/10 px-3 py-2 text-[11px] leading-relaxed text-neutral-300 shadow-xl" style={{ backgroundColor: "#1a1a1a" }}>
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 z-10 w-56 rounded-lg border border-neutral-200 bg-white px-3 py-2 text-[11px] leading-relaxed text-neutral-600 shadow-xl dark:border-white/10 dark:bg-[#1a1a1a] dark:text-neutral-300">
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
@@ -343,14 +355,16 @@ function InfoTip({ text }: { text: string }) {
|
||||
<button
|
||||
type="button"
|
||||
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 ${
|
||||
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-neutral-300 text-neutral-500 hover:text-neutral-900 hover:border-neutral-400 dark:border-white/15 dark:hover:text-white dark:hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
{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 */}
|
||||
<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 */}
|
||||
@@ -398,7 +412,7 @@ function TrainerMultiSelect({
|
||||
<div
|
||||
onClick={() => { setOpen(true); inputRef.current?.focus(); }}
|
||||
className={`flex flex-wrap items-center gap-1.5 rounded-lg border px-3 py-2 min-h-[42px] cursor-text transition-colors ${
|
||||
open ? "border-gold bg-white/[0.06]" : "border-white/[0.08] bg-white/[0.04]"
|
||||
open ? "border-gold bg-neutral-50 dark:bg-white/[0.06]" : "border-neutral-200 bg-neutral-100 dark:border-white/[0.08] dark:bg-white/[0.04]"
|
||||
}`}
|
||||
>
|
||||
{Array.from(selected).map((t) => (
|
||||
@@ -422,12 +436,12 @@ function TrainerMultiSelect({
|
||||
if (e.key === "Escape") { setOpen(false); setSearch(""); }
|
||||
}}
|
||||
placeholder={selected.size === 0 ? "Все тренеры" : ""}
|
||||
className="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-neutral-500 outline-none"
|
||||
className="flex-1 min-w-[80px] bg-transparent text-sm text-neutral-900 placeholder-neutral-500 outline-none dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-lg border border-white/[0.08] shadow-xl overflow-hidden" style={{ backgroundColor: "#1a1a1a" }}>
|
||||
<div className="absolute z-10 mt-1 w-full rounded-lg border border-neutral-200 bg-white shadow-xl overflow-hidden dark:border-white/[0.08] dark:bg-[#1a1a1a]">
|
||||
<div className="max-h-48 overflow-y-auto styled-scrollbar">
|
||||
{filtered.map((trainer) => (
|
||||
<button
|
||||
@@ -439,7 +453,7 @@ function TrainerMultiSelect({
|
||||
setSearch("");
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm text-white transition-colors hover:bg-white/[0.05]"
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-900 transition-colors hover:bg-neutral-100 dark:text-white dark:hover:bg-white/[0.05]"
|
||||
>
|
||||
{trainer}
|
||||
</button>
|
||||
|
||||
@@ -271,7 +271,7 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
||||
{/* Cards */}
|
||||
{/* Mobile swipe hint */}
|
||||
<div
|
||||
className={`absolute bottom-2 left-1/2 -translate-x-1/2 z-20 text-xs text-neutral-400 tracking-wide transition-opacity duration-1000 md:hidden ${
|
||||
className={`absolute bottom-2 left-1/2 -translate-x-1/2 z-20 text-xs text-neutral-600 dark:text-neutral-400 tracking-wide transition-opacity duration-1000 md:hidden ${
|
||||
swipeHintVisible ? "opacity-60" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
@@ -303,7 +303,7 @@ export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarou
|
||||
filter: style.filter,
|
||||
borderColor: style.isCenter ? "transparent" : style.borderColor,
|
||||
boxShadow: style.isCenter
|
||||
? "0 0 40px rgba(201,169,110,0.15), 0 0 80px rgba(201,169,110,0.08)"
|
||||
? "0 0 40px rgba(201,169,110,0.25), 0 0 80px rgba(201,169,110,0.12), 0 8px 32px rgba(0,0,0,0.08)"
|
||||
: style.boxShadow,
|
||||
transition: style.transition,
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { TeamProfile } from "./TeamProfile";
|
||||
import type { SiteContent, ScheduleLocation, TeamMember } from "@/types/content";
|
||||
|
||||
/* ── Types ─────────────────────────────────────────────────────────── */
|
||||
|
||||
interface TeamGridProps {
|
||||
data: SiteContent["team"];
|
||||
schedule?: ScheduleLocation[];
|
||||
scheduleConfig?: SiteContent["scheduleConfig"];
|
||||
}
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────────────── */
|
||||
|
||||
function extractStyles(members: TeamMember[]): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const m of members) {
|
||||
for (const part of m.role.split(" · ")) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed) set.add(trimmed);
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}
|
||||
|
||||
function toColumns<T>(items: T[], count: number): T[][] {
|
||||
const cols: T[][] = Array.from({ length: count }, () => []);
|
||||
items.forEach((item, i) => cols[i % count].push(item));
|
||||
return cols;
|
||||
}
|
||||
|
||||
function useColumnCount(ref: React.RefObject<HTMLDivElement | null>): number {
|
||||
const [cols, setCols] = useState(4);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
function calc() {
|
||||
const w = el!.clientWidth;
|
||||
if (w >= 1200) setCols(5);
|
||||
else if (w >= 900) setCols(4);
|
||||
else if (w >= 600) setCols(3);
|
||||
else setCols(2);
|
||||
}
|
||||
|
||||
calc();
|
||||
const ro = new ResizeObserver(calc);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [ref]);
|
||||
|
||||
return cols;
|
||||
}
|
||||
|
||||
/* ── Main Component ────────────────────────────────────────────────── */
|
||||
|
||||
export function TeamGrid({ data, schedule, scheduleConfig }: TeamGridProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeStyle, setActiveStyle] = useState<string | null>(null);
|
||||
const [selectedMember, setSelectedMember] = useState<TeamMember | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const columnCount = useColumnCount(gridRef);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const members = data?.members ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const trainerName = searchParams.get("trainer");
|
||||
if (trainerName) {
|
||||
const found = members.find((m) => m.name === trainerName);
|
||||
if (found) setSelectedMember(found);
|
||||
}
|
||||
}, [searchParams, members]);
|
||||
|
||||
const styles = useMemo(() => extractStyles(members), [members]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return members.filter((m) => {
|
||||
if (q && !m.name.toLowerCase().includes(q)) return false;
|
||||
if (activeStyle && !m.role.split(" · ").map((s) => s.trim()).includes(activeStyle)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [members, search, activeStyle]);
|
||||
|
||||
const columns = useMemo(() => toColumns(filtered, columnCount), [filtered, columnCount]);
|
||||
|
||||
const openProfile = useCallback((member: TeamMember) => {
|
||||
setSelectedMember(member);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
const closeProfile = useCallback(() => setSelectedMember(null), []);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setSearch("");
|
||||
setActiveStyle(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-neutral-50 dark:bg-[#050505]">
|
||||
{/* Profile view — shown on top, grid stays mounted but hidden */}
|
||||
{selectedMember && (
|
||||
<div className="px-4 sm:px-6 lg:px-8 pt-4 pb-10">
|
||||
<TeamProfile
|
||||
member={selectedMember}
|
||||
onBack={closeProfile}
|
||||
schedule={schedule}
|
||||
scheduleConfig={scheduleConfig}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid + filters — hidden while profile is open so ResizeObserver stays alive */}
|
||||
<div className={selectedMember ? "hidden" : ""}>
|
||||
{/* Filter Bar */}
|
||||
<div className="sticky top-16 z-30 border-b border-neutral-200/50 dark:border-white/[0.04] bg-neutral-50/80 dark:bg-[#050505]/80 backdrop-blur-xl">
|
||||
<div className="mx-auto max-w-[1600px] px-4 sm:px-6">
|
||||
<div className="flex items-center gap-2 py-3 overflow-x-auto scrollbar-hide">
|
||||
<div className="relative flex-shrink-0">
|
||||
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 dark:text-neutral-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск…"
|
||||
className="w-32 sm:w-40 rounded-full bg-neutral-200/60 pl-8 pr-3 py-1.5 text-sm text-neutral-800 placeholder-neutral-400 outline-none transition-all
|
||||
focus:w-48 focus:bg-neutral-200
|
||||
dark:bg-white/[0.06] dark:text-white dark:placeholder-neutral-500
|
||||
dark:focus:bg-white/[0.1]"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 dark:hover:text-white cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-5 bg-neutral-300 dark:bg-white/[0.08] flex-shrink-0" />
|
||||
|
||||
<Chip active={!activeStyle} onClick={() => setActiveStyle(null)}>Все</Chip>
|
||||
{styles.map((s) => (
|
||||
<Chip
|
||||
key={s}
|
||||
active={activeStyle === s}
|
||||
onClick={() => setActiveStyle(activeStyle === s ? null : s)}
|
||||
>
|
||||
{s}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Masonry Grid */}
|
||||
<div ref={gridRef} className="mx-auto max-w-[1600px] px-2 sm:px-4 py-4">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<p className="text-neutral-400 dark:text-neutral-500 text-sm mb-4">Тренеры не найдены</p>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="rounded-full border border-gold/40 px-5 py-2 text-xs font-medium text-gold hover:bg-gold/10 transition-colors cursor-pointer"
|
||||
>
|
||||
Сбросить фильтры
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-1.5 sm:gap-2">
|
||||
{columns.map((col, ci) => (
|
||||
<div key={ci} className="flex-1 flex flex-col gap-1.5 sm:gap-2">
|
||||
{col.map((member, mi) => (
|
||||
<PinCard
|
||||
key={member.name}
|
||||
member={member}
|
||||
index={ci * 100 + mi}
|
||||
onClick={openProfile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Filter Chip ───────────────────────────────────────────────────── */
|
||||
|
||||
function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={[
|
||||
"flex-shrink-0 rounded-full px-3.5 py-1.5 text-xs font-medium transition-all duration-200 cursor-pointer whitespace-nowrap",
|
||||
active
|
||||
? "bg-gold text-black shadow-[0_0_12px_rgba(201,169,110,0.25)]"
|
||||
: "bg-neutral-200/60 text-neutral-600 hover:bg-neutral-300/60 dark:bg-white/[0.06] dark:text-neutral-400 dark:hover:bg-white/[0.1] dark:hover:text-white",
|
||||
].join(" ")}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Pin Card ──────────────────────────────────────────────────────── */
|
||||
|
||||
function PinCard({ member, index, onClick }: { member: TeamMember; index: number; onClick: (m: TeamMember) => void }) {
|
||||
const delay = Math.min((index % 100) * 50, 400);
|
||||
|
||||
return (
|
||||
<article
|
||||
onClick={() => onClick(member)}
|
||||
className="group relative overflow-hidden rounded-xl cursor-pointer"
|
||||
style={{
|
||||
animation: `team-grid-card-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) ${delay}ms both`,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={member.image}
|
||||
alt={member.name}
|
||||
width={400}
|
||||
height={600}
|
||||
sizes="(min-width: 1200px) 20vw, (min-width: 900px) 25vw, (min-width: 600px) 33vw, 50vw"
|
||||
className="w-full h-auto block rounded-xl transition-transform duration-500 ease-out group-hover:scale-[1.03]"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 rounded-xl bg-gradient-to-t from-black/80 via-black/10 to-transparent
|
||||
opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3 sm:p-4
|
||||
translate-y-2 group-hover:translate-y-0
|
||||
opacity-0 group-hover:opacity-100
|
||||
transition-all duration-300 ease-out">
|
||||
<p className="font-display text-sm sm:text-base font-semibold text-white leading-tight tracking-wide uppercase">
|
||||
{member.name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] sm:text-xs text-gold-light/80 leading-snug">
|
||||
{member.role}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 rounded-xl border border-transparent group-hover:border-gold/20 transition-colors duration-300 pointer-events-none" />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export function TeamMemberInfo({ members, activeIndex, onSelect, onOpenBio }: Te
|
||||
href={member.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-white/40 transition-colors hover:text-gold-light"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-neutral-500 transition-colors hover:text-gold-dark dark:text-white/40 dark:hover:text-gold-light"
|
||||
>
|
||||
<Instagram size={14} />
|
||||
{member.instagram.split("/").filter(Boolean).pop()}
|
||||
@@ -32,31 +32,42 @@ export function TeamMemberInfo({ members, activeIndex, onSelect, onOpenBio }: Te
|
||||
)}
|
||||
|
||||
{(member.shortDescription || member.description) && (
|
||||
<p className="mt-3 text-sm leading-relaxed text-white/55 line-clamp-3">
|
||||
<p className="mt-3 text-sm leading-relaxed text-neutral-600 dark:text-white/55 line-clamp-3">
|
||||
{member.shortDescription || member.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onOpenBio}
|
||||
aria-label={`Подробнее о ${member.name}`}
|
||||
className="mt-3 text-sm font-medium text-gold hover:text-gold-light transition-colors cursor-pointer"
|
||||
>
|
||||
Подробнее →
|
||||
</button>
|
||||
|
||||
{/* Progress dots */}
|
||||
<div className="mt-6 flex items-center justify-center gap-1.5">
|
||||
{members.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onSelect(i)}
|
||||
className={`h-1.5 rounded-full transition-all duration-500 cursor-pointer ${
|
||||
i === activeIndex
|
||||
? "w-6 bg-gold"
|
||||
: "w-1.5 bg-white/15 hover:bg-white/30"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
{/* Progress dots — mobile only (desktop has carousel arrows) */}
|
||||
<div className="mt-6 flex items-center justify-center gap-2 sm:hidden">
|
||||
{members.map((m, i) => {
|
||||
const isActive = i === activeIndex;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onSelect(i)}
|
||||
aria-label={`Перейти к ${m.name}`}
|
||||
className="relative flex items-center justify-center w-8 h-8 cursor-pointer group"
|
||||
>
|
||||
{/* 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-neutral-400 group-hover:bg-neutral-500 group-hover:scale-125 dark:bg-white/20 dark:group-hover:bg-white/40"
|
||||
}`} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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 { findStatusConfig } from "@/components/sections/schedule/constants";
|
||||
import { SignupModal } from "@/components/ui/SignupModal";
|
||||
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||
import { formatMarkup } from "@/lib/markup";
|
||||
import { GroupCard } from "@/components/ui/GroupCard";
|
||||
|
||||
@@ -111,12 +112,12 @@ export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamPr
|
||||
<div className="relative shrink-0 w-full sm:w-[380px] lg:w-[420px] sm:sticky sm:top-8">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="mb-3 inline-flex items-center gap-1.5 rounded-full bg-white/[0.06] px-3 py-1.5 text-sm text-white/50 transition-colors hover:text-white hover:bg-white/[0.1] cursor-pointer"
|
||||
className="mb-3 inline-flex items-center gap-1.5 rounded-full bg-neutral-100 px-3 py-1.5 text-sm text-neutral-500 transition-colors hover:text-neutral-900 hover:bg-neutral-200 cursor-pointer dark:bg-white/[0.06] dark:text-white/50 dark:hover:text-white dark:hover:bg-white/[0.1]"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Назад
|
||||
</button>
|
||||
<div className="relative aspect-[3/4] overflow-hidden rounded-2xl border border-white/[0.06]">
|
||||
<div className="relative aspect-[3/4] overflow-hidden rounded-2xl border border-neutral-200 dark:border-white/[0.06]">
|
||||
<Image
|
||||
src={member.image}
|
||||
alt={member.name}
|
||||
@@ -161,7 +162,7 @@ export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamPr
|
||||
|
||||
{/* Bio panel — overlaps photo edge on desktop */}
|
||||
<div className="relative sm:-ml-12 sm:mt-8 mt-0 flex-1 min-w-0 z-10">
|
||||
<div className="relative rounded-2xl border border-white/[0.08] overflow-hidden shadow-2xl shadow-black/40">
|
||||
<div className="relative rounded-2xl border border-neutral-200 overflow-hidden shadow-2xl shadow-neutral-300/50 dark:border-white/[0.08] dark:shadow-black/40">
|
||||
{/* Ambient photo background */}
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
@@ -171,20 +172,20 @@ export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamPr
|
||||
sizes="600px"
|
||||
className="object-cover scale-150 blur-sm grayscale opacity-70 brightness-[0.6] contrast-[1.3]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/20 mix-blend-multiply" />
|
||||
<div className="absolute inset-0 bg-white/80 dark:bg-black/20 dark:mix-blend-multiply" />
|
||||
<div className="absolute inset-0 bg-gold/10 mix-blend-color" />
|
||||
</div>
|
||||
<div className="relative p-5 sm:p-6 space-y-6">
|
||||
<div className="relative p-5 sm:p-6 space-y-6 dark:bg-black/40">
|
||||
{/* Groups — first, most actionable */}
|
||||
{hasGroups && (
|
||||
<div>
|
||||
<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-dark dark:text-gold/70 flex items-center gap-2">
|
||||
<Clock size={12} />
|
||||
Группы
|
||||
</h4>
|
||||
<ScrollRow>
|
||||
{uniqueGroups.map((g, i) => (
|
||||
<div key={i} className="w-56 shrink-0 rounded-xl border border-white/[0.08] bg-white/[0.03] p-3 flex flex-col">
|
||||
<div key={i} className="w-56 shrink-0 rounded-xl border border-neutral-200 bg-neutral-50 p-3 flex flex-col dark:border-white/[0.12] dark:bg-white/[0.06]">
|
||||
<GroupCard
|
||||
compact
|
||||
type={g.type}
|
||||
@@ -206,7 +207,7 @@ export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamPr
|
||||
|
||||
{/* Description */}
|
||||
{member.description && (
|
||||
<div className="text-sm leading-relaxed text-white/50">
|
||||
<div className="text-sm leading-relaxed text-neutral-600 dark:text-white/50">
|
||||
{formatMarkup(member.description)}
|
||||
</div>
|
||||
)}
|
||||
@@ -235,7 +236,7 @@ export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamPr
|
||||
|
||||
{/* Empty state */}
|
||||
{!hasBio && !member.description && (
|
||||
<p className="text-sm text-white/30 italic">
|
||||
<p className="text-sm text-neutral-400 italic dark:text-white/30">
|
||||
Информация скоро появится
|
||||
</p>
|
||||
)}
|
||||
@@ -246,30 +247,7 @@ export function TeamProfile({ member, onBack, schedule, scheduleConfig }: TeamPr
|
||||
|
||||
{/* Image lightbox */}
|
||||
{lightbox && (
|
||||
<div
|
||||
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>
|
||||
<LightboxDialog src={lightbox} onClose={() => setLightbox(null)} />
|
||||
)}
|
||||
|
||||
<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-neutral-200 p-2 text-neutral-700 hover:bg-neutral-300 dark:bg-white/10 dark:text-white dark: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 }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -290,14 +305,15 @@ function CollapsibleSection({ icon: Icon, title, count, children }: { icon: Reac
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
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-dark dark:text-gold/70 flex items-center gap-2">
|
||||
<Icon size={12} />
|
||||
{title}
|
||||
<span className="text-gold/40">{count}</span>
|
||||
<span className="text-gold-dark/50 dark:text-gold/40">{count}</span>
|
||||
</h4>
|
||||
<ChevronDown size={14} className={`text-gold/40 transition-transform duration-200 group-hover:text-gold/60 ${open ? "rotate-180" : ""}`} />
|
||||
<ChevronDown size={14} className={`text-gold-dark/50 dark:text-gold/40 transition-transform duration-200 group-hover:text-gold dark:group-hover:text-gold/60 ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||
@@ -377,7 +393,8 @@ function ScrollRow({ children }: { children: React.ReactNode }) {
|
||||
{canScroll.left && (
|
||||
<button
|
||||
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-white/90 border border-neutral-200 p-2.5 text-neutral-500 hover:text-neutral-900 hover:bg-white dark:bg-black/80 dark:border-white/10 dark:text-white/60 dark:hover:text-white dark:hover:bg-black/90 transition-all cursor-pointer"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
@@ -385,7 +402,8 @@ function ScrollRow({ children }: { children: React.ReactNode }) {
|
||||
{canScroll.right && (
|
||||
<button
|
||||
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-white/90 border border-neutral-200 p-2.5 text-neutral-500 hover:text-neutral-900 hover:bg-white dark:bg-black/80 dark:border-white/10 dark:text-white/60 dark:hover:text-white dark:hover:bg-black/90 transition-all cursor-pointer"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
@@ -400,7 +418,7 @@ function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (s
|
||||
|
||||
if (hasImage) {
|
||||
return (
|
||||
<div className="group w-60 shrink-0 flex rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03] transition-all duration-200 hover:border-gold/30 hover:bg-white/[0.06] hover:shadow-lg hover:shadow-gold/5">
|
||||
<div className="group w-60 shrink-0 flex rounded-xl border border-neutral-200 overflow-hidden bg-white dark:border-white/[0.08] dark:bg-white/[0.03] transition-all duration-200 hover:border-gold/30 dark:hover:bg-white/[0.06] hover:shadow-lg hover:shadow-gold/5">
|
||||
<button
|
||||
onClick={() => onImageClick(item.image!)}
|
||||
className="relative w-18 shrink-0 overflow-hidden cursor-pointer"
|
||||
@@ -414,7 +432,7 @@ function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (s
|
||||
/>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0 p-3">
|
||||
<p className="text-sm text-white/70 group-hover:text-white/90 transition-colors">{item.text}</p>
|
||||
<p className="text-sm text-neutral-600 group-hover:text-neutral-900 dark:text-white/70 dark:group-hover:text-white/90 transition-colors">{item.text}</p>
|
||||
{hasLink && (
|
||||
<a
|
||||
href={item.link}
|
||||
@@ -432,9 +450,9 @@ function RichCard({ item, onImageClick }: { item: RichListItem; onImageClick: (s
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group w-60 shrink-0 rounded-xl border border-white/[0.08] overflow-hidden bg-white/[0.03] transition-all duration-200 hover:border-gold/30 hover:bg-white/[0.06] hover:shadow-lg hover:shadow-gold/5">
|
||||
<div className="group w-60 shrink-0 rounded-xl border border-neutral-200 overflow-hidden bg-white dark:border-white/[0.08] dark:bg-white/[0.03] transition-all duration-200 hover:border-gold/30 dark:hover:bg-white/[0.06] hover:shadow-lg hover:shadow-gold/5">
|
||||
<div className="p-3">
|
||||
<p className="text-sm text-white/60 group-hover:text-white/80 transition-colors">{item.text}</p>
|
||||
<p className="text-sm text-neutral-500 group-hover:text-neutral-800 dark:text-white/60 dark:group-hover:text-white/80 transition-colors">{item.text}</p>
|
||||
{hasLink && (
|
||||
<a
|
||||
href={item.link}
|
||||
|
||||
@@ -8,8 +8,15 @@ export function BackToTop() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
function handleScroll() {
|
||||
setVisible(window.scrollY > UI_CONFIG.scrollThresholds.backToTop);
|
||||
if (!ticking) {
|
||||
ticking = true;
|
||||
requestAnimationFrame(() => {
|
||||
setVisible(window.scrollY > UI_CONFIG.scrollThresholds.backToTop);
|
||||
ticking = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
@@ -19,7 +26,7 @@ export function BackToTop() {
|
||||
<button
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
aria-label="Наверх"
|
||||
className={`fixed bottom-6 right-6 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-gold/30 bg-black/60 text-gold-light backdrop-blur-sm transition-all duration-300 hover:bg-gold/20 hover:border-gold/50 ${
|
||||
className={`fixed bottom-6 right-6 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-gold/40 bg-white/80 text-gold-dark backdrop-blur-sm transition-all duration-300 hover:bg-gold/15 hover:border-gold/60 dark:border-gold/30 dark:bg-black/60 dark:text-gold-light dark:hover:bg-gold/20 dark:hover:border-gold/50 ${
|
||||
visible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -12,14 +12,20 @@ export function FloatingContact() {
|
||||
const { bookingOpen: modalOpen, openBooking, closeBooking } = useBooking();
|
||||
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
function handleScroll() {
|
||||
const contactEl = document.getElementById("contact");
|
||||
const pastHero = window.scrollY > window.innerHeight * 0.7;
|
||||
const reachedContact = contactEl
|
||||
? window.scrollY + window.innerHeight > contactEl.offsetTop + 100
|
||||
: false;
|
||||
|
||||
setVisible(pastHero && !reachedContact);
|
||||
if (!ticking) {
|
||||
ticking = true;
|
||||
requestAnimationFrame(() => {
|
||||
const contactEl = document.getElementById("contact");
|
||||
const pastHero = window.scrollY > window.innerHeight * 0.7;
|
||||
const reachedContact = contactEl
|
||||
? window.scrollY + window.innerHeight > contactEl.offsetTop + 100
|
||||
: false;
|
||||
setVisible(pastHero && !reachedContact);
|
||||
ticking = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
@@ -51,6 +57,8 @@ export function FloatingContact() {
|
||||
{/* Main toggle button */}
|
||||
<button
|
||||
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 ${
|
||||
expanded
|
||||
? "bg-neutral-700 rotate-0"
|
||||
|
||||
@@ -34,7 +34,7 @@ export function FloatingHearts() {
|
||||
{hearts.map((heart) => (
|
||||
<div
|
||||
key={heart.id}
|
||||
className="absolute text-gold"
|
||||
className="absolute text-gold-dark dark:text-gold"
|
||||
style={{
|
||||
left: `${heart.left}%`,
|
||||
bottom: "-20px",
|
||||
|
||||
@@ -61,7 +61,7 @@ export function GroupCard({
|
||||
const typeContent = (
|
||||
<>
|
||||
<span className={`${dot} shrink-0 rounded-full ${dotColor}`} />
|
||||
<span className={`${typeCls} font-semibold text-white/90`}>{type}</span>
|
||||
<span className={`${typeCls} font-semibold text-neutral-900 dark:text-white/90`}>{type}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -77,7 +77,7 @@ export function GroupCard({
|
||||
<span className="inline-flex items-center gap-1.5">{typeContent}</span>
|
||||
)}
|
||||
{showLocation && (address || location) && (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/25 ${locSize} font-medium text-white`}>
|
||||
<span className={`inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/25 ${locSize} font-medium text-neutral-700 dark:text-white`}>
|
||||
<MapPin size={locIcon} className="text-gold" />
|
||||
{shortAddress(address || location || "")}
|
||||
</span>
|
||||
@@ -96,7 +96,7 @@ export function GroupCard({
|
||||
<span className={`rounded-md bg-gold/10 ${dayPad} font-bold text-gold text-center`}>
|
||||
{m.days.join(", ")}
|
||||
</span>
|
||||
<span className={`${timeCls} tabular-nums text-white/60`}>
|
||||
<span className={`${timeCls} tabular-nums text-neutral-600 dark:text-white/60`}>
|
||||
{m.times.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||
|
||||
interface ModalBaseProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
ariaLabel: string;
|
||||
maxWidth?: string;
|
||||
children: React.ReactNode;
|
||||
/** Hide the default close button (e.g. when content has its own) */
|
||||
hideClose?: boolean;
|
||||
}
|
||||
|
||||
export function ModalBase({ open, onClose, ariaLabel, maxWidth = "max-w-md", children, hideClose }: ModalBaseProps) {
|
||||
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
document.body.style.overflow = "hidden";
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
document.removeEventListener("keydown", handleKey);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||
<div
|
||||
ref={focusTrapRef}
|
||||
className={`modal-content relative w-full ${maxWidth} rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.08] dark:bg-neutral-950 p-6 sm:p-8 shadow-2xl`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{!hideClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
className="absolute right-4 top-4 flex h-11 w-11 items-center justify-center rounded-full text-neutral-500 dark:text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/[0.06] dark:hover:text-white cursor-pointer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -45,13 +45,13 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
|
||||
|
||||
<div
|
||||
ref={focusTrapRef}
|
||||
className="modal-content relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl border border-white/[0.08] bg-neutral-950 shadow-2xl"
|
||||
className="modal-content relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl border border-neutral-200 bg-white shadow-2xl dark:border-white/[0.08] dark:bg-neutral-950"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть"
|
||||
className="absolute right-4 top-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/50 text-neutral-400 backdrop-blur-sm transition-colors hover:bg-white/[0.1] hover:text-white cursor-pointer"
|
||||
className="absolute right-4 top-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/50 text-white/70 backdrop-blur-sm transition-colors hover:bg-black/70 hover:text-white cursor-pointer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
@@ -69,21 +69,21 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
|
||||
transform: `scale(${item.imageZoom ?? 1})`,
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-neutral-950 via-transparent to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`p-6 sm:p-8 ${item.image ? "-mt-12 relative" : ""}`}>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-neutral-400">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
<Calendar size={12} />
|
||||
{formatDateRu(item.date)}
|
||||
</span>
|
||||
|
||||
<h2 className="mt-2 text-xl sm:text-2xl font-bold text-white leading-tight">
|
||||
<h2 className="mt-2 text-xl sm:text-2xl font-bold text-neutral-900 leading-tight dark:text-white">
|
||||
{item.title}
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 text-sm sm:text-base leading-relaxed text-neutral-300 whitespace-pre-line">
|
||||
<p className="mt-4 text-sm sm:text-base leading-relaxed text-neutral-600 whitespace-pre-line dark:text-neutral-300">
|
||||
{item.text}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
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 {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
@@ -10,30 +35,33 @@ interface RevealProps {
|
||||
export function Reveal({ children, className = "" }: RevealProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [reducedMotion, setReducedMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setReducedMotion(true);
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.unobserve(el);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: "0px 0px -50px 0px" },
|
||||
);
|
||||
|
||||
const observer = getObserver();
|
||||
callbacks.set(el, () => setVisible(true));
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
|
||||
return () => {
|
||||
callbacks.delete(el);
|
||||
observer.unobserve(el);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={{
|
||||
style={reducedMotion ? undefined : {
|
||||
opacity: visible ? 1 : 0,
|
||||
transform: visible ? "translateY(0)" : "translateY(30px)",
|
||||
transition: "opacity 0.7s ease-out, transform 0.7s ease-out",
|
||||
|
||||
@@ -8,12 +8,12 @@ export function SectionHeading({ children, className = "", centered = false }: S
|
||||
return (
|
||||
<div className={centered ? "text-center" : ""}>
|
||||
<h2
|
||||
className={`font-display text-4xl font-bold uppercase tracking-wide sm:text-5xl lg:text-6xl gradient-text ${className}`}
|
||||
className={`font-display text-4xl font-bold uppercase tracking-wider sm:text-5xl lg:text-7xl text-gold ${className}`}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
<span
|
||||
className={`mt-4 block h-[1px] w-20 bg-gradient-to-r from-gold to-transparent ${
|
||||
className={`mt-5 block h-[1px] w-24 bg-gradient-to-r from-gold via-gold/40 to-transparent ${
|
||||
centered ? "mx-auto" : ""
|
||||
}`}
|
||||
/>
|
||||
|
||||