refactor: update support service and contact form components
- Changed the subject line in the SupportService to include the contact name for better context. - Removed the subject field from the ContactForm state and schema, streamlining the form. - Updated the HeroSection text to better reflect the services offered, enhancing clarity and appeal.
This commit is contained in:
parent
408f99ae3c
commit
a1431cec09
@ -159,7 +159,7 @@ export class SupportService {
|
|||||||
try {
|
try {
|
||||||
// Create a case without account association (Web-to-Case style)
|
// Create a case without account association (Web-to-Case style)
|
||||||
await this.caseService.createWebCase({
|
await this.caseService.createWebCase({
|
||||||
subject: request.subject,
|
subject: `Contact from ${request.name}`,
|
||||||
description: `Contact from: ${request.name}\nEmail: ${request.email}\nPhone: ${request.phone || "Not provided"}\n\n${request.message}`,
|
description: `Contact from: ${request.name}\nEmail: ${request.email}\nPhone: ${request.phone || "Not provided"}\n\n${request.message}`,
|
||||||
suppliedEmail: request.email,
|
suppliedEmail: request.email,
|
||||||
suppliedName: request.name,
|
suppliedName: request.name,
|
||||||
|
|||||||
@ -44,11 +44,12 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {
|
|||||||
|
|
||||||
<div className="relative mx-auto max-w-3xl px-6 sm:px-10 lg:px-14 text-center">
|
<div className="relative mx-auto max-w-3xl px-6 sm:px-10 lg:px-14 text-center">
|
||||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold leading-tight text-foreground">
|
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold leading-tight text-foreground">
|
||||||
<span className="block">Just Moved to Japan?</span>
|
<span className="block">A One Stop Solution</span>
|
||||||
<span className="block text-primary mt-2">Get Connected in English</span>
|
<span className="block text-primary mt-2">for Your IT Needs</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 text-base sm:text-lg text-muted-foreground leading-relaxed font-semibold max-w-2xl mx-auto">
|
<p className="mt-6 text-base sm:text-lg text-muted-foreground leading-relaxed font-semibold max-w-2xl mx-auto">
|
||||||
Internet, phone, VPN and IT support — set up in days, not weeks. No Japanese needed.
|
Internet, phone, VPN and IT support — all in one place, with full English support in
|
||||||
|
Japan.
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
ref={heroCTARef}
|
ref={heroCTARef}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
|
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { cn } from "@/shared/utils";
|
import { cn } from "@/shared/utils";
|
||||||
@ -9,190 +9,352 @@ import {
|
|||||||
personalConversionCards,
|
personalConversionCards,
|
||||||
businessConversionCards,
|
businessConversionCards,
|
||||||
type ConversionServiceCard,
|
type ConversionServiceCard,
|
||||||
|
type CarouselAccent,
|
||||||
} from "@/features/landing-page/data";
|
} from "@/features/landing-page/data";
|
||||||
|
|
||||||
type Tab = "personal" | "business";
|
type Tab = "personal" | "business";
|
||||||
|
|
||||||
function ServiceConversionCard({ card }: { card: ConversionServiceCard }) {
|
/* ─── 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 (
|
return (
|
||||||
<Link href={card.href} className="group flex-shrink-0 w-[260px] sm:w-[280px] snap-start">
|
<Link
|
||||||
<article
|
href={card.href}
|
||||||
data-service-card
|
tabIndex={isActive ? 0 : -1}
|
||||||
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"
|
aria-hidden={!isActive}
|
||||||
>
|
onClick={e => {
|
||||||
{/* Icon + Badge row */}
|
if (!isActive) e.preventDefault();
|
||||||
<div className="flex items-center justify-between mb-4">
|
}}
|
||||||
<div className="text-primary">{card.icon}</div>
|
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 && (
|
{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">
|
<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}
|
{card.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
{/* Content */}
|
||||||
<p className="text-sm text-muted-foreground mb-3 flex-grow">{card.keyBenefit}</p>
|
<p className="text-sm font-medium text-muted-foreground mb-1.5">{card.problemHook}</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">
|
<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}
|
{card.ctaLabel}
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
/* ─── Main Carousel ─── */
|
||||||
|
|
||||||
|
const AUTO_INTERVAL = 5000;
|
||||||
|
const GAP = 24;
|
||||||
|
|
||||||
export function ServicesCarousel() {
|
export function ServicesCarousel() {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("personal");
|
const [activeTab, setActiveTab] = useState<Tab>("personal");
|
||||||
const carouselRef = useRef<HTMLDivElement>(null);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const itemWidthRef = useRef(0);
|
const [cardWidth, setCardWidth] = useState(520);
|
||||||
const isScrollingRef = useRef(false);
|
const autoRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const autoScrollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const touchXRef = useRef(0);
|
||||||
|
const rafRef = useRef(0);
|
||||||
const [sectionRef, isInView] = useInView();
|
const [sectionRef, isInView] = useInView();
|
||||||
|
|
||||||
const cards = activeTab === "personal" ? personalConversionCards : businessConversionCards;
|
const cards = activeTab === "personal" ? personalConversionCards : businessConversionCards;
|
||||||
|
const total = cards.length;
|
||||||
|
|
||||||
const computeItemWidth = useCallback(() => {
|
// Responsive card width (rAF-throttled)
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
computeItemWidth();
|
const update = () => {
|
||||||
window.addEventListener("resize", computeItemWidth);
|
const vw = window.innerWidth;
|
||||||
startAutoScroll();
|
if (vw < 640) setCardWidth(vw - 48);
|
||||||
return () => {
|
else if (vw < 1024) setCardWidth(440);
|
||||||
window.removeEventListener("resize", computeItemWidth);
|
else setCardWidth(520);
|
||||||
stopAutoScroll();
|
|
||||||
};
|
};
|
||||||
}, [computeItemWidth, startAutoScroll, stopAutoScroll]);
|
const onResize = () => {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Reset scroll position when tab changes
|
// Auto-rotate
|
||||||
useEffect(() => {
|
const startAuto = useCallback(() => {
|
||||||
if (carouselRef.current) {
|
if (autoRef.current) clearInterval(autoRef.current);
|
||||||
carouselRef.current.scrollTo({ left: 0, behavior: "smooth" });
|
autoRef.current = setInterval(() => {
|
||||||
|
setActiveIndex(prev => (prev + 1) % total);
|
||||||
|
}, AUTO_INTERVAL);
|
||||||
|
}, [total]);
|
||||||
|
|
||||||
|
const stopAuto = useCallback(() => {
|
||||||
|
if (autoRef.current) {
|
||||||
|
clearInterval(autoRef.current);
|
||||||
|
autoRef.current = null;
|
||||||
}
|
}
|
||||||
computeItemWidth();
|
}, []);
|
||||||
}, [activeTab, computeItemWidth]);
|
|
||||||
|
|
||||||
const handlePrev = useCallback(() => {
|
useEffect(() => {
|
||||||
scrollByOne(-1);
|
startAuto();
|
||||||
startAutoScroll();
|
return stopAuto;
|
||||||
}, [scrollByOne, startAutoScroll]);
|
}, [startAuto, stopAuto]);
|
||||||
|
|
||||||
const handleNext = useCallback(() => {
|
// Reset on tab change
|
||||||
scrollByOne(1);
|
useEffect(() => {
|
||||||
startAutoScroll();
|
setActiveIndex(0);
|
||||||
}, [scrollByOne, startAutoScroll]);
|
}, [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 (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={sectionRef as React.RefObject<HTMLElement>}
|
ref={sectionRef as React.RefObject<HTMLElement>}
|
||||||
className={cn(
|
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",
|
"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"
|
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 */}
|
||||||
{/* 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 mb-8">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-foreground">Our Services</h2>
|
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground">Our Services</h2>
|
||||||
<p className="mt-1 text-base text-muted-foreground">
|
<p className="mt-2 text-lg text-muted-foreground">
|
||||||
Everything you need to stay connected in Japan
|
Everything you need to stay connected in Japan
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex bg-muted rounded-full p-1 self-start">
|
<div className="flex bg-muted rounded-full p-1 self-start">
|
||||||
<button
|
{(["personal", "business"] as const).map(tab => (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setActiveTab("personal")}
|
key={tab}
|
||||||
className={cn(
|
type="button"
|
||||||
"px-4 py-2 text-sm font-semibold rounded-full transition-all",
|
onClick={() => setActiveTab(tab)}
|
||||||
activeTab === "personal"
|
className={cn(
|
||||||
? "bg-foreground text-background shadow-sm"
|
"px-5 py-2.5 text-sm font-semibold rounded-full transition-all",
|
||||||
: "text-muted-foreground hover:text-foreground"
|
activeTab === tab
|
||||||
)}
|
? "bg-foreground text-background shadow-sm"
|
||||||
>
|
: "text-muted-foreground hover:text-foreground"
|
||||||
For You
|
)}
|
||||||
</button>
|
>
|
||||||
<button
|
{tab === "personal" ? "For You" : "For Business"}
|
||||||
type="button"
|
</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>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Navigation buttons */}
|
{/* Spotlight Carousel Track */}
|
||||||
<div className="flex justify-end gap-2 mt-4">
|
<div
|
||||||
<button
|
className="relative overflow-hidden"
|
||||||
type="button"
|
onMouseEnter={stopAuto}
|
||||||
aria-label="Scroll left"
|
onMouseLeave={startAuto}
|
||||||
onClick={handlePrev}
|
onTouchStart={onTouchStart}
|
||||||
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"
|
onTouchEnd={onTouchEnd}
|
||||||
>
|
onKeyDown={onKeyDown}
|
||||||
<ChevronLeft className="h-4 w-4" />
|
tabIndex={0}
|
||||||
</button>
|
role="region"
|
||||||
<button
|
aria-label="Services carousel"
|
||||||
type="button"
|
aria-roledescription="carousel"
|
||||||
aria-label="Scroll right"
|
>
|
||||||
onClick={handleNext}
|
<div
|
||||||
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"
|
className="flex transition-transform duration-500 ease-out"
|
||||||
>
|
style={{
|
||||||
<ChevronRight className="h-4 w-4" />
|
transform: `translateX(calc(50% - ${cardWidth / 2}px + ${trackX}px))`,
|
||||||
</button>
|
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,24 +1,115 @@
|
|||||||
import { Clock, CreditCard, Globe, Users } from "lucide-react";
|
"use client";
|
||||||
|
|
||||||
const trustItems = [
|
import { Clock, CreditCard, Globe, Users } from "lucide-react";
|
||||||
{ icon: Clock, label: "20+ Years" },
|
import type { LucideIcon } from "lucide-react";
|
||||||
{ icon: Globe, label: "Full English" },
|
import { cn } from "@/shared/utils";
|
||||||
{ icon: CreditCard, label: "Foreign Cards" },
|
import { useCountUp } from "@/shared/hooks";
|
||||||
{ icon: Users, label: "10,000+ Customers" },
|
import { useInView } from "@/features/landing-page/hooks";
|
||||||
|
|
||||||
|
const numberFormatter = new Intl.NumberFormat();
|
||||||
|
|
||||||
|
type StatItem =
|
||||||
|
| {
|
||||||
|
icon: LucideIcon;
|
||||||
|
kind: "animated";
|
||||||
|
value: number;
|
||||||
|
suffix: string;
|
||||||
|
label: string;
|
||||||
|
delay: number;
|
||||||
|
formatter?: (n: number) => string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
icon: LucideIcon;
|
||||||
|
kind: "static";
|
||||||
|
text: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats: readonly StatItem[] = [
|
||||||
|
{ icon: Clock, kind: "animated", value: 20, suffix: "+", label: "Years in Japan", delay: 0 },
|
||||||
|
{ icon: Globe, kind: "animated", value: 100, suffix: "%", label: "English Support", delay: 100 },
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
kind: "animated",
|
||||||
|
value: 10000,
|
||||||
|
suffix: "+",
|
||||||
|
label: "Customers Served",
|
||||||
|
delay: 200,
|
||||||
|
formatter: (n: number) => numberFormatter.format(n),
|
||||||
|
},
|
||||||
|
{ icon: CreditCard, kind: "static", text: "Foreign Cards", label: "Accepted" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function AnimatedValue({
|
||||||
|
value,
|
||||||
|
duration,
|
||||||
|
enabled,
|
||||||
|
delay,
|
||||||
|
suffix,
|
||||||
|
formatter,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
duration: number;
|
||||||
|
enabled: boolean;
|
||||||
|
delay: number;
|
||||||
|
suffix: string;
|
||||||
|
formatter?: (n: number) => string;
|
||||||
|
}) {
|
||||||
|
const count = useCountUp({ end: value, duration, enabled, delay });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="text-3xl sm:text-4xl font-extrabold text-primary tabular-nums">
|
||||||
|
{formatter ? formatter(count) : count}
|
||||||
|
{suffix}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TrustStrip() {
|
export function TrustStrip() {
|
||||||
|
const [ref, inView] = useInView();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label="Trust highlights"
|
ref={ref as React.RefObject<HTMLElement>}
|
||||||
className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-surface-sunken/50 border-y border-border/40"
|
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"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="mx-auto max-w-6xl px-6 sm:px-10 lg:px-14 py-5">
|
{/* Gradient background */}
|
||||||
<div className="grid grid-cols-2 gap-4 sm:flex sm:justify-between">
|
<div className="absolute inset-0 bg-gradient-to-r from-surface-sunken via-background to-info-bg/30" />
|
||||||
{trustItems.map(({ icon: Icon, label }) => (
|
|
||||||
<div key={label} className="flex items-center gap-2.5">
|
<div className="relative mx-auto max-w-6xl px-6 sm:px-10 lg:px-14">
|
||||||
<Icon className="h-5 w-5 text-primary shrink-0" />
|
<div className="grid grid-cols-2 gap-8 sm:flex sm:justify-between sm:items-center">
|
||||||
<span className="text-sm font-semibold text-foreground">{label}</span>
|
{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">
|
||||||
|
{stat.kind === "animated" ? (
|
||||||
|
<AnimatedValue
|
||||||
|
value={stat.value}
|
||||||
|
duration={1500}
|
||||||
|
enabled={inView}
|
||||||
|
delay={stat.delay}
|
||||||
|
suffix={stat.suffix}
|
||||||
|
{...(stat.formatter ? { formatter: stat.formatter } : {})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xl sm:text-2xl font-extrabold text-primary">
|
||||||
|
{stat.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-muted-foreground font-medium">{stat.label}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,72 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// TYPES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export interface FormData {
|
|
||||||
subject: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormErrors {
|
|
||||||
subject?: string;
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormTouched {
|
|
||||||
subject?: boolean;
|
|
||||||
name?: boolean;
|
|
||||||
email?: boolean;
|
|
||||||
phone?: boolean;
|
|
||||||
message?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// DATA
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export const CONTACT_SUBJECTS = [
|
|
||||||
{ value: "", label: "Select a topic*" },
|
|
||||||
{ value: "internet", label: "Internet Service Inquiry" },
|
|
||||||
{ value: "sim", label: "Phone/SIM Plan Inquiry" },
|
|
||||||
{ value: "vpn", label: "VPN Service Inquiry" },
|
|
||||||
{ value: "business", label: "Business Solutions" },
|
|
||||||
{ value: "support", label: "Technical Support" },
|
|
||||||
{ value: "billing", label: "Billing Question" },
|
|
||||||
{ value: "other", label: "Other" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// FORM VALIDATION
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export function validateEmail(email: string): boolean {
|
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateForm(data: FormData): FormErrors {
|
|
||||||
const errors: FormErrors = {};
|
|
||||||
|
|
||||||
if (!data.subject) {
|
|
||||||
errors.subject = "Please select a topic";
|
|
||||||
}
|
|
||||||
if (!data.name.trim()) {
|
|
||||||
errors.name = "Name is required";
|
|
||||||
}
|
|
||||||
if (!data.email.trim()) {
|
|
||||||
errors.email = "Email is required";
|
|
||||||
} else if (!validateEmail(data.email)) {
|
|
||||||
errors.email = "Please enter a valid email address";
|
|
||||||
}
|
|
||||||
if (!data.message.trim()) {
|
|
||||||
errors.message = "Message is required";
|
|
||||||
} else if (data.message.trim().length < 10) {
|
|
||||||
errors.message = "Message must be at least 10 characters";
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
@ -2,6 +2,7 @@ export {
|
|||||||
type ServiceCategory,
|
type ServiceCategory,
|
||||||
type ServiceItem,
|
type ServiceItem,
|
||||||
type ConversionServiceCard,
|
type ConversionServiceCard,
|
||||||
|
type CarouselAccent,
|
||||||
type LandingServiceItem,
|
type LandingServiceItem,
|
||||||
personalServices,
|
personalServices,
|
||||||
businessServices,
|
businessServices,
|
||||||
@ -12,12 +13,3 @@ export {
|
|||||||
mobileQuickServices,
|
mobileQuickServices,
|
||||||
landingServices,
|
landingServices,
|
||||||
} from "./services";
|
} from "./services";
|
||||||
|
|
||||||
export {
|
|
||||||
type FormData,
|
|
||||||
type FormErrors,
|
|
||||||
type FormTouched,
|
|
||||||
CONTACT_SUBJECTS,
|
|
||||||
validateEmail,
|
|
||||||
validateForm,
|
|
||||||
} from "./contact-subjects";
|
|
||||||
|
|||||||
@ -17,6 +17,16 @@ import type { ServiceCardAccentColor } from "@/components/molecules";
|
|||||||
|
|
||||||
export type ServiceCategory = "personal" | "business";
|
export type ServiceCategory = "personal" | "business";
|
||||||
|
|
||||||
|
export type CarouselAccent =
|
||||||
|
| "blue"
|
||||||
|
| "emerald"
|
||||||
|
| "violet"
|
||||||
|
| "amber"
|
||||||
|
| "indigo"
|
||||||
|
| "cyan"
|
||||||
|
| "rose"
|
||||||
|
| "slate";
|
||||||
|
|
||||||
export interface ServiceItem {
|
export interface ServiceItem {
|
||||||
title: string;
|
title: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
@ -27,6 +37,8 @@ export interface ConversionServiceCard {
|
|||||||
title: string;
|
title: string;
|
||||||
problemHook: string;
|
problemHook: string;
|
||||||
keyBenefit: string;
|
keyBenefit: string;
|
||||||
|
description: string;
|
||||||
|
accent: CarouselAccent;
|
||||||
badge?: string;
|
badge?: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
href: string;
|
href: string;
|
||||||
@ -98,6 +110,9 @@ export const personalConversionCards: ConversionServiceCard[] = [
|
|||||||
title: "Internet Plans",
|
title: "Internet Plans",
|
||||||
problemHook: "Need reliable internet?",
|
problemHook: "Need reliable internet?",
|
||||||
keyBenefit: "NTT Fiber up to 10Gbps",
|
keyBenefit: "NTT Fiber up to 10Gbps",
|
||||||
|
description:
|
||||||
|
"High-speed NTT fiber with full English installation support. No Japanese paperwork — we handle everything for you.",
|
||||||
|
accent: "blue",
|
||||||
icon: <Wifi className="h-7 w-7" />,
|
icon: <Wifi className="h-7 w-7" />,
|
||||||
href: "/services/internet",
|
href: "/services/internet",
|
||||||
ctaLabel: "View Plans",
|
ctaLabel: "View Plans",
|
||||||
@ -106,6 +121,9 @@ export const personalConversionCards: ConversionServiceCard[] = [
|
|||||||
title: "Phone Plans",
|
title: "Phone Plans",
|
||||||
problemHook: "Need a SIM card?",
|
problemHook: "Need a SIM card?",
|
||||||
keyBenefit: "Docomo network coverage",
|
keyBenefit: "Docomo network coverage",
|
||||||
|
description:
|
||||||
|
"SIM cards on Japan's best network. Foreign credit cards accepted, no hanko required. Get connected in days.",
|
||||||
|
accent: "emerald",
|
||||||
badge: "1st month free",
|
badge: "1st month free",
|
||||||
icon: <Smartphone className="h-7 w-7" />,
|
icon: <Smartphone className="h-7 w-7" />,
|
||||||
href: "/services/sim",
|
href: "/services/sim",
|
||||||
@ -115,6 +133,9 @@ export const personalConversionCards: ConversionServiceCard[] = [
|
|||||||
title: "VPN Service",
|
title: "VPN Service",
|
||||||
problemHook: "Missing shows from home?",
|
problemHook: "Missing shows from home?",
|
||||||
keyBenefit: "Stream US & UK content",
|
keyBenefit: "Stream US & UK content",
|
||||||
|
description:
|
||||||
|
"Stream your favorite shows from home. Pre-configured router — just plug in and watch US & UK content.",
|
||||||
|
accent: "violet",
|
||||||
icon: <Lock className="h-7 w-7" />,
|
icon: <Lock className="h-7 w-7" />,
|
||||||
href: "/services/vpn",
|
href: "/services/vpn",
|
||||||
ctaLabel: "View Plans",
|
ctaLabel: "View Plans",
|
||||||
@ -123,6 +144,9 @@ export const personalConversionCards: ConversionServiceCard[] = [
|
|||||||
title: "Onsite Support",
|
title: "Onsite Support",
|
||||||
problemHook: "Need hands-on help?",
|
problemHook: "Need hands-on help?",
|
||||||
keyBenefit: "English-speaking technicians",
|
keyBenefit: "English-speaking technicians",
|
||||||
|
description:
|
||||||
|
"English-speaking technicians at your door. Router setup, network troubleshooting, and device configuration.",
|
||||||
|
accent: "amber",
|
||||||
icon: <Wrench className="h-7 w-7" />,
|
icon: <Wrench className="h-7 w-7" />,
|
||||||
href: "/services/onsite",
|
href: "/services/onsite",
|
||||||
ctaLabel: "Learn More",
|
ctaLabel: "Learn More",
|
||||||
@ -134,6 +158,9 @@ export const businessConversionCards: ConversionServiceCard[] = [
|
|||||||
title: "Office LAN Setup",
|
title: "Office LAN Setup",
|
||||||
problemHook: "Setting up an office?",
|
problemHook: "Setting up an office?",
|
||||||
keyBenefit: "Complete network infrastructure",
|
keyBenefit: "Complete network infrastructure",
|
||||||
|
description:
|
||||||
|
"Complete network infrastructure for your Japan office. Professional installation with ongoing bilingual support.",
|
||||||
|
accent: "slate",
|
||||||
icon: <Server className="h-7 w-7" />,
|
icon: <Server className="h-7 w-7" />,
|
||||||
href: "/services/business",
|
href: "/services/business",
|
||||||
ctaLabel: "Get a Quote",
|
ctaLabel: "Get a Quote",
|
||||||
@ -142,6 +169,9 @@ export const businessConversionCards: ConversionServiceCard[] = [
|
|||||||
title: "Tech Support",
|
title: "Tech Support",
|
||||||
problemHook: "Need ongoing IT help?",
|
problemHook: "Need ongoing IT help?",
|
||||||
keyBenefit: "Onsite & remote support",
|
keyBenefit: "Onsite & remote support",
|
||||||
|
description:
|
||||||
|
"Dedicated English-speaking IT team for your business. Onsite and remote support whenever you need it.",
|
||||||
|
accent: "amber",
|
||||||
icon: <Wrench className="h-7 w-7" />,
|
icon: <Wrench className="h-7 w-7" />,
|
||||||
href: "/services/onsite",
|
href: "/services/onsite",
|
||||||
ctaLabel: "Get a Quote",
|
ctaLabel: "Get a Quote",
|
||||||
@ -150,6 +180,9 @@ export const businessConversionCards: ConversionServiceCard[] = [
|
|||||||
title: "Dedicated Internet",
|
title: "Dedicated Internet",
|
||||||
problemHook: "Need guaranteed bandwidth?",
|
problemHook: "Need guaranteed bandwidth?",
|
||||||
keyBenefit: "Enterprise-grade connectivity",
|
keyBenefit: "Enterprise-grade connectivity",
|
||||||
|
description:
|
||||||
|
"Enterprise-grade connectivity with guaranteed bandwidth and SLA. Built for businesses that can't afford downtime.",
|
||||||
|
accent: "indigo",
|
||||||
icon: <Building2 className="h-7 w-7" />,
|
icon: <Building2 className="h-7 w-7" />,
|
||||||
href: "/services/business",
|
href: "/services/business",
|
||||||
ctaLabel: "Get a Quote",
|
ctaLabel: "Get a Quote",
|
||||||
@ -158,6 +191,9 @@ export const businessConversionCards: ConversionServiceCard[] = [
|
|||||||
title: "Data Center",
|
title: "Data Center",
|
||||||
problemHook: "Need hosting in Japan?",
|
problemHook: "Need hosting in Japan?",
|
||||||
keyBenefit: "Secure, reliable infrastructure",
|
keyBenefit: "Secure, reliable infrastructure",
|
||||||
|
description:
|
||||||
|
"Secure, reliable hosting infrastructure in Japan. Colocation and managed services for your critical systems.",
|
||||||
|
accent: "cyan",
|
||||||
icon: <Shield className="h-7 w-7" />,
|
icon: <Shield className="h-7 w-7" />,
|
||||||
href: "/services/business",
|
href: "/services/business",
|
||||||
ctaLabel: "Get a Quote",
|
ctaLabel: "Get a Quote",
|
||||||
@ -166,6 +202,9 @@ export const businessConversionCards: ConversionServiceCard[] = [
|
|||||||
title: "Website Services",
|
title: "Website Services",
|
||||||
problemHook: "Need a web presence?",
|
problemHook: "Need a web presence?",
|
||||||
keyBenefit: "Construction & maintenance",
|
keyBenefit: "Construction & maintenance",
|
||||||
|
description:
|
||||||
|
"Professional website construction and ongoing maintenance. Bilingual design that connects with your audience.",
|
||||||
|
accent: "rose",
|
||||||
icon: <Code className="h-7 w-7" />,
|
icon: <Code className="h-7 w-7" />,
|
||||||
href: "/services/business",
|
href: "/services/business",
|
||||||
ctaLabel: "Get a Quote",
|
ctaLabel: "Get a Quote",
|
||||||
|
|||||||
@ -1,3 +1,2 @@
|
|||||||
export { useInView } from "./useInView";
|
export { useInView } from "./useInView";
|
||||||
export { useContactForm } from "./useContactForm";
|
|
||||||
export { useStickyCta } from "./useStickyCta";
|
export { useStickyCta } from "./useStickyCta";
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
|
|
||||||
import type { FormData, FormErrors, FormTouched } from "@/features/landing-page/data";
|
|
||||||
import { validateForm } from "@/features/landing-page/data";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useContactForm - Manages contact form state, validation, and submission.
|
|
||||||
*
|
|
||||||
* Encapsulates:
|
|
||||||
* - Form field values (formData)
|
|
||||||
* - Validation errors (formErrors)
|
|
||||||
* - Touch tracking for blur-based validation (formTouched)
|
|
||||||
* - Submission state (isSubmitting, submitStatus)
|
|
||||||
* - Input change, blur, and submit handlers
|
|
||||||
*
|
|
||||||
* Note: Sticky CTA visibility has been extracted to `useStickyCta`.
|
|
||||||
*/
|
|
||||||
export function useContactForm() {
|
|
||||||
// Form state
|
|
||||||
const [formData, setFormData] = useState<FormData>({
|
|
||||||
subject: "",
|
|
||||||
name: "",
|
|
||||||
email: "",
|
|
||||||
phone: "",
|
|
||||||
message: "",
|
|
||||||
});
|
|
||||||
const [formErrors, setFormErrors] = useState<FormErrors>({});
|
|
||||||
const [formTouched, setFormTouched] = useState<FormTouched>({});
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const handleInputChange = useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
|
||||||
|
|
||||||
// Clear error when user starts typing
|
|
||||||
if (formErrors[name as keyof FormErrors]) {
|
|
||||||
setFormErrors(prev => ({ ...prev, [name]: undefined }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[formErrors]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleInputBlur = useCallback(
|
|
||||||
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
|
||||||
const { name } = e.target;
|
|
||||||
setFormTouched(prev => ({ ...prev, [name]: true }));
|
|
||||||
|
|
||||||
// Validate single field on blur
|
|
||||||
const errors = validateForm(formData);
|
|
||||||
if (errors[name as keyof FormErrors]) {
|
|
||||||
setFormErrors(prev => ({ ...prev, [name]: errors[name as keyof FormErrors] }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[formData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Validate all fields
|
|
||||||
const errors = validateForm(formData);
|
|
||||||
setFormErrors(errors);
|
|
||||||
setFormTouched({ subject: true, name: true, email: true, message: true });
|
|
||||||
|
|
||||||
if (Object.keys(errors).length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setSubmitStatus("idle");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Simulate API call - replace with actual endpoint
|
|
||||||
await new Promise(resolve => {
|
|
||||||
setTimeout(resolve, 1500);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Success
|
|
||||||
setSubmitStatus("success");
|
|
||||||
setFormData({ subject: "", name: "", email: "", phone: "", message: "" });
|
|
||||||
setFormTouched({});
|
|
||||||
setFormErrors({});
|
|
||||||
|
|
||||||
// Reset success message after 5 seconds
|
|
||||||
setTimeout(() => setSubmitStatus("idle"), 5000);
|
|
||||||
} catch {
|
|
||||||
setSubmitStatus("error");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[formData]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
formData,
|
|
||||||
formErrors,
|
|
||||||
formTouched,
|
|
||||||
isSubmitting,
|
|
||||||
submitStatus,
|
|
||||||
handleInputChange,
|
|
||||||
handleInputBlur,
|
|
||||||
handleSubmit,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -35,7 +35,9 @@ export function HowItWorks({
|
|||||||
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
||||||
{eyebrow}
|
{eyebrow}
|
||||||
</p>
|
</p>
|
||||||
<h2 className="text-section-heading text-foreground">{title}</h2>
|
<h2 className="text-2xl sm:text-3xl font-display font-semibold leading-tight tracking-tight text-foreground">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Steps Container */}
|
{/* Steps Container */}
|
||||||
|
|||||||
@ -64,7 +64,9 @@ export function ServiceCTA({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<h2 className="text-section-heading text-foreground mb-3">{headline}</h2>
|
<h2 className="text-2xl sm:text-3xl font-display font-semibold leading-tight tracking-tight text-foreground mb-3">
|
||||||
|
{headline}
|
||||||
|
</h2>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-base text-muted-foreground mb-6 max-w-md mx-auto">{description}</p>
|
<p className="text-base text-muted-foreground mb-6 max-w-md mx-auto">{description}</p>
|
||||||
|
|||||||
@ -84,7 +84,9 @@ export function ServiceFAQ({
|
|||||||
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
||||||
{eyebrow}
|
{eyebrow}
|
||||||
</p>
|
</p>
|
||||||
<h2 className="text-section-heading text-foreground">{title}</h2>
|
<h2 className="text-2xl sm:text-3xl font-display font-semibold leading-tight tracking-tight text-foreground">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAQ Container */}
|
{/* FAQ Container */}
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export function ServicesHero({
|
|||||||
) : null}
|
) : null}
|
||||||
<h1
|
<h1
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-display-xl text-foreground leading-display font-extrabold",
|
"text-3xl sm:text-4xl lg:text-5xl text-foreground leading-tight font-extrabold",
|
||||||
displayFont && "font-display",
|
displayFont && "font-display",
|
||||||
animationClasses
|
animationClasses
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -151,7 +151,7 @@ export function VpnPlansContent({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
className="text-display-md md:text-display-lg font-display font-bold leading-display text-foreground animate-in fade-in slide-in-from-bottom-6 duration-700"
|
className="text-2xl md:text-4xl font-display font-bold leading-tight text-foreground animate-in fade-in slide-in-from-bottom-6 duration-700"
|
||||||
style={{ animationDelay: "100ms" }}
|
style={{ animationDelay: "100ms" }}
|
||||||
>
|
>
|
||||||
Stream Content from Abroad
|
Stream Content from Abroad
|
||||||
@ -202,7 +202,9 @@ export function VpnPlansContent({
|
|||||||
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
||||||
Choose Your Region
|
Choose Your Region
|
||||||
</p>
|
</p>
|
||||||
<h2 className="text-section-heading text-foreground">Available Plans</h2>
|
<h2 className="text-2xl sm:text-3xl font-display font-semibold leading-tight tracking-tight text-foreground">
|
||||||
|
Available Plans
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
Select one region per router rental
|
Select one region per router rental
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -31,7 +31,6 @@ export function ContactForm({ className }: ContactFormProps) {
|
|||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
subject: "",
|
|
||||||
message: "",
|
message: "",
|
||||||
},
|
},
|
||||||
onSubmit: handleSubmit,
|
onSubmit: handleSubmit,
|
||||||
@ -68,62 +67,44 @@ export function ContactForm({ className }: ContactFormProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
|
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<FormField
|
||||||
<FormField
|
label="Name"
|
||||||
label="Name"
|
error={form.touched["name"] ? form.errors["name"] : undefined}
|
||||||
error={form.touched["name"] ? form.errors["name"] : undefined}
|
required
|
||||||
required
|
>
|
||||||
>
|
<Input
|
||||||
<Input
|
value={form.values.name}
|
||||||
value={form.values.name}
|
onChange={e => form.setValue("name", e.target.value)}
|
||||||
onChange={e => form.setValue("name", e.target.value)}
|
onBlur={() => form.setTouchedField("name")}
|
||||||
onBlur={() => form.setTouchedField("name")}
|
placeholder="Your name"
|
||||||
placeholder="Your name"
|
className="bg-muted/20"
|
||||||
className="bg-muted/20"
|
/>
|
||||||
/>
|
</FormField>
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Email"
|
label="Email"
|
||||||
error={form.touched["email"] ? form.errors["email"] : undefined}
|
error={form.touched["email"] ? form.errors["email"] : undefined}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={form.values.email}
|
value={form.values.email}
|
||||||
onChange={e => form.setValue("email", e.target.value)}
|
onChange={e => form.setValue("email", e.target.value)}
|
||||||
onBlur={() => form.setTouchedField("email")}
|
onBlur={() => form.setTouchedField("email")}
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
className="bg-muted/20"
|
className="bg-muted/20"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<FormField label="Phone" error={form.touched["phone"] ? form.errors["phone"] : undefined}>
|
||||||
<FormField label="Phone" error={form.touched["phone"] ? form.errors["phone"] : undefined}>
|
<Input
|
||||||
<Input
|
value={form.values.phone ?? ""}
|
||||||
value={form.values.phone ?? ""}
|
onChange={e => form.setValue("phone", e.target.value)}
|
||||||
onChange={e => form.setValue("phone", e.target.value)}
|
onBlur={() => form.setTouchedField("phone")}
|
||||||
onBlur={() => form.setTouchedField("phone")}
|
placeholder="+81 90-1234-5678"
|
||||||
placeholder="+81 90-1234-5678"
|
className="bg-muted/20"
|
||||||
className="bg-muted/20"
|
/>
|
||||||
/>
|
</FormField>
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Subject"
|
|
||||||
error={form.touched["subject"] ? form.errors["subject"] : undefined}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={form.values.subject}
|
|
||||||
onChange={e => form.setValue("subject", e.target.value)}
|
|
||||||
onBlur={() => form.setTouchedField("subject")}
|
|
||||||
placeholder="How can we help?"
|
|
||||||
className="bg-muted/20"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Message"
|
label="Message"
|
||||||
|
|||||||
@ -31,12 +31,19 @@ export function useCountUp({
|
|||||||
const startTimeRef = useRef<number | undefined>(undefined);
|
const startTimeRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If disabled or reduced motion preferred, show final value immediately
|
if (!enabled) {
|
||||||
if (!enabled || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
setCount(start);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect prefers-reduced-motion — show final value immediately
|
||||||
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||||
setCount(end);
|
setCount(end);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startTimeRef.current = undefined;
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
const animate = (timestamp: number) => {
|
const animate = (timestamp: number) => {
|
||||||
if (!startTimeRef.current) {
|
if (!startTimeRef.current) {
|
||||||
@ -46,9 +53,9 @@ export function useCountUp({
|
|||||||
const progress = Math.min((timestamp - startTimeRef.current) / duration, 1);
|
const progress = Math.min((timestamp - startTimeRef.current) / duration, 1);
|
||||||
// Ease-out cubic for smooth deceleration
|
// Ease-out cubic for smooth deceleration
|
||||||
const eased = 1 - Math.pow(1 - progress, 3);
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
const current = Math.round(start + (end - start) * eased);
|
const next = Math.round(start + (end - start) * eased);
|
||||||
|
|
||||||
setCount(current);
|
setCount(prev => (prev === next ? prev : next));
|
||||||
|
|
||||||
if (progress < 1) {
|
if (progress < 1) {
|
||||||
frameRef.current = requestAnimationFrame(animate);
|
frameRef.current = requestAnimationFrame(animate);
|
||||||
|
|||||||
57
docs/plans/2026-03-04-trust-strip-redesign-design.md
Normal file
57
docs/plans/2026-03-04-trust-strip-redesign-design.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# TrustStrip Redesign — Bold & Impactful Stats Strip
|
||||||
|
|
||||||
|
**Goal:** Replace the minimal icon+text trust strip with a visually impactful stats section featuring large animated numbers, richer descriptors, and a polished gradient background.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, Tailwind CSS, lucide-react icons, existing `useInView` hook
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stats Data
|
||||||
|
|
||||||
|
| Number | Suffix | Descriptor | Icon | Animated |
|
||||||
|
| ------ | ------ | ---------------------- | ---------- | ----------------- |
|
||||||
|
| 20 | + | Years in Japan | Clock | Yes |
|
||||||
|
| 100 | % | English Support | Globe | Yes |
|
||||||
|
| 10,000 | + | Customers Served | Users | Yes |
|
||||||
|
| — | — | Foreign Cards Accepted | CreditCard | No (static label) |
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- Full-width strip between HeroSection and ServicesCarousel (same position as current)
|
||||||
|
- 4 stats centered in a row with vertical dividers between them on desktop
|
||||||
|
- Mobile: 2x2 grid, no dividers
|
||||||
|
- Each stat: icon in soft primary-tinted circle, large bold number, small muted descriptor below
|
||||||
|
|
||||||
|
## Visual Treatment
|
||||||
|
|
||||||
|
- **Background**: subtle gradient `from-surface-sunken via-background to-info-bg/30` — no hard borders, blends with hero
|
||||||
|
- **Vertical dividers**: thin `border-border/30` lines between stats (desktop only)
|
||||||
|
- **Numbers**: `text-3xl sm:text-4xl font-extrabold text-primary`
|
||||||
|
- **Descriptors**: `text-sm text-muted-foreground font-medium`
|
||||||
|
- **Icon circles**: `w-10 h-10 rounded-full bg-primary/10` with `text-primary` icon inside
|
||||||
|
- **4th stat** ("Foreign Cards Accepted"): displays as bold label text with same visual weight, no count-up
|
||||||
|
|
||||||
|
## Animation
|
||||||
|
|
||||||
|
- Existing `useInView` hook detects when strip enters viewport
|
||||||
|
- New `useCountUp(target, duration, enabled)` hook animates number from 0 → target over ~1.5s using `requestAnimationFrame`
|
||||||
|
- Staggered start: 0ms, 100ms, 200ms delays for the 3 animated stats
|
||||||
|
- Entire strip fades in (`opacity-0 translate-y-8` → `opacity-100 translate-y-0`) matching other sections
|
||||||
|
|
||||||
|
## Component Structure
|
||||||
|
|
||||||
|
- **TrustStrip.tsx** — `"use client"`, contains stat data array + layout, uses `useInView` and `useCountUp`
|
||||||
|
- **useCountUp.ts** — new hook: `useCountUp(target: number, duration?: number, enabled?: boolean)` returns current animated value
|
||||||
|
- Exported from existing barrel files (`hooks/index.ts`, `components/index.ts`)
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
1. **Modify**: `apps/portal/src/features/landing-page/components/TrustStrip.tsx` — complete rewrite
|
||||||
|
2. **Create**: `apps/portal/src/features/landing-page/hooks/useCountUp.ts` — new hook
|
||||||
|
3. **Modify**: `apps/portal/src/features/landing-page/hooks/index.ts` — add `useCountUp` export
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- `aria-label="Company statistics"` on the section
|
||||||
|
- Numbers use semantic text (not images)
|
||||||
|
- Respects `prefers-reduced-motion`: skip count-up animation, show final values immediately
|
||||||
@ -100,7 +100,6 @@ export const publicContactRequestSchema = z.object({
|
|||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
email: z.string().email("Valid email required"),
|
email: z.string().email("Valid email required"),
|
||||||
phone: z.string().optional(),
|
phone: z.string().optional(),
|
||||||
subject: z.string().min(1, "Subject is required"),
|
|
||||||
message: z.string().min(10, "Message must be at least 10 characters"),
|
message: z.string().min(10, "Message must be at least 10 characters"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user