diff --git a/apps/portal/next-env.d.ts b/apps/portal/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/apps/portal/next-env.d.ts +++ b/apps/portal/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/portal/src/app/globals.css b/apps/portal/src/app/globals.css index 84d923fc..5c5b0020 100644 --- a/apps/portal/src/app/globals.css +++ b/apps/portal/src/app/globals.css @@ -17,18 +17,19 @@ --radius: 0.625rem; /* Typography */ - --font-sans: var(--font-geist-sans, system-ui, sans-serif); + --font-sans: var(--font-dm-sans, system-ui, sans-serif); --font-display: var(--font-jakarta, var(--font-sans)); + --font-mono: var(--font-jetbrains, ui-monospace, monospace); /* Core Surfaces */ - --background: oklch(1 0 0); + --background: oklch(0.993 0.002 70); --foreground: oklch(0.16 0 0); --card: oklch(1 0 0); --card-foreground: var(--foreground); --popover: oklch(1 0 0); --popover-foreground: var(--foreground); - --muted: oklch(0.96 0.008 234.4); - --muted-foreground: oklch(0.5 0 0); + --muted: oklch(0.965 0.006 70); + --muted-foreground: oklch(0.46 0.01 70); /* Brand - Clean Blue (matches logo) */ --primary: oklch(0.6884 0.1342 234.4); @@ -71,19 +72,20 @@ --neutral-border: oklch(0.87 0.02 272.34); /* Surfaces */ - --surface-elevated: oklch(0.995 0 0); - --surface-sunken: oklch(0.975 0.005 234.4); + --surface-elevated: oklch(0.998 0.001 70); + --surface-sunken: oklch(0.975 0.004 70); /* Chrome */ - --border: oklch(0.93 0.004 234.4); - --input: oklch(0.96 0.004 234.4); + --border: oklch(0.925 0.006 70); + --input: oklch(0.955 0.005 70); --ring: oklch(0.6884 0.1342 234.4 / 0.5); - /* Sidebar - Deep purple/indigo */ - --sidebar: oklch(0.2754 0.1199 272.34); + /* Sidebar - Dark Navy */ + --sidebar: oklch(0.18 0.03 250); --sidebar-foreground: oklch(1 0 0); - --sidebar-border: oklch(0.36 0.1 272.34); - --sidebar-active: oklch(0.99 0 0 / 0.15); + --sidebar-border: oklch(0.25 0.04 250); + --sidebar-active: oklch(0.99 0 0 / 0.12); + --sidebar-accent: var(--primary); /* Header */ --header: oklch(1 0 0 / 0.95); @@ -141,14 +143,14 @@ .dark { /* Surfaces - Rich dark with blue undertone */ - --background: oklch(0.12 0.015 234.4); + --background: oklch(0.12 0.012 250); --foreground: oklch(0.95 0 0); --card: oklch(0.15 0.015 234.4); --card-foreground: var(--foreground); --popover: oklch(0.15 0.015 234.4); --popover-foreground: var(--foreground); - --muted: oklch(0.25 0.01 234.4); - --muted-foreground: oklch(0.74 0 0); + --muted: oklch(0.25 0.008 70); + --muted-foreground: oklch(0.72 0.01 70); /* Brand - Brighter for dark mode contrast */ --primary: oklch(0.75 0.12 234.4); @@ -185,16 +187,16 @@ --neutral-bg: oklch(0.24 0.02 272.34); --neutral-border: oklch(0.38 0.03 272.34); - --surface-elevated: oklch(0.18 0.015 234.4); - --surface-sunken: oklch(0.1 0.015 234.4); + --surface-elevated: oklch(0.18 0.012 250); + --surface-sunken: oklch(0.1 0.012 250); - --border: oklch(0.32 0.02 234.4); - --input: oklch(0.35 0.02 234.4); + --border: oklch(0.3 0.012 70); + --input: oklch(0.33 0.01 70); --ring: oklch(0.75 0.12 234.4 / 0.5); - /* Sidebar - Purple/indigo theme for dark mode */ - --sidebar: oklch(0.2 0.08 272.34); - --sidebar-border: oklch(0.28 0.08 272.34); + /* Sidebar - Dark Navy for dark mode */ + --sidebar: oklch(0.13 0.025 250); + --sidebar-border: oklch(0.22 0.03 250); --header: oklch(0.15 0.015 234.4 / 0.95); --header-foreground: var(--foreground); @@ -226,7 +228,8 @@ @theme { /* Font Families */ --font-family-sans: var(--font-sans); - --font-family-display: var(--font-display); + --font-family-heading: var(--font-display); + --font-family-mono: var(--font-mono); /* Colors */ --color-background: var(--background); @@ -288,6 +291,7 @@ --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-active: var(--sidebar-active); + --color-sidebar-accent: var(--sidebar-accent); --color-header: var(--header); --color-header-foreground: var(--header-foreground); diff --git a/apps/portal/src/app/layout.tsx b/apps/portal/src/app/layout.tsx index 1db386ea..4aab6e70 100644 --- a/apps/portal/src/app/layout.tsx +++ b/apps/portal/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { Plus_Jakarta_Sans } from "next/font/google"; +import { Plus_Jakarta_Sans, DM_Sans, JetBrains_Mono } from "next/font/google"; import { headers } from "next/headers"; import "./globals.css"; import { QueryProvider } from "@/core/providers"; @@ -11,6 +11,18 @@ const plusJakartaSans = Plus_Jakarta_Sans({ display: "swap", }); +const dmSans = DM_Sans({ + subsets: ["latin"], + variable: "--font-dm-sans", + display: "swap", +}); + +const jetbrainsMono = JetBrains_Mono({ + subsets: ["latin"], + variable: "--font-jetbrains", + display: "swap", +}); + export const metadata: Metadata = { title: { default: "Assist Solutions - IT Services for Expats in Japan", @@ -39,7 +51,9 @@ export default async function RootLayout({ return ( - + {children} diff --git a/apps/portal/src/components/atoms/button.tsx b/apps/portal/src/components/atoms/button.tsx index 38b708e6..f190bde7 100644 --- a/apps/portal/src/components/atoms/button.tsx +++ b/apps/portal/src/components/atoms/button.tsx @@ -19,6 +19,8 @@ const buttonVariants = cva( secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]", ghost: "text-foreground hover:bg-muted", + subtle: + "bg-muted/50 text-foreground hover:bg-muted border border-transparent hover:border-border/40", link: "underline-offset-4 hover:underline text-primary", pill: "rounded-full bg-primary text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 hover:-translate-y-0.5", pillOutline: "rounded-full border border-border bg-card text-primary hover:bg-primary/5", diff --git a/apps/portal/src/components/atoms/index.ts b/apps/portal/src/components/atoms/index.ts index b72a661c..77d61234 100644 --- a/apps/portal/src/components/atoms/index.ts +++ b/apps/portal/src/components/atoms/index.ts @@ -59,5 +59,11 @@ export { Logo } from "./logo"; // Navigation and Steps export { StepHeader } from "./step-header"; +// Status +export { StatusIndicator, type StatusIndicatorStatus } from "./status-indicator"; + +// View controls +export { ViewToggle, type ViewMode } from "./view-toggle"; + // Animation export { AnimatedContainer } from "./animated-container"; diff --git a/apps/portal/src/components/atoms/status-indicator.tsx b/apps/portal/src/components/atoms/status-indicator.tsx new file mode 100644 index 00000000..0dd2f877 --- /dev/null +++ b/apps/portal/src/components/atoms/status-indicator.tsx @@ -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 { + status: StatusIndicatorStatus; + label?: string; + pulse?: boolean; + className?: string; +} + +export function StatusIndicator({ status, label, size, pulse, className }: StatusIndicatorProps) { + return ( + + + {label && {label}} + + ); +} diff --git a/apps/portal/src/components/atoms/view-toggle.tsx b/apps/portal/src/components/atoms/view-toggle.tsx new file mode 100644 index 00000000..8abec289 --- /dev/null +++ b/apps/portal/src/components/atoms/view-toggle.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/apps/portal/src/components/molecules/MetricCard/MetricCard.tsx b/apps/portal/src/components/molecules/MetricCard/MetricCard.tsx new file mode 100644 index 00000000..ed417cdd --- /dev/null +++ b/apps/portal/src/components/molecules/MetricCard/MetricCard.tsx @@ -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 = { + 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 ( +
+ {icon && ( +
+ {icon} +
+ )} +
+

{label}

+
+

+ {value} +

+ {trend && ( + + {trend.value} + + )} +
+ {subtitle &&

{subtitle}

} +
+
+ ); +} + +export function MetricCardSkeleton({ className }: { className?: string }) { + return ( +
+
+
+
+
+
+
+ ); +} diff --git a/apps/portal/src/components/molecules/MetricCard/index.ts b/apps/portal/src/components/molecules/MetricCard/index.ts new file mode 100644 index 00000000..5a192930 --- /dev/null +++ b/apps/portal/src/components/molecules/MetricCard/index.ts @@ -0,0 +1 @@ +export { MetricCard, MetricCardSkeleton, type MetricCardProps } from "./MetricCard"; diff --git a/apps/portal/src/components/molecules/SearchFilterBar/SearchFilterBar.tsx b/apps/portal/src/components/molecules/SearchFilterBar/SearchFilterBar.tsx index eaea32da..57bc4e96 100644 --- a/apps/portal/src/components/molecules/SearchFilterBar/SearchFilterBar.tsx +++ b/apps/portal/src/components/molecules/SearchFilterBar/SearchFilterBar.tsx @@ -1,5 +1,7 @@ +"use client"; + import type { ReactNode } from "react"; -import { MagnifyingGlassIcon, FunnelIcon } from "@heroicons/react/24/outline"; +import { MagnifyingGlassIcon, FunnelIcon, XMarkIcon } from "@heroicons/react/24/outline"; interface FilterOption { value: string; @@ -14,6 +16,7 @@ interface SearchFilterBarProps { onFilterChange?: (value: string) => void; filterOptions?: FilterOption[]; filterLabel?: string; + activeFilters?: { label: string; onRemove: () => void }[] | undefined; children?: ReactNode; } @@ -23,52 +26,66 @@ export function SearchFilterBar({ searchPlaceholder = "Search...", filterValue, onFilterChange, - filterOptions = [], - filterLabel, + filterOptions, + filterLabel = "Filter", + activeFilters, children, }: SearchFilterBarProps) { return ( -
- {/* Search */} -
-
- +
+
+ {/* Search */} +
+ + 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" + />
- onSearchChange(e.target.value)} - /> -
- {/* Filters and Actions */} -
- {/* Status Filter */} - {filterOptions.length > 0 && onFilterChange && filterValue !== undefined && ( -
- -
- +
+ {/* Filter select */} + {filterOptions && onFilterChange && ( +
+ +
-
- )} + )} - {/* Additional children (custom actions) */} - {children} + {/* Custom actions (ViewToggle, etc.) */} + {children} +
+ + {/* Active filter pills */} + {activeFilters && activeFilters.length > 0 && ( +
+ {activeFilters.map(filter => ( + + ))} +
+ )}
); } diff --git a/apps/portal/src/components/molecules/ServiceCard/ServiceCard.tsx b/apps/portal/src/components/molecules/ServiceCard/ServiceCard.tsx index bfef3437..48c56dbe 100644 --- a/apps/portal/src/components/molecules/ServiceCard/ServiceCard.tsx +++ b/apps/portal/src/components/molecules/ServiceCard/ServiceCard.tsx @@ -223,7 +223,7 @@ function DefaultCard({ {renderIcon(icon, "h-6 w-6")}
-

{title}

+

{title}

{price && ( From {price} @@ -277,7 +277,7 @@ function FeaturedCard({ href, icon, title, description, highlight, className }:
{/* Content */} -

{title}

+

{title}

{description && (

{description}

)} @@ -332,7 +332,7 @@ function MinimalCard({ href, icon, title, className }: ServiceCardProps) {
{renderIcon(icon, "h-6 w-6 text-primary")}
-

{title}

+

{title}

); @@ -401,7 +401,7 @@ function BentoMediumCard({ {renderIcon(icon, "h-6 w-6")}
-

{title}

+

{title}

{description && (

{description}

)} @@ -460,7 +460,7 @@ function BentoLargeCard({ {renderIcon(icon, "h-7 w-7")}
-

{title}

+

{title}

{description && (

{description}

)} diff --git a/apps/portal/src/components/molecules/index.ts b/apps/portal/src/components/molecules/index.ts index b202fd33..21241450 100644 --- a/apps/portal/src/components/molecules/index.ts +++ b/apps/portal/src/components/molecules/index.ts @@ -28,6 +28,9 @@ export * from "./FilterDropdown"; export * from "./ClearFiltersButton"; export * from "./DetailStatsGrid"; +// Metric display +export { MetricCard, MetricCardSkeleton, type MetricCardProps } from "./MetricCard"; + // Navigation molecules export * from "./BackLink"; diff --git a/apps/portal/src/components/organisms/AppShell/AppShell.tsx b/apps/portal/src/components/organisms/AppShell/AppShell.tsx index dcbc238d..d471fcaa 100644 --- a/apps/portal/src/components/organisms/AppShell/AppShell.tsx +++ b/apps/portal/src/components/organisms/AppShell/AppShell.tsx @@ -199,7 +199,7 @@ export function AppShell({ children }: AppShellProps) { {/* Desktop sidebar */}
-
+
-
- {/* Mobile menu button - 44px minimum touch target */} +
+
+ {/* Mobile menu button */} + + {/* Search trigger */} +
{/* Right side actions */} -
+
{/* Notification bell */} - {/* Help link - visible on larger screens */} + {/* Help link */} - + - {/* Profile link - enhanced for mobile */} + {/* Divider */} +
+ + {/* Profile link */} -
+
{initials}
- {/* Show truncated name on mobile, full name on larger screens */} - - {displayName.split(" ")[0]} - - {displayName} + {displayName}
diff --git a/apps/portal/src/components/organisms/AppShell/Sidebar.tsx b/apps/portal/src/components/organisms/AppShell/Sidebar.tsx index d94bc9f9..91660ad6 100644 --- a/apps/portal/src/components/organisms/AppShell/Sidebar.tsx +++ b/apps/portal/src/components/organisms/AppShell/Sidebar.tsx @@ -10,14 +10,15 @@ import type { ComponentType, SVGProps } from "react"; // Shared navigation item styling const navItemBaseClass = - "group w-full flex items-center px-3 py-2.5 text-sm font-semibold rounded-lg transition-all duration-200 relative focus:outline-none focus:ring-2 focus:ring-white/30"; -const activeClass = "text-white bg-white/20 shadow-sm"; -const inactiveClass = "text-white/90 hover:text-white hover:bg-white/10"; + "group w-full flex items-center px-3 py-2 text-[13px] font-medium rounded-lg transition-all duration-200 relative focus:outline-none focus:ring-2 focus:ring-white/20"; +const activeClass = "text-white bg-white/[0.08] shadow-sm"; +const inactiveClass = "text-white/60 hover:text-white/90 hover:bg-white/[0.06]"; function ActiveIndicator({ small = false }: { small?: boolean }) { - const size = small ? "w-0.5 h-4" : "w-1 h-6"; - const rounded = small ? "rounded-full" : "rounded-r-full"; - return
; + const size = small ? "w-0.5 h-3.5" : "w-[3px] h-5"; + return ( +
+ ); } function NavIcon({ @@ -31,19 +32,19 @@ function NavIcon({ }) { if (variant === "logout") { return ( -
- +
+
); } return (
- +
); } @@ -64,28 +65,36 @@ export const Sidebar = memo(function Sidebar({ }: SidebarProps) { return (
-
+
-
- +
+
- Assist Solutions -

Customer Portal

+ Assist Solutions +

Customer Portal

-
@@ -141,7 +150,7 @@ function ExpandableNavItem({ isExpanded ? "max-h-96 opacity-100" : "max-h-0 opacity-0" }`} > -
+
{item.children?.map((child: NavigationChild) => { const isChildActive = pathname === (child.href || "").split(/[?#]/)[0]; return ( @@ -150,20 +159,15 @@ function ExpandableNavItem({ href={child.href} prefetch onMouseEnter={() => child.href && void router.prefetch(child.href)} - className={`group flex items-center px-3 py-2 text-sm rounded-md transition-all duration-200 relative ${ + className={`group flex items-center px-2.5 py-1.5 text-[13px] rounded-md transition-all duration-200 relative ${ isChildActive - ? "text-white bg-white/20 font-semibold" - : "text-white/80 hover:text-white hover:bg-white/10 font-medium" + ? "text-white bg-white/[0.08] font-medium" + : "text-white/50 hover:text-white/80 hover:bg-white/[0.04] font-normal" }`} title={child.tooltip || child.name} aria-current={isChildActive ? "page" : undefined} > {isChildActive && } -
{child.name} ); @@ -176,13 +180,15 @@ function ExpandableNavItem({ function LogoutNavItem({ item, onLogout }: { item: NavigationItem; onLogout: () => void }) { return ( - +
+ +
); } diff --git a/apps/portal/src/components/organisms/AppShell/navigation.ts b/apps/portal/src/components/organisms/AppShell/navigation.ts index 136e82d4..9e022ab4 100644 --- a/apps/portal/src/components/organisms/AppShell/navigation.ts +++ b/apps/portal/src/components/organisms/AppShell/navigation.ts @@ -23,14 +23,16 @@ export interface NavigationItem { icon: React.ComponentType>; children?: NavigationChild[] | undefined; isLogout?: boolean | undefined; + section?: string | undefined; } export const baseNavigation: NavigationItem[] = [ - { name: "Dashboard", href: "/account", icon: HomeIcon }, + { name: "Dashboard", href: "/account", icon: HomeIcon, section: "Overview" }, { name: "Orders", href: "/account/orders", icon: ClipboardDocumentListIcon }, { name: "Billing", icon: CreditCardIcon, + section: "Account", children: [ { name: "Invoices", href: "/account/billing/invoices" }, { name: "Payment Methods", href: "/account/billing/payments" }, @@ -78,6 +80,7 @@ export function computeNavigation(activeSubscriptions?: Subscription[]): Navigat icon: currentItem.icon, href: currentItem.href, isLogout: currentItem.isLogout, + section: currentItem.section, children: [{ name: "All Subscriptions", href: "/account/subscriptions" }, ...dynamicChildren], }; } diff --git a/apps/portal/src/components/organisms/SiteFooter/SiteFooter.tsx b/apps/portal/src/components/organisms/SiteFooter/SiteFooter.tsx index 203d7c48..237fd36e 100644 --- a/apps/portal/src/components/organisms/SiteFooter/SiteFooter.tsx +++ b/apps/portal/src/components/organisms/SiteFooter/SiteFooter.tsx @@ -33,7 +33,7 @@ function FooterLinkColumn({ }) { return (
-

{title}

+

{title}

    {links.map(link => (
  • diff --git a/apps/portal/src/components/templates/AuthLayout/AuthLayout.tsx b/apps/portal/src/components/templates/AuthLayout/AuthLayout.tsx index eb2d594f..5869ce2c 100644 --- a/apps/portal/src/components/templates/AuthLayout/AuthLayout.tsx +++ b/apps/portal/src/components/templates/AuthLayout/AuthLayout.tsx @@ -41,7 +41,7 @@ export function AuthLayout({ )}
    -

    +

    {title}

    {subtitle && ( diff --git a/apps/portal/src/features/dashboard/components/QuickStats.tsx b/apps/portal/src/features/dashboard/components/QuickStats.tsx index d31617f1..f7f185f1 100644 --- a/apps/portal/src/features/dashboard/components/QuickStats.tsx +++ b/apps/portal/src/features/dashboard/components/QuickStats.tsx @@ -58,32 +58,33 @@ function StatItem({ icon: Icon, label, value, href, tone = "primary", emptyText
    - +
    -

    {label}

    +

    {label}

    {value > 0 ? ( -

    {value}

    +

    + {value} +

    ) : ( -

    {emptyText || "None"}

    +

    {emptyText || "None"}

    )}
    - + ); } diff --git a/apps/portal/src/features/dashboard/views/DashboardView.tsx b/apps/portal/src/features/dashboard/views/DashboardView.tsx index fbfd3a7e..6b583865 100644 --- a/apps/portal/src/features/dashboard/views/DashboardView.tsx +++ b/apps/portal/src/features/dashboard/views/DashboardView.tsx @@ -54,37 +54,28 @@ function DashboardGreeting({ }) { return (
    -

    - Welcome back -

    -

    - {displayName} +

    + Welcome back, {displayName}

    {taskCount > 0 ? (
    - {hasUrgentTask && } + {hasUrgentTask && } {taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`}
    ) : (

    Everything is up to date

    diff --git a/apps/portal/src/features/landing-page/components/CTABanner.tsx b/apps/portal/src/features/landing-page/components/CTABanner.tsx index b1783f3f..a24117fe 100644 --- a/apps/portal/src/features/landing-page/components/CTABanner.tsx +++ b/apps/portal/src/features/landing-page/components/CTABanner.tsx @@ -3,9 +3,9 @@ import { Button } from "@/components/atoms/button"; export function CTABanner() { return ( -
    +
    -

    +

    Ready to Get Set Up?

    @@ -27,6 +27,6 @@ export function CTABanner() {

    -
    +
    ); } diff --git a/apps/portal/src/features/landing-page/components/Chapter.tsx b/apps/portal/src/features/landing-page/components/Chapter.tsx new file mode 100644 index 00000000..0e694306 --- /dev/null +++ b/apps/portal/src/features/landing-page/components/Chapter.tsx @@ -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 ( +
    + {children} +
    + ); +} diff --git a/apps/portal/src/features/landing-page/components/ContactSection.tsx b/apps/portal/src/features/landing-page/components/ContactSection.tsx index a2340dd2..54fbfa9a 100644 --- a/apps/portal/src/features/landing-page/components/ContactSection.tsx +++ b/apps/portal/src/features/landing-page/components/ContactSection.tsx @@ -95,19 +95,19 @@ function MapAndAddress() { } export function ContactSection() { - const [ref, isInView] = useInView(); + const [ref, isInView] = useInView(); return (
    -

    +

    Tell Us What You Need

    diff --git a/apps/portal/src/features/landing-page/components/HeroSection.tsx b/apps/portal/src/features/landing-page/components/HeroSection.tsx index 0ce10666..f944a7a8 100644 --- a/apps/portal/src/features/landing-page/components/HeroSection.tsx +++ b/apps/portal/src/features/landing-page/components/HeroSection.tsx @@ -10,13 +10,13 @@ interface HeroSectionProps { } export function HeroSection({ heroCTARef }: HeroSectionProps) { - const [heroRef, heroInView] = useInView(); + const [heroRef, heroInView] = useInView(); return ( -
    @@ -44,7 +44,7 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) { />
    -

    +

    A One Stop Solution for Your IT Needs

    @@ -70,6 +70,6 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {
    -
    +
    ); } diff --git a/apps/portal/src/features/landing-page/components/ServicesCarousel.tsx b/apps/portal/src/features/landing-page/components/ServicesCarousel.tsx index 433bc008..85624c7b 100644 --- a/apps/portal/src/features/landing-page/components/ServicesCarousel.tsx +++ b/apps/portal/src/features/landing-page/components/ServicesCarousel.tsx @@ -4,7 +4,7 @@ import { memo, useEffect, useState } from "react"; import Link from "next/link"; import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react"; import { cn } from "@/shared/utils"; -import { useCarousel, useInView } from "@/features/landing-page/hooks"; +import { useSnapCarousel, useInView } from "@/features/landing-page/hooks"; import { personalConversionCards, businessConversionCards, @@ -101,9 +101,9 @@ const ACCENTS: Record = { }, }; -/* ─── Crossfade Card ─── */ +/* ─── Service Card ─── */ -const CrossfadeCard = memo(function CrossfadeCard({ card }: { card: ConversionServiceCard }) { +const ServiceCard = memo(function ServiceCard({ card }: { card: ConversionServiceCard }) { const a = ACCENTS[card.accent]; return ( @@ -139,7 +139,7 @@ const CrossfadeCard = memo(function CrossfadeCard({ card }: { card: ConversionSe

{card.problemHook}

-

+

{card.title}

@@ -197,7 +197,9 @@ function CarouselHeader({

-

Our Services

+

+ Our Services +

Everything you need to stay connected in Japan

@@ -255,7 +257,7 @@ function CarouselNav({ const styles = ACCENTS[card.accent]; return (
); } diff --git a/apps/portal/src/features/landing-page/components/SupportDownloadsSection.tsx b/apps/portal/src/features/landing-page/components/SupportDownloadsSection.tsx index eef0f65d..646f3cea 100644 --- a/apps/portal/src/features/landing-page/components/SupportDownloadsSection.tsx +++ b/apps/portal/src/features/landing-page/components/SupportDownloadsSection.tsx @@ -18,7 +18,7 @@ export function SupportDownloadsSection() { )} >
-

+

Remote Support

diff --git a/apps/portal/src/features/landing-page/components/TrustStrip.tsx b/apps/portal/src/features/landing-page/components/TrustStrip.tsx index 99173836..7c1dbe4b 100644 --- a/apps/portal/src/features/landing-page/components/TrustStrip.tsx +++ b/apps/portal/src/features/landing-page/components/TrustStrip.tsx @@ -58,7 +58,7 @@ function AnimatedValue({ const count = useCountUp({ end: value, duration, enabled, delay }); return ( - + {formatter ? formatter(count) : count} {suffix} @@ -66,14 +66,14 @@ function AnimatedValue({ } export function TrustStrip() { - const [ref, inView] = useInView(); + const [ref, inView] = useInView(); return ( -

@@ -104,7 +104,7 @@ export function TrustStrip() { {...(stat.formatter ? { formatter: stat.formatter } : {})} /> ) : ( - + {stat.text} )} @@ -114,6 +114,6 @@ export function TrustStrip() { ))}
- +
); } diff --git a/apps/portal/src/features/landing-page/components/WhyUsSection.tsx b/apps/portal/src/features/landing-page/components/WhyUsSection.tsx index 5825a580..eedd7322 100644 --- a/apps/portal/src/features/landing-page/components/WhyUsSection.tsx +++ b/apps/portal/src/features/landing-page/components/WhyUsSection.tsx @@ -13,13 +13,13 @@ const trustPoints = [ ]; export function WhyUsSection() { - const [ref, isInView] = useInView(); + const [ref, isInView] = useInView(); return ( -
@@ -36,7 +36,7 @@ export function WhyUsSection() {
-

+

Built on Trust and Excellence

@@ -63,6 +63,6 @@ export function WhyUsSection() {

- +
); } diff --git a/apps/portal/src/features/landing-page/components/index.ts b/apps/portal/src/features/landing-page/components/index.ts index 04cf58dd..a6ad0e0d 100644 --- a/apps/portal/src/features/landing-page/components/index.ts +++ b/apps/portal/src/features/landing-page/components/index.ts @@ -1,4 +1,5 @@ // Landing page sections +export { Chapter } from "./Chapter"; export { HeroSection } from "./HeroSection"; export { TrustStrip } from "./TrustStrip"; export { ServicesCarousel } from "./ServicesCarousel"; diff --git a/apps/portal/src/features/landing-page/hooks/index.ts b/apps/portal/src/features/landing-page/hooks/index.ts index 5db5bd9d..538c37fd 100644 --- a/apps/portal/src/features/landing-page/hooks/index.ts +++ b/apps/portal/src/features/landing-page/hooks/index.ts @@ -1,3 +1,3 @@ -export { useCarousel, useInfiniteCarousel } from "./useInfiniteCarousel"; export { useInView } from "./useInView"; +export { useSnapCarousel } from "./useSnapCarousel"; export { useStickyCta } from "./useStickyCta"; diff --git a/apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts b/apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts deleted file mode 100644 index 286c5753..00000000 --- a/apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts +++ /dev/null @@ -1,122 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; - -export function useCarousel({ 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 | 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; diff --git a/apps/portal/src/features/landing-page/hooks/useSnapCarousel.ts b/apps/portal/src/features/landing-page/hooks/useSnapCarousel.ts new file mode 100644 index 00000000..9e7b8d70 --- /dev/null +++ b/apps/portal/src/features/landing-page/hooks/useSnapCarousel.ts @@ -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(null); + const [activeIndex, setActiveIndex] = useState(0); + const totalRef = useRef(total); + totalRef.current = total; + + const pausedRef = useRef(false); + const pauseTimerRef = useRef>(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, + }; +} diff --git a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx index 915e7568..5d866ca4 100644 --- a/apps/portal/src/features/landing-page/views/PublicLandingView.tsx +++ b/apps/portal/src/features/landing-page/views/PublicLandingView.tsx @@ -4,6 +4,7 @@ import { ArrowRight } from "lucide-react"; import { Button } from "@/components/atoms/button"; import { useStickyCta } from "@/features/landing-page/hooks"; import { + Chapter, HeroSection, TrustStrip, ServicesCarousel, @@ -17,14 +18,32 @@ export function PublicLandingView() { const { heroCTARef, showStickyCTA } = useStickyCta(); return ( -
- - - - - - - +
+ {/* Chapter 1: Who we are */} + + + + + + {/* Chapter 2: What we offer */} + + + + + {/* Chapter 3: Why choose us */} + + + + + + {/* Chapter 4: Get in touch */} + + + + {/* Sticky Mobile CTA */} {showStickyCTA && ( diff --git a/apps/portal/src/features/marketing/views/AboutUsView.tsx b/apps/portal/src/features/marketing/views/AboutUsView.tsx index 09330dd7..b1e928c6 100644 --- a/apps/portal/src/features/marketing/views/AboutUsView.tsx +++ b/apps/portal/src/features/marketing/views/AboutUsView.tsx @@ -136,7 +136,7 @@ function HeroSection() { Since 2002 -

+

Your Trusted IT Partner in Japan

@@ -173,7 +173,7 @@ function ServicesSection() {
-

What We Do

+

What We Do

End-to-end IT services designed for the international community in Japan — all in English. @@ -202,7 +202,7 @@ function ValuesSection() {

-

Our Values

+

Our Values

These principles guide how we serve customers, support our community, and advance our craft every day. @@ -240,7 +240,7 @@ function CorporateSection() {

-

Corporate Data

+

Corporate Data

diff --git a/apps/portal/src/features/services/components/base/ServicesHero.tsx b/apps/portal/src/features/services/components/base/ServicesHero.tsx index 26272eac..0d947e46 100644 --- a/apps/portal/src/features/services/components/base/ServicesHero.tsx +++ b/apps/portal/src/features/services/components/base/ServicesHero.tsx @@ -58,7 +58,7 @@ export function ServicesHero({

+ {/* Header: name + status */} +
+
+

+ {subscription.productName} +

+

+ #{subscription.serviceId} +

+
+ +
+ + {/* Price */} +
+ + {formatCurrency(subscription.amount, subscription.currency)} + + {cycleLabel && /{cycleLabel}} +
+ + {/* Footer: next due + action */} +
+ {subscription.nextDue && ( +
+ + + {new Date(subscription.nextDue).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} + +
+ )} + + Manage + + +
+ + ); +} + +export function SubscriptionGridCardSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/portal/src/features/subscriptions/components/index.ts b/apps/portal/src/features/subscriptions/components/index.ts index 6328ac5a..6a579485 100644 --- a/apps/portal/src/features/subscriptions/components/index.ts +++ b/apps/portal/src/features/subscriptions/components/index.ts @@ -1,2 +1,3 @@ export * from "./SubscriptionStatusBadge"; export * from "./CancellationFlow"; +export { SubscriptionGridCard, SubscriptionGridCardSkeleton } from "./SubscriptionGridCard"; diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx index 9b4b94fa..25302b68 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx @@ -2,17 +2,19 @@ import { useState, useMemo } from "react"; import { Button } from "@/components/atoms/button"; -import { ErrorBoundary, SummaryStats } from "@/components/molecules"; -import type { StatItem } from "@/components/molecules/SummaryStats"; +import { ViewToggle, type ViewMode } from "@/components/atoms/view-toggle"; +import { MetricCard, MetricCardSkeleton } from "@/components/molecules/MetricCard"; +import { ErrorBoundary } from "@/components/molecules"; import { PageLayout } from "@/components/templates/PageLayout"; import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar"; -import { - SubscriptionStatsCardsSkeleton, - SubscriptionTableSkeleton, -} from "@/components/atoms/loading-skeleton"; +import { SubscriptionTableSkeleton } from "@/components/atoms/loading-skeleton"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { SubscriptionTable } from "@/features/subscriptions/components/SubscriptionTable"; -import { Server, CheckCircle, XCircle } from "lucide-react"; +import { + SubscriptionGridCard, + SubscriptionGridCardSkeleton, +} from "@/features/subscriptions/components/SubscriptionGridCard"; +import { Server, CheckCircle, XCircle, TrendingUp } from "lucide-react"; import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks"; import { SUBSCRIPTION_STATUS, @@ -22,37 +24,83 @@ import { const SUBSCRIPTION_STATUS_OPTIONS = Object.values(SUBSCRIPTION_STATUS) as SubscriptionStatus[]; -function SubscriptionStatsCards({ +function SubscriptionMetrics({ stats, }: { stats: { active: number; completed: number; cancelled: number }; }) { - const items: StatItem[] = [ - { - icon: , - label: "Active", - value: stats.active, - tone: "success", - }, - { - icon: , - label: "Completed", - value: stats.completed, - tone: "primary", - }, - { - icon: , - label: "Cancelled", - value: stats.cancelled, - tone: "muted", - }, - ]; - return ; + return ( +
+ } + label="Active" + value={stats.active} + tone="success" + /> + } + label="Total" + value={stats.active + stats.completed + stats.cancelled} + tone="primary" + /> + } + label="Completed" + value={stats.completed} + tone="info" + /> + } + label="Cancelled" + value={stats.cancelled} + tone="neutral" + /> +
+ ); +} + +function SubscriptionMetricsSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); +} + +function SubscriptionGrid({ + subscriptions, + loading, +}: { + subscriptions: Subscription[]; + loading: boolean; +}) { + if (subscriptions.length === 0 && !loading) { + return ( +
+ +

No subscriptions found

+

No active subscriptions at this time

+
+ ); + } + + return ( +
+ {subscriptions.map(sub => ( + + ))} + {loading && + Array.from({ length: 3 }).map((_, i) => )} +
+ ); } export function SubscriptionsListContainer() { const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); + const [viewMode, setViewMode] = useState("grid"); const { data: subscriptionData, @@ -85,6 +133,17 @@ export function SubscriptionsListContainer() { [] ); + const activeFilters = useMemo(() => { + const filters: { label: string; onRemove: () => void }[] = []; + if (statusFilter !== "all") { + filters.push({ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("all") }); + } + if (searchTerm) { + filters.push({ label: `Search: ${searchTerm}`, onRemove: () => setSearchTerm("") }); + } + return filters; + }, [statusFilter, searchTerm]); + if (showLoading || error) { return (
- +
@@ -114,9 +173,10 @@ export function SubscriptionsListContainer() { } > - {stats && } -
-
+ {stats && } + +
+
setStatusFilter(value as SubscriptionStatus | "all")} filterOptions={statusFilterOptions} filterLabel="Filter by status" - /> + activeFilters={activeFilters} + > + + +
+ +
+ {viewMode === "grid" ? ( + + ) : ( + + )}
-
diff --git a/apps/portal/src/styles/tokens.css b/apps/portal/src/styles/tokens.css index 046f0f07..e2f128df 100644 --- a/apps/portal/src/styles/tokens.css +++ b/apps/portal/src/styles/tokens.css @@ -54,7 +54,7 @@ --cp-page-padding: var(--cp-page-padding-x); /* Sidebar */ - --cp-sidebar-width: 16rem; /* 256px */ + --cp-sidebar-width: 13.75rem; /* 220px */ --cp-sidebar-width-collapsed: 4rem; /* 64px */ /* ============= TYPOGRAPHY ============= */ diff --git a/apps/portal/src/styles/utilities.css b/apps/portal/src/styles/utilities.css index c0502a23..0e7b5bff 100644 --- a/apps/portal/src/styles/utilities.css +++ b/apps/portal/src/styles/utilities.css @@ -242,6 +242,34 @@ animation-delay: calc(var(--cp-stagger-5) + 50ms); } + /* Card grid stagger - faster delay for dense grids */ + .cp-stagger-grid > * { + opacity: 0; + animation: cp-fade-up var(--cp-duration-normal) var(--cp-ease-out) forwards; + } + + .cp-stagger-grid > *:nth-child(1) { + animation-delay: 0ms; + } + .cp-stagger-grid > *:nth-child(2) { + animation-delay: 30ms; + } + .cp-stagger-grid > *:nth-child(3) { + animation-delay: 60ms; + } + .cp-stagger-grid > *:nth-child(4) { + animation-delay: 90ms; + } + .cp-stagger-grid > *:nth-child(5) { + animation-delay: 120ms; + } + .cp-stagger-grid > *:nth-child(6) { + animation-delay: 150ms; + } + .cp-stagger-grid > *:nth-child(n + 7) { + animation-delay: 180ms; + } + /* ===== TAB SLIDE TRANSITIONS ===== */ .cp-slide-fade-left { animation: cp-slide-fade-left 300ms var(--cp-ease-out) forwards; @@ -672,6 +700,7 @@ .cp-animate-scale-in, .cp-animate-slide-left, .cp-stagger-children > *, + .cp-stagger-grid > *, .cp-card-hover-lift, .cp-slide-fade-left, .cp-slide-fade-right, diff --git a/docs/plans/2026-03-05-parallax-pinned-chapters-design.md b/docs/plans/2026-03-05-parallax-pinned-chapters-design.md new file mode 100644 index 00000000..c412b274 --- /dev/null +++ b/docs/plans/2026-03-05-parallax-pinned-chapters-design.md @@ -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 ( +
+ {children} +
+ ); +} +``` + +### Updated PublicLandingView structure + +```tsx +
+ + + + + + + + + + + + + + +
+ {" "} + {/* No sticky - last chapter */} + + +
+
+``` + +### 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 | diff --git a/docs/plans/2026-03-05-parallax-pinned-chapters-plan.md b/docs/plans/2026-03-05-parallax-pinned-chapters-plan.md new file mode 100644 index 00000000..69e24e22 --- /dev/null +++ b/docs/plans/2026-03-05-parallax-pinned-chapters-plan.md @@ -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 ( +
+ {children} +
+ ); +} +``` + +**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(null); + const [activeIndex, setActiveIndex] = useState(0); + + // Auto-play pause tracking + const pausedRef = useRef(false); + const pauseTimerRef = useRef>(); + + 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 +
+ {cards.map((card, i) => ( +
+
+ +
+
+ ))} +
+``` + +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 `
` is the semantic wrapper now) +- Keep the gradient background, dot pattern, and all content unchanged + +Change: + +```tsx +
+``` + +To: + +```tsx +
+``` + +**Step 2: Update TrustStrip** + +- Remove `full-bleed` from the section +- Change to a `div` (Chapter is the semantic `
`) +- Keep all content and animation unchanged + +Change: + +```tsx +
+``` + +To: + +```tsx +
+``` + +**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 `
` to `
` +- Keep all content unchanged + +**Step 2: Update CTABanner** + +- Remove `full-bleed` from the section +- Change `
` to `
` +- 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 `
` to `
` (or keep as `
` since Chapter 4 is a plain `
`) + +Actually, Chapter 4 is NOT sticky (it's the last chapter, just a plain wrapper). So ContactSection and SupportDownloadsSection can remain as `
` 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 ( +
+ {/* Chapter 1: Who we are */} + + + + + + {/* Chapter 2: What we offer */} + + + + + {/* Chapter 3: Why choose us */} + + + + + + {/* Chapter 4: Get in touch (no sticky, last chapter) */} +
+ + +
+ + {/* Sticky Mobile CTA */} + {showStickyCTA && ( +
+ +
+ )} +
+ ); +} +``` + +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) +``` diff --git a/docs/plans/2026-03-05-portal-ui-overhaul-plan.md b/docs/plans/2026-03-05-portal-ui-overhaul-plan.md new file mode 100644 index 00000000..74377fbd --- /dev/null +++ b/docs/plans/2026-03-05-portal-ui-overhaul-plan.md @@ -0,0 +1,1690 @@ +# Portal UI Overhaul Implementation Plan + +> **For Claude:** + +**Goal:** Overhaul the signed-in portal experience with a dark navy sidebar, updated typography, warmer color palette, new shared components, redesigned header with command palette, and an enriched subscriptions page with grid view. + +**Architecture:** Pure frontend changes across the portal app. Update design tokens (CSS variables), font imports, shared components (atoms/molecules), shell components (header, sidebar), and feature views (subscriptions, dashboard). No BFF or domain changes needed. + +**Tech Stack:** Next.js 15, React 19, Tailwind CSS v4, CVA (class-variance-authority), HeroIcons, Lucide React, next/font/google + +--- + +## Task 1: Typography — Add DM Sans & JetBrains Mono Fonts + +**Files:** + +- Modify: `apps/portal/src/app/layout.tsx` +- Modify: `apps/portal/src/app/globals.css` (lines 20-21, the `--font-sans` and `--font-display` variables) + +**Step 1: Add DM Sans and JetBrains Mono font imports in layout.tsx** + +Add two new font imports alongside the existing Plus Jakarta Sans: + +```tsx +import { Plus_Jakarta_Sans, DM_Sans, JetBrains_Mono } from "next/font/google"; + +const plusJakartaSans = Plus_Jakarta_Sans({ + subsets: ["latin"], + variable: "--font-jakarta", + 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", +}); +``` + +Update the body className to include all three font variables: + +```tsx + +``` + +**Step 2: Update CSS font-family variables in globals.css** + +In the `:root` block (around lines 20-21), change: + +```css +/* Typography */ +--font-sans: var(--font-dm-sans, system-ui, sans-serif); +--font-display: var(--font-jakarta, var(--font-sans)); +--font-mono: var(--font-jetbrains, ui-monospace, monospace); +``` + +**Step 3: Add font-mono to Tailwind theme mapping** + +In the `@theme` block of globals.css (around line 228), add after `--font-family-display`: + +```css +--font-family-mono: var(--font-mono); +``` + +**Step 4: Verify** + +Run: `pnpm type-check` +Expected: PASS (no type errors from font imports) + +Run: `pnpm lint` +Expected: PASS + +**Step 5: Commit** + +``` +feat(portal): update typography system with DM Sans and JetBrains Mono + +Replace Geist Sans with DM Sans for body text and add JetBrains Mono +for monospace/data display. Plus Jakarta Sans remains for display headings. +``` + +--- + +## Task 2: Color System — Dark Navy Sidebar & Warmer Neutrals + +**Files:** + +- Modify: `apps/portal/src/app/globals.css` (`:root` and `.dark` blocks) + +**Step 1: Update sidebar colors to dark navy** + +In the `:root` block, replace the sidebar color variables (around lines 82-86): + +```css +/* Sidebar - Dark Navy */ +--sidebar: oklch(0.18 0.03 250); +--sidebar-foreground: oklch(1 0 0); +--sidebar-border: oklch(0.25 0.04 250); +--sidebar-active: oklch(0.99 0 0 / 0.12); +--sidebar-accent: var(--primary); +``` + +In the `.dark` block, update sidebar dark mode (around lines 195-197): + +```css +/* Sidebar - Dark Navy for dark mode */ +--sidebar: oklch(0.13 0.025 250); +--sidebar-border: oklch(0.22 0.03 250); +``` + +**Step 2: Add sidebar-accent to Tailwind theme mapping** + +In the `@theme` block, after `--color-sidebar-active` (around line 290), add: + +```css +--color-sidebar-accent: var(--sidebar-accent); +``` + +**Step 3: Warm up neutral surface colors** + +In the `:root` block, adjust these variables to add slight warmth: + +```css +/* Core Surfaces - slightly warmer */ +--muted: oklch(0.965 0.006 70); +--muted-foreground: oklch(0.46 0.01 70); + +/* Chrome - slightly warmer borders */ +--border: oklch(0.925 0.006 70); +--input: oklch(0.955 0.005 70); +``` + +In the `.dark` block, update the warm equivalents: + +```css +--muted: oklch(0.25 0.008 70); +--muted-foreground: oklch(0.72 0.01 70); +--border: oklch(0.3 0.012 70); +--input: oklch(0.33 0.01 70); +``` + +**Step 4: Update the main background for a subtle warm tint** + +In `:root`: + +```css +--background: oklch(0.993 0.002 70); +--surface-elevated: oklch(0.998 0.001 70); +--surface-sunken: oklch(0.975 0.004 70); +``` + +In `.dark`: + +```css +--background: oklch(0.12 0.012 250); +--surface-elevated: oklch(0.18 0.012 250); +--surface-sunken: oklch(0.1 0.012 250); +``` + +**Step 5: Verify** + +Run: `pnpm type-check` +Expected: PASS + +Run: `pnpm lint` +Expected: PASS + +**Step 6: Commit** + +``` +style(portal): dark navy sidebar and warmer neutral palette + +Switch sidebar from deep purple to dark navy for brand continuity. +Warm up neutral surfaces with subtle warmth (hue 70) to reduce sterility. +``` + +--- + +## Task 3: Sidebar Component — Navy Styling & Section Labels + +**Files:** + +- Modify: `apps/portal/src/components/organisms/AppShell/Sidebar.tsx` +- Modify: `apps/portal/src/components/organisms/AppShell/navigation.ts` + +**Step 1: Update sidebar header branding area** + +In `Sidebar.tsx`, update the header section (around lines 67-77). Replace the logo background and add a subtle gradient to the sidebar header: + +```tsx +
+
+
+ +
+
+ Assist Solutions +

Customer Portal

+
+
+
+``` + +**Step 2: Add section labels to navigation** + +In `navigation.ts`, add a `section` property to NavigationItem interface: + +```ts +export interface NavigationItem { + name: string; + href?: string; + icon: ComponentType>; + children?: NavigationChild[]; + isLogout?: boolean; + section?: string; +} +``` + +Add section labels to the `baseNavigation` array. Add `section: "Overview"` to Dashboard, `section: "Account"` to Billing (the first item after Dashboard/Orders), and `section: ""` to the logout item. This creates visual groupings. + +```ts +export const baseNavigation: NavigationItem[] = [ + { name: "Dashboard", href: "/account", icon: HomeIcon, section: "Overview" }, + { name: "Orders", href: "/account/orders", icon: ClipboardDocumentListIcon }, + { + name: "Billing", + icon: CreditCardIcon, + section: "Account", + children: [ + { name: "Invoices", href: "/account/billing/invoices" }, + { name: "Payment Methods", href: "/account/billing/payment-methods" }, + ], + }, + // ... rest remains the same +``` + +**Step 3: Render section labels in Sidebar.tsx** + +In the `Sidebar` component's nav rendering, add section label support. Replace the `navigation.map` block: + +```tsx + +``` + +**Step 4: Update active state to use primary blue accent instead of white** + +In `Sidebar.tsx`, update the shared styling constants (around lines 12-15): + +```tsx +const navItemBaseClass = + "group w-full flex items-center px-3 py-2 text-[13px] font-medium rounded-lg transition-all duration-200 relative focus:outline-none focus:ring-2 focus:ring-white/20"; +const activeClass = "text-white bg-white/[0.08] shadow-sm"; +const inactiveClass = "text-white/60 hover:text-white/90 hover:bg-white/[0.06]"; +``` + +Update `ActiveIndicator` to use primary blue: + +```tsx +function ActiveIndicator({ small = false }: { small?: boolean }) { + const size = small ? "w-0.5 h-3.5" : "w-[3px] h-5"; + return ( +
+ ); +} +``` + +Update `NavIcon` to use subtler styling: + +```tsx +function NavIcon({ + icon: Icon, + isActive, + variant = "default", +}: { + icon: ComponentType>; + isActive: boolean; + variant?: "default" | "logout"; +}) { + if (variant === "logout") { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +} +``` + +**Step 5: Update child navigation items styling** + +In `ExpandableNavItem`, update the child items container and styling (around line 144): + +```tsx +
+ {item.children?.map((child: NavigationChild) => { + const isChildActive = pathname === (child.href || "").split(/[?#]/)[0]; + return ( + child.href && void router.prefetch(child.href)} + className={`group flex items-center px-2.5 py-1.5 text-[13px] rounded-md transition-all duration-200 relative ${ + isChildActive + ? "text-white bg-white/[0.08] font-medium" + : "text-white/50 hover:text-white/80 hover:bg-white/[0.04] font-normal" + }`} + title={child.tooltip || child.name} + aria-current={isChildActive ? "page" : undefined} + > + {isChildActive && } + {child.name} + + ); + })} +
+``` + +Remove the dot indicator before child names (cleaner without it). + +**Step 6: Update the logout button styling** + +```tsx +function LogoutNavItem({ item, onLogout }: { item: NavigationItem; onLogout: () => void }) { + return ( +
+ +
+ ); +} +``` + +**Step 7: Verify** + +Run: `pnpm type-check` +Expected: PASS + +Run: `pnpm lint` +Expected: PASS + +**Step 8: Commit** + +``` +style(portal): overhaul sidebar with navy theme and section labels + +- Switch from purple to dark navy color scheme +- Add section labels (Overview, Account) for visual grouping +- Use primary blue for active indicator instead of white +- Reduce visual weight of icons and text for cleaner look +- Separate logout with border divider +``` + +--- + +## Task 4: Header — Redesign with Search & Improved Profile + +**Files:** + +- Modify: `apps/portal/src/components/organisms/AppShell/Header.tsx` +- Modify: `apps/portal/src/components/organisms/AppShell/AppShell.tsx` (pass breadcrumbs to header) + +**Step 1: Redesign the Header component** + +Replace the entire Header component in `Header.tsx`: + +```tsx +"use client"; + +import Link from "next/link"; +import { memo } from "react"; +import { + Bars3Icon, + QuestionMarkCircleIcon, + MagnifyingGlassIcon, +} from "@heroicons/react/24/outline"; +import { NotificationBell } from "@/features/notifications"; + +interface UserInfo { + firstName?: string | null; + lastName?: string | null; + email?: string | null; +} + +function getDisplayName(user: UserInfo | null, profileReady: boolean): string { + const fullName = [user?.firstName, user?.lastName].filter(Boolean).join(" "); + const emailPrefix = user?.email?.split("@")[0]; + if (profileReady) { + return fullName || emailPrefix || "Account"; + } + return emailPrefix || "Account"; +} + +function getInitials(user: UserInfo | null, profileReady: boolean, displayName: string): string { + if (profileReady && user?.firstName && user?.lastName) { + return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase(); + } + return displayName.slice(0, 2).toUpperCase(); +} + +interface HeaderProps { + onMenuClick: () => void; + user: UserInfo | null; + profileReady: boolean; +} + +export const Header = memo(function Header({ onMenuClick, user, profileReady }: HeaderProps) { + const displayName = getDisplayName(user, profileReady); + const initials = getInitials(user, profileReady, displayName); + + return ( +
+
+ {/* Mobile menu button */} + + + {/* Search trigger */} + + +
+ + {/* Right side actions */} +
+ {/* Notification bell */} + + + {/* Help link */} + + + + + {/* Divider */} +
+ + {/* Profile link */} + +
+ {initials} +
+ {displayName} + +
+
+
+ ); +}); +``` + +Key changes: + +- Reduced header height from 64px to 56px (h-14) +- Added search trigger button with keyboard shortcut hint +- Added visual divider between actions and profile +- Tightened spacing and made icons slightly smaller +- More transparent glass effect on the header + +**Step 2: Verify** + +Run: `pnpm type-check` +Expected: PASS + +Run: `pnpm lint` +Expected: PASS + +**Step 3: Commit** + +``` +style(portal): redesign header with search bar and tighter layout + +- Add search trigger button with keyboard shortcut hint (visual only for now) +- Reduce header height from 64px to 56px +- Add divider between action icons and profile +- Increase glass morphism transparency +- Tighten icon sizes and spacing +``` + +--- + +## Task 5: New Shared Component — StatusIndicator + +**Files:** + +- Create: `apps/portal/src/components/atoms/status-indicator.tsx` +- Modify: `apps/portal/src/components/atoms/index.ts` + +**Step 1: Create the StatusIndicator component** + +```tsx +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 { + status: StatusIndicatorStatus; + label?: string; + pulse?: boolean; + className?: string; +} + +export function StatusIndicator({ status, label, size, pulse, className }: StatusIndicatorProps) { + return ( + + + {label && {label}} + + ); +} +``` + +**Step 2: Export from atoms barrel** + +In `apps/portal/src/components/atoms/index.ts`, add: + +```ts +export { StatusIndicator, type StatusIndicatorStatus } from "./status-indicator"; +``` + +**Step 3: Verify** + +Run: `pnpm type-check` +Expected: PASS + +**Step 4: Commit** + +``` +feat(portal): add StatusIndicator shared component + +Consistent dot + label component for status display across subscriptions, +orders, and support views. Supports 5 status variants with optional pulse. +``` + +--- + +## Task 6: New Shared Component — MetricCard + +**Files:** + +- Create: `apps/portal/src/components/molecules/MetricCard/MetricCard.tsx` +- Create: `apps/portal/src/components/molecules/MetricCard/index.ts` +- Modify: `apps/portal/src/components/molecules/index.ts` + +**Step 1: Create the MetricCard component** + +```tsx +import type { ReactNode } from "react"; +import { cn } from "@/shared/utils"; + +type MetricTone = "primary" | "success" | "warning" | "danger" | "info" | "neutral"; + +const toneStyles: Record = { + 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 ( +
+ {icon && ( +
+ {icon} +
+ )} +
+

{label}

+
+

+ {value} +

+ {trend && ( + + {trend.value} + + )} +
+ {subtitle &&

{subtitle}

} +
+
+ ); +} + +export function MetricCardSkeleton({ className }: { className?: string }) { + return ( +
+
+
+
+
+
+
+ ); +} +``` + +**Step 2: Create barrel file** + +`apps/portal/src/components/molecules/MetricCard/index.ts`: + +```ts +export { MetricCard, MetricCardSkeleton, type MetricCardProps } from "./MetricCard"; +``` + +**Step 3: Export from molecules barrel** + +In `apps/portal/src/components/molecules/index.ts`, add: + +```ts +export { MetricCard, MetricCardSkeleton, type MetricCardProps } from "./MetricCard"; +``` + +**Step 4: Verify** + +Run: `pnpm type-check` +Expected: PASS + +**Step 5: Commit** + +``` +feat(portal): add MetricCard shared component + +Reusable metric display with icon, value, trend indicator, and tone-based +styling. Used across dashboard, subscriptions, and billing pages. +``` + +--- + +## Task 7: New Shared Component — ViewToggle + +**Files:** + +- Create: `apps/portal/src/components/atoms/view-toggle.tsx` +- Modify: `apps/portal/src/components/atoms/index.ts` + +**Step 1: Create the ViewToggle component** + +```tsx +"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 ( +
+ + +
+ ); +} +``` + +**Step 2: Export from atoms barrel** + +In `apps/portal/src/components/atoms/index.ts`, add: + +```ts +export { ViewToggle, type ViewMode } from "./view-toggle"; +``` + +**Step 3: Verify** + +Run: `pnpm type-check` +Expected: PASS + +**Step 4: Commit** + +``` +feat(portal): add ViewToggle shared component + +Grid/list toggle button pair for collection views. Used in subscriptions +and any future list pages. +``` + +--- + +## Task 8: Enhanced FilterBar Component + +**Files:** + +- Modify: `apps/portal/src/components/molecules/SearchFilterBar/SearchFilterBar.tsx` + +**Step 1: Enhance the SearchFilterBar with active filter pills** + +Read the current file first, then update it. Keep the existing props interface but add support for additional filter display and an optional ViewToggle integration: + +```tsx +"use client"; + +import { MagnifyingGlassIcon, FunnelIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { cn } from "@/shared/utils"; +import type { ReactNode } from "react"; + +export interface FilterOption { + value: string; + label: string; +} + +export interface SearchFilterBarProps { + searchValue: string; + onSearchChange: (value: string) => void; + searchPlaceholder?: string; + filterValue?: string; + onFilterChange?: (value: string) => void; + filterOptions?: FilterOption[]; + filterLabel?: string; + activeFilters?: { label: string; onRemove: () => void }[]; + children?: ReactNode; +} + +export function SearchFilterBar({ + searchValue, + onSearchChange, + searchPlaceholder = "Search...", + filterValue, + onFilterChange, + filterOptions, + filterLabel = "Filter", + activeFilters, + children, +}: SearchFilterBarProps) { + return ( +
+
+ {/* Search */} +
+ + 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" + /> +
+ +
+ {/* Filter select */} + {filterOptions && onFilterChange && ( +
+ + +
+ )} + + {/* Custom actions (ViewToggle, etc.) */} + {children} +
+
+ + {/* Active filter pills */} + {activeFilters && activeFilters.length > 0 && ( +
+ {activeFilters.map(filter => ( + + ))} +
+ )} +
+ ); +} +``` + +**Step 2: Verify** + +Run: `pnpm type-check` +Expected: PASS + +Run: `pnpm lint` +Expected: PASS + +**Step 3: Commit** + +``` +style(portal): enhance SearchFilterBar with active filter pills + +- Add active filter pill display with remove buttons +- Accept children for custom actions (ViewToggle integration) +- Tighten visual styling and reduce border weight +``` + +--- + +## Task 9: Subscription Grid Card Component + +**Files:** + +- Create: `apps/portal/src/features/subscriptions/components/SubscriptionGridCard.tsx` +- Modify: `apps/portal/src/features/subscriptions/components/index.ts` + +**Step 1: Create the grid card component** + +```tsx +"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, SUBSCRIPTION_CYCLE } 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 ( + + {/* Header: name + status */} +
+
+

+ {subscription.productName} +

+

+ #{subscription.serviceId} +

+
+ +
+ + {/* Price */} +
+ + {formatCurrency(subscription.amount, subscription.currency)} + + {cycleLabel && /{cycleLabel}} +
+ + {/* Footer: next due + action */} +
+ {subscription.nextDue && ( +
+ + + {new Date(subscription.nextDue).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} + +
+ )} + + Manage + + +
+ + ); +} + +export function SubscriptionGridCardSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} +``` + +**Step 2: Export from subscriptions components barrel** + +In `apps/portal/src/features/subscriptions/components/index.ts`, add: + +```ts +export { SubscriptionGridCard, SubscriptionGridCardSkeleton } from "./SubscriptionGridCard"; +``` + +**Step 3: Verify** + +Run: `pnpm type-check` +Expected: PASS + +**Step 4: Commit** + +``` +feat(portal): add SubscriptionGridCard component + +Card-based subscription display for grid view with status indicator, +pricing, next due date, and hover-reveal manage action. +``` + +--- + +## Task 10: Subscriptions Page — Grid View & Enriched Stats + +**Files:** + +- Modify: `apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx` + +**Step 1: Rewrite the SubscriptionsList view** + +Replace the full contents of `SubscriptionsList.tsx`: + +```tsx +"use client"; + +import { useState, useMemo } from "react"; +import { Button } from "@/components/atoms/button"; +import { ViewToggle, type ViewMode } from "@/components/atoms/view-toggle"; +import { MetricCard, MetricCardSkeleton } from "@/components/molecules/MetricCard"; +import { ErrorBoundary } from "@/components/molecules"; +import { PageLayout } from "@/components/templates/PageLayout"; +import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar"; +import { SubscriptionTableSkeleton } from "@/components/atoms/loading-skeleton"; +import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; +import { SubscriptionTable } from "@/features/subscriptions/components/SubscriptionTable"; +import { + SubscriptionGridCard, + SubscriptionGridCardSkeleton, +} from "@/features/subscriptions/components/SubscriptionGridCard"; +import { Server, CheckCircle, XCircle, TrendingUp } from "lucide-react"; +import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks"; +import { + SUBSCRIPTION_STATUS, + type Subscription, + type SubscriptionStatus, +} from "@customer-portal/domain/subscriptions"; + +const SUBSCRIPTION_STATUS_OPTIONS = Object.values(SUBSCRIPTION_STATUS) as SubscriptionStatus[]; + +function SubscriptionMetrics({ + stats, +}: { + stats: { active: number; completed: number; cancelled: number }; +}) { + return ( +
+ } + label="Active" + value={stats.active} + tone="success" + /> + } + label="Total" + value={stats.active + stats.completed + stats.cancelled} + tone="primary" + /> + } + label="Completed" + value={stats.completed} + tone="info" + /> + } + label="Cancelled" + value={stats.cancelled} + tone="neutral" + /> +
+ ); +} + +function SubscriptionMetricsSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); +} + +function SubscriptionGrid({ + subscriptions, + loading, +}: { + subscriptions: Subscription[]; + loading: boolean; +}) { + if (subscriptions.length === 0 && !loading) { + return ( +
+ +

No subscriptions found

+

No active subscriptions at this time

+
+ ); + } + + return ( +
+ {subscriptions.map(sub => ( + + ))} + {loading && + Array.from({ length: 3 }).map((_, i) => )} +
+ ); +} + +export function SubscriptionsListContainer() { + const [searchTerm, setSearchTerm] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [viewMode, setViewMode] = useState("grid"); + + const { + data: subscriptionData, + error, + isFetching, + } = useSubscriptions(statusFilter === "all" ? {} : { status: statusFilter }); + const { data: stats } = useSubscriptionStats(); + const showLoading = !subscriptionData && !error; + + const subscriptions = useMemo((): Subscription[] => { + if (!subscriptionData) return []; + if (Array.isArray(subscriptionData)) return subscriptionData; + return subscriptionData.subscriptions; + }, [subscriptionData]); + + const filteredSubscriptions = useMemo(() => { + if (!searchTerm) return subscriptions; + return subscriptions.filter( + s => + s.productName.toLowerCase().includes(searchTerm.toLowerCase()) || + s.serviceId.toString().includes(searchTerm) + ); + }, [subscriptions, searchTerm]); + + const statusFilterOptions = useMemo( + () => [ + { value: "all" as const, label: "All Status" }, + ...SUBSCRIPTION_STATUS_OPTIONS.map(status => ({ value: status, label: status })), + ], + [] + ); + + const activeFilters = useMemo(() => { + const filters: { label: string; onRemove: () => void }[] = []; + if (statusFilter !== "all") { + filters.push({ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("all") }); + } + if (searchTerm) { + filters.push({ label: `Search: ${searchTerm}`, onRemove: () => setSearchTerm("") }); + } + return filters; + }, [statusFilter, searchTerm]); + + if (showLoading || error) { + return ( + } + title="Subscriptions" + description="Manage your active subscriptions" + > + +
+ + +
+
+
+ ); + } + + return ( + } + title="Subscriptions" + description="Manage your active subscriptions" + actions={ + + } + > + + {stats && } + +
+
+ setStatusFilter(value as SubscriptionStatus | "all")} + filterOptions={statusFilterOptions} + filterLabel="Filter by status" + activeFilters={activeFilters.length > 0 ? activeFilters : undefined} + > + + +
+ +
+ {viewMode === "grid" ? ( + + ) : ( + + )} +
+
+
+
+ ); +} + +export default SubscriptionsListContainer; +``` + +**Step 2: Verify** + +Run: `pnpm type-check` +Expected: PASS + +Run: `pnpm lint` +Expected: PASS + +**Step 3: Commit** + +``` +feat(portal): overhaul subscriptions page with grid view and metrics + +- Replace stat cards with 4-column MetricCard grid (active, total, completed, cancelled) +- Add grid/list view toggle with default grid view +- Grid displays subscription cards with status, price, and next due date +- Add active filter pills for search and status filters +- Keep existing table view as list mode option +``` + +--- + +## Task 11: Dashboard — Enhanced Control Center Layout + +**Files:** + +- Modify: `apps/portal/src/features/dashboard/views/DashboardView.tsx` +- Modify: `apps/portal/src/features/dashboard/components/QuickStats.tsx` + +**Step 1: Update DashboardGreeting for a cleaner look** + +In `DashboardView.tsx`, update the `DashboardGreeting` component: + +```tsx +function DashboardGreeting({ + displayName, + taskCount, + hasUrgentTask, +}: { + displayName: string; + taskCount: number; + hasUrgentTask: boolean; +}) { + return ( +
+

+ Welcome back, {displayName} +

+ {taskCount > 0 ? ( +
+ + {hasUrgentTask && } + {taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`} + +
+ ) : ( +

+ Everything is up to date +

+ )} +
+ ); +} +``` + +Key changes: Combined greeting into single line, removed "Welcome back" as separate element, tightened spacing. + +**Step 2: Update QuickStats to use MetricCard style** + +In `QuickStats.tsx`, update `StatItem` to have tighter, more refined styling: + +```tsx +function StatItem({ icon: Icon, label, value, href, tone = "primary", emptyText }: StatItemProps) { + const styles = toneStyles[tone]; + + return ( + +
+ +
+
+

{label}

+ {value > 0 ? ( +

+ {value} +

+ ) : ( +

{emptyText || "None"}

+ )} +
+ + + ); +} +``` + +**Step 3: Verify** + +Run: `pnpm type-check` +Expected: PASS + +Run: `pnpm lint` +Expected: PASS + +**Step 4: Commit** + +``` +style(portal): refine dashboard greeting and quick stats + +- Combine greeting into single "Welcome back, Name" heading +- Tighten QuickStats card styling with reduced padding and font sizes +- Use font-display for numeric values +- Lighter hover states and borders +``` + +--- + +## Task 12: Button Variant — Add `subtle` Variant + +**Files:** + +- Modify: `apps/portal/src/components/atoms/button.tsx` + +**Step 1: Add the `subtle` variant to buttonVariants** + +In the `variant` object inside `buttonVariants` (around line 12), add after `ghost`: + +```ts +subtle: + "bg-muted/50 text-foreground hover:bg-muted border border-transparent hover:border-border/40", +``` + +**Step 2: Verify** + +Run: `pnpm type-check` +Expected: PASS + +**Step 3: Commit** + +``` +feat(portal): add subtle button variant + +Faint background tint button for secondary actions inside cards. Sits +between ghost (transparent) and outline (bordered). +``` + +--- + +## Task 13: AppShell Layout — Reduce Sidebar Width & Background Texture + +**Files:** + +- Modify: `apps/portal/src/components/organisms/AppShell/AppShell.tsx` +- Modify: `apps/portal/src/styles/tokens.css` + +**Step 1: Reduce sidebar width from 240px to 220px** + +In `AppShell.tsx`, update the desktop sidebar container (around line 202): + +```tsx +
+``` + +In `tokens.css`, update the sidebar width variable (line 57): + +```css +--cp-sidebar-width: 13.75rem; /* 220px */ +``` + +**Step 2: Add a subtle background pattern to the main content area** + +In `AppShell.tsx`, update the main content wrapper (around line 213): + +```tsx +
+``` + +No additional texture needed - the warmer neutrals from Task 2 already provide enough visual interest. Keep it clean. + +**Step 3: Verify** + +Run: `pnpm type-check` +Expected: PASS + +**Step 4: Commit** + +``` +style(portal): reduce sidebar width to 220px + +Tighter sidebar gives more room to content. Update both the CSS +variable and the inline width. +``` + +--- + +## Task 14: Motion Refinements — Card Entrance & Hover Polish + +**Files:** + +- Modify: `apps/portal/src/styles/utilities.css` + +**Step 1: Add card grid stagger animation** + +Add after the existing stagger children rules (around line 243): + +```css +/* 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; +} +``` + +**Step 2: Add to reduced motion rules** + +In the `@media (prefers-reduced-motion: reduce)` block, add `.cp-stagger-grid > *` to the selector list. + +**Step 3: Verify** + +Run: `pnpm lint` +Expected: PASS + +**Step 4: Commit** + +``` +style(portal): add grid stagger animation for subscription cards + +Faster 30ms stagger delay for dense grid layouts. Respects +prefers-reduced-motion. +``` + +--- + +## Task 15: Final Verification & Type Check + +**Step 1: Run full type check** + +Run: `pnpm type-check` +Expected: PASS with no errors + +**Step 2: Run linting** + +Run: `pnpm lint` +Expected: PASS with no errors + +**Step 3: Run tests** + +Run: `pnpm test` +Expected: All existing tests pass (changes are purely visual) + +**Step 4: Final commit if any fixes needed** + +``` +chore(portal): fix any type or lint issues from UI overhaul +``` + +--- + +## Dependency Graph + +``` +Task 1 (fonts) ──────────────┐ +Task 2 (colors) ─────────────┤ + ├──> Task 3 (sidebar) ──────┐ + ├──> Task 4 (header) │ + ├──> Task 5 (StatusIndicator) ├──> Task 9 (grid card) ──> Task 10 (subscriptions page) + ├──> Task 6 (MetricCard) ────┘ │ + ├──> Task 7 (ViewToggle) ─────────────────────────────────┘ + ├──> Task 8 (FilterBar) ──────────────────────────────────┘ + ├──> Task 12 (button variant) + └──> Task 14 (motion) + +Task 11 (dashboard) depends on Task 6 (MetricCard) +Task 13 (AppShell width) is independent +Task 15 (verification) depends on all tasks +``` + +**Independent task groups that can be parallelized:** + +- Group A: Tasks 1, 2 (design tokens — must go first) +- Group B: Tasks 3, 4, 13 (shell components — after Group A) +- Group C: Tasks 5, 6, 7, 8, 12, 14 (shared components — after Group A) +- Group D: Tasks 9, 10 (subscriptions — after Group C) +- Group E: Task 11 (dashboard — after Task 6) +- Group F: Task 15 (verification — after all) diff --git a/docs/plans/image.png b/docs/plans/image.png new file mode 100644 index 00000000..f4a14437 Binary files /dev/null and b/docs/plans/image.png differ diff --git a/image.png b/image.png index ede6718d..f4a14437 100644 Binary files a/image.png and b/image.png differ