Enhance Homepage UX with Mobile Navigation and Form Improvements

- Add mobile hamburger menu with full navigation
- Fix touch support for Services dropdown
- Add form validation and accessibility labels to contact form
- Add carousel position indicators and swipe support
- Add scroll-triggered animations with useInView hook
- Add sticky mobile CTA bar
- Improve Remote Support section mobile layout
- Add animated background blobs
- Add language selector (EN indicator)
- Optimize hero image with priority flag

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
tema 2026-01-29 17:42:19 +09:00
parent ff870c9f4f
commit c9d568d22f
8 changed files with 1261 additions and 307 deletions

118
CLAUDE.md Normal file
View File

@ -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/<module>/schema.ts`
- Types are derived: `export type X = z.infer<typeof xSchema>`
- 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/<feature>/` 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

View File

@ -1,3 +1,8 @@
Timestamp,API Endpoint,API Method,Phone Number,SIM Identifier,Request Payload,Response Status,Error,Additional Info 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.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-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

1 Timestamp API Endpoint API Method Phone Number SIM Identifier Request Payload Response Status Error Additional Info
2 2026-01-19T04:05:41.856Z /mvno/getTrafficInfo/ POST 02000524104652 02000524104652 {"account":"02000524104652"} Success OK
3 2026-01-19T04:05:41.945Z /mvno/getTrafficInfo/ POST 02000524104652 02000524104652 {"account":"02000524104652"} Success OK
4 2026-01-20T08:29:56.809Z /mvno/getTrafficInfo/ POST 02000524104652 02000524104652 {"account":"02000524104652"} Success OK
5 2026-01-20T08:29:56.956Z /mvno/getTrafficInfo/ POST 02000524104652 02000524104652 {"account":"02000524104652"} Success OK
6 2026-01-29T05:54:55.030Z /mvno/getTrafficInfo/ POST 07000240050 07000240050 {"account":"07000240050"} Error: 210 API Error: NG API Error: NG
7 2026-01-29T05:54:59.051Z /mvno/getTrafficInfo/ POST 07000240050 07000240050 {"account":"07000240050"} Error: 210 API Error: NG API Error: NG
8 2026-01-29T05:55:03.587Z /mvno/getTrafficInfo/ POST 07000240050 07000240050 {"account":"07000240050"} Error: 210 API Error: NG API Error: NG

View File

@ -217,3 +217,38 @@
font-family: var(--font-geist-sans, system-ui), system-ui, sans-serif; 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;
}

View File

@ -7,12 +7,23 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Logo } from "@/components/atoms/logo"; import { Logo } from "@/components/atoms/logo";
import { SiteFooter } from "@/components/organisms/SiteFooter"; import { SiteFooter } from "@/components/organisms/SiteFooter";
import { useAuthStore } from "@/features/auth/stores/auth.store"; 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 { export interface PublicShellProps {
children: ReactNode; children: ReactNode;
@ -23,6 +34,9 @@ export function PublicShell({ children }: PublicShellProps) {
const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth); const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth);
const checkAuth = useAuthStore(state => state.checkAuth); const checkAuth = useAuthStore(state => state.checkAuth);
const [servicesOpen, setServicesOpen] = useState(false); const [servicesOpen, setServicesOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isTouchDevice, setIsTouchDevice] = useState(false);
const servicesDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!hasCheckedAuth) { if (!hasCheckedAuth) {
@ -30,57 +44,68 @@ export function PublicShell({ children }: PublicShellProps) {
} }
}, [checkAuth, hasCheckedAuth]); }, [checkAuth, hasCheckedAuth]);
return ( // Detect touch device
<div className="min-h-screen flex flex-col bg-background text-foreground"> useEffect(() => {
{/* Subtle background pattern - clean and minimal */} setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0);
<div className="fixed inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/5 via-background to-background" /> }, []);
<header className="sticky top-0 z-40 border-b border-border/40 bg-background/95 backdrop-blur-md supports-[backdrop-filter]:bg-background/80"> // Close dropdown when clicking outside
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] h-16 grid grid-cols-[auto_1fr_auto] items-center gap-4"> useEffect(() => {
<Link href="/" className="inline-flex items-center gap-2.5 min-w-0 group"> if (!servicesOpen) return;
<span className="inline-flex items-center justify-center h-9 w-9 rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary/15">
<Logo size={20} />
</span>
<span className="min-w-0 hidden sm:block">
<span className="block text-base font-bold leading-none tracking-tight text-foreground">
Assist Solution
</span>
</span>
</Link>
<nav className="flex items-center justify-center gap-1 sm:gap-3 text-sm font-semibold text-muted-foreground"> function handleClickOutside(event: MouseEvent) {
<div if (
className="relative" servicesDropdownRef.current &&
onMouseEnter={() => setServicesOpen(true)} !servicesDropdownRef.current.contains(event.target as Node)
onMouseLeave={() => setServicesOpen(false)} ) {
onFocus={() => setServicesOpen(true)} setServicesOpen(false);
onBlur={() => setServicesOpen(false)} }
> }
<Link
href="/services"
className="inline-flex items-center gap-1 px-3 py-2 rounded-md hover:text-foreground transition-colors"
>
Services
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${servicesOpen ? "rotate-180" : ""}`}
/>
</Link>
{servicesOpen && (
<div className="absolute left-1/2 -translate-x-1/2 top-full pt-2 z-50">
{/* Arrow pointer */}
<div className="absolute left-1/2 -translate-x-1/2 -top-0 w-3 h-3 rotate-45 bg-white border-l border-t border-border/50" />
<div className="w-[420px] rounded-2xl border border-border/50 bg-white shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200"> document.addEventListener("mousedown", handleClickOutside);
{/* Header */} return () => document.removeEventListener("mousedown", handleClickOutside);
<div className="px-5 py-3 bg-gradient-to-r from-primary/5 to-transparent border-b border-border/30"> }, [servicesOpen]);
<p className="text-xs font-semibold text-primary uppercase tracking-wider">
Browse Our Services
</p>
</div>
{/* Services Grid */} // Close mobile menu when route changes or on escape key
<div className="p-3 grid grid-cols-2 gap-1"> 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", href: "/services/internet",
label: "Internet Plans", label: "Internet Plans",
@ -116,10 +141,74 @@ export function PublicShell({ children }: PublicShellProps) {
icon: <Wrench className="h-5 w-5" />, icon: <Wrench className="h-5 w-5" />,
color: "bg-slate-100 text-slate-600", color: "bg-slate-100 text-slate-600",
}, },
].map(item => ( ];
return (
<div className="min-h-screen flex flex-col bg-background text-foreground">
{/* Skip to main content link for accessibility */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-md"
>
Skip to main content
</a>
{/* Subtle background pattern - clean and minimal */}
<div className="fixed inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/5 via-background to-background" />
<header className="sticky top-0 z-40 border-b border-border/40 bg-background/95 backdrop-blur-md supports-[backdrop-filter]:bg-background/80">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] h-16 grid grid-cols-[auto_1fr_auto] items-center gap-4">
<Link href="/" className="inline-flex items-center gap-2.5 min-w-0 group">
<span className="inline-flex items-center justify-center h-9 w-9 rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary/15">
<Logo size={20} />
</span>
<span className="min-w-0 hidden sm:block">
<span className="block text-base font-bold leading-none tracking-tight text-foreground">
Assist Solution
</span>
</span>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center justify-center gap-1 sm:gap-3 text-sm font-semibold text-muted-foreground">
<div
ref={servicesDropdownRef}
className="relative"
onMouseEnter={() => !isTouchDevice && setServicesOpen(true)}
onMouseLeave={() => !isTouchDevice && setServicesOpen(false)}
>
<Link
href="/services"
onClick={handleServicesClick}
className="inline-flex items-center gap-1 px-3 py-2 rounded-md hover:text-foreground transition-colors"
aria-expanded={servicesOpen}
aria-haspopup="true"
>
Services
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${servicesOpen ? "rotate-180" : ""}`}
/>
</Link>
{servicesOpen && (
<div className="absolute left-1/2 -translate-x-1/2 top-full pt-2 z-50">
{/* Arrow pointer */}
<div className="absolute left-1/2 -translate-x-1/2 -top-0 w-3 h-3 rotate-45 bg-white border-l border-t border-border/50" />
<div className="w-[420px] rounded-2xl border border-border/50 bg-white shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200">
{/* Header */}
<div className="px-5 py-3 bg-gradient-to-r from-primary/5 to-transparent border-b border-border/30">
<p className="text-xs font-semibold text-primary uppercase tracking-wider">
Browse Our Services
</p>
</div>
{/* Services Grid */}
<div className="p-3 grid grid-cols-2 gap-1">
{serviceItems.map(item => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
onClick={() => setServicesOpen(false)}
className="group flex items-start gap-3 rounded-xl p-3 hover:bg-muted/50 transition-all duration-150" className="group flex items-start gap-3 rounded-xl p-3 hover:bg-muted/50 transition-all duration-150"
> >
<div <div
@ -143,6 +232,7 @@ export function PublicShell({ children }: PublicShellProps) {
<div className="px-5 py-3 bg-muted/30 border-t border-border/30"> <div className="px-5 py-3 bg-muted/30 border-t border-border/30">
<Link <Link
href="/services" href="/services"
onClick={() => setServicesOpen(false)}
className="inline-flex items-center gap-1.5 text-sm font-semibold text-primary hover:text-primary/80 transition-colors" className="inline-flex items-center gap-1.5 text-sm font-semibold text-primary hover:text-primary/80 transition-colors"
> >
View all services View all services
@ -155,13 +245,13 @@ export function PublicShell({ children }: PublicShellProps) {
</div> </div>
<Link <Link
href="/about" href="/about"
className="hidden sm:inline-flex items-center px-3 py-2 rounded-md hover:text-foreground transition-colors" className="inline-flex items-center px-3 py-2 rounded-md hover:text-foreground transition-colors"
> >
About About
</Link> </Link>
<Link <Link
href="/blog" href="/blog"
className="hidden sm:inline-flex items-center px-3 py-2 rounded-md hover:text-foreground transition-colors" className="inline-flex items-center px-3 py-2 rounded-md hover:text-foreground transition-colors"
> >
Blog Blog
</Link> </Link>
@ -173,26 +263,129 @@ export function PublicShell({ children }: PublicShellProps) {
</Link> </Link>
</nav> </nav>
{/* Mobile: Language indicator + hamburger */}
<div className="flex md:hidden items-center justify-center">
<div className="flex items-center gap-1 text-sm text-muted-foreground mr-2">
<Globe className="h-4 w-4" />
<span className="font-medium">EN</span>
</div>
</div>
<div className="flex items-center gap-3 justify-self-end">
{/* Language Selector - Desktop */}
<div className="hidden md:flex items-center gap-1 text-sm text-muted-foreground">
<Globe className="h-4 w-4" />
<span className="font-medium">EN</span>
</div>
{/* Auth Button - Desktop */}
{isAuthenticated ? ( {isAuthenticated ? (
<Link <Link
href="/account" href="/account"
className="justify-self-end inline-flex items-center justify-center rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="hidden md:inline-flex items-center justify-center rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
> >
My Account My Account
</Link> </Link>
) : ( ) : (
<Link <Link
href="/auth/login" href="/auth/login"
className="justify-self-end inline-flex items-center justify-center rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="hidden md:inline-flex items-center justify-center rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
Sign in
</Link>
)}
{/* Mobile Menu Button */}
<button
type="button"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-md hover:bg-muted/50 transition-colors"
aria-expanded={mobileMenuOpen}
aria-label={mobileMenuOpen ? "Close menu" : "Open menu"}
>
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
</div>
</div>
</header>
{/* Mobile Menu Overlay - Rendered outside header to avoid stacking context issues */}
{mobileMenuOpen && (
<div className="md:hidden fixed inset-0 top-16 z-50 bg-white animate-in fade-in duration-200 overflow-y-auto">
<nav className="flex flex-col p-6 space-y-2">
<div className="space-y-1">
<p className="text-xs font-semibold text-primary uppercase tracking-wider px-3 py-2">
Services
</p>
{serviceItems.map(item => (
<Link
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className="flex items-center gap-3 px-3 py-3 rounded-lg hover:bg-muted/50 transition-colors"
>
<div
className={`flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-lg ${item.color}`}
>
{item.icon}
</div>
<div>
<p className="text-sm font-semibold text-foreground">{item.label}</p>
<p className="text-xs text-muted-foreground">{item.desc}</p>
</div>
</Link>
))}
</div>
<div className="border-t border-border/40 pt-4 space-y-1">
<Link
href="/about"
onClick={closeMobileMenu}
className="flex items-center px-3 py-3 rounded-lg text-base font-semibold text-foreground hover:bg-muted/50 transition-colors"
>
About
</Link>
<Link
href="/blog"
onClick={closeMobileMenu}
className="flex items-center px-3 py-3 rounded-lg text-base font-semibold text-foreground hover:bg-muted/50 transition-colors"
>
Blog
</Link>
<Link
href="/contact"
onClick={closeMobileMenu}
className="flex items-center px-3 py-3 rounded-lg text-base font-semibold text-foreground hover:bg-muted/50 transition-colors"
>
Support
</Link>
</div>
<div className="border-t border-border/40 pt-4">
{isAuthenticated ? (
<Link
href="/account"
onClick={closeMobileMenu}
className="flex items-center justify-center rounded-full bg-primary px-6 py-3 text-base font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors"
>
My Account
</Link>
) : (
<Link
href="/auth/login"
onClick={closeMobileMenu}
className="flex items-center justify-center rounded-full bg-primary px-6 py-3 text-base font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors"
> >
Sign in Sign in
</Link> </Link>
)} )}
</div> </div>
</header> </nav>
</div>
)}
<main className="flex-1"> <main id="main-content" className="flex-1">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] pt-0 pb-12 sm:pb-16"> <div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] pt-0 pb-0">
{children} {children}
</div> </div>
</main> </main>

View File

@ -267,18 +267,31 @@ export function AboutUsView() {
{/* Corporate Data Section */} {/* Corporate Data Section */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-[#f7f7f7] py-12"> <section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-[#f7f7f7] py-12">
<div className="max-w-6xl mx-auto px-6 sm:px-8 space-y-6"> <div className="max-w-6xl mx-auto px-6 sm:px-8 space-y-3">
<div className="flex items-center gap-3"> {/* Row 1: headings same level */}
<div className="grid grid-cols-1 lg:grid-cols-[1.2fr_0.8fr] gap-x-10 gap-y-2 items-start">
<h2 className="text-2xl font-bold text-foreground">Corporate Data</h2> <h2 className="text-2xl font-bold text-foreground">Corporate Data</h2>
<div>
<h3 className="text-xl font-bold text-foreground mb-2">Address</h3>
<p className="text-muted-foreground font-semibold leading-relaxed">
3F Azabu Maruka Bldg., 3-8-2 Higashi Azabu,
<br />
Minato-ku, Tokyo 106-0044
<br />
Tel: 03-3560-1006 Fax: 03-3560-1007
</p>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-[1.2fr_0.8fr] gap-10 items-start"> </div>
<div className="space-y-5">
{/* Row 2: corporate data list | map (no stretch, no extra space below left column) */}
<div className="grid grid-cols-1 lg:grid-cols-[1.2fr_0.8fr] gap-x-10 gap-y-2 items-start">
<div className="space-y-2">
<div> <div>
<h3 className="text-xl font-semibold text-foreground flex items-center gap-2"> <h3 className="text-xl font-semibold text-foreground flex items-center gap-2">
<span className="h-6 w-1 rounded-full bg-primary" /> <span className="h-6 w-1 rounded-full bg-primary" />
Representative Director Representative Director
</h3> </h3>
<p className="text-muted-foreground font-semibold mt-1">Daisuke Nagakawa</p> <p className="text-muted-foreground font-semibold mt-0.5">Daisuke Nagakawa</p>
</div> </div>
<div> <div>
@ -286,7 +299,7 @@ export function AboutUsView() {
<span className="h-6 w-1 rounded-full bg-primary" /> <span className="h-6 w-1 rounded-full bg-primary" />
Employees Employees
</h3> </h3>
<p className="text-muted-foreground font-semibold mt-1"> <p className="text-muted-foreground font-semibold mt-0.5">
21 Staff Members (as of March 31st, 2025) 21 Staff Members (as of March 31st, 2025)
</p> </p>
</div> </div>
@ -296,7 +309,7 @@ export function AboutUsView() {
<span className="h-6 w-1 rounded-full bg-primary" /> <span className="h-6 w-1 rounded-full bg-primary" />
Established Established
</h3> </h3>
<p className="text-muted-foreground font-semibold mt-1">March 8, 2002</p> <p className="text-muted-foreground font-semibold mt-0.5">March 8, 2002</p>
</div> </div>
<div> <div>
@ -304,7 +317,7 @@ export function AboutUsView() {
<span className="h-6 w-1 rounded-full bg-primary" /> <span className="h-6 w-1 rounded-full bg-primary" />
Paid in Capital Paid in Capital
</h3> </h3>
<p className="text-muted-foreground font-semibold mt-1">40,000,000 JPY</p> <p className="text-muted-foreground font-semibold mt-0.5">40,000,000 JPY</p>
</div> </div>
<div> <div>
@ -312,7 +325,7 @@ export function AboutUsView() {
<span className="h-6 w-1 rounded-full bg-primary" /> <span className="h-6 w-1 rounded-full bg-primary" />
Business Hours Business Hours
</h3> </h3>
<div className="text-muted-foreground font-semibold mt-1 space-y-1"> <div className="text-muted-foreground font-semibold mt-0.5 space-y-0.5">
<p>Mon - Fri 9:30AM - 6:00PM Customer Support Team</p> <p>Mon - Fri 9:30AM - 6:00PM Customer Support Team</p>
<p>Mon - Fri 9:30AM - 6:00PM In-office Tech Support Team</p> <p>Mon - Fri 9:30AM - 6:00PM In-office Tech Support Team</p>
<p>Mon - Sat 10:00AM - 9:00PM Onsite Tech Support Team</p> <p>Mon - Sat 10:00AM - 9:00PM Onsite Tech Support Team</p>
@ -320,22 +333,11 @@ export function AboutUsView() {
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="rounded-2xl overflow-hidden w-full min-h-[320px]">
<div className="bg-muted/20 rounded-2xl border border-border/60 p-4">
<h3 className="text-xl font-bold text-foreground mb-2 text-center">Address</h3>
<p className="text-center text-muted-foreground font-semibold leading-relaxed">
3F Azabu Maruka Bldg., 3-8-2 Higashi Azabu,
<br />
Minato-ku, Tokyo 106-0044
<br />
Tel: 03-3560-1006 Fax: 03-3560-1007
</p>
</div>
<div className="rounded-2xl overflow-hidden shadow-md border border-border/70">
<iframe <iframe
title="Assist Solutions Corp Map" title="Assist Solutions Corp Map"
src="https://www.google.com/maps?q=Assist+Solutions+Corp,+3-8-2+Higashi+Azabu,+Minato-ku,+Tokyo&output=embed" src="https://www.google.com/maps?q=Assist+Solutions+Corp,+3-8-2+Higashi+Azabu,+Minato-ku,+Tokyo&output=embed"
className="w-full h-[320px]" className="w-full h-[320px] block"
loading="lazy" loading="lazy"
allowFullScreen allowFullScreen
referrerPolicy="no-referrer-when-downgrade" referrerPolicy="no-referrer-when-downgrade"
@ -343,7 +345,6 @@ export function AboutUsView() {
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
</div> </div>
); );

View File

@ -345,16 +345,10 @@ function ExpandedTierDetails({
config, config,
tiers, tiers,
setupFee, setupFee,
ctaPath,
ctaLabel,
onCtaClick,
}: { }: {
config: OfferingTypeConfig; config: OfferingTypeConfig;
tiers: TierInfo[]; tiers: TierInfo[];
setupFee: number; setupFee: number;
ctaPath: string;
ctaLabel: string;
onCtaClick?: (e: React.MouseEvent) => void;
}) { }) {
return ( return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden"> <div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
@ -455,23 +449,14 @@ function ExpandedTierDetails({
))} ))}
</div> </div>
{/* Footer with setup fee and CTA */} {/* Footer with setup fee */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-5 mt-5 border-t border-border"> <div className="pt-5 mt-5 border-t border-border">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
+ ¥{setupFee.toLocaleString()} one-time setup + ¥{setupFee.toLocaleString()} one-time setup
</span>{" "} </span>{" "}
(or 12/24-month installment) (or 12/24-month installment)
</p> </p>
{onCtaClick ? (
<Button onClick={onCtaClick} size="lg" className="whitespace-nowrap">
{ctaLabel}
</Button>
) : (
<Button as="a" href={ctaPath} size="lg" className="whitespace-nowrap">
{ctaLabel}
</Button>
)}
</div> </div>
</div> </div>
</div> </div>
@ -484,24 +469,14 @@ function ExpandedTierDetails({
function AvailablePlansSection({ function AvailablePlansSection({
plansByOffering, plansByOffering,
setupFee, setupFee,
ctaPath,
ctaLabel,
onCtaClick,
}: { }: {
plansByOffering: Record<string, { minPrice: number; tiers: TierInfo[] }>; plansByOffering: Record<string, { minPrice: number; tiers: TierInfo[] }>;
setupFee: number; setupFee: number;
ctaPath: string;
ctaLabel: string;
onCtaClick?: (e: React.MouseEvent) => void;
}) { }) {
const [expandedCards, setExpandedCards] = useState<Record<string, boolean>>({ const [expandedCard, setExpandedCard] = useState<string | null>(null);
home10g: false,
home1g: false,
apartment: false,
});
const toggleCard = (id: string) => { const toggleCard = (id: string) => {
setExpandedCards(prev => ({ ...prev, [id]: !prev[id] })); setExpandedCard(prev => (prev === id ? null : id));
}; };
// Get configs that have data // Get configs that have data
@ -522,7 +497,7 @@ function AvailablePlansSection({
key={config.id} key={config.id}
config={config} config={config}
minPrice={offeringData.minPrice} minPrice={offeringData.minPrice}
isExpanded={expandedCards[config.id] ?? false} isExpanded={expandedCard === config.id}
onToggle={() => toggleCard(config.id)} onToggle={() => toggleCard(config.id)}
/> />
); );
@ -533,7 +508,7 @@ function AvailablePlansSection({
<div className="space-y-4"> <div className="space-y-4">
{availableConfigs.map(config => { {availableConfigs.map(config => {
const offeringData = plansByOffering[config.id]; const offeringData = plansByOffering[config.id];
if (!offeringData || !expandedCards[config.id]) return null; if (!offeringData || expandedCard !== config.id) return null;
return ( return (
<ExpandedTierDetails <ExpandedTierDetails
@ -541,9 +516,6 @@ function AvailablePlansSection({
config={config} config={config}
tiers={offeringData.tiers} tiers={offeringData.tiers}
setupFee={setupFee} setupFee={setupFee}
ctaPath={ctaPath}
ctaLabel={ctaLabel}
onCtaClick={onCtaClick}
/> />
); );
})} })}
@ -816,9 +788,6 @@ export function PublicInternetPlansContent({
<AvailablePlansSection <AvailablePlansSection
plansByOffering={plansByOffering} plansByOffering={plansByOffering}
setupFee={consolidatedPlanData.setupFee} setupFee={consolidatedPlanData.setupFee}
ctaPath={ctaPath}
ctaLabel={ctaLabel}
onCtaClick={onCtaClick}
/> />
)} )}

View File

@ -115,7 +115,7 @@ export function PublicContactView() {
} }
return ( return (
<div className="max-w-6xl mx-auto px-4 pb-16"> <div className="max-w-6xl mx-auto px-4 pb-0">
{/* Header */} {/* Header */}
<div className="text-center mb-12 pt-8"> <div className="text-center mb-12 pt-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary/10 rounded-2xl mb-4 text-primary"> <div className="inline-flex items-center justify-center w-16 h-16 bg-primary/10 rounded-2xl mb-4 text-primary">