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 <noreply@anthropic.com>
This commit is contained in:
@@ -46,7 +46,7 @@ body {
|
|||||||
/* ===== Focus ===== */
|
/* ===== Focus ===== */
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
@apply outline-2 outline-offset-2 outline-[#c9a96e];
|
@apply outline-2 outline-offset-2 outline-gold;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Scrollbar hide utility ===== */
|
/* ===== Scrollbar hide utility ===== */
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const metadata: Metadata = {
|
|||||||
title: siteContent.meta.title,
|
title: siteContent.meta.title,
|
||||||
description: siteContent.meta.description,
|
description: siteContent.meta.description,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "BLACK HEART DANCE HOUSE",
|
title: siteContent.meta.title,
|
||||||
description: siteContent.meta.description,
|
description: siteContent.meta.description,
|
||||||
locale: "ru_RU",
|
locale: "ru_RU",
|
||||||
type: "website",
|
type: "website",
|
||||||
@@ -28,13 +28,13 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="ru" className="dark">
|
<html lang="ru" className="dark">
|
||||||
<body
|
<body
|
||||||
className={`${inter.variable} ${oswald.variable} bg-[#050505] text-neutral-50 font-sans antialiased`}
|
className={`${inter.variable} ${oswald.variable} surface-base font-sans antialiased`}
|
||||||
>
|
>
|
||||||
<Header />
|
<Header />
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
|
|||||||
@@ -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 ===== */
|
/* ===== Buttons ===== */
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply inline-flex items-center justify-center font-semibold rounded-full transition-all duration-300 cursor-pointer;
|
@apply inline-flex items-center justify-center font-semibold rounded-full transition-all duration-300 cursor-pointer;
|
||||||
@apply bg-[#c9a96e] text-black;
|
@apply bg-gold text-black;
|
||||||
@apply hover:bg-[#d4b87a] hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
|
@apply hover:bg-gold-light hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
|
||||||
@apply dark:bg-[#c9a96e] dark:text-black;
|
@apply dark:bg-gold dark:text-black;
|
||||||
@apply dark:hover:bg-[#d4b87a] dark:hover:shadow-[0_0_30px_rgba(201,169,110,0.35)];
|
@apply dark:hover:bg-gold-light 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];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Scrollbar ===== */
|
/* ===== Scrollbar ===== */
|
||||||
@@ -73,14 +27,3 @@
|
|||||||
scrollbar-color: rgb(64 64 64) transparent;
|
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];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -45,8 +45,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.accent-text {
|
.accent-text {
|
||||||
@apply text-[#a08050];
|
@apply text-gold-dark;
|
||||||
@apply dark:text-[#d4b87a];
|
@apply dark:text-gold-light;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Layout ===== */
|
/* ===== Layout ===== */
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glass-card:hover {
|
.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 ===== */
|
/* ===== Photo Filter ===== */
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function Footer() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-1.5 text-sm text-neutral-500">
|
<div className="flex items-center gap-1.5 text-sm text-neutral-500">
|
||||||
<span>Made with</span>
|
<span>Made with</span>
|
||||||
<Heart size={14} className="fill-[#c9a96e] text-[#c9a96e]" />
|
<Heart size={14} className="fill-gold text-gold" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { Menu, X } from "lucide-react";
|
import { Menu, X } from "lucide-react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { BRAND, NAV_LINKS } from "@/lib/constants";
|
import { BRAND, NAV_LINKS } from "@/lib/constants";
|
||||||
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
import { HeroLogo } from "@/components/ui/HeroLogo";
|
import { HeroLogo } from "@/components/ui/HeroLogo";
|
||||||
import { BookingModal } from "@/components/ui/BookingModal";
|
import { BookingModal } from "@/components/ui/BookingModal";
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ export function Header() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
setScrolled(window.scrollY > 20);
|
setScrolled(window.scrollY > UI_CONFIG.scrollThresholds.header);
|
||||||
}
|
}
|
||||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
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"
|
className="relative text-black transition-transform duration-300 drop-shadow-[0_0_3px_rgba(201,169,110,0.5)] group-hover:scale-110"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-display text-lg font-bold tracking-tight text-[#c9a96e]">
|
<span className="font-display text-lg font-bold tracking-tight text-gold">
|
||||||
{BRAND.shortName}
|
{BRAND.shortName}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -100,9 +101,9 @@ export function Header() {
|
|||||||
<a
|
<a
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
className={`relative py-1 text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-[#c9a96e] after:transition-all after:duration-300 ${
|
className={`relative py-1 text-sm font-medium transition-all duration-300 after:absolute after:bottom-0 after:left-0 after:h-[2px] after:bg-gold after:transition-all after:duration-300 ${
|
||||||
isActive
|
isActive
|
||||||
? "text-[#d4b87a] after:w-full"
|
? "text-gold-light after:w-full"
|
||||||
: "text-neutral-400 after:w-0 hover:text-white hover:after:w-full"
|
: "text-neutral-400 after:w-0 hover:text-white hover:after:w-full"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -112,7 +113,7 @@ export function Header() {
|
|||||||
})}
|
})}
|
||||||
<button
|
<button
|
||||||
onClick={() => setBookingOpen(true)}
|
onClick={() => setBookingOpen(true)}
|
||||||
className="rounded-full bg-[#c9a96e] px-4 py-1.5 text-sm font-semibold text-black transition-all hover:bg-[#d4b87a] hover:shadow-lg hover:shadow-[#c9a96e]/20 cursor-pointer"
|
className="rounded-full bg-gold px-4 py-1.5 text-sm font-semibold text-black transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/20 cursor-pointer"
|
||||||
>
|
>
|
||||||
Записаться
|
Записаться
|
||||||
</button>
|
</button>
|
||||||
@@ -145,7 +146,7 @@ export function Header() {
|
|||||||
onClick={() => setMenuOpen(false)}
|
onClick={() => setMenuOpen(false)}
|
||||||
className={`block py-3 text-base transition-colors ${
|
className={`block py-3 text-base transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? "text-[#d4b87a]"
|
? "text-gold-light"
|
||||||
: "text-neutral-400 hover:text-white"
|
: "text-neutral-400 hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -158,7 +159,7 @@ export function Header() {
|
|||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
setBookingOpen(true);
|
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"
|
||||||
>
|
>
|
||||||
Записаться
|
Записаться
|
||||||
</button>
|
</button>
|
||||||
@@ -168,7 +169,7 @@ export function Header() {
|
|||||||
{/* Floating booking button — visible on scroll, mobile */}
|
{/* Floating booking button — visible on scroll, mobile */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setBookingOpen(true)}
|
onClick={() => setBookingOpen(true)}
|
||||||
className={`fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-full bg-[#c9a96e] px-5 py-3 text-sm font-semibold text-black shadow-lg shadow-[#c9a96e]/25 transition-all duration-500 hover:bg-[#d4b87a] hover:shadow-xl hover:shadow-[#c9a96e]/30 cursor-pointer md:hidden ${
|
className={`fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-full bg-gold px-5 py-3 text-sm font-semibold text-black shadow-lg shadow-gold/25 transition-all duration-500 hover:bg-gold-light hover:shadow-xl hover:shadow-gold/30 cursor-pointer md:hidden ${
|
||||||
scrolled ? "translate-y-0 opacity-100" : "translate-y-16 opacity-0 pointer-events-none"
|
scrolled ? "translate-y-0 opacity-100" : "translate-y-16 opacity-0 pointer-events-none"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ export function About() {
|
|||||||
{stats.map((stat, i) => (
|
{stats.map((stat, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="group flex flex-col items-center gap-3 rounded-2xl border border-neutral-200 bg-white/50 p-6 transition-all duration-300 hover:border-[#c9a96e]/30 sm:p-8 dark:border-white/[0.06] dark:bg-white/[0.02] dark:hover:border-[#c9a96e]/20"
|
className="group flex flex-col items-center gap-3 rounded-2xl border border-neutral-200 bg-white/50 p-6 transition-all duration-300 hover:border-gold/30 sm:p-8 dark:border-white/[0.06] dark:bg-white/[0.02] dark:hover:border-gold/20"
|
||||||
>
|
>
|
||||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-[#c9a96e]/10 text-[#a08050] transition-colors group-hover:bg-[#c9a96e]/20 dark:text-[#d4b87a]">
|
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gold/10 text-gold-dark transition-colors group-hover:bg-gold/20 dark:text-gold-light">
|
||||||
{stat.icon}
|
{stat.icon}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-display text-3xl font-bold text-neutral-900 sm:text-4xl dark:text-white">
|
<span className="font-display text-3xl font-bold text-neutral-900 sm:text-4xl dark:text-white">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Reveal } from "@/components/ui/Reveal";
|
|||||||
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
|
import { ShowcaseLayout } from "@/components/ui/ShowcaseLayout";
|
||||||
import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
|
import { useShowcaseRotation } from "@/hooks/useShowcaseRotation";
|
||||||
import type { ClassItem } from "@/types";
|
import type { ClassItem } from "@/types";
|
||||||
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
|
|
||||||
const iconMap: Record<string, React.ReactNode> = {
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
flame: <Flame size={20} />,
|
flame: <Flame size={20} />,
|
||||||
@@ -22,7 +23,7 @@ export function Classes() {
|
|||||||
const { classes } = siteContent;
|
const { classes } = siteContent;
|
||||||
const { activeIndex, select, setHovering } = useShowcaseRotation({
|
const { activeIndex, select, setHovering } = useShowcaseRotation({
|
||||||
totalItems: classes.items.length,
|
totalItems: classes.items.length,
|
||||||
autoPlayInterval: 5000,
|
autoPlayInterval: UI_CONFIG.showcase.autoPlayInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,7 +57,7 @@ export function Classes() {
|
|||||||
|
|
||||||
{/* Icon + name overlay */}
|
{/* Icon + name overlay */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-6">
|
<div className="absolute bottom-0 left-0 right-0 p-6">
|
||||||
<div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-[#c9a96e]/20 text-[#d4b87a] backdrop-blur-sm">
|
<div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-lg bg-gold/20 text-gold-light backdrop-blur-sm">
|
||||||
{iconMap[item.icon]}
|
{iconMap[item.icon]}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-white">
|
<h3 className="text-2xl font-bold text-white">
|
||||||
@@ -80,7 +81,7 @@ export function Classes() {
|
|||||||
<div
|
<div
|
||||||
className={`flex h-7 w-7 lg:h-9 lg:w-9 shrink-0 items-center justify-center rounded-lg transition-colors ${
|
className={`flex h-7 w-7 lg:h-9 lg:w-9 shrink-0 items-center justify-center rounded-lg transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-[#c9a96e]/20 text-[#d4b87a]"
|
? "bg-gold/20 text-gold-light"
|
||||||
: "bg-neutral-200/50 text-neutral-500 dark:bg-white/[0.06] dark:text-neutral-400"
|
: "bg-neutral-200/50 text-neutral-500 dark:bg-white/[0.06] dark:text-neutral-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -90,7 +91,7 @@ export function Classes() {
|
|||||||
<p
|
<p
|
||||||
className={`text-xs lg:text-sm font-semibold truncate transition-colors ${
|
className={`text-xs lg:text-sm font-semibold truncate transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? "text-[#c9a96e]"
|
? "text-gold"
|
||||||
: "text-neutral-700 dark:text-neutral-300"
|
: "text-neutral-700 dark:text-neutral-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { siteContent } from "@/data/content";
|
|||||||
import { BRAND } from "@/lib/constants";
|
import { BRAND } from "@/lib/constants";
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
|
import { IconBadge } from "@/components/ui/IconBadge";
|
||||||
|
|
||||||
export function Contact() {
|
export function Contact() {
|
||||||
const { contact } = siteContent;
|
const { contact } = siteContent;
|
||||||
@@ -17,42 +18,34 @@ export function Contact() {
|
|||||||
<div className="mt-10 space-y-5">
|
<div className="mt-10 space-y-5">
|
||||||
{contact.addresses.map((address, i) => (
|
{contact.addresses.map((address, i) => (
|
||||||
<div key={i} className="group flex items-center gap-4">
|
<div key={i} className="group flex items-center gap-4">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#c9a96e]/10 text-[#a08050] transition-colors group-hover:bg-[#c9a96e]/15 dark:bg-[#c9a96e]/10 dark:text-[#d4b87a] dark:group-hover:bg-[#c9a96e]/15">
|
<IconBadge><MapPin size={18} /></IconBadge>
|
||||||
<MapPin size={18} />
|
|
||||||
</div>
|
|
||||||
<p className="body-text">{address}</p>
|
<p className="body-text">{address}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="group flex items-center gap-4">
|
<div className="group flex items-center gap-4">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#c9a96e]/10 text-[#a08050] transition-colors group-hover:bg-[#c9a96e]/15 dark:bg-[#c9a96e]/10 dark:text-[#d4b87a] dark:group-hover:bg-[#c9a96e]/15">
|
<IconBadge><Phone size={18} /></IconBadge>
|
||||||
<Phone size={18} />
|
|
||||||
</div>
|
|
||||||
<a
|
<a
|
||||||
href={`tel:${contact.phone}`}
|
href={`tel:${contact.phone}`}
|
||||||
className="text-neutral-600 transition-colors hover:text-[#a08050] dark:text-neutral-300 dark:hover:text-[#d4b87a]"
|
className="text-neutral-600 transition-colors hover:text-gold-dark dark:text-neutral-300 dark:hover:text-gold-light"
|
||||||
>
|
>
|
||||||
{contact.phone}
|
{contact.phone}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="group flex items-center gap-4">
|
<div className="group flex items-center gap-4">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#c9a96e]/10 text-[#a08050] transition-colors group-hover:bg-[#c9a96e]/15 dark:bg-[#c9a96e]/10 dark:text-[#d4b87a] dark:group-hover:bg-[#c9a96e]/15">
|
<IconBadge><Clock size={18} /></IconBadge>
|
||||||
<Clock size={18} />
|
|
||||||
</div>
|
|
||||||
<p className="body-text">{contact.workingHours}</p>
|
<p className="body-text">{contact.workingHours}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-neutral-200 pt-5 dark:border-white/[0.08]">
|
<div className="border-t border-neutral-200 pt-5 dark:border-white/[0.08]">
|
||||||
<div className="group flex items-center gap-4">
|
<div className="group flex items-center gap-4">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#c9a96e]/10 text-[#a08050] transition-colors group-hover:bg-[#c9a96e]/15 dark:bg-[#c9a96e]/10 dark:text-[#d4b87a] dark:group-hover:bg-[#c9a96e]/15">
|
<IconBadge><Instagram size={18} /></IconBadge>
|
||||||
<Instagram size={18} />
|
|
||||||
</div>
|
|
||||||
<a
|
<a
|
||||||
href={contact.instagram}
|
href={contact.instagram}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-neutral-600 transition-colors hover:text-[#a08050] dark:text-neutral-300 dark:hover:text-[#d4b87a]"
|
className="text-neutral-600 transition-colors hover:text-gold-dark dark:text-neutral-300 dark:hover:text-gold-light"
|
||||||
>
|
>
|
||||||
{BRAND.instagramHandle}
|
{BRAND.instagramHandle}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { siteContent } from "@/data/content";
|
|||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
|
|
||||||
const VISIBLE_COUNT = 4;
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
|
|
||||||
|
const VISIBLE_COUNT = UI_CONFIG.faq.visibleCount;
|
||||||
|
|
||||||
export function FAQ() {
|
export function FAQ() {
|
||||||
const { faq } = siteContent;
|
const { faq } = siteContent;
|
||||||
@@ -36,7 +38,7 @@ export function FAQ() {
|
|||||||
<div
|
<div
|
||||||
className={`rounded-xl border transition-all duration-300 ${
|
className={`rounded-xl border transition-all duration-300 ${
|
||||||
isOpen
|
isOpen
|
||||||
? "border-[#c9a96e]/30 bg-gradient-to-br from-[#c9a96e]/[0.06] via-transparent to-[#c9a96e]/[0.03] shadow-md shadow-[#c9a96e]/5"
|
? "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-[#0a0a0a] dark:hover:border-white/[0.12]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -48,8 +50,8 @@ export function FAQ() {
|
|||||||
<span
|
<span
|
||||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-bold transition-colors duration-300 ${
|
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-bold transition-colors duration-300 ${
|
||||||
isOpen
|
isOpen
|
||||||
? "bg-[#c9a96e] text-black"
|
? "bg-gold text-black"
|
||||||
: "bg-[#c9a96e]/10 text-[#a08050] dark:text-[#d4b87a]"
|
: "bg-gold/10 text-gold-dark dark:text-gold-light"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{idx + 1}
|
{idx + 1}
|
||||||
@@ -62,7 +64,7 @@ export function FAQ() {
|
|||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={16}
|
size={16}
|
||||||
className={`shrink-0 transition-all duration-300 ${
|
className={`shrink-0 transition-all duration-300 ${
|
||||||
isOpen ? "text-[#c9a96e] rotate-180" : "text-neutral-400 dark:text-neutral-500"
|
isOpen ? "text-gold rotate-180" : "text-neutral-400 dark:text-neutral-500"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -92,7 +94,7 @@ export function FAQ() {
|
|||||||
setExpanded(!expanded);
|
setExpanded(!expanded);
|
||||||
if (expanded) setOpenIndex(null);
|
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} вопросов`}
|
{expanded ? "Скрыть" : `Ещё ${faq.items.length - VISIBLE_COUNT} вопросов`}
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function Hero() {
|
|||||||
<div className="hero-cta absolute bottom-8 left-1/2 -translate-x-1/2">
|
<div className="hero-cta absolute bottom-8 left-1/2 -translate-x-1/2">
|
||||||
<a
|
<a
|
||||||
href="#about"
|
href="#about"
|
||||||
className="flex flex-col items-center gap-1 text-neutral-600 transition-colors hover:text-[#d4b87a]"
|
className="flex flex-col items-center gap-1 text-neutral-600 transition-colors hover:text-gold-light"
|
||||||
>
|
>
|
||||||
<span className="text-xs uppercase tracking-widest">Scroll</span>
|
<span className="text-xs uppercase tracking-widest">Scroll</span>
|
||||||
<ChevronDown size={20} className="animate-bounce" />
|
<ChevronDown size={20} className="animate-bounce" />
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function Pricing() {
|
|||||||
onClick={() => setActiveTab(tab.id)}
|
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 ${
|
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
|
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]"
|
: "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) => {
|
{regularItems.map((item, i) => {
|
||||||
const isPopular = i === 0;
|
const isPopular = i === 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => setBookingOpen(true)}
|
onClick={() => 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
|
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]"
|
: "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 */}
|
{/* Popular badge */}
|
||||||
{isPopular && (
|
{isPopular && (
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-[#c9a96e] px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-black shadow-md shadow-[#c9a96e]/30">
|
<span className="inline-flex items-center gap-1 rounded-full bg-gold px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-black shadow-md shadow-gold/30">
|
||||||
<Sparkles size={10} />
|
<Sparkles size={10} />
|
||||||
Популярный
|
Популярный
|
||||||
</span>
|
</span>
|
||||||
@@ -86,7 +86,7 @@ export function Pricing() {
|
|||||||
|
|
||||||
<div className={isPopular ? "mt-1" : ""}>
|
<div className={isPopular ? "mt-1" : ""}>
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<p className={`text-sm font-medium ${isPopular ? "text-[#a08050] dark:text-[#d4b87a]" : "text-neutral-700 dark:text-neutral-300"}`}>
|
<p className={`text-sm font-medium ${isPopular ? "text-gold-dark dark:text-gold-light" : "text-neutral-700 dark:text-neutral-300"}`}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -98,22 +98,22 @@ export function Pricing() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Price */}
|
{/* Price */}
|
||||||
<p className={`mt-3 font-display text-2xl font-bold ${isPopular ? "text-[#c9a96e]" : "text-neutral-900 dark:text-white"}`}>
|
<p className={`mt-3 font-display text-2xl font-bold ${isPopular ? "text-gold" : "text-neutral-900 dark:text-white"}`}>
|
||||||
{item.price}
|
{item.price}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Unlimited — featured card */}
|
{/* Unlimited — featured card */}
|
||||||
{unlimitedItem && (
|
{unlimitedItem && (
|
||||||
<div onClick={() => 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">
|
<button onClick={() => setBookingOpen(true)} className="mt-6 w-full cursor-pointer text-left team-card-glitter rounded-2xl border border-gold/30 bg-gradient-to-r from-gold/10 via-gold/5 to-gold/10 dark:from-gold/[0.06] dark:via-transparent dark:to-gold/[0.06] p-6 sm:p-8 transition-shadow duration-300 hover:shadow-xl hover:shadow-gold/20">
|
||||||
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
||||||
<div className="text-center sm:text-left">
|
<div className="text-center sm:text-left">
|
||||||
<div className="flex items-center justify-center gap-2 sm:justify-start">
|
<div className="flex items-center justify-center gap-2 sm:justify-start">
|
||||||
<Crown size={18} className="text-[#c9a96e]" />
|
<Crown size={18} className="text-gold" />
|
||||||
<p className="text-lg font-bold text-neutral-900 dark:text-white">
|
<p className="text-lg font-bold text-neutral-900 dark:text-white">
|
||||||
{unlimitedItem.name}
|
{unlimitedItem.name}
|
||||||
</p>
|
</p>
|
||||||
@@ -124,11 +124,11 @@ export function Pricing() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="shrink-0 font-display text-3xl font-bold text-[#c9a96e]">
|
<p className="shrink-0 font-display text-3xl font-bold text-gold">
|
||||||
{unlimitedItem.price}
|
{unlimitedItem.price}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -139,10 +139,10 @@ export function Pricing() {
|
|||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
<div className="mx-auto mt-10 max-w-2xl space-y-3">
|
||||||
{pricing.rentalItems.map((item, i) => (
|
{pricing.rentalItems.map((item, i) => (
|
||||||
<div
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => setBookingOpen(true)}
|
onClick={() => 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]"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-neutral-900 dark:text-white">
|
<p className="font-medium text-neutral-900 dark:text-white">
|
||||||
@@ -154,10 +154,10 @@ export function Pricing() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 font-display text-xl font-bold text-[#a08050] dark:text-[#d4b87a]">
|
<span className="shrink-0 font-display text-xl font-bold text-gold-dark dark:text-gold-light">
|
||||||
{item.price}
|
{item.price}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
@@ -172,7 +172,7 @@ export function Pricing() {
|
|||||||
key={i}
|
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]"
|
className="flex gap-4 rounded-2xl border border-neutral-200 bg-white px-5 py-4 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
|
||||||
>
|
>
|
||||||
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#c9a96e]/10 text-xs font-bold text-[#a08050] dark:bg-[#c9a96e]/10 dark:text-[#d4b87a]">
|
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
|
<p className="text-sm leading-relaxed text-neutral-700 dark:text-neutral-300">
|
||||||
|
|||||||
@@ -1,77 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
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 { siteContent } from "@/data/content";
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
import type { ScheduleDay } from "@/types/content";
|
import { DayCard } from "./schedule/DayCard";
|
||||||
|
import { ScheduleFilters } from "./schedule/ScheduleFilters";
|
||||||
const TYPE_DOT: Record<string, string> = {
|
import { MobileSchedule } from "./schedule/MobileSchedule";
|
||||||
"Exotic Pole Dance": "bg-[#c9a96e]",
|
import type { StatusFilter } from "./schedule/constants";
|
||||||
"Pole Dance": "bg-rose-500",
|
|
||||||
"Body Plastic": "bg-purple-500",
|
|
||||||
"Трюковые комбинации с пилоном": "bg-amber-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
type StatusFilter = "all" | "hasSlots" | "recruiting";
|
|
||||||
|
|
||||||
function DayCard({ day }: { day: ScheduleDay }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a] overflow-hidden">
|
|
||||||
{/* Day header */}
|
|
||||||
<div className="border-b border-neutral-100 bg-neutral-50 px-5 py-4 dark:border-white/[0.04] dark:bg-white/[0.02]">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#c9a96e]/10 text-sm font-bold text-[#a08050] dark:bg-[#c9a96e]/10 dark:text-[#d4b87a]">
|
|
||||||
{day.dayShort}
|
|
||||||
</span>
|
|
||||||
<span className="text-base font-semibold text-neutral-900 dark:text-white/90">
|
|
||||||
{day.day}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Classes */}
|
|
||||||
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
|
|
||||||
{day.classes.map((cls, i) => (
|
|
||||||
<div key={i} className={`px-5 py-3.5 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-neutral-500 dark:text-white/40">
|
|
||||||
<Clock size={13} />
|
|
||||||
<span className="font-semibold">{cls.time}</span>
|
|
||||||
</div>
|
|
||||||
{cls.hasSlots && (
|
|
||||||
<span className="shrink-0 rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
|
|
||||||
есть места
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{cls.recruiting && (
|
|
||||||
<span className="shrink-0 rounded-full bg-sky-500/15 border border-sky-500/25 px-2 py-0.5 text-[10px] font-semibold text-sky-600 dark:text-sky-400">
|
|
||||||
набор
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1.5 flex items-center gap-2 text-sm font-medium text-neutral-800 dark:text-white/80">
|
|
||||||
<User size={13} className="shrink-0 text-neutral-400 dark:text-white/30" />
|
|
||||||
{cls.trainer}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`h-2 w-2 shrink-0 rounded-full ${TYPE_DOT[cls.type] ?? "bg-white/30"}`} />
|
|
||||||
<span className="text-xs text-neutral-500 dark:text-white/40">{cls.type}</span>
|
|
||||||
</div>
|
|
||||||
{cls.level && (
|
|
||||||
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-0.5 text-[10px] font-semibold text-rose-600 dark:text-rose-400">
|
|
||||||
{cls.level}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Schedule() {
|
export function Schedule() {
|
||||||
const { schedule } = siteContent;
|
const { schedule } = siteContent;
|
||||||
@@ -121,7 +58,7 @@ export function Schedule() {
|
|||||||
.filter((day) => day.classes.length > 0);
|
.filter((day) => day.classes.length > 0);
|
||||||
}, [location.days, filterTrainer, filterType, filterStatus]);
|
}, [location.days, filterTrainer, filterType, filterStatus]);
|
||||||
|
|
||||||
const hasActiveFilter = filterTrainer || filterType || filterStatus !== "all";
|
const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all");
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
setFilterTrainer(null);
|
setFilterTrainer(null);
|
||||||
@@ -129,10 +66,6 @@ export function Schedule() {
|
|||||||
setFilterStatus("all");
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
id="schedule"
|
id="schedule"
|
||||||
@@ -158,7 +91,7 @@ export function Schedule() {
|
|||||||
}}
|
}}
|
||||||
className={`inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
|
className={`inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
|
||||||
i === locationIndex
|
i === locationIndex
|
||||||
? "bg-[#c9a96e] text-black shadow-[0_0_20px_rgba(201,169,110,0.3)]"
|
? "bg-gold text-black shadow-[0_0_20px_rgba(201,169,110,0.3)]"
|
||||||
: "border border-neutral-300 text-neutral-500 hover:border-neutral-400 hover:text-neutral-700 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20"
|
: "border border-neutral-300 text-neutral-500 hover:border-neutral-400 hover:text-neutral-700 dark:border-white/10 dark:text-neutral-400 dark:hover:text-white dark:hover:border-white/20"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -171,187 +104,36 @@ export function Schedule() {
|
|||||||
|
|
||||||
{/* Compact filters — desktop only */}
|
{/* Compact filters — desktop only */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="mt-5 hidden sm:flex items-center justify-center gap-1.5 flex-wrap">
|
<ScheduleFilters
|
||||||
{/* Class types */}
|
types={types}
|
||||||
{types.map((type) => (
|
trainers={trainers}
|
||||||
<button
|
hasAnySlots={hasAnySlots}
|
||||||
key={type}
|
hasAnyRecruiting={hasAnyRecruiting}
|
||||||
onClick={() => setFilterType(filterType === type ? null : type)}
|
filterType={filterType}
|
||||||
className={`${pillBase} ${filterType === type ? pillActive : pillInactive}`}
|
setFilterType={setFilterType}
|
||||||
>
|
filterTrainer={filterTrainer}
|
||||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${TYPE_DOT[type] ?? "bg-white/30"}`} />
|
setFilterTrainer={setFilterTrainer}
|
||||||
{type}
|
filterStatus={filterStatus}
|
||||||
</button>
|
setFilterStatus={setFilterStatus}
|
||||||
))}
|
showTrainers={showTrainers}
|
||||||
|
setShowTrainers={setShowTrainers}
|
||||||
{/* Divider */}
|
hasActiveFilter={hasActiveFilter}
|
||||||
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
|
clearFilters={clearFilters}
|
||||||
|
/>
|
||||||
{/* Status filters */}
|
|
||||||
{hasAnySlots && (
|
|
||||||
<button
|
|
||||||
onClick={() => setFilterStatus(filterStatus === "hasSlots" ? "all" : "hasSlots")}
|
|
||||||
className={`${pillBase} ${filterStatus === "hasSlots" ? "bg-emerald-500/20 text-emerald-700 border border-emerald-500/40 dark:text-emerald-400 dark:border-emerald-500/30" : pillInactive}`}
|
|
||||||
>
|
|
||||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
|
|
||||||
Есть места
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{hasAnyRecruiting && (
|
|
||||||
<button
|
|
||||||
onClick={() => setFilterStatus(filterStatus === "recruiting" ? "all" : "recruiting")}
|
|
||||||
className={`${pillBase} ${filterStatus === "recruiting" ? "bg-sky-500/20 text-sky-700 border border-sky-500/40 dark:text-sky-400 dark:border-sky-500/30" : pillInactive}`}
|
|
||||||
>
|
|
||||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
|
|
||||||
Набор
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
|
|
||||||
|
|
||||||
{/* Trainer dropdown toggle */}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTrainers(!showTrainers)}
|
|
||||||
className={`${pillBase} ${filterTrainer ? pillActive : pillInactive}`}
|
|
||||||
>
|
|
||||||
<User size={11} />
|
|
||||||
{filterTrainer ?? "Тренер"}
|
|
||||||
<ChevronDown size={10} className={`transition-transform duration-200 ${showTrainers ? "rotate-180" : ""}`} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Clear */}
|
|
||||||
{hasActiveFilter && (
|
|
||||||
<button
|
|
||||||
onClick={clearFilters}
|
|
||||||
className="inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[11px] text-neutral-400 hover:text-neutral-600 dark:text-white/25 dark:hover:text-white/50 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<X size={11} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Trainer pills — expandable */}
|
|
||||||
{showTrainers && (
|
|
||||||
<div className="mt-2 flex flex-wrap items-center justify-center gap-1.5">
|
|
||||||
{trainers.map((trainer) => (
|
|
||||||
<button
|
|
||||||
key={trainer}
|
|
||||||
onClick={() => {
|
|
||||||
setFilterTrainer(filterTrainer === trainer ? null : trainer);
|
|
||||||
setShowTrainers(false);
|
|
||||||
}}
|
|
||||||
className={`${pillBase} ${filterTrainer === trainer ? pillActive : pillInactive}`}
|
|
||||||
>
|
|
||||||
{trainer}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: compact agenda list with tap-to-filter */}
|
{/* Mobile: compact agenda list with tap-to-filter */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="mt-6 px-4 sm:hidden">
|
<MobileSchedule
|
||||||
{/* Active filter indicator */}
|
filteredDays={filteredDays}
|
||||||
{hasActiveFilter && (
|
filterType={filterType}
|
||||||
<div className="mb-3 flex items-center justify-between rounded-xl bg-[#c9a96e]/10 px-4 py-2.5 dark:bg-[#c9a96e]/5">
|
setFilterType={setFilterType}
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-[#a08050] dark:text-[#d4b87a]">
|
filterTrainer={filterTrainer}
|
||||||
{filterTrainer && (
|
setFilterTrainer={setFilterTrainer}
|
||||||
<span className="flex items-center gap-1">
|
hasActiveFilter={hasActiveFilter}
|
||||||
<User size={11} />
|
clearFilters={clearFilters}
|
||||||
{filterTrainer}
|
/>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{filterType && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${TYPE_DOT[filterType] ?? "bg-white/30"}`} />
|
|
||||||
{filterType}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={clearFilters}
|
|
||||||
className="flex items-center gap-1 rounded-full px-2 py-1 text-[11px] text-[#a08050] dark:text-[#d4b87a] active:bg-[#c9a96e]/20"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
Сбросить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredDays.length > 0 ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{filteredDays.map((day) => (
|
|
||||||
<div key={day.day}>
|
|
||||||
{/* Day header */}
|
|
||||||
<div className="flex items-center gap-2.5 py-2.5">
|
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[#c9a96e]/10 text-xs font-bold text-[#a08050] dark:bg-[#c9a96e]/10 dark:text-[#d4b87a]">
|
|
||||||
{day.dayShort}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-neutral-900 dark:text-white/90">
|
|
||||||
{day.day}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Class rows */}
|
|
||||||
<div className="ml-1 border-l-2 border-neutral-200 dark:border-white/[0.08]">
|
|
||||||
{day.classes.map((cls, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={`ml-3 flex items-start gap-3 rounded-lg px-3 py-2 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}
|
|
||||||
>
|
|
||||||
{/* Time */}
|
|
||||||
<span className="shrink-0 w-[72px] text-xs font-semibold tabular-nums text-neutral-500 dark:text-white/40 pt-0.5">
|
|
||||||
{cls.time}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Info — tappable trainer & type */}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button
|
|
||||||
onClick={() => setFilterTrainer(filterTrainer === cls.trainer ? null : cls.trainer)}
|
|
||||||
className={`truncate text-sm font-medium text-left active:opacity-60 ${filterTrainer === cls.trainer ? "text-[#c9a96e] underline underline-offset-2" : "text-neutral-800 dark:text-white/80"}`}
|
|
||||||
>
|
|
||||||
{cls.trainer}
|
|
||||||
</button>
|
|
||||||
{cls.hasSlots && (
|
|
||||||
<span className="shrink-0 rounded-full bg-emerald-500/15 border border-emerald-500/25 px-1.5 py-px text-[9px] font-semibold text-emerald-600 dark:text-emerald-400">
|
|
||||||
места
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{cls.recruiting && (
|
|
||||||
<span className="shrink-0 rounded-full bg-sky-500/15 border border-sky-500/25 px-1.5 py-px text-[9px] font-semibold text-sky-600 dark:text-sky-400">
|
|
||||||
набор
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{cls.level && (
|
|
||||||
<span className="shrink-0 rounded-full bg-rose-500/15 border border-rose-500/25 px-1.5 py-px text-[9px] font-semibold text-rose-600 dark:text-rose-400">
|
|
||||||
{cls.level}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)}
|
|
||||||
className={`mt-0.5 flex items-center gap-1.5 active:opacity-60 ${filterType === cls.type ? "opacity-100" : ""}`}
|
|
||||||
>
|
|
||||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${TYPE_DOT[cls.type] ?? "bg-white/30"}`} />
|
|
||||||
<span className={`text-[11px] ${filterType === cls.type ? "text-[#c9a96e] underline underline-offset-2" : "text-neutral-400 dark:text-white/30"}`}>{cls.type}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">
|
|
||||||
Нет занятий по выбранным фильтрам
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
{/* Desktop: grid layout */}
|
{/* Desktop: grid layout */}
|
||||||
|
|||||||
@@ -1,165 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import Image from "next/image";
|
|
||||||
import { Instagram } from "lucide-react";
|
|
||||||
import { siteContent } from "@/data/content";
|
import { siteContent } from "@/data/content";
|
||||||
import { SectionHeading } from "@/components/ui/SectionHeading";
|
import { SectionHeading } from "@/components/ui/SectionHeading";
|
||||||
import { Reveal } from "@/components/ui/Reveal";
|
import { Reveal } from "@/components/ui/Reveal";
|
||||||
|
import { TeamCarousel } from "@/components/sections/team/TeamCarousel";
|
||||||
const AUTO_PLAY_MS = 4500;
|
import { TeamMemberInfo } from "@/components/sections/team/TeamMemberInfo";
|
||||||
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 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Team() {
|
export function Team() {
|
||||||
const { team } = siteContent;
|
const { team } = siteContent;
|
||||||
const total = team.members.length;
|
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -184,119 +34,17 @@ export function Team() {
|
|||||||
|
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
{/* Stage */}
|
<TeamCarousel
|
||||||
<div
|
members={team.members}
|
||||||
className="relative mx-auto flex items-end justify-center cursor-grab select-none active:cursor-grabbing touch-pan-y"
|
activeIndex={activeIndex}
|
||||||
style={{ height: 440 }}
|
onActiveChange={setActiveIndex}
|
||||||
onPointerDown={onPointerDown}
|
/>
|
||||||
onPointerMove={onPointerMove}
|
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
onPointerCancel={onPointerUp}
|
|
||||||
onLostPointerCapture={onPointerUp}
|
|
||||||
>
|
|
||||||
{/* Spotlight cone */}
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute left-1/2 -translate-x-1/2 bottom-0"
|
|
||||||
style={{
|
|
||||||
width: 400,
|
|
||||||
height: 500,
|
|
||||||
background:
|
|
||||||
"conic-gradient(from 180deg at 50% 0%, transparent 30%, rgba(201,169,110,0.06) 45%, rgba(201,169,110,0.1) 50%, rgba(201,169,110,0.06) 55%, transparent 70%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Cards */}
|
<TeamMemberInfo
|
||||||
{team.members.map((m, i) => {
|
members={team.members}
|
||||||
const style = getCardStyle(i);
|
activeIndex={activeIndex}
|
||||||
if (!style) return null;
|
onSelect={setActiveIndex}
|
||||||
|
/>
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={m.name}
|
|
||||||
className={`absolute bottom-0 overflow-hidden rounded-2xl border pointer-events-none ${style.isCenter ? "team-card-glitter" : ""}`}
|
|
||||||
style={{
|
|
||||||
width: style.width,
|
|
||||||
height: style.height,
|
|
||||||
opacity: style.opacity,
|
|
||||||
zIndex: style.zIndex,
|
|
||||||
transform: style.transform,
|
|
||||||
filter: style.filter,
|
|
||||||
borderColor: style.isCenter ? "transparent" : style.borderColor,
|
|
||||||
boxShadow: style.isCenter
|
|
||||||
? "0 0 40px rgba(201,169,110,0.15), 0 0 80px rgba(201,169,110,0.08)"
|
|
||||||
: style.boxShadow,
|
|
||||||
transition: style.transition,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={m.image}
|
|
||||||
alt={m.name}
|
|
||||||
fill
|
|
||||||
sizes="280px"
|
|
||||||
className="object-cover"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{style.isCenter && (
|
|
||||||
<>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" />
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-5">
|
|
||||||
<h3 className="text-lg font-bold text-white sm:text-xl drop-shadow-lg">
|
|
||||||
{m.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm font-medium text-[#d4b87a] drop-shadow-lg">
|
|
||||||
{m.role}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Member info */}
|
|
||||||
<div
|
|
||||||
key={activeIndex}
|
|
||||||
className="mx-auto mt-8 max-w-lg text-center"
|
|
||||||
style={{
|
|
||||||
animation: "team-info-in 0.6s cubic-bezier(0.16, 1, 0.3, 1)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{member.instagram && (
|
|
||||||
<a
|
|
||||||
href={member.instagram}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 text-sm text-white/40 transition-colors hover:text-[#d4b87a]"
|
|
||||||
>
|
|
||||||
<Instagram size={14} />
|
|
||||||
{member.instagram.split("/").filter(Boolean).pop()}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{member.description && (
|
|
||||||
<p className="mt-3 text-sm leading-relaxed text-white/55">
|
|
||||||
{member.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress dots */}
|
|
||||||
<div className="mt-6 flex items-center justify-center gap-1.5">
|
|
||||||
{team.members.map((_, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => goTo(i)}
|
|
||||||
className={`h-1.5 rounded-full transition-all duration-500 cursor-pointer ${
|
|
||||||
i === activeIndex
|
|
||||||
? "w-6 bg-[#c9a96e]"
|
|
||||||
: "w-1.5 bg-white/15 hover:bg-white/30"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
60
src/components/sections/schedule/DayCard.tsx
Normal file
60
src/components/sections/schedule/DayCard.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="rounded-2xl border border-neutral-200 bg-white dark:border-white/[0.06] dark:bg-[#0a0a0a] overflow-hidden">
|
||||||
|
{/* Day header */}
|
||||||
|
<div className="border-b border-neutral-100 bg-neutral-50 px-5 py-4 dark:border-white/[0.04] dark:bg-white/[0.02]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gold/10 text-sm font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
|
||||||
|
{day.dayShort}
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-semibold text-neutral-900 dark:text-white/90">
|
||||||
|
{day.day}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Classes */}
|
||||||
|
<div className="divide-y divide-neutral-100 dark:divide-white/[0.04]">
|
||||||
|
{day.classes.map((cls, i) => (
|
||||||
|
<div key={i} className={`px-5 py-3.5 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-neutral-500 dark:text-white/40">
|
||||||
|
<Clock size={13} />
|
||||||
|
<span className="font-semibold">{cls.time}</span>
|
||||||
|
</div>
|
||||||
|
{cls.hasSlots && (
|
||||||
|
<span className="shrink-0 rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
|
||||||
|
есть места
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{cls.recruiting && (
|
||||||
|
<span className="shrink-0 rounded-full bg-sky-500/15 border border-sky-500/25 px-2 py-0.5 text-[10px] font-semibold text-sky-600 dark:text-sky-400">
|
||||||
|
набор
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2 text-sm font-medium text-neutral-800 dark:text-white/80">
|
||||||
|
<User size={13} className="shrink-0 text-neutral-400 dark:text-white/30" />
|
||||||
|
{cls.trainer}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`h-2 w-2 shrink-0 rounded-full ${TYPE_DOT[cls.type] ?? "bg-white/30"}`} />
|
||||||
|
<span className="text-xs text-neutral-500 dark:text-white/40">{cls.type}</span>
|
||||||
|
</div>
|
||||||
|
{cls.level && (
|
||||||
|
<span className="rounded-full bg-rose-500/15 border border-rose-500/25 px-2 py-0.5 text-[10px] font-semibold text-rose-600 dark:text-rose-400">
|
||||||
|
{cls.level}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/components/sections/schedule/MobileSchedule.tsx
Normal file
127
src/components/sections/schedule/MobileSchedule.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="mt-6 px-4 sm:hidden">
|
||||||
|
{/* Active filter indicator */}
|
||||||
|
{hasActiveFilter && (
|
||||||
|
<div className="mb-3 flex items-center justify-between rounded-xl bg-gold/10 px-4 py-2.5 dark:bg-gold/5">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-gold-dark dark:text-gold-light">
|
||||||
|
{filterTrainer && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<User size={11} />
|
||||||
|
{filterTrainer}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{filterType && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${TYPE_DOT[filterType] ?? "bg-white/30"}`} />
|
||||||
|
{filterType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="flex items-center gap-1 rounded-full px-2 py-1 text-[11px] text-gold-dark dark:text-gold-light active:bg-gold/20"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredDays.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredDays.map((day) => (
|
||||||
|
<div key={day.day}>
|
||||||
|
{/* Day header */}
|
||||||
|
<div className="flex items-center gap-2.5 py-2.5">
|
||||||
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gold/10 text-xs font-bold text-gold-dark dark:bg-gold/10 dark:text-gold-light">
|
||||||
|
{day.dayShort}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-neutral-900 dark:text-white/90">
|
||||||
|
{day.day}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Class rows */}
|
||||||
|
<div className="ml-1 border-l-2 border-neutral-200 dark:border-white/[0.08]">
|
||||||
|
{day.classes.map((cls, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`ml-3 flex items-start gap-3 rounded-lg px-3 py-2 ${cls.hasSlots ? "bg-emerald-500/5" : cls.recruiting ? "bg-sky-500/5" : ""}`}
|
||||||
|
>
|
||||||
|
{/* Time */}
|
||||||
|
<span className="shrink-0 w-[72px] text-xs font-semibold tabular-nums text-neutral-500 dark:text-white/40 pt-0.5">
|
||||||
|
{cls.time}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Info — tappable trainer & type */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterTrainer(filterTrainer === cls.trainer ? null : cls.trainer)}
|
||||||
|
className={`truncate text-sm font-medium text-left active:opacity-60 ${filterTrainer === cls.trainer ? "text-gold underline underline-offset-2" : "text-neutral-800 dark:text-white/80"}`}
|
||||||
|
>
|
||||||
|
{cls.trainer}
|
||||||
|
</button>
|
||||||
|
{cls.hasSlots && (
|
||||||
|
<span className="shrink-0 rounded-full bg-emerald-500/15 border border-emerald-500/25 px-1.5 py-px text-[9px] font-semibold text-emerald-600 dark:text-emerald-400">
|
||||||
|
места
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{cls.recruiting && (
|
||||||
|
<span className="shrink-0 rounded-full bg-sky-500/15 border border-sky-500/25 px-1.5 py-px text-[9px] font-semibold text-sky-600 dark:text-sky-400">
|
||||||
|
набор
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{cls.level && (
|
||||||
|
<span className="shrink-0 rounded-full bg-rose-500/15 border border-rose-500/25 px-1.5 py-px text-[9px] font-semibold text-rose-600 dark:text-rose-400">
|
||||||
|
{cls.level}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterType(filterType === cls.type ? null : cls.type)}
|
||||||
|
className={`mt-0.5 flex items-center gap-1.5 active:opacity-60 ${filterType === cls.type ? "opacity-100" : ""}`}
|
||||||
|
>
|
||||||
|
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${TYPE_DOT[cls.type] ?? "bg-white/30"}`} />
|
||||||
|
<span className={`text-[11px] ${filterType === cls.type ? "text-gold underline underline-offset-2" : "text-neutral-400 dark:text-white/30"}`}>{cls.type}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-12 text-center text-sm text-neutral-400 dark:text-white/30">
|
||||||
|
Нет занятий по выбранным фильтрам
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/components/sections/schedule/ScheduleFilters.tsx
Normal file
126
src/components/sections/schedule/ScheduleFilters.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="mt-5 hidden sm:flex items-center justify-center gap-1.5 flex-wrap">
|
||||||
|
{/* Class types */}
|
||||||
|
{types.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => setFilterType(filterType === type ? null : type)}
|
||||||
|
className={`${pillBase} ${filterType === type ? pillActive : pillInactive}`}
|
||||||
|
>
|
||||||
|
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${TYPE_DOT[type] ?? "bg-white/30"}`} />
|
||||||
|
{type}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
|
||||||
|
|
||||||
|
{/* Status filters */}
|
||||||
|
{hasAnySlots && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterStatus(filterStatus === "hasSlots" ? "all" : "hasSlots")}
|
||||||
|
className={`${pillBase} ${filterStatus === "hasSlots" ? "bg-emerald-500/20 text-emerald-700 border border-emerald-500/40 dark:text-emerald-400 dark:border-emerald-500/30" : pillInactive}`}
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
|
||||||
|
Есть места
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{hasAnyRecruiting && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFilterStatus(filterStatus === "recruiting" ? "all" : "recruiting")}
|
||||||
|
className={`${pillBase} ${filterStatus === "recruiting" ? "bg-sky-500/20 text-sky-700 border border-sky-500/40 dark:text-sky-400 dark:border-sky-500/30" : pillInactive}`}
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
|
||||||
|
Набор
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<span className="mx-1 h-4 w-px shrink-0 bg-neutral-200 dark:bg-white/10" />
|
||||||
|
|
||||||
|
{/* Trainer dropdown toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTrainers(!showTrainers)}
|
||||||
|
className={`${pillBase} ${filterTrainer ? pillActive : pillInactive}`}
|
||||||
|
>
|
||||||
|
<User size={11} />
|
||||||
|
{filterTrainer ?? "Тренер"}
|
||||||
|
<ChevronDown size={10} className={`transition-transform duration-200 ${showTrainers ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Clear */}
|
||||||
|
{hasActiveFilter && (
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-[11px] text-neutral-400 hover:text-neutral-600 dark:text-white/25 dark:hover:text-white/50 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trainer pills — expandable */}
|
||||||
|
{showTrainers && (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-center gap-1.5">
|
||||||
|
{trainers.map((trainer) => (
|
||||||
|
<button
|
||||||
|
key={trainer}
|
||||||
|
onClick={() => {
|
||||||
|
setFilterTrainer(filterTrainer === trainer ? null : trainer);
|
||||||
|
setShowTrainers(false);
|
||||||
|
}}
|
||||||
|
className={`${pillBase} ${filterTrainer === trainer ? pillActive : pillInactive}`}
|
||||||
|
>
|
||||||
|
{trainer}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/sections/schedule/constants.ts
Normal file
15
src/components/sections/schedule/constants.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export const TYPE_DOT: Record<string, string> = {
|
||||||
|
"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";
|
||||||
239
src/components/sections/team/TeamCarousel.tsx
Normal file
239
src/components/sections/team/TeamCarousel.tsx
Normal file
@@ -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<number | null>(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 (
|
||||||
|
<div
|
||||||
|
className="relative mx-auto flex items-end justify-center cursor-grab select-none active:cursor-grabbing touch-pan-y"
|
||||||
|
style={{ height: UI_CONFIG.team.stageHeight }}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}
|
||||||
|
onLostPointerCapture={onPointerUp}
|
||||||
|
>
|
||||||
|
{/* Spotlight cone */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute left-1/2 -translate-x-1/2 bottom-0"
|
||||||
|
style={{
|
||||||
|
width: 400,
|
||||||
|
height: 500,
|
||||||
|
background:
|
||||||
|
"conic-gradient(from 180deg at 50% 0%, transparent 30%, rgba(201,169,110,0.06) 45%, rgba(201,169,110,0.1) 50%, rgba(201,169,110,0.06) 55%, transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cards */}
|
||||||
|
{members.map((m, i) => {
|
||||||
|
const style = getCardStyle(i);
|
||||||
|
if (!style) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.name}
|
||||||
|
className={`absolute bottom-0 overflow-hidden rounded-2xl border pointer-events-none ${style.isCenter ? "team-card-glitter" : ""}`}
|
||||||
|
style={{
|
||||||
|
width: style.width,
|
||||||
|
height: style.height,
|
||||||
|
opacity: style.opacity,
|
||||||
|
zIndex: style.zIndex,
|
||||||
|
transform: style.transform,
|
||||||
|
filter: style.filter,
|
||||||
|
borderColor: style.isCenter ? "transparent" : style.borderColor,
|
||||||
|
boxShadow: style.isCenter
|
||||||
|
? "0 0 40px rgba(201,169,110,0.15), 0 0 80px rgba(201,169,110,0.08)"
|
||||||
|
: style.boxShadow,
|
||||||
|
transition: style.transition,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={m.image}
|
||||||
|
alt={m.name}
|
||||||
|
fill
|
||||||
|
sizes="280px"
|
||||||
|
className="object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{style.isCenter && (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" />
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-5">
|
||||||
|
<h3 className="text-lg font-bold text-white sm:text-xl drop-shadow-lg">
|
||||||
|
{m.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm font-medium text-gold-light drop-shadow-lg">
|
||||||
|
{m.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/sections/team/TeamMemberInfo.tsx
Normal file
55
src/components/sections/team/TeamMemberInfo.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
key={activeIndex}
|
||||||
|
className="mx-auto mt-8 max-w-lg text-center"
|
||||||
|
style={{
|
||||||
|
animation: "team-info-in 0.6s cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.instagram && (
|
||||||
|
<a
|
||||||
|
href={member.instagram}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-white/40 transition-colors hover:text-gold-light"
|
||||||
|
>
|
||||||
|
<Instagram size={14} />
|
||||||
|
{member.instagram.split("/").filter(Boolean).pop()}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{member.description && (
|
||||||
|
<p className="mt-3 text-sm leading-relaxed text-white/55">
|
||||||
|
{member.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress dots */}
|
||||||
|
<div className="mt-6 flex items-center justify-center gap-1.5">
|
||||||
|
{members.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSelect(i)}
|
||||||
|
className={`h-1.5 rounded-full transition-all duration-500 cursor-pointer ${
|
||||||
|
i === activeIndex
|
||||||
|
? "w-6 bg-gold"
|
||||||
|
: "w-1.5 bg-white/15 hover:bg-white/30"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ChevronUp } from "lucide-react";
|
import { ChevronUp } from "lucide-react";
|
||||||
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
|
|
||||||
export function BackToTop() {
|
export function BackToTop() {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
setVisible(window.scrollY > 600);
|
setVisible(window.scrollY > UI_CONFIG.scrollThresholds.backToTop);
|
||||||
}
|
}
|
||||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
@@ -18,7 +19,7 @@ export function BackToTop() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
aria-label="Наверх"
|
aria-label="Наверх"
|
||||||
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-[#c9a96e]/30 bg-black/60 text-[#d4b87a] backdrop-blur-sm transition-all duration-300 hover:bg-[#c9a96e]/20 hover:border-[#c9a96e]/50 ${
|
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-gold/30 bg-black/60 text-gold-light backdrop-blur-sm transition-all duration-300 hover:bg-gold/20 hover:border-gold/50 ${
|
||||||
visible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0 pointer-events-none"
|
visible ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0 pointer-events-none"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function BookingModal({ open, onClose }: BookingModalProps) {
|
|||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="mt-6 rounded-full bg-[#c9a96e] px-6 py-2.5 text-sm font-semibold text-black transition-all hover:bg-[#d4b87a] cursor-pointer"
|
className="mt-6 rounded-full bg-gold px-6 py-2.5 text-sm font-semibold text-black transition-all hover:bg-gold-light cursor-pointer"
|
||||||
>
|
>
|
||||||
Закрыть
|
Закрыть
|
||||||
</button>
|
</button>
|
||||||
@@ -142,7 +142,7 @@ export function BookingModal({ open, onClose }: BookingModalProps) {
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Ваше имя"
|
placeholder="Ваше имя"
|
||||||
required
|
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]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -152,13 +152,13 @@ export function BookingModal({ open, onClose }: BookingModalProps) {
|
|||||||
onChange={(e) => handlePhoneChange(e.target.value)}
|
onChange={(e) => handlePhoneChange(e.target.value)}
|
||||||
placeholder="+375 (__) ___-__-__"
|
placeholder="+375 (__) ___-__-__"
|
||||||
required
|
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]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#c9a96e] py-3 text-sm font-semibold text-black transition-all hover:bg-[#d4b87a] hover:shadow-lg hover:shadow-[#c9a96e]/20 cursor-pointer"
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gold py-3 text-sm font-semibold text-black transition-all hover:bg-gold-light hover:shadow-lg hover:shadow-gold/20 cursor-pointer"
|
||||||
>
|
>
|
||||||
<Send size={15} />
|
<Send size={15} />
|
||||||
Отправить в Instagram
|
Отправить в Instagram
|
||||||
@@ -178,14 +178,14 @@ export function BookingModal({ open, onClose }: BookingModalProps) {
|
|||||||
href={contact.instagram}
|
href={contact.instagram}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.03] py-3 text-sm font-medium text-neutral-300 transition-all hover:border-[#c9a96e]/30 hover:text-[#d4b87a] cursor-pointer"
|
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.03] py-3 text-sm font-medium text-neutral-300 transition-all hover:border-gold/30 hover:text-gold-light cursor-pointer"
|
||||||
>
|
>
|
||||||
<Instagram size={16} />
|
<Instagram size={16} />
|
||||||
Instagram
|
Instagram
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={`tel:${contact.phone.replace(/\s/g, "")}`}
|
href={`tel:${contact.phone.replace(/\s/g, "")}`}
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.03] py-3 text-sm font-medium text-neutral-300 transition-all hover:border-[#c9a96e]/30 hover:text-[#d4b87a] cursor-pointer"
|
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.03] py-3 text-sm font-medium text-neutral-300 transition-all hover:border-gold/30 hover:text-gold-light cursor-pointer"
|
||||||
>
|
>
|
||||||
<Phone size={16} />
|
<Phone size={16} />
|
||||||
Позвонить
|
Позвонить
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Link from "next/link";
|
|||||||
|
|
||||||
interface ButtonProps {
|
interface ButtonProps {
|
||||||
href?: string;
|
href?: string;
|
||||||
variant?: "primary" | "outline" | "ghost";
|
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -17,13 +16,12 @@ const sizes = {
|
|||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
href,
|
href,
|
||||||
variant = "primary",
|
|
||||||
size = "md",
|
size = "md",
|
||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
onClick,
|
onClick,
|
||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
const classes = `btn-${variant} ${sizes[size]} ${className}`;
|
const classes = `btn-primary ${sizes[size]} ${className}`;
|
||||||
|
|
||||||
if (href) {
|
if (href) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
|
|
||||||
interface Heart {
|
interface Heart {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -15,7 +16,7 @@ export function FloatingHearts() {
|
|||||||
const [hearts, setHearts] = useState<Heart[]>([]);
|
const [hearts, setHearts] = useState<Heart[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const generated: Heart[] = Array.from({ length: 12 }, (_, i) => ({
|
const generated: Heart[] = Array.from({ length: UI_CONFIG.team.floatingHeartsCount }, (_, i) => ({
|
||||||
id: i,
|
id: i,
|
||||||
left: Math.random() * 100,
|
left: Math.random() * 100,
|
||||||
size: 8 + Math.random() * 16,
|
size: 8 + Math.random() * 16,
|
||||||
@@ -33,7 +34,7 @@ export function FloatingHearts() {
|
|||||||
{hearts.map((heart) => (
|
{hearts.map((heart) => (
|
||||||
<div
|
<div
|
||||||
key={heart.id}
|
key={heart.id}
|
||||||
className="absolute text-[#c9a96e]"
|
className="absolute text-gold"
|
||||||
style={{
|
style={{
|
||||||
left: `${heart.left}%`,
|
left: `${heart.left}%`,
|
||||||
bottom: "-20px",
|
bottom: "-20px",
|
||||||
|
|||||||
14
src/components/ui/IconBadge.tsx
Normal file
14
src/components/ui/IconBadge.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
interface IconBadgeProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconBadge({ children, className = "" }: IconBadgeProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gold/10 text-gold-dark transition-colors group-hover:bg-gold/15 dark:text-gold-light ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export function SectionHeading({ children, className = "", centered = false }: S
|
|||||||
{children}
|
{children}
|
||||||
</h2>
|
</h2>
|
||||||
<span
|
<span
|
||||||
className={`mt-4 block h-[1px] w-20 bg-gradient-to-r from-[#c9a96e] to-transparent ${
|
className={`mt-4 block h-[1px] w-20 bg-gradient-to-r from-gold to-transparent ${
|
||||||
centered ? "mx-auto" : ""
|
centered ? "mx-auto" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useEffect, useState, useCallback } from "react";
|
import { useRef, useEffect, useState, useCallback } from "react";
|
||||||
|
import { UI_CONFIG } from "@/lib/config";
|
||||||
|
|
||||||
interface ShowcaseLayoutProps<T> {
|
interface ShowcaseLayoutProps<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
@@ -34,7 +35,7 @@ export function ShowcaseLayout<T>({
|
|||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
setDisplayIndex(activeIndex);
|
setDisplayIndex(activeIndex);
|
||||||
setFading(false);
|
setFading(false);
|
||||||
}, 250);
|
}, UI_CONFIG.showcase.fadeMs);
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [activeIndex, displayIndex]);
|
}, [activeIndex, displayIndex]);
|
||||||
|
|
||||||
@@ -85,8 +86,7 @@ export function ShowcaseLayout<T>({
|
|||||||
const dy = e.changedTouches[0].clientY - touchStart.current.y;
|
const dy = e.changedTouches[0].clientY - touchStart.current.y;
|
||||||
touchStart.current = null;
|
touchStart.current = null;
|
||||||
|
|
||||||
// Only trigger if horizontal swipe is dominant and > 50px
|
if (Math.abs(dx) > UI_CONFIG.showcase.swipeThreshold && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
||||||
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
|
||||||
if (dx < 0 && activeIndex < items.length - 1) {
|
if (dx < 0 && activeIndex < items.length - 1) {
|
||||||
onSelect(activeIndex + 1);
|
onSelect(activeIndex + 1);
|
||||||
} else if (dx > 0 && activeIndex > 0) {
|
} else if (dx > 0 && activeIndex > 0) {
|
||||||
@@ -131,7 +131,7 @@ export function ShowcaseLayout<T>({
|
|||||||
{/* Counter */}
|
{/* Counter */}
|
||||||
{counter && (
|
{counter && (
|
||||||
<div className="mt-3 flex items-center justify-center gap-2 lg:justify-start">
|
<div className="mt-3 flex items-center justify-center gap-2 lg:justify-start">
|
||||||
<span className="text-xs font-medium tabular-nums text-[#c9a96e]">
|
<span className="text-xs font-medium tabular-nums text-gold">
|
||||||
{String(activeIndex + 1).padStart(2, "0")}
|
{String(activeIndex + 1).padStart(2, "0")}
|
||||||
</span>
|
</span>
|
||||||
<span className="h-[1px] w-8 bg-white/10" />
|
<span className="h-[1px] w-8 bg-white/10" />
|
||||||
@@ -155,7 +155,7 @@ export function ShowcaseLayout<T>({
|
|||||||
onClick={() => onSelect(i)}
|
onClick={() => onSelect(i)}
|
||||||
className={`cursor-pointer rounded-xl border-2 text-left transition-all duration-300 ${
|
className={`cursor-pointer rounded-xl border-2 text-left transition-all duration-300 ${
|
||||||
i === activeIndex
|
i === activeIndex
|
||||||
? "border-[#c9a96e]/60 bg-[#c9a96e]/10 dark:bg-[#c9a96e]/5"
|
? "border-gold/60 bg-gold/10 dark:bg-gold/5"
|
||||||
: "border-transparent bg-neutral-100 hover:bg-neutral-200 dark:bg-white/[0.03] dark:hover:bg-white/[0.06]"
|
: "border-transparent bg-neutral-100 hover:bg-neutral-200 dark:bg-white/[0.03] dark:hover:bg-white/[0.06]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Instagram } from "lucide-react";
|
|
||||||
|
|
||||||
interface SocialLinksProps {
|
|
||||||
instagram?: string;
|
|
||||||
instagramHandle?: string;
|
|
||||||
className?: string;
|
|
||||||
iconSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SocialLinks({
|
|
||||||
instagram,
|
|
||||||
instagramHandle,
|
|
||||||
className = "",
|
|
||||||
iconSize = 24,
|
|
||||||
}: SocialLinksProps) {
|
|
||||||
return (
|
|
||||||
<div className={`flex items-center gap-4 ${className}`}>
|
|
||||||
{instagram && (
|
|
||||||
<a
|
|
||||||
href={instagram}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="social-icon flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Instagram size={iconSize} />
|
|
||||||
{instagramHandle && <span className="text-sm font-medium">{instagramHandle}</span>}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
21
src/lib/config.ts
Normal file
21
src/lib/config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export const UI_CONFIG = {
|
||||||
|
scrollThresholds: {
|
||||||
|
header: 20,
|
||||||
|
backToTop: 600,
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
autoPlayMs: 4500,
|
||||||
|
pauseMs: 12000,
|
||||||
|
cardSpacing: 260,
|
||||||
|
stageHeight: 440,
|
||||||
|
floatingHeartsCount: 12,
|
||||||
|
},
|
||||||
|
faq: {
|
||||||
|
visibleCount: 4,
|
||||||
|
},
|
||||||
|
showcase: {
|
||||||
|
autoPlayInterval: 5000,
|
||||||
|
fadeMs: 250,
|
||||||
|
swipeThreshold: 50,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -16,6 +16,3 @@ export const NAV_LINKS: NavLink[] = [
|
|||||||
{ label: "FAQ", href: "#faq" },
|
{ label: "FAQ", href: "#faq" },
|
||||||
{ label: "Контакты", href: "#contact" },
|
{ label: "Контакты", href: "#contact" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const API_BASE_URL =
|
|
||||||
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000/api/v1";
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export type { NavLink } from "./navigation";
|
export type { NavLink } from "./navigation";
|
||||||
export type { ClassItem, TeamMember, FAQItem, PricingItem, ContactInfo, SiteContent } from "./content";
|
export type { ClassItem, TeamMember, FAQItem, PricingItem, ContactInfo, SiteContent, ScheduleClass, ScheduleDay, ScheduleLocation } from "./content";
|
||||||
|
|||||||
Reference in New Issue
Block a user