From d5afaf92ba393fa8f5ba3e8c279ac93c0f3abb1a Mon Sep 17 00:00:00 2001 From: "diana.dolgolyova" Date: Wed, 11 Mar 2026 14:57:39 +0300 Subject: [PATCH] refactor: centralize gold tokens, extract sub-components, clean up unused code - Replace hardcoded hex colors with gold/gold-light/gold-dark Tailwind tokens - Extract Schedule into DayCard, ScheduleFilters, MobileSchedule sub-components - Extract Team into TeamCarousel, TeamMemberInfo sub-components - Add UI_CONFIG for centralized magic numbers (timings, thresholds) - Add reusable IconBadge component, simplify Contact section - Convert Pricing clickable divs to semantic buttons for a11y - Remove unused SocialLinks, btn-outline, btn-ghost, nav-link CSS classes - Fix React setState-during-render error in TeamCarousel (deferred update pattern) Co-Authored-By: Claude Opus 4.6 --- src/app/globals.css | 2 +- src/app/layout.tsx | 8 +- src/app/styles/components.css | 65 +--- src/app/styles/theme.css | 6 +- src/components/layout/Footer.tsx | 2 +- src/components/layout/Header.tsx | 17 +- src/components/sections/About.tsx | 4 +- src/components/sections/Classes.tsx | 9 +- src/components/sections/Contact.tsx | 21 +- src/components/sections/FAQ.tsx | 14 +- src/components/sections/Hero.tsx | 2 +- src/components/sections/Pricing.tsx | 34 +-- src/components/sections/Schedule.tsx | 282 ++---------------- src/components/sections/Team.tsx | 278 +---------------- src/components/sections/schedule/DayCard.tsx | 60 ++++ .../sections/schedule/MobileSchedule.tsx | 127 ++++++++ .../sections/schedule/ScheduleFilters.tsx | 126 ++++++++ src/components/sections/schedule/constants.ts | 15 + src/components/sections/team/TeamCarousel.tsx | 239 +++++++++++++++ .../sections/team/TeamMemberInfo.tsx | 55 ++++ src/components/ui/BackToTop.tsx | 5 +- src/components/ui/BookingModal.tsx | 12 +- src/components/ui/Button.tsx | 4 +- src/components/ui/FloatingHearts.tsx | 5 +- src/components/ui/IconBadge.tsx | 14 + src/components/ui/SectionHeading.tsx | 2 +- src/components/ui/ShowcaseLayout.tsx | 10 +- src/components/ui/SocialLinks.tsx | 31 -- src/lib/config.ts | 21 ++ src/lib/constants.ts | 3 - src/types/index.ts | 2 +- 31 files changed, 784 insertions(+), 691 deletions(-) create mode 100644 src/components/sections/schedule/DayCard.tsx create mode 100644 src/components/sections/schedule/MobileSchedule.tsx create mode 100644 src/components/sections/schedule/ScheduleFilters.tsx create mode 100644 src/components/sections/schedule/constants.ts create mode 100644 src/components/sections/team/TeamCarousel.tsx create mode 100644 src/components/sections/team/TeamMemberInfo.tsx create mode 100644 src/components/ui/IconBadge.tsx delete mode 100644 src/components/ui/SocialLinks.tsx create mode 100644 src/lib/config.ts diff --git a/src/app/globals.css b/src/app/globals.css index 74fa896..fd69f65 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -46,7 +46,7 @@ body { /* ===== Focus ===== */ :focus-visible { - @apply outline-2 outline-offset-2 outline-[#c9a96e]; + @apply outline-2 outline-offset-2 outline-gold; } /* ===== Scrollbar hide utility ===== */ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e961cba..72f4595 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -19,7 +19,7 @@ export const metadata: Metadata = { title: siteContent.meta.title, description: siteContent.meta.description, openGraph: { - title: "BLACK HEART DANCE HOUSE", + title: siteContent.meta.title, description: siteContent.meta.description, locale: "ru_RU", type: "website", @@ -28,13 +28,13 @@ export const metadata: Metadata = { export default function RootLayout({ children, -}: Readonly<{ +}: { children: React.ReactNode; -}>) { +}) { return (
{children}
diff --git a/src/app/styles/components.css b/src/app/styles/components.css index 97c60ee..747eeeb 100644 --- a/src/app/styles/components.css +++ b/src/app/styles/components.css @@ -1,57 +1,11 @@ -/* ===== Navigation ===== */ - -.nav-link { - @apply text-sm font-medium transition-all duration-300; - @apply text-neutral-500; - @apply hover:text-neutral-900; - @apply dark:text-neutral-400 dark:hover:text-white; -} - -.nav-link-active { - @apply text-[#a08050]; - @apply dark:text-[#d4b87a]; -} - -.social-icon { - @apply text-neutral-400 transition-all duration-300; - @apply hover:text-[#a08050]; - @apply dark:text-neutral-500 dark:hover:text-[#d4b87a]; -} - -/* ===== Cards ===== */ - -.card { - @apply rounded-2xl border p-6 transition-all duration-500 cursor-pointer; - @apply border-neutral-200 bg-white; - @apply hover:border-[#c9a96e]/30 hover:shadow-lg; - @apply dark:border-white/[0.08] dark:bg-[#111]; - @apply dark:hover:border-[#c9a96e]/25 dark:hover:bg-[#151515]; - @apply dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.06)]; -} - /* ===== Buttons ===== */ .btn-primary { @apply inline-flex items-center justify-center font-semibold rounded-full transition-all duration-300 cursor-pointer; - @apply bg-[#c9a96e] text-black; - @apply hover:bg-[#d4b87a] hover:shadow-[0_0_30px_rgba(201,169,110,0.35)]; - @apply dark:bg-[#c9a96e] dark:text-black; - @apply dark:hover:bg-[#d4b87a] dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.35)]; -} - -.btn-outline { - @apply inline-flex items-center justify-center font-semibold rounded-full transition-all duration-300 cursor-pointer; - @apply border border-[#c9a96e] text-[#a08050]; - @apply hover:bg-[#c9a96e] hover:text-black; - @apply dark:border-[#c9a96e] dark:text-[#d4b87a]; - @apply dark:hover:bg-[#c9a96e] dark:hover:text-black; -} - -.btn-ghost { - @apply inline-flex items-center justify-center font-medium rounded-full transition-all duration-300 cursor-pointer; - @apply text-neutral-600; - @apply hover:text-[#a08050]; - @apply dark:text-neutral-400 dark:hover:text-[#d4b87a]; + @apply bg-gold text-black; + @apply hover:bg-gold-light hover:shadow-[0_0_30px_rgba(201,169,110,0.35)]; + @apply dark:bg-gold dark:text-black; + @apply dark:hover:bg-gold-light dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.35)]; } /* ===== Scrollbar ===== */ @@ -73,14 +27,3 @@ scrollbar-color: rgb(64 64 64) transparent; } } - -/* ===== Contact ===== */ - -.contact-item { - @apply flex items-center gap-4; -} - -.contact-icon { - @apply shrink-0 text-[#a08050]; - @apply dark:text-[#d4b87a]; -} diff --git a/src/app/styles/theme.css b/src/app/styles/theme.css index 2487dc7..aa55cc2 100644 --- a/src/app/styles/theme.css +++ b/src/app/styles/theme.css @@ -45,8 +45,8 @@ } .accent-text { - @apply text-[#a08050]; - @apply dark:text-[#d4b87a]; + @apply text-gold-dark; + @apply dark:text-gold-light; } /* ===== Layout ===== */ @@ -86,7 +86,7 @@ } .glass-card:hover { - @apply dark:border-[#c9a96e]/15 dark:bg-white/[0.06]; + @apply dark:border-gold/15 dark:bg-white/[0.06]; } /* ===== Photo Filter ===== */ diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index b53437b..22e426b 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -13,7 +13,7 @@ export function Footer() {

Made with - +
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index cd9610c..8b6d4f0 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { Menu, X } from "lucide-react"; import { useState, useEffect } from "react"; import { BRAND, NAV_LINKS } from "@/lib/constants"; +import { UI_CONFIG } from "@/lib/config"; import { HeroLogo } from "@/components/ui/HeroLogo"; import { BookingModal } from "@/components/ui/BookingModal"; @@ -15,7 +16,7 @@ export function Header() { useEffect(() => { function handleScroll() { - setScrolled(window.scrollY > 20); + setScrolled(window.scrollY > UI_CONFIG.scrollThresholds.header); } window.addEventListener("scroll", handleScroll, { passive: true }); return () => window.removeEventListener("scroll", handleScroll); @@ -88,7 +89,7 @@ export function Header() { className="relative text-black transition-transform duration-300 drop-shadow-[0_0_3px_rgba(201,169,110,0.5)] group-hover:scale-110" /> - + {BRAND.shortName} @@ -100,9 +101,9 @@ export function Header() { @@ -112,7 +113,7 @@ export function Header() { })} @@ -145,7 +146,7 @@ export function Header() { onClick={() => setMenuOpen(false)} className={`block py-3 text-base transition-colors ${ isActive - ? "text-[#d4b87a]" + ? "text-gold-light" : "text-neutral-400 hover:text-white" }`} > @@ -158,7 +159,7 @@ export function Header() { setMenuOpen(false); setBookingOpen(true); }} - className="mt-2 w-full rounded-full bg-[#c9a96e] py-3 text-sm font-semibold text-black transition-all hover:bg-[#d4b87a] cursor-pointer" + className="mt-2 w-full rounded-full bg-gold py-3 text-sm font-semibold text-black transition-all hover:bg-gold-light cursor-pointer" > Записаться @@ -168,7 +169,7 @@ export function Header() { {/* Floating booking button — visible on scroll, mobile */} @@ -92,7 +94,7 @@ export function FAQ() { setExpanded(!expanded); if (expanded) setOpenIndex(null); }} - className="inline-flex items-center gap-1.5 rounded-full border border-neutral-200 bg-white px-5 py-2 text-sm font-medium text-neutral-600 transition-all hover:border-[#c9a96e]/40 hover:text-[#c9a96e] dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-neutral-400 dark:hover:border-[#c9a96e]/30 dark:hover:text-[#c9a96e] cursor-pointer" + className="inline-flex items-center gap-1.5 rounded-full border border-neutral-200 bg-white px-5 py-2 text-sm font-medium text-neutral-600 transition-all hover:border-gold/40 hover:text-gold dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-neutral-400 dark:hover:border-gold/30 dark:hover:text-gold cursor-pointer" > {expanded ? "Скрыть" : `Ещё ${faq.items.length - VISIBLE_COUNT} вопросов`} Scroll diff --git a/src/components/sections/Pricing.tsx b/src/components/sections/Pricing.tsx index 7b7eed2..644d3fc 100644 --- a/src/components/sections/Pricing.tsx +++ b/src/components/sections/Pricing.tsx @@ -41,7 +41,7 @@ export function Pricing() { onClick={() => setActiveTab(tab.id)} className={`inline-flex items-center gap-2 rounded-full px-6 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${ activeTab === tab.id - ? "bg-[#c9a96e] text-black shadow-lg shadow-[#c9a96e]/25" + ? "bg-gold text-black shadow-lg shadow-gold/25" : "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-white/[0.06] dark:text-neutral-300 dark:hover:bg-white/[0.1]" }`} > @@ -65,19 +65,19 @@ export function Pricing() { {regularItems.map((item, i) => { const isPopular = i === 0; return ( -
setBookingOpen(true)} - className={`group relative cursor-pointer rounded-2xl border p-5 transition-all duration-300 ${ + className={`group relative cursor-pointer rounded-2xl border p-5 transition-all duration-300 text-left ${ isPopular - ? "border-[#c9a96e]/40 bg-gradient-to-br from-[#c9a96e]/10 via-transparent to-[#c9a96e]/5 dark:from-[#c9a96e]/[0.07] dark:to-[#c9a96e]/[0.02] shadow-lg shadow-[#c9a96e]/10 hover:shadow-xl hover:shadow-[#c9a96e]/20" + ? "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 hover:shadow-xl hover:shadow-gold/20" : "border-neutral-200 bg-white hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]" }`} > {/* Popular badge */} {isPopular && (
- + Популярный @@ -86,7 +86,7 @@ export function Pricing() {
{/* Name */} -

+

{item.name}

@@ -98,22 +98,22 @@ export function Pricing() { )} {/* Price */} -

+

{item.price}

-
+ ); })}
{/* Unlimited — featured card */} {unlimitedItem && ( -
setBookingOpen(true)} className="mt-6 cursor-pointer team-card-glitter rounded-2xl border border-[#c9a96e]/30 bg-gradient-to-r from-[#c9a96e]/10 via-[#c9a96e]/5 to-[#c9a96e]/10 dark:from-[#c9a96e]/[0.06] dark:via-transparent dark:to-[#c9a96e]/[0.06] p-6 sm:p-8 transition-shadow duration-300 hover:shadow-xl hover:shadow-[#c9a96e]/20"> + )}
@@ -139,10 +139,10 @@ export function Pricing() {
{pricing.rentalItems.map((item, i) => ( -
setBookingOpen(true)} - className="cursor-pointer flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 transition-colors hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]" + className="w-full cursor-pointer text-left flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 transition-colors hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]" >

@@ -154,10 +154,10 @@ export function Pricing() {

)}
- + {item.price} -
+ ))}
@@ -172,7 +172,7 @@ export function Pricing() { 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]" > - + {i + 1}

diff --git a/src/components/sections/Schedule.tsx b/src/components/sections/Schedule.tsx index 3c9384a..1f045f6 100644 --- a/src/components/sections/Schedule.tsx +++ b/src/components/sections/Schedule.tsx @@ -1,77 +1,14 @@ "use client"; import { useState, useMemo } from "react"; -import { MapPin, Clock, User, X, ChevronDown } from "lucide-react"; +import { MapPin } from "lucide-react"; import { siteContent } from "@/data/content"; import { SectionHeading } from "@/components/ui/SectionHeading"; import { Reveal } from "@/components/ui/Reveal"; -import type { ScheduleDay } from "@/types/content"; - -const TYPE_DOT: Record = { - "Exotic Pole Dance": "bg-[#c9a96e]", - "Pole Dance": "bg-rose-500", - "Body Plastic": "bg-purple-500", - "Трюковые комбинации с пилоном": "bg-amber-500", -}; - -type StatusFilter = "all" | "hasSlots" | "recruiting"; - -function DayCard({ day }: { day: ScheduleDay }) { - return ( -

- {/* Day header */} -
-
- - {day.dayShort} - - - {day.day} - -
-
- - {/* Classes */} -
- {day.classes.map((cls, i) => ( -
-
-
- - {cls.time} -
- {cls.hasSlots && ( - - есть места - - )} - {cls.recruiting && ( - - набор - - )} -
-
- - {cls.trainer} -
-
-
- - {cls.type} -
- {cls.level && ( - - {cls.level} - - )} -
-
- ))} -
-
- ); -} +import { DayCard } from "./schedule/DayCard"; +import { ScheduleFilters } from "./schedule/ScheduleFilters"; +import { MobileSchedule } from "./schedule/MobileSchedule"; +import type { StatusFilter } from "./schedule/constants"; export function Schedule() { const { schedule } = siteContent; @@ -121,7 +58,7 @@ export function Schedule() { .filter((day) => day.classes.length > 0); }, [location.days, filterTrainer, filterType, filterStatus]); - const hasActiveFilter = filterTrainer || filterType || filterStatus !== "all"; + const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all"); function clearFilters() { setFilterTrainer(null); @@ -129,10 +66,6 @@ export function Schedule() { setFilterStatus("all"); } - const pillBase = "inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-medium transition-all duration-200 cursor-pointer whitespace-nowrap"; - const pillActive = "bg-[#c9a96e]/20 text-[#a08050] border border-[#c9a96e]/40 dark:text-[#d4b87a] dark:border-[#c9a96e]/30"; - const pillInactive = "border border-neutral-200 text-neutral-500 hover:border-neutral-300 dark:border-white/[0.08] dark:text-white/35 dark:hover:border-white/15"; - return (
@@ -171,187 +104,36 @@ export function Schedule() { {/* Compact filters — desktop only */} -
- {/* Class types */} - {types.map((type) => ( - - ))} - - {/* Divider */} - - - {/* Status filters */} - {hasAnySlots && ( - - )} - {hasAnyRecruiting && ( - - )} - - {/* Divider */} - - - {/* Trainer dropdown toggle */} - - - {/* Clear */} - {hasActiveFilter && ( - - )} -
- - {/* Trainer pills — expandable */} - {showTrainers && ( -
- {trainers.map((trainer) => ( - - ))} -
- )} +
{/* Mobile: compact agenda list with tap-to-filter */} -
- {/* Active filter indicator */} - {hasActiveFilter && ( -
-
- {filterTrainer && ( - - - {filterTrainer} - - )} - {filterType && ( - - - {filterType} - - )} -
- -
- )} - - {filteredDays.length > 0 ? ( -
- {filteredDays.map((day) => ( -
- {/* Day header */} -
- - {day.dayShort} - - - {day.day} - -
- - {/* Class rows */} -
- {day.classes.map((cls, i) => ( -
- {/* Time */} - - {cls.time} - - - {/* Info — tappable trainer & type */} -
-
- - {cls.hasSlots && ( - - места - - )} - {cls.recruiting && ( - - набор - - )} - {cls.level && ( - - {cls.level} - - )} -
- -
-
- ))} -
-
- ))} -
- ) : ( -
- Нет занятий по выбранным фильтрам -
- )} -
+
{/* Desktop: grid layout */} diff --git a/src/components/sections/Team.tsx b/src/components/sections/Team.tsx index f3b5ace..37bc510 100644 --- a/src/components/sections/Team.tsx +++ b/src/components/sections/Team.tsx @@ -1,165 +1,15 @@ "use client"; -import { useState, useRef, useCallback, useEffect } from "react"; -import Image from "next/image"; -import { Instagram } from "lucide-react"; +import { useState } from "react"; import { siteContent } from "@/data/content"; import { SectionHeading } from "@/components/ui/SectionHeading"; import { Reveal } from "@/components/ui/Reveal"; - -const AUTO_PLAY_MS = 4500; -const PAUSE_MS = 12000; -const CARD_SPACING = 260; // px between card centers - -function wrapIndex(i: number, total: number) { - return ((i % total) + total) % total; -} - -function getDiff(index: number, active: number, total: number) { - let diff = index - active; - if (diff > total / 2) diff -= total; - if (diff < -total / 2) diff += total; - return diff; -} - -// Interpolation helpers -function lerp(a: number, b: number, t: number) { - return a + (b - a) * t; -} -function clamp(v: number, min: number, max: number) { - return Math.max(min, Math.min(max, v)); -} - -// Slot properties for each position (0=center, 1=near, 2=mid, 3=far, 4=hidden) -const SLOTS = [ - { w: 280, h: 400, opacity: 1, scale: 1, x: 0, brightness: 1, grayscale: 0, z: 10, border: true }, - { w: 220, h: 340, opacity: 0.8, scale: 0.97, x: 260, brightness: 0.6, grayscale: 0.2, z: 5, border: false }, - { w: 180, h: 280, opacity: 0.6, scale: 0.93, x: 470, brightness: 0.45, grayscale: 0.35, z: 3, border: false }, - { w: 150, h: 230, opacity: 0.35, scale: 0.88, x: 640, brightness: 0.3, grayscale: 0.5, z: 2, border: false }, - { w: 120, h: 180, opacity: 0, scale: 0.83, x: 780, brightness: 0.2, grayscale: 0.8, z: 1, border: false }, -]; +import { TeamCarousel } from "@/components/sections/team/TeamCarousel"; +import { TeamMemberInfo } from "@/components/sections/team/TeamMemberInfo"; export function Team() { const { team } = siteContent; - const total = team.members.length; const [activeIndex, setActiveIndex] = useState(0); - const [dragOffset, setDragOffset] = useState(0); - const isDraggingRef = useRef(false); - const pausedUntilRef = useRef(0); - const dragStartRef = useRef<{ x: number; startIndex: number } | null>(null); - - const member = team.members[activeIndex]; - - const goTo = useCallback( - (i: number) => { - setActiveIndex(wrapIndex(i, total)); - setDragOffset(0); - pausedUntilRef.current = Date.now() + PAUSE_MS; - }, - [total] - ); - - // Auto-rotate — completely skip while dragging - useEffect(() => { - const id = setInterval(() => { - if (isDraggingRef.current) return; - if (Date.now() < pausedUntilRef.current) return; - setActiveIndex((i) => (i + 1) % total); - }, AUTO_PLAY_MS); - return () => clearInterval(id); - }, [total]); - - // Pointer handlers - const onPointerDown = useCallback( - (e: React.PointerEvent) => { - (e.target as HTMLElement).setPointerCapture(e.pointerId); - isDraggingRef.current = true; - setActiveIndex((cur) => { - dragStartRef.current = { x: e.clientX, startIndex: cur }; - return cur; - }); - setDragOffset(0); - }, - [] - ); - - const onPointerMove = useCallback( - (e: React.PointerEvent) => { - if (!dragStartRef.current) return; - const dx = e.clientX - dragStartRef.current.x; - setDragOffset(dx); - }, - [] - ); - - const onPointerUp = useCallback(() => { - if (!dragStartRef.current) return; - const startIdx = dragStartRef.current.startIndex; - // Read current dragOffset from state via functional update trick - setDragOffset((currentOffset) => { - const wasDrag = Math.abs(currentOffset) > 10; - const steps = wasDrag ? Math.round(currentOffset / CARD_SPACING) : 0; - if (steps !== 0) { - const newIndex = wrapIndex(startIdx - steps, total); - setActiveIndex(newIndex); - } - return 0; // reset offset - }); - dragStartRef.current = null; - isDraggingRef.current = false; - pausedUntilRef.current = Date.now() + PAUSE_MS; - }, [total]); - - // Compute interpolated style for each card - // During drag, base position is startIndex; otherwise activeIndex - const baseIndex = dragStartRef.current ? dragStartRef.current.startIndex : activeIndex; - - function getCardStyle(index: number) { - const baseDiff = getDiff(index, baseIndex, total); - const fractionalShift = dragOffset / CARD_SPACING; - const continuousDiff = baseDiff + fractionalShift; - const absDiff = Math.abs(continuousDiff); - - if (absDiff > 4) return null; - - // Interpolate between the two nearest slot positions - const lowerSlot = Math.floor(absDiff); - const upperSlot = Math.ceil(absDiff); - const t = absDiff - lowerSlot; - - const s0 = SLOTS[clamp(lowerSlot, 0, 4)]; - const s1 = SLOTS[clamp(upperSlot, 0, 4)]; - - const sign = continuousDiff >= 0 ? 1 : -1; - const x = sign * lerp(s0.x, s1.x, t); - const w = lerp(s0.w, s1.w, t); - const h = lerp(s0.h, s1.h, t); - const opacity = lerp(s0.opacity, s1.opacity, t); - const scale = lerp(s0.scale, s1.scale, t); - const brightness = lerp(s0.brightness, s1.brightness, t); - const grayscale = lerp(s0.grayscale, s1.grayscale, t); - const z = Math.round(lerp(s0.z, s1.z, t)); - const showBorder = absDiff < 0.5; - - if (opacity < 0.02) return null; - - return { - width: w, - height: h, - opacity, - zIndex: z, - transform: `translateX(${x}px) scale(${scale})`, - filter: `brightness(${brightness}) grayscale(${grayscale})`, - borderColor: showBorder ? "rgba(201,169,110,0.3)" : "transparent", - boxShadow: showBorder - ? "0 0 60px rgba(201,169,110,0.12)" - : "none", - transition: isDraggingRef.current - ? "none" - : "all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94)", - isCenter: absDiff < 0.5, - }; - } return (
- {/* Stage */} -
- {/* Spotlight cone */} -
+ - {/* Cards */} - {team.members.map((m, i) => { - const style = getCardStyle(i); - if (!style) return null; - - return ( -
- {m.name} - - {style.isCenter && ( - <> -
-
-

- {m.name} -

-

- {m.role} -

-
- - )} -
- ); - })} - -
- - {/* Member info */} -
- {member.instagram && ( - - - {member.instagram.split("/").filter(Boolean).pop()} - - )} - - {member.description && ( -

- {member.description} -

- )} - - {/* Progress dots */} -
- {team.members.map((_, i) => ( -
-
+
diff --git a/src/components/sections/schedule/DayCard.tsx b/src/components/sections/schedule/DayCard.tsx new file mode 100644 index 0000000..03b23cb --- /dev/null +++ b/src/components/sections/schedule/DayCard.tsx @@ -0,0 +1,60 @@ +import { Clock, User } from "lucide-react"; +import type { ScheduleDay } from "@/types/content"; +import { TYPE_DOT } from "./constants"; + +export function DayCard({ day }: { day: ScheduleDay }) { + return ( +
+ {/* Day header */} +
+
+ + {day.dayShort} + + + {day.day} + +
+
+ + {/* Classes */} +
+ {day.classes.map((cls, i) => ( +
+
+
+ + {cls.time} +
+ {cls.hasSlots && ( + + есть места + + )} + {cls.recruiting && ( + + набор + + )} +
+
+ + {cls.trainer} +
+
+
+ + {cls.type} +
+ {cls.level && ( + + {cls.level} + + )} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/sections/schedule/MobileSchedule.tsx b/src/components/sections/schedule/MobileSchedule.tsx new file mode 100644 index 0000000..42e1bf5 --- /dev/null +++ b/src/components/sections/schedule/MobileSchedule.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { User, X } from "lucide-react"; +import type { ScheduleDay } from "@/types/content"; +import { TYPE_DOT } from "./constants"; + +interface MobileScheduleProps { + filteredDays: ScheduleDay[]; + filterType: string | null; + setFilterType: (type: string | null) => void; + filterTrainer: string | null; + setFilterTrainer: (trainer: string | null) => void; + hasActiveFilter: boolean; + clearFilters: () => void; +} + +export function MobileSchedule({ + filteredDays, + filterType, + setFilterType, + filterTrainer, + setFilterTrainer, + hasActiveFilter, + clearFilters, +}: MobileScheduleProps) { + return ( +
+ {/* Active filter indicator */} + {hasActiveFilter && ( +
+
+ {filterTrainer && ( + + + {filterTrainer} + + )} + {filterType && ( + + + {filterType} + + )} +
+ +
+ )} + + {filteredDays.length > 0 ? ( +
+ {filteredDays.map((day) => ( +
+ {/* Day header */} +
+ + {day.dayShort} + + + {day.day} + +
+ + {/* Class rows */} +
+ {day.classes.map((cls, i) => ( +
+ {/* Time */} + + {cls.time} + + + {/* Info — tappable trainer & type */} +
+
+ + {cls.hasSlots && ( + + места + + )} + {cls.recruiting && ( + + набор + + )} + {cls.level && ( + + {cls.level} + + )} +
+ +
+
+ ))} +
+
+ ))} +
+ ) : ( +
+ Нет занятий по выбранным фильтрам +
+ )} +
+ ); +} diff --git a/src/components/sections/schedule/ScheduleFilters.tsx b/src/components/sections/schedule/ScheduleFilters.tsx new file mode 100644 index 0000000..ee255f8 --- /dev/null +++ b/src/components/sections/schedule/ScheduleFilters.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { User, X, ChevronDown } from "lucide-react"; +import { + TYPE_DOT, + pillBase, + pillActive, + pillInactive, + type StatusFilter, +} from "./constants"; + +interface ScheduleFiltersProps { + types: string[]; + trainers: string[]; + hasAnySlots: boolean; + hasAnyRecruiting: boolean; + filterType: string | null; + setFilterType: (type: string | null) => void; + filterTrainer: string | null; + setFilterTrainer: (trainer: string | null) => void; + filterStatus: StatusFilter; + setFilterStatus: (status: StatusFilter) => void; + showTrainers: boolean; + setShowTrainers: (show: boolean) => void; + hasActiveFilter: boolean; + clearFilters: () => void; +} + +export function ScheduleFilters({ + types, + trainers, + hasAnySlots, + hasAnyRecruiting, + filterType, + setFilterType, + filterTrainer, + setFilterTrainer, + filterStatus, + setFilterStatus, + showTrainers, + setShowTrainers, + hasActiveFilter, + clearFilters, +}: ScheduleFiltersProps) { + return ( + <> +
+ {/* Class types */} + {types.map((type) => ( + + ))} + + {/* Divider */} + + + {/* Status filters */} + {hasAnySlots && ( + + )} + {hasAnyRecruiting && ( + + )} + + {/* Divider */} + + + {/* Trainer dropdown toggle */} + + + {/* Clear */} + {hasActiveFilter && ( + + )} +
+ + {/* Trainer pills — expandable */} + {showTrainers && ( +
+ {trainers.map((trainer) => ( + + ))} +
+ )} + + ); +} diff --git a/src/components/sections/schedule/constants.ts b/src/components/sections/schedule/constants.ts new file mode 100644 index 0000000..af9cd1b --- /dev/null +++ b/src/components/sections/schedule/constants.ts @@ -0,0 +1,15 @@ +export const TYPE_DOT: Record = { + "Exotic Pole Dance": "bg-gold", + "Pole Dance": "bg-rose-500", + "Body Plastic": "bg-purple-500", + "Трюковые комбинации с пилоном": "bg-amber-500", +}; + +export type StatusFilter = "all" | "hasSlots" | "recruiting"; + +export const pillBase = + "inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-medium transition-all duration-200 cursor-pointer whitespace-nowrap"; +export const pillActive = + "bg-gold/20 text-gold-dark border border-gold/40 dark:text-gold-light dark:border-gold/30"; +export const pillInactive = + "border border-neutral-200 text-neutral-500 hover:border-neutral-300 dark:border-white/[0.08] dark:text-white/35 dark:hover:border-white/15"; diff --git a/src/components/sections/team/TeamCarousel.tsx b/src/components/sections/team/TeamCarousel.tsx new file mode 100644 index 0000000..c3895f2 --- /dev/null +++ b/src/components/sections/team/TeamCarousel.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import Image from "next/image"; +import { UI_CONFIG } from "@/lib/config"; +import type { TeamMember } from "@/types/content"; + +const { + autoPlayMs: AUTO_PLAY_MS, + pauseMs: PAUSE_MS, + cardSpacing: CARD_SPACING, +} = UI_CONFIG.team; + +function wrapIndex(i: number, total: number) { + return ((i % total) + total) % total; +} + +function getDiff(index: number, active: number, total: number) { + let diff = index - active; + if (diff > total / 2) diff -= total; + if (diff < -total / 2) diff += total; + return diff; +} + +function lerp(a: number, b: number, t: number) { + return a + (b - a) * t; +} + +function clamp(v: number, min: number, max: number) { + return Math.max(min, Math.min(max, v)); +} + +// Slot properties for each position (0=center, 1=near, 2=mid, 3=far, 4=hidden) +const SLOTS = [ + { w: 280, h: 400, opacity: 1, scale: 1, x: 0, brightness: 1, grayscale: 0, z: 10, border: true }, + { w: 220, h: 340, opacity: 0.8, scale: 0.97, x: 260, brightness: 0.6, grayscale: 0.2, z: 5, border: false }, + { w: 180, h: 280, opacity: 0.6, scale: 0.93, x: 470, brightness: 0.45, grayscale: 0.35, z: 3, border: false }, + { w: 150, h: 230, opacity: 0.35, scale: 0.88, x: 640, brightness: 0.3, grayscale: 0.5, z: 2, border: false }, + { w: 120, h: 180, opacity: 0, scale: 0.83, x: 780, brightness: 0.2, grayscale: 0.8, z: 1, border: false }, +]; + +interface TeamCarouselProps { + members: TeamMember[]; + activeIndex: number; + onActiveChange: (index: number) => void; +} + +export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarouselProps) { + const total = members.length; + const [dragOffset, setDragOffset] = useState(0); + const isDraggingRef = useRef(false); + const pausedUntilRef = useRef(0); + const dragStartRef = useRef<{ x: number; startIndex: number } | null>(null); + + // Pause auto-rotation when activeIndex changes externally (e.g. dot click) + const prevIndexRef = useRef(activeIndex); + useEffect(() => { + if (prevIndexRef.current !== activeIndex) { + prevIndexRef.current = activeIndex; + pausedUntilRef.current = Date.now() + PAUSE_MS; + } + }, [activeIndex]); + + // Auto-rotate — completely skip while dragging + useEffect(() => { + const id = setInterval(() => { + if (isDraggingRef.current) return; + if (Date.now() < pausedUntilRef.current) return; + onActiveChange((activeIndex + 1) % total); + }, AUTO_PLAY_MS); + return () => clearInterval(id); + }, [total, activeIndex, onActiveChange]); + + // Pointer handlers + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + (e.target as HTMLElement).setPointerCapture(e.pointerId); + isDraggingRef.current = true; + dragStartRef.current = { x: e.clientX, startIndex: activeIndex }; + setDragOffset(0); + }, + [activeIndex] + ); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragStartRef.current) return; + const dx = e.clientX - dragStartRef.current.x; + setDragOffset(dx); + }, + [] + ); + + // Deferred index update — avoids calling parent setState during render + // (onLostPointerCapture can fire during React reconciliation) + const pendingIndexRef = useRef(null); + useEffect(() => { + if (pendingIndexRef.current !== null) { + onActiveChange(pendingIndexRef.current); + pendingIndexRef.current = null; + } + }); + + const onPointerUp = useCallback(() => { + if (!dragStartRef.current) return; + const startIdx = dragStartRef.current.startIndex; + const currentOffset = dragOffset; + const wasDrag = Math.abs(currentOffset) > 10; + const steps = wasDrag ? Math.round(currentOffset / CARD_SPACING) : 0; + setDragOffset(0); + if (steps !== 0) { + pendingIndexRef.current = wrapIndex(startIdx - steps, total); + } + dragStartRef.current = null; + isDraggingRef.current = false; + pausedUntilRef.current = Date.now() + PAUSE_MS; + }, [total, dragOffset]); + + // Compute interpolated style for each card + const baseIndex = dragStartRef.current ? dragStartRef.current.startIndex : activeIndex; + + function getCardStyle(index: number) { + const baseDiff = getDiff(index, baseIndex, total); + const fractionalShift = dragOffset / CARD_SPACING; + const continuousDiff = baseDiff + fractionalShift; + const absDiff = Math.abs(continuousDiff); + + if (absDiff > 4) return null; + + const lowerSlot = Math.floor(absDiff); + const upperSlot = Math.ceil(absDiff); + const t = absDiff - lowerSlot; + + const s0 = SLOTS[clamp(lowerSlot, 0, 4)]; + const s1 = SLOTS[clamp(upperSlot, 0, 4)]; + + const sign = continuousDiff >= 0 ? 1 : -1; + const x = sign * lerp(s0.x, s1.x, t); + const w = lerp(s0.w, s1.w, t); + const h = lerp(s0.h, s1.h, t); + const opacity = lerp(s0.opacity, s1.opacity, t); + const scale = lerp(s0.scale, s1.scale, t); + const brightness = lerp(s0.brightness, s1.brightness, t); + const grayscale = lerp(s0.grayscale, s1.grayscale, t); + const z = Math.round(lerp(s0.z, s1.z, t)); + const showBorder = absDiff < 0.5; + + if (opacity < 0.02) return null; + + return { + width: w, + height: h, + opacity, + zIndex: z, + transform: `translateX(${x}px) scale(${scale})`, + filter: `brightness(${brightness}) grayscale(${grayscale})`, + borderColor: showBorder ? "rgba(201,169,110,0.3)" : "transparent", + boxShadow: showBorder + ? "0 0 60px rgba(201,169,110,0.12)" + : "none", + transition: isDraggingRef.current + ? "none" + : "all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94)", + isCenter: absDiff < 0.5, + }; + } + + return ( +
+ {/* Spotlight cone */} +
+ + {/* Cards */} + {members.map((m, i) => { + const style = getCardStyle(i); + if (!style) return null; + + return ( +
+ {m.name} + + {style.isCenter && ( + <> +
+
+

+ {m.name} +

+

+ {m.role} +

+
+ + )} +
+ ); + })} +
+ ); +} diff --git a/src/components/sections/team/TeamMemberInfo.tsx b/src/components/sections/team/TeamMemberInfo.tsx new file mode 100644 index 0000000..d7e7ca3 --- /dev/null +++ b/src/components/sections/team/TeamMemberInfo.tsx @@ -0,0 +1,55 @@ +import { Instagram } from "lucide-react"; +import type { TeamMember } from "@/types/content"; + +interface TeamMemberInfoProps { + members: TeamMember[]; + activeIndex: number; + onSelect: (index: number) => void; +} + +export function TeamMemberInfo({ members, activeIndex, onSelect }: TeamMemberInfoProps) { + const member = members[activeIndex]; + + return ( +
+ {member.instagram && ( + + + {member.instagram.split("/").filter(Boolean).pop()} + + )} + + {member.description && ( +

+ {member.description} +

+ )} + + {/* Progress dots */} +
+ {members.map((_, i) => ( +
+
+ ); +} diff --git a/src/components/ui/BackToTop.tsx b/src/components/ui/BackToTop.tsx index 6834312..cfd2194 100644 --- a/src/components/ui/BackToTop.tsx +++ b/src/components/ui/BackToTop.tsx @@ -2,13 +2,14 @@ import { useState, useEffect } from "react"; import { ChevronUp } from "lucide-react"; +import { UI_CONFIG } from "@/lib/config"; export function BackToTop() { const [visible, setVisible] = useState(false); useEffect(() => { function handleScroll() { - setVisible(window.scrollY > 600); + setVisible(window.scrollY > UI_CONFIG.scrollThresholds.backToTop); } window.addEventListener("scroll", handleScroll, { passive: true }); return () => window.removeEventListener("scroll", handleScroll); @@ -18,7 +19,7 @@ export function BackToTop() { @@ -142,7 +142,7 @@ export function BookingModal({ open, onClose }: BookingModalProps) { onChange={(e) => setName(e.target.value)} placeholder="Ваше имя" required - className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-[#c9a96e]/40 focus:bg-white/[0.06]" + className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]" />
@@ -152,13 +152,13 @@ export function BookingModal({ open, onClose }: BookingModalProps) { onChange={(e) => handlePhoneChange(e.target.value)} placeholder="+375 (__) ___-__-__" required - className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-[#c9a96e]/40 focus:bg-white/[0.06]" + className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]" />