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:
barsa 2026-03-04 16:16:14 +09:00
parent 408f99ae3c
commit a1431cec09
18 changed files with 570 additions and 417 deletions

View File

@ -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,

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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;
}

View File

@ -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";

View File

@ -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",

View File

@ -1,3 +1,2 @@
export { useInView } from "./useInView"; export { useInView } from "./useInView";
export { useContactForm } from "./useContactForm";
export { useStickyCta } from "./useStickyCta"; export { useStickyCta } from "./useStickyCta";

View File

@ -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,
};
}

View File

@ -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 */}

View File

@ -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>

View File

@ -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 */}

View File

@ -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
)} )}

View File

@ -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>

View File

@ -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"

View File

@ -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);

View 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

View File

@ -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"),
}); });