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:
parent
f6329bbe3b
commit
57f2c543d1
2
apps/portal/next-env.d.ts
vendored
2
apps/portal/next-env.d.ts
vendored
@ -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.
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
59
apps/portal/src/components/atoms/status-indicator.tsx
Normal file
59
apps/portal/src/components/atoms/status-indicator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/portal/src/components/atoms/view-toggle.tsx
Normal file
52
apps/portal/src/components/atoms/view-toggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/portal/src/components/molecules/MetricCard/index.ts
Normal file
1
apps/portal/src/components/molecules/MetricCard/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { MetricCard, MetricCardSkeleton, type MetricCardProps } from "./MetricCard";
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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]">⌘</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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
32
apps/portal/src/features/landing-page/components/Chapter.tsx
Normal file
32
apps/portal/src/features/landing-page/components/Chapter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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;
|
|
||||||
124
apps/portal/src/features/landing-page/hooks/useSnapCarousel.ts
Normal file
124
apps/portal/src/features/landing-page/hooks/useSnapCarousel.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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 && (
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,2 +1,3 @@
|
|||||||
export * from "./SubscriptionStatusBadge";
|
export * from "./SubscriptionStatusBadge";
|
||||||
export * from "./CancellationFlow";
|
export * from "./CancellationFlow";
|
||||||
|
export { SubscriptionGridCard, SubscriptionGridCardSkeleton } from "./SubscriptionGridCard";
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 ============= */
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
162
docs/plans/2026-03-05-parallax-pinned-chapters-design.md
Normal file
162
docs/plans/2026-03-05-parallax-pinned-chapters-design.md
Normal 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 |
|
||||||
619
docs/plans/2026-03-05-parallax-pinned-chapters-plan.md
Normal file
619
docs/plans/2026-03-05-parallax-pinned-chapters-plan.md
Normal 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)
|
||||||
|
```
|
||||||
1690
docs/plans/2026-03-05-portal-ui-overhaul-plan.md
Normal file
1690
docs/plans/2026-03-05-portal-ui-overhaul-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
docs/plans/image.png
Normal file
BIN
docs/plans/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 506 KiB |
Loading…
x
Reference in New Issue
Block a user