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" />
/// <reference types="next/image-types/global" /> /// <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 // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

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

View File

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

View File

@ -19,6 +19,8 @@ const buttonVariants = cva(
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]", "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", 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", 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", 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", 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 // Navigation and Steps
export { StepHeader } from "./step-header"; export { StepHeader } from "./step-header";
// Status
export { StatusIndicator, type StatusIndicatorStatus } from "./status-indicator";
// View controls
export { ViewToggle, type ViewMode } from "./view-toggle";
// Animation // Animation
export { AnimatedContainer } from "./animated-container"; 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 type { ReactNode } from "react";
import { MagnifyingGlassIcon, FunnelIcon } from "@heroicons/react/24/outline"; import { MagnifyingGlassIcon, FunnelIcon, XMarkIcon } from "@heroicons/react/24/outline";
interface FilterOption { interface FilterOption {
value: string; value: string;
@ -14,6 +16,7 @@ interface SearchFilterBarProps {
onFilterChange?: (value: string) => void; onFilterChange?: (value: string) => void;
filterOptions?: FilterOption[]; filterOptions?: FilterOption[];
filterLabel?: string; filterLabel?: string;
activeFilters?: { label: string; onRemove: () => void }[] | undefined;
children?: ReactNode; children?: ReactNode;
} }
@ -23,52 +26,66 @@ export function SearchFilterBar({
searchPlaceholder = "Search...", searchPlaceholder = "Search...",
filterValue, filterValue,
onFilterChange, onFilterChange,
filterOptions = [], filterOptions,
filterLabel, filterLabel = "Filter",
activeFilters,
children, children,
}: SearchFilterBarProps) { }: SearchFilterBarProps) {
return ( return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="space-y-3">
{/* Search */} <div className="flex flex-col sm:flex-row gap-2.5">
<div className="relative flex-1 max-w-sm"> {/* Search */}
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div className="relative flex-1 max-w-sm">
<MagnifyingGlassIcon className="h-5 w-5 text-muted-foreground" /> <MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/50" />
<input
type="text"
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> </div>
<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)}
/>
</div>
{/* Filters and Actions */} <div className="flex items-center gap-2">
<div className="flex items-center gap-3"> {/* Filter select */}
{/* Status Filter */} {filterOptions && onFilterChange && (
{filterOptions.length > 0 && onFilterChange && filterValue !== undefined && ( <div className="relative">
<div className="relative"> <select
<select value={filterValue ?? "all"}
value={filterValue} onChange={e => onFilterChange(e.target.value)}
onChange={e => onFilterChange(e.target.value)} 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"
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" aria-label={filterLabel}
aria-label={filterLabel} >
> {filterOptions.map(opt => (
{filterOptions.map(option => ( <option key={opt.value} value={opt.value}>
<option key={option.value} value={option.value}> {opt.label}
{option.label} </option>
</option> ))}
))} </select>
</select> <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 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> </div>
</div> )}
)}
{/* Additional children (custom actions) */} {/* Custom actions (ViewToggle, etc.) */}
{children} {children}
</div>
</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> </div>
); );
} }

View File

@ -223,7 +223,7 @@ function DefaultCard({
{renderIcon(icon, "h-6 w-6")} {renderIcon(icon, "h-6 w-6")}
</div> </div>
<div className="flex-1"> <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 && ( {price && (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
From <span className="font-medium text-foreground">{price}</span> From <span className="font-medium text-foreground">{price}</span>
@ -277,7 +277,7 @@ function FeaturedCard({ href, icon, title, description, highlight, className }:
</div> </div>
{/* Content */} {/* 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 && ( {description && (
<p className="text-sm text-muted-foreground leading-relaxed flex-grow">{description}</p> <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"> <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")} {renderIcon(icon, "h-6 w-6 text-primary")}
</div> </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> </div>
</CardWrapper> </CardWrapper>
); );
@ -401,7 +401,7 @@ function BentoMediumCard({
{renderIcon(icon, "h-6 w-6")} {renderIcon(icon, "h-6 w-6")}
</div> </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 && ( {description && (
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p> <p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
)} )}
@ -460,7 +460,7 @@ function BentoLargeCard({
{renderIcon(icon, "h-7 w-7")} {renderIcon(icon, "h-7 w-7")}
</div> </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 && ( {description && (
<p className="text-muted-foreground leading-relaxed max-w-sm mb-6">{description}</p> <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 "./ClearFiltersButton";
export * from "./DetailStatsGrid"; export * from "./DetailStatsGrid";
// Metric display
export { MetricCard, MetricCardSkeleton, type MetricCardProps } from "./MetricCard";
// Navigation molecules // Navigation molecules
export * from "./BackLink"; export * from "./BackLink";

View File

@ -199,7 +199,7 @@ export function AppShell({ children }: AppShellProps) {
{/* Desktop sidebar */} {/* Desktop sidebar */}
<div className="hidden md:flex md:flex-shrink-0"> <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 <Sidebar
navigation={navigation} navigation={navigation}
pathname={pathname} pathname={pathname}

View File

@ -2,7 +2,11 @@
import Link from "next/link"; import Link from "next/link";
import { memo } from "react"; 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"; import { NotificationBell } from "@/features/notifications";
interface UserInfo { interface UserInfo {
@ -39,50 +43,62 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
const initials = getInitials(user, profileReady, displayName); const initials = getInitials(user, profileReady, displayName);
return ( return (
<div className="relative z-40 bg-header border-b border-header-border/50 backdrop-blur-xl"> <div className="relative z-40 bg-header/80 backdrop-blur-xl border-b border-border/40">
<div className="flex items-center h-16 gap-2 sm:gap-3 px-3 sm:px-6"> <div className="flex items-center h-14 gap-2 px-3 sm:px-5">
{/* Mobile menu button - 44px minimum touch target */} {/* Mobile menu button */}
<button <button
type="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} onClick={onMenuClick}
aria-label="Open navigation" 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> </button>
<div className="flex-1" /> <div className="flex-1" />
{/* Right side actions */} {/* Right side actions */}
<div className="flex items-center gap-1 sm:gap-2"> <div className="flex items-center gap-0.5">
{/* Notification bell */} {/* Notification bell */}
<NotificationBell /> <NotificationBell />
{/* Help link - visible on larger screens */} {/* Help link */}
<Link <Link
href="/account/support" href="/account/support"
prefetch prefetch
aria-label="Help" 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" title="Support Center"
> >
<QuestionMarkCircleIcon className="h-5 w-5" /> <QuestionMarkCircleIcon className="h-4.5 w-4.5" />
</Link> </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 <Link
href="/account/settings" href="/account/settings"
prefetch 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} {initials}
</div> </div>
{/* Show truncated name on mobile, full name on larger screens */} <span className="hidden sm:inline text-[13px]">{displayName}</span>
<span className="hidden xs:inline sm:hidden max-w-[80px] truncate text-sm">
{displayName.split(" ")[0]}
</span>
<span className="hidden sm:inline">{displayName}</span>
</Link> </Link>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@ -33,7 +33,7 @@ function FooterLinkColumn({
}) { }) {
return ( return (
<div> <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"> <ul className="space-y-2 text-sm">
{links.map(link => ( {links.map(link => (
<li key={link.href}> <li key={link.href}>

View File

@ -41,7 +41,7 @@ export function AuthLayout({
)} )}
<div className="text-center"> <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} {title}
</h1> </h1>
{subtitle && ( {subtitle && (

View File

@ -58,32 +58,33 @@ function StatItem({ icon: Icon, label, value, href, tone = "primary", emptyText
<Link <Link
href={href} href={href}
className={cn( className={cn(
"group flex items-center gap-4 p-4 rounded-xl", "group flex items-center gap-3.5 p-3.5 rounded-xl",
"bg-surface border border-border/60", "bg-card border border-border/60",
"transition-all duration-[var(--cp-duration-normal)]", "transition-all duration-200",
"hover:shadow-md hover:-translate-y-0.5", "hover:border-border hover:shadow-[var(--cp-shadow-1)] hover:-translate-y-0.5",
styles.hoverBorder styles.hoverBorder
)} )}
> >
<div <div
className={cn( className={cn(
"flex-shrink-0 h-11 w-11 rounded-xl flex items-center justify-center", "flex-shrink-0 h-10 w-10 rounded-lg flex items-center justify-center",
"transition-all duration-[var(--cp-duration-normal)]", "transition-all duration-200",
"group-hover:scale-105",
styles.iconBg styles.iconBg
)} )}
> >
<Icon className={cn("h-5 w-5", styles.iconColor)} /> <Icon className={cn("h-4.5 w-4.5", styles.iconColor)} />
</div> </div>
<div className="min-w-0 flex-1"> <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 ? ( {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> </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> </Link>
); );
} }

View File

@ -54,37 +54,28 @@ function DashboardGreeting({
}) { }) {
return ( return (
<div className="mb-8"> <div className="mb-8">
<p <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">
className="text-sm font-medium text-muted-foreground animate-in fade-in slide-in-from-bottom-2 duration-500" Welcome back, {displayName}
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> </h2>
{taskCount > 0 ? ( {taskCount > 0 ? (
<div <div
className="flex items-center gap-2 mt-3 animate-in fade-in slide-in-from-bottom-4 duration-500" className="flex items-center gap-2 mt-2 animate-in fade-in slide-in-from-bottom-4 duration-500"
style={{ animationDelay: "100ms" }} style={{ animationDelay: "50ms" }}
> >
<span <span
className={cn( 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 ? "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`} {taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`}
</span> </span>
</div> </div>
) : ( ) : (
<p <p
className="text-sm text-muted-foreground mt-2 animate-in fade-in slide-in-from-bottom-4 duration-500" className="text-sm text-muted-foreground mt-1.5 animate-in fade-in slide-in-from-bottom-4 duration-500"
style={{ animationDelay: "100ms" }} style={{ animationDelay: "50ms" }}
> >
Everything is up to date Everything is up to date
</p> </p>

View File

@ -3,9 +3,9 @@ import { Button } from "@/components/atoms/button";
export function CTABanner() { export function CTABanner() {
return ( 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"> <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? Ready to Get Set Up?
</h2> </h2>
<p className="mt-2 text-base text-muted-foreground"> <p className="mt-2 text-base text-muted-foreground">
@ -27,6 +27,6 @@ export function CTABanner() {
</Button> </Button>
</div> </div>
</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() { export function ContactSection() {
const [ref, isInView] = useInView(); const [ref, isInView] = useInView<HTMLElement>();
return ( return (
<section <section
id="contact" id="contact"
ref={ref} ref={ref}
className={cn( 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" 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"> <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 Tell Us What You Need
</h2> </h2>
<div className="rounded-2xl bg-card border border-border/60 shadow-sm p-5 sm:p-7"> <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) { export function HeroSection({ heroCTARef }: HeroSectionProps) {
const [heroRef, heroInView] = useInView(); const [heroRef, heroInView] = useInView<HTMLDivElement>();
return ( return (
<section <div
ref={heroRef} ref={heroRef}
className={cn( 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" 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"> <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">A One Stop Solution</span>
<span className="block text-primary mt-2">for Your IT Needs</span> <span className="block text-primary mt-2">for Your IT Needs</span>
</h1> </h1>
@ -70,6 +70,6 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {
</Button> </Button>
</div> </div>
</div> </div>
</section> </div>
); );
} }

View File

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

View File

@ -13,13 +13,13 @@ const trustPoints = [
]; ];
export function WhyUsSection() { export function WhyUsSection() {
const [ref, isInView] = useInView(); const [ref, isInView] = useInView<HTMLDivElement>();
return ( return (
<section <div
ref={ref} ref={ref}
className={cn( 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" isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)} )}
> >
@ -36,7 +36,7 @@ export function WhyUsSection() {
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<div> <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 Built on Trust and Excellence
</h2> </h2>
<p className="text-xl sm:text-2xl font-semibold text-foreground leading-relaxed"> <p className="text-xl sm:text-2xl font-semibold text-foreground leading-relaxed">
@ -63,6 +63,6 @@ export function WhyUsSection() {
</div> </div>
</div> </div>
</div> </div>
</section> </div>
); );
} }

View File

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

View File

@ -1,3 +1,3 @@
export { useCarousel, useInfiniteCarousel } from "./useInfiniteCarousel";
export { useInView } from "./useInView"; export { useInView } from "./useInView";
export { useSnapCarousel } from "./useSnapCarousel";
export { useStickyCta } from "./useStickyCta"; 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 { Button } from "@/components/atoms/button";
import { useStickyCta } from "@/features/landing-page/hooks"; import { useStickyCta } from "@/features/landing-page/hooks";
import { import {
Chapter,
HeroSection, HeroSection,
TrustStrip, TrustStrip,
ServicesCarousel, ServicesCarousel,
@ -17,14 +18,32 @@ export function PublicLandingView() {
const { heroCTARef, showStickyCTA } = useStickyCta(); const { heroCTARef, showStickyCTA } = useStickyCta();
return ( return (
<div className="space-y-0 pb-8 pt-0"> <div className="pb-8">
<HeroSection heroCTARef={heroCTARef} /> {/* Chapter 1: Who we are */}
<TrustStrip /> <Chapter
<ServicesCarousel /> zIndex={1}
<WhyUsSection /> className="min-h-dvh flex flex-col bg-gradient-to-br from-surface-sunken via-background to-info-bg/80"
<CTABanner /> >
<SupportDownloadsSection /> <HeroSection heroCTARef={heroCTARef} />
<ContactSection /> <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 */} {/* Sticky Mobile CTA */}
{showStickyCTA && ( {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"> <span className="inline-block rounded-full bg-primary/10 px-4 py-1.5 text-sm font-semibold tracking-wide text-primary">
Since 2002 Since 2002
</span> </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> Your Trusted IT Partner <span className="cp-gradient-text">in Japan</span>
</h1> </h1>
<div className="max-w-lg space-y-4 text-base leading-relaxed text-muted-foreground sm:text-lg"> <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"> <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="mx-auto max-w-6xl px-6 sm:px-8">
<div className="cp-stagger-children mb-10 max-w-xl"> <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"> <p className="mt-3 leading-relaxed text-muted-foreground">
End-to-end IT services designed for the international community in Japan all in End-to-end IT services designed for the international community in Japan all in
English. English.
@ -202,7 +202,7 @@ function ValuesSection() {
<section className="full-bleed bg-surface-sunken py-16 sm:py-20"> <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="mx-auto max-w-6xl px-6 sm:px-8">
<div className="cp-stagger-children mb-10 text-center"> <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"> <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 These principles guide how we serve customers, support our community, and advance our
craft every day. craft every day.
@ -240,7 +240,7 @@ function CorporateSection() {
<section className="full-bleed bg-background py-16 sm:py-20"> <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="mx-auto max-w-6xl px-6 sm:px-8">
<div className="cp-stagger-children mb-10"> <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>
<div className="grid grid-cols-1 gap-10 lg:grid-cols-5"> <div className="grid grid-cols-1 gap-10 lg:grid-cols-5">

View File

@ -58,7 +58,7 @@ export function ServicesHero({
<h1 <h1
className={cn( className={cn(
"text-2xl sm:text-3xl lg:text-4xl text-foreground leading-tight font-bold tracking-tight", "text-2xl sm:text-3xl lg:text-4xl text-foreground leading-tight font-bold tracking-tight",
displayFont && "font-display", displayFont && "font-heading",
animationClasses animationClasses
)} )}
style={animated ? { animationDelay: "50ms" } : undefined} 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 "./SubscriptionStatusBadge";
export * from "./CancellationFlow"; export * from "./CancellationFlow";
export { SubscriptionGridCard, SubscriptionGridCardSkeleton } from "./SubscriptionGridCard";

View File

@ -2,17 +2,19 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { ErrorBoundary, SummaryStats } from "@/components/molecules"; import { ViewToggle, type ViewMode } from "@/components/atoms/view-toggle";
import type { StatItem } from "@/components/molecules/SummaryStats"; import { MetricCard, MetricCardSkeleton } from "@/components/molecules/MetricCard";
import { ErrorBoundary } from "@/components/molecules";
import { PageLayout } from "@/components/templates/PageLayout"; import { PageLayout } from "@/components/templates/PageLayout";
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar"; import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
import { import { SubscriptionTableSkeleton } from "@/components/atoms/loading-skeleton";
SubscriptionStatsCardsSkeleton,
SubscriptionTableSkeleton,
} from "@/components/atoms/loading-skeleton";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { SubscriptionTable } from "@/features/subscriptions/components/SubscriptionTable"; 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 { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks";
import { import {
SUBSCRIPTION_STATUS, SUBSCRIPTION_STATUS,
@ -22,37 +24,83 @@ import {
const SUBSCRIPTION_STATUS_OPTIONS = Object.values(SUBSCRIPTION_STATUS) as SubscriptionStatus[]; const SUBSCRIPTION_STATUS_OPTIONS = Object.values(SUBSCRIPTION_STATUS) as SubscriptionStatus[];
function SubscriptionStatsCards({ function SubscriptionMetrics({
stats, stats,
}: { }: {
stats: { active: number; completed: number; cancelled: number }; stats: { active: number; completed: number; cancelled: number };
}) { }) {
const items: StatItem[] = [ return (
{ <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
icon: <CheckCircle className="h-5 w-5" />, <MetricCard
label: "Active", icon={<CheckCircle className="h-5 w-5" />}
value: stats.active, label="Active"
tone: "success", value={stats.active}
}, tone="success"
{ />
icon: <CheckCircle className="h-5 w-5" />, <MetricCard
label: "Completed", icon={<Server className="h-5 w-5" />}
value: stats.completed, label="Total"
tone: "primary", value={stats.active + stats.completed + stats.cancelled}
}, tone="primary"
{ />
icon: <XCircle className="h-5 w-5" />, <MetricCard
label: "Cancelled", icon={<TrendingUp className="h-5 w-5" />}
value: stats.cancelled, label="Completed"
tone: "muted", value={stats.completed}
}, tone="info"
]; />
return <SummaryStats variant="cards" className="mb-6" items={items} />; <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() { export function SubscriptionsListContainer() {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<SubscriptionStatus | "all">("all"); const [statusFilter, setStatusFilter] = useState<SubscriptionStatus | "all">("all");
const [viewMode, setViewMode] = useState<ViewMode>("grid");
const { const {
data: subscriptionData, 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) { if (showLoading || error) {
return ( return (
<PageLayout <PageLayout
@ -94,7 +153,7 @@ export function SubscriptionsListContainer() {
> >
<AsyncBlock isLoading={false} error={error}> <AsyncBlock isLoading={false} error={error}>
<div className="space-y-6"> <div className="space-y-6">
<SubscriptionStatsCardsSkeleton /> <SubscriptionMetricsSkeleton />
<SubscriptionTableSkeleton rows={6} /> <SubscriptionTableSkeleton rows={6} />
</div> </div>
</AsyncBlock> </AsyncBlock>
@ -114,9 +173,10 @@ export function SubscriptionsListContainer() {
} }
> >
<ErrorBoundary> <ErrorBoundary>
{stats && <SubscriptionStatsCards stats={stats} />} {stats && <SubscriptionMetrics 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"> <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 <SearchFilterBar
searchValue={searchTerm} searchValue={searchTerm}
onSearchChange={setSearchTerm} onSearchChange={setSearchTerm}
@ -125,13 +185,26 @@ export function SubscriptionsListContainer() {
onFilterChange={value => setStatusFilter(value as SubscriptionStatus | "all")} onFilterChange={value => setStatusFilter(value as SubscriptionStatus | "all")}
filterOptions={statusFilterOptions} filterOptions={statusFilterOptions}
filterLabel="Filter by status" 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>
<SubscriptionTable
subscriptions={filteredSubscriptions}
loading={isFetching && !!subscriptionData}
className="border-0 rounded-none shadow-none"
/>
</div> </div>
</ErrorBoundary> </ErrorBoundary>
</PageLayout> </PageLayout>

View File

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

View File

@ -242,6 +242,34 @@
animation-delay: calc(var(--cp-stagger-5) + 50ms); 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 ===== */ /* ===== TAB SLIDE TRANSITIONS ===== */
.cp-slide-fade-left { .cp-slide-fade-left {
animation: cp-slide-fade-left 300ms var(--cp-ease-out) forwards; animation: cp-slide-fade-left 300ms var(--cp-ease-out) forwards;
@ -672,6 +700,7 @@
.cp-animate-scale-in, .cp-animate-scale-in,
.cp-animate-slide-left, .cp-animate-slide-left,
.cp-stagger-children > *, .cp-stagger-children > *,
.cp-stagger-grid > *,
.cp-card-hover-lift, .cp-card-hover-lift,
.cp-slide-fade-left, .cp-slide-fade-left,
.cp-slide-fade-right, .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