Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76307e298b | |||
| ec08f8e8d5 | |||
| a769ea844d | |||
| 8088b99a43 | |||
| 228e547e10 | |||
| c9303e5aad | |||
| c9cfe63837 | |||
| f65a6ed811 | |||
| 09b2f40090 | |||
| 4c8c6eb0d2 |
@@ -25,7 +25,7 @@ Content language: Russian
|
|||||||
src/
|
src/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── layout.tsx # Root layout, fonts, metadata
|
│ ├── layout.tsx # Root layout, fonts, metadata
|
||||||
│ ├── page.tsx # Landing: Hero → [OpenDay] → About → Team → Classes → MasterClasses → Schedule → Pricing → News → FAQ → Contact
|
│ ├── page.tsx # Landing: Hero → About → Classes → Team → [OpenDay] → Schedule → Pricing → MasterClasses → News → FAQ → Contact
|
||||||
│ ├── globals.css # Tailwind imports
|
│ ├── globals.css # Tailwind imports
|
||||||
│ ├── styles/
|
│ ├── styles/
|
||||||
│ │ ├── theme.css # Theme variables, semantic classes
|
│ │ ├── theme.css # Theme variables, semantic classes
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/** Reusable loading skeleton for admin pages */
|
||||||
|
export function AdminSkeleton({ rows = 3 }: { rows?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
{/* Title skeleton */}
|
||||||
|
<div className="h-8 w-48 rounded-lg bg-neutral-800" />
|
||||||
|
{/* Content skeletons */}
|
||||||
|
{Array.from({ length: rows }, (_, i) => (
|
||||||
|
<div key={i} className="space-y-3">
|
||||||
|
<div className="h-4 w-24 rounded bg-neutral-800" />
|
||||||
|
<div className="h-10 w-full rounded-lg bg-neutral-800" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Plus, Trash2, GripVertical, ChevronDown } from "lucide-react";
|
import { Plus, Trash2, GripVertical, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
|
|
||||||
interface ArrayEditorProps<T> {
|
interface ArrayEditorProps<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
@@ -31,6 +32,7 @@ export function ArrayEditor<T>({
|
|||||||
hiddenItems,
|
hiddenItems,
|
||||||
addPosition = "bottom",
|
addPosition = "bottom",
|
||||||
}: ArrayEditorProps<T>) {
|
}: ArrayEditorProps<T>) {
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
||||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||||
const [insertAt, setInsertAt] = useState<number | null>(null);
|
const [insertAt, setInsertAt] = useState<number | null>(null);
|
||||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||||
@@ -170,6 +172,8 @@ export function ArrayEditor<T>({
|
|||||||
<div
|
<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-white transition-colors select-none"
|
||||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||||
|
aria-label="Перетащить для сортировки"
|
||||||
|
role="button"
|
||||||
>
|
>
|
||||||
<GripVertical size={16} />
|
<GripVertical size={16} />
|
||||||
</div>
|
</div>
|
||||||
@@ -187,7 +191,8 @@ export function ArrayEditor<T>({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeItem(i)}
|
onClick={() => setConfirmDelete(i)}
|
||||||
|
aria-label="Удалить элемент"
|
||||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
|
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
@@ -257,12 +262,15 @@ export function ArrayEditor<T>({
|
|||||||
<div
|
<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-white transition-colors select-none"
|
||||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||||
|
aria-label="Перетащить для сортировки"
|
||||||
|
role="button"
|
||||||
>
|
>
|
||||||
<GripVertical size={16} />
|
<GripVertical size={16} />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeItem(i)}
|
onClick={() => removeItem(i)}
|
||||||
|
aria-label="Удалить элемент"
|
||||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
@@ -293,8 +301,23 @@ export function ArrayEditor<T>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{label && (
|
{(label || (collapsible && items.length > 1)) && (
|
||||||
<h3 className="text-sm font-medium text-neutral-300 mb-3">{label}</h3>
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
{label ? <h3 className="text-sm font-medium 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"
|
||||||
|
title={allCollapsed ? "Развернуть все" : "Свернуть все"}
|
||||||
|
>
|
||||||
|
<ChevronsUpDown size={16} className={`transition-transform duration-200 ${allCollapsed ? "" : "rotate-90"}`} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{addPosition === "top" && (
|
{addPosition === "top" && (
|
||||||
@@ -355,6 +378,14 @@ export function ArrayEditor<T>({
|
|||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDelete !== null}
|
||||||
|
title="Удалить элемент?"
|
||||||
|
message="Это действие нельзя отменить."
|
||||||
|
onConfirm={() => { if (confirmDelete !== null) removeItem(confirmDelete); setConfirmDelete(null); }}
|
||||||
|
onCancel={() => setConfirmDelete(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
interface CollapsibleSectionProps {
|
||||||
|
title: string;
|
||||||
|
count?: number;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
isOpen?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared collapsible section for admin pages.
|
||||||
|
* Supports both controlled (isOpen/onToggle) and uncontrolled (defaultOpen) modes.
|
||||||
|
*/
|
||||||
|
export function CollapsibleSection({
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
defaultOpen = true,
|
||||||
|
isOpen: controlledOpen,
|
||||||
|
onToggle,
|
||||||
|
children,
|
||||||
|
}: CollapsibleSectionProps) {
|
||||||
|
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||||
|
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||||
|
const toggle = onToggle ?? (() => setInternalOpen((v) => !v));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-neutral-900/30 overflow-hidden">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-200 group-hover:text-white transition-colors">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{count !== undefined && (
|
||||||
|
<span className="text-xs text-neutral-500">{count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`text-neutral-500 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||||
|
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="px-5 pb-5 space-y-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { AlertTriangle, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
destructive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = "Удалить",
|
||||||
|
cancelLabel = "Отмена",
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
destructive = true,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
cancelRef.current?.focus();
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onCancel();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onCancel]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{destructive && (
|
||||||
|
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-500/10">
|
||||||
|
<AlertTriangle size={20} className="text-red-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-white">{title}</h3>
|
||||||
|
<p className="mt-1.5 text-sm text-neutral-400">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`rounded-lg px-4 py-2 text-sm font-semibold transition-colors cursor-pointer ${
|
||||||
|
destructive
|
||||||
|
? "bg-red-600 text-white hover:bg-red-500"
|
||||||
|
: "bg-gold text-black hover:bg-gold-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
interface PriceFieldProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceField({ label, value, onChange, placeholder = "0" }: PriceFieldProps) {
|
||||||
|
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
|
||||||
|
|
||||||
|
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">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={raw}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<span className="flex items-center pr-4 text-sm font-medium text-gold select-none">
|
||||||
|
BYN
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ interface GroupBooking {
|
|||||||
status: BookingStatus;
|
status: BookingStatus;
|
||||||
confirmedDate?: string;
|
confirmedDate?: string;
|
||||||
confirmedGroup?: string;
|
confirmedGroup?: string;
|
||||||
|
confirmedHall?: string;
|
||||||
confirmedComment?: string;
|
confirmedComment?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ChevronDown, Plus, X, AlertCircle, Check } from "lucide-react";
|
import { Plus, X, AlertCircle, Check } from "lucide-react";
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField } from "../_components/FormField";
|
import { InputField } from "../_components/FormField";
|
||||||
|
import { CollapsibleSection } from "../_components/CollapsibleSection";
|
||||||
import type { ContactInfo } from "@/types/content";
|
import type { ContactInfo } from "@/types/content";
|
||||||
|
|
||||||
// --- Phone input with mask ---
|
// --- Phone input with mask ---
|
||||||
@@ -160,45 +161,6 @@ function AddressList({ items, onChange }: { items: string[]; onChange: (items: s
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Collapsible section ---
|
|
||||||
function CollapsibleSection({
|
|
||||||
title,
|
|
||||||
defaultOpen = true,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-white/10 bg-neutral-900/30 overflow-hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(!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"
|
|
||||||
>
|
|
||||||
<h3 className="text-sm font-semibold text-neutral-200 group-hover:text-white transition-colors">{title}</h3>
|
|
||||||
<ChevronDown
|
|
||||||
size={16}
|
|
||||||
className={`text-neutral-500 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
|
||||||
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
|
|
||||||
>
|
|
||||||
<div className="overflow-hidden">
|
|
||||||
<div className="px-5 pb-5 space-y-4">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContactEditorPage() {
|
export default function ContactEditorPage() {
|
||||||
return (
|
return (
|
||||||
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты">
|
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты">
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ function VideoManager({
|
|||||||
});
|
});
|
||||||
}, [slots]);
|
}, [slots]);
|
||||||
|
|
||||||
const totalSize = fileSizes.reduce((sum, s) => sum + (s || 0), 0);
|
const totalSize = fileSizes.reduce((sum: number, s) => sum + (s || 0), 0);
|
||||||
const totalMb = totalSize / (1024 * 1024);
|
const totalMb = totalSize / (1024 * 1024);
|
||||||
|
|
||||||
function getLoadRating(mb: number): { label: string; color: string } {
|
function getLoadRating(mb: number): { label: string; color: string } {
|
||||||
|
|||||||
@@ -29,17 +29,18 @@ import {
|
|||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: "/admin", label: "Дашборд", icon: LayoutDashboard },
|
{ href: "/admin", label: "Дашборд", icon: LayoutDashboard },
|
||||||
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe },
|
{ href: "/admin/meta", label: "SEO / Мета", icon: Globe },
|
||||||
|
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
|
||||||
|
// Sections follow user-side order: Hero → About → Classes → Team → OpenDay → Schedule → Pricing → MC → News → FAQ → Contact
|
||||||
{ href: "/admin/hero", label: "Главный экран", icon: Sparkles },
|
{ href: "/admin/hero", label: "Главный экран", icon: Sparkles },
|
||||||
{ href: "/admin/about", label: "О студии", icon: FileText },
|
{ href: "/admin/about", label: "О студии", icon: FileText },
|
||||||
{ href: "/admin/team", label: "Команда", icon: Users },
|
|
||||||
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
|
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
|
||||||
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
|
{ href: "/admin/team", label: "Команда", icon: Users },
|
||||||
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen },
|
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen },
|
||||||
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
|
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
|
||||||
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
|
|
||||||
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
|
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
|
||||||
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
|
||||||
{ href: "/admin/news", label: "Новости", icon: Newspaper },
|
{ href: "/admin/news", label: "Новости", icon: Newspaper },
|
||||||
|
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle },
|
||||||
{ href: "/admin/popups", label: "Всплывающие окна", icon: MessageSquare },
|
{ href: "/admin/popups", label: "Всплывающие окна", icon: MessageSquare },
|
||||||
{ href: "/admin/contact", label: "Контакты", icon: Phone },
|
{ href: "/admin/contact", label: "Контакты", icon: Phone },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Image from "next/image";
|
|||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField, TextareaField, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
|
import { InputField, TextareaField, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
|
||||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
|
import { PriceField } from "../_components/PriceField";
|
||||||
import { Plus, X, Upload, Loader2, AlertCircle, Check, Search } from "lucide-react";
|
import { Plus, X, Upload, Loader2, AlertCircle, Check, Search } from "lucide-react";
|
||||||
import { adminFetch } from "@/lib/csrf";
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
||||||
@@ -38,32 +39,6 @@ function itemMatchesLocation(item: MasterClassItem, locationFilter: string): boo
|
|||||||
return (item.location || "") === locationFilter;
|
return (item.location || "") === locationFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Price Field ---
|
|
||||||
|
|
||||||
function PriceField({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
|
|
||||||
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
|
|
||||||
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">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={raw}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
onChange(v ? `${v} BYN` : "");
|
|
||||||
}}
|
|
||||||
placeholder={placeholder ?? "0"}
|
|
||||||
className="flex-1 bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none min-w-0"
|
|
||||||
/>
|
|
||||||
<span className="flex items-center pr-4 text-sm font-medium text-gold select-none">
|
|
||||||
BYN
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MasterClassesData {
|
interface MasterClassesData {
|
||||||
title: string;
|
title: string;
|
||||||
items: MasterClassItem[];
|
items: MasterClassItem[];
|
||||||
|
|||||||
+62
-17
@@ -18,17 +18,22 @@ function CropPreview({
|
|||||||
image,
|
image,
|
||||||
focalX,
|
focalX,
|
||||||
focalY,
|
focalY,
|
||||||
|
zoom,
|
||||||
onImageChange,
|
onImageChange,
|
||||||
onFocalChange,
|
onFocalChange,
|
||||||
|
onZoomChange,
|
||||||
}: {
|
}: {
|
||||||
image: string;
|
image: string;
|
||||||
focalX: number;
|
focalX: number;
|
||||||
focalY: number;
|
focalY: number;
|
||||||
|
zoom: number;
|
||||||
onImageChange: (path: string) => void;
|
onImageChange: (path: string) => void;
|
||||||
onFocalChange: (x: number, y: number) => void;
|
onFocalChange: (x: number, y: number) => void;
|
||||||
|
onZoomChange: (z: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const dragStartRef = useRef({ x: 0, y: 0, startFocalX: 0, startFocalY: 0 });
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
@@ -52,39 +57,51 @@ function CropPreview({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFocalFromEvent(clientX: number, clientY: number) {
|
|
||||||
const el = containerRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const x = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
|
||||||
const y = Math.max(0, Math.min(100, ((clientY - rect.top) / rect.height) * 100));
|
|
||||||
onFocalChange(Math.round(x), Math.round(y));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePointerDown(e: React.PointerEvent) {
|
function handlePointerDown(e: React.PointerEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
updateFocalFromEvent(e.clientX, e.clientY);
|
dragStartRef.current = {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
startFocalX: focalX,
|
||||||
|
startFocalY: focalY,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerMove(e: React.PointerEvent) {
|
function handlePointerMove(e: React.PointerEvent) {
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
updateFocalFromEvent(e.clientX, e.clientY);
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const { x: startX, y: startY, startFocalX, startFocalY } = dragStartRef.current;
|
||||||
|
// Invert: dragging right moves focal left (image slides right)
|
||||||
|
const dx = ((e.clientX - startX) / rect.width) * 100;
|
||||||
|
const dy = ((e.clientY - startY) / rect.height) * 100;
|
||||||
|
const newX = Math.max(0, Math.min(100, startFocalX - dx));
|
||||||
|
const newY = Math.max(0, Math.min(100, startFocalY - dy));
|
||||||
|
onFocalChange(Math.round(newX), Math.round(newY));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerUp() {
|
function handlePointerUp() {
|
||||||
setDragging(false);
|
setDragging(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleWheel(e: React.WheelEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||||
|
const newZoom = Math.max(1, Math.min(3, zoom + delta));
|
||||||
|
onZoomChange(Math.round(newZoom * 10) / 10);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-neutral-400 mb-1.5">
|
<label className="block text-sm text-neutral-400 mb-1.5">
|
||||||
Изображение <span className="text-neutral-600">(перетащите для кадрирования)</span>
|
Изображение <span className="text-neutral-600">(перетащите фото · колёсико для масштаба)</span>
|
||||||
</label>
|
</label>
|
||||||
{image ? (
|
{image ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Crop area — drag to reposition */}
|
{/* Crop area — drag image to reposition */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative w-full aspect-[21/9] overflow-hidden rounded-xl border border-white/10 cursor-grab active:cursor-grabbing select-none"
|
className="relative w-full aspect-[21/9] overflow-hidden rounded-xl border border-white/10 cursor-grab active:cursor-grabbing select-none"
|
||||||
@@ -92,18 +109,44 @@ function CropPreview({
|
|||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerCancel={handlePointerUp}
|
onPointerCancel={handlePointerUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={image}
|
src={image}
|
||||||
alt="Превью"
|
alt="Превью"
|
||||||
fill
|
fill
|
||||||
className="object-cover pointer-events-none"
|
className="object-cover pointer-events-none"
|
||||||
style={{ objectPosition: `${focalX}% ${focalY}%` }}
|
style={{
|
||||||
|
objectPosition: `${focalX}% ${focalY}%`,
|
||||||
|
transform: `scale(${zoom})`,
|
||||||
|
}}
|
||||||
sizes="(max-width: 768px) 100vw, 600px"
|
sizes="(max-width: 768px) 100vw, 600px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Actions */}
|
{/* Zoom slider + actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<span className="text-[10px] text-neutral-500">−</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="3"
|
||||||
|
step="0.1"
|
||||||
|
value={zoom}
|
||||||
|
onChange={(e) => onZoomChange(parseFloat(e.target.value))}
|
||||||
|
className="flex-1 h-1 accent-[#c9a96e] cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-neutral-500">+</span>
|
||||||
|
{zoom > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onZoomChange(1); onFocalChange(50, 50); }}
|
||||||
|
className="text-[10px] text-neutral-500 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<label className="flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 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-lg border border-white/10 px-3 py-1.5 text-xs text-neutral-400 hover:text-white hover:border-white/25 transition-colors">
|
||||||
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Upload size={12} />}
|
||||||
Заменить
|
Заменить
|
||||||
@@ -112,7 +155,7 @@ function CropPreview({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onImageChange("")}
|
onClick={() => onImageChange("")}
|
||||||
className="rounded-lg px-3 py-1.5 text-xs text-neutral-500 hover:text-red-400 transition-colors ml-auto"
|
className="rounded-lg px-3 py-1.5 text-xs text-neutral-500 hover:text-red-400 transition-colors"
|
||||||
>
|
>
|
||||||
Удалить
|
Удалить
|
||||||
</button>
|
</button>
|
||||||
@@ -204,8 +247,10 @@ export default function NewsEditorPage() {
|
|||||||
image={item.image || ""}
|
image={item.image || ""}
|
||||||
focalX={item.imageFocalX ?? 50}
|
focalX={item.imageFocalX ?? 50}
|
||||||
focalY={item.imageFocalY ?? 50}
|
focalY={item.imageFocalY ?? 50}
|
||||||
|
zoom={item.imageZoom ?? 1}
|
||||||
onImageChange={(v) => updateItem({ ...item, image: v || undefined })}
|
onImageChange={(v) => updateItem({ ...item, image: v || undefined })}
|
||||||
onFocalChange={(x, y) => updateItem({ ...item, imageFocalX: x, imageFocalY: y })}
|
onFocalChange={(x, y) => updateItem({ ...item, imageFocalX: x, imageFocalY: y })}
|
||||||
|
onZoomChange={(z) => updateItem({ ...item, imageZoom: z })}
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InputField
|
||||||
label="Ссылка (необязательно)"
|
label="Ссылка (необязательно)"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { adminFetch } from "@/lib/csrf";
|
import { adminFetch } from "@/lib/csrf";
|
||||||
import { ParticipantLimits, SelectField } from "../_components/FormField";
|
import { ParticipantLimits, SelectField } from "../_components/FormField";
|
||||||
|
import { PriceField } from "../_components/PriceField";
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
@@ -100,13 +101,11 @@ function EventSettings({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="sm:max-w-xs">
|
||||||
<label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label>
|
<PriceField
|
||||||
<input
|
label="Цена за занятие"
|
||||||
type="number"
|
value={event.pricePerClass ? `${event.pricePerClass} BYN` : ""}
|
||||||
value={event.pricePerClass}
|
onChange={(v) => onChange({ pricePerClass: parseInt(v) || 0 })}
|
||||||
onChange={(e) => onChange({ pricePerClass: 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 sm:max-w-xs"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,12 +129,10 @@ function EventSettings({
|
|||||||
{event.discountPrice > 0 && (
|
{event.discountPrice > 0 && (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 mt-3">
|
<div className="grid gap-4 sm:grid-cols-2 mt-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-neutral-400 mb-1.5">Цена со скидкой (BYN)</label>
|
<PriceField
|
||||||
<input
|
label="Цена со скидкой"
|
||||||
type="number"
|
value={event.discountPrice ? `${event.discountPrice} BYN` : ""}
|
||||||
value={event.discountPrice}
|
onChange={(v) => onChange({ discountPrice: parseInt(v) || 0 })}
|
||||||
onChange={(e) => onChange({ discountPrice: 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"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronsUpDown } from "lucide-react";
|
||||||
import { SectionEditor } from "../_components/SectionEditor";
|
import { SectionEditor } from "../_components/SectionEditor";
|
||||||
import { InputField, SelectField } from "../_components/FormField";
|
import { InputField, SelectField } from "../_components/FormField";
|
||||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||||
|
import { CollapsibleSection } from "../_components/CollapsibleSection";
|
||||||
|
import { PriceField } from "../_components/PriceField";
|
||||||
|
|
||||||
interface PricingItem {
|
interface PricingItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,96 +26,46 @@ interface PricingData {
|
|||||||
showContactHint?: boolean;
|
showContactHint?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
function PricingContent({ data, update }: { data: PricingData; update: (d: PricingData) => void }) {
|
||||||
const raw = value.replace(/\s*BYN\s*$/i, "").trim();
|
const [sections, setSections] = useState({ subscriptions: true, rental: true, rules: false });
|
||||||
|
const allOpen = sections.subscriptions && sections.rental && sections.rules;
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
const target = !allOpen;
|
||||||
|
setSections({ subscriptions: target, rental: target, rules: target });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSection(key: keyof typeof sections) {
|
||||||
|
setSections(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<label className="block text-sm text-neutral-400 mb-1.5">{label}</label>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex rounded-lg border border-white/10 bg-neutral-800 focus-within:border-gold transition-colors">
|
<div className="grid gap-3 sm:grid-cols-2 flex-1">
|
||||||
<input
|
<InputField
|
||||||
type="text"
|
label="Заголовок секции"
|
||||||
value={raw}
|
value={data.title}
|
||||||
onChange={(e) => {
|
onChange={(v) => update({ ...data, title: v })}
|
||||||
const v = e.target.value;
|
/>
|
||||||
onChange(v ? `${v} BYN` : "");
|
<InputField
|
||||||
}}
|
label="Подзаголовок"
|
||||||
placeholder="0"
|
value={data.subtitle}
|
||||||
className="flex-1 bg-transparent px-4 py-2.5 text-white placeholder-neutral-500 outline-none min-w-0"
|
onChange={(v) => update({ ...data, subtitle: v })}
|
||||||
/>
|
/>
|
||||||
<span className="flex items-center pr-4 text-sm font-medium text-gold select-none">
|
|
||||||
BYN
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CollapsibleSection({
|
|
||||||
title,
|
|
||||||
count,
|
|
||||||
defaultOpen = true,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
count?: number;
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-white/10 bg-neutral-900/30 overflow-hidden">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen(!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"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="text-sm font-semibold text-neutral-200 group-hover:text-white transition-colors">{title}</h3>
|
|
||||||
{count !== undefined && (
|
|
||||||
<span className="text-xs text-neutral-500">{count}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
|
||||||
size={16}
|
|
||||||
className={`text-neutral-500 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
|
||||||
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
|
|
||||||
>
|
|
||||||
<div className="overflow-hidden">
|
|
||||||
<div className="px-5 pb-5 space-y-4">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAll}
|
||||||
|
className="rounded p-1 text-neutral-500 hover:text-white transition-colors ml-3 mt-4"
|
||||||
|
title={allOpen ? "Свернуть все секции" : "Развернуть все секции"}
|
||||||
|
>
|
||||||
|
<ChevronsUpDown size={16} className={`transition-transform duration-200 ${allOpen ? "rotate-90" : ""}`} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PricingEditorPage() {
|
{/* Абонементы */}
|
||||||
return (
|
<CollapsibleSection title="Абонементы" count={data.items.length} isOpen={sections.subscriptions} onToggle={() => toggleSection("subscriptions")}>
|
||||||
<SectionEditor<PricingData> sectionKey="pricing" title="Цены">
|
|
||||||
{(data, update) => (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<InputField
|
|
||||||
label="Заголовок секции"
|
|
||||||
value={data.title}
|
|
||||||
onChange={(v) => update({ ...data, title: v })}
|
|
||||||
/>
|
|
||||||
<InputField
|
|
||||||
label="Подзаголовок"
|
|
||||||
value={data.subtitle}
|
|
||||||
onChange={(v) => update({ ...data, subtitle: v })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Абонементы */}
|
|
||||||
<CollapsibleSection title="Абонементы" count={data.items.length}>
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const itemOptions = data.items
|
const itemOptions = data.items
|
||||||
.map((it, idx) => ({ value: String(idx), label: it.name }))
|
.map((it, idx) => ({ value: String(idx), label: it.name }))
|
||||||
@@ -196,7 +148,7 @@ export default function PricingEditorPage() {
|
|||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* Аренда */}
|
{/* Аренда */}
|
||||||
<CollapsibleSection title="Аренда" count={data.rentalItems.length}>
|
<CollapsibleSection title="Аренда" count={data.rentalItems.length} isOpen={sections.rental} onToggle={() => toggleSection("rental")}>
|
||||||
<InputField
|
<InputField
|
||||||
label="Заголовок"
|
label="Заголовок"
|
||||||
value={data.rentalTitle}
|
value={data.rentalTitle}
|
||||||
@@ -236,7 +188,7 @@ export default function PricingEditorPage() {
|
|||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* Правила */}
|
{/* Правила */}
|
||||||
<CollapsibleSection title="Правила" count={data.rules.length} defaultOpen={false}>
|
<CollapsibleSection title="Правила" count={data.rules.length} isOpen={sections.rules} onToggle={() => toggleSection("rules")}>
|
||||||
<ArrayEditor
|
<ArrayEditor
|
||||||
items={data.rules}
|
items={data.rules}
|
||||||
onChange={(rules) => update({ ...data, rules })}
|
onChange={(rules) => update({ ...data, rules })}
|
||||||
@@ -248,7 +200,13 @@ export default function PricingEditorPage() {
|
|||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PricingEditorPage() {
|
||||||
|
return (
|
||||||
|
<SectionEditor<PricingData> sectionKey="pricing" title="Цены">
|
||||||
|
{(data, update) => <PricingContent data={data} update={update} />}
|
||||||
</SectionEditor>
|
</SectionEditor>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,8 +95,10 @@ function timeToMinutes(timeStr: string): number {
|
|||||||
return t.h * 60 + t.m;
|
return t.h * 60 + t.m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GRID_TOP_PAD = 8; // px — space so the first time label isn't clipped
|
||||||
|
|
||||||
function minutesToY(minutes: number): number {
|
function minutesToY(minutes: number): number {
|
||||||
return ((minutes - HOUR_START * 60) / 60) * HOUR_HEIGHT;
|
return ((minutes - HOUR_START * 60) / 60) * HOUR_HEIGHT + GRID_TOP_PAD;
|
||||||
}
|
}
|
||||||
|
|
||||||
function yToMinutes(y: number): number {
|
function yToMinutes(y: number): number {
|
||||||
@@ -927,12 +929,12 @@ function CalendarGrid({
|
|||||||
{/* Time grid */}
|
{/* Time grid */}
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{/* Time labels */}
|
{/* Time labels */}
|
||||||
<div className="w-14 shrink-0 relative" style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT}px` }}>
|
<div className="w-14 shrink-0 relative" style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT + GRID_TOP_PAD}px` }}>
|
||||||
{hours.slice(0, -1).map((h) => (
|
{hours.slice(0, -1).map((h) => (
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
className="absolute left-0 right-0 text-right pr-2 text-xs text-neutral-500"
|
className="absolute left-0 right-0 text-right pr-2 text-xs text-neutral-500"
|
||||||
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT - 6}px` }}
|
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT + GRID_TOP_PAD - 6}px` }}
|
||||||
>
|
>
|
||||||
{String(h).padStart(2, "0")}:00
|
{String(h).padStart(2, "0")}:00
|
||||||
</div>
|
</div>
|
||||||
@@ -951,7 +953,7 @@ function CalendarGrid({
|
|||||||
key={day.day}
|
key={day.day}
|
||||||
ref={(el) => { columnRefs.current[di] = el; }}
|
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-white/10 relative ${drag ? "cursor-grabbing" : "cursor-pointer"}`}
|
||||||
style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT}px` }}
|
style={{ height: `${TOTAL_HOURS * HOUR_HEIGHT + GRID_TOP_PAD}px` }}
|
||||||
onMouseMove={(e) => {
|
onMouseMove={(e) => {
|
||||||
if (drag) return;
|
if (drag) return;
|
||||||
// Ignore if hovering over a class block
|
// Ignore if hovering over a class block
|
||||||
@@ -978,7 +980,7 @@ function CalendarGrid({
|
|||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
className="absolute left-0 right-0 border-t border-white/5"
|
className="absolute left-0 right-0 border-t border-white/5"
|
||||||
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT}px` }}
|
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT + GRID_TOP_PAD}px` }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{/* Half-hour lines */}
|
{/* Half-hour lines */}
|
||||||
@@ -986,7 +988,7 @@ function CalendarGrid({
|
|||||||
<div
|
<div
|
||||||
key={`${h}-30`}
|
key={`${h}-30`}
|
||||||
className="absolute left-0 right-0 border-t border-white/[0.02]"
|
className="absolute left-0 right-0 border-t border-white/[0.02]"
|
||||||
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT + HOUR_HEIGHT / 2}px` }}
|
style={{ top: `${(h - HOUR_START) * HOUR_HEIGHT + HOUR_HEIGHT / 2 + GRID_TOP_PAD}px` }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,15 @@
|
|||||||
--color-gold: #c9a96e;
|
--color-gold: #c9a96e;
|
||||||
--color-gold-light: #d4b87a;
|
--color-gold-light: #d4b87a;
|
||||||
--color-gold-dark: #a08050;
|
--color-gold-dark: #a08050;
|
||||||
|
--color-surface-deep: #050505;
|
||||||
|
--color-surface-dark: #0a0a0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Base ===== */
|
/* ===== Base ===== */
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -83,3 +86,39 @@ body {
|
|||||||
.admin-scrollbar::-webkit-scrollbar-thumb:hover {
|
.admin-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: rgba(255, 255, 255, 0.3);
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Hide number input spinners ===== */
|
||||||
|
|
||||||
|
input[type="number"]::-webkit-inner-spin-button,
|
||||||
|
input[type="number"]::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Global page scrollbar ===== */
|
||||||
|
|
||||||
|
html {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(201, 169, 110, 0.3) var(--color-surface-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-surface-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(201, 169, 110, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(201, 169, 110, 0.5);
|
||||||
|
}
|
||||||
|
|||||||
+29
-24
@@ -12,7 +12,10 @@ import { BackToTop } from "@/components/ui/BackToTop";
|
|||||||
import { FloatingContact } from "@/components/ui/FloatingContact";
|
import { FloatingContact } from "@/components/ui/FloatingContact";
|
||||||
import { Header } from "@/components/layout/Header";
|
import { Header } from "@/components/layout/Header";
|
||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
|
import { ClientShell } from "@/components/layout/ClientShell";
|
||||||
import { getContent } from "@/lib/content";
|
import { getContent } from "@/lib/content";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
import { OpenDay } from "@/components/sections/OpenDay";
|
import { OpenDay } from "@/components/sections/OpenDay";
|
||||||
import { getActiveOpenDay } from "@/lib/openDay";
|
import { getActiveOpenDay } from "@/lib/openDay";
|
||||||
import { getAllMcRegistrations } from "@/lib/db";
|
import { getAllMcRegistrations } from "@/lib/db";
|
||||||
@@ -27,30 +30,32 @@ export default function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<ClientShell>
|
||||||
<main>
|
<Header />
|
||||||
<Hero data={content.hero} />
|
<main>
|
||||||
{openDayData && <OpenDay data={openDayData} popups={content.popups} />}
|
<Hero data={content.hero} />
|
||||||
<About
|
<About
|
||||||
data={content.about}
|
data={content.about}
|
||||||
stats={{
|
stats={{
|
||||||
trainers: content.team.members.length,
|
trainers: content.team.members.length,
|
||||||
classes: content.classes.items.length,
|
classes: content.classes.items.length,
|
||||||
locations: content.schedule.locations.length,
|
locations: content.schedule.locations.length,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Team data={content.team} schedule={content.schedule.locations} />
|
<Classes data={content.classes} />
|
||||||
<Classes data={content.classes} />
|
<Team data={content.team} schedule={content.schedule.locations} />
|
||||||
<MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} />
|
{openDayData && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team.members} />}
|
||||||
<Schedule data={content.schedule} classItems={content.classes.items} />
|
<Schedule data={content.schedule} classItems={content.classes.items} teamMembers={content.team.members} />
|
||||||
<Pricing data={content.pricing} />
|
<Pricing data={content.pricing} />
|
||||||
<News data={content.news} />
|
<MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} />
|
||||||
<FAQ data={content.faq} />
|
<News data={content.news} />
|
||||||
<Contact data={content.contact} />
|
<FAQ data={content.faq} />
|
||||||
<BackToTop />
|
<Contact data={content.contact} />
|
||||||
<FloatingContact />
|
<BackToTop />
|
||||||
</main>
|
<FloatingContact />
|
||||||
<Footer />
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</ClientShell>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
.surface-base {
|
.surface-base {
|
||||||
@apply bg-neutral-50 text-neutral-900;
|
@apply bg-neutral-50 text-neutral-900;
|
||||||
@apply dark:bg-[#050505] dark:text-neutral-100;
|
@apply dark:bg-[var(--color-surface-deep)] dark:text-neutral-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-muted {
|
.surface-muted {
|
||||||
@apply bg-neutral-100;
|
@apply bg-neutral-100;
|
||||||
@apply dark:bg-[#080808];
|
@apply dark:bg-[var(--color-surface-deep)];
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-glass {
|
.surface-glass {
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
.surface-card {
|
.surface-card {
|
||||||
@apply bg-white/80 backdrop-blur-sm;
|
@apply bg-white/80 backdrop-blur-sm;
|
||||||
@apply dark:bg-[#111] dark:backdrop-blur-sm;
|
@apply dark:bg-neutral-900 dark:backdrop-blur-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Borders ===== */
|
/* ===== Borders ===== */
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
.muted-text {
|
.muted-text {
|
||||||
@apply text-neutral-500;
|
@apply text-neutral-500;
|
||||||
@apply dark:text-neutral-500;
|
@apply dark:text-neutral-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accent-text {
|
.accent-text {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BookingProvider } from "@/contexts/BookingContext";
|
||||||
|
|
||||||
|
/** Client boundary wrapper that provides shared contexts to the page. */
|
||||||
|
export function ClientShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return <BookingProvider>{children}</BookingProvider>;
|
||||||
|
}
|
||||||
@@ -7,12 +7,13 @@ import { BRAND, NAV_LINKS } from "@/lib/constants";
|
|||||||
import { UI_CONFIG } from "@/lib/config";
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
import { HeroLogo } from "@/components/ui/HeroLogo";
|
import { HeroLogo } from "@/components/ui/HeroLogo";
|
||||||
import { SignupModal } from "@/components/ui/SignupModal";
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
|
import { useBooking } from "@/contexts/BookingContext";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [activeSection, setActiveSection] = useState("");
|
const [activeSection, setActiveSection] = useState("");
|
||||||
const [bookingOpen, setBookingOpen] = useState(false);
|
const { bookingOpen, openBooking, closeBooking } = useBooking();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let ticking = false;
|
let ticking = false;
|
||||||
@@ -29,15 +30,6 @@ export function Header() {
|
|||||||
return () => window.removeEventListener("scroll", handleScroll);
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Listen for booking open events from other components
|
|
||||||
useEffect(() => {
|
|
||||||
function onOpenBooking() {
|
|
||||||
setBookingOpen(true);
|
|
||||||
}
|
|
||||||
window.addEventListener("open-booking", onOpenBooking);
|
|
||||||
return () => window.removeEventListener("open-booking", onOpenBooking);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Filter out nav links whose target section doesn't exist on the page
|
// Filter out nav links whose target section doesn't exist on the page
|
||||||
const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS);
|
const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -124,12 +116,6 @@ export function Header() {
|
|||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<button
|
|
||||||
onClick={() => setBookingOpen(true)}
|
|
||||||
className="rounded-full bg-gold px-4 py-1.5 text-sm font-semibold text-black transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/20 cursor-pointer"
|
|
||||||
>
|
|
||||||
Записаться
|
|
||||||
</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 lg:hidden">
|
<div className="flex items-center gap-2 lg:hidden">
|
||||||
@@ -172,7 +158,7 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<SignupModal open={bookingOpen} onClose={() => setBookingOpen(false)} endpoint="/api/group-booking" />
|
<SignupModal open={bookingOpen} onClose={closeBooking} endpoint="/api/group-booking" />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function FAQ({ data: faq }: FAQProps) {
|
|||||||
const hasMore = faq.items.length > VISIBLE_COUNT;
|
const hasMore = faq.items.length > VISIBLE_COUNT;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="faq" className="section-glow relative section-padding bg-neutral-100 dark:bg-[#080808]">
|
<section id="faq" className="section-glow relative section-padding bg-neutral-100 dark:bg-neutral-950">
|
||||||
<div className="section-divider absolute top-0 left-0 right-0" />
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
<div className="section-container">
|
<div className="section-container">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
@@ -41,11 +41,12 @@ export function FAQ({ data: faq }: FAQProps) {
|
|||||||
className={`rounded-xl border transition-all duration-300 ${
|
className={`rounded-xl border transition-all duration-300 ${
|
||||||
isOpen
|
isOpen
|
||||||
? "border-gold/30 bg-gradient-to-br from-gold/[0.06] via-transparent to-gold/[0.03] shadow-md shadow-gold/5"
|
? "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-[#0a0a0a] dark:hover:border-white/[0.12]"
|
: "border-neutral-200 bg-white hover:border-neutral-300 dark:border-white/[0.06] dark:bg-neutral-950 dark:hover:border-white/[0.12]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggle(idx)}
|
onClick={() => toggle(idx)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
className="flex w-full items-center gap-3 px-5 py-4 text-left cursor-pointer"
|
className="flex w-full items-center gap-3 px-5 py-4 text-left cursor-pointer"
|
||||||
>
|
>
|
||||||
{/* Number badge */}
|
{/* Number badge */}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/Button";
|
|||||||
import { FloatingHearts } from "@/components/ui/FloatingHearts";
|
import { FloatingHearts } from "@/components/ui/FloatingHearts";
|
||||||
import { HeroLogo } from "@/components/ui/HeroLogo";
|
import { HeroLogo } from "@/components/ui/HeroLogo";
|
||||||
import type { SiteContent } from "@/types/content";
|
import type { SiteContent } from "@/types/content";
|
||||||
|
import { useBooking } from "@/contexts/BookingContext";
|
||||||
|
|
||||||
const DEFAULT_VIDEOS = ["/video/ira.mp4", "/video/nadezda.mp4", "/video/nastya-2.mp4"];
|
const DEFAULT_VIDEOS = ["/video/ira.mp4", "/video/nadezda.mp4", "/video/nastya-2.mp4"];
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ interface HeroProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Hero({ data: hero }: HeroProps) {
|
export function Hero({ data: hero }: HeroProps) {
|
||||||
|
const { openBooking } = useBooking();
|
||||||
const sectionRef = useRef<HTMLElement>(null);
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
const scrolledRef = useRef(false);
|
const scrolledRef = useRef(false);
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -78,7 +80,7 @@ export function Hero({ data: hero }: HeroProps) {
|
|||||||
}, [scrollToNext]);
|
}, [scrollToNext]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="hero" ref={sectionRef} className="relative flex min-h-svh items-center justify-center overflow-hidden bg-[#050505]">
|
<section id="hero" ref={sectionRef} className="relative flex min-h-svh items-center justify-center overflow-hidden bg-neutral-950">
|
||||||
{/* Videos render only after hydration to avoid SSR mismatch */}
|
{/* Videos render only after hydration to avoid SSR mismatch */}
|
||||||
{mounted && (
|
{mounted && (
|
||||||
<>
|
<>
|
||||||
@@ -140,7 +142,7 @@ export function Hero({ data: hero }: HeroProps) {
|
|||||||
{/* Loading overlay — covers videos but not content */}
|
{/* Loading overlay — covers videos but not content */}
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className="absolute inset-0 z-[5] bg-[#050505] pointer-events-none transition-opacity duration-1000"
|
className="absolute inset-0 z-[5] bg-neutral-950 pointer-events-none transition-opacity duration-1000"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Floating hearts */}
|
{/* Floating hearts */}
|
||||||
@@ -162,12 +164,12 @@ export function Hero({ data: hero }: HeroProps) {
|
|||||||
<span className="gradient-text">{hero.headline}</span>
|
<span className="gradient-text">{hero.headline}</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="hero-subtitle mx-auto mt-6 max-w-lg text-lg text-[#b8a080] sm:text-xl">
|
<p className="hero-subtitle mx-auto mt-6 max-w-lg text-lg text-gold/70 sm:text-xl">
|
||||||
{hero.subheadline}
|
{hero.subheadline}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="hero-cta mt-12">
|
<div className="hero-cta mt-12">
|
||||||
<Button size="lg" onClick={() => window.dispatchEvent(new Event("open-booking"))}>
|
<Button size="lg" onClick={openBooking}>
|
||||||
{hero.ctaText}
|
{hero.ctaText}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,30 +7,12 @@ import { SectionHeading } from "@/components/ui/SectionHeading";
|
|||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { NewsModal } from "@/components/ui/NewsModal";
|
import { NewsModal } from "@/components/ui/NewsModal";
|
||||||
import type { SiteContent, NewsItem } from "@/types/content";
|
import type { SiteContent, NewsItem } from "@/types/content";
|
||||||
|
import { formatDateRu } from "@/lib/formatting";
|
||||||
|
|
||||||
interface NewsProps {
|
interface NewsProps {
|
||||||
data: SiteContent["news"];
|
data: SiteContent["news"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
try {
|
|
||||||
const d = new Date(iso);
|
|
||||||
const date = d.toLocaleDateString("ru-RU", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
// Show time only if it's a full ISO timestamp (not just date)
|
|
||||||
if (iso.includes("T")) {
|
|
||||||
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
|
||||||
return `${date}, ${time}`;
|
|
||||||
}
|
|
||||||
return date;
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function FeaturedArticle({
|
function FeaturedArticle({
|
||||||
item,
|
item,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -52,7 +34,10 @@ function FeaturedArticle({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
sizes="(min-width: 768px) 80vw, 100vw"
|
sizes="(min-width: 768px) 80vw, 100vw"
|
||||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||||
style={{ objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%` }}
|
style={{
|
||||||
|
objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%`,
|
||||||
|
transform: `scale(${item.imageZoom ?? 1})`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +47,7 @@ function FeaturedArticle({
|
|||||||
>
|
>
|
||||||
<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 bg-white/15 px-3 py-1 text-xs font-medium text-white/80 backdrop-blur-sm">
|
||||||
<Calendar size={12} />
|
<Calendar size={12} />
|
||||||
{formatDate(item.date)}
|
{formatDateRu(item.date)}
|
||||||
</span>
|
</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 text-white leading-tight">
|
||||||
{item.title}
|
{item.title}
|
||||||
@@ -96,13 +81,16 @@ function CompactArticle({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
sizes="112px"
|
sizes="112px"
|
||||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
style={{ objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%` }}
|
style={{
|
||||||
|
objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%`,
|
||||||
|
transform: `scale(${item.imageZoom ?? 1})`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-xs text-neutral-400 dark:text-white/30">
|
<span className="text-xs text-neutral-400 dark:text-white/30">
|
||||||
{formatDate(item.date)}
|
{formatDateRu(item.date)}
|
||||||
</span>
|
</span>
|
||||||
<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">
|
<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">
|
||||||
{item.title}
|
{item.title}
|
||||||
@@ -115,11 +103,11 @@ function CompactArticle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_VISIBLE = 4;
|
const PER_PAGE = 4;
|
||||||
|
|
||||||
export function News({ data }: NewsProps) {
|
export function News({ data }: NewsProps) {
|
||||||
const [selected, setSelected] = useState<NewsItem | null>(null);
|
const [selected, setSelected] = useState<NewsItem | null>(null);
|
||||||
const [showAll, setShowAll] = useState(false);
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
if (!data.items || data.items.length === 0) return null;
|
if (!data.items || data.items.length === 0) return null;
|
||||||
|
|
||||||
@@ -129,8 +117,8 @@ export function News({ data }: NewsProps) {
|
|||||||
.sort((a, b) => (b.date || "").localeCompare(a.date || ""));
|
.sort((a, b) => (b.date || "").localeCompare(a.date || ""));
|
||||||
if (sorted.length === 0) return null;
|
if (sorted.length === 0) return null;
|
||||||
const [featured, ...rest] = sorted;
|
const [featured, ...rest] = sorted;
|
||||||
const visibleRest = showAll ? rest : rest.slice(0, INITIAL_VISIBLE - 1);
|
const totalPages = Math.max(1, Math.ceil(rest.length / PER_PAGE));
|
||||||
const hasMore = rest.length > INITIAL_VISIBLE - 1 && !showAll;
|
const visibleRest = rest.slice(page * PER_PAGE, (page + 1) * PER_PAGE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="news" className="section-glow relative section-padding">
|
<section id="news" className="section-glow relative section-padding">
|
||||||
@@ -162,17 +150,48 @@ export function News({ data }: NewsProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasMore && (
|
{totalPages > 1 && (
|
||||||
<Reveal>
|
<div className="flex items-center justify-center gap-2">
|
||||||
<div className="text-center">
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPage((p) => Math.max(0, p - 1));
|
||||||
|
const el = document.getElementById("news");
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAll(true)}
|
key={i}
|
||||||
className="rounded-full border border-white/10 bg-white/[0.03] px-6 py-2.5 text-sm font-medium text-neutral-400 hover:text-white hover:border-white/25 transition-colors cursor-pointer"
|
onClick={() => {
|
||||||
|
setPage(i);
|
||||||
|
const el = document.getElementById("news");
|
||||||
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}}
|
||||||
|
className={`h-8 w-8 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"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Показать ещё ({rest.length - INITIAL_VISIBLE + 1})
|
{i + 1}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
))}
|
||||||
</Reveal>
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPage((p) => Math.min(totalPages - 1, p + 1));
|
||||||
|
const el = document.getElementById("news");
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { Calendar, Users, Sparkles } from "lucide-react";
|
import Image from "next/image";
|
||||||
|
import { Calendar, Sparkles, User } from "lucide-react";
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { SignupModal } from "@/components/ui/SignupModal";
|
import { SignupModal } from "@/components/ui/SignupModal";
|
||||||
@@ -14,6 +15,7 @@ interface OpenDayProps {
|
|||||||
classes: OpenDayClass[];
|
classes: OpenDayClass[];
|
||||||
};
|
};
|
||||||
popups?: SiteContent["popups"];
|
popups?: SiteContent["popups"];
|
||||||
|
teamMembers?: { name: string; image: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateRu(dateStr: string): string {
|
function formatDateRu(dateStr: string): string {
|
||||||
@@ -25,10 +27,20 @@ function formatDateRu(dateStr: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpenDay({ data, popups }: OpenDayProps) {
|
export function OpenDay({ data, popups, teamMembers }: OpenDayProps) {
|
||||||
const { event, classes } = data;
|
const { event, classes } = data;
|
||||||
const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
|
const [signup, setSignup] = useState<{ classId: number; label: string } | null>(null);
|
||||||
|
|
||||||
|
const trainerPhotos = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
if (teamMembers) {
|
||||||
|
for (const m of teamMembers) {
|
||||||
|
if (m.image) map[m.name] = m.image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [teamMembers]);
|
||||||
|
|
||||||
// Group classes by hall
|
// Group classes by hall
|
||||||
const hallGroups = useMemo(() => {
|
const hallGroups = useMemo(() => {
|
||||||
const groups: Record<string, OpenDayClass[]> = {};
|
const groups: Record<string, OpenDayClass[]> = {};
|
||||||
@@ -48,7 +60,8 @@ export function OpenDay({ data, popups }: OpenDayProps) {
|
|||||||
if (classes.length === 0) return null;
|
if (classes.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="open-day" className="py-10 sm:py-14">
|
<section id="open-day" className="section-glow relative py-10 sm:py-14">
|
||||||
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
<div className="mx-auto max-w-6xl px-4">
|
<div className="mx-auto max-w-6xl px-4">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SectionHeading centered>{event.title}</SectionHeading>
|
<SectionHeading centered>{event.title}</SectionHeading>
|
||||||
@@ -99,6 +112,7 @@ export function OpenDay({ data, popups }: OpenDayProps) {
|
|||||||
cls={cls}
|
cls={cls}
|
||||||
maxParticipants={event.maxParticipants}
|
maxParticipants={event.maxParticipants}
|
||||||
onSignup={setSignup}
|
onSignup={setSignup}
|
||||||
|
trainerPhoto={trainerPhotos[cls.trainer]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -116,6 +130,7 @@ export function OpenDay({ data, popups }: OpenDayProps) {
|
|||||||
key={cls.id}
|
key={cls.id}
|
||||||
cls={cls}
|
cls={cls}
|
||||||
onSignup={setSignup}
|
onSignup={setSignup}
|
||||||
|
trainerPhoto={trainerPhotos[cls.trainer]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -148,23 +163,26 @@ function ClassCard({
|
|||||||
cls,
|
cls,
|
||||||
maxParticipants = 0,
|
maxParticipants = 0,
|
||||||
onSignup,
|
onSignup,
|
||||||
|
trainerPhoto,
|
||||||
}: {
|
}: {
|
||||||
cls: OpenDayClass;
|
cls: OpenDayClass;
|
||||||
maxParticipants?: number;
|
maxParticipants?: number;
|
||||||
onSignup: (info: { classId: number; label: string }) => void;
|
onSignup: (info: { classId: number; label: string }) => void;
|
||||||
|
trainerPhoto?: string;
|
||||||
}) {
|
}) {
|
||||||
const label = `${cls.style} · ${cls.trainer} · ${cls.startTime}–${cls.endTime}`;
|
const label = `${cls.style} · ${cls.trainer} · ${cls.startTime}–${cls.endTime}`;
|
||||||
|
|
||||||
if (cls.cancelled) {
|
if (cls.cancelled) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-white/5 bg-neutral-900/30 p-4 opacity-50">
|
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3 sm:p-4 opacity-50">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
<span className="text-xs text-neutral-500">{cls.startTime}–{cls.endTime}</span>
|
<span className="rounded-md bg-neutral-800 px-2 py-0.5 text-[11px] font-bold text-neutral-500">
|
||||||
<p className="text-sm text-neutral-500 line-through">{cls.style}</p>
|
{cls.startTime}–{cls.endTime}
|
||||||
<p className="text-xs text-neutral-600">{cls.trainer}</p>
|
</span>
|
||||||
|
<p className="text-sm text-neutral-500 line-through">{cls.trainer} · {cls.style}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">
|
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2.5 py-0.5 font-medium">
|
||||||
Отменено
|
Отменено
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,27 +193,72 @@ function ClassCard({
|
|||||||
const isFull = maxParticipants > 0 && cls.bookingCount >= maxParticipants;
|
const isFull = maxParticipants > 0 && cls.bookingCount >= maxParticipants;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-xl border p-4 transition-all ${isFull ? "border-white/5 bg-neutral-900/50" : "border-white/10 bg-neutral-900 hover:border-gold/20"}`}>
|
<div className={`rounded-xl border transition-all ${
|
||||||
<div className="flex items-start justify-between gap-3">
|
isFull
|
||||||
<div className="flex-1 min-w-0">
|
? "border-white/[0.04] bg-white/[0.01]"
|
||||||
<span className="text-xs text-gold font-medium">{cls.startTime}–{cls.endTime}</span>
|
: "border-white/[0.06] bg-white/[0.02] hover:border-white/[0.12] hover:bg-white/[0.04]"
|
||||||
<p className="text-sm font-medium text-white mt-0.5">{cls.style}</p>
|
}`}>
|
||||||
<p className="text-xs text-neutral-400 flex items-center gap-1 mt-0.5">
|
<div className="flex items-start gap-3 p-3 sm:p-4">
|
||||||
<Users size={10} />
|
{/* Trainer photo */}
|
||||||
{cls.trainer}
|
<button
|
||||||
</p>
|
onClick={() => {
|
||||||
{maxParticipants > 0 && (
|
window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer }));
|
||||||
<p className={`text-[10px] mt-1 ${isFull ? "text-amber-400" : "text-neutral-500"}`}>
|
}}
|
||||||
{cls.bookingCount}/{maxParticipants} мест
|
aria-label={`Профиль тренера: ${cls.trainer}`}
|
||||||
</p>
|
className="relative flex items-center justify-center h-10 w-10 rounded-full overflow-hidden shrink-0 ring-1 ring-white/10 hover:ring-gold/30 transition-all cursor-pointer mt-0.5"
|
||||||
|
title={`Подробнее о ${cls.trainer}`}
|
||||||
|
>
|
||||||
|
{trainerPhoto ? (
|
||||||
|
<Image src={trainerPhoto} alt={cls.trainer} fill className="object-cover" sizes="40px" />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full w-full bg-white/[0.06]">
|
||||||
|
<User size={16} className="text-white/40" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
{/* Trainer name — clickable to bio */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer }));
|
||||||
|
}}
|
||||||
|
className="text-sm font-semibold text-white/90 hover:text-gold transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{cls.trainer}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Time + style */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-md bg-gold/10 px-2 py-0.5 text-[11px] font-bold text-gold min-w-[80px] text-center">
|
||||||
|
{cls.startTime}–{cls.endTime}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-white/60">{cls.style}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{maxParticipants > 0 && (
|
||||||
|
<span className={`rounded-full px-2.5 py-0.5 text-[10px] 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"
|
||||||
|
}`}>
|
||||||
|
{cls.bookingCount}/{maxParticipants} мест
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Book button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => onSignup({ classId: cls.id, label })}
|
onClick={() => onSignup({ classId: cls.id, label })}
|
||||||
className={`shrink-0 rounded-full px-4 py-2 text-xs font-medium transition-colors cursor-pointer ${
|
className={`shrink-0 self-center rounded-xl px-4 py-2 text-xs font-semibold transition-all cursor-pointer ${
|
||||||
isFull
|
isFull
|
||||||
? "bg-amber-500/10 border border-amber-500/20 text-amber-400 hover:bg-amber-500/20"
|
? "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/20 text-gold hover:bg-gold/20"
|
: "bg-gold/10 border border-gold/25 text-gold hover:bg-gold/20 hover:border-gold/40"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isFull ? "Лист ожидания" : "Записаться"}
|
{isFull ? "Лист ожидания" : "Записаться"}
|
||||||
|
|||||||
+112
-120
@@ -25,7 +25,7 @@ export function Pricing({ data: pricing }: PricingProps) {
|
|||||||
const regularItems = pricing.items.filter((item) => !item.featured);
|
const regularItems = pricing.items.filter((item) => !item.featured);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="pricing" className="section-glow relative section-padding bg-neutral-50 dark:bg-[#050505]">
|
<section id="pricing" className="section-glow relative section-padding bg-neutral-50 dark:bg-neutral-950">
|
||||||
<div className="section-divider absolute top-0 left-0 right-0" />
|
<div className="section-divider absolute top-0 left-0 right-0" />
|
||||||
<div className="section-container">
|
<div className="section-container">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
@@ -53,136 +53,128 @@ export function Pricing({ data: pricing }: PricingProps) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Prices tab */}
|
{/* Prices tab */}
|
||||||
{activeTab === "prices" && (
|
<div className={activeTab === "prices" ? "block" : "hidden"}>
|
||||||
<Reveal>
|
<div className="mx-auto mt-10 max-w-4xl">
|
||||||
<div className="mx-auto mt-10 max-w-4xl">
|
<p className="mb-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
<p className="mb-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
{pricing.subtitle}
|
||||||
{pricing.subtitle}
|
</p>
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Cards grid */}
|
{/* Cards grid */}
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{regularItems.map((item, i) => {
|
{regularItems.map((item, i) => {
|
||||||
const isPopular = item.popular ?? false;
|
const isPopular = item.popular ?? false;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`group relative rounded-2xl border p-5 transition-all duration-300 ${
|
className={`group relative rounded-2xl border p-5 transition-all duration-300 ${
|
||||||
isPopular
|
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-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-[#0a0a0a]"
|
: "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-neutral-950"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Popular badge */}
|
{/* Popular badge */}
|
||||||
{isPopular && (
|
{isPopular && (
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-gold px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-black shadow-md shadow-gold/30">
|
<span className="inline-flex items-center gap-1 rounded-full bg-gold px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-black shadow-md shadow-gold/30">
|
||||||
<Sparkles size={10} />
|
<Sparkles size={10} />
|
||||||
Популярный
|
Популярный
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={isPopular ? "mt-1" : ""}>
|
||||||
|
{/* Name */}
|
||||||
|
<p className={`text-sm font-medium ${isPopular ? "text-gold-dark dark:text-gold-light" : "text-neutral-700 dark:text-neutral-300"}`}>
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Note */}
|
||||||
|
{item.note && (
|
||||||
|
<p className="mt-1 text-xs text-neutral-400 dark:text-neutral-500">
|
||||||
|
{item.note}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={isPopular ? "mt-1" : ""}>
|
{/* Price */}
|
||||||
{/* Name */}
|
<p className={`mt-3 font-display text-xl sm:text-2xl font-bold ${isPopular ? "text-gold" : "text-neutral-900 dark:text-white"}`}>
|
||||||
<p className={`text-sm font-medium ${isPopular ? "text-gold-dark dark:text-gold-light" : "text-neutral-700 dark:text-neutral-300"}`}>
|
{item.price}
|
||||||
{item.name}
|
</p>
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Note */}
|
|
||||||
{item.note && (
|
|
||||||
<p className="mt-1 text-xs text-neutral-400 dark:text-neutral-500">
|
|
||||||
{item.note}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Price */}
|
|
||||||
<p className={`mt-3 font-display text-2xl font-bold ${isPopular ? "text-gold" : "text-neutral-900 dark:text-white"}`}>
|
|
||||||
{item.price}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Featured — big card */}
|
|
||||||
{featuredItem && (
|
|
||||||
<div className="mt-6 w-full team-card-glitter rounded-2xl border border-gold/30 bg-gradient-to-r from-gold/10 via-gold/5 to-gold/10 dark:from-gold/[0.06] dark:via-transparent dark:to-gold/[0.06] p-6 sm:p-8">
|
|
||||||
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
|
||||||
<div className="text-center sm:text-left">
|
|
||||||
<div className="flex items-center justify-center gap-2 sm:justify-start">
|
|
||||||
<Crown size={18} className="text-gold" />
|
|
||||||
<p className="text-lg font-bold text-neutral-900 dark:text-white">
|
|
||||||
{featuredItem.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{featuredItem.note && (
|
|
||||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
|
||||||
{featuredItem.note}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="shrink-0 font-display text-3xl font-bold text-gold">
|
|
||||||
{featuredItem.price}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
{/* Featured — big card */}
|
||||||
</Reveal>
|
{featuredItem && (
|
||||||
)}
|
<div className="mt-6 w-full team-card-glitter rounded-2xl border border-gold/30 bg-gradient-to-r from-gold/10 via-gold/5 to-gold/10 dark:from-gold/[0.06] dark:via-transparent dark:to-gold/[0.06] p-6 sm:p-8">
|
||||||
|
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
||||||
{/* Rental tab */}
|
<div className="text-center sm:text-left">
|
||||||
{activeTab === "rental" && (
|
<div className="flex items-center justify-center gap-2 sm:justify-start">
|
||||||
<Reveal>
|
<Crown size={18} className="text-gold" />
|
||||||
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
<p className="text-lg font-bold text-neutral-900 dark:text-white">
|
||||||
{pricing.rentalItems.map((item, i) => (
|
{featuredItem.name}
|
||||||
<div
|
</p>
|
||||||
key={i}
|
</div>
|
||||||
className="flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
{featuredItem.note && (
|
||||||
>
|
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
<div>
|
{featuredItem.note}
|
||||||
<p className="font-medium text-neutral-900 dark:text-white">
|
|
||||||
{item.name}
|
|
||||||
</p>
|
|
||||||
{item.note && (
|
|
||||||
<p className="mt-0.5 text-sm text-neutral-500 dark:text-neutral-400">
|
|
||||||
{item.note}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 font-display text-xl font-bold text-gold-dark dark:text-gold-light">
|
<p className="shrink-0 font-display text-2xl sm:text-3xl font-bold text-gold">
|
||||||
{item.price}
|
{featuredItem.price}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Rules tab */}
|
|
||||||
{activeTab === "rules" && (
|
|
||||||
<Reveal>
|
|
||||||
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
|
||||||
{pricing.rules.map((rule, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex gap-4 rounded-2xl border border-neutral-200 bg-white px-5 py-4 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
|
||||||
>
|
|
||||||
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
|
|
||||||
{i + 1}
|
|
||||||
</span>
|
|
||||||
<p className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
|
|
||||||
{rule}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Reveal>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{/* Rental tab */}
|
||||||
|
<div className={activeTab === "rental" ? "block" : "hidden"}>
|
||||||
|
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
||||||
|
{pricing.rentalItems.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 dark:border-white/[0.06] dark:bg-neutral-950"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-neutral-900 dark:text-white">
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
{item.note && (
|
||||||
|
<p className="mt-0.5 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{item.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 font-display text-xl font-bold text-gold-dark dark:text-gold-light">
|
||||||
|
{item.price}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rules tab */}
|
||||||
|
<div className={activeTab === "rules" ? "block" : "hidden"}>
|
||||||
|
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
||||||
|
{pricing.rules.map((rule, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex gap-4 rounded-2xl border border-neutral-200 bg-white px-5 py-4 dark:border-white/[0.06] dark:bg-neutral-950"
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
|
||||||
|
{rule}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { ScheduleFilters } from "./schedule/ScheduleFilters";
|
|||||||
import { MobileSchedule } from "./schedule/MobileSchedule";
|
import { MobileSchedule } from "./schedule/MobileSchedule";
|
||||||
import { GroupView } from "./schedule/GroupView";
|
import { GroupView } from "./schedule/GroupView";
|
||||||
import { buildTypeDots, shortAddress, startTimeMinutes, TIME_PRESETS } from "./schedule/constants";
|
import { buildTypeDots, shortAddress, startTimeMinutes, TIME_PRESETS } from "./schedule/constants";
|
||||||
import type { StatusFilter, TimeFilter, ScheduleDayMerged, ScheduleClassWithLocation } from "./schedule/constants";
|
import type { StatusTag, TimeFilter, ScheduleDayMerged, ScheduleClassWithLocation } from "./schedule/constants";
|
||||||
import type { SiteContent } from "@/types/content";
|
import type { SiteContent } from "@/types/content";
|
||||||
|
|
||||||
type ViewMode = "days" | "groups";
|
type ViewMode = "days" | "groups";
|
||||||
@@ -20,8 +20,8 @@ interface ScheduleState {
|
|||||||
locationMode: LocationMode;
|
locationMode: LocationMode;
|
||||||
viewMode: ViewMode;
|
viewMode: ViewMode;
|
||||||
filterTrainer: string | null;
|
filterTrainer: string | null;
|
||||||
filterType: string | null;
|
filterTypes: Set<string>;
|
||||||
filterStatus: StatusFilter;
|
filterStatusSet: Set<StatusTag>;
|
||||||
filterTime: TimeFilter;
|
filterTime: TimeFilter;
|
||||||
filterDaySet: Set<string>;
|
filterDaySet: Set<string>;
|
||||||
bookingGroup: string | null;
|
bookingGroup: string | null;
|
||||||
@@ -31,8 +31,8 @@ type ScheduleAction =
|
|||||||
| { type: "SET_LOCATION"; mode: LocationMode }
|
| { type: "SET_LOCATION"; mode: LocationMode }
|
||||||
| { type: "SET_VIEW"; mode: ViewMode }
|
| { type: "SET_VIEW"; mode: ViewMode }
|
||||||
| { type: "SET_TRAINER"; value: string | null }
|
| { type: "SET_TRAINER"; value: string | null }
|
||||||
| { type: "SET_TYPE"; value: string | null }
|
| { type: "TOGGLE_TYPE"; value: string }
|
||||||
| { type: "SET_STATUS"; value: StatusFilter }
|
| { type: "TOGGLE_STATUS"; value: StatusTag }
|
||||||
| { type: "SET_TIME"; value: TimeFilter }
|
| { type: "SET_TIME"; value: TimeFilter }
|
||||||
| { type: "TOGGLE_DAY"; day: string }
|
| { type: "TOGGLE_DAY"; day: string }
|
||||||
| { type: "SET_BOOKING"; value: string | null }
|
| { type: "SET_BOOKING"; value: string | null }
|
||||||
@@ -40,10 +40,10 @@ type ScheduleAction =
|
|||||||
|
|
||||||
const initialState: ScheduleState = {
|
const initialState: ScheduleState = {
|
||||||
locationMode: "all",
|
locationMode: "all",
|
||||||
viewMode: "days",
|
viewMode: "groups",
|
||||||
filterTrainer: null,
|
filterTrainer: null,
|
||||||
filterType: null,
|
filterTypes: new Set(),
|
||||||
filterStatus: "all",
|
filterStatusSet: new Set(),
|
||||||
filterTime: "all",
|
filterTime: "all",
|
||||||
filterDaySet: new Set(),
|
filterDaySet: new Set(),
|
||||||
bookingGroup: null,
|
bookingGroup: null,
|
||||||
@@ -57,10 +57,18 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
|
|||||||
return { ...state, viewMode: action.mode };
|
return { ...state, viewMode: action.mode };
|
||||||
case "SET_TRAINER":
|
case "SET_TRAINER":
|
||||||
return { ...state, filterTrainer: action.value };
|
return { ...state, filterTrainer: action.value };
|
||||||
case "SET_TYPE":
|
case "TOGGLE_TYPE": {
|
||||||
return { ...state, filterType: action.value };
|
const next = new Set(state.filterTypes);
|
||||||
case "SET_STATUS":
|
if (next.has(action.value)) next.delete(action.value);
|
||||||
return { ...state, filterStatus: action.value };
|
else next.add(action.value);
|
||||||
|
return { ...state, filterTypes: next };
|
||||||
|
}
|
||||||
|
case "TOGGLE_STATUS": {
|
||||||
|
const next = new Set(state.filterStatusSet);
|
||||||
|
if (next.has(action.value)) next.delete(action.value);
|
||||||
|
else next.add(action.value);
|
||||||
|
return { ...state, filterStatusSet: next };
|
||||||
|
}
|
||||||
case "SET_TIME":
|
case "SET_TIME":
|
||||||
return { ...state, filterTime: action.value };
|
return { ...state, filterTime: action.value };
|
||||||
case "TOGGLE_DAY": {
|
case "TOGGLE_DAY": {
|
||||||
@@ -72,18 +80,19 @@ function scheduleReducer(state: ScheduleState, action: ScheduleAction): Schedule
|
|||||||
case "SET_BOOKING":
|
case "SET_BOOKING":
|
||||||
return { ...state, bookingGroup: action.value };
|
return { ...state, bookingGroup: action.value };
|
||||||
case "CLEAR_FILTERS":
|
case "CLEAR_FILTERS":
|
||||||
return { ...state, filterTrainer: null, filterType: null, filterStatus: "all", filterTime: "all", filterDaySet: new Set() };
|
return { ...state, filterTrainer: null, filterTypes: new Set(), filterStatusSet: new Set(), filterTime: "all", filterDaySet: new Set() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScheduleProps {
|
interface ScheduleProps {
|
||||||
data: SiteContent["schedule"];
|
data: SiteContent["schedule"];
|
||||||
classItems?: { name: string; color?: string }[];
|
classItems?: { name: string; color?: string }[];
|
||||||
|
teamMembers?: { name: string; image: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
export function Schedule({ data: schedule, classItems, teamMembers }: ScheduleProps) {
|
||||||
const [state, dispatch] = useReducer(scheduleReducer, initialState);
|
const [state, dispatch] = useReducer(scheduleReducer, initialState);
|
||||||
const { locationMode, viewMode, filterTrainer, filterType, filterStatus, filterTime, filterDaySet, bookingGroup } = state;
|
const { locationMode, viewMode, filterTrainer, filterTypes, filterStatusSet, filterTime, filterDaySet, bookingGroup } = state;
|
||||||
|
|
||||||
const isAllMode = locationMode === "all";
|
const isAllMode = locationMode === "all";
|
||||||
|
|
||||||
@@ -93,8 +102,8 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setFilterTrainer = useCallback((value: string | null) => dispatch({ type: "SET_TRAINER", value }), []);
|
const setFilterTrainer = useCallback((value: string | null) => dispatch({ type: "SET_TRAINER", value }), []);
|
||||||
const setFilterType = useCallback((value: string | null) => dispatch({ type: "SET_TYPE", value }), []);
|
const toggleFilterType = useCallback((value: string) => dispatch({ type: "TOGGLE_TYPE", value }), []);
|
||||||
const setFilterStatus = useCallback((value: StatusFilter) => dispatch({ type: "SET_STATUS", value }), []);
|
const toggleFilterStatus = useCallback((value: StatusTag) => dispatch({ type: "TOGGLE_STATUS", value }), []);
|
||||||
const setFilterTime = useCallback((value: TimeFilter) => dispatch({ type: "SET_TIME", value }), []);
|
const setFilterTime = useCallback((value: TimeFilter) => dispatch({ type: "SET_TIME", value }), []);
|
||||||
|
|
||||||
const setFilterTrainerFromCard = useCallback((trainer: string | null) => {
|
const setFilterTrainerFromCard = useCallback((trainer: string | null) => {
|
||||||
@@ -102,13 +111,23 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
if (trainer) scrollToSchedule();
|
if (trainer) scrollToSchedule();
|
||||||
}, [scrollToSchedule]);
|
}, [scrollToSchedule]);
|
||||||
|
|
||||||
const setFilterTypeFromCard = useCallback((type: string | null) => {
|
const toggleFilterTypeFromCard = useCallback((type: string) => {
|
||||||
dispatch({ type: "SET_TYPE", value: type });
|
dispatch({ type: "TOGGLE_TYPE", value: type });
|
||||||
if (type) scrollToSchedule();
|
scrollToSchedule();
|
||||||
}, [scrollToSchedule]);
|
}, [scrollToSchedule]);
|
||||||
|
|
||||||
const typeDots = useMemo(() => buildTypeDots(classItems), [classItems]);
|
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]);
|
||||||
|
|
||||||
// Build days: either from one location or merged from all
|
// Build days: either from one location or merged from all
|
||||||
const activeDays: ScheduleDayMerged[] = useMemo(() => {
|
const activeDays: ScheduleDayMerged[] = useMemo(() => {
|
||||||
if (locationMode !== "all") {
|
if (locationMode !== "all") {
|
||||||
@@ -175,7 +194,7 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const filteredDays: ScheduleDayMerged[] = useMemo(() => {
|
const filteredDays: ScheduleDayMerged[] = useMemo(() => {
|
||||||
const noFilter = !filterTrainer && !filterType && filterStatus === "all" && filterTime === "all" && filterDaySet.size === 0;
|
const noFilter = !filterTrainer && filterTypes.size === 0 && filterStatusSet.size === 0 && filterTime === "all" && filterDaySet.size === 0;
|
||||||
if (noFilter) return activeDays;
|
if (noFilter) return activeDays;
|
||||||
|
|
||||||
// First filter by day names if any selected
|
// First filter by day names if any selected
|
||||||
@@ -189,10 +208,10 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
classes: day.classes.filter(
|
classes: day.classes.filter(
|
||||||
(cls) =>
|
(cls) =>
|
||||||
(!filterTrainer || cls.trainer === filterTrainer) &&
|
(!filterTrainer || cls.trainer === filterTrainer) &&
|
||||||
(!filterType || cls.type === filterType) &&
|
(filterTypes.size === 0 || filterTypes.has(cls.type)) &&
|
||||||
(filterStatus === "all" ||
|
(filterStatusSet.size === 0 ||
|
||||||
(filterStatus === "hasSlots" && cls.hasSlots) ||
|
(filterStatusSet.has("hasSlots") && cls.hasSlots) ||
|
||||||
(filterStatus === "recruiting" && cls.recruiting)) &&
|
(filterStatusSet.has("recruiting") && cls.recruiting)) &&
|
||||||
(!activeTimeRange || (() => {
|
(!activeTimeRange || (() => {
|
||||||
const m = startTimeMinutes(cls.time);
|
const m = startTimeMinutes(cls.time);
|
||||||
return m >= activeTimeRange[0] && m < activeTimeRange[1];
|
return m >= activeTimeRange[0] && m < activeTimeRange[1];
|
||||||
@@ -200,9 +219,9 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
.filter((day) => day.classes.length > 0);
|
.filter((day) => day.classes.length > 0);
|
||||||
}, [activeDays, filterTrainer, filterType, filterStatus, filterTime, activeTimeRange, filterDaySet]);
|
}, [activeDays, filterTrainer, filterTypes, filterStatusSet, filterTime, activeTimeRange, filterDaySet]);
|
||||||
|
|
||||||
const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all" || filterTime !== "all" || filterDaySet.size > 0);
|
const hasActiveFilter = !!(filterTrainer || filterTypes.size > 0 || filterStatusSet.size > 0 || filterTime !== "all" || filterDaySet.size > 0);
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
dispatch({ type: "CLEAR_FILTERS" });
|
dispatch({ type: "CLEAR_FILTERS" });
|
||||||
@@ -327,11 +346,11 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
types={types}
|
types={types}
|
||||||
hasAnySlots={hasAnySlots}
|
hasAnySlots={hasAnySlots}
|
||||||
hasAnyRecruiting={hasAnyRecruiting}
|
hasAnyRecruiting={hasAnyRecruiting}
|
||||||
filterType={filterType}
|
filterTypes={filterTypes}
|
||||||
setFilterType={setFilterType}
|
toggleFilterType={toggleFilterType}
|
||||||
filterTrainer={filterTrainer}
|
filterTrainer={filterTrainer}
|
||||||
filterStatus={filterStatus}
|
filterStatusSet={filterStatusSet}
|
||||||
setFilterStatus={setFilterStatus}
|
toggleFilterStatus={toggleFilterStatus}
|
||||||
filterTime={filterTime}
|
filterTime={filterTime}
|
||||||
setFilterTime={setFilterTime}
|
setFilterTime={setFilterTime}
|
||||||
availableDays={availableDays}
|
availableDays={availableDays}
|
||||||
@@ -350,8 +369,8 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
<MobileSchedule
|
<MobileSchedule
|
||||||
typeDots={typeDots}
|
typeDots={typeDots}
|
||||||
filteredDays={filteredDays}
|
filteredDays={filteredDays}
|
||||||
filterType={filterType}
|
filterTypes={filterTypes}
|
||||||
setFilterType={setFilterTypeFromCard}
|
toggleFilterType={toggleFilterTypeFromCard}
|
||||||
filterTrainer={filterTrainer}
|
filterTrainer={filterTrainer}
|
||||||
setFilterTrainer={setFilterTrainerFromCard}
|
setFilterTrainer={setFilterTrainerFromCard}
|
||||||
hasActiveFilter={hasActiveFilter}
|
hasActiveFilter={hasActiveFilter}
|
||||||
@@ -371,7 +390,7 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
key={day.day}
|
key={day.day}
|
||||||
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""}
|
className={filteredDays.length === 1 ? "w-full max-w-[340px]" : ""}
|
||||||
>
|
>
|
||||||
<DayCard day={day} typeDots={typeDots} showLocation={isAllMode} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainerFromCard} filterType={filterType} setFilterType={setFilterTypeFromCard} />
|
<DayCard day={day} typeDots={typeDots} showLocation={isAllMode} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainerFromCard} filterTypes={filterTypes} toggleFilterType={toggleFilterTypeFromCard} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -389,12 +408,13 @@ export function Schedule({ data: schedule, classItems }: ScheduleProps) {
|
|||||||
<GroupView
|
<GroupView
|
||||||
typeDots={typeDots}
|
typeDots={typeDots}
|
||||||
filteredDays={filteredDays}
|
filteredDays={filteredDays}
|
||||||
filterType={filterType}
|
filterTypes={filterTypes}
|
||||||
setFilterType={setFilterTypeFromCard}
|
toggleFilterType={toggleFilterTypeFromCard}
|
||||||
filterTrainer={filterTrainer}
|
filterTrainer={filterTrainer}
|
||||||
setFilterTrainer={setFilterTrainerFromCard}
|
setFilterTrainer={setFilterTrainerFromCard}
|
||||||
showLocation={isAllMode}
|
showLocation={isAllMode}
|
||||||
onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })}
|
onBook={(v) => dispatch({ type: "SET_BOOKING", value: v })}
|
||||||
|
trainerPhotos={trainerPhotos}
|
||||||
/>
|
/>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import { TeamCarousel } from "@/components/sections/team/TeamCarousel";
|
import { TeamCarousel } from "@/components/sections/team/TeamCarousel";
|
||||||
@@ -17,6 +17,48 @@ export function Team({ data: team, schedule }: TeamProps) {
|
|||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [showProfile, setShowProfile] = useState(false);
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
|
|
||||||
|
const openProfile = useCallback((index: number) => {
|
||||||
|
setActiveIndex(index);
|
||||||
|
setShowProfile(true);
|
||||||
|
history.pushState({ trainerProfile: true }, "");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeProfile = useCallback(() => {
|
||||||
|
setShowProfile(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openTrainerByName = useCallback((name: string) => {
|
||||||
|
const idx = team.members.findIndex((m) => m.name === name);
|
||||||
|
if (idx >= 0) {
|
||||||
|
openProfile(idx);
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.getElementById("team");
|
||||||
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}, [team.members, openProfile]);
|
||||||
|
|
||||||
|
// Handle browser back button
|
||||||
|
useEffect(() => {
|
||||||
|
function onPopState(e: PopStateEvent) {
|
||||||
|
if (showProfile) {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowProfile(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("popstate", onPopState);
|
||||||
|
return () => window.removeEventListener("popstate", onPopState);
|
||||||
|
}, [showProfile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handler(e: Event) {
|
||||||
|
const name = (e as CustomEvent<string>).detail;
|
||||||
|
if (name) openTrainerByName(name);
|
||||||
|
}
|
||||||
|
window.addEventListener("openTrainerProfile", handler);
|
||||||
|
return () => window.removeEventListener("openTrainerProfile", handler);
|
||||||
|
}, [openTrainerByName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id="team"
|
id="team"
|
||||||
@@ -54,14 +96,14 @@ export function Team({ data: team, schedule }: TeamProps) {
|
|||||||
members={team.members}
|
members={team.members}
|
||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
onSelect={setActiveIndex}
|
onSelect={setActiveIndex}
|
||||||
onOpenBio={() => setShowProfile(true)}
|
onOpenBio={() => openProfile(activeIndex)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<TeamProfile
|
<TeamProfile
|
||||||
member={team.members[activeIndex]}
|
member={team.members[activeIndex]}
|
||||||
onBack={() => setShowProfile(false)}
|
onBack={() => { history.back(); }}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ interface DayCardProps {
|
|||||||
showLocation?: boolean;
|
showLocation?: boolean;
|
||||||
filterTrainer: string | null;
|
filterTrainer: string | null;
|
||||||
setFilterTrainer: (trainer: string | null) => void;
|
setFilterTrainer: (trainer: string | null) => void;
|
||||||
filterType: string | null;
|
filterTypes: Set<string>;
|
||||||
setFilterType: (type: string | null) => void;
|
toggleFilterType: (type: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClassRow({
|
function ClassRow({
|
||||||
@@ -17,15 +17,15 @@ function ClassRow({
|
|||||||
typeDots,
|
typeDots,
|
||||||
filterTrainer,
|
filterTrainer,
|
||||||
setFilterTrainer,
|
setFilterTrainer,
|
||||||
filterType,
|
filterTypes,
|
||||||
setFilterType,
|
toggleFilterType,
|
||||||
}: {
|
}: {
|
||||||
cls: ScheduleClassWithLocation;
|
cls: ScheduleClassWithLocation;
|
||||||
typeDots: Record<string, string>;
|
typeDots: Record<string, string>;
|
||||||
filterTrainer: string | null;
|
filterTrainer: string | null;
|
||||||
setFilterTrainer: (trainer: string | null) => void;
|
setFilterTrainer: (trainer: string | null) => void;
|
||||||
filterType: string | null;
|
filterTypes: Set<string>;
|
||||||
setFilterType: (type: string | null) => void;
|
toggleFilterType: (type: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={`px-5 py-3.5 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}>
|
<div className={`px-5 py-3.5 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}>
|
||||||
@@ -47,26 +47,18 @@ function ClassRow({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilterTrainer(filterTrainer === cls.trainer ? null : cls.trainer)}
|
onClick={() => setFilterTrainer(filterTrainer === cls.trainer ? null : cls.trainer)}
|
||||||
className={`mt-1.5 flex items-center gap-2 text-sm font-medium cursor-pointer active:opacity-60 ${
|
className="mt-1.5 flex items-center gap-2 text-sm font-medium cursor-pointer active:opacity-60 text-neutral-800 dark:text-white/80"
|
||||||
filterTrainer === cls.trainer
|
|
||||||
? "text-gold underline underline-offset-2"
|
|
||||||
: "text-neutral-800 dark:text-white/80"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<User size={13} className="shrink-0 text-neutral-400 dark:text-white/30" />
|
<User size={13} className="shrink-0 text-neutral-400 dark:text-white/30" />
|
||||||
{cls.trainer}
|
{cls.trainer}
|
||||||
</button>
|
</button>
|
||||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)}
|
onClick={() => toggleFilterType(cls.type)}
|
||||||
className="flex items-center gap-2 cursor-pointer active:opacity-60"
|
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={`h-2 w-2 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
|
||||||
<span className={`text-xs ${
|
<span className="text-xs text-neutral-500 dark:text-white/40">{cls.type}</span>
|
||||||
filterType === cls.type
|
|
||||||
? "text-gold underline underline-offset-2"
|
|
||||||
: "text-neutral-500 dark:text-white/40"
|
|
||||||
}`}>{cls.type}</span>
|
|
||||||
</button>
|
</button>
|
||||||
{cls.level && (
|
{cls.level && (
|
||||||
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-0.5 text-[10px] font-semibold text-rose-600 dark:text-rose-400">
|
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-0.5 text-[10px] font-semibold text-rose-600 dark:text-rose-400">
|
||||||
@@ -78,7 +70,7 @@ function ClassRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterTrainer, filterType, setFilterType }: DayCardProps) {
|
export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterTrainer, filterTypes, toggleFilterType }: DayCardProps) {
|
||||||
// Group classes by location when showLocation is true
|
// Group classes by location when showLocation is true
|
||||||
const locationGroups = showLocation
|
const locationGroups = showLocation
|
||||||
? Array.from(
|
? Array.from(
|
||||||
@@ -115,15 +107,15 @@ export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterT
|
|||||||
<div key={locName}>
|
<div key={locName}>
|
||||||
{/* Location sub-header */}
|
{/* Location sub-header */}
|
||||||
<div className={`flex items-center gap-1.5 px-5 py-2 bg-neutral-100/60 dark:bg-white/[0.03] ${gi > 0 ? "border-t border-neutral-200 dark:border-white/[0.06]" : ""}`}>
|
<div className={`flex items-center gap-1.5 px-5 py-2 bg-neutral-100/60 dark:bg-white/[0.03] ${gi > 0 ? "border-t border-neutral-200 dark:border-white/[0.06]" : ""}`}>
|
||||||
<MapPin size={11} className="shrink-0 text-neutral-400 dark:text-white/25" />
|
<MapPin size={11} className="shrink-0 text-gold/60" />
|
||||||
<span className="text-[11px] font-medium text-neutral-400 dark:text-white/30">
|
<span className="text-[11px] font-medium text-neutral-600 dark:text-white/50">
|
||||||
{locName}
|
{locName}
|
||||||
{address && <span className="text-neutral-300 dark:text-white/15"> · {shortAddress(address)}</span>}
|
{address && <span className="text-neutral-400 dark:text-white/30"> · {shortAddress(address)}</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
|
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
|
||||||
{classes.map((cls, i) => (
|
{classes.map((cls, i) => (
|
||||||
<ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterType={filterType} setFilterType={setFilterType} />
|
<ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterTypes={filterTypes} toggleFilterType={toggleFilterType} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,7 +125,7 @@ export function DayCard({ day, typeDots, showLocation, filterTrainer, setFilterT
|
|||||||
// Single location — no sub-headers
|
// Single location — no sub-headers
|
||||||
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
|
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
|
||||||
{day.classes.map((cls, i) => (
|
{day.classes.map((cls, i) => (
|
||||||
<ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterType={filterType} setFilterType={setFilterType} />
|
<ClassRow key={i} cls={cls} typeDots={typeDots} filterTrainer={filterTrainer} setFilterTrainer={setFilterTrainer} filterTypes={filterTypes} toggleFilterType={toggleFilterType} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { User, MapPin } from "lucide-react";
|
import { useMemo } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { User, MapPin, Calendar } from "lucide-react";
|
||||||
import { shortAddress } from "./constants";
|
import { shortAddress } from "./constants";
|
||||||
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
|
import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
|
||||||
|
|
||||||
@@ -117,27 +119,33 @@ function groupByType(groups: ScheduleGroup[]): { type: string; groups: ScheduleG
|
|||||||
interface GroupViewProps {
|
interface GroupViewProps {
|
||||||
typeDots: Record<string, string>;
|
typeDots: Record<string, string>;
|
||||||
filteredDays: ScheduleDayMerged[];
|
filteredDays: ScheduleDayMerged[];
|
||||||
filterType: string | null;
|
filterTypes: Set<string>;
|
||||||
setFilterType: (type: string | null) => void;
|
toggleFilterType: (type: string) => void;
|
||||||
filterTrainer: string | null;
|
filterTrainer: string | null;
|
||||||
setFilterTrainer: (trainer: string | null) => void;
|
setFilterTrainer: (trainer: string | null) => void;
|
||||||
showLocation?: boolean;
|
showLocation?: boolean;
|
||||||
onBook?: (groupInfo: string) => void;
|
onBook?: (groupInfo: string) => void;
|
||||||
|
trainerPhotos?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WEEKDAY_NAMES = ["Воскресенье", "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота"];
|
||||||
|
|
||||||
export function GroupView({
|
export function GroupView({
|
||||||
typeDots,
|
typeDots,
|
||||||
filteredDays,
|
filteredDays,
|
||||||
filterType,
|
filterTypes,
|
||||||
setFilterType,
|
toggleFilterType,
|
||||||
filterTrainer,
|
filterTrainer,
|
||||||
setFilterTrainer,
|
setFilterTrainer,
|
||||||
showLocation,
|
showLocation,
|
||||||
onBook,
|
onBook,
|
||||||
|
trainerPhotos = {},
|
||||||
}: GroupViewProps) {
|
}: GroupViewProps) {
|
||||||
const groups = buildGroups(filteredDays);
|
const groups = buildGroups(filteredDays);
|
||||||
const byTrainer = groupByTrainer(groups);
|
const byTrainer = groupByTrainer(groups);
|
||||||
|
|
||||||
|
const todayName = useMemo(() => WEEKDAY_NAMES[new Date().getDay()], []);
|
||||||
|
|
||||||
if (groups.length === 0) {
|
if (groups.length === 0) {
|
||||||
return (
|
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-400 dark:text-white/30">
|
||||||
@@ -147,119 +155,144 @@ export function GroupView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-8 space-y-3 px-4 sm:px-6 lg:px-8 xl:px-6 max-w-4xl mx-auto">
|
<div className="mt-8 space-y-4 px-4 sm:px-6 lg:px-8 xl:px-6 max-w-4xl mx-auto">
|
||||||
{Array.from(byTrainer.entries()).map(([trainer, trainerGroups]) => {
|
{Array.from(byTrainer.entries()).map(([trainer, trainerGroups]) => {
|
||||||
const byType = groupByType(trainerGroups);
|
const byType = groupByType(trainerGroups);
|
||||||
const totalGroups = trainerGroups.length;
|
const isActive = filterTrainer === trainer;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={trainer} className="space-y-2">
|
||||||
key={trainer}
|
|
||||||
className="rounded-xl border border-neutral-200 bg-white overflow-hidden dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
|
||||||
>
|
|
||||||
{/* Trainer header */}
|
{/* Trainer header */}
|
||||||
<button
|
<div className="flex items-center gap-2.5">
|
||||||
onClick={() => setFilterTrainer(filterTrainer === trainer ? null : trainer)}
|
{/* Photo — clicks to open trainer bio */}
|
||||||
className={`flex items-center gap-2 w-full px-4 py-2.5 text-left transition-colors cursor-pointer ${
|
<button
|
||||||
filterTrainer === trainer
|
onClick={() => {
|
||||||
? "bg-gold/10 dark:bg-gold/5"
|
window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: trainer }));
|
||||||
: "bg-neutral-50 dark:bg-white/[0.02]"
|
}}
|
||||||
}`}
|
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"
|
||||||
<User size={14} className={filterTrainer === trainer ? "text-gold" : "text-neutral-400 dark:text-white/40"} />
|
}`}
|
||||||
<span className={`text-sm font-semibold ${
|
title={`Подробнее о ${trainer}`}
|
||||||
filterTrainer === trainer ? "text-gold" : "text-neutral-800 dark:text-white/80"
|
>
|
||||||
}`}>
|
{trainerPhotos[trainer] ? (
|
||||||
{trainer}
|
<Image
|
||||||
</span>
|
src={trainerPhotos[trainer]}
|
||||||
<span className="ml-auto text-[10px] text-neutral-400 dark:text-white/25">
|
alt={trainer}
|
||||||
{totalGroups === 1 ? "1 группа" : `${totalGroups} групп${totalGroups < 5 ? "ы" : ""}`}
|
fill
|
||||||
</span>
|
className="object-cover"
|
||||||
</button>
|
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>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{/* Name — clicks to filter */}
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterTrainer(isActive ? null : trainer)}
|
||||||
|
className="cursor-pointer group"
|
||||||
|
>
|
||||||
|
<span className={`text-base font-semibold transition-colors ${
|
||||||
|
isActive ? "text-gold" : "text-white/90 group-hover:text-white"
|
||||||
|
}`}>
|
||||||
|
{trainer}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Type → Groups */}
|
{/* Groups */}
|
||||||
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
|
<div className="space-y-2 pl-2">
|
||||||
{byType.map(({ type, groups: typeGroups }) => {
|
{byType.map(({ type, groups: typeGroups }) => {
|
||||||
const dotColor = typeDots[type] ?? "bg-white/30";
|
const dotColor = typeDots[type] ?? "bg-white/30";
|
||||||
|
|
||||||
return (
|
return typeGroups.map((group, gi) => {
|
||||||
<div key={type} className="px-4 py-2.5">
|
const merged = mergeSlotsByDay(group.slots);
|
||||||
{/* Class type row */}
|
|
||||||
<button
|
const hasToday = group.slots.some(s => s.day === todayName);
|
||||||
onClick={() => setFilterType(filterType === type ? null : type)}
|
|
||||||
className="flex items-center gap-1.5 cursor-pointer"
|
return (
|
||||||
|
<div
|
||||||
|
key={`${type}-${gi}`}
|
||||||
|
className={`rounded-xl border transition-all ${
|
||||||
|
hasToday
|
||||||
|
? "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]"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`h-2 w-2 shrink-0 rounded-full ${dotColor}`} />
|
<div className="flex items-start gap-3 p-3 sm:p-4">
|
||||||
<span className="text-sm font-medium text-neutral-800 dark:text-white/80">
|
{/* Left: type dot + info */}
|
||||||
{type}
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
</span>
|
{/* Type name */}
|
||||||
</button>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
{/* Group rows under this type */}
|
onClick={() => toggleFilterType(type)}
|
||||||
<div className="mt-1.5 space-y-1 pl-3.5">
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
{typeGroups.map((group, gi) => {
|
>
|
||||||
const merged = mergeSlotsByDay(group.slots);
|
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${dotColor}`} />
|
||||||
|
<span className="text-sm font-semibold text-white/90">{type}</span>
|
||||||
return (
|
</button>
|
||||||
<div
|
|
||||||
key={gi}
|
|
||||||
className="flex items-center gap-2 flex-wrap"
|
|
||||||
>
|
|
||||||
{/* Datetimes */}
|
|
||||||
<div className="flex items-center gap-0.5 flex-wrap">
|
|
||||||
{merged.map((m, i) => (
|
|
||||||
<span key={i} className="inline-flex items-center gap-1 text-xs">
|
|
||||||
{i > 0 && <span className="text-neutral-300 dark:text-white/15 mx-0.5">·</span>}
|
|
||||||
<span className="rounded bg-gold/10 px-1.5 py-0.5 text-[10px] font-bold text-gold-dark dark:text-gold">
|
|
||||||
{m.days.join(", ")}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium tabular-nums text-neutral-500 dark:text-white/45">
|
|
||||||
{m.times.join(", ")}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Badges */}
|
|
||||||
{group.level && (
|
{group.level && (
|
||||||
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-px text-[10px] font-semibold text-rose-600 dark:text-rose-400">
|
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-px text-[10px] font-semibold text-rose-400">
|
||||||
{group.level}
|
{group.level}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{hasToday && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-gold/15 border border-gold/25 px-2 py-px text-[10px] font-semibold text-gold">
|
||||||
|
<Calendar size={9} />
|
||||||
|
Сегодня
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schedule rows */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{merged.map((m, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<span className="rounded-md bg-gold/10 px-2 py-0.5 text-[11px] font-bold text-gold min-w-[52px] text-center">
|
||||||
|
{m.days.join(", ")}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium tabular-nums text-white/60">
|
||||||
|
{m.times.join(", ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom badges */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
{group.hasSlots && (
|
{group.hasSlots && (
|
||||||
<span className="rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2 py-px text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
|
<span className="rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2.5 py-0.5 text-[10px] font-semibold text-emerald-400">
|
||||||
есть места
|
есть места
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{group.recruiting && (
|
{group.recruiting && (
|
||||||
<span className="rounded-full bg-sky-500/15 border border-sky-500/25 px-2 py-px text-[10px] font-semibold text-sky-600 dark:text-sky-400">
|
<span className="rounded-full bg-sky-500/15 border border-sky-500/25 px-2.5 py-0.5 text-[10px] font-semibold text-sky-400">
|
||||||
набор
|
набор
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Location */}
|
|
||||||
{showLocation && group.location && (
|
{showLocation && group.location && (
|
||||||
<span className="flex items-center gap-1 text-[10px] text-neutral-400 dark:text-white/25">
|
<span className="flex items-center gap-1 rounded-full bg-white/[0.04] border border-white/[0.08] px-2.5 py-0.5 text-[10px] font-medium text-white/45">
|
||||||
<MapPin size={9} />
|
<MapPin size={9} />
|
||||||
{group.location}
|
{shortAddress(group.locationAddress || group.location)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Book button */}
|
|
||||||
{onBook && (
|
|
||||||
<button
|
|
||||||
onClick={() => onBook(`${group.type}, ${group.trainer}, ${group.slots.map(s => s.dayShort).join("/")} ${group.slots[0]?.time ?? ""}`)}
|
|
||||||
className="ml-auto rounded-lg bg-gold/10 border border-gold/20 px-3 py-1 text-[11px] font-semibold text-gold hover:bg-gold/20 transition-colors cursor-pointer shrink-0"
|
|
||||||
>
|
|
||||||
Записаться
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
|
{/* Right: book button */}
|
||||||
|
{onBook && (
|
||||||
|
<button
|
||||||
|
onClick={() => onBook(`${group.type}, ${group.trainer}, ${group.slots.map(s => s.dayShort).join("/")} ${group.slots[0]?.time ?? ""}`)}
|
||||||
|
className="shrink-0 self-center rounded-xl bg-gold/10 border border-gold/25 px-4 py-2 text-xs font-semibold text-gold hover:bg-gold/20 hover:border-gold/40 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
Записаться
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
});
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import type { ScheduleDayMerged, ScheduleClassWithLocation } from "./constants";
|
|||||||
interface MobileScheduleProps {
|
interface MobileScheduleProps {
|
||||||
typeDots: Record<string, string>;
|
typeDots: Record<string, string>;
|
||||||
filteredDays: ScheduleDayMerged[];
|
filteredDays: ScheduleDayMerged[];
|
||||||
filterType: string | null;
|
filterTypes: Set<string>;
|
||||||
setFilterType: (type: string | null) => void;
|
toggleFilterType: (type: string) => void;
|
||||||
filterTrainer: string | null;
|
filterTrainer: string | null;
|
||||||
setFilterTrainer: (trainer: string | null) => void;
|
setFilterTrainer: (trainer: string | null) => void;
|
||||||
hasActiveFilter: boolean;
|
hasActiveFilter: boolean;
|
||||||
@@ -19,16 +19,16 @@ interface MobileScheduleProps {
|
|||||||
function ClassRow({
|
function ClassRow({
|
||||||
cls,
|
cls,
|
||||||
typeDots,
|
typeDots,
|
||||||
filterType,
|
filterTypes,
|
||||||
setFilterType,
|
toggleFilterType,
|
||||||
filterTrainer,
|
filterTrainer,
|
||||||
setFilterTrainer,
|
setFilterTrainer,
|
||||||
showLocation,
|
showLocation,
|
||||||
}: {
|
}: {
|
||||||
cls: ScheduleClassWithLocation;
|
cls: ScheduleClassWithLocation;
|
||||||
typeDots: Record<string, string>;
|
typeDots: Record<string, string>;
|
||||||
filterType: string | null;
|
filterTypes: Set<string>;
|
||||||
setFilterType: (type: string | null) => void;
|
toggleFilterType: (type: string) => void;
|
||||||
filterTrainer: string | null;
|
filterTrainer: string | null;
|
||||||
setFilterTrainer: (trainer: string | null) => void;
|
setFilterTrainer: (trainer: string | null) => void;
|
||||||
showLocation?: boolean;
|
showLocation?: boolean;
|
||||||
@@ -47,7 +47,7 @@ function ClassRow({
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilterTrainer(filterTrainer === cls.trainer ? null : cls.trainer)}
|
onClick={() => setFilterTrainer(filterTrainer === cls.trainer ? null : cls.trainer)}
|
||||||
className={`truncate text-sm font-medium text-left active:opacity-60 ${filterTrainer === cls.trainer ? "text-gold underline underline-offset-2" : "text-neutral-800 dark:text-white/80"}`}
|
className="truncate text-sm font-medium text-left active:opacity-60 text-neutral-800 dark:text-white/80"
|
||||||
>
|
>
|
||||||
{cls.trainer}
|
{cls.trainer}
|
||||||
</button>
|
</button>
|
||||||
@@ -69,11 +69,11 @@ function ClassRow({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex items-center gap-2">
|
<div className="mt-0.5 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)}
|
onClick={() => toggleFilterType(cls.type)}
|
||||||
className={`flex items-center gap-1.5 active:opacity-60 ${filterType === cls.type ? "opacity-100" : ""}`}
|
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={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[cls.type] ?? "bg-white/30"}`} />
|
||||||
<span className={`text-[11px] ${filterType === cls.type ? "text-gold underline underline-offset-2" : "text-neutral-400 dark:text-white/30"}`}>{cls.type}</span>
|
<span className="text-[11px] text-neutral-400 dark:text-white/30">{cls.type}</span>
|
||||||
</button>
|
</button>
|
||||||
{showLocation && cls.locationName && (
|
{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-400 dark:text-white/20">
|
||||||
@@ -90,8 +90,8 @@ function ClassRow({
|
|||||||
export function MobileSchedule({
|
export function MobileSchedule({
|
||||||
typeDots,
|
typeDots,
|
||||||
filteredDays,
|
filteredDays,
|
||||||
filterType,
|
filterTypes,
|
||||||
setFilterType,
|
toggleFilterType,
|
||||||
filterTrainer,
|
filterTrainer,
|
||||||
setFilterTrainer,
|
setFilterTrainer,
|
||||||
hasActiveFilter,
|
hasActiveFilter,
|
||||||
@@ -110,12 +110,12 @@ export function MobileSchedule({
|
|||||||
{filterTrainer}
|
{filterTrainer}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{filterType && (
|
{filterTypes.size > 0 && Array.from(filterTypes).map((type) => (
|
||||||
<span className="flex items-center gap-1">
|
<span key={type} className="flex items-center gap-1">
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${typeDots[filterType] ?? "bg-white/30"}`} />
|
<span className={`h-1.5 w-1.5 rounded-full ${typeDots[type] ?? "bg-white/30"}`} />
|
||||||
{filterType}
|
{type}
|
||||||
</span>
|
</span>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={clearFilters}
|
onClick={clearFilters}
|
||||||
@@ -175,8 +175,8 @@ export function MobileSchedule({
|
|||||||
key={i}
|
key={i}
|
||||||
cls={cls}
|
cls={cls}
|
||||||
typeDots={typeDots}
|
typeDots={typeDots}
|
||||||
filterType={filterType}
|
filterTypes={filterTypes}
|
||||||
setFilterType={setFilterType}
|
toggleFilterType={toggleFilterType}
|
||||||
filterTrainer={filterTrainer}
|
filterTrainer={filterTrainer}
|
||||||
setFilterTrainer={setFilterTrainer}
|
setFilterTrainer={setFilterTrainer}
|
||||||
/>
|
/>
|
||||||
@@ -190,8 +190,8 @@ export function MobileSchedule({
|
|||||||
key={i}
|
key={i}
|
||||||
cls={cls}
|
cls={cls}
|
||||||
typeDots={typeDots}
|
typeDots={typeDots}
|
||||||
filterType={filterType}
|
filterTypes={filterTypes}
|
||||||
setFilterType={setFilterType}
|
toggleFilterType={toggleFilterType}
|
||||||
filterTrainer={filterTrainer}
|
filterTrainer={filterTrainer}
|
||||||
setFilterTrainer={setFilterTrainer}
|
setFilterTrainer={setFilterTrainer}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
pillActive,
|
pillActive,
|
||||||
pillInactive,
|
pillInactive,
|
||||||
TIME_PRESETS,
|
TIME_PRESETS,
|
||||||
type StatusFilter,
|
type StatusTag,
|
||||||
type TimeFilter,
|
type TimeFilter,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
|
||||||
@@ -16,11 +16,11 @@ interface ScheduleFiltersProps {
|
|||||||
types: string[];
|
types: string[];
|
||||||
hasAnySlots: boolean;
|
hasAnySlots: boolean;
|
||||||
hasAnyRecruiting: boolean;
|
hasAnyRecruiting: boolean;
|
||||||
filterType: string | null;
|
filterTypes: Set<string>;
|
||||||
setFilterType: (type: string | null) => void;
|
toggleFilterType: (type: string) => void;
|
||||||
filterTrainer: string | null;
|
filterTrainer: string | null;
|
||||||
filterStatus: StatusFilter;
|
filterStatusSet: Set<StatusTag>;
|
||||||
setFilterStatus: (status: StatusFilter) => void;
|
toggleFilterStatus: (status: StatusTag) => void;
|
||||||
filterTime: TimeFilter;
|
filterTime: TimeFilter;
|
||||||
setFilterTime: (time: TimeFilter) => void;
|
setFilterTime: (time: TimeFilter) => void;
|
||||||
availableDays: { day: string; dayShort: string }[];
|
availableDays: { day: string; dayShort: string }[];
|
||||||
@@ -35,11 +35,11 @@ export function ScheduleFilters({
|
|||||||
types,
|
types,
|
||||||
hasAnySlots,
|
hasAnySlots,
|
||||||
hasAnyRecruiting,
|
hasAnyRecruiting,
|
||||||
filterType,
|
filterTypes,
|
||||||
setFilterType,
|
toggleFilterType,
|
||||||
filterTrainer,
|
filterTrainer,
|
||||||
filterStatus,
|
filterStatusSet,
|
||||||
setFilterStatus,
|
toggleFilterStatus,
|
||||||
filterTime,
|
filterTime,
|
||||||
setFilterTime,
|
setFilterTime,
|
||||||
availableDays,
|
availableDays,
|
||||||
@@ -59,8 +59,8 @@ export function ScheduleFilters({
|
|||||||
{types.map((type) => (
|
{types.map((type) => (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
onClick={() => setFilterType(filterType === type ? null : type)}
|
onClick={() => toggleFilterType(type)}
|
||||||
className={`${pillBase} ${filterType === type ? pillActive : pillInactive}`}
|
className={`${pillBase} ${filterTypes.has(type) ? pillActive : pillInactive}`}
|
||||||
>
|
>
|
||||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[type] ?? "bg-white/30"}`} />
|
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${typeDots[type] ?? "bg-white/30"}`} />
|
||||||
{type}
|
{type}
|
||||||
@@ -73,8 +73,8 @@ export function ScheduleFilters({
|
|||||||
{/* Status filters */}
|
{/* Status filters */}
|
||||||
{hasAnySlots && (
|
{hasAnySlots && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilterStatus(filterStatus === "hasSlots" ? "all" : "hasSlots")}
|
onClick={() => toggleFilterStatus("hasSlots")}
|
||||||
className={`${pillBase} ${filterStatus === "hasSlots" ? "bg-emerald-500/20 text-emerald-700 border border-emerald-500/40 dark:text-emerald-400 dark:border-emerald-500/30" : pillInactive}`}
|
className={`${pillBase} ${filterStatusSet.has("hasSlots") ? "bg-emerald-500/20 text-emerald-700 border border-emerald-500/40 dark:text-emerald-400 dark:border-emerald-500/30" : pillInactive}`}
|
||||||
>
|
>
|
||||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
|
||||||
Есть места
|
Есть места
|
||||||
@@ -82,8 +82,8 @@ export function ScheduleFilters({
|
|||||||
)}
|
)}
|
||||||
{hasAnyRecruiting && (
|
{hasAnyRecruiting && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilterStatus(filterStatus === "recruiting" ? "all" : "recruiting")}
|
onClick={() => toggleFilterStatus("recruiting")}
|
||||||
className={`${pillBase} ${filterStatus === "recruiting" ? "bg-sky-500/20 text-sky-700 border border-sky-500/40 dark:text-sky-400 dark:border-sky-500/30" : pillInactive}`}
|
className={`${pillBase} ${filterStatusSet.has("recruiting") ? "bg-sky-500/20 text-sky-700 border border-sky-500/40 dark:text-sky-400 dark:border-sky-500/30" : pillInactive}`}
|
||||||
>
|
>
|
||||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
|
||||||
Набор
|
Набор
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export function buildTypeDots(
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StatusTag = "hasSlots" | "recruiting";
|
||||||
|
/** @deprecated Use Set<StatusTag> instead */
|
||||||
export type StatusFilter = "all" | "hasSlots" | "recruiting";
|
export type StatusFilter = "all" | "hasSlots" | "recruiting";
|
||||||
export type TimeFilter = "all" | "morning" | "afternoon" | "evening";
|
export type TimeFilter = "all" | "morning" | "afternoon" | "evening";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Clock, MapPin, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
|
import { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Clock, MapPin, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import type { TeamMember, RichListItem, ScheduleLocation } from "@/types/content";
|
import type { TeamMember, RichListItem, ScheduleLocation } from "@/types/content";
|
||||||
@@ -28,64 +28,66 @@ export function TeamProfile({ member, onBack, schedule }: TeamProfileProps) {
|
|||||||
const hasEducation = member.education && member.education.length > 0;
|
const hasEducation = member.education && member.education.length > 0;
|
||||||
|
|
||||||
// Extract trainer's groups from schedule using groupId
|
// Extract trainer's groups from schedule using groupId
|
||||||
const groupMap = new Map<string, { type: string; location: string; address: string; slots: { day: string; dayShort: string; time: string }[]; level?: string; recruiting?: boolean }>();
|
const uniqueGroups = useMemo(() => {
|
||||||
schedule?.forEach(location => {
|
const groupMap = new Map<string, { type: string; location: string; address: string; slots: { day: string; dayShort: string; time: string }[]; level?: string; recruiting?: boolean }>();
|
||||||
location.days.forEach(day => {
|
schedule?.forEach(location => {
|
||||||
day.classes
|
location.days.forEach(day => {
|
||||||
.filter(c => c.trainer === member.name)
|
day.classes
|
||||||
.forEach(c => {
|
.filter(c => c.trainer === member.name)
|
||||||
const key = c.groupId
|
.forEach(c => {
|
||||||
? `${c.groupId}||${location.name}`
|
const key = c.groupId
|
||||||
: `${c.trainer}||${c.type}||${location.name}`;
|
? `${c.groupId}||${location.name}`
|
||||||
const existing = groupMap.get(key);
|
: `${c.trainer}||${c.type}||${location.name}`;
|
||||||
if (existing) {
|
const existing = groupMap.get(key);
|
||||||
existing.slots.push({ day: day.day, dayShort: day.dayShort, time: c.time });
|
if (existing) {
|
||||||
if (c.level && !existing.level) existing.level = c.level;
|
existing.slots.push({ day: day.day, dayShort: day.dayShort, time: c.time });
|
||||||
if (c.recruiting) existing.recruiting = true;
|
if (c.level && !existing.level) existing.level = c.level;
|
||||||
} else {
|
if (c.recruiting) existing.recruiting = true;
|
||||||
groupMap.set(key, {
|
} else {
|
||||||
type: c.type,
|
groupMap.set(key, {
|
||||||
location: location.name,
|
type: c.type,
|
||||||
address: location.address,
|
location: location.name,
|
||||||
slots: [{ day: day.day, dayShort: day.dayShort, time: c.time }],
|
address: location.address,
|
||||||
level: c.level,
|
slots: [{ day: day.day, dayShort: day.dayShort, time: c.time }],
|
||||||
recruiting: c.recruiting,
|
level: c.level,
|
||||||
});
|
recruiting: c.recruiting,
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
return Array.from(groupMap.values()).map(g => {
|
||||||
const uniqueGroups = Array.from(groupMap.values()).map(g => {
|
// Merge slots by day, then merge days with identical time sets
|
||||||
// Merge slots by day, then merge days with identical time sets
|
const dayMap = new Map<string, { dayShort: string; times: string[] }>();
|
||||||
const dayMap = new Map<string, { dayShort: string; times: string[] }>();
|
const dayOrder: string[] = [];
|
||||||
const dayOrder: string[] = [];
|
for (const s of g.slots) {
|
||||||
for (const s of g.slots) {
|
const existing = dayMap.get(s.day);
|
||||||
const existing = dayMap.get(s.day);
|
if (existing) {
|
||||||
if (existing) {
|
if (!existing.times.includes(s.time)) existing.times.push(s.time);
|
||||||
if (!existing.times.includes(s.time)) existing.times.push(s.time);
|
} else {
|
||||||
} else {
|
dayMap.set(s.day, { dayShort: s.dayShort, times: [s.time] });
|
||||||
dayMap.set(s.day, { dayShort: s.dayShort, times: [s.time] });
|
dayOrder.push(s.day);
|
||||||
dayOrder.push(s.day);
|
}
|
||||||
}
|
}
|
||||||
}
|
for (const entry of dayMap.values()) entry.times.sort();
|
||||||
for (const entry of dayMap.values()) entry.times.sort();
|
const merged: { days: string[]; times: string[] }[] = [];
|
||||||
const merged: { days: string[]; times: string[] }[] = [];
|
const used = new Set<string>();
|
||||||
const used = new Set<string>();
|
for (const day of dayOrder) {
|
||||||
for (const day of dayOrder) {
|
if (used.has(day)) continue;
|
||||||
if (used.has(day)) continue;
|
const entry = dayMap.get(day)!;
|
||||||
const entry = dayMap.get(day)!;
|
const timeKey = entry.times.join("|");
|
||||||
const timeKey = entry.times.join("|");
|
const days = [entry.dayShort];
|
||||||
const days = [entry.dayShort];
|
used.add(day);
|
||||||
used.add(day);
|
for (const other of dayOrder) {
|
||||||
for (const other of dayOrder) {
|
if (used.has(other)) continue;
|
||||||
if (used.has(other)) continue;
|
const o = dayMap.get(other)!;
|
||||||
const o = dayMap.get(other)!;
|
if (o.times.join("|") === timeKey) { days.push(o.dayShort); used.add(other); }
|
||||||
if (o.times.join("|") === timeKey) { days.push(o.dayShort); used.add(other); }
|
}
|
||||||
|
merged.push({ days, times: entry.times });
|
||||||
}
|
}
|
||||||
merged.push({ days, times: entry.times });
|
return { ...g, merged };
|
||||||
}
|
});
|
||||||
return { ...g, merged };
|
}, [member.name, schedule]);
|
||||||
});
|
|
||||||
const hasGroups = uniqueGroups.length > 0;
|
const hasGroups = uniqueGroups.length > 0;
|
||||||
|
|
||||||
const hasBio = hasVictories || hasEducation || hasGroups;
|
const hasBio = hasVictories || hasEducation || hasGroups;
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { useState, useEffect } from "react";
|
|||||||
import { Phone, X, Mail, Instagram } from "lucide-react";
|
import { Phone, X, Mail, Instagram } from "lucide-react";
|
||||||
import { BRAND } from "@/lib/constants";
|
import { BRAND } from "@/lib/constants";
|
||||||
import { SignupModal } from "./SignupModal";
|
import { SignupModal } from "./SignupModal";
|
||||||
|
import { useBooking } from "@/contexts/BookingContext";
|
||||||
|
|
||||||
export function FloatingContact() {
|
export function FloatingContact() {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const { bookingOpen: modalOpen, openBooking, closeBooking } = useBooking();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
@@ -53,7 +54,7 @@ export function FloatingContact() {
|
|||||||
className={`flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all duration-300 ${
|
className={`flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all duration-300 ${
|
||||||
expanded
|
expanded
|
||||||
? "bg-neutral-700 rotate-0"
|
? "bg-neutral-700 rotate-0"
|
||||||
: "bg-[#c9a96e] hover:bg-[#d4b87a] shadow-[#c9a96e]/30"
|
: "bg-gold hover:bg-gold-light shadow-gold/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
@@ -72,7 +73,7 @@ export function FloatingContact() {
|
|||||||
{/* Phone */}
|
{/* Phone */}
|
||||||
<a
|
<a
|
||||||
href="tel:+375293897001"
|
href="tel:+375293897001"
|
||||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-[#2ecc71] shadow-lg shadow-[#2ecc71]/20 transition-transform hover:scale-110"
|
className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/20 transition-transform hover:scale-110"
|
||||||
aria-label="Позвонить"
|
aria-label="Позвонить"
|
||||||
>
|
>
|
||||||
<Phone size={20} className="text-white" />
|
<Phone size={20} className="text-white" />
|
||||||
@@ -91,8 +92,8 @@ export function FloatingContact() {
|
|||||||
|
|
||||||
{/* Записаться */}
|
{/* Записаться */}
|
||||||
<button
|
<button
|
||||||
onClick={() => { setModalOpen(true); setExpanded(false); }}
|
onClick={() => { openBooking(); setExpanded(false); }}
|
||||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-[#c9a96e] shadow-lg shadow-[#c9a96e]/20 transition-transform hover:scale-110"
|
className="flex h-12 w-12 items-center justify-center rounded-full bg-gold shadow-lg shadow-gold/20 transition-transform hover:scale-110"
|
||||||
aria-label="Записаться"
|
aria-label="Записаться"
|
||||||
>
|
>
|
||||||
<span className="text-xs font-bold text-black leading-tight text-center">
|
<span className="text-xs font-bold text-black leading-tight text-center">
|
||||||
@@ -104,7 +105,7 @@ export function FloatingContact() {
|
|||||||
|
|
||||||
<SignupModal
|
<SignupModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onClose={() => setModalOpen(false)}
|
onClose={closeBooking}
|
||||||
title="Записаться на занятие"
|
title="Записаться на занятие"
|
||||||
subtitle="Оставьте контактные данные и мы свяжемся с вами"
|
subtitle="Оставьте контактные данные и мы свяжемся с вами"
|
||||||
endpoint="/api/group-booking"
|
endpoint="/api/group-booking"
|
||||||
|
|||||||
@@ -5,31 +5,17 @@ import { createPortal } from "react-dom";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { X, Calendar, ExternalLink } from "lucide-react";
|
import { X, Calendar, ExternalLink } from "lucide-react";
|
||||||
import type { NewsItem } from "@/types/content";
|
import type { NewsItem } from "@/types/content";
|
||||||
|
import { formatDateRu } from "@/lib/formatting";
|
||||||
|
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||||
|
|
||||||
interface NewsModalProps {
|
interface NewsModalProps {
|
||||||
item: NewsItem | null;
|
item: NewsItem | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
try {
|
|
||||||
const d = new Date(iso);
|
|
||||||
const date = d.toLocaleDateString("ru-RU", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
if (iso.includes("T")) {
|
|
||||||
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
|
||||||
return `${date}, ${time}`;
|
|
||||||
}
|
|
||||||
return date;
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NewsModal({ item, onClose }: NewsModalProps) {
|
export function NewsModal({ item, onClose }: NewsModalProps) {
|
||||||
|
const focusTrapRef = useFocusTrap<HTMLDivElement>(!!item);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
@@ -40,21 +26,16 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
|
|||||||
}, [item, onClose]);
|
}, [item, onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) document.body.style.overflow = "hidden";
|
||||||
document.body.style.overflow = "hidden";
|
else document.body.style.overflow = "";
|
||||||
} else {
|
return () => { document.body.style.overflow = ""; };
|
||||||
document.body.style.overflow = "";
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
};
|
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4"
|
className="modal-overlay fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={item.title}
|
aria-label={item.title}
|
||||||
@@ -63,7 +44,8 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
|
|||||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="modal-content relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl border border-white/[0.08] bg-[#0a0a0a] shadow-2xl"
|
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"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -82,16 +64,19 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
|
|||||||
fill
|
fill
|
||||||
sizes="(min-width: 768px) 672px, 100vw"
|
sizes="(min-width: 768px) 672px, 100vw"
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
style={{ objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%` }}
|
style={{
|
||||||
|
objectPosition: `${item.imageFocalX ?? 50}% ${item.imageFocalY ?? 50}%`,
|
||||||
|
transform: `scale(${item.imageZoom ?? 1})`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0a0a0a] via-transparent to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-neutral-950 via-transparent to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`p-6 sm:p-8 ${item.image ? "-mt-12 relative" : ""}`}>
|
<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-400">
|
||||||
<Calendar size={12} />
|
<Calendar size={12} />
|
||||||
{formatDate(item.date)}
|
{formatDateRu(item.date)}
|
||||||
</span>
|
</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-white leading-tight">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { X, CheckCircle, Send, Phone as PhoneIcon, Instagram } from "lucide-react";
|
import { X, CheckCircle, Send, Phone as PhoneIcon, Instagram } from "lucide-react";
|
||||||
import { BRAND } from "@/lib/constants";
|
import { BRAND } from "@/lib/constants";
|
||||||
|
import { useFocusTrap } from "@/hooks/useFocusTrap";
|
||||||
|
|
||||||
interface SignupModalProps {
|
interface SignupModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -47,6 +48,7 @@ export function SignupModal({
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [successData, setSuccessData] = useState<Record<string, unknown> | null>(null);
|
const [successData, setSuccessData] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
|
||||||
|
|
||||||
function handlePhoneChange(raw: string) {
|
function handlePhoneChange(raw: string) {
|
||||||
let digits = raw.replace(/\D/g, "");
|
let digits = raw.replace(/\D/g, "");
|
||||||
@@ -141,10 +143,11 @@ export function SignupModal({
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-label={title} onClick={handleClose}>
|
<div className="modal-overlay fixed inset-0 z-[60] flex items-center justify-center p-4" role="dialog" aria-modal="true" aria-label={title} onClick={handleClose}>
|
||||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
<div
|
<div
|
||||||
className="modal-content relative w-full max-w-md rounded-2xl border border-white/[0.08] bg-[#0a0a0a] p-6 sm:p-8 shadow-2xl"
|
ref={focusTrapRef}
|
||||||
|
className="modal-content relative w-full max-w-md rounded-2xl border border-white/[0.08] bg-neutral-950 p-6 sm:p-8 shadow-2xl"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useCallback } from "react";
|
||||||
|
|
||||||
|
interface BookingContextValue {
|
||||||
|
bookingOpen: boolean;
|
||||||
|
openBooking: () => void;
|
||||||
|
closeBooking: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BookingContext = createContext<BookingContextValue | null>(null);
|
||||||
|
|
||||||
|
export function BookingProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [bookingOpen, setBookingOpen] = useState(false);
|
||||||
|
const openBooking = useCallback(() => setBookingOpen(true), []);
|
||||||
|
const closeBooking = useCallback(() => setBookingOpen(false), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BookingContext.Provider value={{ bookingOpen, openBooking, closeBooking }}>
|
||||||
|
{children}
|
||||||
|
</BookingContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOOP = () => {};
|
||||||
|
const FALLBACK: BookingContextValue = { bookingOpen: false, openBooking: NOOP, closeBooking: NOOP };
|
||||||
|
|
||||||
|
/** Returns booking context. Safe to use outside BookingProvider (returns inert fallback). */
|
||||||
|
export function useBooking(): BookingContextValue {
|
||||||
|
return useContext(BookingContext) ?? FALLBACK;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traps keyboard focus within a container while active.
|
||||||
|
* Returns a ref to attach to the modal/dialog container element.
|
||||||
|
*/
|
||||||
|
export function useFocusTrap<T extends HTMLElement>(active: boolean) {
|
||||||
|
const containerRef = useRef<T>(null);
|
||||||
|
const previousFocus = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
previousFocus.current = document.activeElement as HTMLElement;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Focus the first focusable element
|
||||||
|
const focusableSelector =
|
||||||
|
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
function getFocusable() {
|
||||||
|
return Array.from(
|
||||||
|
container!.querySelectorAll<HTMLElement>(focusableSelector)
|
||||||
|
).filter((el) => el.offsetParent !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay to let modal animate in
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const elements = getFocusable();
|
||||||
|
if (elements.length > 0) elements[0].focus();
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
const elements = getFocusable();
|
||||||
|
if (elements.length === 0) return;
|
||||||
|
|
||||||
|
const first = elements[0];
|
||||||
|
const last = elements[elements.length - 1];
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
// Restore focus to the element that triggered the modal
|
||||||
|
previousFocus.current?.focus();
|
||||||
|
};
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
return containerRef;
|
||||||
|
}
|
||||||
@@ -11,14 +11,14 @@ export const BRAND = {
|
|||||||
|
|
||||||
export const NAV_LINKS: NavLink[] = [
|
export const NAV_LINKS: NavLink[] = [
|
||||||
{ label: "Главная", href: "#hero" },
|
{ label: "Главная", href: "#hero" },
|
||||||
{ label: "День открытых дверей", href: "#open-day" },
|
|
||||||
{ label: "О нас", href: "#about" },
|
{ label: "О нас", href: "#about" },
|
||||||
{ label: "Команда", href: "#team" },
|
|
||||||
{ label: "Направления", href: "#classes" },
|
{ label: "Направления", href: "#classes" },
|
||||||
{ label: "Мастер-классы", href: "#master-classes" },
|
{ label: "Команда", href: "#team" },
|
||||||
|
{ label: "День открытых дверей", href: "#open-day" },
|
||||||
{ label: "Расписание", href: "#schedule" },
|
{ label: "Расписание", href: "#schedule" },
|
||||||
{ label: "Стоимость", href: "#pricing" },
|
{ label: "Стоимость", href: "#pricing" },
|
||||||
{ label: "FAQ", href: "#faq" },
|
{ label: "Мастер-классы", href: "#master-classes" },
|
||||||
{ label: "Новости", href: "#news" },
|
{ label: "Новости", href: "#news" },
|
||||||
|
{ label: "FAQ", href: "#faq" },
|
||||||
{ label: "Контакты", href: "#contact" },
|
{ label: "Контакты", href: "#contact" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -864,6 +864,7 @@ export interface ReminderItem {
|
|||||||
telegram?: string;
|
telegram?: string;
|
||||||
reminderStatus?: string;
|
reminderStatus?: string;
|
||||||
eventLabel: string;
|
eventLabel: string;
|
||||||
|
eventHall?: string;
|
||||||
eventDate: string;
|
eventDate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Shared date/text formatting utilities.
|
||||||
|
* Replaces duplicate formatDate implementations across News, NewsModal, MasterClasses, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MONTHS_RU = [
|
||||||
|
"января", "февраля", "марта", "апреля", "мая", "июня",
|
||||||
|
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
||||||
|
];
|
||||||
|
|
||||||
|
const WEEKDAYS_RU = [
|
||||||
|
"воскресенье", "понедельник", "вторник", "среда",
|
||||||
|
"четверг", "пятница", "суббота",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Format ISO date string to Russian locale (e.g. "15 марта 2026, 19:00") */
|
||||||
|
export function formatDateRu(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const date = d.toLocaleDateString("ru-RU", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
if (iso.includes("T")) {
|
||||||
|
const time = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
return `${date}, ${time}`;
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format ISO date to short Russian date (e.g. "15.03.2026") */
|
||||||
|
export function formatDateShort(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format ISO date with weekday (e.g. "понедельник, 15 марта") */
|
||||||
|
export function formatDateWithWeekday(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + "T12:00:00");
|
||||||
|
return d.toLocaleDateString("ru-RU", {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse date-only ISO string safely */
|
||||||
|
export function parseDate(iso: string): Date {
|
||||||
|
return new Date(iso + "T00:00:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MONTHS_RU, WEEKDAYS_RU };
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* UI constants: z-index layers, timing, border-radius tokens.
|
||||||
|
* Centralizes magic numbers that were scattered across components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Z-Index System (ordered layers) ---
|
||||||
|
export const Z = {
|
||||||
|
/** Below normal content */
|
||||||
|
below: 0,
|
||||||
|
/** Floating buttons (BackToTop, FloatingContact) */
|
||||||
|
float: 40,
|
||||||
|
/** Sticky header */
|
||||||
|
header: 50,
|
||||||
|
/** Modal overlays */
|
||||||
|
modal: 60,
|
||||||
|
/** Drag-and-drop ghost elements */
|
||||||
|
drag: 9999,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// --- Timing (ms) ---
|
||||||
|
export const TIMING = {
|
||||||
|
/** Auto-save debounce in admin editors */
|
||||||
|
autoSaveDebounce: 800,
|
||||||
|
/** Toast visibility after save */
|
||||||
|
toastSuccess: 2000,
|
||||||
|
/** Toast visibility after error */
|
||||||
|
toastError: 4000,
|
||||||
|
/** Scroll debounce reset */
|
||||||
|
scrollDebounce: 1000,
|
||||||
|
/** DnD highlight duration after drop */
|
||||||
|
dropHighlight: 1500,
|
||||||
|
/** Instagram validation debounce */
|
||||||
|
igValidation: 800,
|
||||||
|
/** Open Day polling interval */
|
||||||
|
openDayPoll: 10000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// --- Border Radius Tokens ---
|
||||||
|
export const RADIUS = {
|
||||||
|
/** Small elements: badges, chips */
|
||||||
|
sm: "rounded-lg",
|
||||||
|
/** Cards, panels, modals */
|
||||||
|
card: "rounded-2xl",
|
||||||
|
/** Pill buttons, tags */
|
||||||
|
full: "rounded-full",
|
||||||
|
/** Form inputs */
|
||||||
|
input: "rounded-lg",
|
||||||
|
} as const;
|
||||||
@@ -86,6 +86,7 @@ export interface NewsItem {
|
|||||||
image?: string;
|
image?: string;
|
||||||
imageFocalX?: number; // 0-100, default 50
|
imageFocalX?: number; // 0-100, default 50
|
||||||
imageFocalY?: number; // 0-100, default 50
|
imageFocalY?: number; // 0-100, default 50
|
||||||
|
imageZoom?: number; // 1-3, default 1
|
||||||
link?: string;
|
link?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user