diff --git a/src/app/admin/_components/AdminSkeleton.tsx b/src/app/admin/_components/AdminSkeleton.tsx new file mode 100644 index 0000000..3558f2c --- /dev/null +++ b/src/app/admin/_components/AdminSkeleton.tsx @@ -0,0 +1,18 @@ +"use client"; + +/** Reusable loading skeleton for admin pages */ +export function AdminSkeleton({ rows = 3 }: { rows?: number }) { + return ( +
+ {/* Title skeleton */} +
+ {/* Content skeletons */} + {Array.from({ length: rows }, (_, i) => ( +
+
+
+
+ ))} +
+ ); +} diff --git a/src/app/admin/_components/ArrayEditor.tsx b/src/app/admin/_components/ArrayEditor.tsx index 2d558d6..a97d156 100644 --- a/src/app/admin/_components/ArrayEditor.tsx +++ b/src/app/admin/_components/ArrayEditor.tsx @@ -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 { items: T[]; @@ -31,6 +32,7 @@ export function ArrayEditor({ hiddenItems, addPosition = "bottom", }: ArrayEditorProps) { + const [confirmDelete, setConfirmDelete] = useState(null); const [dragIndex, setDragIndex] = useState(null); const [insertAt, setInsertAt] = useState(null); const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); @@ -170,6 +172,8 @@ export function ArrayEditor({
handleGripMouseDown(e, i)} + aria-label="Перетащить для сортировки" + role="button" >
@@ -187,7 +191,8 @@ export function ArrayEditor({
, document.body )} + + { if (confirmDelete !== null) removeItem(confirmDelete); setConfirmDelete(null); }} + onCancel={() => setConfirmDelete(null)} + />
); } diff --git a/src/app/admin/_components/CollapsibleSection.tsx b/src/app/admin/_components/CollapsibleSection.tsx new file mode 100644 index 0000000..a81c6a2 --- /dev/null +++ b/src/app/admin/_components/CollapsibleSection.tsx @@ -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 ( +
+ +
+
+
{children}
+
+
+
+ ); +} diff --git a/src/app/admin/_components/ConfirmDialog.tsx b/src/app/admin/_components/ConfirmDialog.tsx new file mode 100644 index 0000000..b9fc9d1 --- /dev/null +++ b/src/app/admin/_components/ConfirmDialog.tsx @@ -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(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( +
+
+
e.stopPropagation()} + > + + +
+ {destructive && ( +
+ +
+ )} +
+

{title}

+

{message}

+
+
+ +
+ + +
+
+
, + document.body + ); +} diff --git a/src/app/admin/_components/PriceField.tsx b/src/app/admin/_components/PriceField.tsx new file mode 100644 index 0000000..b669ff2 --- /dev/null +++ b/src/app/admin/_components/PriceField.tsx @@ -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 ( +
+ +
+ { + 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" + /> + + BYN + +
+
+ ); +} diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx index 0682c16..e0c4017 100644 --- a/src/app/admin/bookings/page.tsx +++ b/src/app/admin/bookings/page.tsx @@ -28,6 +28,7 @@ interface GroupBooking { status: BookingStatus; confirmedDate?: string; confirmedGroup?: string; + confirmedHall?: string; confirmedComment?: string; notes?: string; createdAt: string; diff --git a/src/app/admin/contact/page.tsx b/src/app/admin/contact/page.tsx index 4915722..7a70d61 100644 --- a/src/app/admin/contact/page.tsx +++ b/src/app/admin/contact/page.tsx @@ -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 ( -
- -
-
-
- {children} -
-
-
-
- ); -} - export default function ContactEditorPage() { return ( sectionKey="contact" title="Контакты"> diff --git a/src/app/admin/hero/page.tsx b/src/app/admin/hero/page.tsx index 256ad11..44bc340 100644 --- a/src/app/admin/hero/page.tsx +++ b/src/app/admin/hero/page.tsx @@ -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 } { diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 27c3632..76cb04b 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -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 }, diff --git a/src/app/admin/master-classes/page.tsx b/src/app/admin/master-classes/page.tsx index c8b11bc..d11cc29 100644 --- a/src/app/admin/master-classes/page.tsx +++ b/src/app/admin/master-classes/page.tsx @@ -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 ( -
- -
- { - 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" - /> - - BYN - -
-
- ); -} - interface MasterClassesData { title: string; items: MasterClassItem[]; diff --git a/src/app/admin/open-day/page.tsx b/src/app/admin/open-day/page.tsx index 49662fe..e4e6b95 100644 --- a/src/app/admin/open-day/page.tsx +++ b/src/app/admin/open-day/page.tsx @@ -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({ />
-
- - 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" +
+ onChange({ pricePerClass: parseInt(v) || 0 })} />
@@ -130,12 +129,10 @@ function EventSettings({ {event.discountPrice > 0 && (
- - 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" + onChange({ discountPrice: parseInt(v) || 0 })} />
diff --git a/src/app/admin/pricing/page.tsx b/src/app/admin/pricing/page.tsx index 535647b..ed109f3 100644 --- a/src/app/admin/pricing/page.tsx +++ b/src/app/admin/pricing/page.tsx @@ -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 ( -
- -
- { - 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" - /> - - BYN - -
-
- ); -} - -function CollapsibleSection({ - title, - count, - isOpen, - onToggle, - children, -}: { - title: string; - count?: number; - isOpen: boolean; - onToggle: () => void; - children: React.ReactNode; -}) { - return ( -
- -
-
-
- {children} -
-
-
-
- ); -} - 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; diff --git a/src/app/admin/schedule/page.tsx b/src/app/admin/schedule/page.tsx index 1fc9bbb..081cf3d 100644 --- a/src/app/admin/schedule/page.tsx +++ b/src/app/admin/schedule/page.tsx @@ -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 */}
{/* Time labels */} -
+
{hours.slice(0, -1).map((h) => (
{String(h).padStart(2, "0")}:00
@@ -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({
))} {/* Half-hour lines */} @@ -986,7 +988,7 @@ function CalendarGrid({
))} diff --git a/src/app/globals.css b/src/app/globals.css index 21869c4..c178819 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 { diff --git a/src/app/page.tsx b/src/app/page.tsx index 15c7fb9..680d88b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( <> -
-
- - - - - {openDayData && } - - - - - - - - -
-
+ +
+
+ + + + + {openDayData && } + + + + + + + + +
+
+ ); } diff --git a/src/app/styles/theme.css b/src/app/styles/theme.css index aa55cc2..07a723d 100644 --- a/src/app/styles/theme.css +++ b/src/app/styles/theme.css @@ -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 { diff --git a/src/components/layout/ClientShell.tsx b/src/components/layout/ClientShell.tsx new file mode 100644 index 0000000..d0cf3b0 --- /dev/null +++ b/src/components/layout/ClientShell.tsx @@ -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 {children}; +} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 5b0b142..7b6bde2 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -7,12 +7,13 @@ import { BRAND, NAV_LINKS } from "@/lib/constants"; import { UI_CONFIG } from "@/lib/config"; import { HeroLogo } from "@/components/ui/HeroLogo"; import { SignupModal } from "@/components/ui/SignupModal"; +import { useBooking } from "@/contexts/BookingContext"; export function Header() { const [menuOpen, setMenuOpen] = useState(false); const [scrolled, setScrolled] = useState(false); const [activeSection, setActiveSection] = useState(""); - const [bookingOpen, setBookingOpen] = useState(false); + const { bookingOpen, openBooking, closeBooking } = useBooking(); useEffect(() => { let ticking = false; @@ -29,15 +30,6 @@ export function Header() { 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 const [visibleLinks, setVisibleLinks] = useState(NAV_LINKS); useEffect(() => { @@ -166,7 +158,7 @@ export function Header() {
- setBookingOpen(false)} endpoint="/api/group-booking" /> + ); } diff --git a/src/components/sections/FAQ.tsx b/src/components/sections/FAQ.tsx index 67e0f17..f1fd771 100644 --- a/src/components/sections/FAQ.tsx +++ b/src/components/sections/FAQ.tsx @@ -25,7 +25,7 @@ export function FAQ({ data: faq }: FAQProps) { const hasMore = faq.items.length > VISIBLE_COUNT; return ( -
+
@@ -41,11 +41,12 @@ export function FAQ({ data: faq }: FAQProps) { className={`rounded-xl border transition-all duration-300 ${ isOpen ? "border-gold/30 bg-gradient-to-br from-gold/[0.06] via-transparent to-gold/[0.03] shadow-md shadow-gold/5" - : "border-neutral-200 bg-white hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#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]" }`} >
diff --git a/src/components/sections/News.tsx b/src/components/sections/News.tsx index 7f004e8..bdf5c56 100644 --- a/src/components/sections/News.tsx +++ b/src/components/sections/News.tsx @@ -7,30 +7,12 @@ import { SectionHeading } from "@/components/ui/SectionHeading"; import { Reveal } from "@/components/ui/Reveal"; import { NewsModal } from "@/components/ui/NewsModal"; import type { SiteContent, NewsItem } from "@/types/content"; +import { formatDateRu } from "@/lib/formatting"; interface NewsProps { 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({ item, onClick, @@ -65,7 +47,7 @@ function FeaturedArticle({ > - {formatDate(item.date)} + {formatDateRu(item.date)}

{item.title} @@ -108,7 +90,7 @@ function CompactArticle({ )}
- {formatDate(item.date)} + {formatDateRu(item.date)}

{item.title} diff --git a/src/components/sections/OpenDay.tsx b/src/components/sections/OpenDay.tsx index dbe23cb..da080a8 100644 --- a/src/components/sections/OpenDay.tsx +++ b/src/components/sections/OpenDay.tsx @@ -204,6 +204,7 @@ function ClassCard({ onClick={() => { window.dispatchEvent(new CustomEvent("openTrainerProfile", { detail: cls.trainer })); }} + aria-label={`Профиль тренера: ${cls.trainer}`} 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}`} > diff --git a/src/components/sections/Pricing.tsx b/src/components/sections/Pricing.tsx index 4fff60a..19c0c56 100644 --- a/src/components/sections/Pricing.tsx +++ b/src/components/sections/Pricing.tsx @@ -25,7 +25,7 @@ export function Pricing({ data: pricing }: PricingProps) { const regularItems = pricing.items.filter((item) => !item.featured); return ( -
+
@@ -69,7 +69,7 @@ export function Pricing({ data: pricing }: PricingProps) { className={`group relative rounded-2xl border p-5 transition-all duration-300 ${ isPopular ? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10" - : "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a]" + : "border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-neutral-950" }`} > {/* Popular badge */} @@ -96,7 +96,7 @@ export function Pricing({ data: pricing }: PricingProps) { )} {/* Price */} -

+

{item.price}

@@ -122,7 +122,7 @@ export function Pricing({ data: pricing }: PricingProps) {

)}
-

+

{featuredItem.price}

@@ -137,7 +137,7 @@ export function Pricing({ data: pricing }: PricingProps) { {pricing.rentalItems.map((item, i) => (

@@ -163,7 +163,7 @@ export function Pricing({ data: pricing }: PricingProps) { {pricing.rules.map((rule, i) => (

{i + 1} diff --git a/src/components/sections/team/TeamProfile.tsx b/src/components/sections/team/TeamProfile.tsx index 4f1e3d0..c5b592b 100644 --- a/src/components/sections/team/TeamProfile.tsx +++ b/src/components/sections/team/TeamProfile.tsx @@ -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 { ArrowLeft, Instagram, Trophy, GraduationCap, ExternalLink, X, Clock, MapPin, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; 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; // Extract trainer's groups from schedule using groupId - const groupMap = new Map(); - schedule?.forEach(location => { - location.days.forEach(day => { - day.classes - .filter(c => c.trainer === member.name) - .forEach(c => { - const key = c.groupId - ? `${c.groupId}||${location.name}` - : `${c.trainer}||${c.type}||${location.name}`; - const existing = groupMap.get(key); - if (existing) { - existing.slots.push({ day: day.day, dayShort: day.dayShort, time: c.time }); - if (c.level && !existing.level) existing.level = c.level; - if (c.recruiting) existing.recruiting = true; - } else { - groupMap.set(key, { - type: c.type, - location: location.name, - address: location.address, - slots: [{ day: day.day, dayShort: day.dayShort, time: c.time }], - level: c.level, - recruiting: c.recruiting, - }); - } - }); + const uniqueGroups = useMemo(() => { + const groupMap = new Map(); + schedule?.forEach(location => { + location.days.forEach(day => { + day.classes + .filter(c => c.trainer === member.name) + .forEach(c => { + const key = c.groupId + ? `${c.groupId}||${location.name}` + : `${c.trainer}||${c.type}||${location.name}`; + const existing = groupMap.get(key); + if (existing) { + existing.slots.push({ day: day.day, dayShort: day.dayShort, time: c.time }); + if (c.level && !existing.level) existing.level = c.level; + if (c.recruiting) existing.recruiting = true; + } else { + groupMap.set(key, { + type: c.type, + location: location.name, + address: location.address, + slots: [{ day: day.day, dayShort: day.dayShort, time: c.time }], + level: c.level, + recruiting: c.recruiting, + }); + } + }); + }); }); - }); - const uniqueGroups = Array.from(groupMap.values()).map(g => { - // Merge slots by day, then merge days with identical time sets - const dayMap = new Map(); - const dayOrder: string[] = []; - for (const s of g.slots) { - const existing = dayMap.get(s.day); - if (existing) { - if (!existing.times.includes(s.time)) existing.times.push(s.time); - } else { - dayMap.set(s.day, { dayShort: s.dayShort, times: [s.time] }); - dayOrder.push(s.day); + return Array.from(groupMap.values()).map(g => { + // Merge slots by day, then merge days with identical time sets + const dayMap = new Map(); + const dayOrder: string[] = []; + for (const s of g.slots) { + const existing = dayMap.get(s.day); + if (existing) { + if (!existing.times.includes(s.time)) existing.times.push(s.time); + } else { + dayMap.set(s.day, { dayShort: s.dayShort, times: [s.time] }); + dayOrder.push(s.day); + } } - } - for (const entry of dayMap.values()) entry.times.sort(); - const merged: { days: string[]; times: string[] }[] = []; - const used = new Set(); - for (const day of dayOrder) { - if (used.has(day)) continue; - const entry = dayMap.get(day)!; - const timeKey = entry.times.join("|"); - const days = [entry.dayShort]; - used.add(day); - for (const other of dayOrder) { - if (used.has(other)) continue; - const o = dayMap.get(other)!; - if (o.times.join("|") === timeKey) { days.push(o.dayShort); used.add(other); } + for (const entry of dayMap.values()) entry.times.sort(); + const merged: { days: string[]; times: string[] }[] = []; + const used = new Set(); + for (const day of dayOrder) { + if (used.has(day)) continue; + const entry = dayMap.get(day)!; + const timeKey = entry.times.join("|"); + const days = [entry.dayShort]; + used.add(day); + for (const other of dayOrder) { + if (used.has(other)) continue; + const o = dayMap.get(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 hasBio = hasVictories || hasEducation || hasGroups; diff --git a/src/components/ui/FloatingContact.tsx b/src/components/ui/FloatingContact.tsx index d41fade..a324d18 100644 --- a/src/components/ui/FloatingContact.tsx +++ b/src/components/ui/FloatingContact.tsx @@ -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 */} @@ -91,8 +92,8 @@ export function FloatingContact() { {/* Записаться */}