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
+7 -6
View File
@@ -4,11 +4,12 @@ import { useState, useEffect } from "react";
import { Phone, X, Mail, Instagram } from "lucide-react";
import { BRAND } from "@/lib/constants";
import { SignupModal } from "./SignupModal";
import { useBooking } from "@/contexts/BookingContext";
export function FloatingContact() {
const [visible, setVisible] = useState(false);
const [expanded, setExpanded] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const { bookingOpen: modalOpen, openBooking, closeBooking } = useBooking();
useEffect(() => {
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 ${
expanded
? "bg-neutral-700 rotate-0"
: "bg-[#c9a96e] hover:bg-[#d4b87a] shadow-[#c9a96e]/30"
: "bg-gold hover:bg-gold-light shadow-gold/30"
}`}
>
{expanded ? (
@@ -72,7 +73,7 @@ export function FloatingContact() {
{/* Phone */}
<a
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="Позвонить"
>
<Phone size={20} className="text-white" />
@@ -91,8 +92,8 @@ export function FloatingContact() {
{/* Записаться */}
<button
onClick={() => { setModalOpen(true); 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"
onClick={() => { openBooking(); setExpanded(false); }}
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="Записаться"
>
<span className="text-xs font-bold text-black leading-tight text-center">
@@ -104,7 +105,7 @@ export function FloatingContact() {
<SignupModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onClose={closeBooking}
title="Записаться на занятие"
subtitle="Оставьте контактные данные и мы свяжемся с вами"
endpoint="/api/group-booking"
+9 -22
View File
@@ -5,31 +5,17 @@ import { createPortal } from "react-dom";
import Image from "next/image";
import { X, Calendar, ExternalLink } from "lucide-react";
import type { NewsItem } from "@/types/content";
import { formatDateRu } from "@/lib/formatting";
import { useFocusTrap } from "@/hooks/useFocusTrap";
interface NewsModalProps {
item: NewsItem | null;
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) {
const focusTrapRef = useFocusTrap<HTMLDivElement>(!!item);
useEffect(() => {
if (!item) return;
function onKey(e: KeyboardEvent) {
@@ -49,7 +35,7 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
return createPortal(
<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"
aria-modal="true"
aria-label={item.title}
@@ -58,7 +44,8 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div
className="modal-content relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl border border-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()}
>
<button
@@ -82,14 +69,14 @@ export function NewsModal({ item, onClose }: NewsModalProps) {
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 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">
<Calendar size={12} />
{formatDate(item.date)}
{formatDateRu(item.date)}
</span>
<h2 className="mt-2 text-xl sm:text-2xl font-bold text-white leading-tight">
+5 -2
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { X, CheckCircle, Send, Phone as PhoneIcon, Instagram } from "lucide-react";
import { BRAND } from "@/lib/constants";
import { useFocusTrap } from "@/hooks/useFocusTrap";
interface SignupModalProps {
open: boolean;
@@ -47,6 +48,7 @@ export function SignupModal({
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [successData, setSuccessData] = useState<Record<string, unknown> | null>(null);
const focusTrapRef = useFocusTrap<HTMLDivElement>(open);
function handlePhoneChange(raw: string) {
let digits = raw.replace(/\D/g, "");
@@ -141,10 +143,11 @@ export function SignupModal({
if (!open) return null;
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="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()}
>
<button