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:
parent
ff870c9f4f
commit
c9d568d22f
118
CLAUDE.md
Normal file
118
CLAUDE.md
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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,8 +44,115 @@ export function PublicShell({ children }: PublicShellProps) {
|
|||||||
}
|
}
|
||||||
}, [checkAuth, hasCheckedAuth]);
|
}, [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: <Wifi className="h-5 w-5" />,
|
||||||
|
color: "bg-sky-50 text-sky-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/services/sim",
|
||||||
|
label: "Phone Plans",
|
||||||
|
desc: "Docomo network SIM cards",
|
||||||
|
icon: <Smartphone className="h-5 w-5" />,
|
||||||
|
color: "bg-emerald-50 text-emerald-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/services/business",
|
||||||
|
label: "Business Solutions",
|
||||||
|
desc: "Enterprise IT services",
|
||||||
|
icon: <Building2 className="h-5 w-5" />,
|
||||||
|
color: "bg-violet-50 text-violet-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/services/vpn",
|
||||||
|
label: "VPN Service",
|
||||||
|
desc: "US & UK server access",
|
||||||
|
icon: <Lock className="h-5 w-5" />,
|
||||||
|
color: "bg-amber-50 text-amber-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/services/onsite",
|
||||||
|
label: "Onsite Support",
|
||||||
|
desc: "Tech help at your location",
|
||||||
|
icon: <Wrench className="h-5 w-5" />,
|
||||||
|
color: "bg-slate-100 text-slate-600",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-background text-foreground">
|
<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 */}
|
{/* 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" />
|
<div className="fixed inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/5 via-background to-background" />
|
||||||
|
|
||||||
@ -48,17 +169,20 @@ export function PublicShell({ children }: PublicShellProps) {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex items-center justify-center gap-1 sm:gap-3 text-sm font-semibold text-muted-foreground">
|
{/* Desktop Navigation */}
|
||||||
|
<nav className="hidden md:flex items-center justify-center gap-1 sm:gap-3 text-sm font-semibold text-muted-foreground">
|
||||||
<div
|
<div
|
||||||
|
ref={servicesDropdownRef}
|
||||||
className="relative"
|
className="relative"
|
||||||
onMouseEnter={() => setServicesOpen(true)}
|
onMouseEnter={() => !isTouchDevice && setServicesOpen(true)}
|
||||||
onMouseLeave={() => setServicesOpen(false)}
|
onMouseLeave={() => !isTouchDevice && setServicesOpen(false)}
|
||||||
onFocus={() => setServicesOpen(true)}
|
|
||||||
onBlur={() => setServicesOpen(false)}
|
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href="/services"
|
href="/services"
|
||||||
|
onClick={handleServicesClick}
|
||||||
className="inline-flex items-center gap-1 px-3 py-2 rounded-md hover:text-foreground transition-colors"
|
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
|
Services
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
@ -80,46 +204,11 @@ export function PublicShell({ children }: PublicShellProps) {
|
|||||||
|
|
||||||
{/* Services Grid */}
|
{/* Services Grid */}
|
||||||
<div className="p-3 grid grid-cols-2 gap-1">
|
<div className="p-3 grid grid-cols-2 gap-1">
|
||||||
{[
|
{serviceItems.map(item => (
|
||||||
{
|
|
||||||
href: "/services/internet",
|
|
||||||
label: "Internet Plans",
|
|
||||||
desc: "NTT Fiber up to 10Gbps",
|
|
||||||
icon: <Wifi className="h-5 w-5" />,
|
|
||||||
color: "bg-sky-50 text-sky-600",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/services/sim",
|
|
||||||
label: "Phone Plans",
|
|
||||||
desc: "Docomo network SIM cards",
|
|
||||||
icon: <Smartphone className="h-5 w-5" />,
|
|
||||||
color: "bg-emerald-50 text-emerald-600",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/services/business",
|
|
||||||
label: "Business Solutions",
|
|
||||||
desc: "Enterprise IT services",
|
|
||||||
icon: <Building2 className="h-5 w-5" />,
|
|
||||||
color: "bg-violet-50 text-violet-600",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/services/vpn",
|
|
||||||
label: "VPN Service",
|
|
||||||
desc: "US & UK server access",
|
|
||||||
icon: <Lock className="h-5 w-5" />,
|
|
||||||
color: "bg-amber-50 text-amber-600",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/services/onsite",
|
|
||||||
label: "Onsite Support",
|
|
||||||
desc: "Tech help at your location",
|
|
||||||
icon: <Wrench className="h-5 w-5" />,
|
|
||||||
color: "bg-slate-100 text-slate-600",
|
|
||||||
},
|
|
||||||
].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>
|
||||||
|
|
||||||
{isAuthenticated ? (
|
{/* Mobile: Language indicator + hamburger */}
|
||||||
<Link
|
<div className="flex md:hidden items-center justify-center">
|
||||||
href="/account"
|
<div className="flex items-center gap-1 text-sm text-muted-foreground mr-2">
|
||||||
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"
|
<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 ? (
|
||||||
|
<Link
|
||||||
|
href="/account"
|
||||||
|
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
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/auth/login"
|
||||||
|
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"}
|
||||||
>
|
>
|
||||||
My Account
|
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||||
</Link>
|
</button>
|
||||||
) : (
|
</div>
|
||||||
<Link
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1">
|
{/* Mobile Menu Overlay - Rendered outside header to avoid stacking context issues */}
|
||||||
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] pt-0 pb-12 sm:pb-16">
|
{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
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[1.2fr_0.8fr] gap-10 items-start">
|
|
||||||
<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,27 +333,15 @@ 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">
|
<iframe
|
||||||
<h3 className="text-xl font-bold text-foreground mb-2 text-center">Address</h3>
|
title="Assist Solutions Corp Map"
|
||||||
<p className="text-center text-muted-foreground font-semibold leading-relaxed">
|
src="https://www.google.com/maps?q=Assist+Solutions+Corp,+3-8-2+Higashi+Azabu,+Minato-ku,+Tokyo&output=embed"
|
||||||
3F Azabu Maruka Bldg., 3-8-2 Higashi Azabu,
|
className="w-full h-[320px] block"
|
||||||
<br />
|
loading="lazy"
|
||||||
Minato-ku, Tokyo 106-0044
|
allowFullScreen
|
||||||
<br />
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
Tel: 03-3560-1006 Fax: 03-3560-1007
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl overflow-hidden shadow-md border border-border/70">
|
|
||||||
<iframe
|
|
||||||
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"
|
|
||||||
className="w-full h-[320px]"
|
|
||||||
loading="lazy"
|
|
||||||
allowFullScreen
|
|
||||||
referrerPolicy="no-referrer-when-downgrade"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user