From a1431cec09a33d5dce204aa6e5b0a3e75152bdb8 Mon Sep 17 00:00:00 2001 From: barsa Date: Wed, 4 Mar 2026 16:16:14 +0900 Subject: [PATCH] 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. --- .../src/modules/support/support.service.ts | 2 +- .../landing-page/components/HeroSection.tsx | 7 +- .../components/ServicesCarousel.tsx | 442 ++++++++++++------ .../landing-page/components/TrustStrip.tsx | 119 ++++- .../landing-page/data/contact-subjects.ts | 72 --- .../src/features/landing-page/data/index.ts | 10 +- .../features/landing-page/data/services.tsx | 39 ++ .../src/features/landing-page/hooks/index.ts | 1 - .../landing-page/hooks/useContactForm.ts | 111 ----- .../services/components/base/HowItWorks.tsx | 4 +- .../services/components/base/ServiceCTA.tsx | 4 +- .../services/components/base/ServiceFAQ.tsx | 4 +- .../services/components/base/ServicesHero.tsx | 2 +- .../components/vpn/VpnPlansContent.tsx | 6 +- .../support/components/ContactForm.tsx | 91 ++-- apps/portal/src/shared/hooks/useCountUp.ts | 15 +- .../2026-03-04-trust-strip-redesign-design.md | 57 +++ packages/domain/support/schema.ts | 1 - 18 files changed, 570 insertions(+), 417 deletions(-) delete mode 100644 apps/portal/src/features/landing-page/data/contact-subjects.ts delete mode 100644 apps/portal/src/features/landing-page/hooks/useContactForm.ts create mode 100644 docs/plans/2026-03-04-trust-strip-redesign-design.md diff --git a/apps/bff/src/modules/support/support.service.ts b/apps/bff/src/modules/support/support.service.ts index 81b307a7..a7bee755 100644 --- a/apps/bff/src/modules/support/support.service.ts +++ b/apps/bff/src/modules/support/support.service.ts @@ -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, diff --git a/apps/portal/src/features/landing-page/components/HeroSection.tsx b/apps/portal/src/features/landing-page/components/HeroSection.tsx index 4a9be768..e7cdfed7 100644 --- a/apps/portal/src/features/landing-page/components/HeroSection.tsx +++ b/apps/portal/src/features/landing-page/components/HeroSection.tsx @@ -44,11 +44,12 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {

- Just Moved to Japan? - Get Connected in English + A One Stop Solution + for Your IT Needs

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

([ + ["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 ( - -
- {/* Icon + Badge row */} -
-
{card.icon}
+ { + 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), + }} + > +
+ {/* Icon + Badge */} +
+
+
{card.icon}
+
{card.badge && ( - + {card.badge} )}
-

{card.problemHook}

-

{card.title}

-

{card.keyBenefit}

- + + {/* Content */} +

{card.problemHook}

+

+ {card.title} +

+

+ {card.description} +

+ + {/* CTA Button */} + {card.ctaLabel} -
+
); -} +}); + +/* ─── Main Carousel ─── */ + +const AUTO_INTERVAL = 5000; +const GAP = 24; export function ServicesCarousel() { const [activeTab, setActiveTab] = useState("personal"); - const carouselRef = useRef(null); - const itemWidthRef = useRef(0); - const isScrollingRef = useRef(false); - const autoScrollTimerRef = useRef | null>(null); + const [activeIndex, setActiveIndex] = useState(0); + const [cardWidth, setCardWidth] = useState(520); + const autoRef = useRef | 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("[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 (
} 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" )} > -
- {/* Header + Tabs */} -
+ {/* Header + Tabs */} +
+
-

Our Services

-

+

Our Services

+

Everything you need to stay connected in Japan

- - -
-
- - {/* Carousel */} -
-
- {cards.map(card => ( - + {(["personal", "business"] as const).map(tab => ( + ))}
+
+
- {/* Navigation buttons */} -
- - + {/* Spotlight Carousel Track */} +
+
+ {cards.map((card, i) => { + const offset = Math.abs(i - activeIndex); + return ( +
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); + }} + > + +
+ ); + })} +
+
+ + {/* Navigation: Dots + Arrows */} +
+
+ +
+ {cards.map((card, i) => { + const a = getAccent(card.accent); + return ( +
+
diff --git a/apps/portal/src/features/landing-page/components/TrustStrip.tsx b/apps/portal/src/features/landing-page/components/TrustStrip.tsx index 4faebf3f..609c664e 100644 --- a/apps/portal/src/features/landing-page/components/TrustStrip.tsx +++ b/apps/portal/src/features/landing-page/components/TrustStrip.tsx @@ -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 ( + + {formatter ? formatter(count) : count} + {suffix} + + ); +} + export function TrustStrip() { + const [ref, inView] = useInView(); + return (
} + 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" + )} > -
-
- {trustItems.map(({ icon: Icon, label }) => ( -
- - {label} + {/* Gradient background */} +
+ +
+
+ {stats.map((stat, i) => ( +
+
+ +
+
+ {stat.kind === "animated" ? ( + + ) : ( + + {stat.text} + + )} + {stat.label} +
))}
diff --git a/apps/portal/src/features/landing-page/data/contact-subjects.ts b/apps/portal/src/features/landing-page/data/contact-subjects.ts deleted file mode 100644 index 3ab74dcb..00000000 --- a/apps/portal/src/features/landing-page/data/contact-subjects.ts +++ /dev/null @@ -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; -} diff --git a/apps/portal/src/features/landing-page/data/index.ts b/apps/portal/src/features/landing-page/data/index.ts index c70298a3..d0a51b05 100644 --- a/apps/portal/src/features/landing-page/data/index.ts +++ b/apps/portal/src/features/landing-page/data/index.ts @@ -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"; diff --git a/apps/portal/src/features/landing-page/data/services.tsx b/apps/portal/src/features/landing-page/data/services.tsx index a81ab478..96bfcbc6 100644 --- a/apps/portal/src/features/landing-page/data/services.tsx +++ b/apps/portal/src/features/landing-page/data/services.tsx @@ -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: , 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: , 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: , 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: , 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: , 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: , 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: , 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: , 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: , href: "/services/business", ctaLabel: "Get a Quote", diff --git a/apps/portal/src/features/landing-page/hooks/index.ts b/apps/portal/src/features/landing-page/hooks/index.ts index 7ac6519e..85e5d8ee 100644 --- a/apps/portal/src/features/landing-page/hooks/index.ts +++ b/apps/portal/src/features/landing-page/hooks/index.ts @@ -1,3 +1,2 @@ export { useInView } from "./useInView"; -export { useContactForm } from "./useContactForm"; export { useStickyCta } from "./useStickyCta"; diff --git a/apps/portal/src/features/landing-page/hooks/useContactForm.ts b/apps/portal/src/features/landing-page/hooks/useContactForm.ts deleted file mode 100644 index 265e482c..00000000 --- a/apps/portal/src/features/landing-page/hooks/useContactForm.ts +++ /dev/null @@ -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({ - subject: "", - name: "", - email: "", - phone: "", - message: "", - }); - const [formErrors, setFormErrors] = useState({}); - const [formTouched, setFormTouched] = useState({}); - const [isSubmitting, setIsSubmitting] = useState(false); - const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle"); - - // --------------------------------------------------------------------------- - // Handlers - // --------------------------------------------------------------------------- - const handleInputChange = useCallback( - (e: React.ChangeEvent) => { - 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) => { - 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) => { - 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, - }; -} diff --git a/apps/portal/src/features/services/components/base/HowItWorks.tsx b/apps/portal/src/features/services/components/base/HowItWorks.tsx index 834392f6..de72cb88 100644 --- a/apps/portal/src/features/services/components/base/HowItWorks.tsx +++ b/apps/portal/src/features/services/components/base/HowItWorks.tsx @@ -35,7 +35,9 @@ export function HowItWorks({

{eyebrow}

-

{title}

+

+ {title} +

{/* Steps Container */} diff --git a/apps/portal/src/features/services/components/base/ServiceCTA.tsx b/apps/portal/src/features/services/components/base/ServiceCTA.tsx index f63f923d..c2558553 100644 --- a/apps/portal/src/features/services/components/base/ServiceCTA.tsx +++ b/apps/portal/src/features/services/components/base/ServiceCTA.tsx @@ -64,7 +64,9 @@ export function ServiceCTA({
{/* Headline */} -

{headline}

+

+ {headline} +

{/* Description */}

{description}

diff --git a/apps/portal/src/features/services/components/base/ServiceFAQ.tsx b/apps/portal/src/features/services/components/base/ServiceFAQ.tsx index ff103270..4d24eab2 100644 --- a/apps/portal/src/features/services/components/base/ServiceFAQ.tsx +++ b/apps/portal/src/features/services/components/base/ServiceFAQ.tsx @@ -84,7 +84,9 @@ export function ServiceFAQ({

{eyebrow}

-

{title}

+

+ {title} +

{/* FAQ Container */} diff --git a/apps/portal/src/features/services/components/base/ServicesHero.tsx b/apps/portal/src/features/services/components/base/ServicesHero.tsx index 2754e641..8e6b0911 100644 --- a/apps/portal/src/features/services/components/base/ServicesHero.tsx +++ b/apps/portal/src/features/services/components/base/ServicesHero.tsx @@ -57,7 +57,7 @@ export function ServicesHero({ ) : null}

Stream Content from Abroad @@ -202,7 +202,9 @@ export function VpnPlansContent({

Choose Your Region

-

Available Plans

+

+ Available Plans +

Select one region per router rental

diff --git a/apps/portal/src/features/support/components/ContactForm.tsx b/apps/portal/src/features/support/components/ContactForm.tsx index e50ca9cb..23416c9e 100644 --- a/apps/portal/src/features/support/components/ContactForm.tsx +++ b/apps/portal/src/features/support/components/ContactForm.tsx @@ -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) { )}
void form.handleSubmit(event)} className="space-y-4"> -
- - form.setValue("name", e.target.value)} - onBlur={() => form.setTouchedField("name")} - placeholder="Your name" - className="bg-muted/20" - /> - + + form.setValue("name", e.target.value)} + onBlur={() => form.setTouchedField("name")} + placeholder="Your name" + className="bg-muted/20" + /> + - - form.setValue("email", e.target.value)} - onBlur={() => form.setTouchedField("email")} - placeholder="your@email.com" - className="bg-muted/20" - /> - -
+ + form.setValue("email", e.target.value)} + onBlur={() => form.setTouchedField("email")} + placeholder="your@email.com" + className="bg-muted/20" + /> + -
- - form.setValue("phone", e.target.value)} - onBlur={() => form.setTouchedField("phone")} - placeholder="+81 90-1234-5678" - className="bg-muted/20" - /> - - - - form.setValue("subject", e.target.value)} - onBlur={() => form.setTouchedField("subject")} - placeholder="How can we help?" - className="bg-muted/20" - /> - -
+ + form.setValue("phone", e.target.value)} + onBlur={() => form.setTouchedField("phone")} + placeholder="+81 90-1234-5678" + className="bg-muted/20" + /> + (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); diff --git a/docs/plans/2026-03-04-trust-strip-redesign-design.md b/docs/plans/2026-03-04-trust-strip-redesign-design.md new file mode 100644 index 00000000..dd9b9676 --- /dev/null +++ b/docs/plans/2026-03-04-trust-strip-redesign-design.md @@ -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 diff --git a/packages/domain/support/schema.ts b/packages/domain/support/schema.ts index e2be0081..13f6e705 100644 --- a/packages/domain/support/schema.ts +++ b/packages/domain/support/schema.ts @@ -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"), });