-
-
-
+
{BRAND.instagramHandle}
diff --git a/src/components/sections/FAQ.tsx b/src/components/sections/FAQ.tsx
index d74413b..b43a165 100644
--- a/src/components/sections/FAQ.tsx
+++ b/src/components/sections/FAQ.tsx
@@ -6,7 +6,9 @@ import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading";
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() {
const { faq } = siteContent;
@@ -36,7 +38,7 @@ export function FAQ() {
@@ -48,8 +50,8 @@ export function FAQ() {
{idx + 1}
@@ -62,7 +64,7 @@ export function FAQ() {
@@ -92,7 +94,7 @@ export function FAQ() {
setExpanded(!expanded);
if (expanded) setOpenIndex(null);
}}
- className="inline-flex items-center gap-1.5 rounded-full border border-neutral-200 bg-white px-5 py-2 text-sm font-medium text-neutral-600 transition-all hover:border-[#c9a96e]/40 hover:text-[#c9a96e] dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-neutral-400 dark:hover:border-[#c9a96e]/30 dark:hover:text-[#c9a96e] cursor-pointer"
+ className="inline-flex items-center gap-1.5 rounded-full border border-neutral-200 bg-white px-5 py-2 text-sm font-medium text-neutral-600 transition-all hover:border-gold/40 hover:text-gold dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-neutral-400 dark:hover:border-gold/30 dark:hover:text-gold cursor-pointer"
>
{expanded ? "Скрыть" : `Ещё ${faq.items.length - VISIBLE_COUNT} вопросов`}
Scroll
diff --git a/src/components/sections/Pricing.tsx b/src/components/sections/Pricing.tsx
index 7b7eed2..644d3fc 100644
--- a/src/components/sections/Pricing.tsx
+++ b/src/components/sections/Pricing.tsx
@@ -41,7 +41,7 @@ export function Pricing() {
onClick={() => setActiveTab(tab.id)}
className={`inline-flex items-center gap-2 rounded-full px-6 py-2.5 text-sm font-medium transition-all duration-300 cursor-pointer ${
activeTab === tab.id
- ? "bg-[#c9a96e] text-black shadow-lg shadow-[#c9a96e]/25"
+ ? "bg-gold text-black shadow-lg shadow-gold/25"
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-white/[0.06] dark:text-neutral-300 dark:hover:bg-white/[0.1]"
}`}
>
@@ -65,19 +65,19 @@ export function Pricing() {
{regularItems.map((item, i) => {
const isPopular = i === 0;
return (
- setBookingOpen(true)}
- className={`group relative cursor-pointer rounded-2xl border p-5 transition-all duration-300 ${
+ className={`group relative cursor-pointer rounded-2xl border p-5 transition-all duration-300 text-left ${
isPopular
- ? "border-[#c9a96e]/40 bg-gradient-to-br from-[#c9a96e]/10 via-transparent to-[#c9a96e]/5 dark:from-[#c9a96e]/[0.07] dark:to-[#c9a96e]/[0.02] shadow-lg shadow-[#c9a96e]/10 hover:shadow-xl hover:shadow-[#c9a96e]/20"
+ ? "border-gold/40 bg-gradient-to-br from-gold/10 via-transparent to-gold/5 dark:from-gold/[0.07] dark:to-gold/[0.02] shadow-lg shadow-gold/10 hover:shadow-xl hover:shadow-gold/20"
: "border-neutral-200 bg-white hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]"
}`}
>
{/* Popular badge */}
{isPopular && (
-
+
Популярный
@@ -86,7 +86,7 @@ export function Pricing() {
{/* Name */}
-
+
{item.name}
@@ -98,22 +98,22 @@ export function Pricing() {
)}
{/* Price */}
-
+
{item.price}
-
+
);
})}
{/* Unlimited — featured card */}
{unlimitedItem && (
- setBookingOpen(true)} className="mt-6 cursor-pointer team-card-glitter rounded-2xl border border-[#c9a96e]/30 bg-gradient-to-r from-[#c9a96e]/10 via-[#c9a96e]/5 to-[#c9a96e]/10 dark:from-[#c9a96e]/[0.06] dark:via-transparent dark:to-[#c9a96e]/[0.06] p-6 sm:p-8 transition-shadow duration-300 hover:shadow-xl hover:shadow-[#c9a96e]/20">
+
)}
@@ -139,10 +139,10 @@ export function Pricing() {
{pricing.rentalItems.map((item, i) => (
-
setBookingOpen(true)}
- className="cursor-pointer flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 transition-colors hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]"
+ className="w-full cursor-pointer text-left flex items-center justify-between gap-4 rounded-2xl border border-neutral-200 bg-white px-6 py-5 transition-colors hover:border-neutral-300 dark:border-white/[0.06] dark:bg-[#0a0a0a] dark:hover:border-white/[0.12]"
>
@@ -154,10 +154,10 @@ export function Pricing() {
)}
-
+
{item.price}
-
+
))}
@@ -172,7 +172,7 @@ export function Pricing() {
key={i}
className="flex gap-4 rounded-2xl border border-neutral-200 bg-white px-5 py-4 dark:border-white/[0.06] dark:bg-[#0a0a0a]"
>
-
+
{i + 1}
diff --git a/src/components/sections/Schedule.tsx b/src/components/sections/Schedule.tsx
index 3c9384a..1f045f6 100644
--- a/src/components/sections/Schedule.tsx
+++ b/src/components/sections/Schedule.tsx
@@ -1,77 +1,14 @@
"use client";
import { useState, useMemo } from "react";
-import { MapPin, Clock, User, X, ChevronDown } from "lucide-react";
+import { MapPin } from "lucide-react";
import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
-import type { ScheduleDay } from "@/types/content";
-
-const TYPE_DOT: Record = {
- "Exotic Pole Dance": "bg-[#c9a96e]",
- "Pole Dance": "bg-rose-500",
- "Body Plastic": "bg-purple-500",
- "Трюковые комбинации с пилоном": "bg-amber-500",
-};
-
-type StatusFilter = "all" | "hasSlots" | "recruiting";
-
-function DayCard({ day }: { day: ScheduleDay }) {
- return (
-
- {/* Day header */}
-
-
-
- {day.dayShort}
-
-
- {day.day}
-
-
-
-
- {/* Classes */}
-
- {day.classes.map((cls, i) => (
-
-
-
-
- {cls.time}
-
- {cls.hasSlots && (
-
- есть места
-
- )}
- {cls.recruiting && (
-
- набор
-
- )}
-
-
-
- {cls.trainer}
-
-
-
-
- {cls.type}
-
- {cls.level && (
-
- {cls.level}
-
- )}
-
-
- ))}
-
-
- );
-}
+import { DayCard } from "./schedule/DayCard";
+import { ScheduleFilters } from "./schedule/ScheduleFilters";
+import { MobileSchedule } from "./schedule/MobileSchedule";
+import type { StatusFilter } from "./schedule/constants";
export function Schedule() {
const { schedule } = siteContent;
@@ -121,7 +58,7 @@ export function Schedule() {
.filter((day) => day.classes.length > 0);
}, [location.days, filterTrainer, filterType, filterStatus]);
- const hasActiveFilter = filterTrainer || filterType || filterStatus !== "all";
+ const hasActiveFilter = !!(filterTrainer || filterType || filterStatus !== "all");
function clearFilters() {
setFilterTrainer(null);
@@ -129,10 +66,6 @@ export function Schedule() {
setFilterStatus("all");
}
- const pillBase = "inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-medium transition-all duration-200 cursor-pointer whitespace-nowrap";
- const pillActive = "bg-[#c9a96e]/20 text-[#a08050] border border-[#c9a96e]/40 dark:text-[#d4b87a] dark:border-[#c9a96e]/30";
- const pillInactive = "border border-neutral-200 text-neutral-500 hover:border-neutral-300 dark:border-white/[0.08] dark:text-white/35 dark:hover:border-white/15";
-
return (
@@ -171,187 +104,36 @@ export function Schedule() {
{/* Compact filters — desktop only */}
-
- {/* Class types */}
- {types.map((type) => (
-
- ))}
-
- {/* Divider */}
-
-
- {/* Status filters */}
- {hasAnySlots && (
-
- )}
- {hasAnyRecruiting && (
-
- )}
-
- {/* Divider */}
-
-
- {/* Trainer dropdown toggle */}
-
-
- {/* Clear */}
- {hasActiveFilter && (
-
- )}
-
-
- {/* Trainer pills — expandable */}
- {showTrainers && (
-
- {trainers.map((trainer) => (
-
- ))}
-
- )}
+
{/* Mobile: compact agenda list with tap-to-filter */}
-
- {/* Active filter indicator */}
- {hasActiveFilter && (
-
-
- {filterTrainer && (
-
-
- {filterTrainer}
-
- )}
- {filterType && (
-
-
- {filterType}
-
- )}
-
-
-
- )}
-
- {filteredDays.length > 0 ? (
-
- {filteredDays.map((day) => (
-
- {/* Day header */}
-
-
- {day.dayShort}
-
-
- {day.day}
-
-
-
- {/* Class rows */}
-
- {day.classes.map((cls, i) => (
-
- {/* Time */}
-
- {cls.time}
-
-
- {/* Info — tappable trainer & type */}
-
-
-
- {cls.hasSlots && (
-
- места
-
- )}
- {cls.recruiting && (
-
- набор
-
- )}
- {cls.level && (
-
- {cls.level}
-
- )}
-
-
-
-
- ))}
-
-
- ))}
-
- ) : (
-
- Нет занятий по выбранным фильтрам
-
- )}
-
+
{/* Desktop: grid layout */}
diff --git a/src/components/sections/Team.tsx b/src/components/sections/Team.tsx
index f3b5ace..37bc510 100644
--- a/src/components/sections/Team.tsx
+++ b/src/components/sections/Team.tsx
@@ -1,165 +1,15 @@
"use client";
-import { useState, useRef, useCallback, useEffect } from "react";
-import Image from "next/image";
-import { Instagram } from "lucide-react";
+import { useState } from "react";
import { siteContent } from "@/data/content";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { Reveal } from "@/components/ui/Reveal";
-
-const AUTO_PLAY_MS = 4500;
-const PAUSE_MS = 12000;
-const CARD_SPACING = 260; // px between card centers
-
-function wrapIndex(i: number, total: number) {
- return ((i % total) + total) % total;
-}
-
-function getDiff(index: number, active: number, total: number) {
- let diff = index - active;
- if (diff > total / 2) diff -= total;
- if (diff < -total / 2) diff += total;
- return diff;
-}
-
-// Interpolation helpers
-function lerp(a: number, b: number, t: number) {
- return a + (b - a) * t;
-}
-function clamp(v: number, min: number, max: number) {
- return Math.max(min, Math.min(max, v));
-}
-
-// Slot properties for each position (0=center, 1=near, 2=mid, 3=far, 4=hidden)
-const SLOTS = [
- { w: 280, h: 400, opacity: 1, scale: 1, x: 0, brightness: 1, grayscale: 0, z: 10, border: true },
- { w: 220, h: 340, opacity: 0.8, scale: 0.97, x: 260, brightness: 0.6, grayscale: 0.2, z: 5, border: false },
- { w: 180, h: 280, opacity: 0.6, scale: 0.93, x: 470, brightness: 0.45, grayscale: 0.35, z: 3, border: false },
- { w: 150, h: 230, opacity: 0.35, scale: 0.88, x: 640, brightness: 0.3, grayscale: 0.5, z: 2, border: false },
- { w: 120, h: 180, opacity: 0, scale: 0.83, x: 780, brightness: 0.2, grayscale: 0.8, z: 1, border: false },
-];
+import { TeamCarousel } from "@/components/sections/team/TeamCarousel";
+import { TeamMemberInfo } from "@/components/sections/team/TeamMemberInfo";
export function Team() {
const { team } = siteContent;
- const total = team.members.length;
const [activeIndex, setActiveIndex] = useState(0);
- const [dragOffset, setDragOffset] = useState(0);
- const isDraggingRef = useRef(false);
- const pausedUntilRef = useRef(0);
- const dragStartRef = useRef<{ x: number; startIndex: number } | null>(null);
-
- const member = team.members[activeIndex];
-
- const goTo = useCallback(
- (i: number) => {
- setActiveIndex(wrapIndex(i, total));
- setDragOffset(0);
- pausedUntilRef.current = Date.now() + PAUSE_MS;
- },
- [total]
- );
-
- // Auto-rotate — completely skip while dragging
- useEffect(() => {
- const id = setInterval(() => {
- if (isDraggingRef.current) return;
- if (Date.now() < pausedUntilRef.current) return;
- setActiveIndex((i) => (i + 1) % total);
- }, AUTO_PLAY_MS);
- return () => clearInterval(id);
- }, [total]);
-
- // Pointer handlers
- const onPointerDown = useCallback(
- (e: React.PointerEvent) => {
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
- isDraggingRef.current = true;
- setActiveIndex((cur) => {
- dragStartRef.current = { x: e.clientX, startIndex: cur };
- return cur;
- });
- setDragOffset(0);
- },
- []
- );
-
- const onPointerMove = useCallback(
- (e: React.PointerEvent) => {
- if (!dragStartRef.current) return;
- const dx = e.clientX - dragStartRef.current.x;
- setDragOffset(dx);
- },
- []
- );
-
- const onPointerUp = useCallback(() => {
- if (!dragStartRef.current) return;
- const startIdx = dragStartRef.current.startIndex;
- // Read current dragOffset from state via functional update trick
- setDragOffset((currentOffset) => {
- const wasDrag = Math.abs(currentOffset) > 10;
- const steps = wasDrag ? Math.round(currentOffset / CARD_SPACING) : 0;
- if (steps !== 0) {
- const newIndex = wrapIndex(startIdx - steps, total);
- setActiveIndex(newIndex);
- }
- return 0; // reset offset
- });
- dragStartRef.current = null;
- isDraggingRef.current = false;
- pausedUntilRef.current = Date.now() + PAUSE_MS;
- }, [total]);
-
- // Compute interpolated style for each card
- // During drag, base position is startIndex; otherwise activeIndex
- const baseIndex = dragStartRef.current ? dragStartRef.current.startIndex : activeIndex;
-
- function getCardStyle(index: number) {
- const baseDiff = getDiff(index, baseIndex, total);
- const fractionalShift = dragOffset / CARD_SPACING;
- const continuousDiff = baseDiff + fractionalShift;
- const absDiff = Math.abs(continuousDiff);
-
- if (absDiff > 4) return null;
-
- // Interpolate between the two nearest slot positions
- const lowerSlot = Math.floor(absDiff);
- const upperSlot = Math.ceil(absDiff);
- const t = absDiff - lowerSlot;
-
- const s0 = SLOTS[clamp(lowerSlot, 0, 4)];
- const s1 = SLOTS[clamp(upperSlot, 0, 4)];
-
- const sign = continuousDiff >= 0 ? 1 : -1;
- const x = sign * lerp(s0.x, s1.x, t);
- const w = lerp(s0.w, s1.w, t);
- const h = lerp(s0.h, s1.h, t);
- const opacity = lerp(s0.opacity, s1.opacity, t);
- const scale = lerp(s0.scale, s1.scale, t);
- const brightness = lerp(s0.brightness, s1.brightness, t);
- const grayscale = lerp(s0.grayscale, s1.grayscale, t);
- const z = Math.round(lerp(s0.z, s1.z, t));
- const showBorder = absDiff < 0.5;
-
- if (opacity < 0.02) return null;
-
- return {
- width: w,
- height: h,
- opacity,
- zIndex: z,
- transform: `translateX(${x}px) scale(${scale})`,
- filter: `brightness(${brightness}) grayscale(${grayscale})`,
- borderColor: showBorder ? "rgba(201,169,110,0.3)" : "transparent",
- boxShadow: showBorder
- ? "0 0 60px rgba(201,169,110,0.12)"
- : "none",
- transition: isDraggingRef.current
- ? "none"
- : "all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
- isCenter: absDiff < 0.5,
- };
- }
return (
- {/* Stage */}
-
- {/* Spotlight cone */}
-
+
- {/* Cards */}
- {team.members.map((m, i) => {
- const style = getCardStyle(i);
- if (!style) return null;
-
- return (
-
-
-
- {style.isCenter && (
- <>
-
-
-
- {m.name}
-
-
- {m.role}
-
-
- >
- )}
-
- );
- })}
-
-
-
- {/* Member info */}
-
- {member.instagram && (
-
-
- {member.instagram.split("/").filter(Boolean).pop()}
-
- )}
-
- {member.description && (
-
- {member.description}
-
- )}
-
- {/* Progress dots */}
-
- {team.members.map((_, i) => (
-
-
+
diff --git a/src/components/sections/schedule/DayCard.tsx b/src/components/sections/schedule/DayCard.tsx
new file mode 100644
index 0000000..03b23cb
--- /dev/null
+++ b/src/components/sections/schedule/DayCard.tsx
@@ -0,0 +1,60 @@
+import { Clock, User } from "lucide-react";
+import type { ScheduleDay } from "@/types/content";
+import { TYPE_DOT } from "./constants";
+
+export function DayCard({ day }: { day: ScheduleDay }) {
+ return (
+
+ {/* Day header */}
+
+
+
+ {day.dayShort}
+
+
+ {day.day}
+
+
+
+
+ {/* Classes */}
+
+ {day.classes.map((cls, i) => (
+
+
+
+
+ {cls.time}
+
+ {cls.hasSlots && (
+
+ есть места
+
+ )}
+ {cls.recruiting && (
+
+ набор
+
+ )}
+
+
+
+ {cls.trainer}
+
+
+
+
+ {cls.type}
+
+ {cls.level && (
+
+ {cls.level}
+
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/sections/schedule/MobileSchedule.tsx b/src/components/sections/schedule/MobileSchedule.tsx
new file mode 100644
index 0000000..42e1bf5
--- /dev/null
+++ b/src/components/sections/schedule/MobileSchedule.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { User, X } from "lucide-react";
+import type { ScheduleDay } from "@/types/content";
+import { TYPE_DOT } from "./constants";
+
+interface MobileScheduleProps {
+ filteredDays: ScheduleDay[];
+ filterType: string | null;
+ setFilterType: (type: string | null) => void;
+ filterTrainer: string | null;
+ setFilterTrainer: (trainer: string | null) => void;
+ hasActiveFilter: boolean;
+ clearFilters: () => void;
+}
+
+export function MobileSchedule({
+ filteredDays,
+ filterType,
+ setFilterType,
+ filterTrainer,
+ setFilterTrainer,
+ hasActiveFilter,
+ clearFilters,
+}: MobileScheduleProps) {
+ return (
+
+ {/* Active filter indicator */}
+ {hasActiveFilter && (
+
+
+ {filterTrainer && (
+
+
+ {filterTrainer}
+
+ )}
+ {filterType && (
+
+
+ {filterType}
+
+ )}
+
+
+
+ )}
+
+ {filteredDays.length > 0 ? (
+
+ {filteredDays.map((day) => (
+
+ {/* Day header */}
+
+
+ {day.dayShort}
+
+
+ {day.day}
+
+
+
+ {/* Class rows */}
+
+ {day.classes.map((cls, i) => (
+
+ {/* Time */}
+
+ {cls.time}
+
+
+ {/* Info — tappable trainer & type */}
+
+
+
+ {cls.hasSlots && (
+
+ места
+
+ )}
+ {cls.recruiting && (
+
+ набор
+
+ )}
+ {cls.level && (
+
+ {cls.level}
+
+ )}
+
+
+
+
+ ))}
+
+
+ ))}
+
+ ) : (
+
+ Нет занятий по выбранным фильтрам
+
+ )}
+
+ );
+}
diff --git a/src/components/sections/schedule/ScheduleFilters.tsx b/src/components/sections/schedule/ScheduleFilters.tsx
new file mode 100644
index 0000000..ee255f8
--- /dev/null
+++ b/src/components/sections/schedule/ScheduleFilters.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { User, X, ChevronDown } from "lucide-react";
+import {
+ TYPE_DOT,
+ pillBase,
+ pillActive,
+ pillInactive,
+ type StatusFilter,
+} from "./constants";
+
+interface ScheduleFiltersProps {
+ types: string[];
+ trainers: string[];
+ hasAnySlots: boolean;
+ hasAnyRecruiting: boolean;
+ filterType: string | null;
+ setFilterType: (type: string | null) => void;
+ filterTrainer: string | null;
+ setFilterTrainer: (trainer: string | null) => void;
+ filterStatus: StatusFilter;
+ setFilterStatus: (status: StatusFilter) => void;
+ showTrainers: boolean;
+ setShowTrainers: (show: boolean) => void;
+ hasActiveFilter: boolean;
+ clearFilters: () => void;
+}
+
+export function ScheduleFilters({
+ types,
+ trainers,
+ hasAnySlots,
+ hasAnyRecruiting,
+ filterType,
+ setFilterType,
+ filterTrainer,
+ setFilterTrainer,
+ filterStatus,
+ setFilterStatus,
+ showTrainers,
+ setShowTrainers,
+ hasActiveFilter,
+ clearFilters,
+}: ScheduleFiltersProps) {
+ return (
+ <>
+
+ {/* Class types */}
+ {types.map((type) => (
+
+ ))}
+
+ {/* Divider */}
+
+
+ {/* Status filters */}
+ {hasAnySlots && (
+
+ )}
+ {hasAnyRecruiting && (
+
+ )}
+
+ {/* Divider */}
+
+
+ {/* Trainer dropdown toggle */}
+
+
+ {/* Clear */}
+ {hasActiveFilter && (
+
+ )}
+
+
+ {/* Trainer pills — expandable */}
+ {showTrainers && (
+
+ {trainers.map((trainer) => (
+
+ ))}
+
+ )}
+ >
+ );
+}
diff --git a/src/components/sections/schedule/constants.ts b/src/components/sections/schedule/constants.ts
new file mode 100644
index 0000000..af9cd1b
--- /dev/null
+++ b/src/components/sections/schedule/constants.ts
@@ -0,0 +1,15 @@
+export const TYPE_DOT: Record
= {
+ "Exotic Pole Dance": "bg-gold",
+ "Pole Dance": "bg-rose-500",
+ "Body Plastic": "bg-purple-500",
+ "Трюковые комбинации с пилоном": "bg-amber-500",
+};
+
+export type StatusFilter = "all" | "hasSlots" | "recruiting";
+
+export const pillBase =
+ "inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-medium transition-all duration-200 cursor-pointer whitespace-nowrap";
+export const pillActive =
+ "bg-gold/20 text-gold-dark border border-gold/40 dark:text-gold-light dark:border-gold/30";
+export const pillInactive =
+ "border border-neutral-200 text-neutral-500 hover:border-neutral-300 dark:border-white/[0.08] dark:text-white/35 dark:hover:border-white/15";
diff --git a/src/components/sections/team/TeamCarousel.tsx b/src/components/sections/team/TeamCarousel.tsx
new file mode 100644
index 0000000..c3895f2
--- /dev/null
+++ b/src/components/sections/team/TeamCarousel.tsx
@@ -0,0 +1,239 @@
+"use client";
+
+import { useState, useRef, useCallback, useEffect } from "react";
+import Image from "next/image";
+import { UI_CONFIG } from "@/lib/config";
+import type { TeamMember } from "@/types/content";
+
+const {
+ autoPlayMs: AUTO_PLAY_MS,
+ pauseMs: PAUSE_MS,
+ cardSpacing: CARD_SPACING,
+} = UI_CONFIG.team;
+
+function wrapIndex(i: number, total: number) {
+ return ((i % total) + total) % total;
+}
+
+function getDiff(index: number, active: number, total: number) {
+ let diff = index - active;
+ if (diff > total / 2) diff -= total;
+ if (diff < -total / 2) diff += total;
+ return diff;
+}
+
+function lerp(a: number, b: number, t: number) {
+ return a + (b - a) * t;
+}
+
+function clamp(v: number, min: number, max: number) {
+ return Math.max(min, Math.min(max, v));
+}
+
+// Slot properties for each position (0=center, 1=near, 2=mid, 3=far, 4=hidden)
+const SLOTS = [
+ { w: 280, h: 400, opacity: 1, scale: 1, x: 0, brightness: 1, grayscale: 0, z: 10, border: true },
+ { w: 220, h: 340, opacity: 0.8, scale: 0.97, x: 260, brightness: 0.6, grayscale: 0.2, z: 5, border: false },
+ { w: 180, h: 280, opacity: 0.6, scale: 0.93, x: 470, brightness: 0.45, grayscale: 0.35, z: 3, border: false },
+ { w: 150, h: 230, opacity: 0.35, scale: 0.88, x: 640, brightness: 0.3, grayscale: 0.5, z: 2, border: false },
+ { w: 120, h: 180, opacity: 0, scale: 0.83, x: 780, brightness: 0.2, grayscale: 0.8, z: 1, border: false },
+];
+
+interface TeamCarouselProps {
+ members: TeamMember[];
+ activeIndex: number;
+ onActiveChange: (index: number) => void;
+}
+
+export function TeamCarousel({ members, activeIndex, onActiveChange }: TeamCarouselProps) {
+ const total = members.length;
+ const [dragOffset, setDragOffset] = useState(0);
+ const isDraggingRef = useRef(false);
+ const pausedUntilRef = useRef(0);
+ const dragStartRef = useRef<{ x: number; startIndex: number } | null>(null);
+
+ // Pause auto-rotation when activeIndex changes externally (e.g. dot click)
+ const prevIndexRef = useRef(activeIndex);
+ useEffect(() => {
+ if (prevIndexRef.current !== activeIndex) {
+ prevIndexRef.current = activeIndex;
+ pausedUntilRef.current = Date.now() + PAUSE_MS;
+ }
+ }, [activeIndex]);
+
+ // Auto-rotate — completely skip while dragging
+ useEffect(() => {
+ const id = setInterval(() => {
+ if (isDraggingRef.current) return;
+ if (Date.now() < pausedUntilRef.current) return;
+ onActiveChange((activeIndex + 1) % total);
+ }, AUTO_PLAY_MS);
+ return () => clearInterval(id);
+ }, [total, activeIndex, onActiveChange]);
+
+ // Pointer handlers
+ const onPointerDown = useCallback(
+ (e: React.PointerEvent) => {
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
+ isDraggingRef.current = true;
+ dragStartRef.current = { x: e.clientX, startIndex: activeIndex };
+ setDragOffset(0);
+ },
+ [activeIndex]
+ );
+
+ const onPointerMove = useCallback(
+ (e: React.PointerEvent) => {
+ if (!dragStartRef.current) return;
+ const dx = e.clientX - dragStartRef.current.x;
+ setDragOffset(dx);
+ },
+ []
+ );
+
+ // Deferred index update — avoids calling parent setState during render
+ // (onLostPointerCapture can fire during React reconciliation)
+ const pendingIndexRef = useRef(null);
+ useEffect(() => {
+ if (pendingIndexRef.current !== null) {
+ onActiveChange(pendingIndexRef.current);
+ pendingIndexRef.current = null;
+ }
+ });
+
+ const onPointerUp = useCallback(() => {
+ if (!dragStartRef.current) return;
+ const startIdx = dragStartRef.current.startIndex;
+ const currentOffset = dragOffset;
+ const wasDrag = Math.abs(currentOffset) > 10;
+ const steps = wasDrag ? Math.round(currentOffset / CARD_SPACING) : 0;
+ setDragOffset(0);
+ if (steps !== 0) {
+ pendingIndexRef.current = wrapIndex(startIdx - steps, total);
+ }
+ dragStartRef.current = null;
+ isDraggingRef.current = false;
+ pausedUntilRef.current = Date.now() + PAUSE_MS;
+ }, [total, dragOffset]);
+
+ // Compute interpolated style for each card
+ const baseIndex = dragStartRef.current ? dragStartRef.current.startIndex : activeIndex;
+
+ function getCardStyle(index: number) {
+ const baseDiff = getDiff(index, baseIndex, total);
+ const fractionalShift = dragOffset / CARD_SPACING;
+ const continuousDiff = baseDiff + fractionalShift;
+ const absDiff = Math.abs(continuousDiff);
+
+ if (absDiff > 4) return null;
+
+ const lowerSlot = Math.floor(absDiff);
+ const upperSlot = Math.ceil(absDiff);
+ const t = absDiff - lowerSlot;
+
+ const s0 = SLOTS[clamp(lowerSlot, 0, 4)];
+ const s1 = SLOTS[clamp(upperSlot, 0, 4)];
+
+ const sign = continuousDiff >= 0 ? 1 : -1;
+ const x = sign * lerp(s0.x, s1.x, t);
+ const w = lerp(s0.w, s1.w, t);
+ const h = lerp(s0.h, s1.h, t);
+ const opacity = lerp(s0.opacity, s1.opacity, t);
+ const scale = lerp(s0.scale, s1.scale, t);
+ const brightness = lerp(s0.brightness, s1.brightness, t);
+ const grayscale = lerp(s0.grayscale, s1.grayscale, t);
+ const z = Math.round(lerp(s0.z, s1.z, t));
+ const showBorder = absDiff < 0.5;
+
+ if (opacity < 0.02) return null;
+
+ return {
+ width: w,
+ height: h,
+ opacity,
+ zIndex: z,
+ transform: `translateX(${x}px) scale(${scale})`,
+ filter: `brightness(${brightness}) grayscale(${grayscale})`,
+ borderColor: showBorder ? "rgba(201,169,110,0.3)" : "transparent",
+ boxShadow: showBorder
+ ? "0 0 60px rgba(201,169,110,0.12)"
+ : "none",
+ transition: isDraggingRef.current
+ ? "none"
+ : "all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
+ isCenter: absDiff < 0.5,
+ };
+ }
+
+ return (
+
+ {/* Spotlight cone */}
+
+
+ {/* Cards */}
+ {members.map((m, i) => {
+ const style = getCardStyle(i);
+ if (!style) return null;
+
+ return (
+
+
+
+ {style.isCenter && (
+ <>
+
+
+
+ {m.name}
+
+
+ {m.role}
+
+
+ >
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/sections/team/TeamMemberInfo.tsx b/src/components/sections/team/TeamMemberInfo.tsx
new file mode 100644
index 0000000..d7e7ca3
--- /dev/null
+++ b/src/components/sections/team/TeamMemberInfo.tsx
@@ -0,0 +1,55 @@
+import { Instagram } from "lucide-react";
+import type { TeamMember } from "@/types/content";
+
+interface TeamMemberInfoProps {
+ members: TeamMember[];
+ activeIndex: number;
+ onSelect: (index: number) => void;
+}
+
+export function TeamMemberInfo({ members, activeIndex, onSelect }: TeamMemberInfoProps) {
+ const member = members[activeIndex];
+
+ return (
+
+ {member.instagram && (
+
+
+ {member.instagram.split("/").filter(Boolean).pop()}
+
+ )}
+
+ {member.description && (
+
+ {member.description}
+
+ )}
+
+ {/* Progress dots */}
+
+ {members.map((_, i) => (
+
+
+ );
+}
diff --git a/src/components/ui/BackToTop.tsx b/src/components/ui/BackToTop.tsx
index 6834312..cfd2194 100644
--- a/src/components/ui/BackToTop.tsx
+++ b/src/components/ui/BackToTop.tsx
@@ -2,13 +2,14 @@
import { useState, useEffect } from "react";
import { ChevronUp } from "lucide-react";
+import { UI_CONFIG } from "@/lib/config";
export function BackToTop() {
const [visible, setVisible] = useState(false);
useEffect(() => {
function handleScroll() {
- setVisible(window.scrollY > 600);
+ setVisible(window.scrollY > UI_CONFIG.scrollThresholds.backToTop);
}
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
@@ -18,7 +19,7 @@ export function BackToTop() {
@@ -152,13 +152,13 @@ export function BookingModal({ open, onClose }: BookingModalProps) {
onChange={(e) => handlePhoneChange(e.target.value)}
placeholder="+375 (__) ___-__-__"
required
- className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-[#c9a96e]/40 focus:bg-white/[0.06]"
+ className="w-full rounded-xl border border-white/[0.08] bg-white/[0.04] px-4 py-3 text-sm text-white placeholder-neutral-500 outline-none transition-colors focus:border-gold/40 focus:bg-white/[0.06]"
/>