refactor: update styles and improve loading states across portal components

- Modified global CSS to enhance typography and introduce new surface styles.
- Updated layout component to utilize the new Jakarta font variable.
- Improved loading states in account and public services views for better user experience.
- Refactored loading components to use consistent styling and structure.
- Enhanced the SiteFooter and AuthLayout components with updated font styles.
- Streamlined the PublicLandingLoadingView for better visual consistency.
- Added new BackLink component for improved navigation in order detail loading state.
This commit is contained in:
barsa 2026-03-04 11:59:22 +09:00
parent 26776373f7
commit 6b13d74d06
43 changed files with 1929 additions and 1749 deletions

View File

@ -2,7 +2,7 @@ import { Skeleton } from "@/components/atoms/loading-skeleton";
export default function PublicServicesLoading() {
return (
<div className="container mx-auto px-4 py-12 space-y-8 animate-in fade-in duration-300">
<div className="max-w-6xl mx-auto px-4 pt-8 space-y-6 animate-in fade-in duration-300">
{/* Header section */}
<div className="text-center space-y-4">
<Skeleton className="h-10 w-64 mx-auto" />
@ -10,9 +10,9 @@ export default function PublicServicesLoading() {
</div>
{/* Services grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-card border border-border rounded-2xl p-6 space-y-4">
<div key={i} className="bg-card border border-border rounded-xl p-6 space-y-4">
<Skeleton className="h-12 w-12 rounded-xl" />
<div className="space-y-2">
<Skeleton className="h-6 w-32" />

View File

@ -1,26 +1,30 @@
import { RouteLoading } from "@/components/molecules/RouteLoading";
import { HomeIcon } from "@heroicons/react/24/outline";
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
export default function AccountLoading() {
return (
<RouteLoading
icon={<HomeIcon />}
title="Account"
description="Loading your account..."
title="Dashboard"
description="Overview of your account"
mode="content"
>
<div className="space-y-6">
<LoadingCard />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-card border border-border rounded-2xl p-6">
<Skeleton className="h-4 w-24 mb-3" />
<Skeleton className="h-8 w-32" />
</div>
<div className="bg-card border border-border rounded-2xl p-6">
<Skeleton className="h-4 w-24 mb-3" />
<Skeleton className="h-8 w-32" />
</div>
<div className="space-y-8">
{/* Greeting skeleton */}
<div className="space-y-3">
<div className="h-4 cp-skeleton-shimmer rounded w-24" />
<div className="h-10 cp-skeleton-shimmer rounded w-56" />
<div className="h-4 cp-skeleton-shimmer rounded w-40" />
</div>
{/* Tasks skeleton */}
<div className="space-y-4">
<div className="h-24 cp-skeleton-shimmer rounded-2xl" />
<div className="h-24 cp-skeleton-shimmer rounded-2xl" />
</div>
{/* Bottom section skeleton (stats + activity) */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="h-44 cp-skeleton-shimmer rounded-2xl" />
<div className="h-44 cp-skeleton-shimmer rounded-2xl" />
</div>
</div>
</RouteLoading>

View File

@ -1,6 +1,6 @@
import { RouteLoading } from "@/components/molecules/RouteLoading";
import { ClipboardDocumentCheckIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
import { Button } from "@/components/atoms/button";
import { BackLink } from "@/components/molecules/BackLink";
import { ClipboardDocumentCheckIcon } from "@heroicons/react/24/outline";
export default function AccountOrderDetailLoading() {
return (
@ -10,69 +10,56 @@ export default function AccountOrderDetailLoading() {
description="Loading order details..."
mode="content"
>
<div className="mb-6">
<Button
as="a"
href="/account/orders"
size="sm"
variant="ghost"
leftIcon={<ArrowLeftIcon className="h-4 w-4" />}
className="text-gray-600 hover:text-gray-900"
>
Back to orders
</Button>
</div>
<BackLink href="/account/orders" label="Back to orders" />
<div className="animate-pulse">
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-200 bg-gradient-to-br from-white to-slate-50 px-6 py-6 sm:px-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<div className="h-8 w-48 rounded bg-slate-200" />
<div className="h-6 w-24 rounded-full bg-slate-200" />
</div>
<div className="h-4 w-64 rounded bg-slate-100" />
<div className="rounded-xl border border-border bg-card shadow-sm">
<div className="border-b border-border px-6 py-6 sm:px-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<div className="h-8 w-48 rounded cp-skeleton-shimmer" />
<div className="h-6 w-24 rounded-full cp-skeleton-shimmer" />
</div>
<div className="h-4 w-64 rounded cp-skeleton-shimmer" />
</div>
<div className="flex items-start gap-6 sm:gap-8">
<div className="text-right space-y-2">
<div className="h-3 w-16 rounded bg-slate-100" />
<div className="h-9 w-24 rounded bg-slate-200" />
</div>
<div className="text-right space-y-2">
<div className="h-3 w-16 rounded bg-slate-100" />
<div className="h-9 w-24 rounded bg-slate-200" />
</div>
<div className="flex items-start gap-6 sm:gap-8">
<div className="text-right space-y-2">
<div className="h-3 w-16 rounded cp-skeleton-shimmer" />
<div className="h-9 w-24 rounded cp-skeleton-shimmer" />
</div>
<div className="text-right space-y-2">
<div className="h-3 w-16 rounded cp-skeleton-shimmer" />
<div className="h-9 w-24 rounded cp-skeleton-shimmer" />
</div>
</div>
</div>
</div>
<div className="px-6 py-6 sm:px-8">
<div className="flex flex-col gap-6">
<div>
<div className="mb-2 h-3 w-32 rounded bg-slate-200" />
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, idx) => (
<div
key={idx}
className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex flex-1 items-start gap-3">
<div className="h-6 w-6 rounded-lg bg-slate-100 flex-shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="h-5 w-3/4 rounded bg-slate-200" />
<div className="h-3 w-24 rounded bg-slate-100" />
</div>
<div className="flex flex-col items-end gap-1">
<div className="h-6 w-28 rounded bg-slate-200" />
</div>
<div className="px-6 py-6 sm:px-8">
<div className="flex flex-col gap-6">
<div>
<div className="mb-2 h-3 w-32 rounded cp-skeleton-shimmer" />
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, idx) => (
<div
key={idx}
className="flex flex-col gap-3 rounded-xl border border-border bg-card px-4 py-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex flex-1 items-start gap-3">
<div className="h-6 w-6 rounded-lg cp-skeleton-shimmer flex-shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="h-5 w-3/4 rounded cp-skeleton-shimmer" />
<div className="h-3 w-24 rounded cp-skeleton-shimmer" />
</div>
<div className="flex flex-col items-end gap-1">
<div className="h-6 w-28 rounded cp-skeleton-shimmer" />
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>

View File

@ -18,7 +18,7 @@
/* Typography */
--font-sans: var(--font-geist-sans, system-ui, sans-serif);
--font-display: var(--font-sans);
--font-display: var(--font-jakarta, var(--font-sans));
/* Core Surfaces */
--background: oklch(1 0 0);
@ -70,8 +70,12 @@
--neutral-bg: oklch(0.97 0.008 272.34);
--neutral-border: oklch(0.87 0.02 272.34);
/* Surfaces */
--surface-elevated: oklch(0.995 0 0);
--surface-sunken: oklch(0.975 0.005 234.4);
/* Chrome */
--border: oklch(0.92 0.005 234.4);
--border: oklch(0.93 0.004 234.4);
--input: oklch(0.96 0.004 234.4);
--ring: oklch(0.6884 0.1342 234.4 / 0.5);
@ -181,6 +185,9 @@
--neutral-bg: oklch(0.24 0.02 272.34);
--neutral-border: oklch(0.38 0.03 272.34);
--surface-elevated: oklch(0.18 0.015 234.4);
--surface-sunken: oklch(0.1 0.015 234.4);
--border: oklch(0.32 0.02 234.4);
--input: oklch(0.35 0.02 234.4);
--ring: oklch(0.75 0.12 234.4 / 0.5);
@ -302,6 +309,10 @@
--color-navy: var(--navy);
--color-navy-foreground: var(--navy-foreground);
/* Surface tokens */
--color-surface-elevated: var(--surface-elevated);
--color-surface-sunken: var(--surface-sunken);
/* Glass tokens */
--color-glass-bg: var(--glass-bg);
--color-glass-border: var(--glass-border);

View File

@ -1,9 +1,16 @@
import type { Metadata } from "next";
import { Plus_Jakarta_Sans } from "next/font/google";
import { headers } from "next/headers";
import "./globals.css";
import { QueryProvider } from "@/core/providers";
import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning";
const plusJakartaSans = Plus_Jakarta_Sans({
subsets: ["latin"],
variable: "--font-jakarta",
display: "swap",
});
export const metadata: Metadata = {
title: {
default: "Assist Solutions - IT Services for Expats in Japan",
@ -32,7 +39,7 @@ export default async function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className="antialiased">
<body className={`${plusJakartaSans.variable} antialiased`}>
<QueryProvider nonce={nonce}>
{children}
<SessionTimeoutWarning />

View File

@ -0,0 +1,49 @@
"use client";
import { Button } from "@/components/atoms/button";
import { cn } from "@/shared/utils";
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import type { ReactNode } from "react";
type Alignment = "left" | "center" | "right";
interface BackLinkProps {
href: string;
label?: string;
align?: Alignment;
className?: string;
buttonClassName?: string;
icon?: ReactNode;
}
const alignmentMap: Record<Alignment, string> = {
left: "justify-start",
center: "justify-center",
right: "justify-end",
};
export function BackLink({
href,
label = "Back",
align = "left",
className,
buttonClassName,
icon = <ArrowLeftIcon className="w-4 h-4" />,
}: BackLinkProps) {
return (
<div className={cn("mb-6 flex", alignmentMap[align], className)}>
<Button
as="a"
href={href}
size="sm"
variant="ghost"
leftIcon={icon}
className={cn("text-muted-foreground hover:text-foreground", buttonClassName)}
>
{label}
</Button>
</div>
);
}
export type { BackLinkProps };

View File

@ -0,0 +1,2 @@
export { BackLink } from "./BackLink";
export type { BackLinkProps } from "./BackLink";

View File

@ -28,6 +28,9 @@ export * from "./FilterDropdown";
export * from "./ClearFiltersButton";
export * from "./DetailStatsGrid";
// Navigation molecules
export * from "./BackLink";
// Loading skeleton molecules
export * from "./LoadingSkeletons";

View File

@ -11,7 +11,9 @@ import { Phone, MapPin } from "lucide-react";
export function SiteFooter() {
return (
<footer className="border-t border-border/40 bg-muted/30">
<footer className="relative border-t border-border/40 bg-muted/30">
{/* Subtle gradient separator */}
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-primary/20 to-transparent" />
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8">
{/* Company Info */}
@ -48,7 +50,7 @@ export function SiteFooter() {
{/* Services */}
<div>
<h3 className="text-sm font-semibold text-foreground mb-4">Services</h3>
<h3 className="text-sm font-semibold text-foreground mb-4 font-display">Services</h3>
<ul className="space-y-2 text-sm">
<li>
<Link
@ -79,7 +81,7 @@ export function SiteFooter() {
{/* Company */}
<div>
<h3 className="text-sm font-semibold text-foreground mb-4">Company</h3>
<h3 className="text-sm font-semibold text-foreground mb-4 font-display">Company</h3>
<ul className="space-y-2 text-sm">
<li>
<Link

View File

@ -41,7 +41,9 @@ export function AuthLayout({
)}
<div className="text-center">
<h1 className="text-2xl font-bold tracking-tight text-foreground mb-2">{title}</h1>
<h1 className="text-2xl font-bold tracking-tight text-foreground mb-2 font-display">
{title}
</h1>
{subtitle && (
<p className="text-sm text-muted-foreground leading-relaxed max-w-sm mx-auto">
{subtitle}
@ -58,7 +60,7 @@ export function AuthLayout({
{/* Trust indicator */}
<div className="mt-6 text-center">
<p className="text-xs text-muted-foreground/60 flex items-center justify-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-green-500/80" />
<span className="h-2 w-2 rounded-full bg-success" />
Secure login protected by SSL encryption
</p>
</div>

View File

@ -16,6 +16,7 @@ interface PageLayoutProps {
actions?: ReactNode | undefined;
breadcrumbs?: BreadcrumbItem[] | undefined;
loading?: boolean | undefined;
loadingFallback?: ReactNode | undefined;
error?: Error | string | null | undefined;
onRetry?: (() => void) | undefined;
children: ReactNode;
@ -28,32 +29,23 @@ export function PageLayout({
actions,
breadcrumbs,
loading = false,
loadingFallback,
error = null,
onRetry,
children,
}: PageLayoutProps) {
return (
<div
className="py-[var(--cp-space-lg)] sm:py-[var(--cp-space-xl)] md:py-[var(--cp-space-2xl)]"
suppressHydrationWarning
>
<div
className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-md)] sm:px-[var(--cp-space-lg)] md:px-8"
suppressHydrationWarning
>
<div className="py-[var(--cp-space-lg)] sm:py-[var(--cp-space-xl)] md:py-[var(--cp-space-2xl)]">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-md)] sm:px-[var(--cp-space-lg)] md:px-8">
{/* Breadcrumbs - scrollable on mobile */}
{breadcrumbs && breadcrumbs.length > 0 && (
<nav
className="mb-[var(--cp-space-md)] sm:mb-[var(--cp-space-lg)] -mx-[var(--cp-space-md)] px-[var(--cp-space-md)] sm:mx-0 sm:px-0 overflow-x-auto scrollbar-none"
aria-label="Breadcrumb"
suppressHydrationWarning
>
<ol
className="flex items-center space-x-1 sm:space-x-2 text-sm text-muted-foreground whitespace-nowrap"
suppressHydrationWarning
>
<ol className="flex items-center space-x-1 sm:space-x-2 text-sm text-muted-foreground whitespace-nowrap">
{breadcrumbs.map((item, index) => (
<li key={index} className="flex items-center flex-shrink-0" suppressHydrationWarning>
<li key={index} className="flex items-center flex-shrink-0">
{index > 0 && (
<ChevronRightIcon className="h-4 w-4 mx-1 sm:mx-2 text-muted-foreground/50 flex-shrink-0" />
)}
@ -61,16 +53,11 @@ export function PageLayout({
<Link
href={item.href}
className="hover:text-foreground transition-colors duration-200 py-1 px-0.5 -mx-0.5 rounded"
suppressHydrationWarning
>
{item.label}
</Link>
) : (
<span
className="text-foreground font-medium py-1"
aria-current="page"
suppressHydrationWarning
>
<span className="text-foreground font-medium py-1" aria-current="page">
{item.label}
</span>
)}
@ -81,36 +68,21 @@ export function PageLayout({
)}
{/* Header */}
<div
className="mb-[var(--cp-space-lg)] sm:mb-[var(--cp-space-xl)] md:mb-[var(--cp-space-2xl)] pb-[var(--cp-space-lg)] sm:pb-[var(--cp-space-xl)] md:pb-[var(--cp-space-2xl)] border-b border-border"
suppressHydrationWarning
>
<div
className="flex flex-col gap-[var(--cp-space-md)] sm:gap-[var(--cp-space-lg)]"
suppressHydrationWarning
>
<div className="mb-[var(--cp-space-lg)] sm:mb-[var(--cp-space-xl)] md:mb-[var(--cp-space-2xl)] pb-[var(--cp-space-lg)] sm:pb-[var(--cp-space-xl)] md:pb-[var(--cp-space-2xl)] border-b border-border">
<div className="flex flex-col gap-[var(--cp-space-md)] sm:gap-[var(--cp-space-lg)]">
{/* Title row */}
<div className="flex items-start min-w-0 flex-1" suppressHydrationWarning>
<div className="flex items-start min-w-0 flex-1">
{icon && (
<div
className="h-7 w-7 sm:h-8 sm:w-8 text-primary mr-[var(--cp-space-md)] sm:mr-[var(--cp-space-lg)] flex-shrink-0 mt-0.5"
suppressHydrationWarning
>
<div className="h-7 w-7 sm:h-8 sm:w-8 text-primary mr-[var(--cp-space-md)] sm:mr-[var(--cp-space-lg)] flex-shrink-0 mt-0.5">
{icon}
</div>
)}
<div className="min-w-0 flex-1" suppressHydrationWarning>
<h1
className="text-xl sm:text-2xl md:text-3xl font-bold text-foreground leading-tight"
suppressHydrationWarning
>
<div className="min-w-0 flex-1">
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-foreground leading-tight">
{title}
</h1>
{description && (
<p
className="text-sm text-muted-foreground mt-1 leading-relaxed line-clamp-2 sm:line-clamp-none"
suppressHydrationWarning
>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed line-clamp-2 sm:line-clamp-none">
{description}
</p>
)}
@ -119,10 +91,7 @@ export function PageLayout({
{/* Actions - full width on mobile, stacks buttons */}
{actions && (
<div
className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 w-full sm:w-auto [&>*]:w-full [&>*]:sm:w-auto"
suppressHydrationWarning
>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 w-full sm:w-auto [&>*]:w-full [&>*]:sm:w-auto">
{actions}
</div>
)}
@ -130,8 +99,8 @@ export function PageLayout({
</div>
{/* Content with loading and error states */}
<div className="space-y-[var(--cp-space-xl)] sm:space-y-[var(--cp-space-2xl)]" suppressHydrationWarning>
{renderPageContent(loading, error ?? undefined, children, onRetry)}
<div className="space-y-[var(--cp-space-xl)] sm:space-y-[var(--cp-space-2xl)]">
{renderPageContent(loading, error ?? undefined, children, onRetry, loadingFallback)}
</div>
</div>
</div>
@ -142,10 +111,11 @@ function renderPageContent(
loading: boolean | undefined,
error: Error | string | undefined,
children: React.ReactNode,
onRetry?: () => void
onRetry?: () => void,
loadingFallback?: React.ReactNode
): React.ReactNode {
if (loading) {
return <PageLoadingState />;
return loadingFallback ?? <PageLoadingState />;
}
if (error) {
return <PageErrorState error={error} onRetry={onRetry} />;

View File

@ -111,35 +111,35 @@ export function PublicShell({ children }: PublicShellProps) {
label: "Internet Plans",
desc: "NTT Fiber up to 10Gbps",
icon: <Wifi className="h-5 w-5" />,
color: "bg-sky-50 text-sky-600",
color: "bg-info-soft text-info",
},
{
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",
color: "bg-success-soft text-success",
},
{
href: "/services/business",
label: "Business Solutions",
desc: "Enterprise IT services",
icon: <Building2 className="h-5 w-5" />,
color: "bg-violet-50 text-violet-600",
color: "bg-primary-soft text-primary",
},
{
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",
color: "bg-warning-soft text-warning",
},
{
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",
color: "bg-muted text-muted-foreground",
},
];
@ -157,7 +157,7 @@ export function PublicShell({ children }: PublicShellProps) {
<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-space-md)] sm:px-[var(--cp-page-padding)] h-16 grid grid-cols-[auto_1fr_auto] items-center gap-3 sm:gap-4">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-md)] sm:px-[var(--cp-page-padding)] h-[72px] grid grid-cols-[auto_1fr_auto] items-center gap-3 sm:gap-4">
<Link href="/" className="inline-flex items-center gap-2 sm: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} />
@ -192,9 +192,9 @@ export function PublicShell({ children }: PublicShellProps) {
{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="absolute left-1/2 -translate-x-1/2 -top-0 w-3 h-3 rotate-45 bg-card 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">
<div className="w-[420px] rounded-2xl border border-border/50 bg-card shadow-[var(--cp-shadow-3)] 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">
@ -335,7 +335,7 @@ export function PublicShell({ children }: PublicShellProps) {
{/* Mobile Menu Overlay - Full screen with slide animation */}
{mobileMenuOpen && (
<div
className="md:hidden fixed inset-0 top-16 z-50 bg-background animate-in slide-in-from-right-full duration-300 ease-out overflow-hidden"
className="md:hidden fixed inset-0 top-[72px] z-50 bg-background animate-in slide-in-from-right-full duration-300 ease-out overflow-hidden"
style={{
// iOS safe area support
paddingBottom: "env(safe-area-inset-bottom, 0px)",

View File

@ -0,0 +1,302 @@
"use client";
import {
Mail,
MessageSquare,
PhoneCall,
Train,
MapPin,
CheckCircle,
AlertCircle,
} from "lucide-react";
import { Spinner } from "@/components/atoms";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { CONTACT_SUBJECTS, type FormErrors } from "@/features/landing-page/data";
import { useInView, useContactForm } from "@/features/landing-page/hooks";
type ContactFormReturn = ReturnType<typeof useContactForm>;
interface ContactSectionProps {
formData: ContactFormReturn["formData"];
formErrors: ContactFormReturn["formErrors"];
formTouched: ContactFormReturn["formTouched"];
isSubmitting: ContactFormReturn["isSubmitting"];
submitStatus: ContactFormReturn["submitStatus"];
handleInputChange: ContactFormReturn["handleInputChange"];
handleInputBlur: ContactFormReturn["handleInputBlur"];
handleSubmit: ContactFormReturn["handleSubmit"];
}
function getInputClassName(
fieldName: keyof FormErrors,
formTouched: ContactSectionProps["formTouched"],
formErrors: ContactSectionProps["formErrors"]
) {
const baseClass =
"w-full rounded-md border px-4 py-3 text-sm focus-visible:outline-none focus-visible:ring-2 bg-card 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"
}`;
}
export function ContactSection({
formData,
formErrors,
formTouched,
isSubmitting,
submitStatus,
handleInputChange,
handleInputBlur,
handleSubmit,
}: ContactSectionProps) {
const [contactRef, contactInView] = useInView();
return (
<section
ref={contactRef as React.RefObject<HTMLElement>}
className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-primary-soft py-14 sm:py-16 transition-all duration-700 ${
contactInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
}`}
>
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 space-y-6">
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground">CONTACT US</h2>
<div className="rounded-3xl bg-card border border-primary/20 shadow-sm p-6 sm:p-8">
<div className="grid grid-cols-1 lg:grid-cols-[1.1fr_0.9fr] gap-10 lg:gap-12">
<div className="space-y-5">
<div className="flex items-center gap-2 text-primary font-bold text-lg">
<Mail className="h-5 w-5" />
<span>By Online Form (Anytime)</span>
</div>
{submitStatus === "success" && (
<AlertBanner variant="success" title="Message sent successfully!">
Thank you for contacting us. We&apos;ll get back to you within 24 hours.
</AlertBanner>
)}
{submitStatus === "error" && (
<AlertBanner variant="error" title="Failed to send message">
Please try again or contact us directly by phone.
</AlertBanner>
)}
<form className="space-y-4" onSubmit={handleSubmit} noValidate>
{/* Subject Dropdown */}
<div className="space-y-1">
<label htmlFor="contact-subject" className="sr-only">
Subject
</label>
<select
id="contact-subject"
name="subject"
value={formData.subject}
onChange={handleInputChange}
onBlur={handleInputBlur}
aria-required="true"
aria-invalid={formTouched.subject && !!formErrors.subject}
aria-describedby={formErrors.subject ? "subject-error" : undefined}
className={getInputClassName("subject", formTouched, formErrors)}
>
{CONTACT_SUBJECTS.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{formTouched.subject && formErrors.subject && (
<p id="subject-error" className="text-sm text-danger flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{formErrors.subject}
</p>
)}
</div>
{/* Name Input */}
<div className="space-y-1">
<label htmlFor="contact-name" className="sr-only">
Name
</label>
<input
id="contact-name"
type="text"
name="name"
placeholder="Name*"
value={formData.name}
onChange={handleInputChange}
onBlur={handleInputBlur}
aria-required="true"
aria-invalid={formTouched.name && !!formErrors.name}
aria-describedby={formErrors.name ? "name-error" : undefined}
className={getInputClassName("name", formTouched, formErrors)}
/>
{formTouched.name && formErrors.name && (
<p id="name-error" className="text-sm text-danger flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{formErrors.name}
</p>
)}
</div>
{/* Email Input */}
<div className="space-y-1">
<label htmlFor="contact-email" className="sr-only">
Email
</label>
<input
id="contact-email"
type="email"
name="email"
placeholder="Email*"
value={formData.email}
onChange={handleInputChange}
onBlur={handleInputBlur}
aria-required="true"
aria-invalid={formTouched.email && !!formErrors.email}
aria-describedby={formErrors.email ? "email-error" : undefined}
className={getInputClassName("email", formTouched, formErrors)}
/>
{formTouched.email && formErrors.email && (
<p id="email-error" className="text-sm text-danger flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{formErrors.email}
</p>
)}
</div>
{/* Phone Input (optional) */}
<div className="space-y-1">
<label htmlFor="contact-phone" className="sr-only">
Phone (optional)
</label>
<input
id="contact-phone"
type="tel"
name="phone"
placeholder="Phone (optional)"
value={formData.phone}
onChange={handleInputChange}
className="w-full rounded-md border border-border px-4 py-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 bg-card"
/>
</div>
{/* Message Textarea */}
<div className="space-y-1">
<label htmlFor="contact-message" className="sr-only">
Inquiry
</label>
<textarea
id="contact-message"
name="message"
placeholder="Inquiry*"
rows={4}
value={formData.message}
onChange={handleInputChange}
onBlur={handleInputBlur}
aria-required="true"
aria-invalid={formTouched.message && !!formErrors.message}
aria-describedby={formErrors.message ? "message-error" : undefined}
className={`${getInputClassName("message", formTouched, formErrors)} resize-none`}
/>
{formTouched.message && formErrors.message && (
<p id="message-error" className="text-sm text-danger flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{formErrors.message}
</p>
)}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={isSubmitting}
className="w-full inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-3 text-sm font-semibold text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<>
<Spinner size="sm" />
Sending...
</>
) : submitStatus === "success" ? (
<>
<CheckCircle className="h-4 w-4" />
Sent!
</>
) : (
"Submit"
)}
</button>
</form>
<div className="flex flex-col gap-3">
<div className="inline-flex items-center gap-2 text-primary font-semibold">
<MessageSquare className="h-5 w-5" />
<span>By Chat (Anytime)</span>
</div>
<p className="text-sm text-foreground">
Click the bottom right &ldquo;Chat Button&rdquo; to reach our team anytime.
</p>
<div className="inline-flex items-center gap-2 text-primary font-semibold">
<PhoneCall className="h-5 w-5" />
<span>By Phone (9:30-18:00 JST)</span>
</div>
<div className="text-sm text-foreground">
<p className="font-semibold">Toll Free within Japan</p>
<p className="text-lg font-bold text-primary">0120-660-470</p>
<p className="font-semibold mt-1">From Overseas (may incur calling rates)</p>
<p className="text-lg font-bold text-primary">+81-3-3560-1006</p>
</div>
</div>
</div>
<div className="space-y-6">
<div className="w-full rounded-2xl overflow-hidden shadow-md border border-border/60 bg-card aspect-[4/3]">
<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-full"
loading="lazy"
allowFullScreen
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded-2xl bg-card shadow-sm border border-border/60 p-5 space-y-2">
<div className="inline-flex items-center gap-2 text-primary font-semibold">
<Train className="h-5 w-5" />
<span>Access</span>
</div>
<p className="text-sm text-foreground">
Subway Oedo Line / Nanboku Line
<br />
Short distance walk from exit 6 of Azabu-Juban Station
<br />
(1F of our building is Domino&apos;s Pizza)
</p>
</div>
<div className="rounded-2xl bg-card shadow-sm border border-border/60 p-5 space-y-2">
<div className="inline-flex items-center gap-2 text-primary font-semibold">
<MapPin className="h-5 w-5" />
<span>Address</span>
</div>
<p className="text-sm text-foreground">
3F Azabu Maruka Bldg.,
<br />
3-8-2 Higashi Azabu, Minato-ku,
<br />
Tokyo 106-0044
<br />
Tel: 03-3560-1006 Fax: 03-3560-1007
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,186 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { ArrowRight } from "lucide-react";
import {
personalServices,
businessServices,
mobileQuickServices,
type ServiceCategory,
} from "@/features/landing-page/data";
import { useInView } from "@/features/landing-page/hooks";
interface HeroSectionProps {
heroCTARef: React.RefObject<HTMLDivElement | null>;
}
export function HeroSection({ heroCTARef }: HeroSectionProps) {
const [heroRef, heroInView] = useInView();
const [activeCategory, setActiveCategory] = useState<ServiceCategory>("personal");
return (
<section
ref={heroRef as React.RefObject<HTMLElement>}
className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 py-12 sm:py-16 overflow-hidden transition-all duration-700 ${
heroInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
}`}
>
{/* Gradient Background */}
<div className="absolute inset-0 bg-gradient-to-br from-surface-sunken via-background to-info-bg/80" />
{/* Dot Grid Pattern Overlay */}
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage: `radial-gradient(circle at center, oklch(0.65 0.05 234.4 / 0.15) 1px, transparent 1px)`,
backgroundSize: "24px 24px",
}}
/>
{/* Subtle gradient accent in corner */}
<div
className="absolute -top-32 -right-32 w-96 h-96 rounded-full pointer-events-none"
style={{
background: "radial-gradient(circle, oklch(0.85 0.08 200 / 0.3) 0%, transparent 70%)",
}}
/>
<div className="relative mx-auto max-w-6xl grid grid-cols-1 lg:grid-cols-[1.05fr_minmax(0,0.95fr)] items-center gap-10 lg:gap-16 px-6 sm:px-10 lg:px-14">
<div className="space-y-6 text-left max-w-2xl">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold leading-tight text-foreground">
<span className="block whitespace-nowrap">A One Stop Solution</span>
<span className="block text-primary mt-2 whitespace-nowrap">for Your IT Needs</span>
</h1>
<p className="text-base sm:text-lg text-muted-foreground leading-relaxed font-semibold">
No Japanese required. Get reliable internet, mobile, and VPN services with full English
support. Serving expats and international businesses for over 20 years.
</p>
<div
ref={heroCTARef}
className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 pt-2"
>
<Link
href="/services"
className="inline-flex items-center justify-center gap-2 rounded-full px-6 sm:px-8 py-3 text-base font-semibold bg-primary text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 transition-transform hover:-translate-y-0.5"
>
Browse Services
<ArrowRight className="h-5 w-5" />
</Link>
<Link
href="/contact"
className="inline-flex items-center justify-center gap-2 rounded-full px-6 sm:px-8 py-3 text-base font-semibold border border-border bg-card text-primary hover:bg-primary/5 transition-colors"
>
Need Assistance?
</Link>
</div>
</div>
{/* Mobile Quick Services - visible on mobile only */}
<div className="lg:hidden w-full">
<div
className="flex gap-3 overflow-x-auto pb-4 snap-x snap-mandatory"
style={{ scrollbarWidth: "none" }}
>
{mobileQuickServices.map(service => (
<Link
key={service.title}
href={service.href}
className="flex-shrink-0 w-[140px] snap-start group"
>
<div className="flex flex-col items-center gap-2 p-4 rounded-xl border border-border/70 bg-card shadow-sm hover:shadow-md transition-shadow">
<div className="w-12 h-12 rounded-full border-2 border-primary/30 flex items-center justify-center group-hover:border-primary/60 transition-colors">
<div className="[&>svg]:h-6 [&>svg]:w-6">{service.icon}</div>
</div>
<span className="text-xs font-semibold text-foreground text-center">
{service.title}
</span>
</div>
</Link>
))}
<Link href="/services" className="flex-shrink-0 w-[140px] snap-start group">
<div className="flex flex-col items-center justify-center gap-2 p-4 rounded-xl border border-dashed border-primary/40 bg-primary/5 h-full min-h-[120px] hover:bg-primary/10 transition-colors">
<ArrowRight className="h-6 w-6 text-primary" />
<span className="text-xs font-semibold text-primary text-center">
See All Services
</span>
</div>
</Link>
</div>
</div>
{/* Desktop Services Panel - hidden on mobile */}
<div className="hidden lg:block relative w-full">
<div className="rounded-2xl border border-border/70 bg-card shadow-lg p-6">
{/* Tab Switcher */}
<div className="flex mb-6 bg-muted rounded-full p-1">
<button
type="button"
onClick={() => setActiveCategory("personal")}
className={`flex-1 py-2.5 px-4 text-sm font-semibold rounded-full transition-all ${
activeCategory === "personal"
? "bg-foreground text-background shadow-md"
: "text-muted-foreground hover:text-foreground"
}`}
>
Personal Services
</button>
<button
type="button"
onClick={() => setActiveCategory("business")}
className={`flex-1 py-2.5 px-4 text-sm font-semibold rounded-full transition-all ${
activeCategory === "business"
? "bg-foreground text-background shadow-md"
: "text-muted-foreground hover:text-foreground"
}`}
>
Business Services
</button>
</div>
{/* Services Grid */}
<div className="grid grid-cols-2 gap-3 h-[320px] content-start overflow-hidden">
{(activeCategory === "personal" ? personalServices : businessServices).map(
service => (
<Link
key={service.title}
href={service.href}
className={`group flex flex-col items-center justify-center gap-2 p-3 rounded-xl hover:bg-muted/50 transition-colors ${
activeCategory === "personal" ? "h-[152px]" : "h-[100px]"
}`}
>
<div
className={`rounded-full border-2 border-primary/30 flex items-center justify-center group-hover:border-primary/60 transition-colors flex-shrink-0 ${
activeCategory === "personal" ? "w-20 h-20" : "w-14 h-14"
}`}
>
<div
className={
activeCategory === "personal"
? "[&>svg]:h-10 [&>svg]:w-10"
: "[&>svg]:h-7 [&>svg]:w-7"
}
>
{service.icon}
</div>
</div>
<span
className={`font-semibold text-foreground text-center leading-tight ${
activeCategory === "personal" ? "text-sm" : "text-xs"
}`}
>
{service.title}
</span>
</Link>
)
)}
</div>
</div>
</div>
</div>
{/* Gradient fade to next section */}
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-background pointer-events-none" />
</section>
);
}

View File

@ -0,0 +1,122 @@
"use client";
import Link from "next/link";
import { ArrowRight, Check, Wifi, Smartphone } from "lucide-react";
import { useInView } from "@/features/landing-page/hooks";
export function PopularServicesSection() {
const [popularRef, popularInView] = useInView();
return (
<section
ref={popularRef as React.RefObject<HTMLElement>}
className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-background py-14 sm:py-16 transition-all duration-700 ${
popularInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
}`}
>
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14">
{/* Section Header */}
<div className="text-center mb-10 sm:mb-12">
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground mb-4">
Most Popular Services
</h2>
<p className="text-base sm:text-lg text-muted-foreground max-w-2xl mx-auto">
Get connected with ease. No Japanese required, no complicated paperwork, and full
English support every step of the way.
</p>
</div>
{/* Two-column card layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
{/* Internet Plans Card */}
<div className="relative rounded-2xl border border-info-border bg-card shadow-lg p-6 sm:p-8 flex flex-col">
<div className="absolute top-4 right-4 sm:top-6 sm:right-6">
<span className="inline-flex items-center rounded-full bg-primary px-3 py-1 text-xs font-bold text-primary-foreground uppercase tracking-wide">
Popular
</span>
</div>
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-info-soft flex items-center justify-center mb-5">
<Wifi className="h-8 w-8 sm:h-10 sm:w-10 text-primary" />
</div>
<h3 className="text-xl sm:text-2xl font-bold text-foreground mb-3">Internet Plans</h3>
<p className="text-muted-foreground mb-5 leading-relaxed">
High-speed fiber internet on Japan&apos;s reliable NTT network. We handle all the
Japanese paperwork and coordinate installation in English.
</p>
<ul className="space-y-3 mb-6 flex-grow">
{[
"Speeds up to 10 Gbps available",
"English installation coordination",
"No Japanese contracts to sign",
"Foreign credit cards accepted",
].map(feature => (
<li key={feature} className="flex items-start gap-3">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center mt-0.5">
<Check className="h-3 w-3 text-primary" />
</span>
<span className="text-sm text-foreground">{feature}</span>
</li>
))}
</ul>
<Link
href="/services/internet"
className="w-full inline-flex items-center justify-center gap-2 rounded-full bg-primary px-6 py-3.5 text-base font-semibold text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 transition-all hover:-translate-y-0.5"
>
View Plans
<ArrowRight className="h-5 w-5" />
</Link>
</div>
{/* Phone Plans Card */}
<div className="relative rounded-2xl border border-info-border bg-card shadow-lg p-6 sm:p-8 flex flex-col">
<div className="absolute top-4 right-4 sm:top-6 sm:right-6">
<span className="inline-flex items-center rounded-full bg-primary px-3 py-1 text-xs font-bold text-primary-foreground uppercase tracking-wide">
Popular
</span>
</div>
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-info-soft flex items-center justify-center mb-5">
<Smartphone className="h-8 w-8 sm:h-10 sm:w-10 text-primary" />
</div>
<h3 className="text-xl sm:text-2xl font-bold text-foreground mb-3">Phone Plans</h3>
<p className="text-muted-foreground mb-5 leading-relaxed">
Mobile SIM cards with voice and data on Japan&apos;s top network. Sign up online, no
hanko needed, and get your SIM delivered fast.
</p>
<ul className="space-y-3 mb-6 flex-grow">
{[
"Data-only and voice + data options",
"Keep your number with MNP transfer",
"eSIM available for instant activation",
"Flexible plans with no long-term contracts",
].map(feature => (
<li key={feature} className="flex items-start gap-3">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center mt-0.5">
<Check className="h-3 w-3 text-primary" />
</span>
<span className="text-sm text-foreground">{feature}</span>
</li>
))}
</ul>
<Link
href="/services/sim"
className="w-full inline-flex items-center justify-center gap-2 rounded-full bg-primary px-6 py-3.5 text-base font-semibold text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 transition-all hover:-translate-y-0.5"
>
View Plans
<ArrowRight className="h-5 w-5" />
</Link>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,192 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { ArrowRight } from "lucide-react";
import { supportDownloads } from "@/features/landing-page/data";
import { useInView } from "@/features/landing-page/hooks";
export function RemoteSupportSection() {
const [supportRef, supportInView] = useInView();
const [remoteSupportTab, setRemoteSupportTab] = useState(0);
return (
<section
ref={supportRef as React.RefObject<HTMLElement>}
className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-background py-12 sm:py-16 transition-all duration-700 overflow-hidden ${
supportInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
}`}
>
{/* Gradient fade to next section */}
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-primary-soft pointer-events-none" />
<div className="relative max-w-6xl mx-auto px-6 sm:px-10 lg:px-14">
{/* Section header */}
<div className="flex items-center gap-4 mb-8">
<div className="relative">
<h2 className="text-3xl sm:text-4xl font-extrabold text-primary tracking-tight">
Remote Support
</h2>
<p className="text-sm sm:text-base text-muted-foreground mt-2 max-w-md">
Download one of our secure tools and let our technicians help you remotely
</p>
</div>
</div>
{/* Mobile: Stacked Cards Layout */}
<div className="md:hidden space-y-4">
{supportDownloads.map(item => (
<div
key={item.title}
className="group relative bg-card rounded-2xl border border-border/60 shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden"
>
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-sky-400 to-cyan-500" />
<div className="p-5">
<div className="flex items-start gap-4">
<div className="relative flex-shrink-0">
<div className="w-16 h-16 rounded-xl flex items-center justify-center bg-gradient-to-br from-sky-50 to-cyan-100">
<Image
src={item.image}
alt={item.title}
width={48}
height={48}
className="object-contain w-10 h-10"
/>
</div>
<span className="absolute -top-1 -right-1 w-3 h-3 rounded-full bg-sky-400 animate-pulse" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold text-foreground mb-1">{item.title}</h3>
<p className="text-xs font-semibold uppercase tracking-wide mb-2 text-info">
{item.useCase.replace("Best for: ", "")}
</p>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
{item.description}
</p>
</div>
</div>
<Link
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="mt-4 flex items-center justify-center gap-2 w-full rounded-xl py-3.5 text-sm font-semibold text-white transition-all duration-200 active:scale-[0.98] bg-gradient-to-r from-sky-500 to-cyan-600 hover:from-sky-600 hover:to-cyan-700"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Download {item.title.split(" ")[0]}
</Link>
</div>
</div>
))}
</div>
{/* Desktop: Tabbed Layout */}
<div className="hidden md:block">
<div className="bg-card rounded-3xl border border-border/60 shadow-lg overflow-hidden">
<div className="flex border-b border-border/40">
{supportDownloads.map((item, index) => (
<button
key={item.title}
type="button"
onClick={() => setRemoteSupportTab(index)}
className={`flex-1 flex items-center justify-center gap-3 py-4 px-6 text-base font-medium transition-all duration-200 relative ${
remoteSupportTab === index
? "text-primary bg-primary/5"
: "text-muted-foreground hover:text-foreground hover:bg-muted/30"
}`}
aria-pressed={remoteSupportTab === index}
>
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors ${
remoteSupportTab === index ? "bg-info-soft" : "bg-muted/50"
}`}
>
<Image
src={item.image}
alt=""
width={28}
height={28}
className="object-contain"
/>
</div>
<span>{item.title}</span>
{remoteSupportTab === index && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gradient-to-r from-sky-400 to-cyan-500" />
)}
</button>
))}
</div>
{(() => {
const currentDownload = supportDownloads[remoteSupportTab];
if (!currentDownload) return null;
return (
<div className="p-8 lg:p-12">
<div className="flex items-start gap-8 lg:gap-12">
<div className="h-32 w-32 lg:h-40 lg:w-40 rounded-2xl flex items-center justify-center flex-shrink-0 transition-colors bg-gradient-to-br from-sky-50 to-cyan-100">
<Image
src={currentDownload.image}
alt={currentDownload.title}
width={96}
height={96}
className="object-contain w-20 h-20 lg:w-24 lg:h-24"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold uppercase tracking-wide mb-2 text-info">
{currentDownload.useCase}
</p>
<h3 className="text-2xl lg:text-3xl font-bold text-foreground mb-4">
{currentDownload.title}
</h3>
<p className="text-base lg:text-lg text-muted-foreground leading-relaxed mb-6">
{currentDownload.description}
</p>
<Link
href={currentDownload.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full px-8 py-3.5 text-base font-semibold text-white transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg bg-gradient-to-r from-sky-500 to-cyan-600 hover:from-sky-600 hover:to-cyan-700 shadow-sky-200"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Download Now
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
</div>
);
})()}
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,281 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { services } from "@/features/landing-page/data";
import { useInView } from "@/features/landing-page/hooks";
export function SolutionsCarousel() {
const [solutionsRef, solutionsInView] = useInView();
const carouselRef = useRef<HTMLDivElement>(null);
const itemWidthRef = useRef(0);
const [currentSlide, setCurrentSlide] = useState(0);
const isScrollingRef = useRef(false);
const autoScrollTimerRef = useRef<NodeJS.Timeout | null>(null);
const touchStartXRef = useRef(0);
const touchEndXRef = useRef(0);
const [pageCount, setPageCount] = useState(services.length);
const computeItemWidth = useCallback(() => {
const container = carouselRef.current;
if (!container) return;
const card = container.querySelector<HTMLElement>("[data-service-card]");
if (!card) return;
const gap =
Number.parseFloat(getComputedStyle(container).columnGap || "0") ||
Number.parseFloat(getComputedStyle(container).gap || "0") ||
24;
itemWidthRef.current = card.getBoundingClientRect().width + gap;
// Calculate page count based on how many cards are visible at once
const containerWidth = container.clientWidth;
const visibleCards = Math.max(1, Math.floor(containerWidth / itemWidthRef.current));
setPageCount(Math.max(1, services.length - visibleCards + 1));
}, []);
const scrollByOne = useCallback((direction: 1 | -1) => {
const container = carouselRef.current;
if (!container || !itemWidthRef.current || isScrollingRef.current) return;
isScrollingRef.current = true;
container.scrollBy({
left: direction * itemWidthRef.current,
behavior: "smooth",
});
setTimeout(() => {
isScrollingRef.current = false;
}, 500);
}, []);
const 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);
}
autoScrollTimerRef.current = setInterval(() => {
scrollByOne(1);
}, 5000);
}, [scrollByOne]);
const stopAutoScroll = useCallback(() => {
if (autoScrollTimerRef.current) {
clearInterval(autoScrollTimerRef.current);
autoScrollTimerRef.current = null;
}
}, []);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) {
touchStartXRef.current = touch.clientX;
touchEndXRef.current = touch.clientX;
}
}, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) {
touchEndXRef.current = touch.clientX;
}
}, []);
const handleTouchEnd = useCallback(() => {
const diff = touchStartXRef.current - touchEndXRef.current;
const minSwipeDistance = 50;
if (Math.abs(diff) > minSwipeDistance) {
if (diff > 0) {
scrollByOne(1);
} else {
scrollByOne(-1);
}
}
startAutoScroll();
}, [scrollByOne, startAutoScroll]);
useEffect(() => {
computeItemWidth();
const handleResize = () => computeItemWidth();
window.addEventListener("resize", handleResize);
startAutoScroll();
return () => {
window.removeEventListener("resize", handleResize);
stopAutoScroll();
};
}, [computeItemWidth, startAutoScroll, stopAutoScroll]);
useEffect(() => {
const container = carouselRef.current;
if (!container) return;
const handleScroll = () => {
updateCurrentSlide();
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
}, [updateCurrentSlide]);
const handleMouseEnter = useCallback(() => {
stopAutoScroll();
}, [stopAutoScroll]);
const handleMouseLeave = useCallback(() => {
startAutoScroll();
}, [startAutoScroll]);
const handlePrevClick = useCallback(() => {
scrollByOne(-1);
startAutoScroll();
}, [scrollByOne, startAutoScroll]);
const handleNextClick = useCallback(() => {
scrollByOne(1);
startAutoScroll();
}, [scrollByOne, startAutoScroll]);
return (
<section
ref={solutionsRef as React.RefObject<HTMLElement>}
className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-primary-soft py-12 sm:py-14 transition-all duration-700 ${
solutionsInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
}`}
>
{/* Gradient fade to next section */}
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-background pointer-events-none" />
<div className="mx-auto max-w-6xl px-6 sm:px-10 lg:px-14">
{/* Header row with title + nav arrows */}
<div className="flex items-center justify-between mb-8">
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground">Solutions</h2>
{/* Desktop navigation arrows — inline with the heading */}
<div className="hidden sm:flex items-center gap-2">
<button
type="button"
aria-label="Previous solution"
onClick={handlePrevClick}
className="h-10 w-10 rounded-full border border-border/80 bg-card text-foreground shadow-sm hover:bg-muted transition-colors active:scale-95 flex items-center justify-center"
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
aria-label="Next solution"
onClick={handleNextClick}
className="h-10 w-10 rounded-full border border-border/80 bg-card text-foreground shadow-sm hover:bg-muted transition-colors active:scale-95 flex items-center justify-center"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
</div>
{/* Carousel track */}
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<div
ref={carouselRef}
className="flex gap-6 overflow-x-auto scroll-smooth snap-x snap-mandatory"
style={{ scrollbarWidth: "none", WebkitOverflowScrolling: "touch" }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{services.map((service, index) => (
<Link
key={`${service.title}-${index}`}
href={service.href}
className="group flex-shrink-0 w-[280px] sm:w-[300px] snap-start"
>
<article
data-service-card
className="h-full rounded-2xl bg-card px-6 py-8 shadow-md border border-border/60 transition-all duration-300 group-hover:-translate-y-1 group-hover:shadow-lg flex flex-col"
>
<div className="mb-5 w-14 h-14 rounded-xl bg-primary/8 flex items-center justify-center">
{service.icon}
</div>
<h3 className="text-lg font-bold text-foreground mb-2 font-display">
{service.title}
</h3>
<p className="text-sm text-muted-foreground leading-relaxed flex-grow">
{service.description}
</p>
</article>
</Link>
))}
</div>
</div>
{/* Progress dots + mobile arrows */}
<div className="flex items-center justify-center gap-4 mt-6">
{/* Mobile-only prev arrow */}
<button
type="button"
aria-label="Previous solution"
onClick={handlePrevClick}
className="sm:hidden h-9 w-9 rounded-full border border-border/80 bg-card text-foreground shadow-sm hover:bg-muted transition-colors active:scale-95 flex items-center justify-center flex-shrink-0"
>
<ChevronLeft className="h-4 w-4" />
</button>
{/* Dots — one per scroll page, not per card */}
<div className="flex items-center gap-2">
{(() => {
const activeDot = Math.min(currentSlide, pageCount - 1);
return Array.from({ length: pageCount }).map((_, index) => (
<button
key={index}
className={`h-2 rounded-full transition-all duration-300 ${
activeDot === index
? "bg-primary w-8"
: "bg-foreground/20 w-2 hover:bg-foreground/40"
}`}
onClick={() => scrollToIndex(index)}
aria-label={`Go to page ${index + 1}`}
/>
));
})()}
</div>
{/* Mobile-only next arrow */}
<button
type="button"
aria-label="Next solution"
onClick={handleNextClick}
className="sm:hidden h-9 w-9 rounded-full border border-border/80 bg-card text-foreground shadow-sm hover:bg-muted transition-colors active:scale-95 flex items-center justify-center flex-shrink-0"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,68 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { ArrowRight, BadgeCheck } from "lucide-react";
import { useInView } from "@/features/landing-page/hooks";
export function TrustSection() {
const [trustRef, trustInView] = useInView();
return (
<section
ref={trustRef as React.RefObject<HTMLElement>}
className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-background transition-all duration-700 ${
trustInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
}`}
>
{/* Gradient fade to next section */}
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-primary-soft pointer-events-none" />
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 py-14 sm:py-16">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1.1fr] gap-10 lg:gap-14 items-center">
<div className="relative w-full overflow-hidden rounded-2xl shadow-md border border-border/60 bg-card aspect-[4/5]">
<Image
src="/assets/images/Why_us.png"
alt="Team collaborating with trust and excellence"
fill
priority
className="object-cover"
sizes="(max-width: 1024px) 100vw, 40vw"
/>
</div>
<div className="space-y-6">
<div>
<h2 className="text-2xl sm:text-3xl font-extrabold text-primary uppercase tracking-wide mb-3">
Built on Trust and Excellence
</h2>
<p className="text-xl sm:text-2xl font-semibold text-foreground leading-relaxed">
For over two decades, we&apos;ve been helping foreigners, expats, and international
businesses in Japan navigate the tech landscape with confidence.
</p>
</div>
<ul className="space-y-3 text-foreground">
{[
"Full English support, no Japanese needed",
"Foreign credit cards accepted",
"Bilingual contracts and documentation",
].map(item => (
<li key={item} className="flex items-center gap-3 text-base font-semibold">
<BadgeCheck className="h-5 w-5 text-primary" />
<span>{item}</span>
</li>
))}
</ul>
<Link
href="/about"
className="inline-flex items-center gap-2 text-primary font-semibold hover:text-primary/80 transition-colors"
>
About our company
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
</div>
</section>
);
}

View File

@ -1,3 +1,11 @@
// Landing page sections
export { HeroSection } from "./HeroSection";
export { TrustSection } from "./TrustSection";
export { SolutionsCarousel } from "./SolutionsCarousel";
export { PopularServicesSection } from "./PopularServicesSection";
export { RemoteSupportSection } from "./RemoteSupportSection";
export { ContactSection } from "./ContactSection";
// Trust indicators
export { TrustBadge } from "./TrustBadge";
export { TrustIndicators } from "./TrustIndicators";

View File

@ -0,0 +1,72 @@
// =============================================================================
// TYPES
// =============================================================================
export interface FormData {
subject: string;
name: string;
email: string;
phone: string;
message: string;
}
export interface FormErrors {
subject?: string;
name?: string;
email?: string;
message?: string;
}
export interface FormTouched {
subject?: boolean;
name?: boolean;
email?: boolean;
phone?: boolean;
message?: boolean;
}
// =============================================================================
// DATA
// =============================================================================
export const CONTACT_SUBJECTS = [
{ value: "", label: "Select a topic*" },
{ value: "internet", label: "Internet Service Inquiry" },
{ value: "sim", label: "Phone/SIM Plan Inquiry" },
{ value: "vpn", label: "VPN Service Inquiry" },
{ value: "business", label: "Business Solutions" },
{ value: "support", label: "Technical Support" },
{ value: "billing", label: "Billing Question" },
{ value: "other", label: "Other" },
];
// =============================================================================
// FORM VALIDATION
// =============================================================================
export function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export function validateForm(data: FormData): FormErrors {
const errors: FormErrors = {};
if (!data.subject) {
errors.subject = "Please select a topic";
}
if (!data.name.trim()) {
errors.name = "Name is required";
}
if (!data.email.trim()) {
errors.email = "Email is required";
} else if (!validateEmail(data.email)) {
errors.email = "Please enter a valid email address";
}
if (!data.message.trim()) {
errors.message = "Message is required";
} else if (data.message.trim().length < 10) {
errors.message = "Message must be at least 10 characters";
}
return errors;
}

View File

@ -0,0 +1,18 @@
export {
type ServiceCategory,
type ServiceItem,
personalServices,
businessServices,
services,
supportDownloads,
mobileQuickServices,
} from "./services";
export {
type FormData,
type FormErrors,
type FormTouched,
CONTACT_SUBJECTS,
validateEmail,
validateForm,
} from "./contact-subjects";

View File

@ -0,0 +1,139 @@
import {
Building2,
Code,
Lock,
Server,
Settings,
Shield,
Smartphone,
Wifi,
Wrench,
} from "lucide-react";
// =============================================================================
// TYPES
// =============================================================================
export type ServiceCategory = "personal" | "business";
export interface ServiceItem {
title: string;
icon: React.ReactNode;
href: string;
}
// =============================================================================
// SERVICE DATA
// =============================================================================
export const personalServices: ServiceItem[] = [
{
title: "Internet Plan",
icon: <Wifi className="h-8 w-8 text-primary" />,
href: "/services/internet",
},
{
title: "Phone Plan",
icon: <Smartphone className="h-8 w-8 text-primary" />,
href: "/services/sim",
},
{ title: "VPN Service", icon: <Lock className="h-8 w-8 text-primary" />, href: "/services/vpn" },
{
title: "Onsite Support",
icon: <Wrench className="h-8 w-8 text-primary" />,
href: "/services/onsite",
},
];
export const businessServices: ServiceItem[] = [
{
title: "Office LAN Setup",
icon: <Server className="h-8 w-8 text-primary" />,
href: "/services/business",
},
{
title: "Onsite & Remote Tech Support",
icon: <Wrench className="h-8 w-8 text-primary" />,
href: "/services/onsite",
},
{
title: "Dedicated Internet Access",
icon: <Building2 className="h-8 w-8 text-primary" />,
href: "/services/business",
},
{
title: "Data Center Service",
icon: <Shield className="h-8 w-8 text-primary" />,
href: "/services/business",
},
{
title: "Website Construction",
icon: <Code className="h-8 w-8 text-primary" />,
href: "/services/business",
},
{
title: "Website Maintenance",
icon: <Settings className="h-8 w-8 text-primary" />,
href: "/services/business",
},
];
export const services = [
{
title: "Internet Plans",
description:
"High-speed NTT fiber with English installation support. No Japanese paperwork, we handle everything for you.",
icon: <Wifi className="h-8 w-8 text-primary" />,
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: <Smartphone className="h-8 w-8 text-primary" />,
href: "/services/sim",
},
{
title: "Business Solutions",
description:
"Enterprise IT for international companies. Dedicated internet, office networks, and bilingual tech support.",
icon: <Building2 className="h-8 w-8 text-primary" />,
href: "/services/business",
},
{
title: "VPN",
description:
"Stream your favorite shows from home. Pre-configured router, just plug in and watch US/UK content.",
icon: <Lock className="h-8 w-8 text-primary" />,
href: "/services/vpn",
},
{
title: "Onsite Support",
description:
"English-speaking technicians at your home or office. Router setup, network issues, and device help.",
icon: <Wrench className="h-8 w-8 text-primary" />,
href: "/services/onsite",
},
];
export 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)
export const mobileQuickServices = personalServices.slice(0, 3);

View File

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

View File

@ -0,0 +1,136 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { FormData, FormErrors, FormTouched } from "@/features/landing-page/data";
import { validateForm } from "@/features/landing-page/data";
/**
* useContactForm - Manages contact form state, validation, and submission.
*
* Encapsulates:
* - Form field values (formData)
* - Validation errors (formErrors)
* - Touch tracking for blur-based validation (formTouched)
* - Submission state (isSubmitting, submitStatus)
* - Sticky CTA visibility (showStickyCTA) tied to hero CTA intersection
* - Input change, blur, and submit handlers
*/
export function useContactForm() {
// Form state
const [formData, setFormData] = useState<FormData>({
subject: "",
name: "",
email: "",
phone: "",
message: "",
});
const [formErrors, setFormErrors] = useState<FormErrors>({});
const [formTouched, setFormTouched] = useState<FormTouched>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle");
// Hero CTA visibility for sticky mobile CTA
const heroCTARef = useRef<HTMLDivElement>(null);
const [showStickyCTA, setShowStickyCTA] = useState(false);
// ---------------------------------------------------------------------------
// Sticky CTA observer
// ---------------------------------------------------------------------------
useEffect(() => {
const ctaElement = heroCTARef.current;
if (!ctaElement) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry) {
setShowStickyCTA(!entry.isIntersecting);
}
},
{ threshold: 0 }
);
observer.observe(ctaElement);
return () => observer.disconnect();
}, []);
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error when user starts typing
if (formErrors[name as keyof FormErrors]) {
setFormErrors(prev => ({ ...prev, [name]: undefined }));
}
},
[formErrors]
);
const handleInputBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name } = e.target;
setFormTouched(prev => ({ ...prev, [name]: true }));
// Validate single field on blur
const errors = validateForm(formData);
if (errors[name as keyof FormErrors]) {
setFormErrors(prev => ({ ...prev, [name]: errors[name as keyof FormErrors] }));
}
},
[formData]
);
const handleSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Validate all fields
const errors = validateForm(formData);
setFormErrors(errors);
setFormTouched({ subject: true, name: true, email: true, message: true });
if (Object.keys(errors).length > 0) {
return;
}
setIsSubmitting(true);
setSubmitStatus("idle");
try {
// Simulate API call - replace with actual endpoint
await new Promise(resolve => {
setTimeout(resolve, 1500);
});
// Success
setSubmitStatus("success");
setFormData({ subject: "", name: "", email: "", phone: "", message: "" });
setFormTouched({});
setFormErrors({});
// Reset success message after 5 seconds
setTimeout(() => setSubmitStatus("idle"), 5000);
} catch {
setSubmitStatus("error");
} finally {
setIsSubmitting(false);
}
},
[formData]
);
return {
formData,
formErrors,
formTouched,
isSubmitting,
submitStatus,
showStickyCTA,
heroCTARef,
handleInputChange,
handleInputBlur,
handleSubmit,
};
}

View File

@ -0,0 +1,31 @@
import { useEffect, useRef, useState } from "react";
/**
* useInView - Intersection Observer hook for scroll-triggered animations
* Returns a ref and boolean indicating if element is in viewport.
* Once the element becomes visible, it stays marked as "in view" (trigger once).
*/
export function useInView(options: IntersectionObserverInit = {}) {
const ref = useRef<HTMLElement>(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;
}

View File

@ -4,7 +4,7 @@ export function PublicLandingLoadingView() {
return (
<div className="space-y-0 pb-8 pt-0 sm:pt-0">
{/* Hero Section Skeleton */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-[#f7f7f7] py-12 sm:py-16">
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-surface-sunken py-12 sm:py-16">
<div className="mx-auto max-w-6xl grid grid-cols-1 lg:grid-cols-[1.05fr_minmax(0,0.95fr)] items-center gap-10 lg:gap-16 px-6 sm:px-10 lg:px-14 pt-0">
<div className="space-y-4 max-w-2xl">
<div className="space-y-2">
@ -27,7 +27,7 @@ export function PublicLandingLoadingView() {
</section>
{/* Solutions Carousel Skeleton */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-[#8dc3fb] py-12 sm:py-14">
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-primary-soft py-12 sm:py-14">
<div className="mx-auto max-w-7xl px-6 sm:px-10 lg:px-14 space-y-6">
<Skeleton className="h-10 w-40 rounded-md" />
<div className="flex gap-6 overflow-hidden">
@ -68,7 +68,7 @@ export function PublicLandingLoadingView() {
{Array.from({ length: 2 }).map((_, idx) => (
<div
key={idx}
className="rounded-2xl border border-border/60 bg-white p-6 shadow-sm space-y-4"
className="rounded-2xl border border-border/60 bg-card p-6 shadow-sm space-y-4"
>
<Skeleton className="h-5 w-32 mx-auto rounded-md" />
<Skeleton className="h-24 w-24 mx-auto rounded-full" />
@ -79,11 +79,11 @@ export function PublicLandingLoadingView() {
</section>
{/* Contact Skeleton */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-[#e8f5ff] py-14 sm:py-16">
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-primary-soft py-14 sm:py-16">
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 space-y-6">
<Skeleton className="h-10 w-48 rounded-md" />
<div className="grid grid-cols-1 lg:grid-cols-[1.1fr_0.9fr] gap-10 lg:gap-12">
<div className="rounded-2xl bg-white shadow-sm border border-border/60 p-6 sm:p-8 space-y-4">
<div className="rounded-2xl bg-card shadow-sm border border-border/60 p-6 sm:p-8 space-y-4">
<Skeleton className="h-8 w-48 rounded-md" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Skeleton className="h-11 w-full rounded-md" />

View File

@ -238,12 +238,12 @@ export function AddressForm({
const baseInputClasses = `w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 transition-colors ${
hasError
? "border-red-300 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 focus:ring-blue-500 focus:border-blue-500"
} ${disabled ? "bg-gray-50 cursor-not-allowed" : "bg-white"}`;
: "border-border focus:ring-blue-500 focus:border-blue-500"
} ${disabled ? "bg-muted cursor-not-allowed" : "bg-card"}`;
return (
<div key={field} className={variant === "compact" ? "mb-3" : "mb-4"}>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-foreground mb-1">
{labels[field]}
{isRequired && <span className="text-red-500 ml-1">*</span>}
</label>
@ -288,8 +288,8 @@ export function AddressForm({
variant === "inline"
? ""
: variant === "compact"
? "p-4 bg-gray-50 rounded-lg border border-gray-200"
: "p-6 bg-white border border-gray-200 rounded-lg";
? "p-4 bg-muted rounded-lg border border-border"
: "p-6 bg-card border border-border rounded-lg";
// Get all validation errors
const allErrors = Object.values(form.errors).filter(Boolean) as string[];
@ -301,9 +301,9 @@ export function AddressForm({
<div className="mb-6">
<div className="flex items-center gap-2 mb-2">
<MapPinIcon className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
</div>
{description && <p className="text-sm text-gray-600">{description}</p>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
)}

View File

@ -60,17 +60,17 @@ export function OrderSummary({
const router = useRouter();
const containerClass =
variant === "enhanced"
? "bg-gradient-to-br from-gray-50 to-blue-50 rounded-2xl border-2 border-gray-200 p-8 shadow-lg"
: "bg-white border border-gray-200 rounded-xl p-6";
? "bg-gradient-to-br from-muted to-info-bg rounded-2xl border-2 border-border p-8 shadow-lg"
: "bg-card border border-border rounded-xl p-6";
return (
<div className={containerClass}>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Order Summary</h3>
<h3 className="text-lg font-semibold text-foreground mb-4">Order Summary</h3>
{/* Plan & Configuration Details */}
<div className="space-y-2 text-sm mb-4">
<div className="flex justify-between">
<span className="text-gray-600">Plan:</span>
<span className="text-muted-foreground">Plan:</span>
<span className="font-medium">
{plan.name}
{plan.internetPlanTier && ` (${plan.internetPlanTier})`}
@ -79,14 +79,14 @@ export function OrderSummary({
{configDetails.map((detail, index) => (
<div key={index} className="flex justify-between">
<span className="text-gray-600">{detail.label}:</span>
<span className="text-muted-foreground">{detail.label}:</span>
<span className="font-medium">{detail.value}</span>
</div>
))}
{selectedAddons.length > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">Add-ons:</span>
<span className="text-muted-foreground">Add-ons:</span>
<span className="font-medium">{selectedAddons.length} selected</span>
</div>
)}
@ -94,15 +94,15 @@ export function OrderSummary({
{/* Pricing Breakdown */}
{variant === "enhanced" && (
<div className="pt-2 border-t border-gray-300 mb-4">
<h4 className="font-semibold text-gray-900 mb-3">Pricing Summary</h4>
<div className="pt-2 border-t border-border mb-4">
<h4 className="font-semibold text-foreground mb-3">Pricing Summary</h4>
{/* Monthly Costs */}
<div className="space-y-2 mb-4">
<div className="text-sm font-medium text-gray-700 mb-1">Monthly Costs:</div>
<div className="text-sm font-medium text-foreground mb-1">Monthly Costs:</div>
{plan.monthlyPrice != null && (
<div className="flex justify-between text-sm">
<span className="text-gray-600">
<span className="text-muted-foreground">
Base Plan {plan.internetPlanTier && `(${plan.internetPlanTier})`}:
</span>
<span className="font-medium">¥{plan.monthlyPrice.toLocaleString()}</span>
@ -113,7 +113,7 @@ export function OrderSummary({
(addon, index) =>
addon.billingCycle === "Monthly" && (
<div key={index} className="flex justify-between text-sm">
<span className="text-gray-600">{String(addon.name)}:</span>
<span className="text-muted-foreground">{String(addon.name)}:</span>
<span className="font-medium">
¥{(addon.monthlyPrice ?? 0).toLocaleString()}/month
</span>
@ -121,8 +121,8 @@ export function OrderSummary({
)
)}
<div className="flex justify-between pt-2 border-t border-gray-200">
<span className="font-semibold text-gray-900">Total Monthly:</span>
<div className="flex justify-between pt-2 border-t border-border">
<span className="font-semibold text-foreground">Total Monthly:</span>
<span className="font-bold text-lg text-blue-600">
{hasMissingPrices ? (
<span className="text-red-600 text-sm">Some prices unavailable</span>
@ -136,11 +136,11 @@ export function OrderSummary({
{/* One-time Costs */}
{(oneTimeTotal > 0 || activationFees.length > 0) && (
<div className="space-y-2">
<div className="text-sm font-medium text-gray-700 mb-1">One-time Costs:</div>
<div className="text-sm font-medium text-foreground mb-1">One-time Costs:</div>
{activationFees.map((fee, index) => (
<div key={index} className="flex justify-between text-sm">
<span className="text-gray-600">{String(fee.name)}:</span>
<span className="text-muted-foreground">{String(fee.name)}:</span>
<span className="font-medium">¥{(fee.oneTimePrice ?? 0).toLocaleString()}</span>
</div>
))}
@ -149,7 +149,7 @@ export function OrderSummary({
(addon, index) =>
addon.billingCycle !== "Monthly" && (
<div key={index} className="flex justify-between text-sm">
<span className="text-gray-600">{String(addon.name)}:</span>
<span className="text-muted-foreground">{String(addon.name)}:</span>
<span className="font-medium">
¥{(addon.oneTimePrice ?? 0).toLocaleString()}
</span>
@ -158,8 +158,8 @@ export function OrderSummary({
)}
{oneTimeTotal > 0 && (
<div className="flex justify-between pt-2 border-t border-gray-200">
<span className="font-semibold text-gray-900">Total One-time:</span>
<div className="flex justify-between pt-2 border-t border-border">
<span className="font-semibold text-foreground">Total One-time:</span>
<span className="font-bold text-lg text-orange-600">
¥{oneTimeTotal.toLocaleString()}
</span>
@ -176,14 +176,14 @@ export function OrderSummary({
{/* Show base plan */}
<div className="space-y-2 mb-4">
<div className="flex justify-between">
<span className="text-gray-600">{plan.name}</span>
<span className="text-muted-foreground">{plan.name}</span>
<span className="font-medium">¥{(plan.monthlyPrice ?? 0).toLocaleString()}/mo</span>
</div>
{/* Show activation fees */}
{activationFees.map((fee, index) => (
<div key={index} className="flex justify-between">
<span className="text-gray-600">{String(fee.name)}</span>
<span className="text-muted-foreground">{String(fee.name)}</span>
<span className="font-medium">
¥{(fee.oneTimePrice ?? fee.unitPrice ?? 0).toLocaleString()} one-time
</span>
@ -193,7 +193,7 @@ export function OrderSummary({
{/* Show selected addons */}
{selectedAddons.map((addon, index) => (
<div key={index} className="flex justify-between">
<span className="text-gray-600">{String(addon.name)}</span>
<span className="text-muted-foreground">{String(addon.name)}</span>
<span className="font-medium">
¥
{(addon.billingCycle === "Monthly"
@ -208,7 +208,7 @@ export function OrderSummary({
{/* Info lines */}
{infoLines.length > 0 && (
<div className="text-xs text-gray-500 mb-4 space-y-1">
<div className="text-xs text-muted-foreground mb-4 space-y-1">
{infoLines.map((line, index) => (
<div key={index}> {line}</div>
))}

View File

@ -91,7 +91,7 @@ export function PricingDisplay({
className={`border rounded-lg p-6 ${
tier.isRecommended
? "border-blue-500 ring-2 ring-blue-100 bg-blue-50"
: "border-gray-200 bg-white"
: "border-border bg-card"
}`}
>
{tier.isRecommended && (
@ -103,23 +103,23 @@ export function PricingDisplay({
)}
<div className="text-center mb-4">
<h3 className="text-lg font-semibold text-gray-900 mb-2">{tier.name}</h3>
<h3 className="text-lg font-semibold text-foreground mb-2">{tier.name}</h3>
<div className="flex items-baseline justify-center gap-1">
{getCurrencyIcon()}
<span className={`font-bold text-gray-900 ${sizeClasses[size].price}`}>
<span className={`font-bold text-foreground ${sizeClasses[size].price}`}>
{formatCurrency(tier.price)}
</span>
<span className={`text-gray-500 ${sizeClasses[size].label}`}>
<span className={`text-muted-foreground ${sizeClasses[size].label}`}>
/{tier.billingCycle.toLowerCase()}
</span>
</div>
{tier.description && (
<p className="text-gray-600 text-sm mt-2">{tier.description}</p>
<p className="text-muted-foreground text-sm mt-2">{tier.description}</p>
)}
</div>
{tier.features && tier.features.length > 0 && (
<ul className="space-y-2 text-sm text-gray-700">
<ul className="space-y-2 text-sm text-foreground">
{tier.features.map((feature, featureIndex) => (
<li key={featureIndex} className="flex items-start">
<span className="text-green-600 mr-2"></span>
@ -133,10 +133,10 @@ export function PricingDisplay({
</div>
{disclaimer && (
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
<div className="mt-6 p-4 bg-muted rounded-lg">
<div className="flex items-start gap-2">
<InformationCircleIcon className="h-5 w-5 text-gray-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-gray-600">{disclaimer}</p>
<InformationCircleIcon className="h-5 w-5 text-muted-foreground flex-shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground">{disclaimer}</p>
</div>
</div>
)}
@ -148,22 +148,22 @@ export function PricingDisplay({
return (
<div className={`${alignmentClasses[alignment]}`}>
{variant === "detailed" && (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Pricing</h3>
<div className="bg-card border border-border rounded-lg p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">Pricing</h3>
{/* Monthly pricing */}
{monthlyPrice && (
<div className="mb-4">
<div className="flex items-baseline gap-1">
{getCurrencyIcon()}
<span className={`font-bold text-gray-900 ${sizeClasses[size].price}`}>
<span className={`font-bold text-foreground ${sizeClasses[size].price}`}>
{formatCurrency(monthlyPrice)}
</span>
<span className={`text-gray-500 ${sizeClasses[size].label}`}>/month</span>
<span className={`text-muted-foreground ${sizeClasses[size].label}`}>/month</span>
</div>
{originalMonthlyPrice && originalMonthlyPrice > monthlyPrice && (
<div className="flex items-baseline gap-1 mt-1">
<span className="text-gray-400 line-through text-sm">
<span className="text-muted-foreground/60 line-through text-sm">
¥{formatCurrency(originalMonthlyPrice)}
</span>
<span className="text-green-600 text-sm font-medium">
@ -186,7 +186,7 @@ export function PricingDisplay({
</div>
{originalOneTimePrice && originalOneTimePrice > oneTimePrice && (
<div className="flex items-baseline gap-1 mt-1">
<span className="text-gray-400 line-through text-sm">
<span className="text-muted-foreground/60 line-through text-sm">
¥{formatCurrency(originalOneTimePrice)}
</span>
<span className="text-green-600 text-sm font-medium">
@ -200,8 +200,8 @@ export function PricingDisplay({
{/* Features */}
{features.length > 0 && (
<div className="mb-4">
<h4 className="font-medium text-gray-900 mb-2">Included:</h4>
<ul className="space-y-1 text-sm text-gray-700">
<h4 className="font-medium text-foreground mb-2">Included:</h4>
<ul className="space-y-1 text-sm text-foreground">
{features.map((feature, index) => (
<li key={index} className="flex items-start">
<span className="text-green-600 mr-2"></span>
@ -221,10 +221,10 @@ export function PricingDisplay({
{monthlyPrice && (
<div className="flex items-baseline gap-1 mb-2">
{getCurrencyIcon()}
<span className={`font-bold text-gray-900 ${sizeClasses[size].price}`}>
<span className={`font-bold text-foreground ${sizeClasses[size].price}`}>
{formatCurrency(monthlyPrice)}
</span>
<span className={`text-gray-500 ${sizeClasses[size].label}`}>/month</span>
<span className={`text-muted-foreground ${sizeClasses[size].label}`}>/month</span>
</div>
)}
@ -252,8 +252,8 @@ export function PricingDisplay({
)}
{disclaimer && (
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
<p className="text-xs text-gray-600">{disclaimer}</p>
<div className="mt-4 p-3 bg-muted rounded-lg">
<p className="text-xs text-muted-foreground">{disclaimer}</p>
</div>
)}
</div>

View File

@ -75,7 +75,7 @@ export function ProductCard({
case "success":
return "bg-emerald-100 text-emerald-800 border-emerald-300";
default:
return "bg-gray-100 text-gray-800 border-gray-300";
return "bg-muted text-foreground border-border";
}
};
@ -105,10 +105,12 @@ export function ProductCard({
{(monthlyPrice || oneTimePrice) && (
<div className="text-right flex-shrink-0">
{monthlyPrice && (
<div className="flex items-baseline justify-end gap-1 text-2xl font-bold text-gray-900">
<div className="flex items-baseline justify-end gap-1 text-2xl font-bold text-foreground">
<CurrencyYenIcon className="h-6 w-6" />
<span>{monthlyPrice.toLocaleString()}</span>
<span className="text-sm text-gray-500 font-normal whitespace-nowrap">/month</span>
<span className="text-sm text-muted-foreground font-normal whitespace-nowrap">
/month
</span>
</div>
)}
{oneTimePrice && (
@ -124,15 +126,17 @@ export function ProductCard({
{/* Product name and description */}
<div className="mb-4">
<h3 className="text-xl font-semibold text-gray-900 mb-2">{name}</h3>
{description && <p className="text-gray-600 text-sm leading-relaxed">{description}</p>}
<h3 className="text-xl font-semibold text-foreground mb-2">{name}</h3>
{description && (
<p className="text-muted-foreground text-sm leading-relaxed">{description}</p>
)}
</div>
{/* Features list */}
{features.length > 0 && (
<div className="mb-6 flex-grow">
<h4 className="font-medium text-gray-900 mb-3">Features:</h4>
<ul className="space-y-2 text-sm text-gray-700">
<h4 className="font-medium text-foreground mb-3">Features:</h4>
<ul className="space-y-2 text-sm text-foreground">
{features.map((feature, index) => (
<li key={index} className="flex items-start">
<span className="text-green-600 mr-2 flex-shrink-0"></span>
@ -173,7 +177,7 @@ export function ProductCard({
</div>
{/* Custom footer */}
{footer && <div className="mt-4 pt-4 border-t border-gray-200">{footer}</div>}
{footer && <div className="mt-4 pt-4 border-t border-border">{footer}</div>}
</AnimatedCard>
);
}

View File

@ -51,14 +51,14 @@ export function ProductComparison({
const renderFeatureValue = (value: string | boolean | number | null | undefined) => {
if (value === null || value === undefined) {
return <span className="text-gray-400"></span>;
return <span className="text-muted-foreground/60"></span>;
}
if (typeof value === "boolean") {
return value ? (
<CheckIcon className="h-5 w-5 text-green-600 mx-auto" />
) : (
<XMarkIcon className="h-5 w-5 text-gray-400 mx-auto" />
<XMarkIcon className="h-5 w-5 text-muted-foreground/60 mx-auto" />
);
}
@ -74,9 +74,9 @@ export function ProductComparison({
<div className="space-y-8">
{title && (
<div className="text-center">
<h2 className="text-3xl font-bold text-gray-900 mb-4">{title}</h2>
<h2 className="text-3xl font-bold text-foreground mb-4">{title}</h2>
{description && (
<p className="text-lg text-gray-600 max-w-3xl mx-auto">{description}</p>
<p className="text-lg text-muted-foreground max-w-3xl mx-auto">{description}</p>
)}
</div>
)}
@ -99,24 +99,24 @@ export function ProductComparison({
)}
{product.badge && !product.isRecommended && (
<div className="mb-3">
<span className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm font-medium">
<span className="bg-muted text-foreground px-3 py-1 rounded-full text-sm font-medium">
{product.badge}
</span>
</div>
)}
<h3 className="text-xl font-bold text-gray-900 mb-2">{product.name}</h3>
<h3 className="text-xl font-bold text-foreground mb-2">{product.name}</h3>
{product.description && (
<p className="text-gray-600 text-sm">{product.description}</p>
<p className="text-muted-foreground text-sm">{product.description}</p>
)}
{showPricing && (product.monthlyPrice || product.oneTimePrice) && (
<div className="mt-4">
{product.monthlyPrice && (
<div className="flex items-baseline justify-center gap-1 text-2xl font-bold text-gray-900">
<div className="flex items-baseline justify-center gap-1 text-2xl font-bold text-foreground">
<CurrencyYenIcon className="h-6 w-6" />
<span>{product.monthlyPrice.toLocaleString()}</span>
<span className="text-sm text-gray-500 font-normal">/month</span>
<span className="text-sm text-muted-foreground font-normal">/month</span>
</div>
)}
{product.oneTimePrice && (
@ -139,7 +139,7 @@ export function ProductComparison({
return (
<li key={featureIndex} className="flex items-start justify-between">
<span className="text-sm text-gray-700 flex-1">{feature.name}</span>
<span className="text-sm text-foreground flex-1">{feature.name}</span>
<div className="ml-3 flex-shrink-0">{renderFeatureValue(value)}</div>
</li>
);
@ -172,8 +172,10 @@ export function ProductComparison({
<div className="space-y-8">
{title && (
<div className="text-center">
<h2 className="text-3xl font-bold text-gray-900 mb-4">{title}</h2>
{description && <p className="text-lg text-gray-600 max-w-3xl mx-auto">{description}</p>}
<h2 className="text-3xl font-bold text-foreground mb-4">{title}</h2>
{description && (
<p className="text-lg text-muted-foreground max-w-3xl mx-auto">{description}</p>
)}
</div>
)}
@ -182,12 +184,12 @@ export function ProductComparison({
<table className="w-full">
{/* Header */}
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-4 px-6 font-medium text-gray-900 bg-gray-50">
<tr className="border-b border-border">
<th className="text-left py-4 px-6 font-medium text-foreground bg-muted">
Features
</th>
{displayProducts.map(product => (
<th key={product.id} className="text-center py-4 px-6 bg-gray-50 min-w-[200px]">
<th key={product.id} className="text-center py-4 px-6 bg-muted min-w-[200px]">
<div className="space-y-2">
{product.isRecommended && (
<div>
@ -198,22 +200,22 @@ export function ProductComparison({
)}
{product.badge && !product.isRecommended && (
<div>
<span className="bg-gray-100 text-gray-800 px-2 py-1 rounded-full text-xs font-medium">
<span className="bg-muted text-foreground px-2 py-1 rounded-full text-xs font-medium">
{product.badge}
</span>
</div>
)}
<div className="font-bold text-gray-900">{product.name}</div>
<div className="font-bold text-foreground">{product.name}</div>
{product.description && (
<div className="text-sm text-gray-600">{product.description}</div>
<div className="text-sm text-muted-foreground">{product.description}</div>
)}
{showPricing && (product.monthlyPrice || product.oneTimePrice) && (
<div className="space-y-1">
{product.monthlyPrice && (
<div className="flex items-baseline justify-center gap-1 text-lg font-bold text-gray-900">
<div className="flex items-baseline justify-center gap-1 text-lg font-bold text-foreground">
<CurrencyYenIcon className="h-4 w-4" />
<span>{product.monthlyPrice.toLocaleString()}</span>
<span className="text-xs text-gray-500 font-normal">/mo</span>
<span className="text-xs text-muted-foreground font-normal">/mo</span>
</div>
)}
{product.oneTimePrice && (
@ -234,12 +236,14 @@ export function ProductComparison({
{/* Features */}
<tbody>
{features.map((feature, featureIndex) => (
<tr key={featureIndex} className="border-b border-gray-100 hover:bg-gray-50">
<tr key={featureIndex} className="border-b border-border/50 hover:bg-muted/50">
<td className="py-4 px-6">
<div>
<div className="font-medium text-gray-900">{feature.name}</div>
<div className="font-medium text-foreground">{feature.name}</div>
{feature.description && (
<div className="text-sm text-gray-600 mt-1">{feature.description}</div>
<div className="text-sm text-muted-foreground mt-1">
{feature.description}
</div>
)}
</div>
</td>

View File

@ -18,22 +18,22 @@ interface ServiceHighlightsProps {
function HighlightItem({ icon, title, description, highlight }: HighlightFeature) {
return (
<div className="group relative flex flex-col h-full p-5 rounded-xl bg-muted/30 border-l-4 border-l-primary/60 border-y border-r border-border/30 hover:bg-muted/50 transition-colors duration-200">
{/* Icon - smaller, inline style */}
<div className="flex items-center gap-3 mb-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary flex-shrink-0">
{icon}
</div>
{highlight && (
<span className="inline-flex items-center gap-1 py-0.5 px-2 rounded-full bg-primary/10 text-[10px] font-semibold text-primary whitespace-nowrap">
<CheckCircle className="h-3 w-3" />
{highlight}
</span>
)}
<div className="group relative flex items-start gap-3.5 p-4 rounded-xl border border-border/40 hover:bg-muted/40 transition-colors duration-200">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary flex-shrink-0 mt-0.5 [&>svg]:h-5 [&>svg]:w-5">
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-foreground text-sm">{title}</h3>
{highlight && (
<span className="inline-flex items-center gap-1 py-0.5 px-2 rounded-full bg-primary/10 text-[10px] font-semibold text-primary whitespace-nowrap">
<CheckCircle className="h-3 w-3" />
{highlight}
</span>
)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
</div>
<h3 className="font-semibold text-foreground text-sm mb-1.5">{title}</h3>
<p className="text-xs text-muted-foreground leading-relaxed flex-grow">{description}</p>
</div>
);
}
@ -145,7 +145,7 @@ export function ServiceHighlights({ features, className = "" }: ServiceHighlight
</div>
{/* Desktop: Grid layout */}
<div className={cn("hidden md:grid md:grid-cols-2 lg:grid-cols-3 gap-5", className)}>
<div className={cn("hidden md:grid md:grid-cols-2 lg:grid-cols-3 gap-3", className)}>
{features.map((feature, index) => (
<HighlightItem key={index} {...feature} />
))}

View File

@ -1,49 +1,6 @@
"use client";
import { Button } from "@/components/atoms/button";
import { cn } from "@/shared/utils";
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import type { ReactNode } from "react";
type Alignment = "left" | "center" | "right";
interface ServicesBackLinkProps {
href: string;
label?: string;
align?: Alignment;
className?: string;
buttonClassName?: string;
icon?: ReactNode;
}
const alignmentMap: Record<Alignment, string> = {
left: "justify-start",
center: "justify-center",
right: "justify-end",
};
export function ServicesBackLink({
href,
label = "Back",
align = "left",
className,
buttonClassName,
icon = <ArrowLeftIcon className="w-4 h-4" />,
}: ServicesBackLinkProps) {
return (
<div className={cn("mb-6 flex", alignmentMap[align], className)}>
<Button
as="a"
href={href}
size="sm"
variant="ghost"
leftIcon={icon}
className={cn("text-muted-foreground hover:text-foreground", buttonClassName)}
>
{label}
</Button>
</div>
);
}
export type { ServicesBackLinkProps };
/**
* @deprecated Use BackLink from @/components/molecules instead.
* This re-export exists for backward compatibility.
*/
export { BackLink as ServicesBackLink } from "@/components/molecules/BackLink";
export type { BackLinkProps as ServicesBackLinkProps } from "@/components/molecules/BackLink";

View File

@ -41,7 +41,7 @@ export function ServicesHero({
"flex flex-col gap-3 mb-10",
alignmentMap[align],
className,
align === "center" ? "mx-auto max-w-2xl" : ""
align === "center" ? "mx-auto max-w-3xl" : ""
)}
>
{eyebrow ? (
@ -57,7 +57,7 @@ export function ServicesHero({
) : null}
<h1
className={cn(
"text-display-md md:text-display-lg text-foreground leading-tight",
"text-display-xl text-foreground leading-tight font-bold",
displayFont && "font-display",
animationClasses
)}

View File

@ -10,9 +10,9 @@ function getStepIndicatorClasses(status: StepStatus): string {
case "active":
return "border-blue-500 text-blue-500 bg-blue-50";
case "disabled":
return "border-gray-300 text-gray-400 bg-gray-50";
return "border-border text-muted-foreground/60 bg-muted";
default:
return "border-gray-300 text-gray-500 bg-white";
return "border-border text-muted-foreground bg-card";
}
}

View File

@ -24,6 +24,7 @@ import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import type { SimCatalogProduct } from "@customer-portal/domain/services";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
import { ServicesHero } from "@/features/services/components/base/ServicesHero";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { CardPricing } from "@/features/services/components/base/CardPricing";
import { DeviceCompatibility } from "./DeviceCompatibility";
@ -279,22 +280,19 @@ export function SimPlansContent({
const { regularPlans, familyPlans } = getCurrentPlans();
return (
<div className="max-w-6xl mx-auto px-4 pb-16 pt-8">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
<div className="max-w-6xl mx-auto py-8 pb-16 space-y-12">
<ServicesBackLink href={servicesBasePath} label="Back to Services" className="mb-0" />
<div className="text-center pt-8 pb-8">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 border border-primary/20 mb-6">
<Sparkles className="w-4 h-4 text-primary" />
<span className="text-sm font-medium text-primary">Powered by NTT DOCOMO</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-foreground mb-4">
SIM Cards for Foreigners in Japan
</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
No hanko, no complicated Japanese forms. Get a SIM card with your foreign credit card and
English support. eSIM for instant delivery or physical SIM shipped to your door.
</p>
</div>
<ServicesHero
title="SIM Cards for Foreigners in Japan"
description="No hanko, no complicated Japanese forms. Get a SIM card with your foreign credit card and English support. eSIM for instant delivery or physical SIM shipped to your door."
eyebrow={
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 border border-primary/20">
<Sparkles className="w-4 h-4 text-primary" />
<span className="text-sm font-medium text-primary">Powered by NTT DOCOMO</span>
</div>
}
/>
{variant === "account" && hasExistingSim && (
<div className="space-y-4 mb-8">

View File

@ -18,7 +18,7 @@ const vpnFeatures = [
export function VpnPlanCard({ plan }: VpnPlanCardProps) {
return (
<AnimatedCard className="p-6 border border-border hover:border-primary/40 transition-all duration-300 hover:shadow-lg flex flex-col h-full bg-white">
<AnimatedCard className="p-6 border border-border hover:border-primary/40 transition-all duration-300 hover:shadow-lg flex flex-col h-full bg-card">
{/* Header with icon and name */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-start gap-3">
@ -52,7 +52,7 @@ export function VpnPlanCard({ plan }: VpnPlanCardProps) {
{vpnFeatures.map((feature, index) => (
<li key={index} className="flex items-start gap-2 text-sm">
<svg
className="h-4 w-4 text-gray-500 flex-shrink-0 mt-0.5"
className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5"
viewBox="0 0 20 20"
fill="currentColor"
>

View File

@ -69,7 +69,7 @@ function getSetupFee(installations: InternetInstallationCatalogItem[]): number {
function InternetPlansLoadingSkeleton({ servicesBasePath }: { servicesBasePath: string }) {
return (
<div className="max-w-4xl mx-auto px-4">
<ServicesBackLink href={servicesBasePath} label="Back to Services" className="mb-4" />
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
<div className="text-center mb-12">
<Skeleton className="h-10 w-96 mx-auto mb-4" />
<Skeleton className="h-4 w-[32rem] max-w-full mx-auto" />

View File

@ -23,6 +23,7 @@ import type {
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
import { ServicesHero } from "@/features/services/components/base/ServicesHero";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { Button } from "@/components/atoms/button";
import type { TierInfo } from "@/features/services/components/internet/PublicOfferingCard";
@ -36,17 +37,17 @@ import { cn } from "@/shared/utils";
// Tier styling - matching the design with left border accents
const tierStyles = {
Silver: {
card: "border-gray-200 bg-white border-l-4 border-l-gray-400",
accent: "text-gray-600",
card: "border-border bg-card border-l-4 border-l-muted-foreground/60",
accent: "text-muted-foreground",
header: "Silver",
},
Gold: {
card: "border-gray-200 bg-white border-l-4 border-l-amber-500",
card: "border-border bg-card border-l-4 border-l-amber-500",
accent: "text-amber-600",
header: "Gold",
},
Platinum: {
card: "border-gray-200 bg-white border-l-4 border-l-primary",
card: "border-border bg-card border-l-4 border-l-primary",
accent: "text-primary",
header: "Platinum",
},
@ -166,7 +167,7 @@ function ConsolidatedInternetCard({
{tier.features.map((feature, index) => (
<li key={index} className="flex items-start gap-2 text-sm">
<svg
className="h-4 w-4 text-gray-500 flex-shrink-0 mt-0.5"
className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-0.5"
viewBox="0 0 20 20"
fill="currentColor"
>
@ -232,7 +233,7 @@ const offeringTypeConfigs: OfferingTypeConfig[] = [
id: "home1g",
title: "Home 1Gbps",
badge: "1Gbps",
badgeColor: "bg-gray-200 text-gray-700",
badgeColor: "bg-muted text-foreground",
description: "High-speed fiber. The most popular choice for home internet.",
icon: <Home className="h-5 w-5 text-muted-foreground" />,
},
@ -240,7 +241,7 @@ const offeringTypeConfigs: OfferingTypeConfig[] = [
id: "apartment",
title: "Apartment",
badge: "Up to 1Gbps",
badgeColor: "bg-gray-200 text-gray-700",
badgeColor: "bg-muted text-foreground",
description:
"For mansions and apartment buildings. Speed depends on your building (up to 1Gbps).",
icon: <Building className="h-5 w-5 text-muted-foreground" />,
@ -381,7 +382,7 @@ function ExpandedTierDetails({
<div
key={tier.tier}
className={cn(
"rounded-xl border p-4 transition-all duration-200 flex flex-col relative bg-white",
"rounded-xl border p-4 transition-all duration-200 flex flex-col relative bg-card",
tierStyles[tier.tier].card
)}
>
@ -558,7 +559,7 @@ function MobilePlanCard({
<div
key={tier.tier}
className={cn(
"rounded-xl border p-4 transition-all duration-200 flex flex-col relative bg-white",
"rounded-xl border p-4 transition-all duration-200 flex flex-col relative bg-card",
tierStyles[tier.tier].card
)}
>
@ -958,17 +959,12 @@ export function PublicInternetPlansContent({
}
return (
<div className="space-y-6">
<div className="max-w-6xl mx-auto py-8 pb-16 space-y-12">
{/* Back link */}
<ServicesBackLink href={servicesBasePath} />
<ServicesBackLink href={servicesBasePath} className="mb-0" />
{/* Hero - Clean and impactful */}
<div className="text-center py-4">
<h1 className="text-3xl md:text-4xl font-bold text-foreground tracking-tight">
{heroTitle}
</h1>
<p className="text-base text-muted-foreground mt-2 max-w-lg mx-auto">{heroDescription}</p>
</div>
<ServicesHero title={heroTitle} description={heroDescription} />
{/* Service Highlights */}
<ServiceHighlights features={internetFeatures} />

View File

@ -39,7 +39,7 @@ export function PublicVpnPlansView() {
if (isLoading || error) {
return (
<div className="max-w-6xl mx-auto px-4">
<div className="max-w-6xl mx-auto">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
<AsyncBlock
@ -98,8 +98,8 @@ export function PublicVpnPlansView() {
];
return (
<div className="max-w-6xl mx-auto px-4 pb-16">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
<div className="max-w-6xl mx-auto py-8 pb-16 space-y-12">
<ServicesBackLink href={servicesBasePath} label="Back to Services" className="mb-0" />
<ServicesHero
title="Stream Your Favorites from Home"
@ -176,11 +176,11 @@ function HowItWorksStep({ number, icon, title, description }: HowItWorksStepProp
<div className="flex flex-col items-center text-center flex-1 min-w-0">
{/* Icon with number badge */}
<div className="relative mb-4">
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-gray-50 border border-gray-200 text-primary shadow-sm">
<div className="flex h-16 w-16 items-center justify-center rounded-xl bg-muted border border-border text-primary shadow-sm">
{icon}
</div>
{/* Number badge */}
<div className="absolute -top-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white text-xs font-bold shadow-sm">
<div className="absolute -top-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-bold shadow-sm">
{number}
</div>
</div>
@ -229,7 +229,7 @@ function VpnHowItWorksSection() {
{/* Steps with connecting line */}
<div className="relative">
{/* Connecting line - hidden on mobile */}
<div className="hidden md:block absolute top-8 left-[12%] right-[12%] h-0.5 bg-gray-200" />
<div className="hidden md:block absolute top-8 left-[12%] right-[12%] h-0.5 bg-border" />
{/* Curved path SVG for visual connection - hidden on mobile */}
<svg

View File

@ -35,6 +35,11 @@
--cp-space-2xl: var(--cp-space-8);
--cp-space-3xl: var(--cp-space-12);
/* Section spacing */
--cp-section-gap: 3rem; /* 48px - default gap between page sections */
--cp-section-gap-lg: 4rem; /* 64px - larger section gaps */
--cp-service-page-py: 2rem; /* 32px - consistent service page vertical padding */
/* ============= CONTAINERS ============= */
--cp-container-sm: 640px;
--cp-container-md: 768px;

BIN
image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB