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 {
|
||||
// Create a case without account association (Web-to-Case style)
|
||||
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}`,
|
||||
suppliedEmail: request.email,
|
||||
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">
|
||||
<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 text-primary mt-2">Get Connected in English</span>
|
||||
<span className="block">A One Stop Solution</span>
|
||||
<span className="block text-primary mt-2">for Your IT Needs</span>
|
||||
</h1>
|
||||
<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>
|
||||
<div
|
||||
ref={heroCTARef}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
@ -9,190 +9,352 @@ import {
|
||||
personalConversionCards,
|
||||
businessConversionCards,
|
||||
type ConversionServiceCard,
|
||||
type CarouselAccent,
|
||||
} from "@/features/landing-page/data";
|
||||
|
||||
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 (
|
||||
<Link href={card.href} className="group flex-shrink-0 w-[260px] sm:w-[280px] snap-start">
|
||||
<article
|
||||
data-service-card
|
||||
className="h-full rounded-2xl bg-card border border-border/60 px-6 py-7 shadow-sm hover:shadow-md hover:border-primary/30 transition-all duration-300 group-hover:-translate-y-1 flex flex-col"
|
||||
>
|
||||
{/* Icon + Badge row */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-primary">{card.icon}</div>
|
||||
<Link
|
||||
href={card.href}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
aria-hidden={!isActive}
|
||||
onClick={e => {
|
||||
if (!isActive) e.preventDefault();
|
||||
}}
|
||||
className={cn(
|
||||
"block h-full rounded-3xl border overflow-hidden transition-shadow duration-500",
|
||||
isActive ? "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 && (
|
||||
<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}
|
||||
</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">
|
||||
|
||||
{/* 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}
|
||||
</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}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/* ─── Main Carousel ─── */
|
||||
|
||||
const AUTO_INTERVAL = 5000;
|
||||
const GAP = 24;
|
||||
|
||||
export function ServicesCarousel() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("personal");
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
const itemWidthRef = useRef(0);
|
||||
const isScrollingRef = useRef(false);
|
||||
const autoScrollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [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;
|
||||
|
||||
const computeItemWidth = useCallback(() => {
|
||||
const container = carouselRef.current;
|
||||
if (!container) return;
|
||||
const card = container.querySelector<HTMLElement>("[data-service-card]");
|
||||
if (!card) return;
|
||||
const style = getComputedStyle(container);
|
||||
const gap = Number.parseFloat(style.columnGap || style.gap || "0") || 24;
|
||||
itemWidthRef.current = card.getBoundingClientRect().width + gap;
|
||||
}, []);
|
||||
|
||||
const scrollByOne = useCallback((direction: 1 | -1) => {
|
||||
const container = carouselRef.current;
|
||||
if (!container || !itemWidthRef.current || isScrollingRef.current) return;
|
||||
isScrollingRef.current = true;
|
||||
container.scrollBy({
|
||||
left: direction * itemWidthRef.current,
|
||||
behavior: "smooth",
|
||||
});
|
||||
setTimeout(() => {
|
||||
isScrollingRef.current = false;
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const startAutoScroll = useCallback(() => {
|
||||
if (autoScrollTimerRef.current) clearInterval(autoScrollTimerRef.current);
|
||||
autoScrollTimerRef.current = setInterval(() => scrollByOne(1), 5000);
|
||||
}, [scrollByOne]);
|
||||
|
||||
const stopAutoScroll = useCallback(() => {
|
||||
if (autoScrollTimerRef.current) {
|
||||
clearInterval(autoScrollTimerRef.current);
|
||||
autoScrollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Responsive card width (rAF-throttled)
|
||||
useEffect(() => {
|
||||
computeItemWidth();
|
||||
window.addEventListener("resize", computeItemWidth);
|
||||
startAutoScroll();
|
||||
return () => {
|
||||
window.removeEventListener("resize", computeItemWidth);
|
||||
stopAutoScroll();
|
||||
const update = () => {
|
||||
const vw = window.innerWidth;
|
||||
if (vw < 640) setCardWidth(vw - 48);
|
||||
else if (vw < 1024) setCardWidth(440);
|
||||
else setCardWidth(520);
|
||||
};
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (carouselRef.current) {
|
||||
carouselRef.current.scrollTo({ left: 0, behavior: "smooth" });
|
||||
// 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;
|
||||
}
|
||||
computeItemWidth();
|
||||
}, [activeTab, computeItemWidth]);
|
||||
}, []);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
scrollByOne(-1);
|
||||
startAutoScroll();
|
||||
}, [scrollByOne, startAutoScroll]);
|
||||
useEffect(() => {
|
||||
startAuto();
|
||||
return stopAuto;
|
||||
}, [startAuto, stopAuto]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
scrollByOne(1);
|
||||
startAutoScroll();
|
||||
}, [scrollByOne, startAutoScroll]);
|
||||
// Reset on tab change
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [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 (
|
||||
<section
|
||||
ref={sectionRef as React.RefObject<HTMLElement>}
|
||||
className={cn(
|
||||
"relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-surface-sunken/30 py-14 sm:py-16 transition-all duration-700",
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto max-w-6xl px-6 sm:px-10 lg:px-14">
|
||||
{/* Header + Tabs */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||
{/* 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-2xl sm:text-3xl font-extrabold text-foreground">Our Services</h2>
|
||||
<p className="mt-1 text-base text-muted-foreground">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("personal")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-semibold rounded-full transition-all",
|
||||
activeTab === "personal"
|
||||
? "bg-foreground text-background shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
For You
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("business")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-semibold rounded-full transition-all",
|
||||
activeTab === "business"
|
||||
? "bg-foreground text-background shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
For Business
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carousel */}
|
||||
<div className="relative" onMouseEnter={stopAutoScroll} onMouseLeave={startAutoScroll}>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="flex gap-5 overflow-x-auto scroll-smooth pb-4 snap-x snap-mandatory"
|
||||
style={{
|
||||
scrollbarWidth: "none",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
}}
|
||||
>
|
||||
{cards.map(card => (
|
||||
<ServiceConversionCard key={card.title} card={card} />
|
||||
{(["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>
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll left"
|
||||
onClick={handlePrev}
|
||||
className="h-9 w-9 rounded-full border border-border bg-card text-foreground shadow-sm hover:bg-muted transition-colors flex items-center justify-center"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll right"
|
||||
onClick={handleNext}
|
||||
className="h-9 w-9 rounded-full border border-border bg-card text-foreground shadow-sm hover:bg-muted transition-colors flex items-center justify-center"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
{/* Spotlight Carousel Track */}
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
onMouseEnter={stopAuto}
|
||||
onMouseLeave={startAuto}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label="Services carousel"
|
||||
aria-roledescription="carousel"
|
||||
>
|
||||
<div
|
||||
className="flex transition-transform duration-500 ease-out"
|
||||
style={{
|
||||
transform: `translateX(calc(50% - ${cardWidth / 2}px + ${trackX}px))`,
|
||||
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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@ -1,24 +1,115 @@
|
||||
import { Clock, CreditCard, Globe, Users } from "lucide-react";
|
||||
"use client";
|
||||
|
||||
const trustItems = [
|
||||
{ icon: Clock, label: "20+ Years" },
|
||||
{ icon: Globe, label: "Full English" },
|
||||
{ icon: CreditCard, label: "Foreign Cards" },
|
||||
{ icon: Users, label: "10,000+ Customers" },
|
||||
import { Clock, CreditCard, Globe, Users } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { useCountUp } from "@/shared/hooks";
|
||||
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() {
|
||||
const [ref, inView] = useInView();
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Trust highlights"
|
||||
className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-surface-sunken/50 border-y border-border/40"
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto max-w-6xl px-6 sm:px-10 lg:px-14 py-5">
|
||||
<div className="grid grid-cols-2 gap-4 sm:flex sm:justify-between">
|
||||
{trustItems.map(({ icon: Icon, label }) => (
|
||||
<div key={label} className="flex items-center gap-2.5">
|
||||
<Icon className="h-5 w-5 text-primary shrink-0" />
|
||||
<span className="text-sm font-semibold text-foreground">{label}</span>
|
||||
{/* 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">
|
||||
{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>
|
||||
|
||||
@ -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 ServiceItem,
|
||||
type ConversionServiceCard,
|
||||
type CarouselAccent,
|
||||
type LandingServiceItem,
|
||||
personalServices,
|
||||
businessServices,
|
||||
@ -12,12 +13,3 @@ export {
|
||||
mobileQuickServices,
|
||||
landingServices,
|
||||
} 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 CarouselAccent =
|
||||
| "blue"
|
||||
| "emerald"
|
||||
| "violet"
|
||||
| "amber"
|
||||
| "indigo"
|
||||
| "cyan"
|
||||
| "rose"
|
||||
| "slate";
|
||||
|
||||
export interface ServiceItem {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
@ -27,6 +37,8 @@ export interface ConversionServiceCard {
|
||||
title: string;
|
||||
problemHook: string;
|
||||
keyBenefit: string;
|
||||
description: string;
|
||||
accent: CarouselAccent;
|
||||
badge?: string;
|
||||
icon: React.ReactNode;
|
||||
href: string;
|
||||
@ -98,6 +110,9 @@ export const personalConversionCards: ConversionServiceCard[] = [
|
||||
title: "Internet Plans",
|
||||
problemHook: "Need reliable internet?",
|
||||
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" />,
|
||||
href: "/services/internet",
|
||||
ctaLabel: "View Plans",
|
||||
@ -106,6 +121,9 @@ export const personalConversionCards: ConversionServiceCard[] = [
|
||||
title: "Phone Plans",
|
||||
problemHook: "Need a SIM card?",
|
||||
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",
|
||||
icon: <Smartphone className="h-7 w-7" />,
|
||||
href: "/services/sim",
|
||||
@ -115,6 +133,9 @@ export const personalConversionCards: ConversionServiceCard[] = [
|
||||
title: "VPN Service",
|
||||
problemHook: "Missing shows from home?",
|
||||
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" />,
|
||||
href: "/services/vpn",
|
||||
ctaLabel: "View Plans",
|
||||
@ -123,6 +144,9 @@ export const personalConversionCards: ConversionServiceCard[] = [
|
||||
title: "Onsite Support",
|
||||
problemHook: "Need hands-on help?",
|
||||
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" />,
|
||||
href: "/services/onsite",
|
||||
ctaLabel: "Learn More",
|
||||
@ -134,6 +158,9 @@ export const businessConversionCards: ConversionServiceCard[] = [
|
||||
title: "Office LAN Setup",
|
||||
problemHook: "Setting up an office?",
|
||||
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" />,
|
||||
href: "/services/business",
|
||||
ctaLabel: "Get a Quote",
|
||||
@ -142,6 +169,9 @@ export const businessConversionCards: ConversionServiceCard[] = [
|
||||
title: "Tech Support",
|
||||
problemHook: "Need ongoing IT help?",
|
||||
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" />,
|
||||
href: "/services/onsite",
|
||||
ctaLabel: "Get a Quote",
|
||||
@ -150,6 +180,9 @@ export const businessConversionCards: ConversionServiceCard[] = [
|
||||
title: "Dedicated Internet",
|
||||
problemHook: "Need guaranteed bandwidth?",
|
||||
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" />,
|
||||
href: "/services/business",
|
||||
ctaLabel: "Get a Quote",
|
||||
@ -158,6 +191,9 @@ export const businessConversionCards: ConversionServiceCard[] = [
|
||||
title: "Data Center",
|
||||
problemHook: "Need hosting in Japan?",
|
||||
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" />,
|
||||
href: "/services/business",
|
||||
ctaLabel: "Get a Quote",
|
||||
@ -166,6 +202,9 @@ export const businessConversionCards: ConversionServiceCard[] = [
|
||||
title: "Website Services",
|
||||
problemHook: "Need a web presence?",
|
||||
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" />,
|
||||
href: "/services/business",
|
||||
ctaLabel: "Get a Quote",
|
||||
|
||||
@ -1,3 +1,2 @@
|
||||
export { useInView } from "./useInView";
|
||||
export { useContactForm } from "./useContactForm";
|
||||
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">
|
||||
{eyebrow}
|
||||
</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>
|
||||
|
||||
{/* Steps Container */}
|
||||
|
||||
@ -64,7 +64,9 @@ export function ServiceCTA({
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<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">
|
||||
{eyebrow}
|
||||
</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>
|
||||
|
||||
{/* FAQ Container */}
|
||||
|
||||
@ -57,7 +57,7 @@ export function ServicesHero({
|
||||
) : null}
|
||||
<h1
|
||||
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",
|
||||
animationClasses
|
||||
)}
|
||||
|
||||
@ -151,7 +151,7 @@ export function VpnPlansContent({
|
||||
</span>
|
||||
</div>
|
||||
<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" }}
|
||||
>
|
||||
Stream Content from Abroad
|
||||
@ -202,7 +202,9 @@ export function VpnPlansContent({
|
||||
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
||||
Choose Your Region
|
||||
</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">
|
||||
Select one region per router rental
|
||||
</p>
|
||||
|
||||
@ -31,7 +31,6 @@ export function ContactForm({ className }: ContactFormProps) {
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
subject: "",
|
||||
message: "",
|
||||
},
|
||||
onSubmit: handleSubmit,
|
||||
@ -68,62 +67,44 @@ export function ContactForm({ className }: ContactFormProps) {
|
||||
)}
|
||||
|
||||
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="Name"
|
||||
error={form.touched["name"] ? form.errors["name"] : undefined}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={form.values.name}
|
||||
onChange={e => form.setValue("name", e.target.value)}
|
||||
onBlur={() => form.setTouchedField("name")}
|
||||
placeholder="Your name"
|
||||
className="bg-muted/20"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Name"
|
||||
error={form.touched["name"] ? form.errors["name"] : undefined}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={form.values.name}
|
||||
onChange={e => form.setValue("name", e.target.value)}
|
||||
onBlur={() => form.setTouchedField("name")}
|
||||
placeholder="Your name"
|
||||
className="bg-muted/20"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Email"
|
||||
error={form.touched["email"] ? form.errors["email"] : undefined}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.values.email}
|
||||
onChange={e => form.setValue("email", e.target.value)}
|
||||
onBlur={() => form.setTouchedField("email")}
|
||||
placeholder="your@email.com"
|
||||
className="bg-muted/20"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
label="Email"
|
||||
error={form.touched["email"] ? form.errors["email"] : undefined}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.values.email}
|
||||
onChange={e => form.setValue("email", e.target.value)}
|
||||
onBlur={() => form.setTouchedField("email")}
|
||||
placeholder="your@email.com"
|
||||
className="bg-muted/20"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField label="Phone" error={form.touched["phone"] ? form.errors["phone"] : undefined}>
|
||||
<Input
|
||||
value={form.values.phone ?? ""}
|
||||
onChange={e => form.setValue("phone", e.target.value)}
|
||||
onBlur={() => form.setTouchedField("phone")}
|
||||
placeholder="+81 90-1234-5678"
|
||||
className="bg-muted/20"
|
||||
/>
|
||||
</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 label="Phone" error={form.touched["phone"] ? form.errors["phone"] : undefined}>
|
||||
<Input
|
||||
value={form.values.phone ?? ""}
|
||||
onChange={e => form.setValue("phone", e.target.value)}
|
||||
onBlur={() => form.setTouchedField("phone")}
|
||||
placeholder="+81 90-1234-5678"
|
||||
className="bg-muted/20"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Message"
|
||||
|
||||
@ -31,12 +31,19 @@ export function useCountUp({
|
||||
const startTimeRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
// If disabled or reduced motion preferred, show final value immediately
|
||||
if (!enabled || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
if (!enabled) {
|
||||
setCount(start);
|
||||
return;
|
||||
}
|
||||
|
||||
// Respect prefers-reduced-motion — show final value immediately
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setCount(end);
|
||||
return;
|
||||
}
|
||||
|
||||
startTimeRef.current = undefined;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const animate = (timestamp: number) => {
|
||||
if (!startTimeRef.current) {
|
||||
@ -46,9 +53,9 @@ export function useCountUp({
|
||||
const progress = Math.min((timestamp - startTimeRef.current) / duration, 1);
|
||||
// Ease-out cubic for smooth deceleration
|
||||
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) {
|
||||
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"),
|
||||
email: z.string().email("Valid email required"),
|
||||
phone: z.string().optional(),
|
||||
subject: z.string().min(1, "Subject is required"),
|
||||
message: z.string().min(10, "Message must be at least 10 characters"),
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user