style: update background gradients and refactor component references
- Replaced oklch color definitions with color-mix for improved gradient consistency across OnsiteSupportContent, HeroSection, and ServicesOverviewContent components. - Refactored ref attributes in multiple sections to remove unnecessary type assertions for better type safety. - Enhanced ServicesCarousel component by introducing a new accent color system for better visual clarity and consistency.
This commit is contained in:
parent
a1431cec09
commit
a0f97cdec4
@ -111,7 +111,7 @@ export function OnsiteSupportContent() {
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at center, oklch(0.65 0.05 234.4 / 0.12) 1px, transparent 1px)`,
|
||||
backgroundImage: `radial-gradient(circle at center, color-mix(in oklch, var(--primary) 12%, transparent) 1px, transparent 1px)`,
|
||||
backgroundSize: "24px 24px",
|
||||
}}
|
||||
/>
|
||||
@ -120,13 +120,15 @@ export function OnsiteSupportContent() {
|
||||
<div
|
||||
className="absolute -top-32 -right-32 w-96 h-96 rounded-full pointer-events-none opacity-50"
|
||||
style={{
|
||||
background: "radial-gradient(circle, oklch(0.85 0.08 200 / 0.4) 0%, transparent 70%)",
|
||||
background:
|
||||
"radial-gradient(circle, color-mix(in oklch, var(--info) 30%, transparent) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-32 -left-32 w-80 h-80 rounded-full pointer-events-none opacity-40"
|
||||
style={{
|
||||
background: "radial-gradient(circle, oklch(0.85 0.06 80 / 0.3) 0%, transparent 70%)",
|
||||
background:
|
||||
"radial-gradient(circle, color-mix(in oklch, var(--warning) 25%, transparent) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -242,7 +244,7 @@ export function OnsiteSupportContent() {
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-30"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at center, oklch(0.6 0.0 0 / 0.08) 1px, transparent 1px)`,
|
||||
backgroundImage: `radial-gradient(circle at center, color-mix(in oklch, var(--muted-foreground) 8%, transparent) 1px, transparent 1px)`,
|
||||
backgroundSize: "32px 32px",
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -11,7 +11,7 @@ export function ContactSection() {
|
||||
return (
|
||||
<section
|
||||
id="contact"
|
||||
ref={ref as React.RefObject<HTMLElement>}
|
||||
ref={ref}
|
||||
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"
|
||||
|
||||
@ -14,7 +14,7 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={heroRef as React.RefObject<HTMLElement>}
|
||||
ref={heroRef}
|
||||
className={cn(
|
||||
"relative left-1/2 right-1/2 w-screen -translate-x-1/2 py-16 sm:py-20 lg:py-24 overflow-hidden transition-all duration-700",
|
||||
heroInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
@ -28,7 +28,7 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at center, oklch(0.65 0.05 234.4 / 0.15) 1px, transparent 1px)`,
|
||||
backgroundImage: `radial-gradient(circle at center, color-mix(in oklch, var(--primary) 15%, transparent) 1px, transparent 1px)`,
|
||||
backgroundSize: "24px 24px",
|
||||
}}
|
||||
/>
|
||||
@ -38,7 +38,8 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {
|
||||
className="absolute -top-32 -right-32 w-96 h-96 rounded-full pointer-events-none"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
background: "radial-gradient(circle, oklch(0.85 0.08 200 / 0.3) 0%, transparent 70%)",
|
||||
background:
|
||||
"radial-gradient(circle, color-mix(in oklch, var(--info) 25%, transparent) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { memo, useEffect, 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 { useInfiniteCarousel, useInView } from "@/features/landing-page/hooks";
|
||||
import {
|
||||
personalConversionCards,
|
||||
businessConversionCards,
|
||||
@ -14,50 +14,107 @@ import {
|
||||
|
||||
type Tab = "personal" | "business";
|
||||
|
||||
/* ─── Accent color system (oklch for perceptual uniformity) ─── */
|
||||
/* ─── Accent color system ─── */
|
||||
|
||||
interface AccentDef {
|
||||
l: number;
|
||||
c: number;
|
||||
h: number;
|
||||
interface AccentStyles {
|
||||
iconBg: string;
|
||||
iconText: string;
|
||||
ctaBg: string;
|
||||
dotBg: string;
|
||||
borderInactive: string;
|
||||
borderActive: string;
|
||||
cssVar: string;
|
||||
}
|
||||
|
||||
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 }],
|
||||
]);
|
||||
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)",
|
||||
},
|
||||
};
|
||||
|
||||
// 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})`;
|
||||
}
|
||||
/* ─── Slide visual styles by distance from center ─── */
|
||||
|
||||
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)" },
|
||||
{ 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 slideStyles(offset: number) {
|
||||
return SLIDE_STYLES[Math.min(offset, 2)];
|
||||
function slideStyle(offset: number) {
|
||||
return SLIDE_STYLES[Math.min(offset, 2)] ?? SLIDE_STYLES[2]!;
|
||||
}
|
||||
|
||||
const GAP = 24;
|
||||
|
||||
/* ─── Spotlight Card ─── */
|
||||
|
||||
const SpotlightCard = memo(function SpotlightCard({
|
||||
@ -67,7 +124,7 @@ const SpotlightCard = memo(function SpotlightCard({
|
||||
card: ConversionServiceCard;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const a = getAccent(card.accent);
|
||||
const a = ACCENTS[card.accent];
|
||||
|
||||
return (
|
||||
<Link
|
||||
@ -79,19 +136,22 @@ const SpotlightCard = memo(function SpotlightCard({
|
||||
}}
|
||||
className={cn(
|
||||
"block h-full rounded-3xl border overflow-hidden transition-shadow duration-500",
|
||||
isActive ? "shadow-xl hover:shadow-2xl" : "shadow-sm"
|
||||
isActive
|
||||
? cn("shadow-xl hover:shadow-2xl", a.borderActive)
|
||||
: cn("shadow-sm", a.borderInactive)
|
||||
)}
|
||||
style={{
|
||||
background: `linear-gradient(145deg, ${accentBg(a)}, oklch(0.995 0 0))`,
|
||||
borderColor: oklch(a, isActive ? 0.25 : 0.1),
|
||||
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">
|
||||
{/* 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) }}
|
||||
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>
|
||||
@ -101,8 +161,6 @@ const SpotlightCard = memo(function SpotlightCard({
|
||||
</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}
|
||||
@ -110,11 +168,11 @@ const SpotlightCard = memo(function SpotlightCard({
|
||||
<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) }}
|
||||
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" />
|
||||
@ -124,191 +182,173 @@ const SpotlightCard = memo(function SpotlightCard({
|
||||
);
|
||||
});
|
||||
|
||||
/* ─── Main Carousel ─── */
|
||||
/* ─── Header + Tab Toggle ─── */
|
||||
|
||||
const AUTO_INTERVAL = 5000;
|
||||
const GAP = 24;
|
||||
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 [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;
|
||||
}
|
||||
}, []);
|
||||
const c = useInfiniteCarousel({ items: cards });
|
||||
|
||||
useEffect(() => {
|
||||
startAuto();
|
||||
return stopAuto;
|
||||
}, [startAuto, stopAuto]);
|
||||
|
||||
// Reset on tab change
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
c.reset();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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));
|
||||
const trackX = -(c.trackIndex * (c.cardWidth + GAP));
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef as React.RefObject<HTMLElement>}
|
||||
ref={sectionRef}
|
||||
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>
|
||||
<CarouselHeader activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
{/* Spotlight Carousel Track */}
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
onMouseEnter={stopAuto}
|
||||
onMouseLeave={startAuto}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onKeyDown={onKeyDown}
|
||||
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 transition-transform duration-500 ease-out"
|
||||
className="flex ease-out"
|
||||
style={{
|
||||
transform: `translateX(calc(50% - ${cardWidth / 2}px + ${trackX}px))`,
|
||||
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}
|
||||
>
|
||||
{cards.map((card, i) => {
|
||||
const offset = Math.abs(i - activeIndex);
|
||||
{c.extendedItems.map((card, i) => {
|
||||
const offset = Math.abs(i - c.trackIndex);
|
||||
const style = slideStyle(offset);
|
||||
return (
|
||||
<div
|
||||
key={card.href}
|
||||
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={`${i + 1} of ${total}: ${card.title}`}
|
||||
aria-label={`${c.activeIndex + 1} of ${c.total}: ${card.title}`}
|
||||
style={{
|
||||
width: cardWidth,
|
||||
...slideStyles(offset),
|
||||
width: c.cardWidth,
|
||||
transform: `scale(${style.scale})`,
|
||||
opacity: style.opacity,
|
||||
filter: style.filter,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (offset > 0) goTo(i);
|
||||
if (i < c.trackIndex) c.goPrev();
|
||||
else if (i > c.trackIndex) c.goNext();
|
||||
}}
|
||||
>
|
||||
<SpotlightCard card={card} isActive={offset === 0} />
|
||||
@ -318,45 +358,13 @@ export function ServicesCarousel() {
|
||||
</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>
|
||||
<CarouselNav
|
||||
cards={cards}
|
||||
activeIndex={c.activeIndex}
|
||||
goTo={c.goTo}
|
||||
goPrev={c.goPrev}
|
||||
goNext={c.goNext}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ export function SupportDownloadsSection() {
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={ref as React.RefObject<HTMLElement>}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"py-14 sm:py-16 transition-all duration-700",
|
||||
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
|
||||
@ -70,7 +70,7 @@ export function TrustStrip() {
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={ref as React.RefObject<HTMLElement>}
|
||||
ref={ref}
|
||||
aria-label="Company statistics"
|
||||
className={cn(
|
||||
"relative left-1/2 right-1/2 w-screen -translate-x-1/2 py-10 sm:py-12 overflow-hidden transition-all duration-700",
|
||||
|
||||
@ -17,7 +17,7 @@ export function WhyUsSection() {
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={ref as React.RefObject<HTMLElement>}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-background transition-all duration-700",
|
||||
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export { useInfiniteCarousel } from "./useInfiniteCarousel";
|
||||
export { useInView } from "./useInView";
|
||||
export { useStickyCta } from "./useStickyCta";
|
||||
|
||||
@ -7,8 +7,10 @@ const DEFAULT_OPTIONS: IntersectionObserverInit = {};
|
||||
* Returns a ref and boolean indicating if element is in viewport.
|
||||
* Once the element becomes visible, it stays marked as "in view" (trigger once).
|
||||
*/
|
||||
export function useInView(options: IntersectionObserverInit = DEFAULT_OPTIONS) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
export function useInView<T extends HTMLElement = HTMLElement>(
|
||||
options: IntersectionObserverInit = DEFAULT_OPTIONS
|
||||
) {
|
||||
const ref = useRef<T>(null!);
|
||||
const [isInView, setIsInView] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const AUTO_INTERVAL = 5000;
|
||||
|
||||
function useResponsiveCardWidth() {
|
||||
const [cardWidth, setCardWidth] = useState(520);
|
||||
const rafRef = useRef(0);
|
||||
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return cardWidth;
|
||||
}
|
||||
|
||||
export function useInfiniteCarousel<T>({ items }: { items: T[] }) {
|
||||
const total = items.length;
|
||||
const autoRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const touchXRef = useRef(0);
|
||||
const cardWidth = useResponsiveCardWidth();
|
||||
const [trackIndex, setTrackIndex] = useState(1);
|
||||
const [isTransitioning, setIsTransitioning] = useState(true);
|
||||
|
||||
const extendedItems = useMemo(() => {
|
||||
if (total === 0) return [];
|
||||
return [items[total - 1]!, ...items, items[0]!];
|
||||
}, [items, total]);
|
||||
|
||||
const activeIndex = (((trackIndex - 1) % total) + total) % total;
|
||||
|
||||
const startAuto = useCallback(() => {
|
||||
if (autoRef.current) clearInterval(autoRef.current);
|
||||
autoRef.current = setInterval(() => {
|
||||
setTrackIndex(prev => prev + 1);
|
||||
setIsTransitioning(true);
|
||||
}, AUTO_INTERVAL);
|
||||
}, []);
|
||||
|
||||
const stopAuto = useCallback(() => {
|
||||
if (autoRef.current) {
|
||||
clearInterval(autoRef.current);
|
||||
autoRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
startAuto();
|
||||
return stopAuto;
|
||||
}, [startAuto, stopAuto]);
|
||||
|
||||
const handleTransitionEnd = useCallback(() => {
|
||||
if (trackIndex >= total + 1) {
|
||||
setIsTransitioning(false);
|
||||
setTrackIndex(1);
|
||||
} else if (trackIndex <= 0) {
|
||||
setIsTransitioning(false);
|
||||
setTrackIndex(total);
|
||||
}
|
||||
}, [trackIndex, total]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTransitioning) return;
|
||||
const id = requestAnimationFrame(() => setIsTransitioning(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [isTransitioning]);
|
||||
|
||||
const navigate = useCallback(
|
||||
(updater: number | ((prev: number) => number)) => {
|
||||
setTrackIndex(updater);
|
||||
setIsTransitioning(true);
|
||||
startAuto();
|
||||
},
|
||||
[startAuto]
|
||||
);
|
||||
|
||||
const goTo = useCallback((i: number) => navigate(i + 1), [navigate]);
|
||||
const goPrev = useCallback(() => navigate(p => p - 1), [navigate]);
|
||||
const goNext = useCallback(() => navigate(p => p + 1), [navigate]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setTrackIndex(1);
|
||||
setIsTransitioning(false);
|
||||
}, []);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") goPrev();
|
||||
else if (e.key === "ArrowRight") goNext();
|
||||
},
|
||||
[goPrev, goNext]
|
||||
);
|
||||
|
||||
return {
|
||||
extendedItems,
|
||||
total,
|
||||
activeIndex,
|
||||
trackIndex,
|
||||
cardWidth,
|
||||
isTransitioning,
|
||||
handleTransitionEnd,
|
||||
goTo,
|
||||
goPrev,
|
||||
goNext,
|
||||
reset,
|
||||
startAuto,
|
||||
stopAuto,
|
||||
onTouchStart,
|
||||
onTouchEnd,
|
||||
onKeyDown,
|
||||
};
|
||||
}
|
||||
@ -43,7 +43,7 @@ export function ServiceCTA({
|
||||
<section className={cn("relative text-center py-12 rounded-2xl overflow-hidden", className)}>
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/10 via-primary/5 to-purple-500/10" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_oklch(0.55_0.18_260_/_0.1),_transparent_70%)]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_color-mix(in_oklch,var(--color-indigo-500)_10%,transparent),_transparent_70%)]" />
|
||||
|
||||
{/* Decorative rings */}
|
||||
<div
|
||||
|
||||
@ -108,7 +108,7 @@ export function ServicesOverviewContent({
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at center, oklch(0.65 0.05 234.4 / 0.12) 1px, transparent 1px)`,
|
||||
backgroundImage: `radial-gradient(circle at center, color-mix(in oklch, var(--primary) 12%, transparent) 1px, transparent 1px)`,
|
||||
backgroundSize: "24px 24px",
|
||||
}}
|
||||
/>
|
||||
@ -117,7 +117,8 @@ export function ServicesOverviewContent({
|
||||
<div
|
||||
className="absolute -top-32 -right-32 w-96 h-96 rounded-full pointer-events-none opacity-60"
|
||||
style={{
|
||||
background: "radial-gradient(circle, oklch(0.85 0.08 200 / 0.4) 0%, transparent 70%)",
|
||||
background:
|
||||
"radial-gradient(circle, color-mix(in oklch, var(--info) 30%, transparent) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -182,7 +183,7 @@ export function ServicesOverviewContent({
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-30"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at center, oklch(0.6 0.0 0 / 0.08) 1px, transparent 1px)`,
|
||||
backgroundImage: `radial-gradient(circle at center, color-mix(in oklch, var(--muted-foreground) 8%, transparent) 1px, transparent 1px)`,
|
||||
backgroundSize: "32px 32px",
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
const reducedMotionQuery =
|
||||
typeof window === "undefined" ? undefined : window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
|
||||
interface UseCountUpOptions {
|
||||
/** Starting value (default: 0) */
|
||||
start?: number;
|
||||
@ -37,7 +40,7 @@ export function useCountUp({
|
||||
}
|
||||
|
||||
// Respect prefers-reduced-motion — show final value immediately
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
if (reducedMotionQuery?.matches) {
|
||||
setCount(end);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -0,0 +1,322 @@
|
||||
# Carousel Badge Fix & Contact Form Improvements — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix carousel card badge positioning (inline with icon, not above), remove prices, and improve contact form design/layout across all 3 pages.
|
||||
|
||||
**Architecture:** Pure UI changes across 5 existing files. No new components, no new abstractions. The shared `ContactForm` component gets tighter spacing and better styling; each page's surrounding layout is improved independently.
|
||||
|
||||
**Tech Stack:** React 19, Tailwind CSS, Next.js 15, lucide-react icons
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Fix carousel card badge position and remove prices
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/portal/src/features/landing-page/components/ServicesCarousel.tsx:16-41`
|
||||
|
||||
**Step 1: Update `ServiceConversionCard` layout**
|
||||
|
||||
Replace lines 22-38 (the card content inside `<article>`) with:
|
||||
|
||||
```tsx
|
||||
{/* 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>
|
||||
<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>
|
||||
```
|
||||
|
||||
Key changes:
|
||||
|
||||
- Badge and icon now share a `flex items-center justify-between` row (icon left, badge right)
|
||||
- Removed `priceFrom` rendering entirely (the `{card.priceFrom && ...}` block is gone)
|
||||
- Badge no longer has `self-start` or `mb-3` since it's in the flex row
|
||||
|
||||
**Step 2: Verify it builds**
|
||||
|
||||
Run: `pnpm type-check`
|
||||
Expected: No type errors (priceFrom still exists in the type, just not rendered)
|
||||
|
||||
**Step 3: Visual check**
|
||||
|
||||
Open localhost:3000 and verify:
|
||||
|
||||
- Badge "1st month free" appears to the right of the phone icon on the Phone Plans card
|
||||
- No prices shown on any card
|
||||
- Cards without badges look normal (just icon, no empty space on the right)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/portal/src/features/landing-page/components/ServicesCarousel.tsx
|
||||
git commit -m "fix: move carousel badge inline with icon, remove prices"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Improve shared ContactForm styling
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/portal/src/features/support/components/ContactForm.tsx`
|
||||
|
||||
**Step 1: Tighten form spacing and polish styling**
|
||||
|
||||
1. Line 63 — change outer container padding:
|
||||
- From: `"bg-card rounded-2xl border border-border/60 p-6"`
|
||||
- To: `"bg-card rounded-2xl border border-border/60 p-5 sm:p-6"`
|
||||
|
||||
2. Line 70 — tighten form spacing:
|
||||
- From: `className="space-y-5"`
|
||||
- To: `className="space-y-4"`
|
||||
|
||||
3. Lines 71, 102 — tighten grid gaps:
|
||||
- From: `gap-5` (both grids)
|
||||
- To: `gap-4` (both grids)
|
||||
|
||||
4. Line 134 — improve textarea styling (add ring transition):
|
||||
- From: `className="flex min-h-[120px] w-full rounded-lg border border-input bg-muted/20 px-4 py-3 text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:border-transparent disabled:cursor-not-allowed disabled:opacity-50 transition-colors resize-y text-sm"`
|
||||
- To: `className="flex min-h-[100px] w-full rounded-lg border border-input bg-muted/20 px-3.5 py-2.5 text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:border-transparent disabled:cursor-not-allowed disabled:opacity-50 transition-all resize-y text-sm"`
|
||||
|
||||
5. Lines 155-157 — add clock icon to response time note:
|
||||
- Add `Clock` to the lucide-react import (line 8)
|
||||
- Replace the `<p>` with:
|
||||
```tsx
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-4 pt-3 border-t border-border/40">
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>We typically respond within 24 hours.</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Step 2: Verify it builds**
|
||||
|
||||
Run: `pnpm type-check`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Visual check**
|
||||
|
||||
Check form on all 3 pages (localhost:3000, /contact, /support). Verify tighter spacing, consistent styling.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/portal/src/features/support/components/ContactForm.tsx
|
||||
git commit -m "style: tighten contact form spacing and polish styling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Improve landing page ContactSection layout
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/portal/src/features/landing-page/components/ContactSection.tsx`
|
||||
|
||||
**Step 1: Improve contact method visual hierarchy and tighten layout**
|
||||
|
||||
Key changes to the left column (lines 27-52):
|
||||
|
||||
- Give each contact method (form, chat, phone) a subtle background card with border
|
||||
- Tighten `space-y-6` to `space-y-5`
|
||||
- Reduce outer card padding from `p-6 sm:p-8` to `p-5 sm:p-7`
|
||||
- Reduce grid gap from `gap-10 lg:gap-12` to `gap-8 lg:gap-10`
|
||||
|
||||
Replace the entire left column `<div className="space-y-6">` (lines 27-52) with:
|
||||
|
||||
```tsx
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-2 text-primary font-bold text-lg">
|
||||
<Mail className="h-5 w-5" />
|
||||
<span>By Online Form (Anytime)</span>
|
||||
</div>
|
||||
<ContactForm className="border-0 p-0 rounded-none bg-transparent" />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
|
||||
<div className="rounded-xl bg-muted/30 p-4 space-y-1.5">
|
||||
<div className="inline-flex items-center gap-2 text-primary font-semibold text-sm">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<span>By Chat (Anytime)</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Click the “Chat Button” at the bottom right to reach our team.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-muted/30 p-4 space-y-1.5">
|
||||
<div className="inline-flex items-center gap-2 text-primary font-semibold text-sm">
|
||||
<PhoneCall className="h-4 w-4" />
|
||||
<span>By Phone (9:30-18:00 JST)</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">
|
||||
Toll Free: <span className="text-sm font-bold text-primary">0120-660-470</span>
|
||||
</p>
|
||||
<p className="font-medium text-foreground mt-0.5">
|
||||
Overseas: <span className="text-sm font-bold text-primary">+81-3-3560-1006</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Also update the outer card and grid (lines 24-25):
|
||||
|
||||
- Line 24: change `p-6 sm:p-8` to `p-5 sm:p-7`
|
||||
- Line 25: change `gap-10 lg:gap-12` to `gap-8 lg:gap-10`
|
||||
|
||||
And the right column (line 56): change `space-y-6` to `space-y-5`
|
||||
|
||||
**Step 2: Verify it builds**
|
||||
|
||||
Run: `pnpm type-check`
|
||||
|
||||
**Step 3: Visual check**
|
||||
|
||||
Check localhost:3000, scroll to bottom contact section. Verify:
|
||||
|
||||
- Chat and Phone cards are side-by-side in subtle background cards
|
||||
- Phone numbers are inline with labels (more compact)
|
||||
- Overall section feels tighter and more organized
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/portal/src/features/landing-page/components/ContactSection.tsx
|
||||
git commit -m "style: improve landing page contact section hierarchy and compactness"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Improve PublicContactView layout
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/portal/src/features/support/views/PublicContactView.tsx`
|
||||
|
||||
**Step 1: Tighten header and sidebar**
|
||||
|
||||
1. Header section (lines 11-22) — reduce spacing:
|
||||
- Change `mb-12 pt-8` to `mb-10 pt-8`
|
||||
- Change `mb-4` (on heading) to `mb-3`
|
||||
|
||||
2. Grid layout (line 25) — reduce gap:
|
||||
- From: `gap-10 mb-16`
|
||||
- To: `gap-8 mb-14`
|
||||
|
||||
3. Sidebar cards (lines 32-122) — make more compact:
|
||||
- Change `space-y-6` to `space-y-4`
|
||||
- Change all sidebar card padding from `p-5` to `p-4` (Phone, Chat, Email, Hours, Office cards)
|
||||
- Change icon containers from `h-11 w-11` to `h-10 w-10`
|
||||
|
||||
**Step 2: Verify it builds**
|
||||
|
||||
Run: `pnpm type-check`
|
||||
|
||||
**Step 3: Visual check**
|
||||
|
||||
Check localhost:3000/contact. Verify:
|
||||
|
||||
- Sidebar cards are slightly more compact
|
||||
- Overall page feels tighter
|
||||
- Mobile stacking still works cleanly
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/portal/src/features/support/views/PublicContactView.tsx
|
||||
git commit -m "style: tighten contact us page layout and sidebar spacing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Improve PublicSupportView contact section
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/portal/src/features/support/views/PublicSupportView.tsx:248-264`
|
||||
|
||||
**Step 1: Tighten the "Still Need Help?" section**
|
||||
|
||||
Replace lines 248-263 with:
|
||||
|
||||
```tsx
|
||||
{
|
||||
/* Contact Form Fallback */
|
||||
}
|
||||
<section className="mb-12" aria-labelledby="contact-heading">
|
||||
<div className="text-center mb-5">
|
||||
<div className="inline-flex items-center justify-center w-11 h-11 bg-primary/10 rounded-xl mb-2.5 text-primary">
|
||||
<Send className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 id="contact-heading" className="text-2xl font-bold text-foreground">
|
||||
Still Need Help?
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Send us a message and we'll get back to you within 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<ContactForm />
|
||||
</div>
|
||||
</section>;
|
||||
```
|
||||
|
||||
Key changes:
|
||||
|
||||
- Icon container `w-12 h-12` → `w-11 h-11`, icon `h-6 w-6` → `h-5 w-5`
|
||||
- Section `mb-6` → `mb-5`
|
||||
- Icon margin `mb-3` → `mb-2.5`
|
||||
- Description text size `text-base` → `text-sm`
|
||||
|
||||
**Step 2: Verify it builds**
|
||||
|
||||
Run: `pnpm type-check`
|
||||
|
||||
**Step 3: Visual check**
|
||||
|
||||
Check localhost:3000/support, scroll to bottom. Verify:
|
||||
|
||||
- "Still Need Help?" section is slightly tighter
|
||||
- Form uses updated shared styling
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/portal/src/features/support/views/PublicSupportView.tsx
|
||||
git commit -m "style: tighten support page contact section spacing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Final verification
|
||||
|
||||
**Step 1: Run full lint and type check**
|
||||
|
||||
```bash
|
||||
pnpm type-check && pnpm lint
|
||||
```
|
||||
|
||||
Expected: No errors
|
||||
|
||||
**Step 2: Visual smoke test all 3 pages**
|
||||
|
||||
- localhost:3000 — landing page carousel + bottom contact section
|
||||
- localhost:3000/contact — contact us page
|
||||
- localhost:3000/support — support page bottom form
|
||||
|
||||
Check at mobile (375px) and desktop (1280px) widths.
|
||||
328
docs/plans/2026-03-04-trust-strip-redesign.md
Normal file
328
docs/plans/2026-03-04-trust-strip-redesign.md
Normal file
@ -0,0 +1,328 @@
|
||||
# TrustStrip Redesign — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the minimal icon+text trust strip with a bold stats section featuring large animated count-up numbers, icon circles, vertical dividers, and a gradient background.
|
||||
|
||||
**Architecture:** 3-file change — new `useCountUp` hook for the animation, rewritten `TrustStrip.tsx` component, barrel file update. Uses existing `useInView` hook and codebase patterns (cn utility, Tailwind, lucide-react). No new dependencies.
|
||||
|
||||
**Tech Stack:** React 19, Tailwind CSS, lucide-react, existing `useInView` hook
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create the useCountUp hook
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `apps/portal/src/features/landing-page/hooks/useCountUp.ts`
|
||||
|
||||
**Step 1: Write the hook**
|
||||
|
||||
Create `apps/portal/src/features/landing-page/hooks/useCountUp.ts`:
|
||||
|
||||
```tsx
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* useCountUp — Animates a number from 0 to target over a duration.
|
||||
* Respects prefers-reduced-motion. Only runs when enabled is true.
|
||||
*/
|
||||
export function useCountUp(target: number, duration = 1500, enabled = false): number {
|
||||
const [value, setValue] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
// Respect prefers-reduced-motion
|
||||
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (prefersReduced) {
|
||||
setValue(target);
|
||||
return;
|
||||
}
|
||||
|
||||
let startTime: number | null = null;
|
||||
let rafId: number;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const elapsed = timestamp - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Ease-out cubic for smooth deceleration
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setValue(Math.round(eased * target));
|
||||
|
||||
if (progress < 1) {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [target, duration, enabled]);
|
||||
|
||||
return value;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Export from barrel file**
|
||||
|
||||
Modify `apps/portal/src/features/landing-page/hooks/index.ts` — add this line:
|
||||
|
||||
```ts
|
||||
export { useCountUp } from "./useCountUp";
|
||||
```
|
||||
|
||||
**Step 3: Verify it builds**
|
||||
|
||||
Run: `pnpm type-check`
|
||||
Expected: No errors
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/portal/src/features/landing-page/hooks/useCountUp.ts apps/portal/src/features/landing-page/hooks/index.ts
|
||||
git commit -m "feat: add useCountUp hook for animated number transitions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rewrite TrustStrip component
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/portal/src/features/landing-page/components/TrustStrip.tsx` (complete rewrite)
|
||||
|
||||
**Step 1: Replace TrustStrip.tsx with new implementation**
|
||||
|
||||
Rewrite `apps/portal/src/features/landing-page/components/TrustStrip.tsx`:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { Clock, CreditCard, Globe, Users } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { useInView, useCountUp } from "@/features/landing-page/hooks";
|
||||
|
||||
interface StatItem {
|
||||
icon: LucideIcon;
|
||||
value: number;
|
||||
suffix: string;
|
||||
label: string;
|
||||
delay: number;
|
||||
formatter?: (n: number) => string;
|
||||
}
|
||||
|
||||
const stats: StatItem[] = [
|
||||
{ icon: Clock, value: 20, suffix: "+", label: "Years in Japan", delay: 0 },
|
||||
{ icon: Globe, value: 100, suffix: "%", label: "English Support", delay: 100 },
|
||||
{
|
||||
icon: Users,
|
||||
value: 10000,
|
||||
suffix: "+",
|
||||
label: "Customers Served",
|
||||
delay: 200,
|
||||
formatter: (n: number) => n.toLocaleString(),
|
||||
},
|
||||
];
|
||||
|
||||
function AnimatedStat({ stat, inView }: { stat: StatItem; inView: boolean }) {
|
||||
const count = useCountUp(stat.value, 1500, inView);
|
||||
|
||||
return (
|
||||
<span className="text-3xl sm:text-4xl font-extrabold text-primary tabular-nums">
|
||||
{stat.formatter ? stat.formatter(count) : count}
|
||||
{stat.suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrustStrip() {
|
||||
const [ref, inView] = useInView();
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={ref as React.RefObject<HTMLElement>}
|
||||
aria-label="Company statistics"
|
||||
className={cn(
|
||||
"relative left-1/2 right-1/2 w-screen -translate-x-1/2 py-10 sm:py-12 overflow-hidden transition-all duration-700",
|
||||
inView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
)}
|
||||
>
|
||||
{/* Gradient background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-surface-sunken via-background to-info-bg/30" />
|
||||
|
||||
<div className="relative mx-auto max-w-6xl px-6 sm:px-10 lg:px-14">
|
||||
<div className="grid grid-cols-2 gap-8 sm:flex sm:justify-between sm:items-center">
|
||||
{/* Animated stats */}
|
||||
{stats.map((stat, i) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className={cn(
|
||||
"flex flex-col items-center text-center gap-3 sm:flex-1",
|
||||
i < stats.length - 1 && "sm:border-r sm:border-border/30"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10">
|
||||
<stat.icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<AnimatedStat stat={stat} inView={inView} />
|
||||
<span className="text-sm text-muted-foreground font-medium">{stat.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Static stat — Foreign Cards */}
|
||||
<div className={cn("flex flex-col items-center text-center gap-3 sm:flex-1")}>
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-xl sm:text-2xl font-extrabold text-primary">Foreign Cards</span>
|
||||
<span className="text-sm text-muted-foreground font-medium">Accepted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Key decisions:
|
||||
|
||||
- `AnimatedStat` is a separate component so each stat has its own `useCountUp` instance
|
||||
- The 4th stat (Foreign Cards) is rendered separately since it has no numeric animation
|
||||
- Desktop: flex row with `border-r` dividers on first 3 items. Mobile: 2-col grid
|
||||
- `tabular-nums` prevents layout jitter during count-up animation
|
||||
- `formatter` on the 10,000 stat adds comma separators
|
||||
|
||||
**Step 2: Verify it builds**
|
||||
|
||||
Run: `pnpm type-check`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Visual check**
|
||||
|
||||
Open `localhost:3000` and verify:
|
||||
|
||||
- 4 stats displayed in a row on desktop, 2x2 grid on mobile
|
||||
- Numbers animate from 0 to target when scrolling into view
|
||||
- Vertical dividers between first 3 stats on desktop
|
||||
- "Foreign Cards / Accepted" shows as static bold text
|
||||
- Gradient background blends with hero above
|
||||
- Each stat has a primary-tinted circular icon above it
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/portal/src/features/landing-page/components/TrustStrip.tsx
|
||||
git commit -m "feat: redesign TrustStrip with bold animated stats"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Stagger the count-up animations
|
||||
|
||||
The current implementation starts all counters simultaneously. Add stagger delays.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/portal/src/features/landing-page/hooks/useCountUp.ts`
|
||||
- Modify: `apps/portal/src/features/landing-page/components/TrustStrip.tsx`
|
||||
|
||||
**Step 1: Add delay parameter to useCountUp**
|
||||
|
||||
In `useCountUp.ts`, change the signature and add a delay before animation starts:
|
||||
|
||||
```tsx
|
||||
export function useCountUp(target: number, duration = 1500, enabled = false, delay = 0): number {
|
||||
```
|
||||
|
||||
Update the effect body — wrap the animation start in a `setTimeout`:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (prefersReduced) {
|
||||
setValue(target);
|
||||
return;
|
||||
}
|
||||
|
||||
let startTime: number | null = null;
|
||||
let rafId: number;
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const elapsed = timestamp - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setValue(Math.round(eased * target));
|
||||
|
||||
if (progress < 1) {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [target, duration, enabled, delay]);
|
||||
```
|
||||
|
||||
**Step 2: Pass delay from TrustStrip**
|
||||
|
||||
In `AnimatedStat`, update the `useCountUp` call:
|
||||
|
||||
```tsx
|
||||
function AnimatedStat({ stat, inView }: { stat: StatItem; inView: boolean }) {
|
||||
const count = useCountUp(stat.value, 1500, inView, stat.delay);
|
||||
```
|
||||
|
||||
**Step 3: Verify it builds**
|
||||
|
||||
Run: `pnpm type-check`
|
||||
Expected: No errors
|
||||
|
||||
**Step 4: Visual check**
|
||||
|
||||
Open `localhost:3000`, scroll to trust strip. The 3 animated numbers should start counting up in sequence: "20+" first, "100%" ~100ms later, "10,000+" ~200ms later. The stagger should feel subtle but add polish.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/portal/src/features/landing-page/hooks/useCountUp.ts apps/portal/src/features/landing-page/components/TrustStrip.tsx
|
||||
git commit -m "feat: add staggered delay to count-up animations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Final verification
|
||||
|
||||
**Step 1: Run type check and lint**
|
||||
|
||||
Run: `pnpm type-check`
|
||||
Expected: No errors
|
||||
|
||||
**Step 2: Visual smoke test**
|
||||
|
||||
Check `localhost:3000` at:
|
||||
|
||||
- Desktop (1280px): 4 stats in a row, dividers, gradient bg, count-up animation
|
||||
- Tablet (768px): same row, slightly smaller numbers
|
||||
- Mobile (375px): 2x2 grid, no dividers, animation still works
|
||||
|
||||
**Step 3: Accessibility check**
|
||||
|
||||
- Tab through the page — stats section should be announced as "Company statistics"
|
||||
- Set `prefers-reduced-motion: reduce` in browser DevTools — numbers should show final values immediately without animation
|
||||
Loading…
x
Reference in New Issue
Block a user