diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e9075eac --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,118 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Customer Portal with BFF (Backend for Frontend) architecture. A pnpm monorepo with a Next.js 15 frontend, NestJS 11 backend, and shared domain layer. + +## Common Commands + +```bash +# Development +pnpm dev:start # Start PostgreSQL + Redis services +pnpm dev # Start both apps with hot reload +pnpm dev:all # Build domain + start both apps + +# Building +pnpm build # Build all (domain first, then apps) +pnpm domain:build # Build domain layer only + +# Quality checks +pnpm lint # Run ESLint across all packages +pnpm type-check # TypeScript type checking +pnpm format:check # Check Prettier formatting + +# Database (Prisma) +pnpm db:migrate # Run migrations +pnpm db:generate # Generate Prisma client +pnpm db:studio # Open Prisma Studio +pnpm db:reset # Reset database + +# Testing +pnpm test # Run tests across packages +pnpm --filter @customer-portal/bff test # BFF tests (node --test) + +# Single package commands +pnpm --filter @customer-portal/portal dev # Portal only +pnpm --filter @customer-portal/bff dev # BFF only +``` + +## Architecture + +### Monorepo Structure + +``` +apps/ + portal/ # Next.js 15 frontend (React 19, Tailwind CSS 4, shadcn/ui) + bff/ # NestJS 11 backend (Prisma, PostgreSQL, Redis) +packages/ + domain/ # Shared types, Zod schemas, mappers (framework-agnostic) +``` + +### System Boundaries + +- **Domain (`packages/domain/`)**: Shared contracts, Zod validation, cross-app utilities. Must be framework-agnostic. +- **BFF (`apps/bff/`)**: NestJS HTTP boundary, orchestration, integrations (Salesforce/WHMCS/Freebit). +- **Portal (`apps/portal/`)**: Next.js UI. Pages are thin wrappers over feature modules in `src/features/`. + +### External Systems + +- **WHMCS**: Billing, subscriptions, invoices, authoritative address storage +- **Salesforce**: CRM (Accounts, Contacts, Cases), order management +- **Freebit**: SIM management + +### Data Flow + +Portal → BFF → External Systems (WHMCS/Salesforce/Freebit) + +User mapping: `user_id ↔ whmcs_client_id ↔ sf_contact_id/sf_account_id` + +## Import Rules (ESLint Enforced) + +```ts +// ✅ Correct (Portal + BFF) +import type { Invoice } from "@customer-portal/domain/billing"; +import { invoiceSchema } from "@customer-portal/domain/billing"; + +// ✅ Correct (BFF only) +import { Whmcs } from "@customer-portal/domain/billing/providers"; + +// ❌ Forbidden +import { Billing } from "@customer-portal/domain"; // no root imports +import { Invoice } from "@customer-portal/domain/billing/contract"; // no deep imports +``` + +Portal must never import `providers` from domain. + +## Key Patterns + +### Validation (Zod-first) + +- Schemas live in `packages/domain//schema.ts` +- Types are derived: `export type X = z.infer` +- Query params use `z.coerce.*` for URL strings + +### BFF Controllers + +- Controllers are thin: no business logic, no Zod imports +- Use `createZodDto(schema)` + global `ZodValidationPipe` +- Integrations transform data via domain mappers and return domain types + +### Portal Features + +- Pages under `apps/portal/src/app/**` are wrappers (no API calls) +- Feature modules at `apps/portal/src/features//` own: + - `hooks/` (React Query) + - `services/` (API calls) + - `components/` and `views/` (UI) + +## Code Standards + +- No `any` in public APIs +- No `console.log` in production (use logger) +- TypeScript strict mode enabled +- Always read docs before guessing endpoint behavior: + - `docs/development/` for BFF/Portal/Domain patterns + - `docs/architecture/` for system boundaries + - `docs/integrations/` for external API details diff --git a/apps/bff/sim-api-test-log.csv b/apps/bff/sim-api-test-log.csv index f59e6de4..0eed255a 100644 --- a/apps/bff/sim-api-test-log.csv +++ b/apps/bff/sim-api-test-log.csv @@ -1,3 +1,8 @@ Timestamp,API Endpoint,API Method,Phone Number,SIM Identifier,Request Payload,Response Status,Error,Additional Info 2026-01-19T04:05:41.856Z,/mvno/getTrafficInfo/,POST,02000524104652,02000524104652,"{""account"":""02000524104652""}",Success,,OK 2026-01-19T04:05:41.945Z,/mvno/getTrafficInfo/,POST,02000524104652,02000524104652,"{""account"":""02000524104652""}",Success,,OK +2026-01-20T08:29:56.809Z,/mvno/getTrafficInfo/,POST,02000524104652,02000524104652,"{""account"":""02000524104652""}",Success,,OK +2026-01-20T08:29:56.956Z,/mvno/getTrafficInfo/,POST,02000524104652,02000524104652,"{""account"":""02000524104652""}",Success,,OK +2026-01-29T05:54:55.030Z,/mvno/getTrafficInfo/,POST,07000240050,07000240050,"{""account"":""07000240050""}",Error: 210,API Error: NG,API Error: NG +2026-01-29T05:54:59.051Z,/mvno/getTrafficInfo/,POST,07000240050,07000240050,"{""account"":""07000240050""}",Error: 210,API Error: NG,API Error: NG +2026-01-29T05:55:03.587Z,/mvno/getTrafficInfo/,POST,07000240050,07000240050,"{""account"":""07000240050""}",Error: 210,API Error: NG,API Error: NG diff --git a/apps/portal/src/app/globals.css b/apps/portal/src/app/globals.css index 5c838804..0466760f 100644 --- a/apps/portal/src/app/globals.css +++ b/apps/portal/src/app/globals.css @@ -217,3 +217,38 @@ font-family: var(--font-geist-sans, system-ui), system-ui, sans-serif; } } + +/* ============================================================================= + ANIMATIONS + ============================================================================= */ + +/* Floating blob animation for hero background */ +@keyframes blob-float { + 0%, + 100% { + transform: translate(0, 0) scale(1); + } + 33% { + transform: translate(10px, -10px) scale(1.02); + } + 66% { + transform: translate(-5px, 5px) scale(0.98); + } +} + +.animate-blob-float { + animation: blob-float 20s ease-in-out infinite; +} + +/* Animation delay utilities for staggered effects */ +.animation-delay-2000 { + animation-delay: 2s; +} + +.animation-delay-4000 { + animation-delay: 4s; +} + +.animation-delay-6000 { + animation-delay: 6s; +} diff --git a/apps/portal/src/components/templates/PublicShell/PublicShell.tsx b/apps/portal/src/components/templates/PublicShell/PublicShell.tsx index 35bb8ab8..c5de3dc6 100644 --- a/apps/portal/src/components/templates/PublicShell/PublicShell.tsx +++ b/apps/portal/src/components/templates/PublicShell/PublicShell.tsx @@ -7,12 +7,23 @@ "use client"; import type { ReactNode } from "react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { Logo } from "@/components/atoms/logo"; import { SiteFooter } from "@/components/organisms/SiteFooter"; import { useAuthStore } from "@/features/auth/stores/auth.store"; -import { Wifi, Smartphone, Building2, Lock, Wrench, ChevronDown, ArrowRight } from "lucide-react"; +import { + Wifi, + Smartphone, + Building2, + Lock, + Wrench, + ChevronDown, + ArrowRight, + Menu, + X, + Globe, +} from "lucide-react"; export interface PublicShellProps { children: ReactNode; @@ -23,6 +34,9 @@ export function PublicShell({ children }: PublicShellProps) { const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth); const checkAuth = useAuthStore(state => state.checkAuth); const [servicesOpen, setServicesOpen] = useState(false); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [isTouchDevice, setIsTouchDevice] = useState(false); + const servicesDropdownRef = useRef(null); useEffect(() => { if (!hasCheckedAuth) { @@ -30,8 +44,115 @@ export function PublicShell({ children }: PublicShellProps) { } }, [checkAuth, hasCheckedAuth]); + // Detect touch device + useEffect(() => { + setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0); + }, []); + + // Close dropdown when clicking outside + useEffect(() => { + if (!servicesOpen) return; + + function handleClickOutside(event: MouseEvent) { + if ( + servicesDropdownRef.current && + !servicesDropdownRef.current.contains(event.target as Node) + ) { + setServicesOpen(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [servicesOpen]); + + // Close mobile menu when route changes or on escape key + useEffect(() => { + function handleEscape(event: KeyboardEvent) { + if (event.key === "Escape") { + setMobileMenuOpen(false); + setServicesOpen(false); + } + } + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, []); + + // Prevent body scroll when mobile menu is open + useEffect(() => { + if (mobileMenuOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [mobileMenuOpen]); + + const handleServicesClick = useCallback( + (e: React.MouseEvent) => { + if (isTouchDevice) { + e.preventDefault(); + setServicesOpen(prev => !prev); + } + }, + [isTouchDevice] + ); + + const closeMobileMenu = useCallback(() => { + setMobileMenuOpen(false); + setServicesOpen(false); + }, []); + + const serviceItems = [ + { + href: "/services/internet", + label: "Internet Plans", + desc: "NTT Fiber up to 10Gbps", + icon: , + color: "bg-sky-50 text-sky-600", + }, + { + href: "/services/sim", + label: "Phone Plans", + desc: "Docomo network SIM cards", + icon: , + color: "bg-emerald-50 text-emerald-600", + }, + { + href: "/services/business", + label: "Business Solutions", + desc: "Enterprise IT services", + icon: , + color: "bg-violet-50 text-violet-600", + }, + { + href: "/services/vpn", + label: "VPN Service", + desc: "US & UK server access", + icon: , + color: "bg-amber-50 text-amber-600", + }, + { + href: "/services/onsite", + label: "Onsite Support", + desc: "Tech help at your location", + icon: , + color: "bg-slate-100 text-slate-600", + }, + ]; + return (
+ {/* Skip to main content link for accessibility */} + + Skip to main content + + {/* Subtle background pattern - clean and minimal */}
@@ -48,17 +169,20 @@ export function PublicShell({ children }: PublicShellProps) { -
+ +
+ {/* Language Selector - Desktop */} +
+ + EN +
+ + {/* Auth Button - Desktop */} + {isAuthenticated ? ( + + My Account + + ) : ( + + Sign in + + )} + + {/* Mobile Menu Button */} + +
-
-
+ {/* Mobile Menu Overlay - Rendered outside header to avoid stacking context issues */} + {mobileMenuOpen && ( +
+ +
+ )} + +
+
{children}
diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index b7ce6b68..4d9b95ae 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -20,7 +20,48 @@ import { Shield, Code, Settings, + CheckCircle, + AlertCircle, } from "lucide-react"; +import { Spinner } from "@/components/atoms/Spinner"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; + +// ============================================================================= +// CUSTOM HOOKS +// ============================================================================= + +/** + * useInView - Intersection Observer hook for scroll-triggered animations + * Returns a ref and boolean indicating if element is in viewport + */ +function useInView(options: IntersectionObserverInit = {}) { + const ref = useRef(null); + const [isInView, setIsInView] = useState(false); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsInView(true); + observer.disconnect(); // triggerOnce + } + }, + { threshold: 0.1, ...options } + ); + + observer.observe(element); + return () => observer.disconnect(); + }, [options]); + + return [ref, isInView] as const; +} + +// ============================================================================= +// TYPES & DATA +// ============================================================================= type ServiceCategory = "personal" | "business"; @@ -30,6 +71,32 @@ interface ServiceItem { href: string; } +interface FormData { + subject: string; + name: string; + email: string; + phone: string; + message: string; +} + +interface FormErrors { + subject?: string; + name?: string; + email?: string; + message?: string; +} + +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" }, +]; + const personalServices: ServiceItem[] = [ { title: "Internet Plan", @@ -82,6 +149,101 @@ const businessServices: ServiceItem[] = [ }, ]; +const services = [ + { + title: "Internet Plans", + description: + "High-speed NTT fiber with English installation support. No Japanese paperwork, we handle everything for you.", + icon: , + href: "/services/internet", + }, + { + title: "Phone Plans", + description: + "SIM cards on Japan's best network. Foreign credit cards accepted, no hanko required. Get connected in days.", + icon: , + href: "/services/sim", + }, + { + title: "Business Solutions", + description: + "Enterprise IT for international companies. Dedicated internet, office networks, and bilingual tech support.", + icon: , + href: "/services/business", + }, + { + title: "VPN", + description: + "Stream your favorite shows from home. Pre-configured router, just plug in and watch US/UK content.", + icon: , + href: "/services/vpn", + }, + { + title: "Onsite Support", + description: + "English-speaking technicians at your home or office. Router setup, network issues, and device help.", + icon: , + href: "/services/onsite", + }, +]; + +const supportDownloads = [ + { + title: "Acronis Quick Assist", + href: "https://www.acronis.com/en/products/cloud/quick-assist/download/", + image: "/assets/images/arconis.png", + description: + "Secure remote desktop tool for quick troubleshooting. Our technicians can view your screen and resolve issues in real-time.", + useCase: "Best for: General tech support & diagnostics", + }, + { + title: "TeamViewer QS", + href: "https://get.teamviewer.com/tokyo", + image: "/assets/images/teamviewer.png", + description: + "Industry-standard remote access software. Allows our team to securely connect to your device for hands-on assistance.", + useCase: "Best for: Complex configurations & file transfers", + }, +]; + +// Mobile quick services (top 3 most popular) +const mobileQuickServices = personalServices.slice(0, 3); + +// ============================================================================= +// FORM VALIDATION +// ============================================================================= + +function validateEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +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; +} + +// ============================================================================= +// MAIN COMPONENT +// ============================================================================= + /** * PublicLandingView - Marketing-focused landing page * @@ -92,51 +254,47 @@ const businessServices: ServiceItem[] = [ * - Trust & Support sections */ export function PublicLandingView() { + // Carousel state const carouselRef = useRef(null); const itemWidthRef = useRef(0); - const [activeCategory, setActiveCategory] = useState("personal"); - - const services = [ - { - title: "Internet Plans", - description: - "High-speed NTT fiber with English installation support. No Japanese paperwork, we handle everything for you.", - icon: , - href: "/services/internet", - }, - { - title: "Phone Plans", - description: - "SIM cards on Japan's best network. Foreign credit cards accepted, no hanko required. Get connected in days.", - icon: , - href: "/services/sim", - }, - { - title: "Business Solutions", - description: - "Enterprise IT for international companies. Dedicated internet, office networks, and bilingual tech support.", - icon: , - href: "/services/business", - }, - { - title: "VPN", - description: - "Stream your favorite shows from home. Pre-configured router, just plug in and watch US/UK content.", - icon: , - href: "/services/vpn", - }, - { - title: "Onsite Support", - description: - "English-speaking technicians at your home or office. Router setup, network issues, and device help.", - icon: , - href: "/services/onsite", - }, - ]; + const [currentSlide, setCurrentSlide] = useState(0); const isScrollingRef = useRef(false); const autoScrollTimerRef = useRef(null); + const touchStartXRef = useRef(0); + const touchEndXRef = useRef(0); + + // Section state + const [activeCategory, setActiveCategory] = useState("personal"); + const [remoteSupportTab, setRemoteSupportTab] = useState(0); + + // 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"); + + // Intersection Observer refs for animations + const [heroRef, heroInView] = useInView(); + const [trustRef, trustInView] = useInView(); + const [solutionsRef, solutionsInView] = useInView(); + const [supportRef, supportInView] = useInView(); + const [contactRef, contactInView] = useInView(); + + // Hero CTA visibility for sticky mobile CTA + const heroCTARef = useRef(null); + const [showStickyCTA, setShowStickyCTA] = useState(false); + + // ============================================================================= + // CAROUSEL LOGIC + // ============================================================================= - // Compute item width for scrolling const computeItemWidth = useCallback(() => { const container = carouselRef.current; if (!container) return; @@ -152,7 +310,6 @@ export function PublicLandingView() { itemWidthRef.current = card.getBoundingClientRect().width + gap; }, []); - // Scroll by one card width const scrollByOne = useCallback((direction: 1 | -1) => { const container = carouselRef.current; if (!container || !itemWidthRef.current || isScrollingRef.current) return; @@ -164,13 +321,30 @@ export function PublicLandingView() { behavior: "smooth", }); - // Reset scrolling flag after animation completes setTimeout(() => { isScrollingRef.current = false; }, 500); }, []); - // Start auto-scroll timer + const scrollToIndex = useCallback((index: number) => { + const container = carouselRef.current; + if (!container || !itemWidthRef.current) return; + + container.scrollTo({ + left: index * itemWidthRef.current, + behavior: "smooth", + }); + }, []); + + const updateCurrentSlide = useCallback(() => { + const container = carouselRef.current; + if (!container || !itemWidthRef.current) return; + + const scrollLeft = container.scrollLeft; + const newIndex = Math.round(scrollLeft / itemWidthRef.current); + setCurrentSlide(Math.max(0, Math.min(newIndex, services.length - 1))); + }, []); + const startAutoScroll = useCallback(() => { if (autoScrollTimerRef.current) { clearInterval(autoScrollTimerRef.current); @@ -181,7 +355,6 @@ export function PublicLandingView() { }, 5000); }, [scrollByOne]); - // Stop auto-scroll timer const stopAutoScroll = useCallback(() => { if (autoScrollTimerRef.current) { clearInterval(autoScrollTimerRef.current); @@ -189,24 +362,101 @@ export function PublicLandingView() { } }, []); - const supportDownloads = [ - { - title: "Acronis Quick Assist", - href: "https://www.acronis.com/en/products/cloud/quick-assist/download/", - image: "/assets/images/arconis.png", - description: - "Secure remote desktop tool for quick troubleshooting. Our technicians can view your screen and resolve issues in real-time.", - useCase: "Best for: General tech support & diagnostics", + // Touch swipe handlers + const handleTouchStart = useCallback((e: React.TouchEvent) => { + touchStartXRef.current = e.touches[0].clientX; + touchEndXRef.current = e.touches[0].clientX; + }, []); + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + touchEndXRef.current = e.touches[0].clientX; + }, []); + + const handleTouchEnd = useCallback(() => { + const diff = touchStartXRef.current - touchEndXRef.current; + const minSwipeDistance = 50; + + if (Math.abs(diff) > minSwipeDistance) { + if (diff > 0) { + scrollByOne(1); // Swipe left = next + } else { + scrollByOne(-1); // Swipe right = prev + } + } + startAutoScroll(); + }, [scrollByOne, startAutoScroll]); + + // ============================================================================= + // FORM 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 })); + } }, - { - title: "TeamViewer QS", - href: "https://get.teamviewer.com/tokyo", - image: "/assets/images/teamviewer.png", - description: - "Industry-standard remote access software. Allows our team to securely connect to your device for hands-on assistance.", - useCase: "Best for: Complex configurations & file transfers", + [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] + ); + + // ============================================================================= + // EFFECTS + // ============================================================================= // Initialize carousel useEffect(() => { @@ -215,7 +465,6 @@ export function PublicLandingView() { const handleResize = () => computeItemWidth(); window.addEventListener("resize", handleResize); - // Start auto-scroll startAutoScroll(); return () => { @@ -224,6 +473,35 @@ export function PublicLandingView() { }; }, [computeItemWidth, startAutoScroll, stopAutoScroll]); + // Track carousel scroll position + useEffect(() => { + const container = carouselRef.current; + if (!container) return; + + const handleScroll = () => { + updateCurrentSlide(); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + return () => container.removeEventListener("scroll", handleScroll); + }, [updateCurrentSlide]); + + // Sticky CTA visibility + useEffect(() => { + const ctaElement = heroCTARef.current; + if (!ctaElement) return; + + const observer = new IntersectionObserver( + ([entry]) => { + setShowStickyCTA(!entry.isIntersecting); + }, + { threshold: 0 } + ); + + observer.observe(ctaElement); + return () => observer.disconnect(); + }, []); + // Pause auto-scroll on hover const handleMouseEnter = useCallback(() => { stopAutoScroll(); @@ -233,33 +511,50 @@ export function PublicLandingView() { startAutoScroll(); }, [startAutoScroll]); - // Manual scroll handlers const handlePrevClick = useCallback(() => { scrollByOne(-1); - // Restart auto-scroll timer startAutoScroll(); }, [scrollByOne, startAutoScroll]); const handleNextClick = useCallback(() => { scrollByOne(1); - // Restart auto-scroll timer startAutoScroll(); }, [scrollByOne, startAutoScroll]); + // ============================================================================= + // RENDER HELPERS + // ============================================================================= + + const getInputClassName = (fieldName: keyof FormErrors) => { + const baseClass = + "w-full rounded-md border px-4 py-3 text-sm focus-visible:outline-none focus-visible:ring-2 bg-white transition-colors"; + const hasError = formTouched[fieldName] && formErrors[fieldName]; + return `${baseClass} ${ + hasError + ? "border-danger focus-visible:ring-danger/60" + : "border-border focus-visible:ring-primary/60" + }`; + }; + + // ============================================================================= + // RENDER + // ============================================================================= + return (
{/* Hero Section */} -
- {/* Abstract gradient blobs */} +
} + className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-gradient-to-br from-slate-50 via-white to-sky-50/50 py-12 sm:py-16 overflow-hidden transition-all duration-700 ${ + heroInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" + }`} + > + {/* Animated gradient blobs */}
- {/* Top-left blob */} -
- {/* Top-right blob */} -
- {/* Bottom-left blob */} -
- {/* Center-right accent */} -
+
+
+
+
@@ -272,7 +567,10 @@ export function PublicLandingView() { No Japanese required. Get reliable internet, mobile, and VPN services with full English support. Serving expats and international businesses for over 20 years.

-
+
-
+ {/* Mobile Quick Services - visible on mobile only */} +
+
+ {mobileQuickServices.map(service => ( + +
+
+
{service.icon}
+
+ + {service.title} + +
+ + ))} + +
+ + + See All Services + +
+ +
+
+ + {/* Desktop Services Panel - hidden on mobile */} +
{/* Tab Switcher */}
@@ -317,7 +649,7 @@ export function PublicLandingView() {
- {/* Services Grid - Fixed height for consistent sizing */} + {/* Services Grid */}
{(activeCategory === "personal" ? personalServices : businessServices).map( service => ( @@ -360,13 +692,19 @@ export function PublicLandingView() {
{/* Trust and Excellence Section */} -
+
} + className={`max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 py-14 sm:py-16 transition-all duration-700 ${ + trustInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" + }`} + >
Team collaborating with trust and excellence @@ -406,8 +744,13 @@ export function PublicLandingView() {
{/* Solutions Carousel */} -
-
+
} + className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-[#e8f5ff] py-12 sm:py-14 transition-all duration-700 ${ + solutionsInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" + }`} + > +

Solutions

@@ -416,6 +759,9 @@ export function PublicLandingView() { ref={carouselRef} className="flex gap-6 overflow-x-auto scroll-smooth pb-6 pr-16 snap-x snap-mandatory" style={{ scrollbarWidth: "none", WebkitOverflowScrolling: "touch" }} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} > {services.map((service, index) => ( ))}
+ + {/* Navigation buttons with larger touch targets */}
+ + {/* Progress dots */} +
+ {services.map((_, index) => ( +
+
+ + + {/* Remote Support - Full section with mobile-optimized card layout */} +
} + className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 py-12 sm:py-16 transition-all duration-700 overflow-hidden ${ + supportInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" + }`} + > + {/* Subtle gradient background with connection pattern */} +
+
+ +
+ {/* Section header with connection metaphor */} +
+
+

+ Remote Support +

+

+ Download one of our secure tools and let our technicians help you remotely +

+
+
+ + {/* Mobile: Stacked Cards Layout */} +
+ {supportDownloads.map(item => ( +
+ {/* Card accent line */} +
+ +
+
+ {/* Tool icon with animated ring */} +
+
+ {item.title} +
+ {/* Pulse indicator */} + +
+ + {/* Content */} +
+

{item.title}

+

+ {item.useCase.replace("Best for: ", "")} +

+

+ {item.description} +

+
+
+ + {/* Download button - full width on mobile for easy tapping */} + + + + + Download {item.title.split(" ")[0]} + +
+
+ ))} +
+ + {/* Desktop: Tabbed Layout (improved) */} +
+
+ {/* Tab buttons */} +
+ {supportDownloads.map((item, index) => ( + + ))} +
+ + {/* Tab content */} +
+
+
+ {supportDownloads[remoteSupportTab].title} +
+
+

+ {supportDownloads[remoteSupportTab].useCase} +

+

+ {supportDownloads[remoteSupportTab].title} +

+

+ {supportDownloads[remoteSupportTab].description} +

+ + + + + Download Now + + +
+
+
+
+
- {/* Support Downloads */} -
-
-

- Remote Support -

-

- Stuck with a tech issue? Our English-speaking technicians can connect to your device - remotely and fix problems in real-time. Download one of these secure tools to get - started. -

-
- -
- {supportDownloads.map(item => ( -
-
- {item.title} -
-

{item.title}

-

{item.useCase}

-

- {item.description} -

- - Download - -
- ))} -
- -
-

- After downloading, run the application and share the session ID with our support team. -

-

- Contact us at{" "} - - info@asolutions.co.jp - {" "} - or call{" "} - - 0120-660-470 - -

-
-
- {/* Contact Section */} -
+
} + className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-[#e8f5ff] py-14 sm:py-16 transition-all duration-700 ${ + contactInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" + }`} + >

CONTACT US

@@ -528,42 +1027,163 @@ export function PublicLandingView() { By Online Form (Anytime)
-
- - - - -