style: update typography and layout across components

- Replaced font references in globals.css to use DM Sans and JetBrains Mono for improved typography consistency.
- Adjusted various components to utilize the new font styles, enhancing visual hierarchy and readability.
- Updated layout properties in AppShell and Sidebar for better alignment and spacing.
- Enhanced button styles to include a new subtle variant for improved UI flexibility.
- Refactored SearchFilterBar to support active filter display, improving user interaction experience.
- Made minor adjustments to the DashboardView and landing page components for better visual consistency.
This commit is contained in:
barsa 2026-03-05 17:23:03 +09:00
parent f6329bbe3b
commit 57f2c543d1
45 changed files with 3390 additions and 402 deletions

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -17,18 +17,19 @@
--radius: 0.625rem;
/* Typography */
--font-sans: var(--font-geist-sans, system-ui, sans-serif);
--font-sans: var(--font-dm-sans, system-ui, sans-serif);
--font-display: var(--font-jakarta, var(--font-sans));
--font-mono: var(--font-jetbrains, ui-monospace, monospace);
/* Core Surfaces */
--background: oklch(1 0 0);
--background: oklch(0.993 0.002 70);
--foreground: oklch(0.16 0 0);
--card: oklch(1 0 0);
--card-foreground: var(--foreground);
--popover: oklch(1 0 0);
--popover-foreground: var(--foreground);
--muted: oklch(0.96 0.008 234.4);
--muted-foreground: oklch(0.5 0 0);
--muted: oklch(0.965 0.006 70);
--muted-foreground: oklch(0.46 0.01 70);
/* Brand - Clean Blue (matches logo) */
--primary: oklch(0.6884 0.1342 234.4);
@ -71,19 +72,20 @@
--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);
--surface-elevated: oklch(0.998 0.001 70);
--surface-sunken: oklch(0.975 0.004 70);
/* Chrome */
--border: oklch(0.93 0.004 234.4);
--input: oklch(0.96 0.004 234.4);
--border: oklch(0.925 0.006 70);
--input: oklch(0.955 0.005 70);
--ring: oklch(0.6884 0.1342 234.4 / 0.5);
/* Sidebar - Deep purple/indigo */
--sidebar: oklch(0.2754 0.1199 272.34);
/* Sidebar - Dark Navy */
--sidebar: oklch(0.18 0.03 250);
--sidebar-foreground: oklch(1 0 0);
--sidebar-border: oklch(0.36 0.1 272.34);
--sidebar-active: oklch(0.99 0 0 / 0.15);
--sidebar-border: oklch(0.25 0.04 250);
--sidebar-active: oklch(0.99 0 0 / 0.12);
--sidebar-accent: var(--primary);
/* Header */
--header: oklch(1 0 0 / 0.95);
@ -141,14 +143,14 @@
.dark {
/* Surfaces - Rich dark with blue undertone */
--background: oklch(0.12 0.015 234.4);
--background: oklch(0.12 0.012 250);
--foreground: oklch(0.95 0 0);
--card: oklch(0.15 0.015 234.4);
--card-foreground: var(--foreground);
--popover: oklch(0.15 0.015 234.4);
--popover-foreground: var(--foreground);
--muted: oklch(0.25 0.01 234.4);
--muted-foreground: oklch(0.74 0 0);
--muted: oklch(0.25 0.008 70);
--muted-foreground: oklch(0.72 0.01 70);
/* Brand - Brighter for dark mode contrast */
--primary: oklch(0.75 0.12 234.4);
@ -185,16 +187,16 @@
--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);
--surface-elevated: oklch(0.18 0.012 250);
--surface-sunken: oklch(0.1 0.012 250);
--border: oklch(0.32 0.02 234.4);
--input: oklch(0.35 0.02 234.4);
--border: oklch(0.3 0.012 70);
--input: oklch(0.33 0.01 70);
--ring: oklch(0.75 0.12 234.4 / 0.5);
/* Sidebar - Purple/indigo theme for dark mode */
--sidebar: oklch(0.2 0.08 272.34);
--sidebar-border: oklch(0.28 0.08 272.34);
/* Sidebar - Dark Navy for dark mode */
--sidebar: oklch(0.13 0.025 250);
--sidebar-border: oklch(0.22 0.03 250);
--header: oklch(0.15 0.015 234.4 / 0.95);
--header-foreground: var(--foreground);
@ -226,7 +228,8 @@
@theme {
/* Font Families */
--font-family-sans: var(--font-sans);
--font-family-display: var(--font-display);
--font-family-heading: var(--font-display);
--font-family-mono: var(--font-mono);
/* Colors */
--color-background: var(--background);
@ -288,6 +291,7 @@
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-active: var(--sidebar-active);
--color-sidebar-accent: var(--sidebar-accent);
--color-header: var(--header);
--color-header-foreground: var(--header-foreground);

View File

@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Plus_Jakarta_Sans } from "next/font/google";
import { Plus_Jakarta_Sans, DM_Sans, JetBrains_Mono } from "next/font/google";
import { headers } from "next/headers";
import "./globals.css";
import { QueryProvider } from "@/core/providers";
@ -11,6 +11,18 @@ const plusJakartaSans = Plus_Jakarta_Sans({
display: "swap",
});
const dmSans = DM_Sans({
subsets: ["latin"],
variable: "--font-dm-sans",
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains",
display: "swap",
});
export const metadata: Metadata = {
title: {
default: "Assist Solutions - IT Services for Expats in Japan",
@ -39,7 +51,9 @@ export default async function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={`${plusJakartaSans.variable} antialiased`}>
<body
className={`${plusJakartaSans.variable} ${dmSans.variable} ${jetbrainsMono.variable} antialiased`}
>
<QueryProvider nonce={nonce}>
{children}
<SessionTimeoutWarning />

View File

@ -19,6 +19,8 @@ const buttonVariants = cva(
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
ghost: "text-foreground hover:bg-muted",
subtle:
"bg-muted/50 text-foreground hover:bg-muted border border-transparent hover:border-border/40",
link: "underline-offset-4 hover:underline text-primary",
pill: "rounded-full bg-primary text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 hover:-translate-y-0.5",
pillOutline: "rounded-full border border-border bg-card text-primary hover:bg-primary/5",

View File

@ -59,5 +59,11 @@ export { Logo } from "./logo";
// Navigation and Steps
export { StepHeader } from "./step-header";
// Status
export { StatusIndicator, type StatusIndicatorStatus } from "./status-indicator";
// View controls
export { ViewToggle, type ViewMode } from "./view-toggle";
// Animation
export { AnimatedContainer } from "./animated-container";

View File

@ -0,0 +1,59 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/utils";
const statusIndicatorVariants = cva("inline-flex items-center gap-1.5", {
variants: {
size: {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
},
},
defaultVariants: {
size: "md",
},
});
const dotVariants = cva("rounded-full flex-shrink-0", {
variants: {
status: {
active: "bg-success",
warning: "bg-warning",
error: "bg-danger",
inactive: "bg-muted-foreground/30",
pending: "bg-info",
},
size: {
sm: "h-1.5 w-1.5",
md: "h-2 w-2",
lg: "h-2.5 w-2.5",
},
pulse: {
true: "animate-pulse",
false: "",
},
},
defaultVariants: {
status: "active",
size: "md",
pulse: false,
},
});
export type StatusIndicatorStatus = "active" | "warning" | "error" | "inactive" | "pending";
interface StatusIndicatorProps extends VariantProps<typeof statusIndicatorVariants> {
status: StatusIndicatorStatus;
label?: string;
pulse?: boolean;
className?: string;
}
export function StatusIndicator({ status, label, size, pulse, className }: StatusIndicatorProps) {
return (
<span className={cn(statusIndicatorVariants({ size }), className)}>
<span className={dotVariants({ status, size, pulse: pulse ?? status === "pending" })} />
{label && <span className="text-muted-foreground">{label}</span>}
</span>
);
}

View File

@ -0,0 +1,52 @@
"use client";
import { Squares2X2Icon, ListBulletIcon } from "@heroicons/react/24/outline";
import { cn } from "@/shared/utils";
export type ViewMode = "grid" | "list";
interface ViewToggleProps {
value: ViewMode;
onChange: (mode: ViewMode) => void;
className?: string;
}
export function ViewToggle({ value, onChange, className }: ViewToggleProps) {
return (
<div
className={cn(
"inline-flex items-center rounded-lg border border-border/60 bg-muted/30 p-0.5",
className
)}
>
<button
type="button"
onClick={() => onChange("grid")}
className={cn(
"inline-flex items-center justify-center h-7 w-7 rounded-md transition-all duration-200",
value === "grid"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
aria-label="Grid view"
aria-pressed={value === "grid"}
>
<Squares2X2Icon className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => onChange("list")}
className={cn(
"inline-flex items-center justify-center h-7 w-7 rounded-md transition-all duration-200",
value === "list"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
aria-label="List view"
aria-pressed={value === "list"}
>
<ListBulletIcon className="h-3.5 w-3.5" />
</button>
</div>
);
}

View File

@ -0,0 +1,89 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/utils";
type MetricTone = "primary" | "success" | "warning" | "danger" | "info" | "neutral";
const toneStyles: Record<MetricTone, { icon: string; accent: string }> = {
primary: { icon: "text-primary bg-primary/10", accent: "text-primary" },
success: { icon: "text-success bg-success/10", accent: "text-success" },
warning: { icon: "text-warning bg-warning/10", accent: "text-warning" },
danger: { icon: "text-danger bg-danger/10", accent: "text-danger" },
info: { icon: "text-info bg-info/10", accent: "text-info" },
neutral: { icon: "text-muted-foreground bg-muted", accent: "text-muted-foreground" },
};
export interface MetricCardProps {
icon?: ReactNode;
label: string;
value: string | number;
subtitle?: string;
tone?: MetricTone;
trend?: { value: string; positive?: boolean };
className?: string;
}
export function MetricCard({
icon,
label,
value,
subtitle,
tone = "primary",
trend,
className,
}: MetricCardProps) {
const styles = toneStyles[tone];
return (
<div
className={cn(
"flex items-start gap-3.5 p-4 rounded-xl bg-card border border-border/60",
"transition-all duration-200 hover:border-border hover:shadow-[var(--cp-shadow-1)]",
className
)}
>
{icon && (
<div
className={cn(
"flex-shrink-0 h-10 w-10 rounded-lg flex items-center justify-center",
styles.icon
)}
>
{icon}
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<div className="flex items-baseline gap-2 mt-0.5">
<p className="text-2xl font-bold text-foreground tabular-nums font-heading tracking-tight">
{value}
</p>
{trend && (
<span
className={cn("text-xs font-medium", trend.positive ? "text-success" : "text-danger")}
>
{trend.value}
</span>
)}
</div>
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
</div>
</div>
);
}
export function MetricCardSkeleton({ className }: { className?: string }) {
return (
<div
className={cn(
"flex items-start gap-3.5 p-4 rounded-xl bg-card border border-border/60",
className
)}
>
<div className="flex-shrink-0 h-10 w-10 rounded-lg cp-skeleton-shimmer" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-3 cp-skeleton-shimmer rounded w-16" />
<div className="h-7 cp-skeleton-shimmer rounded w-12" />
</div>
</div>
);
}

View File

@ -0,0 +1 @@
export { MetricCard, MetricCardSkeleton, type MetricCardProps } from "./MetricCard";

View File

@ -1,5 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { MagnifyingGlassIcon, FunnelIcon } from "@heroicons/react/24/outline";
import { MagnifyingGlassIcon, FunnelIcon, XMarkIcon } from "@heroicons/react/24/outline";
interface FilterOption {
value: string;
@ -14,6 +16,7 @@ interface SearchFilterBarProps {
onFilterChange?: (value: string) => void;
filterOptions?: FilterOption[];
filterLabel?: string;
activeFilters?: { label: string; onRemove: () => void }[] | undefined;
children?: ReactNode;
}
@ -23,53 +26,67 @@ export function SearchFilterBar({
searchPlaceholder = "Search...",
filterValue,
onFilterChange,
filterOptions = [],
filterLabel,
filterOptions,
filterLabel = "Filter",
activeFilters,
children,
}: SearchFilterBarProps) {
return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-3">
<div className="flex flex-col sm:flex-row gap-2.5">
{/* Search */}
<div className="relative flex-1 max-w-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-muted-foreground" />
</div>
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/50" />
<input
type="text"
className="block w-full pl-10 pr-3 py-2.5 border border-border rounded-lg leading-5 bg-card text-foreground shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary transition-colors"
placeholder={searchPlaceholder}
value={searchValue}
onChange={e => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
className="w-full h-9 pl-9 pr-3 rounded-lg border border-border/60 bg-background text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30 transition-all duration-200"
/>
</div>
{/* Filters and Actions */}
<div className="flex items-center gap-3">
{/* Status Filter */}
{filterOptions.length > 0 && onFilterChange && filterValue !== undefined && (
<div className="flex items-center gap-2">
{/* Filter select */}
{filterOptions && onFilterChange && (
<div className="relative">
<select
value={filterValue}
value={filterValue ?? "all"}
onChange={e => onFilterChange(e.target.value)}
className="block w-40 pl-3 pr-8 py-2.5 text-sm border border-border focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary rounded-lg appearance-none bg-card text-foreground shadow-sm cursor-pointer transition-colors"
className="h-9 pl-3 pr-8 rounded-lg border border-border/60 bg-background text-sm text-foreground appearance-none focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30 transition-all duration-200"
aria-label={filterLabel}
>
{filterOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
{filterOptions.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center pr-2.5 pointer-events-none">
<FunnelIcon className="h-4 w-4 text-muted-foreground" />
</div>
<FunnelIcon className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50 pointer-events-none" />
</div>
)}
{/* Additional children (custom actions) */}
{/* Custom actions (ViewToggle, etc.) */}
{children}
</div>
</div>
{/* Active filter pills */}
{activeFilters && activeFilters.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{activeFilters.map(filter => (
<button
key={filter.label}
onClick={filter.onRemove}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-primary/10 text-primary text-xs font-medium hover:bg-primary/20 transition-colors duration-150"
>
{filter.label}
<XMarkIcon className="h-3 w-3" />
</button>
))}
</div>
)}
</div>
);
}

View File

@ -223,7 +223,7 @@ function DefaultCard({
{renderIcon(icon, "h-6 w-6")}
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-foreground font-display">{title}</h3>
<h3 className="text-lg font-semibold text-foreground font-heading">{title}</h3>
{price && (
<span className="text-sm text-muted-foreground">
From <span className="font-medium text-foreground">{price}</span>
@ -277,7 +277,7 @@ function FeaturedCard({ href, icon, title, description, highlight, className }:
</div>
{/* Content */}
<h3 className="text-lg font-semibold text-foreground mb-2 font-display">{title}</h3>
<h3 className="text-lg font-semibold text-foreground mb-2 font-heading">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground leading-relaxed flex-grow">{description}</p>
)}
@ -332,7 +332,7 @@ function MinimalCard({ href, icon, title, className }: ServiceCardProps) {
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 mb-3 transition-all group-hover:bg-primary/15">
{renderIcon(icon, "h-6 w-6 text-primary")}
</div>
<h3 className="text-sm font-semibold text-foreground font-display">{title}</h3>
<h3 className="text-sm font-semibold text-foreground font-heading">{title}</h3>
</div>
</CardWrapper>
);
@ -401,7 +401,7 @@ function BentoMediumCard({
{renderIcon(icon, "h-6 w-6")}
</div>
<h3 className="text-lg font-bold text-foreground mb-2 font-display">{title}</h3>
<h3 className="text-lg font-bold text-foreground mb-2 font-heading">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
)}
@ -460,7 +460,7 @@ function BentoLargeCard({
{renderIcon(icon, "h-7 w-7")}
</div>
<h3 className="text-2xl font-bold text-foreground mb-3 font-display">{title}</h3>
<h3 className="text-2xl font-bold text-foreground mb-3 font-heading">{title}</h3>
{description && (
<p className="text-muted-foreground leading-relaxed max-w-sm mb-6">{description}</p>
)}

View File

@ -28,6 +28,9 @@ export * from "./FilterDropdown";
export * from "./ClearFiltersButton";
export * from "./DetailStatsGrid";
// Metric display
export { MetricCard, MetricCardSkeleton, type MetricCardProps } from "./MetricCard";
// Navigation molecules
export * from "./BackLink";

View File

@ -199,7 +199,7 @@ export function AppShell({ children }: AppShellProps) {
{/* Desktop sidebar */}
<div className="hidden md:flex md:flex-shrink-0">
<div className="flex flex-col w-[240px] border-r border-sidebar-border bg-sidebar shadow-sm">
<div className="flex flex-col w-[220px] border-r border-sidebar-border/40 bg-sidebar">
<Sidebar
navigation={navigation}
pathname={pathname}

View File

@ -2,7 +2,11 @@
import Link from "next/link";
import { memo } from "react";
import { Bars3Icon, QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
import {
Bars3Icon,
MagnifyingGlassIcon,
QuestionMarkCircleIcon,
} from "@heroicons/react/24/outline";
import { NotificationBell } from "@/features/notifications";
interface UserInfo {
@ -39,50 +43,62 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
const initials = getInitials(user, profileReady, displayName);
return (
<div className="relative z-40 bg-header border-b border-header-border/50 backdrop-blur-xl">
<div className="flex items-center h-16 gap-2 sm:gap-3 px-3 sm:px-6">
{/* Mobile menu button - 44px minimum touch target */}
<div className="relative z-40 bg-header/80 backdrop-blur-xl border-b border-border/40">
<div className="flex items-center h-14 gap-2 px-3 sm:px-5">
{/* Mobile menu button */}
<button
type="button"
className="md:hidden flex items-center justify-center w-11 h-11 -ml-1 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/60 active:bg-muted/80 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
className="md:hidden flex items-center justify-center w-10 h-10 -ml-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/60 active:bg-muted transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
onClick={onMenuClick}
aria-label="Open navigation"
>
<Bars3Icon className="h-6 w-6" />
<Bars3Icon className="h-5 w-5" />
</button>
{/* Search trigger */}
<button
type="button"
className="hidden sm:flex items-center gap-2.5 h-9 px-3 w-full max-w-xs rounded-lg bg-muted/50 border border-border/50 text-muted-foreground text-sm hover:bg-muted/80 hover:border-border transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
aria-label="Search"
>
<MagnifyingGlassIcon className="h-3.5 w-3.5 flex-shrink-0" />
<span className="flex-1 text-left text-xs">Search...</span>
<kbd className="hidden lg:inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded border border-border/60 bg-background/80 text-[10px] font-mono text-muted-foreground/60">
<span className="text-[11px]">&#8984;</span>K
</kbd>
</button>
<div className="flex-1" />
{/* Right side actions */}
<div className="flex items-center gap-1 sm:gap-2">
<div className="flex items-center gap-0.5">
{/* Notification bell */}
<NotificationBell />
{/* Help link - visible on larger screens */}
{/* Help link */}
<Link
href="/account/support"
prefetch
aria-label="Help"
className="hidden sm:inline-flex items-center justify-center w-11 h-11 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/60 active:bg-muted/80 transition-all duration-200"
className="hidden sm:inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted/60 transition-all duration-200"
title="Support Center"
>
<QuestionMarkCircleIcon className="h-5 w-5" />
<QuestionMarkCircleIcon className="h-4.5 w-4.5" />
</Link>
{/* Profile link - enhanced for mobile */}
{/* Divider */}
<div className="hidden sm:block w-px h-5 bg-border/60 mx-1.5" />
{/* Profile link */}
<Link
href="/account/settings"
prefetch
className="group flex items-center gap-2 sm:gap-2.5 px-2 sm:px-3 py-1.5 min-h-[44px] text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/60 active:bg-muted/80 rounded-xl transition-all duration-200"
className="group flex items-center gap-2 px-2 py-1 min-h-[40px] text-sm font-medium text-muted-foreground hover:text-foreground rounded-lg transition-all duration-200"
>
<div className="h-8 w-8 sm:h-7 sm:w-7 rounded-lg bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-xs font-bold text-primary-foreground shadow-sm group-hover:shadow-md transition-shadow">
<div className="h-7 w-7 rounded-lg bg-gradient-to-br from-primary to-accent-gradient flex items-center justify-center text-[11px] font-bold text-primary-foreground shadow-sm group-hover:shadow-md transition-shadow">
{initials}
</div>
{/* Show truncated name on mobile, full name on larger screens */}
<span className="hidden xs:inline sm:hidden max-w-[80px] truncate text-sm">
{displayName.split(" ")[0]}
</span>
<span className="hidden sm:inline">{displayName}</span>
<span className="hidden sm:inline text-[13px]">{displayName}</span>
</Link>
</div>
</div>

View File

@ -10,14 +10,15 @@ import type { ComponentType, SVGProps } from "react";
// Shared navigation item styling
const navItemBaseClass =
"group w-full flex items-center px-3 py-2.5 text-sm font-semibold rounded-lg transition-all duration-200 relative focus:outline-none focus:ring-2 focus:ring-white/30";
const activeClass = "text-white bg-white/20 shadow-sm";
const inactiveClass = "text-white/90 hover:text-white hover:bg-white/10";
"group w-full flex items-center px-3 py-2 text-[13px] font-medium rounded-lg transition-all duration-200 relative focus:outline-none focus:ring-2 focus:ring-white/20";
const activeClass = "text-white bg-white/[0.08] shadow-sm";
const inactiveClass = "text-white/60 hover:text-white/90 hover:bg-white/[0.06]";
function ActiveIndicator({ small = false }: { small?: boolean }) {
const size = small ? "w-0.5 h-4" : "w-1 h-6";
const rounded = small ? "rounded-full" : "rounded-r-full";
return <div className={`absolute left-0 top-1/2 -translate-y-1/2 ${size} bg-white ${rounded}`} />;
const size = small ? "w-0.5 h-3.5" : "w-[3px] h-5";
return (
<div className={`absolute left-0 top-1/2 -translate-y-1/2 ${size} bg-primary rounded-full`} />
);
}
function NavIcon({
@ -31,19 +32,19 @@ function NavIcon({
}) {
if (variant === "logout") {
return (
<div className="p-1.5 rounded-md mr-3 text-red-300 group-hover:text-red-100 transition-colors duration-[var(--cp-duration-normal)]">
<Icon className="h-5 w-5" />
<div className="p-1 mr-2.5 text-red-400/70 group-hover:text-red-300 transition-colors duration-200">
<Icon className="h-[18px] w-[18px]" />
</div>
);
}
return (
<div
className={`p-1.5 rounded-md mr-3 transition-colors duration-200 ${
isActive ? "bg-white/20 text-white" : "text-white/80 group-hover:text-white"
className={`p-1 mr-2.5 transition-colors duration-200 ${
isActive ? "text-primary" : "text-white/40 group-hover:text-white/70"
}`}
>
<Icon className="h-5 w-5" />
<Icon className="h-[18px] w-[18px]" />
</div>
);
}
@ -64,28 +65,36 @@ export const Sidebar = memo(function Sidebar({
}: SidebarProps) {
return (
<div className="flex flex-col h-0 flex-1 bg-sidebar">
<div className="flex items-center flex-shrink-0 h-16 px-5 border-b border-sidebar-border">
<div className="flex items-center flex-shrink-0 h-16 px-5 border-b border-sidebar-border/50">
<div className="flex items-center space-x-3">
<div className="h-10 w-10 bg-white rounded-xl shadow-lg shadow-black/10 flex items-center justify-center">
<Logo size={26} />
<div className="h-9 w-9 bg-white/10 backdrop-blur-sm rounded-lg border border-white/10 flex items-center justify-center">
<Logo size={22} />
</div>
<div>
<span className="text-base font-bold text-white">Assist Solutions</span>
<p className="text-xs text-white/70 font-medium">Customer Portal</p>
<span className="text-sm font-bold text-white tracking-tight">Assist Solutions</span>
<p className="text-[11px] text-white/50 font-medium">Customer Portal</p>
</div>
</div>
</div>
<div className="flex-1 flex flex-col pt-6 pb-4 overflow-y-auto">
<nav className="flex-1 px-3 space-y-1">
{navigation.map(item => (
<nav className="flex-1 px-3 space-y-0.5">
{navigation.map((item, index) => (
<div key={item.name}>
{item.section && (
<div className={`px-3 ${index === 0 ? "pt-0" : "pt-5"} pb-2`}>
<span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-white/30">
{item.section}
</span>
</div>
)}
<NavigationItem
key={item.name}
item={item}
pathname={pathname}
isExpanded={expandedItems.includes(item.name)}
toggleExpanded={toggleExpanded}
/>
</div>
))}
</nav>
</div>
@ -141,7 +150,7 @@ function ExpandableNavItem({
isExpanded ? "max-h-96 opacity-100" : "max-h-0 opacity-0"
}`}
>
<div className="mt-1 ml-6 space-y-0.5 border-l border-white/30 pl-4">
<div className="mt-0.5 ml-[30px] space-y-0.5 border-l border-white/[0.08] pl-3">
{item.children?.map((child: NavigationChild) => {
const isChildActive = pathname === (child.href || "").split(/[?#]/)[0];
return (
@ -150,20 +159,15 @@ function ExpandableNavItem({
href={child.href}
prefetch
onMouseEnter={() => child.href && void router.prefetch(child.href)}
className={`group flex items-center px-3 py-2 text-sm rounded-md transition-all duration-200 relative ${
className={`group flex items-center px-2.5 py-1.5 text-[13px] rounded-md transition-all duration-200 relative ${
isChildActive
? "text-white bg-white/20 font-semibold"
: "text-white/80 hover:text-white hover:bg-white/10 font-medium"
? "text-white bg-white/[0.08] font-medium"
: "text-white/50 hover:text-white/80 hover:bg-white/[0.04] font-normal"
}`}
title={child.tooltip || child.name}
aria-current={isChildActive ? "page" : undefined}
>
{isChildActive && <ActiveIndicator small />}
<div
className={`w-1.5 h-1.5 rounded-full mr-3 transition-colors duration-200 ${
isChildActive ? "bg-white" : "bg-white/40 group-hover:bg-white/70"
}`}
/>
<span className="truncate">{child.name}</span>
</Link>
);
@ -176,13 +180,15 @@ function ExpandableNavItem({
function LogoutNavItem({ item, onLogout }: { item: NavigationItem; onLogout: () => void }) {
return (
<div className="px-3 pt-4 mt-2 border-t border-white/[0.06]">
<button
onClick={onLogout}
className="group w-full flex items-center px-3 py-2.5 text-sm font-semibold text-red-300 hover:text-red-100 hover:bg-red-500/25 rounded-lg transition-colors duration-[var(--cp-duration-normal)] focus:outline-none focus:ring-2 focus:ring-red-400/30"
className="group w-full flex items-center px-3 py-2 text-[13px] font-medium text-red-400/70 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-red-400/20"
>
<NavIcon icon={item.icon} isActive={false} variant="logout" />
<span>{item.name}</span>
</button>
</div>
);
}

View File

@ -23,14 +23,16 @@ export interface NavigationItem {
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
children?: NavigationChild[] | undefined;
isLogout?: boolean | undefined;
section?: string | undefined;
}
export const baseNavigation: NavigationItem[] = [
{ name: "Dashboard", href: "/account", icon: HomeIcon },
{ name: "Dashboard", href: "/account", icon: HomeIcon, section: "Overview" },
{ name: "Orders", href: "/account/orders", icon: ClipboardDocumentListIcon },
{
name: "Billing",
icon: CreditCardIcon,
section: "Account",
children: [
{ name: "Invoices", href: "/account/billing/invoices" },
{ name: "Payment Methods", href: "/account/billing/payments" },
@ -78,6 +80,7 @@ export function computeNavigation(activeSubscriptions?: Subscription[]): Navigat
icon: currentItem.icon,
href: currentItem.href,
isLogout: currentItem.isLogout,
section: currentItem.section,
children: [{ name: "All Subscriptions", href: "/account/subscriptions" }, ...dynamicChildren],
};
}

View File

@ -33,7 +33,7 @@ function FooterLinkColumn({
}) {
return (
<div>
<h3 className="text-sm font-semibold text-foreground mb-4 font-display">{title}</h3>
<h3 className="text-sm font-semibold text-foreground mb-4 font-heading">{title}</h3>
<ul className="space-y-2 text-sm">
{links.map(link => (
<li key={link.href}>

View File

@ -41,7 +41,7 @@ export function AuthLayout({
)}
<div className="text-center">
<h1 className="text-2xl font-bold tracking-tight text-foreground mb-2 font-display">
<h1 className="text-2xl font-bold tracking-tight text-foreground mb-2 font-heading">
{title}
</h1>
{subtitle && (

View File

@ -58,32 +58,33 @@ function StatItem({ icon: Icon, label, value, href, tone = "primary", emptyText
<Link
href={href}
className={cn(
"group flex items-center gap-4 p-4 rounded-xl",
"bg-surface border border-border/60",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:shadow-md hover:-translate-y-0.5",
"group flex items-center gap-3.5 p-3.5 rounded-xl",
"bg-card border border-border/60",
"transition-all duration-200",
"hover:border-border hover:shadow-[var(--cp-shadow-1)] hover:-translate-y-0.5",
styles.hoverBorder
)}
>
<div
className={cn(
"flex-shrink-0 h-11 w-11 rounded-xl flex items-center justify-center",
"transition-all duration-[var(--cp-duration-normal)]",
"group-hover:scale-105",
"flex-shrink-0 h-10 w-10 rounded-lg flex items-center justify-center",
"transition-all duration-200",
styles.iconBg
)}
>
<Icon className={cn("h-5 w-5", styles.iconColor)} />
<Icon className={cn("h-4.5 w-4.5", styles.iconColor)} />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</p>
<p className="text-xs font-medium text-muted-foreground">{label}</p>
{value > 0 ? (
<p className="text-2xl font-bold text-foreground tabular-nums mt-0.5">{value}</p>
<p className="text-xl font-bold text-foreground tabular-nums mt-0.5 font-heading">
{value}
</p>
) : (
<p className="text-sm text-muted-foreground mt-1">{emptyText || "None"}</p>
<p className="text-xs text-muted-foreground mt-1">{emptyText || "None"}</p>
)}
</div>
<ArrowRightIcon className="h-4 w-4 text-muted-foreground/40 group-hover:text-foreground group-hover:translate-x-0.5 transition-all flex-shrink-0" />
<ArrowRightIcon className="h-3.5 w-3.5 text-muted-foreground/30 group-hover:text-foreground/60 group-hover:translate-x-0.5 transition-all flex-shrink-0" />
</Link>
);
}

View File

@ -54,37 +54,28 @@ function DashboardGreeting({
}) {
return (
<div className="mb-8">
<p
className="text-sm font-medium text-muted-foreground animate-in fade-in slide-in-from-bottom-2 duration-500"
style={{ animationDelay: "0ms" }}
>
Welcome back
</p>
<h2
className="text-3xl sm:text-4xl font-bold text-foreground mt-1 font-display animate-in fade-in slide-in-from-bottom-4 duration-500"
style={{ animationDelay: "50ms" }}
>
{displayName}
<h2 className="text-2xl sm:text-3xl font-bold text-foreground font-heading tracking-tight animate-in fade-in slide-in-from-bottom-2 duration-500">
Welcome back, {displayName}
</h2>
{taskCount > 0 ? (
<div
className="flex items-center gap-2 mt-3 animate-in fade-in slide-in-from-bottom-4 duration-500"
style={{ animationDelay: "100ms" }}
className="flex items-center gap-2 mt-2 animate-in fade-in slide-in-from-bottom-4 duration-500"
style={{ animationDelay: "50ms" }}
>
<span
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium",
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium",
hasUrgentTask ? "bg-danger/10 text-danger" : "bg-warning/10 text-warning"
)}
>
{hasUrgentTask && <ExclamationTriangleIcon className="h-4 w-4" />}
{hasUrgentTask && <ExclamationTriangleIcon className="h-3.5 w-3.5" />}
{taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`}
</span>
</div>
) : (
<p
className="text-sm text-muted-foreground mt-2 animate-in fade-in slide-in-from-bottom-4 duration-500"
style={{ animationDelay: "100ms" }}
className="text-sm text-muted-foreground mt-1.5 animate-in fade-in slide-in-from-bottom-4 duration-500"
style={{ animationDelay: "50ms" }}
>
Everything is up to date
</p>

View File

@ -3,9 +3,9 @@ import { Button } from "@/components/atoms/button";
export function CTABanner() {
return (
<section aria-label="Call to action" className="full-bleed bg-primary-soft">
<div aria-label="Call to action" className="bg-primary-soft">
<div className="mx-auto max-w-3xl px-6 sm:px-10 lg:px-14 py-14 sm:py-16 text-center">
<h2 className="text-2xl sm:text-3xl font-extrabold text-foreground">
<h2 className="text-2xl sm:text-3xl font-extrabold text-foreground font-heading">
Ready to Get Set Up?
</h2>
<p className="mt-2 text-base text-muted-foreground">
@ -27,6 +27,6 @@ export function CTABanner() {
</Button>
</div>
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,32 @@
import { cn } from "@/shared/utils";
interface ChapterProps {
children: React.ReactNode;
zIndex: number;
className?: string;
overlay?: boolean;
sticky?: boolean;
}
const CHAPTER_SHADOW = "shadow-[0_-8px_30px_-10px_rgba(0,0,0,0.08)]";
export function Chapter({
children,
zIndex,
className,
overlay = false,
sticky = true,
}: ChapterProps) {
return (
<section
className={cn(
sticky ? "sticky top-0 motion-reduce:relative" : "relative",
overlay && cn(CHAPTER_SHADOW, "motion-reduce:!shadow-none"),
className
)}
style={{ zIndex }}
>
{children}
</section>
);
}

View File

@ -95,19 +95,19 @@ function MapAndAddress() {
}
export function ContactSection() {
const [ref, isInView] = useInView();
const [ref, isInView] = useInView<HTMLElement>();
return (
<section
id="contact"
ref={ref}
className={cn(
"full-bleed bg-surface-sunken/30 py-14 sm:py-16 transition-all duration-700",
"bg-surface-sunken/30 py-14 sm:py-16 transition-all duration-700",
isInView ? "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-2xl sm:text-3xl font-extrabold text-foreground">
<h2 className="text-2xl sm:text-3xl font-extrabold text-foreground font-heading">
Tell Us What You Need
</h2>
<div className="rounded-2xl bg-card border border-border/60 shadow-sm p-5 sm:p-7">

View File

@ -10,13 +10,13 @@ interface HeroSectionProps {
}
export function HeroSection({ heroCTARef }: HeroSectionProps) {
const [heroRef, heroInView] = useInView();
const [heroRef, heroInView] = useInView<HTMLDivElement>();
return (
<section
<div
ref={heroRef}
className={cn(
"full-bleed py-16 sm:py-20 lg:py-24 overflow-hidden transition-all duration-700",
"relative flex-1 flex items-center py-16 sm:py-20 lg:py-24 overflow-hidden transition-all duration-700",
heroInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)}
>
@ -44,7 +44,7 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {
/>
<div className="relative mx-auto max-w-3xl px-6 sm:px-10 lg:px-14 text-center">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold leading-tight text-foreground">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold leading-tight text-foreground font-heading">
<span className="block">A One Stop Solution</span>
<span className="block text-primary mt-2">for Your IT Needs</span>
</h1>
@ -70,6 +70,6 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {
</Button>
</div>
</div>
</section>
</div>
);
}

View File

@ -4,7 +4,7 @@ import { memo, useEffect, useState } from "react";
import Link from "next/link";
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/shared/utils";
import { useCarousel, useInView } from "@/features/landing-page/hooks";
import { useSnapCarousel, useInView } from "@/features/landing-page/hooks";
import {
personalConversionCards,
businessConversionCards,
@ -101,9 +101,9 @@ const ACCENTS: Record<CarouselAccent, AccentStyles> = {
},
};
/* ─── Crossfade Card ─── */
/* ─── Service Card ─── */
const CrossfadeCard = memo(function CrossfadeCard({ card }: { card: ConversionServiceCard }) {
const ServiceCard = memo(function ServiceCard({ card }: { card: ConversionServiceCard }) {
const a = ACCENTS[card.accent];
return (
@ -139,7 +139,7 @@ const CrossfadeCard = memo(function CrossfadeCard({ card }: { card: ConversionSe
</div>
<p className="text-sm font-medium text-muted-foreground mb-1">{card.problemHook}</p>
<h3 className="text-2xl sm:text-3xl font-extrabold text-foreground mb-3 leading-tight">
<h3 className="text-2xl sm:text-3xl font-extrabold text-foreground mb-3 leading-tight font-heading">
{card.title}
</h3>
<p className="text-[15px] text-muted-foreground leading-relaxed mb-6 max-w-lg">
@ -197,7 +197,9 @@ function CarouselHeader({
<div className="mx-auto max-w-3xl px-6 sm:px-10 mb-10">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground">Our Services</h2>
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground font-heading">
Our Services
</h2>
<p className="mt-2 text-lg text-muted-foreground">
Everything you need to stay connected in Japan
</p>
@ -255,7 +257,7 @@ function CarouselNav({
const styles = ACCENTS[card.accent];
return (
<button
key={card.href}
key={`${card.title}-${i}`}
type="button"
aria-label={`Go to ${card.title}`}
onClick={() => goTo(i)}
@ -286,61 +288,47 @@ function CarouselNav({
export function ServicesCarousel() {
const [activeTab, setActiveTab] = useState<Tab>("personal");
const [sectionRef, isInView] = useInView();
const [sectionRef, isInView] = useInView<HTMLDivElement>();
const cards = activeTab === "personal" ? personalConversionCards : businessConversionCards;
const c = useCarousel({ items: cards });
const c = useSnapCarousel({ total: cards.length, autoPlayMs: 10000 });
useEffect(() => {
c.reset();
}, [activeTab, c.reset]);
return (
<section
<div
ref={sectionRef}
className={cn(
"full-bleed bg-surface-sunken/30 py-16 sm:py-20 transition-all duration-700",
"py-16 sm:py-20 transition-all duration-700",
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)}
>
<CarouselHeader activeTab={activeTab} onTabChange={setActiveTab} />
<div
className="mx-auto max-w-3xl px-6 sm:px-10"
onTouchStart={c.onTouchStart}
onTouchEnd={c.onTouchEnd}
ref={c.scrollRef}
className="flex overflow-x-auto snap-x snap-mandatory scrollbar-hide"
onPointerDown={c.onPointerDown}
onKeyDown={c.onKeyDown}
tabIndex={0}
role="region"
aria-label="Services carousel"
aria-roledescription="carousel"
>
{/* Grid stacking: all cards occupy the same cell, tallest defines height */}
<div className="grid grid-cols-1 grid-rows-1">
{cards.map((card, i) => {
const isActive = i === c.activeIndex;
return (
{cards.map((card, i) => (
<div
key={card.href}
className={cn(
"col-start-1 row-start-1",
"transition-[opacity,transform] duration-500 ease-out motion-reduce:transition-none",
isActive
? "opacity-100 translate-y-0 scale-100 z-10"
: cn(
"opacity-0 scale-[0.97] z-0 pointer-events-none",
c.direction === "next" ? "translate-y-3" : "-translate-y-3"
)
)}
key={`${card.title}-${i}`}
className="min-w-full snap-center px-6 sm:px-10"
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${c.total}: ${card.title}`}
aria-hidden={!isActive}
aria-label={`${i + 1} of ${cards.length}: ${card.title}`}
>
<CrossfadeCard card={card} />
<div className="mx-auto max-w-3xl">
<ServiceCard card={card} />
</div>
);
})}
</div>
))}
</div>
<CarouselNav
@ -350,6 +338,6 @@ export function ServicesCarousel() {
goPrev={c.goPrev}
goNext={c.goNext}
/>
</section>
</div>
);
}

View File

@ -18,7 +18,7 @@ export function SupportDownloadsSection() {
)}
>
<div className="mx-auto max-w-5xl px-6 sm:px-10 lg:px-14">
<h2 className="text-center text-2xl sm:text-3xl font-extrabold text-foreground tracking-tight mb-2">
<h2 className="text-center text-2xl sm:text-3xl font-extrabold text-foreground tracking-tight mb-2 font-heading">
Remote Support
</h2>
<p className="text-center text-muted-foreground mb-8">

View File

@ -58,7 +58,7 @@ function AnimatedValue({
const count = useCountUp({ end: value, duration, enabled, delay });
return (
<span className="text-3xl sm:text-4xl font-extrabold text-primary tabular-nums">
<span className="text-3xl sm:text-4xl font-extrabold text-primary tabular-nums font-heading">
{formatter ? formatter(count) : count}
{suffix}
</span>
@ -66,14 +66,14 @@ function AnimatedValue({
}
export function TrustStrip() {
const [ref, inView] = useInView();
const [ref, inView] = useInView<HTMLDivElement>();
return (
<section
<div
ref={ref}
aria-label="Company statistics"
className={cn(
"full-bleed py-10 sm:py-12 overflow-hidden transition-all duration-700",
"relative py-10 sm:py-12 overflow-hidden transition-all duration-700",
inView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)}
>
@ -104,7 +104,7 @@ export function TrustStrip() {
{...(stat.formatter ? { formatter: stat.formatter } : {})}
/>
) : (
<span className="text-xl sm:text-2xl font-extrabold text-primary">
<span className="text-xl sm:text-2xl font-extrabold text-primary font-heading">
{stat.text}
</span>
)}
@ -114,6 +114,6 @@ export function TrustStrip() {
))}
</div>
</div>
</section>
</div>
);
}

View File

@ -13,13 +13,13 @@ const trustPoints = [
];
export function WhyUsSection() {
const [ref, isInView] = useInView();
const [ref, isInView] = useInView<HTMLDivElement>();
return (
<section
<div
ref={ref}
className={cn(
"full-bleed bg-background transition-all duration-700",
"transition-all duration-700",
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)}
>
@ -36,7 +36,7 @@ export function WhyUsSection() {
</div>
<div className="space-y-6">
<div>
<h2 className="text-2xl sm:text-3xl font-extrabold text-primary uppercase tracking-wide mb-3">
<h2 className="text-2xl sm:text-3xl font-extrabold text-primary uppercase tracking-wide mb-3 font-heading">
Built on Trust and Excellence
</h2>
<p className="text-xl sm:text-2xl font-semibold text-foreground leading-relaxed">
@ -63,6 +63,6 @@ export function WhyUsSection() {
</div>
</div>
</div>
</section>
</div>
);
}

View File

@ -1,4 +1,5 @@
// Landing page sections
export { Chapter } from "./Chapter";
export { HeroSection } from "./HeroSection";
export { TrustStrip } from "./TrustStrip";
export { ServicesCarousel } from "./ServicesCarousel";

View File

@ -1,3 +1,3 @@
export { useCarousel, useInfiniteCarousel } from "./useInfiniteCarousel";
export { useInView } from "./useInView";
export { useSnapCarousel } from "./useSnapCarousel";
export { useStickyCta } from "./useStickyCta";

View File

@ -1,122 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
export function useCarousel<T>({ items, autoPlayMs = 5000 }: { items: T[]; autoPlayMs?: number }) {
const total = items.length;
const [activeIndex, setActiveIndex] = useState(0);
const [direction, setDirection] = useState<"next" | "prev">("next");
const activeIndexRef = useRef(activeIndex);
activeIndexRef.current = activeIndex;
const touchXRef = useRef(0);
const pausedRef = useRef(false);
const goTo = useCallback((i: number) => {
setDirection(i > activeIndexRef.current ? "next" : "prev");
setActiveIndex(i);
}, []);
const goNext = useCallback(() => {
setDirection("next");
setActiveIndex(prev => (prev + 1) % total);
}, [total]);
const goPrev = useCallback(() => {
setDirection("prev");
setActiveIndex(prev => (prev - 1 + total) % total);
}, [total]);
const reset = useCallback(() => {
setActiveIndex(0);
setDirection("next");
}, []);
const onTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) touchXRef.current = touch.clientX;
}, []);
// Auto-play: pause on user interaction, resume after delay
const interactionTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const pauseAutoPlay = useCallback(() => {
pausedRef.current = true;
clearTimeout(interactionTimerRef.current);
interactionTimerRef.current = setTimeout(() => {
pausedRef.current = false;
}, autoPlayMs * 2);
}, [autoPlayMs]);
const goToWithPause = useCallback(
(i: number) => {
pauseAutoPlay();
goTo(i);
},
[goTo, pauseAutoPlay]
);
const goNextWithPause = useCallback(() => {
pauseAutoPlay();
goNext();
}, [goNext, pauseAutoPlay]);
const goPrevWithPause = useCallback(() => {
pauseAutoPlay();
goPrev();
}, [goPrev, pauseAutoPlay]);
const onTouchEndWithPause = useCallback(
(e: React.TouchEvent) => {
const touch = e.changedTouches[0];
if (!touch) return;
const diff = touchXRef.current - touch.clientX;
if (Math.abs(diff) > 50) {
pauseAutoPlay();
if (diff > 0) goNext();
else goPrev();
}
},
[goNext, goPrev, pauseAutoPlay]
);
const onKeyDownWithPause = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") {
pauseAutoPlay();
goPrev();
} else if (e.key === "ArrowRight") {
pauseAutoPlay();
goNext();
}
},
[goPrev, goNext, pauseAutoPlay]
);
useEffect(() => {
if (total <= 1) return;
const id = setInterval(() => {
if (!pausedRef.current) {
setDirection("next");
setActiveIndex(prev => (prev + 1) % total);
}
}, autoPlayMs);
return () => clearInterval(id);
}, [total, autoPlayMs]);
return {
items,
total,
activeIndex,
direction,
goTo: goToWithPause,
goNext: goNextWithPause,
goPrev: goPrevWithPause,
reset,
onTouchStart,
onTouchEnd: onTouchEndWithPause,
onKeyDown: onKeyDownWithPause,
};
}
/** @deprecated Use `useCarousel` instead */
export const useInfiniteCarousel = useCarousel;

View File

@ -0,0 +1,124 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
interface UseSnapCarouselOptions {
total: number;
autoPlayMs?: number;
}
export function useSnapCarousel({ total, autoPlayMs = 10000 }: UseSnapCarouselOptions) {
const scrollRef = useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
const totalRef = useRef(total);
totalRef.current = total;
const pausedRef = useRef(false);
const pauseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const pauseAutoPlay = useCallback(() => {
pausedRef.current = true;
clearTimeout(pauseTimerRef.current);
pauseTimerRef.current = setTimeout(() => {
pausedRef.current = false;
}, autoPlayMs * 2);
}, [autoPlayMs]);
const getCurrentIndex = useCallback(() => {
const el = scrollRef.current;
if (!el || el.offsetWidth === 0) return 0;
return Math.round(el.scrollLeft / el.offsetWidth);
}, []);
// Sync activeIndex from scroll position (rAF-throttled)
useEffect(() => {
const container = scrollRef.current;
if (!container) return;
let rafId = 0;
const onScroll = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const index = getCurrentIndex();
setActiveIndex(Math.min(index, totalRef.current - 1));
});
};
container.addEventListener("scroll", onScroll, { passive: true });
return () => {
container.removeEventListener("scroll", onScroll);
cancelAnimationFrame(rafId);
};
}, [getCurrentIndex]);
const scrollToIndex = useCallback((index: number) => {
const container = scrollRef.current;
if (!container) return;
container.scrollTo({ left: index * container.offsetWidth, behavior: "smooth" });
}, []);
const goTo = useCallback(
(index: number) => {
pauseAutoPlay();
scrollToIndex(index);
},
[pauseAutoPlay, scrollToIndex]
);
const goNext = useCallback(() => {
pauseAutoPlay();
scrollToIndex((getCurrentIndex() + 1) % totalRef.current);
}, [pauseAutoPlay, scrollToIndex, getCurrentIndex]);
const goPrev = useCallback(() => {
pauseAutoPlay();
scrollToIndex((getCurrentIndex() - 1 + totalRef.current) % totalRef.current);
}, [pauseAutoPlay, scrollToIndex, getCurrentIndex]);
const reset = useCallback(() => {
const container = scrollRef.current;
if (!container) return;
container.scrollTo({ left: 0, behavior: "instant" });
setActiveIndex(0);
}, []);
// Auto-play
useEffect(() => {
if (total <= 1) return;
const id = setInterval(() => {
if (pausedRef.current) return;
const next = (getCurrentIndex() + 1) % totalRef.current;
scrollToIndex(next);
}, autoPlayMs);
return () => clearInterval(id);
}, [total, autoPlayMs, getCurrentIndex, scrollToIndex]);
const onPointerDown = useCallback(() => {
pauseAutoPlay();
}, [pauseAutoPlay]);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") goPrev();
else if (e.key === "ArrowRight") goNext();
},
[goPrev, goNext]
);
// Cleanup pause timer
useEffect(() => {
return () => clearTimeout(pauseTimerRef.current);
}, []);
return {
scrollRef,
activeIndex,
total,
goTo,
goNext,
goPrev,
reset,
onPointerDown,
onKeyDown,
};
}

View File

@ -4,6 +4,7 @@ import { ArrowRight } from "lucide-react";
import { Button } from "@/components/atoms/button";
import { useStickyCta } from "@/features/landing-page/hooks";
import {
Chapter,
HeroSection,
TrustStrip,
ServicesCarousel,
@ -17,14 +18,32 @@ export function PublicLandingView() {
const { heroCTARef, showStickyCTA } = useStickyCta();
return (
<div className="space-y-0 pb-8 pt-0">
<div className="pb-8">
{/* Chapter 1: Who we are */}
<Chapter
zIndex={1}
className="min-h-dvh flex flex-col bg-gradient-to-br from-surface-sunken via-background to-info-bg/80"
>
<HeroSection heroCTARef={heroCTARef} />
<TrustStrip />
</Chapter>
{/* Chapter 2: What we offer */}
<Chapter zIndex={2} overlay className="bg-surface-sunken/30">
<ServicesCarousel />
</Chapter>
{/* Chapter 3: Why choose us */}
<Chapter zIndex={3} overlay className="bg-background">
<WhyUsSection />
<CTABanner />
</Chapter>
{/* Chapter 4: Get in touch */}
<Chapter zIndex={4} overlay sticky={false} className="bg-background">
<SupportDownloadsSection />
<ContactSection />
</Chapter>
{/* Sticky Mobile CTA */}
{showStickyCTA && (

View File

@ -136,7 +136,7 @@ function HeroSection() {
<span className="inline-block rounded-full bg-primary/10 px-4 py-1.5 text-sm font-semibold tracking-wide text-primary">
Since 2002
</span>
<h1 className="text-display-lg font-extrabold leading-[1.1] tracking-tight font-display text-foreground">
<h1 className="text-display-lg font-extrabold leading-[1.1] tracking-tight font-heading text-foreground">
Your Trusted IT Partner <span className="cp-gradient-text">in Japan</span>
</h1>
<div className="max-w-lg space-y-4 text-base leading-relaxed text-muted-foreground sm:text-lg">
@ -173,7 +173,7 @@ function ServicesSection() {
<section className="full-bleed bg-background py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6 sm:px-8">
<div className="cp-stagger-children mb-10 max-w-xl">
<h2 className="text-display-sm font-bold font-display text-foreground">What We Do</h2>
<h2 className="text-display-sm font-bold font-heading text-foreground">What We Do</h2>
<p className="mt-3 leading-relaxed text-muted-foreground">
End-to-end IT services designed for the international community in Japan all in
English.
@ -202,7 +202,7 @@ function ValuesSection() {
<section className="full-bleed bg-surface-sunken py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6 sm:px-8">
<div className="cp-stagger-children mb-10 text-center">
<h2 className="text-display-sm font-bold font-display text-foreground">Our Values</h2>
<h2 className="text-display-sm font-bold font-heading text-foreground">Our Values</h2>
<p className="mx-auto mt-3 max-w-lg leading-relaxed text-muted-foreground">
These principles guide how we serve customers, support our community, and advance our
craft every day.
@ -240,7 +240,7 @@ function CorporateSection() {
<section className="full-bleed bg-background py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6 sm:px-8">
<div className="cp-stagger-children mb-10">
<h2 className="text-display-sm font-bold font-display text-foreground">Corporate Data</h2>
<h2 className="text-display-sm font-bold font-heading text-foreground">Corporate Data</h2>
</div>
<div className="grid grid-cols-1 gap-10 lg:grid-cols-5">

View File

@ -58,7 +58,7 @@ export function ServicesHero({
<h1
className={cn(
"text-2xl sm:text-3xl lg:text-4xl text-foreground leading-tight font-bold tracking-tight",
displayFont && "font-display",
displayFont && "font-heading",
animationClasses
)}
style={animated ? { animationDelay: "50ms" } : undefined}

View File

@ -0,0 +1,108 @@
"use client";
import Link from "next/link";
import { CalendarDaysIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import { StatusIndicator, type StatusIndicatorStatus } from "@/components/atoms";
import { cn } from "@/shared/utils";
import { useFormatCurrency } from "@/shared/hooks";
import type { Subscription, SubscriptionStatus } from "@customer-portal/domain/subscriptions";
import { SUBSCRIPTION_STATUS } from "@customer-portal/domain/subscriptions";
import { getBillingCycleLabel } from "@/features/subscriptions/utils/status-presenters";
function mapSubscriptionStatus(status: SubscriptionStatus): StatusIndicatorStatus {
switch (status) {
case SUBSCRIPTION_STATUS.ACTIVE:
return "active";
case SUBSCRIPTION_STATUS.PENDING:
return "pending";
case SUBSCRIPTION_STATUS.SUSPENDED:
case SUBSCRIPTION_STATUS.CANCELLED:
return "warning";
case SUBSCRIPTION_STATUS.TERMINATED:
return "error";
default:
return "inactive";
}
}
interface SubscriptionGridCardProps {
subscription: Subscription;
className?: string;
}
export function SubscriptionGridCard({ subscription, className }: SubscriptionGridCardProps) {
const { formatCurrency } = useFormatCurrency();
const statusIndicator = mapSubscriptionStatus(subscription.status);
const cycleLabel = getBillingCycleLabel(subscription.cycle);
return (
<Link
href={`/account/subscriptions/${subscription.serviceId}`}
className={cn(
"group flex flex-col p-4 rounded-xl bg-card border border-border/60",
"transition-all duration-200",
"hover:border-border hover:shadow-[var(--cp-shadow-2)] hover:-translate-y-0.5",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
className
)}
>
{/* Header: name + status */}
<div className="flex items-start justify-between gap-2 mb-3">
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-foreground truncate group-hover:text-primary transition-colors">
{subscription.productName}
</h3>
<p className="text-xs text-muted-foreground mt-0.5 font-mono">
#{subscription.serviceId}
</p>
</div>
<StatusIndicator status={statusIndicator} label={subscription.status} size="sm" />
</div>
{/* Price */}
<div className="mb-3">
<span className="text-lg font-bold text-foreground tabular-nums font-heading">
{formatCurrency(subscription.amount, subscription.currency)}
</span>
{cycleLabel && <span className="text-xs text-muted-foreground ml-1">/{cycleLabel}</span>}
</div>
{/* Footer: next due + action */}
<div className="flex items-center justify-between mt-auto pt-3 border-t border-border/40">
{subscription.nextDue && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CalendarDaysIcon className="h-3.5 w-3.5" />
<span>
{new Date(subscription.nextDue).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</span>
</div>
)}
<span className="inline-flex items-center gap-1 text-xs font-medium text-primary opacity-0 group-hover:opacity-100 transition-opacity duration-200">
Manage
<ArrowRightIcon className="h-3 w-3 group-hover:translate-x-0.5 transition-transform" />
</span>
</div>
</Link>
);
}
export function SubscriptionGridCardSkeleton() {
return (
<div className="flex flex-col p-4 rounded-xl bg-card border border-border/60">
<div className="flex items-start justify-between gap-2 mb-3">
<div className="space-y-1.5 flex-1">
<div className="h-4 cp-skeleton-shimmer rounded w-3/4" />
<div className="h-3 cp-skeleton-shimmer rounded w-16" />
</div>
<div className="h-4 cp-skeleton-shimmer rounded-full w-14" />
</div>
<div className="h-6 cp-skeleton-shimmer rounded w-20 mb-3" />
<div className="pt-3 border-t border-border/40">
<div className="h-3 cp-skeleton-shimmer rounded w-24" />
</div>
</div>
);
}

View File

@ -1,2 +1,3 @@
export * from "./SubscriptionStatusBadge";
export * from "./CancellationFlow";
export { SubscriptionGridCard, SubscriptionGridCardSkeleton } from "./SubscriptionGridCard";

View File

@ -2,17 +2,19 @@
import { useState, useMemo } from "react";
import { Button } from "@/components/atoms/button";
import { ErrorBoundary, SummaryStats } from "@/components/molecules";
import type { StatItem } from "@/components/molecules/SummaryStats";
import { ViewToggle, type ViewMode } from "@/components/atoms/view-toggle";
import { MetricCard, MetricCardSkeleton } from "@/components/molecules/MetricCard";
import { ErrorBoundary } from "@/components/molecules";
import { PageLayout } from "@/components/templates/PageLayout";
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
import {
SubscriptionStatsCardsSkeleton,
SubscriptionTableSkeleton,
} from "@/components/atoms/loading-skeleton";
import { SubscriptionTableSkeleton } from "@/components/atoms/loading-skeleton";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { SubscriptionTable } from "@/features/subscriptions/components/SubscriptionTable";
import { Server, CheckCircle, XCircle } from "lucide-react";
import {
SubscriptionGridCard,
SubscriptionGridCardSkeleton,
} from "@/features/subscriptions/components/SubscriptionGridCard";
import { Server, CheckCircle, XCircle, TrendingUp } from "lucide-react";
import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks";
import {
SUBSCRIPTION_STATUS,
@ -22,37 +24,83 @@ import {
const SUBSCRIPTION_STATUS_OPTIONS = Object.values(SUBSCRIPTION_STATUS) as SubscriptionStatus[];
function SubscriptionStatsCards({
function SubscriptionMetrics({
stats,
}: {
stats: { active: number; completed: number; cancelled: number };
}) {
const items: StatItem[] = [
{
icon: <CheckCircle className="h-5 w-5" />,
label: "Active",
value: stats.active,
tone: "success",
},
{
icon: <CheckCircle className="h-5 w-5" />,
label: "Completed",
value: stats.completed,
tone: "primary",
},
{
icon: <XCircle className="h-5 w-5" />,
label: "Cancelled",
value: stats.cancelled,
tone: "muted",
},
];
return <SummaryStats variant="cards" className="mb-6" items={items} />;
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
<MetricCard
icon={<CheckCircle className="h-5 w-5" />}
label="Active"
value={stats.active}
tone="success"
/>
<MetricCard
icon={<Server className="h-5 w-5" />}
label="Total"
value={stats.active + stats.completed + stats.cancelled}
tone="primary"
/>
<MetricCard
icon={<TrendingUp className="h-5 w-5" />}
label="Completed"
value={stats.completed}
tone="info"
/>
<MetricCard
icon={<XCircle className="h-5 w-5" />}
label="Cancelled"
value={stats.cancelled}
tone="neutral"
/>
</div>
);
}
function SubscriptionMetricsSkeleton() {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{Array.from({ length: 4 }).map((_, i) => (
<MetricCardSkeleton key={i} />
))}
</div>
);
}
function SubscriptionGrid({
subscriptions,
loading,
}: {
subscriptions: Subscription[];
loading: boolean;
}) {
if (subscriptions.length === 0 && !loading) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Server className="h-10 w-10 text-muted-foreground/30 mb-3" />
<p className="text-sm font-medium text-foreground">No subscriptions found</p>
<p className="text-xs text-muted-foreground mt-1">No active subscriptions at this time</p>
</div>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 cp-stagger-grid">
{subscriptions.map(sub => (
<SubscriptionGridCard key={sub.serviceId} subscription={sub} />
))}
{loading &&
Array.from({ length: 3 }).map((_, i) => <SubscriptionGridCardSkeleton key={`skel-${i}`} />)}
</div>
);
}
export function SubscriptionsListContainer() {
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<SubscriptionStatus | "all">("all");
const [viewMode, setViewMode] = useState<ViewMode>("grid");
const {
data: subscriptionData,
@ -85,6 +133,17 @@ export function SubscriptionsListContainer() {
[]
);
const activeFilters = useMemo(() => {
const filters: { label: string; onRemove: () => void }[] = [];
if (statusFilter !== "all") {
filters.push({ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("all") });
}
if (searchTerm) {
filters.push({ label: `Search: ${searchTerm}`, onRemove: () => setSearchTerm("") });
}
return filters;
}, [statusFilter, searchTerm]);
if (showLoading || error) {
return (
<PageLayout
@ -94,7 +153,7 @@ export function SubscriptionsListContainer() {
>
<AsyncBlock isLoading={false} error={error}>
<div className="space-y-6">
<SubscriptionStatsCardsSkeleton />
<SubscriptionMetricsSkeleton />
<SubscriptionTableSkeleton rows={6} />
</div>
</AsyncBlock>
@ -114,9 +173,10 @@ export function SubscriptionsListContainer() {
}
>
<ErrorBoundary>
{stats && <SubscriptionStatsCards stats={stats} />}
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
<div className="px-6 py-4 border-b border-border">
{stats && <SubscriptionMetrics stats={stats} />}
<div className="bg-card rounded-xl border border-border/60 shadow-[var(--cp-shadow-1)] overflow-hidden">
<div className="px-4 sm:px-5 py-3.5 border-b border-border/40">
<SearchFilterBar
searchValue={searchTerm}
onSearchChange={setSearchTerm}
@ -125,13 +185,26 @@ export function SubscriptionsListContainer() {
onFilterChange={value => setStatusFilter(value as SubscriptionStatus | "all")}
filterOptions={statusFilterOptions}
filterLabel="Filter by status"
/>
activeFilters={activeFilters}
>
<ViewToggle value={viewMode} onChange={setViewMode} />
</SearchFilterBar>
</div>
<div className="p-4 sm:p-5">
{viewMode === "grid" ? (
<SubscriptionGrid
subscriptions={filteredSubscriptions}
loading={isFetching && !!subscriptionData}
/>
) : (
<SubscriptionTable
subscriptions={filteredSubscriptions}
loading={isFetching && !!subscriptionData}
className="border-0 rounded-none shadow-none"
/>
)}
</div>
</div>
</ErrorBoundary>
</PageLayout>

View File

@ -54,7 +54,7 @@
--cp-page-padding: var(--cp-page-padding-x);
/* Sidebar */
--cp-sidebar-width: 16rem; /* 256px */
--cp-sidebar-width: 13.75rem; /* 220px */
--cp-sidebar-width-collapsed: 4rem; /* 64px */
/* ============= TYPOGRAPHY ============= */

View File

@ -242,6 +242,34 @@
animation-delay: calc(var(--cp-stagger-5) + 50ms);
}
/* Card grid stagger - faster delay for dense grids */
.cp-stagger-grid > * {
opacity: 0;
animation: cp-fade-up var(--cp-duration-normal) var(--cp-ease-out) forwards;
}
.cp-stagger-grid > *:nth-child(1) {
animation-delay: 0ms;
}
.cp-stagger-grid > *:nth-child(2) {
animation-delay: 30ms;
}
.cp-stagger-grid > *:nth-child(3) {
animation-delay: 60ms;
}
.cp-stagger-grid > *:nth-child(4) {
animation-delay: 90ms;
}
.cp-stagger-grid > *:nth-child(5) {
animation-delay: 120ms;
}
.cp-stagger-grid > *:nth-child(6) {
animation-delay: 150ms;
}
.cp-stagger-grid > *:nth-child(n + 7) {
animation-delay: 180ms;
}
/* ===== TAB SLIDE TRANSITIONS ===== */
.cp-slide-fade-left {
animation: cp-slide-fade-left 300ms var(--cp-ease-out) forwards;
@ -672,6 +700,7 @@
.cp-animate-scale-in,
.cp-animate-slide-left,
.cp-stagger-children > *,
.cp-stagger-grid > *,
.cp-card-hover-lift,
.cp-slide-fade-left,
.cp-slide-fade-right,

View File

@ -0,0 +1,162 @@
# Parallax Pinned Chapters + Snap Carousel Design
**Date:** 2026-03-05
**Status:** Approved
## Overview
Redesign the landing page scroll experience using a "chapter" model. Sections are grouped into 4 chapters that pin (sticky) in place as the user scrolls. Each subsequent chapter slides up and covers the previous one, creating a layered card-stack effect. The services carousel is rebuilt with CSS scroll-snap for native horizontal snapping.
## Chapter Structure
| Chapter | Sections | Purpose |
| ------- | -------------------------- | --------------- |
| 1 | HeroSection + TrustStrip | "Who we are" |
| 2 | ServicesCarousel | "What we offer" |
| 3 | WhyUsSection + CTABanner | "Why choose us" |
| 4 | SupportDownloads + Contact | "Get in touch" |
## Scroll Behavior
### Pinning Mechanism
- Each chapter wrapper uses `position: sticky; top: 0`
- Chapters stack with increasing `z-index` (1, 2, 3, 4)
- Each chapter has a solid background so it fully covers the chapter behind it
- A subtle `box-shadow` on the top edge of each chapter creates the "sliding over" depth illusion
- The outer container uses `scroll-snap-type: y proximity` for soft vertical snap (helps chapters land cleanly but does not fight free scrolling)
### Chapter Details
**Chapter 1: Hero + TrustStrip**
- `min-height: 100dvh` to fill the viewport
- Hero fills most of the space, TrustStrip anchored at the bottom
- Pins in place while Chapter 2 slides up over it
- Existing gradient background + dot pattern preserved
**Chapter 2: ServicesCarousel**
- Natural content height (not forced to viewport height)
- Pins in place while Chapter 3 slides up
- Carousel rebuilt with CSS scroll-snap (see Carousel section below)
**Chapter 3: WhyUs + CTABanner**
- Natural content height
- Pins in place while Chapter 4 slides up
- WhyUs image gets a subtle parallax speed difference (scrolls slightly slower than text) for added depth
**Chapter 4: SupportDownloads + Contact**
- Normal scroll, no pinning (last chapter, nothing covers it)
- Existing fade-in animations preserved
## Carousel Rebuild (CSS Scroll-Snap)
The current carousel uses absolute positioning with JS-driven transforms and offset calculations. This will be replaced with a native CSS scroll-snap approach.
### New approach
- Horizontal scroll container with `scroll-snap-type: x mandatory`
- Each card is a snap point with `scroll-snap-align: center`
- Cards are laid out in a flex row, each taking full width of the visible area
- Native touch/swipe works out of the box
- Dot indicators sync with scroll position via `IntersectionObserver` or `scrollLeft` calculation
- Arrow buttons use `scrollBy()` with `behavior: 'smooth'`
- Auto-play uses `scrollBy()` and pauses on user interaction (touch, hover, focus)
- Personal/Business tab toggle resets scroll position to 0
### Benefits over current approach
- No absolute positioning or transform math
- Touch/swipe is native and performant
- Reduced JS complexity
- Better accessibility (native scroll semantics)
- Respects `prefers-reduced-motion` automatically
## Technical Implementation
### New component: ChapterWrapper
A reusable wrapper that applies sticky positioning:
```tsx
interface ChapterProps {
children: React.ReactNode;
zIndex: number;
className?: string;
}
function Chapter({ children, zIndex, className }: ChapterProps) {
return (
<section className={cn("sticky top-0", className)} style={{ zIndex }}>
{children}
</section>
);
}
```
### Updated PublicLandingView structure
```tsx
<main>
<Chapter zIndex={1} className="min-h-dvh">
<HeroSection />
<TrustStrip />
</Chapter>
<Chapter zIndex={2}>
<ServicesCarousel />
</Chapter>
<Chapter zIndex={3}>
<WhyUsSection />
<CTABanner />
</Chapter>
<div>
{" "}
{/* No sticky - last chapter */}
<SupportDownloadsSection />
<ContactSection />
</div>
</main>
```
### Shadow/depth effect
Each Chapter (except Chapter 1) gets a top shadow to enhance the "sliding over" illusion:
```css
.chapter-overlay {
box-shadow: 0 -8px 30px -10px rgba(0, 0, 0, 0.1);
}
```
## Mobile Considerations
- Use `100dvh` (dynamic viewport height) instead of `100vh` to avoid iOS address bar issues
- Carousel CSS scroll-snap is touch-native and works well on mobile
- Sticky positioning works on mobile browsers (iOS Safari, Chrome Android)
- If performance is poor on low-end devices, sticky can be disabled via a CSS class
## Accessibility
- `prefers-reduced-motion: reduce` disables sticky pinning behavior (falls back to normal scroll)
- Carousel maintains keyboard navigation (arrow keys, tab through cards)
- ARIA attributes preserved: `role="region"`, `aria-roledescription="carousel"`, slide labels
- Scroll-snap does not interfere with screen readers
## Files to Modify
| File | Change |
| ------------------------------------------------------- | --------------------------------------- |
| `features/landing-page/views/PublicLandingView.tsx` | Wrap sections in Chapter components |
| `features/landing-page/components/ServicesCarousel.tsx` | Rebuild with CSS scroll-snap |
| `features/landing-page/components/HeroSection.tsx` | Adjust to fill chapter space (flex-1) |
| `features/landing-page/components/TrustStrip.tsx` | Adjust to anchor at bottom of Chapter 1 |
| `features/landing-page/hooks/useInfiniteCarousel.ts` | Replace with scroll-snap hook or remove |
| `features/landing-page/components/index.ts` | Export new Chapter component |
| New: `features/landing-page/components/Chapter.tsx` | Chapter wrapper component |
| New: `features/landing-page/hooks/useSnapCarousel.ts` | Hook for scroll-snap carousel state |

View File

@ -0,0 +1,619 @@
# Parallax Pinned Chapters + Snap Carousel Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Redesign the landing page with sticky "chapter" sections that pin and stack as users scroll, plus a native CSS scroll-snap carousel for services.
**Architecture:** Landing page sections grouped into 4 chapters wrapped in sticky containers with ascending z-index. Each chapter slides over the previous one. The services carousel is rebuilt from JS-driven absolute positioning to native CSS scroll-snap. A `useSnapCarousel` hook manages scroll state, auto-play, and indicator sync.
**Tech Stack:** Next.js 15, React 19, Tailwind CSS v4 (with `@utility`), CSS `position: sticky`, CSS `scroll-snap-type`
**Design doc:** `docs/plans/2026-03-05-parallax-pinned-chapters-design.md`
---
## Task 1: Create the Chapter wrapper component
**Files:**
- Create: `apps/portal/src/features/landing-page/components/Chapter.tsx`
- Modify: `apps/portal/src/features/landing-page/components/index.ts`
**Step 1: Create `Chapter.tsx`**
```tsx
// apps/portal/src/features/landing-page/components/Chapter.tsx
import { cn } from "@/shared/utils";
interface ChapterProps {
children: React.ReactNode;
zIndex: number;
className?: string;
overlay?: boolean;
}
export function Chapter({ children, zIndex, className, overlay = false }: ChapterProps) {
return (
<section
className={cn(
"sticky top-0 will-change-transform",
"motion-safe:sticky motion-reduce:relative",
overlay && "shadow-[0_-8px_30px_-10px_rgba(0,0,0,0.08)]",
className
)}
style={{ zIndex }}
>
{children}
</section>
);
}
```
**Step 2: Add export to `components/index.ts`**
Add this line to `apps/portal/src/features/landing-page/components/index.ts`:
```ts
export { Chapter } from "./Chapter";
```
**Step 3: Verify types compile**
Run: `pnpm type-check`
Expected: PASS (no type errors)
**Step 4: Commit**
```
feat: add Chapter sticky wrapper component
```
---
## Task 2: Create the `useSnapCarousel` hook
**Files:**
- Create: `apps/portal/src/features/landing-page/hooks/useSnapCarousel.ts`
- Modify: `apps/portal/src/features/landing-page/hooks/index.ts`
**Step 1: Create `useSnapCarousel.ts`**
This hook manages a CSS scroll-snap carousel: tracks the active index via scroll position, provides `goTo`/`goNext`/`goPrev` via `scrollTo`, handles auto-play with pause-on-interaction, and syncs dot indicators.
```tsx
// apps/portal/src/features/landing-page/hooks/useSnapCarousel.ts
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
interface UseSnapCarouselOptions {
total: number;
autoPlayMs?: number;
}
export function useSnapCarousel({ total, autoPlayMs = 10000 }: UseSnapCarouselOptions) {
const scrollRef = useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
// Auto-play pause tracking
const pausedRef = useRef(false);
const pauseTimerRef = useRef<ReturnType<typeof setTimeout>>();
const pauseAutoPlay = useCallback(() => {
pausedRef.current = true;
clearTimeout(pauseTimerRef.current);
pauseTimerRef.current = setTimeout(() => {
pausedRef.current = false;
}, autoPlayMs * 2);
}, [autoPlayMs]);
// Sync activeIndex from scroll position
useEffect(() => {
const container = scrollRef.current;
if (!container) return;
const onScroll = () => {
const scrollLeft = container.scrollLeft;
const cardWidth = container.offsetWidth;
if (cardWidth === 0) return;
const index = Math.round(scrollLeft / cardWidth);
setActiveIndex(Math.min(index, total - 1));
};
container.addEventListener("scroll", onScroll, { passive: true });
return () => container.removeEventListener("scroll", onScroll);
}, [total]);
const scrollToIndex = useCallback((index: number) => {
const container = scrollRef.current;
if (!container) return;
const cardWidth = container.offsetWidth;
container.scrollTo({ left: index * cardWidth, behavior: "smooth" });
}, []);
const goTo = useCallback(
(index: number) => {
pauseAutoPlay();
scrollToIndex(index);
},
[pauseAutoPlay, scrollToIndex]
);
const goNext = useCallback(() => {
pauseAutoPlay();
const next = (activeIndex + 1) % total;
scrollToIndex(next);
}, [activeIndex, total, pauseAutoPlay, scrollToIndex]);
const goPrev = useCallback(() => {
pauseAutoPlay();
const prev = (activeIndex - 1 + total) % total;
scrollToIndex(prev);
}, [activeIndex, total, pauseAutoPlay, scrollToIndex]);
const reset = useCallback(() => {
const container = scrollRef.current;
if (!container) return;
container.scrollTo({ left: 0, behavior: "instant" });
setActiveIndex(0);
}, []);
// Auto-play
useEffect(() => {
if (total <= 1) return;
const id = setInterval(() => {
if (pausedRef.current) return;
const container = scrollRef.current;
if (!container) return;
const cardWidth = container.offsetWidth;
const currentIndex = Math.round(container.scrollLeft / cardWidth);
const next = (currentIndex + 1) % total;
container.scrollTo({ left: next * cardWidth, behavior: "smooth" });
}, autoPlayMs);
return () => clearInterval(id);
}, [total, autoPlayMs]);
// Pause on touch/pointer interaction
const onPointerDown = useCallback(() => {
pauseAutoPlay();
}, [pauseAutoPlay]);
// Cleanup
useEffect(() => {
return () => clearTimeout(pauseTimerRef.current);
}, []);
// Keyboard navigation
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") goPrev();
else if (e.key === "ArrowRight") goNext();
},
[goPrev, goNext]
);
return {
scrollRef,
activeIndex,
total,
goTo,
goNext,
goPrev,
reset,
onPointerDown,
onKeyDown,
};
}
```
**Step 2: Update `hooks/index.ts`**
Add this export to `apps/portal/src/features/landing-page/hooks/index.ts`:
```ts
export { useSnapCarousel } from "./useSnapCarousel";
```
**Step 3: Verify types compile**
Run: `pnpm type-check`
Expected: PASS
**Step 4: Commit**
```
feat: add useSnapCarousel hook for CSS scroll-snap carousel
```
---
## Task 3: Rebuild ServicesCarousel with CSS scroll-snap
**Files:**
- Modify: `apps/portal/src/features/landing-page/components/ServicesCarousel.tsx`
**Context:** The current carousel uses absolute positioning with JS transform calculations (`getCircularOffset`, `translateX(${offset * 102}%)`). Replace with a native horizontal scroll container using CSS scroll-snap.
**Step 1: Rewrite `ServicesCarousel.tsx`**
Key changes:
- Replace `useCarousel` with `useSnapCarousel`
- Replace the absolute-positioned card layout with a horizontal flex scroll container
- Add `scroll-snap-type: x mandatory` on the container
- Add `scroll-snap-align: center` on each card
- Each card is `min-w-full` (takes full width of the scroll viewport)
- Remove the invisible sizer div hack
- Remove `onTouchStart`/`onTouchEnd` (native scroll handles swipe)
- Remove `getCircularOffset` helper
- Keep: `CarouselHeader`, `CarouselNav`, `ServiceCard` (unchanged), `ACCENTS` map
- Keep: `useInView` for the section fade-in animation
- Remove the `full-bleed` class from the section (Chapter wrapper will handle full-width)
The scroll container structure:
```tsx
<div
ref={c.scrollRef}
className="flex overflow-x-auto snap-x snap-mandatory scrollbar-hide"
onPointerDown={c.onPointerDown}
onKeyDown={c.onKeyDown}
tabIndex={0}
role="region"
aria-label="Services carousel"
aria-roledescription="carousel"
>
{cards.map((card, i) => (
<div
key={`${card.title}-${i}`}
className="min-w-full snap-center px-6 sm:px-10"
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${cards.length}: ${card.title}`}
>
<div className="mx-auto max-w-3xl">
<ServiceCard card={card} />
</div>
</div>
))}
</div>
```
Also add scrollbar-hide utility if not already present. Check `apps/portal/src/styles/utilities.css` for existing `scrollbar-hide`. If missing, add to the same file:
```css
@utility scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
```
**Step 2: Remove `full-bleed` from section**
The section should no longer use `full-bleed` since the Chapter wrapper handles the full-width layout. Change the section's className from:
```
"full-bleed bg-surface-sunken/30 py-16 sm:py-20 ..."
```
to:
```
"bg-surface-sunken/30 py-16 sm:py-20 ..."
```
**Step 3: Wire up `useSnapCarousel`**
```tsx
const c = useSnapCarousel({ total: cards.length, autoPlayMs: 10000 });
```
Call `c.reset()` when `activeTab` changes (same as before).
**Step 4: Verify types compile**
Run: `pnpm type-check`
Expected: PASS
**Step 5: Commit**
```
refactor: rebuild ServicesCarousel with CSS scroll-snap
```
---
## Task 4: Update HeroSection and TrustStrip for Chapter 1
**Files:**
- Modify: `apps/portal/src/features/landing-page/components/HeroSection.tsx`
- Modify: `apps/portal/src/features/landing-page/components/TrustStrip.tsx`
**Context:** Chapter 1 wraps Hero + TrustStrip. The chapter is `min-h-dvh flex flex-col`. Hero should flex-grow to fill available space, TrustStrip anchors at the bottom.
**Step 1: Update HeroSection**
- Remove `full-bleed` from the section (Chapter wrapper handles full-width)
- Add `flex-1` so it expands to fill the chapter
- Change the section to a `div` (the Chapter `<section>` is the semantic wrapper now)
- Keep the gradient background, dot pattern, and all content unchanged
Change:
```tsx
<section ref={heroRef} className={cn("full-bleed py-16 sm:py-20 lg:py-24 ...")}>
```
To:
```tsx
<div ref={heroRef} className={cn("relative flex-1 flex items-center py-16 sm:py-20 lg:py-24 ...")}>
```
**Step 2: Update TrustStrip**
- Remove `full-bleed` from the section
- Change to a `div` (Chapter is the semantic `<section>`)
- Keep all content and animation unchanged
Change:
```tsx
<section ref={ref} aria-label="Company statistics" className={cn("full-bleed py-10 sm:py-12 ...")}>
```
To:
```tsx
<div ref={ref} aria-label="Company statistics" className={cn("relative py-10 sm:py-12 ...")}>
```
**Step 3: Verify types compile**
Run: `pnpm type-check`
Expected: PASS
**Step 4: Commit**
```
refactor: adjust HeroSection and TrustStrip for Chapter 1 layout
```
---
## Task 5: Update WhyUsSection and CTABanner for Chapter 3
**Files:**
- Modify: `apps/portal/src/features/landing-page/components/WhyUsSection.tsx`
- Modify: `apps/portal/src/features/landing-page/components/CTABanner.tsx`
**Step 1: Update WhyUsSection**
- Remove `full-bleed` from the section
- Change `<section>` to `<div>`
- Keep all content unchanged
**Step 2: Update CTABanner**
- Remove `full-bleed` from the section
- Change `<section>` to `<div>`
- Keep all content unchanged
**Step 3: Verify types compile**
Run: `pnpm type-check`
Expected: PASS
**Step 4: Commit**
```
refactor: adjust WhyUsSection and CTABanner for Chapter 3 layout
```
---
## Task 6: Update SupportDownloadsSection and ContactSection for Chapter 4
**Files:**
- Modify: `apps/portal/src/features/landing-page/components/SupportDownloadsSection.tsx`
- Modify: `apps/portal/src/features/landing-page/components/ContactSection.tsx`
**Step 1: Update ContactSection**
- Remove `full-bleed` from the section
- Change `<section>` to `<div>` (or keep as `<section>` since Chapter 4 is a plain `<div>`)
Actually, Chapter 4 is NOT sticky (it's the last chapter, just a plain wrapper). So ContactSection and SupportDownloadsSection can remain as `<section>` elements. But still remove `full-bleed` since the Chapter 4 wrapper handles layout.
- ContactSection: remove `full-bleed` from className
- SupportDownloadsSection: no `full-bleed` to remove (it doesn't use it), no changes needed
**Step 2: Verify types compile**
Run: `pnpm type-check`
Expected: PASS
**Step 3: Commit**
```
refactor: adjust ContactSection for Chapter 4 layout
```
---
## Task 7: Wire up PublicLandingView with Chapter wrappers
**Files:**
- Modify: `apps/portal/src/features/landing-page/views/PublicLandingView.tsx`
**Context:** This is the main composition file. Wrap section groups in Chapter components.
**Step 1: Rewrite `PublicLandingView.tsx`**
```tsx
"use client";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/atoms/button";
import { useStickyCta } from "@/features/landing-page/hooks";
import {
Chapter,
HeroSection,
TrustStrip,
ServicesCarousel,
WhyUsSection,
CTABanner,
SupportDownloadsSection,
ContactSection,
} from "@/features/landing-page/components";
export function PublicLandingView() {
const { heroCTARef, showStickyCTA } = useStickyCta();
return (
<div className="pb-8">
{/* Chapter 1: Who we are */}
<Chapter zIndex={1} className="min-h-dvh flex flex-col bg-background">
<HeroSection heroCTARef={heroCTARef} />
<TrustStrip />
</Chapter>
{/* Chapter 2: What we offer */}
<Chapter zIndex={2} overlay className="bg-surface-sunken/30">
<ServicesCarousel />
</Chapter>
{/* Chapter 3: Why choose us */}
<Chapter zIndex={3} overlay className="bg-background">
<WhyUsSection />
<CTABanner />
</Chapter>
{/* Chapter 4: Get in touch (no sticky, last chapter) */}
<div className="relative bg-background" style={{ zIndex: 4 }}>
<SupportDownloadsSection />
<ContactSection />
</div>
{/* Sticky Mobile CTA */}
{showStickyCTA && (
<div className="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur-sm border-t border-border p-4 z-50 md:hidden animate-in slide-in-from-bottom-4 duration-300">
<Button
as="a"
href="/services"
variant="pill"
size="lg"
rightIcon={<ArrowRight className="h-5 w-5" />}
className="w-full shadow-lg"
>
Find Your Plan
</Button>
</div>
)}
</div>
);
}
```
Key changes:
- Each section group wrapped in `Chapter` with ascending `zIndex`
- Chapter 1 gets `min-h-dvh flex flex-col` to fill viewport
- Chapters 2 and 3 get `overlay` prop for the top shadow
- Each Chapter gets a solid `bg-*` class so it covers the chapter behind
- Chapter 4 is a plain `div` with `zIndex: 4` and `relative` positioning
- Removed `space-y-0 pt-0` from outer container (chapters handle spacing)
**Step 2: Verify types compile**
Run: `pnpm type-check`
Expected: PASS
**Step 3: Commit**
```
feat: wire up PublicLandingView with sticky Chapter layout
```
---
## Task 8: Visual QA and polish
**Files:**
- Potentially any of the above files for tweaks
**Step 1: Run the dev server and test**
Run: `pnpm --filter @customer-portal/portal dev` (with user permission)
**Step 2: Visual checklist**
Test in browser at `localhost:3000`:
- [ ] Chapter 1 (Hero + TrustStrip) fills the viewport and pins
- [ ] Scrolling down: Chapter 2 (Services) slides up and covers Chapter 1
- [ ] Services carousel: swipe/drag snaps cards into place
- [ ] Services carousel: dot indicators stay in sync
- [ ] Services carousel: arrow buttons work
- [ ] Services carousel: personal/business tab toggle resets to first card
- [ ] Services carousel: auto-play advances cards
- [ ] Chapter 3 (WhyUs + CTA) slides up and covers Chapter 2
- [ ] Chapter 4 (Support + Contact) scrolls normally
- [ ] Contact form is fully interactive (not blocked by sticky)
- [ ] Sticky mobile CTA still appears when hero scrolls out of view
- [ ] `prefers-reduced-motion`: sticky behavior disabled, normal scroll
- [ ] No horizontal overflow / layout shifts
- [ ] Test on mobile viewport (responsive)
**Step 3: Fix any issues found**
Adjust padding, backgrounds, shadows, z-index as needed.
**Step 4: Run lint and type-check**
Run: `pnpm lint && pnpm type-check`
Expected: PASS
**Step 5: Commit**
```
style: polish parallax chapters and snap carousel
```
---
## Task 9: Clean up deprecated code
**Files:**
- Modify: `apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts`
- Modify: `apps/portal/src/features/landing-page/hooks/index.ts`
**Step 1: Check if `useCarousel` / `useInfiniteCarousel` is used elsewhere**
Search for imports of `useCarousel` and `useInfiniteCarousel` across the codebase. If only used in the old ServicesCarousel (now replaced), remove the file.
**Step 2: If unused, delete `useInfiniteCarousel.ts`**
Remove the file and remove its exports from `hooks/index.ts`.
**Step 3: Run lint and type-check**
Run: `pnpm lint && pnpm type-check`
Expected: PASS
**Step 4: Commit**
```
chore: remove unused useCarousel hook (replaced by useSnapCarousel)
```

File diff suppressed because it is too large Load Diff

BIN
docs/plans/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

BIN
image.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 506 KiB