363 lines
11 KiB
TypeScript
Raw Normal View History

"use client";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/shared/utils";
import { useInView } from "@/features/landing-page/hooks";
import {
personalConversionCards,
businessConversionCards,
type ConversionServiceCard,
type CarouselAccent,
} from "@/features/landing-page/data";
type Tab = "personal" | "business";
/* ─── Accent color system (oklch for perceptual uniformity) ─── */
interface AccentDef {
l: number;
c: number;
h: number;
}
const ACCENTS = new Map<CarouselAccent, AccentDef>([
["blue", { l: 0.55, c: 0.18, h: 240 }],
["emerald", { l: 0.6, c: 0.17, h: 160 }],
["violet", { l: 0.55, c: 0.2, h: 290 }],
["amber", { l: 0.72, c: 0.17, h: 75 }],
["indigo", { l: 0.5, c: 0.2, h: 265 }],
["cyan", { l: 0.58, c: 0.14, h: 200 }],
["rose", { l: 0.58, c: 0.2, h: 15 }],
["slate", { l: 0.5, c: 0.03, h: 260 }],
]);
// Safe: all CarouselAccent keys are present in ACCENTS
function getAccent(key: CarouselAccent): AccentDef {
// biome-ignore lint: Map covers all CarouselAccent keys
return ACCENTS.get(key)!;
}
function oklch(a: AccentDef, alpha?: number) {
const alphaStr = alpha == null ? "" : ` / ${alpha}`;
return `oklch(${a.l} ${a.c} ${a.h}${alphaStr})`;
}
function accentBg(a: AccentDef) {
return `oklch(0.97 ${(a.c * 0.12).toFixed(3)} ${a.h})`;
}
const SLIDE_STYLES = [
{ transform: "scale(1)", opacity: 1, filter: "none" },
{ transform: "scale(0.88)", opacity: 0.5, filter: "blur(2px)" },
{ transform: "scale(0.78)", opacity: 0.25, filter: "blur(4px)" },
] as const;
function slideStyles(offset: number) {
return SLIDE_STYLES[Math.min(offset, 2)];
}
/* ─── Spotlight Card ─── */
const SpotlightCard = memo(function SpotlightCard({
card,
isActive,
}: {
card: ConversionServiceCard;
isActive: boolean;
}) {
const a = getAccent(card.accent);
return (
<Link
href={card.href}
tabIndex={isActive ? 0 : -1}
aria-hidden={!isActive}
onClick={e => {
if (!isActive) e.preventDefault();
}}
className={cn(
"block h-full rounded-3xl border overflow-hidden transition-shadow duration-500",
isActive ? "shadow-xl hover:shadow-2xl" : "shadow-sm"
)}
style={{
background: `linear-gradient(145deg, ${accentBg(a)}, oklch(0.995 0 0))`,
borderColor: oklch(a, isActive ? 0.25 : 0.1),
}}
>
<div className="h-full flex flex-col px-7 py-7 sm:px-10 sm:py-9">
{/* Icon + Badge */}
<div className="flex items-center justify-between mb-5">
<div
className="h-14 w-14 rounded-2xl flex items-center justify-center"
style={{ background: oklch(a, 0.12), color: oklch(a) }}
>
<div className="[&>svg]:h-7 [&>svg]:w-7">{card.icon}</div>
</div>
{card.badge && (
<span className="inline-flex items-center rounded-full bg-success/10 text-success px-3 py-1 text-xs font-bold tracking-wide">
{card.badge}
</span>
)}
</div>
{/* Content */}
<p className="text-sm font-medium text-muted-foreground mb-1.5">{card.problemHook}</p>
<h3 className="text-2xl sm:text-3xl font-extrabold text-foreground mb-3 leading-tight">
{card.title}
</h3>
<p className="text-[15px] text-muted-foreground leading-relaxed mb-6 flex-grow">
{card.description}
</p>
{/* CTA Button */}
<span
className="inline-flex items-center gap-2 rounded-full px-6 py-3 text-sm font-bold text-white transition-shadow duration-200 self-start shadow-md hover:shadow-lg"
style={{ background: oklch(a) }}
>
{card.ctaLabel}
<ArrowRight className="h-4 w-4" />
</span>
</div>
</Link>
);
});
/* ─── Main Carousel ─── */
const AUTO_INTERVAL = 5000;
const GAP = 24;
export function ServicesCarousel() {
const [activeTab, setActiveTab] = useState<Tab>("personal");
const [activeIndex, setActiveIndex] = useState(0);
const [cardWidth, setCardWidth] = useState(520);
const autoRef = useRef<ReturnType<typeof setInterval> | null>(null);
const touchXRef = useRef(0);
const rafRef = useRef(0);
const [sectionRef, isInView] = useInView();
const cards = activeTab === "personal" ? personalConversionCards : businessConversionCards;
const total = cards.length;
// Responsive card width (rAF-throttled)
useEffect(() => {
const update = () => {
const vw = window.innerWidth;
if (vw < 640) setCardWidth(vw - 48);
else if (vw < 1024) setCardWidth(440);
else setCardWidth(520);
};
const onResize = () => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(update);
};
update();
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
// Auto-rotate
const startAuto = useCallback(() => {
if (autoRef.current) clearInterval(autoRef.current);
autoRef.current = setInterval(() => {
setActiveIndex(prev => (prev + 1) % total);
}, AUTO_INTERVAL);
}, [total]);
const stopAuto = useCallback(() => {
if (autoRef.current) {
clearInterval(autoRef.current);
autoRef.current = null;
}
}, []);
useEffect(() => {
startAuto();
return stopAuto;
}, [startAuto, stopAuto]);
// Reset on tab change
useEffect(() => {
setActiveIndex(0);
}, [activeTab]);
const goTo = useCallback(
(i: number) => {
setActiveIndex(i);
startAuto();
},
[startAuto]
);
const goPrev = useCallback(() => {
setActiveIndex(p => (p - 1 + total) % total);
startAuto();
}, [total, startAuto]);
const goNext = useCallback(() => {
setActiveIndex(p => (p + 1) % total);
startAuto();
}, [total, startAuto]);
// Touch swipe
const onTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) touchXRef.current = touch.clientX;
}, []);
const onTouchEnd = useCallback(
(e: React.TouchEvent) => {
const touch = e.changedTouches[0];
if (!touch) return;
const diff = touchXRef.current - touch.clientX;
if (Math.abs(diff) > 50) {
if (diff > 0) goNext();
else goPrev();
}
},
[goNext, goPrev]
);
// Keyboard nav
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") goPrev();
else if (e.key === "ArrowRight") goNext();
},
[goPrev, goNext]
);
const trackX = -(activeIndex * (cardWidth + GAP));
return (
<section
ref={sectionRef as React.RefObject<HTMLElement>}
className={cn(
"relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-surface-sunken/30 py-16 sm:py-20 transition-all duration-700",
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)}
>
{/* Header + Tabs */}
<div className="mx-auto max-w-6xl px-6 sm:px-10 lg:px-14 mb-10">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground">Our Services</h2>
<p className="mt-2 text-lg text-muted-foreground">
Everything you need to stay connected in Japan
</p>
</div>
<div className="flex bg-muted rounded-full p-1 self-start">
{(["personal", "business"] as const).map(tab => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
"px-5 py-2.5 text-sm font-semibold rounded-full transition-all",
activeTab === tab
? "bg-foreground text-background shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{tab === "personal" ? "For You" : "For Business"}
</button>
))}
</div>
</div>
</div>
{/* Spotlight Carousel Track */}
<div
className="relative overflow-hidden"
onMouseEnter={stopAuto}
onMouseLeave={startAuto}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onKeyDown={onKeyDown}
tabIndex={0}
role="region"
aria-label="Services carousel"
aria-roledescription="carousel"
>
<div
className="flex transition-transform duration-500 ease-out"
style={{
transform: `translateX(calc(50% - ${cardWidth / 2}px + ${trackX}px))`,
gap: `${GAP}px`,
}}
>
{cards.map((card, i) => {
const offset = Math.abs(i - activeIndex);
return (
<div
key={card.href}
className={cn(
"flex-shrink-0 transition-[transform,opacity,filter] duration-500 ease-out",
offset > 0 && "cursor-pointer"
)}
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${total}: ${card.title}`}
style={{
width: cardWidth,
...slideStyles(offset),
}}
onClick={() => {
if (offset > 0) goTo(i);
}}
>
<SpotlightCard card={card} isActive={offset === 0} />
</div>
);
})}
</div>
</div>
{/* Navigation: Dots + Arrows */}
<div className="mx-auto max-w-6xl px-6 sm:px-10 lg:px-14">
<div className="flex items-center justify-center gap-6 mt-8">
<button
type="button"
aria-label="Previous service"
onClick={goPrev}
className="h-10 w-10 rounded-full border border-border bg-card text-foreground shadow-sm hover:bg-muted transition-colors flex items-center justify-center"
>
<ChevronLeft className="h-5 w-5" />
</button>
<div className="flex items-center gap-2">
{cards.map((card, i) => {
const a = getAccent(card.accent);
return (
<button
key={card.href}
type="button"
aria-label={`Go to ${card.title}`}
onClick={() => goTo(i)}
className={cn(
"rounded-full transition-all duration-300 h-2.5",
i === activeIndex ? "w-8" : "w-2.5 bg-border hover:bg-muted-foreground"
)}
style={i === activeIndex ? { background: oklch(a) } : undefined}
/>
);
})}
</div>
<button
type="button"
aria-label="Next service"
onClick={goNext}
className="h-10 w-10 rounded-full border border-border bg-card text-foreground shadow-sm hover:bg-muted transition-colors flex items-center justify-center"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
</div>
</section>
);
}