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:
2026-03-11 14:57:39 +03:00
parent 08e4af1d55
commit d5afaf92ba
31 changed files with 784 additions and 691 deletions

View File

@@ -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 (
<section
@@ -184,119 +34,17 @@ export function Team() {
<Reveal>
<div className="mt-10">
{/* Stage */}
<div
className="relative mx-auto flex items-end justify-center cursor-grab select-none active:cursor-grabbing touch-pan-y"
style={{ height: 440 }}
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%)",
}}
/>
<TeamCarousel
members={team.members}
activeIndex={activeIndex}
onActiveChange={setActiveIndex}
/>
{/* Cards */}
{team.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-[#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>
<TeamMemberInfo
members={team.members}
activeIndex={activeIndex}
onSelect={setActiveIndex}
/>
</div>
</Reveal>
</div>