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:
2026-03-26 19:45:37 +03:00
parent ec08f8e8d5
commit 76307e298b
32 changed files with 613 additions and 319 deletions
@@ -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>
);
}
+17 -1
View File
@@ -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
);
}
+33
View File
@@ -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>
);
}