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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user