refactor: comprehensive frontend review — consistency, a11y, code quality
- Replace event dispatchers with BookingContext (Hero, Header, FloatingContact) - Add focus trap hook for modals (SignupModal, NewsModal) - Extract shared components: CollapsibleSection, ConfirmDialog, PriceField, AdminSkeleton - Add delete confirmation dialog to ArrayEditor - Replace hardcoded colors (#050505, #0a0a0a, #c9a96e, #2ecc71) with theme tokens - Add CSS variables --color-surface-deep/dark for consistent dark surfaces - Improve contrast: muted text neutral-500 → neutral-400 in dark mode - Fix modal z-index hierarchy (modals z-60, header z-50, floats z-40) - Consolidate duplicate formatDate → shared formatting.ts - Add useMemo to TeamProfile groupMap computation - Fix typography: responsive price text in Pricing section - Add ARIA labels/expanded to FAQ, OpenDay, ArrayEditor grip handles - Hide number input spinners globally - Reorder admin sidebar: Dashboard → SEO → Bookings → site section order - Use shared PriceField in Open Day editor - Fix schedule grid first time slot (09:00) clipped by container - Fix pre-existing type errors (bookings, hero, db interfaces)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Plus, Trash2, GripVertical, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
interface ArrayEditorProps<T> {
|
||||
items: T[];
|
||||
@@ -31,6 +32,7 @@ export function ArrayEditor<T>({
|
||||
hiddenItems,
|
||||
addPosition = "bottom",
|
||||
}: ArrayEditorProps<T>) {
|
||||
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [insertAt, setInsertAt] = useState<number | null>(null);
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
@@ -170,6 +172,8 @@ export function ArrayEditor<T>({
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
aria-label="Перетащить для сортировки"
|
||||
role="button"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
@@ -187,7 +191,8 @@ export function ArrayEditor<T>({
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
@@ -257,12 +262,15 @@ export function ArrayEditor<T>({
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing rounded p-1 text-neutral-500 hover:text-white transition-colors select-none"
|
||||
onMouseDown={(e) => handleGripMouseDown(e, i)}
|
||||
aria-label="Перетащить для сортировки"
|
||||
role="button"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(i)}
|
||||
aria-label="Удалить элемент"
|
||||
className="rounded p-1 text-neutral-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
@@ -370,6 +378,14 @@ export function ArrayEditor<T>({
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete !== null}
|
||||
title="Удалить элемент?"
|
||||
message="Это действие нельзя отменить."
|
||||
onConfirm={() => { if (confirmDelete !== null) removeItem(confirmDelete); setConfirmDelete(null); }}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
</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;
|
||||
confirmedDate?: string;
|
||||
confirmedGroup?: string;
|
||||
confirmedHall?: string;
|
||||
confirmedComment?: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
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 { InputField } from "../_components/FormField";
|
||||
import { CollapsibleSection } from "../_components/CollapsibleSection";
|
||||
import type { ContactInfo } from "@/types/content";
|
||||
|
||||
// --- 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() {
|
||||
return (
|
||||
<SectionEditor<ContactInfo> sectionKey="contact" title="Контакты">
|
||||
|
||||
@@ -232,7 +232,7 @@ function VideoManager({
|
||||
});
|
||||
}, [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);
|
||||
|
||||
function getLoadRating(mb: number): { label: string; color: string } {
|
||||
|
||||
@@ -29,13 +29,14 @@ import {
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/admin", label: "Дашборд", icon: LayoutDashboard },
|
||||
{ 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/about", label: "О студии", icon: FileText },
|
||||
{ href: "/admin/classes", label: "Направления", icon: BookOpen },
|
||||
{ href: "/admin/team", label: "Команда", icon: Users },
|
||||
{ href: "/admin/open-day", label: "День открытых дверей", icon: DoorOpen },
|
||||
{ href: "/admin/schedule", label: "Расписание", icon: Calendar },
|
||||
{ href: "/admin/bookings", label: "Записи", icon: ClipboardList },
|
||||
{ href: "/admin/pricing", label: "Цены", icon: DollarSign },
|
||||
{ href: "/admin/master-classes", label: "Мастер-классы", icon: Star },
|
||||
{ href: "/admin/news", label: "Новости", icon: Newspaper },
|
||||
|
||||
@@ -5,6 +5,7 @@ import Image from "next/image";
|
||||
import { SectionEditor } from "../_components/SectionEditor";
|
||||
import { InputField, TextareaField, ParticipantLimits, AutocompleteMulti } from "../_components/FormField";
|
||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||
import { PriceField } from "../_components/PriceField";
|
||||
import { Plus, X, Upload, Loader2, AlertCircle, Check, Search } from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import type { MasterClassItem, MasterClassSlot } from "@/types/content";
|
||||
@@ -38,32 +39,6 @@ function itemMatchesLocation(item: MasterClassItem, locationFilter: string): boo
|
||||
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 {
|
||||
title: string;
|
||||
items: MasterClassItem[];
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { adminFetch } from "@/lib/csrf";
|
||||
import { ParticipantLimits, SelectField } from "../_components/FormField";
|
||||
import { PriceField } from "../_components/PriceField";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -100,13 +101,11 @@ function EventSettings({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Цена за занятие (BYN)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={event.pricePerClass}
|
||||
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 className="sm:max-w-xs">
|
||||
<PriceField
|
||||
label="Цена за занятие"
|
||||
value={event.pricePerClass ? `${event.pricePerClass} BYN` : ""}
|
||||
onChange={(v) => onChange({ pricePerClass: parseInt(v) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -130,12 +129,10 @@ function EventSettings({
|
||||
{event.discountPrice > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 mt-3">
|
||||
<div>
|
||||
<label className="block text-sm text-neutral-400 mb-1.5">Цена со скидкой (BYN)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={event.discountPrice}
|
||||
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"
|
||||
<PriceField
|
||||
label="Цена со скидкой"
|
||||
value={event.discountPrice ? `${event.discountPrice} BYN` : ""}
|
||||
onChange={(v) => onChange({ discountPrice: parseInt(v) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { SectionEditor } from "../_components/SectionEditor";
|
||||
import { InputField, SelectField } from "../_components/FormField";
|
||||
import { ArrayEditor } from "../_components/ArrayEditor";
|
||||
import { CollapsibleSection } from "../_components/CollapsibleSection";
|
||||
import { PriceField } from "../_components/PriceField";
|
||||
|
||||
interface PricingItem {
|
||||
name: string;
|
||||
@@ -24,76 +26,6 @@ interface PricingData {
|
||||
showContactHint?: boolean;
|
||||
}
|
||||
|
||||
function PriceField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
||||
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="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>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
count,
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
count?: number;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-neutral-900/30 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
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 ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-300 ease-out"
|
||||
style={{ gridTemplateRows: isOpen ? "1fr" : "0fr" }}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-5 pb-5 space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingContent({ data, update }: { data: PricingData; update: (d: PricingData) => void }) {
|
||||
const [sections, setSections] = useState({ subscriptions: true, rental: true, rules: false });
|
||||
const allOpen = sections.subscriptions && sections.rental && sections.rules;
|
||||
|
||||
@@ -95,8 +95,10 @@ function timeToMinutes(timeStr: string): number {
|
||||
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 {
|
||||
return ((minutes - HOUR_START * 60) / 60) * HOUR_HEIGHT;
|
||||
return ((minutes - HOUR_START * 60) / 60) * HOUR_HEIGHT + GRID_TOP_PAD;
|
||||
}
|
||||
|
||||
function yToMinutes(y: number): number {
|
||||
@@ -927,12 +929,12 @@ function CalendarGrid({
|
||||
{/* Time grid */}
|
||||
<div className="flex">
|
||||
{/* 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) => (
|
||||
<div
|
||||
key={h}
|
||||
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
|
||||
</div>
|
||||
@@ -951,7 +953,7 @@ function CalendarGrid({
|
||||
key={day.day}
|
||||
ref={(el) => { columnRefs.current[di] = el; }}
|
||||
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) => {
|
||||
if (drag) return;
|
||||
// Ignore if hovering over a class block
|
||||
@@ -978,7 +980,7 @@ function CalendarGrid({
|
||||
<div
|
||||
key={h}
|
||||
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 */}
|
||||
@@ -986,7 +988,7 @@ function CalendarGrid({
|
||||
<div
|
||||
key={`${h}-30`}
|
||||
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` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
+16
-2
@@ -11,6 +11,8 @@
|
||||
--color-gold: #c9a96e;
|
||||
--color-gold-light: #d4b87a;
|
||||
--color-gold-dark: #a08050;
|
||||
--color-surface-deep: #050505;
|
||||
--color-surface-dark: #0a0a0a;
|
||||
}
|
||||
|
||||
/* ===== Base ===== */
|
||||
@@ -85,11 +87,23 @@ body {
|
||||
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) #0a0a0a;
|
||||
scrollbar-color: rgba(201, 169, 110, 0.3) var(--color-surface-dark);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@@ -97,7 +111,7 @@ html {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0a0a0a;
|
||||
background: var(--color-surface-dark);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
|
||||
+27
-24
@@ -12,6 +12,7 @@ import { BackToTop } from "@/components/ui/BackToTop";
|
||||
import { FloatingContact } from "@/components/ui/FloatingContact";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { ClientShell } from "@/components/layout/ClientShell";
|
||||
import { getContent } from "@/lib/content";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -29,30 +30,32 @@ export default function HomePage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<Hero data={content.hero} />
|
||||
<About
|
||||
data={content.about}
|
||||
stats={{
|
||||
trainers: content.team.members.length,
|
||||
classes: content.classes.items.length,
|
||||
locations: content.schedule.locations.length,
|
||||
}}
|
||||
/>
|
||||
<Classes data={content.classes} />
|
||||
<Team data={content.team} schedule={content.schedule.locations} />
|
||||
{openDayData && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team.members} />}
|
||||
<Schedule data={content.schedule} classItems={content.classes.items} teamMembers={content.team.members} />
|
||||
<Pricing data={content.pricing} />
|
||||
<MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} />
|
||||
<News data={content.news} />
|
||||
<FAQ data={content.faq} />
|
||||
<Contact data={content.contact} />
|
||||
<BackToTop />
|
||||
<FloatingContact />
|
||||
</main>
|
||||
<Footer />
|
||||
<ClientShell>
|
||||
<Header />
|
||||
<main>
|
||||
<Hero data={content.hero} />
|
||||
<About
|
||||
data={content.about}
|
||||
stats={{
|
||||
trainers: content.team.members.length,
|
||||
classes: content.classes.items.length,
|
||||
locations: content.schedule.locations.length,
|
||||
}}
|
||||
/>
|
||||
<Classes data={content.classes} />
|
||||
<Team data={content.team} schedule={content.schedule.locations} />
|
||||
{openDayData && <OpenDay data={openDayData} popups={content.popups} teamMembers={content.team.members} />}
|
||||
<Schedule data={content.schedule} classItems={content.classes.items} teamMembers={content.team.members} />
|
||||
<Pricing data={content.pricing} />
|
||||
<MasterClasses data={content.masterClasses} regCounts={mcRegCounts} popups={content.popups} />
|
||||
<News data={content.news} />
|
||||
<FAQ data={content.faq} />
|
||||
<Contact data={content.contact} />
|
||||
<BackToTop />
|
||||
<FloatingContact />
|
||||
</main>
|
||||
<Footer />
|
||||
</ClientShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
.surface-base {
|
||||
@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 {
|
||||
@apply bg-neutral-100;
|
||||
@apply dark:bg-[#080808];
|
||||
@apply dark:bg-[var(--color-surface-deep)];
|
||||
}
|
||||
|
||||
.surface-glass {
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
.surface-card {
|
||||
@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 ===== */
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
.muted-text {
|
||||
@apply text-neutral-500;
|
||||
@apply dark:text-neutral-500;
|
||||
@apply dark:text-neutral-400;
|
||||
}
|
||||
|
||||
.accent-text {
|
||||
|
||||
Reference in New Issue
Block a user