- Replaced the "About us.png" image with a new "about-us.png" for better naming consistency. - Updated various sections across the landing page to use a full-bleed layout for a more modern design. - Refactored ServiceCard component to conditionally render as a link or div based on the presence of an href prop, enhancing flexibility. - Introduced a new CollapsibleSection component for better organization of content in service-related sections. - Enhanced styling and structure in multiple components, including ContactSection, CTABanner, and TrustStrip, to improve visual hierarchy and user experience.
371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { cn } from "@/shared/utils";
|
|
import { useInfiniteCarousel, 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 ─── */
|
|
|
|
interface AccentStyles {
|
|
iconBg: string;
|
|
iconText: string;
|
|
ctaBg: string;
|
|
dotBg: string;
|
|
borderInactive: string;
|
|
borderActive: string;
|
|
cssVar: string;
|
|
}
|
|
|
|
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 ─── */
|
|
|
|
const SLIDE_STYLES = [
|
|
{ scale: 1, opacity: 1, filter: "none" },
|
|
{ scale: 0.88, opacity: 0.5, filter: "blur(2px)" },
|
|
{ scale: 0.78, opacity: 0.25, filter: "blur(4px)" },
|
|
] as const;
|
|
|
|
function slideStyle(offset: number) {
|
|
return SLIDE_STYLES[Math.min(offset, 2)] ?? SLIDE_STYLES[2]!;
|
|
}
|
|
|
|
const GAP = 24;
|
|
|
|
/* ─── Spotlight Card ─── */
|
|
|
|
const SpotlightCard = memo(function SpotlightCard({
|
|
card,
|
|
isActive,
|
|
}: {
|
|
card: ConversionServiceCard;
|
|
isActive: boolean;
|
|
}) {
|
|
const a = ACCENTS[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
|
|
? cn("shadow-xl hover:shadow-2xl", a.borderActive)
|
|
: cn("shadow-sm", a.borderInactive)
|
|
)}
|
|
style={{
|
|
background: `linear-gradient(145deg, color-mix(in oklch, ${a.cssVar} 8%, white), white)`,
|
|
}}
|
|
>
|
|
<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
|
|
className={cn(
|
|
"h-14 w-14 rounded-2xl flex items-center justify-center",
|
|
a.iconBg,
|
|
a.iconText
|
|
)}
|
|
>
|
|
<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>
|
|
<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
|
|
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
|
|
)}
|
|
>
|
|
{card.ctaLabel}
|
|
<ArrowRight className="h-4 w-4" />
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
);
|
|
});
|
|
|
|
/* ─── Header + Tab Toggle ─── */
|
|
|
|
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 ─── */
|
|
|
|
export function ServicesCarousel() {
|
|
const [activeTab, setActiveTab] = useState<Tab>("personal");
|
|
const [sectionRef, isInView] = useInView();
|
|
const cards = activeTab === "personal" ? personalConversionCards : businessConversionCards;
|
|
const c = useInfiniteCarousel({ items: cards });
|
|
|
|
useEffect(() => {
|
|
c.reset();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [activeTab]);
|
|
|
|
const trackX = -(c.trackIndex * (c.cardWidth + GAP));
|
|
|
|
return (
|
|
<section
|
|
ref={sectionRef}
|
|
className={cn(
|
|
"full-bleed bg-surface-sunken/30 py-16 sm:py-20 transition-all duration-700",
|
|
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
|
)}
|
|
>
|
|
<CarouselHeader activeTab={activeTab} onTabChange={setActiveTab} />
|
|
|
|
<div
|
|
className="relative overflow-hidden"
|
|
onMouseEnter={c.stopAuto}
|
|
onMouseLeave={c.startAuto}
|
|
onTouchStart={c.onTouchStart}
|
|
onTouchEnd={c.onTouchEnd}
|
|
onKeyDown={c.onKeyDown}
|
|
tabIndex={0}
|
|
role="region"
|
|
aria-label="Services carousel"
|
|
aria-roledescription="carousel"
|
|
>
|
|
<div
|
|
className="flex ease-out"
|
|
style={{
|
|
transform: `translateX(calc(50% - ${c.cardWidth / 2}px + ${trackX}px))`,
|
|
gap: `${GAP}px`,
|
|
transitionProperty: c.isTransitioning ? "transform" : "none",
|
|
transitionDuration: c.isTransitioning ? "500ms" : "0ms",
|
|
}}
|
|
onTransitionEnd={c.handleTransitionEnd}
|
|
>
|
|
{c.extendedItems.map((card, i) => {
|
|
const offset = Math.abs(i - c.trackIndex);
|
|
const style = slideStyle(offset);
|
|
return (
|
|
<div
|
|
key={`slide-${i}`}
|
|
className={cn(
|
|
"flex-shrink-0 transition-[transform,opacity,filter] duration-500 ease-out",
|
|
offset > 0 && "cursor-pointer"
|
|
)}
|
|
role="group"
|
|
aria-roledescription="slide"
|
|
aria-label={`${c.activeIndex + 1} of ${c.total}: ${card.title}`}
|
|
style={{
|
|
width: c.cardWidth,
|
|
transform: `scale(${style.scale})`,
|
|
opacity: style.opacity,
|
|
filter: style.filter,
|
|
}}
|
|
onClick={() => {
|
|
if (i < c.trackIndex) c.goPrev();
|
|
else if (i > c.trackIndex) c.goNext();
|
|
}}
|
|
>
|
|
<SpotlightCard card={card} isActive={offset === 0} />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<CarouselNav
|
|
cards={cards}
|
|
activeIndex={c.activeIndex}
|
|
goTo={c.goTo}
|
|
goPrev={c.goPrev}
|
|
goNext={c.goNext}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|