2026-03-04 14:50:45 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2026-03-04 17:13:07 +09:00
|
|
|
import { memo, useEffect, useState } from "react";
|
2026-03-04 14:50:45 +09:00
|
|
|
import Link from "next/link";
|
|
|
|
|
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
|
|
|
|
|
import { cn } from "@/shared/utils";
|
2026-03-04 17:13:07 +09:00
|
|
|
import { useInfiniteCarousel, useInView } from "@/features/landing-page/hooks";
|
2026-03-04 14:50:45 +09:00
|
|
|
import {
|
|
|
|
|
personalConversionCards,
|
|
|
|
|
businessConversionCards,
|
|
|
|
|
type ConversionServiceCard,
|
2026-03-04 16:16:14 +09:00
|
|
|
type CarouselAccent,
|
2026-03-04 14:50:45 +09:00
|
|
|
} from "@/features/landing-page/data";
|
|
|
|
|
|
|
|
|
|
type Tab = "personal" | "business";
|
|
|
|
|
|
2026-03-04 17:13:07 +09:00
|
|
|
/* ─── Accent color system ─── */
|
2026-03-04 16:16:14 +09:00
|
|
|
|
2026-03-04 17:13:07 +09:00
|
|
|
interface AccentStyles {
|
|
|
|
|
iconBg: string;
|
|
|
|
|
iconText: string;
|
|
|
|
|
ctaBg: string;
|
|
|
|
|
dotBg: string;
|
|
|
|
|
borderInactive: string;
|
|
|
|
|
borderActive: string;
|
|
|
|
|
cssVar: string;
|
2026-03-04 16:16:14 +09:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 17:13:07 +09:00
|
|
|
const ACCENTS: Record<CarouselAccent, AccentStyles> = {
|
|
|
|
|
blue: {
|
|
|
|
|
iconBg: "bg-blue-500/12",
|
|
|
|
|
iconText: "text-blue-600",
|
|
|
|
|
ctaBg: "bg-blue-600",
|
|
|
|
|
dotBg: "bg-blue-600",
|
|
|
|
|
borderInactive: "border-blue-500/10",
|
|
|
|
|
borderActive: "border-blue-500/25",
|
|
|
|
|
cssVar: "var(--color-blue-500)",
|
|
|
|
|
},
|
|
|
|
|
emerald: {
|
|
|
|
|
iconBg: "bg-emerald-500/12",
|
|
|
|
|
iconText: "text-emerald-600",
|
|
|
|
|
ctaBg: "bg-emerald-600",
|
|
|
|
|
dotBg: "bg-emerald-600",
|
|
|
|
|
borderInactive: "border-emerald-500/10",
|
|
|
|
|
borderActive: "border-emerald-500/25",
|
|
|
|
|
cssVar: "var(--color-emerald-500)",
|
|
|
|
|
},
|
|
|
|
|
violet: {
|
|
|
|
|
iconBg: "bg-violet-500/12",
|
|
|
|
|
iconText: "text-violet-600",
|
|
|
|
|
ctaBg: "bg-violet-600",
|
|
|
|
|
dotBg: "bg-violet-600",
|
|
|
|
|
borderInactive: "border-violet-500/10",
|
|
|
|
|
borderActive: "border-violet-500/25",
|
|
|
|
|
cssVar: "var(--color-violet-500)",
|
|
|
|
|
},
|
|
|
|
|
amber: {
|
|
|
|
|
iconBg: "bg-amber-500/12",
|
|
|
|
|
iconText: "text-amber-600",
|
|
|
|
|
ctaBg: "bg-amber-600",
|
|
|
|
|
dotBg: "bg-amber-600",
|
|
|
|
|
borderInactive: "border-amber-500/10",
|
|
|
|
|
borderActive: "border-amber-500/25",
|
|
|
|
|
cssVar: "var(--color-amber-500)",
|
|
|
|
|
},
|
|
|
|
|
indigo: {
|
|
|
|
|
iconBg: "bg-indigo-500/12",
|
|
|
|
|
iconText: "text-indigo-600",
|
|
|
|
|
ctaBg: "bg-indigo-600",
|
|
|
|
|
dotBg: "bg-indigo-600",
|
|
|
|
|
borderInactive: "border-indigo-500/10",
|
|
|
|
|
borderActive: "border-indigo-500/25",
|
|
|
|
|
cssVar: "var(--color-indigo-500)",
|
|
|
|
|
},
|
|
|
|
|
cyan: {
|
|
|
|
|
iconBg: "bg-cyan-500/12",
|
|
|
|
|
iconText: "text-cyan-600",
|
|
|
|
|
ctaBg: "bg-cyan-600",
|
|
|
|
|
dotBg: "bg-cyan-600",
|
|
|
|
|
borderInactive: "border-cyan-500/10",
|
|
|
|
|
borderActive: "border-cyan-500/25",
|
|
|
|
|
cssVar: "var(--color-cyan-500)",
|
|
|
|
|
},
|
|
|
|
|
rose: {
|
|
|
|
|
iconBg: "bg-rose-500/12",
|
|
|
|
|
iconText: "text-rose-600",
|
|
|
|
|
ctaBg: "bg-rose-600",
|
|
|
|
|
dotBg: "bg-rose-600",
|
|
|
|
|
borderInactive: "border-rose-500/10",
|
|
|
|
|
borderActive: "border-rose-500/25",
|
|
|
|
|
cssVar: "var(--color-rose-500)",
|
|
|
|
|
},
|
|
|
|
|
slate: {
|
|
|
|
|
iconBg: "bg-slate-500/12",
|
|
|
|
|
iconText: "text-slate-600",
|
|
|
|
|
ctaBg: "bg-slate-600",
|
|
|
|
|
dotBg: "bg-slate-600",
|
|
|
|
|
borderInactive: "border-slate-500/10",
|
|
|
|
|
borderActive: "border-slate-500/25",
|
|
|
|
|
cssVar: "var(--color-slate-500)",
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ─── Slide visual styles by distance from center ─── */
|
2026-03-04 16:16:14 +09:00
|
|
|
|
|
|
|
|
const SLIDE_STYLES = [
|
2026-03-04 17:13:07 +09:00
|
|
|
{ scale: 1, opacity: 1, filter: "none" },
|
|
|
|
|
{ scale: 0.88, opacity: 0.5, filter: "blur(2px)" },
|
|
|
|
|
{ scale: 0.78, opacity: 0.25, filter: "blur(4px)" },
|
2026-03-04 16:16:14 +09:00
|
|
|
] as const;
|
|
|
|
|
|
2026-03-04 17:13:07 +09:00
|
|
|
function slideStyle(offset: number) {
|
|
|
|
|
return SLIDE_STYLES[Math.min(offset, 2)] ?? SLIDE_STYLES[2]!;
|
2026-03-04 16:16:14 +09:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 17:13:07 +09:00
|
|
|
const GAP = 24;
|
|
|
|
|
|
2026-03-04 16:16:14 +09:00
|
|
|
/* ─── Spotlight Card ─── */
|
|
|
|
|
|
|
|
|
|
const SpotlightCard = memo(function SpotlightCard({
|
|
|
|
|
card,
|
|
|
|
|
isActive,
|
|
|
|
|
}: {
|
|
|
|
|
card: ConversionServiceCard;
|
|
|
|
|
isActive: boolean;
|
|
|
|
|
}) {
|
2026-03-04 17:13:07 +09:00
|
|
|
const a = ACCENTS[card.accent];
|
2026-03-04 16:16:14 +09:00
|
|
|
|
2026-03-04 14:50:45 +09:00
|
|
|
return (
|
2026-03-04 16:16:14 +09:00
|
|
|
<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",
|
2026-03-04 17:13:07 +09:00
|
|
|
isActive
|
|
|
|
|
? cn("shadow-xl hover:shadow-2xl", a.borderActive)
|
|
|
|
|
: cn("shadow-sm", a.borderInactive)
|
2026-03-04 16:16:14 +09:00
|
|
|
)}
|
|
|
|
|
style={{
|
2026-03-04 17:13:07 +09:00
|
|
|
background: `linear-gradient(145deg, color-mix(in oklch, ${a.cssVar} 8%, white), white)`,
|
2026-03-04 16:16:14 +09:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="h-full flex flex-col px-7 py-7 sm:px-10 sm:py-9">
|
|
|
|
|
<div className="flex items-center justify-between mb-5">
|
|
|
|
|
<div
|
2026-03-04 17:13:07 +09:00
|
|
|
className={cn(
|
|
|
|
|
"h-14 w-14 rounded-2xl flex items-center justify-center",
|
|
|
|
|
a.iconBg,
|
|
|
|
|
a.iconText
|
|
|
|
|
)}
|
2026-03-04 16:16:14 +09:00
|
|
|
>
|
|
|
|
|
<div className="[&>svg]:h-7 [&>svg]:w-7">{card.icon}</div>
|
|
|
|
|
</div>
|
2026-03-04 14:58:45 +09:00
|
|
|
{card.badge && (
|
2026-03-04 16:16:14 +09:00
|
|
|
<span className="inline-flex items-center rounded-full bg-success/10 text-success px-3 py-1 text-xs font-bold tracking-wide">
|
2026-03-04 14:58:45 +09:00
|
|
|
{card.badge}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-04 16:16:14 +09:00
|
|
|
<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>
|
|
|
|
|
<span
|
2026-03-04 17:13:07 +09:00
|
|
|
className={cn(
|
|
|
|
|
"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",
|
|
|
|
|
a.ctaBg
|
|
|
|
|
)}
|
2026-03-04 16:16:14 +09:00
|
|
|
>
|
2026-03-04 14:50:45 +09:00
|
|
|
{card.ctaLabel}
|
|
|
|
|
<ArrowRight className="h-4 w-4" />
|
|
|
|
|
</span>
|
2026-03-04 16:16:14 +09:00
|
|
|
</div>
|
2026-03-04 14:50:45 +09:00
|
|
|
</Link>
|
|
|
|
|
);
|
2026-03-04 16:16:14 +09:00
|
|
|
});
|
|
|
|
|
|
2026-03-04 17:13:07 +09:00
|
|
|
/* ─── Header + Tab Toggle ─── */
|
2026-03-04 16:16:14 +09:00
|
|
|
|
2026-03-04 17:13:07 +09:00
|
|
|
function CarouselHeader({
|
|
|
|
|
activeTab,
|
|
|
|
|
onTabChange,
|
|
|
|
|
}: {
|
|
|
|
|
activeTab: Tab;
|
|
|
|
|
onTabChange: (tab: Tab) => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<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={() => onTabChange(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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ─── Dots + Arrow Navigation ─── */
|
|
|
|
|
|
|
|
|
|
function CarouselNav({
|
|
|
|
|
cards,
|
|
|
|
|
activeIndex,
|
|
|
|
|
goTo,
|
|
|
|
|
goPrev,
|
|
|
|
|
goNext,
|
|
|
|
|
}: {
|
|
|
|
|
cards: ConversionServiceCard[];
|
|
|
|
|
activeIndex: number;
|
|
|
|
|
goTo: (i: number) => void;
|
|
|
|
|
goPrev: () => void;
|
|
|
|
|
goNext: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<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 styles = ACCENTS[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
|
|
|
|
|
? cn("w-8", styles.dotBg)
|
|
|
|
|
: "w-2.5 bg-border hover:bg-muted-foreground"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ─── Main Carousel ─── */
|
2026-03-04 14:50:45 +09:00
|
|
|
|
|
|
|
|
export function ServicesCarousel() {
|
|
|
|
|
const [activeTab, setActiveTab] = useState<Tab>("personal");
|
|
|
|
|
const [sectionRef, isInView] = useInView();
|
|
|
|
|
const cards = activeTab === "personal" ? personalConversionCards : businessConversionCards;
|
2026-03-04 17:13:07 +09:00
|
|
|
const c = useInfiniteCarousel({ items: cards });
|
2026-03-04 14:50:45 +09:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-03-04 17:13:07 +09:00
|
|
|
c.reset();
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2026-03-04 16:16:14 +09:00
|
|
|
}, [activeTab]);
|
|
|
|
|
|
2026-03-04 17:13:07 +09:00
|
|
|
const trackX = -(c.trackIndex * (c.cardWidth + GAP));
|
2026-03-04 14:50:45 +09:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section
|
2026-03-04 17:13:07 +09:00
|
|
|
ref={sectionRef}
|
2026-03-04 14:50:45 +09:00
|
|
|
className={cn(
|
2026-03-05 10:05:30 +09:00
|
|
|
"full-bleed bg-surface-sunken/30 py-16 sm:py-20 transition-all duration-700",
|
2026-03-04 14:50:45 +09:00
|
|
|
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-03-04 17:13:07 +09:00
|
|
|
<CarouselHeader activeTab={activeTab} onTabChange={setActiveTab} />
|
2026-03-04 14:50:45 +09:00
|
|
|
|
2026-03-04 16:16:14 +09:00
|
|
|
<div
|
|
|
|
|
className="relative overflow-hidden"
|
2026-03-04 17:13:07 +09:00
|
|
|
onMouseEnter={c.stopAuto}
|
|
|
|
|
onMouseLeave={c.startAuto}
|
|
|
|
|
onTouchStart={c.onTouchStart}
|
|
|
|
|
onTouchEnd={c.onTouchEnd}
|
|
|
|
|
onKeyDown={c.onKeyDown}
|
2026-03-04 16:16:14 +09:00
|
|
|
tabIndex={0}
|
|
|
|
|
role="region"
|
|
|
|
|
aria-label="Services carousel"
|
|
|
|
|
aria-roledescription="carousel"
|
|
|
|
|
>
|
|
|
|
|
<div
|
2026-03-04 17:13:07 +09:00
|
|
|
className="flex ease-out"
|
2026-03-04 16:16:14 +09:00
|
|
|
style={{
|
2026-03-04 17:13:07 +09:00
|
|
|
transform: `translateX(calc(50% - ${c.cardWidth / 2}px + ${trackX}px))`,
|
2026-03-04 16:16:14 +09:00
|
|
|
gap: `${GAP}px`,
|
2026-03-04 17:13:07 +09:00
|
|
|
transitionProperty: c.isTransitioning ? "transform" : "none",
|
|
|
|
|
transitionDuration: c.isTransitioning ? "500ms" : "0ms",
|
2026-03-04 16:16:14 +09:00
|
|
|
}}
|
2026-03-04 17:13:07 +09:00
|
|
|
onTransitionEnd={c.handleTransitionEnd}
|
2026-03-04 16:16:14 +09:00
|
|
|
>
|
2026-03-04 17:13:07 +09:00
|
|
|
{c.extendedItems.map((card, i) => {
|
|
|
|
|
const offset = Math.abs(i - c.trackIndex);
|
|
|
|
|
const style = slideStyle(offset);
|
2026-03-04 16:16:14 +09:00
|
|
|
return (
|
|
|
|
|
<div
|
2026-03-04 17:13:07 +09:00
|
|
|
key={`slide-${i}`}
|
2026-03-04 16:16:14 +09:00
|
|
|
className={cn(
|
|
|
|
|
"flex-shrink-0 transition-[transform,opacity,filter] duration-500 ease-out",
|
|
|
|
|
offset > 0 && "cursor-pointer"
|
|
|
|
|
)}
|
|
|
|
|
role="group"
|
|
|
|
|
aria-roledescription="slide"
|
2026-03-04 17:13:07 +09:00
|
|
|
aria-label={`${c.activeIndex + 1} of ${c.total}: ${card.title}`}
|
2026-03-04 16:16:14 +09:00
|
|
|
style={{
|
2026-03-04 17:13:07 +09:00
|
|
|
width: c.cardWidth,
|
|
|
|
|
transform: `scale(${style.scale})`,
|
|
|
|
|
opacity: style.opacity,
|
|
|
|
|
filter: style.filter,
|
2026-03-04 16:16:14 +09:00
|
|
|
}}
|
|
|
|
|
onClick={() => {
|
2026-03-04 17:13:07 +09:00
|
|
|
if (i < c.trackIndex) c.goPrev();
|
|
|
|
|
else if (i > c.trackIndex) c.goNext();
|
2026-03-04 16:16:14 +09:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<SpotlightCard card={card} isActive={offset === 0} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-04 14:50:45 +09:00
|
|
|
|
2026-03-04 17:13:07 +09:00
|
|
|
<CarouselNav
|
|
|
|
|
cards={cards}
|
|
|
|
|
activeIndex={c.activeIndex}
|
|
|
|
|
goTo={c.goTo}
|
|
|
|
|
goPrev={c.goPrev}
|
|
|
|
|
goNext={c.goNext}
|
|
|
|
|
/>
|
2026-03-04 14:50:45 +09:00
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|