2026-03-04 14:50:45 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { 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,
|
|
|
|
|
} from "@/features/landing-page/data";
|
|
|
|
|
|
|
|
|
|
type Tab = "personal" | "business";
|
|
|
|
|
|
|
|
|
|
function ServiceConversionCard({ card }: { card: ConversionServiceCard }) {
|
|
|
|
|
return (
|
|
|
|
|
<Link href={card.href} className="group flex-shrink-0 w-[260px] sm:w-[280px] snap-start">
|
|
|
|
|
<article
|
|
|
|
|
data-service-card
|
|
|
|
|
className="h-full rounded-2xl bg-card border border-border/60 px-6 py-7 shadow-sm hover:shadow-md hover:border-primary/30 transition-all duration-300 group-hover:-translate-y-1 flex flex-col"
|
|
|
|
|
>
|
2026-03-04 14:58:45 +09:00
|
|
|
{/* Icon + Badge row */}
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<div className="text-primary">{card.icon}</div>
|
|
|
|
|
{card.badge && (
|
|
|
|
|
<span className="inline-flex items-center rounded-full bg-success/10 text-success px-2.5 py-0.5 text-xs font-semibold">
|
|
|
|
|
{card.badge}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-04 14:50:45 +09:00
|
|
|
<p className="text-sm text-muted-foreground mb-1">{card.problemHook}</p>
|
|
|
|
|
<h3 className="text-lg font-bold text-foreground mb-1">{card.title}</h3>
|
|
|
|
|
<p className="text-sm text-muted-foreground mb-3 flex-grow">{card.keyBenefit}</p>
|
|
|
|
|
<span className="inline-flex items-center gap-1.5 text-sm font-semibold text-primary group-hover:gap-2.5 transition-all mt-auto">
|
|
|
|
|
{card.ctaLabel}
|
|
|
|
|
<ArrowRight className="h-4 w-4" />
|
|
|
|
|
</span>
|
|
|
|
|
</article>
|
|
|
|
|
</Link>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ServicesCarousel() {
|
|
|
|
|
const [activeTab, setActiveTab] = useState<Tab>("personal");
|
|
|
|
|
const carouselRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const itemWidthRef = useRef(0);
|
|
|
|
|
const isScrollingRef = useRef(false);
|
|
|
|
|
const autoScrollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
|
const [sectionRef, isInView] = useInView();
|
|
|
|
|
|
|
|
|
|
const cards = activeTab === "personal" ? personalConversionCards : businessConversionCards;
|
|
|
|
|
|
|
|
|
|
const computeItemWidth = useCallback(() => {
|
|
|
|
|
const container = carouselRef.current;
|
|
|
|
|
if (!container) return;
|
|
|
|
|
const card = container.querySelector<HTMLElement>("[data-service-card]");
|
|
|
|
|
if (!card) return;
|
|
|
|
|
const style = getComputedStyle(container);
|
|
|
|
|
const gap = Number.parseFloat(style.columnGap || style.gap || "0") || 24;
|
|
|
|
|
itemWidthRef.current = card.getBoundingClientRect().width + gap;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const scrollByOne = useCallback((direction: 1 | -1) => {
|
|
|
|
|
const container = carouselRef.current;
|
|
|
|
|
if (!container || !itemWidthRef.current || isScrollingRef.current) return;
|
|
|
|
|
isScrollingRef.current = true;
|
|
|
|
|
container.scrollBy({
|
|
|
|
|
left: direction * itemWidthRef.current,
|
|
|
|
|
behavior: "smooth",
|
|
|
|
|
});
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
isScrollingRef.current = false;
|
|
|
|
|
}, 500);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const startAutoScroll = useCallback(() => {
|
|
|
|
|
if (autoScrollTimerRef.current) clearInterval(autoScrollTimerRef.current);
|
|
|
|
|
autoScrollTimerRef.current = setInterval(() => scrollByOne(1), 5000);
|
|
|
|
|
}, [scrollByOne]);
|
|
|
|
|
|
|
|
|
|
const stopAutoScroll = useCallback(() => {
|
|
|
|
|
if (autoScrollTimerRef.current) {
|
|
|
|
|
clearInterval(autoScrollTimerRef.current);
|
|
|
|
|
autoScrollTimerRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
computeItemWidth();
|
|
|
|
|
window.addEventListener("resize", computeItemWidth);
|
|
|
|
|
startAutoScroll();
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener("resize", computeItemWidth);
|
|
|
|
|
stopAutoScroll();
|
|
|
|
|
};
|
|
|
|
|
}, [computeItemWidth, startAutoScroll, stopAutoScroll]);
|
|
|
|
|
|
|
|
|
|
// Reset scroll position when tab changes
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (carouselRef.current) {
|
|
|
|
|
carouselRef.current.scrollTo({ left: 0, behavior: "smooth" });
|
|
|
|
|
}
|
|
|
|
|
computeItemWidth();
|
|
|
|
|
}, [activeTab, computeItemWidth]);
|
|
|
|
|
|
|
|
|
|
const handlePrev = useCallback(() => {
|
|
|
|
|
scrollByOne(-1);
|
|
|
|
|
startAutoScroll();
|
|
|
|
|
}, [scrollByOne, startAutoScroll]);
|
|
|
|
|
|
|
|
|
|
const handleNext = useCallback(() => {
|
|
|
|
|
scrollByOne(1);
|
|
|
|
|
startAutoScroll();
|
|
|
|
|
}, [scrollByOne, startAutoScroll]);
|
|
|
|
|
|
|
|
|
|
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-14 sm:py-16 transition-all duration-700",
|
|
|
|
|
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div className="mx-auto max-w-6xl px-6 sm:px-10 lg:px-14">
|
|
|
|
|
{/* Header + Tabs */}
|
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="text-2xl sm:text-3xl font-extrabold text-foreground">Our Services</h2>
|
|
|
|
|
<p className="mt-1 text-base text-muted-foreground">
|
|
|
|
|
Everything you need to stay connected in Japan
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex bg-muted rounded-full p-1 self-start">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setActiveTab("personal")}
|
|
|
|
|
className={cn(
|
|
|
|
|
"px-4 py-2 text-sm font-semibold rounded-full transition-all",
|
|
|
|
|
activeTab === "personal"
|
|
|
|
|
? "bg-foreground text-background shadow-sm"
|
|
|
|
|
: "text-muted-foreground hover:text-foreground"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
For You
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setActiveTab("business")}
|
|
|
|
|
className={cn(
|
|
|
|
|
"px-4 py-2 text-sm font-semibold rounded-full transition-all",
|
|
|
|
|
activeTab === "business"
|
|
|
|
|
? "bg-foreground text-background shadow-sm"
|
|
|
|
|
: "text-muted-foreground hover:text-foreground"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
For Business
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Carousel */}
|
|
|
|
|
<div className="relative" onMouseEnter={stopAutoScroll} onMouseLeave={startAutoScroll}>
|
|
|
|
|
<div
|
|
|
|
|
ref={carouselRef}
|
|
|
|
|
className="flex gap-5 overflow-x-auto scroll-smooth pb-4 snap-x snap-mandatory"
|
|
|
|
|
style={{
|
|
|
|
|
scrollbarWidth: "none",
|
|
|
|
|
WebkitOverflowScrolling: "touch",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{cards.map(card => (
|
|
|
|
|
<ServiceConversionCard key={card.title} card={card} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Navigation buttons */}
|
|
|
|
|
<div className="flex justify-end gap-2 mt-4">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label="Scroll left"
|
|
|
|
|
onClick={handlePrev}
|
|
|
|
|
className="h-9 w-9 rounded-full border border-border bg-card text-foreground shadow-sm hover:bg-muted transition-colors flex items-center justify-center"
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label="Scroll right"
|
|
|
|
|
onClick={handleNext}
|
|
|
|
|
className="h-9 w-9 rounded-full border border-border bg-card text-foreground shadow-sm hover:bg-muted transition-colors flex items-center justify-center"
|
|
|
|
|
>
|
|
|
|
|
<ChevronRight className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|