Assist_Design/docs/plans/2026-03-05-portal-ui-overhaul-plan.md
barsa 57f2c543d1 style: update typography and layout across components
- Replaced font references in globals.css to use DM Sans and JetBrains Mono for improved typography consistency.
- Adjusted various components to utilize the new font styles, enhancing visual hierarchy and readability.
- Updated layout properties in AppShell and Sidebar for better alignment and spacing.
- Enhanced button styles to include a new subtle variant for improved UI flexibility.
- Refactored SearchFilterBar to support active filter display, improving user interaction experience.
- Made minor adjustments to the DashboardView and landing page components for better visual consistency.
2026-03-06 10:45:51 +09:00

49 KiB

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:

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:

<body className={`${plusJakartaSans.variable} ${dmSans.variable} ${jetbrainsMono.variable} antialiased`}>

Step 2: Update CSS font-family variables in globals.css

In the :root block (around lines 20-21), change:

/* 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:

--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):

/* 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):

/* 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:

--color-sidebar-accent: var(--sidebar-accent);

Step 3: Warm up neutral surface colors

In the :root block, adjust these variables to add slight warmth:

/* 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:

--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:

--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:

--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:

<div className="flex items-center flex-shrink-0 h-16 px-5 border-b border-sidebar-border/50">
  <div className="flex items-center space-x-3">
    <div className="h-9 w-9 bg-white/10 backdrop-blur-sm rounded-lg border border-white/10 flex items-center justify-center">
      <Logo size={22} />
    </div>
    <div>
      <span className="text-sm font-bold text-white tracking-tight">Assist Solutions</span>
      <p className="text-[11px] text-white/50 font-medium">Customer Portal</p>
    </div>
  </div>
</div>

Step 2: Add section labels to navigation

In navigation.ts, add a section property to NavigationItem interface:

export interface NavigationItem {
  name: string;
  href?: string;
  icon: ComponentType<SVGProps<SVGSVGElement>>;
  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.

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:

<nav className="flex-1 px-3 space-y-0.5">
  {navigation.map((item, index) => (
    <div key={item.name}>
      {item.section && (
        <div className={`px-3 pt-${index === 0 ? "0" : "5"} pb-2`}>
          <span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-white/30">
            {item.section}
          </span>
        </div>
      )}
      <NavigationItem
        item={item}
        pathname={pathname}
        isExpanded={expandedItems.includes(item.name)}
        toggleExpanded={toggleExpanded}
      />
    </div>
  ))}
</nav>

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):

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:

function ActiveIndicator({ small = false }: { small?: boolean }) {
  const size = small ? "w-0.5 h-3.5" : "w-[3px] h-5";
  return (
    <div className={`absolute left-0 top-1/2 -translate-y-1/2 ${size} bg-primary rounded-full`} />
  );
}

Update NavIcon to use subtler styling:

function NavIcon({
  icon: Icon,
  isActive,
  variant = "default",
}: {
  icon: ComponentType<SVGProps<SVGSVGElement>>;
  isActive: boolean;
  variant?: "default" | "logout";
}) {
  if (variant === "logout") {
    return (
      <div className="p-1 mr-2.5 text-red-400/70 group-hover:text-red-300 transition-colors duration-200">
        <Icon className="h-[18px] w-[18px]" />
      </div>
    );
  }

  return (
    <div
      className={`p-1 mr-2.5 transition-colors duration-200 ${
        isActive ? "text-primary" : "text-white/40 group-hover:text-white/70"
      }`}
    >
      <Icon className="h-[18px] w-[18px]" />
    </div>
  );
}

Step 5: Update child navigation items styling

In ExpandableNavItem, update the child items container and styling (around line 144):

<div className="mt-0.5 ml-[30px] space-y-0.5 border-l border-white/[0.08] pl-3">
  {item.children?.map((child: NavigationChild) => {
    const isChildActive = pathname === (child.href || "").split(/[?#]/)[0];
    return (
      <Link
        key={child.href || child.name}
        href={child.href}
        prefetch
        onMouseEnter={() => 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 && <ActiveIndicator small />}
        <span className="truncate">{child.name}</span>
      </Link>
    );
  })}
</div>

Remove the dot indicator before child names (cleaner without it).

Step 6: Update the logout button styling

function LogoutNavItem({ item, onLogout }: { item: NavigationItem; onLogout: () => void }) {
  return (
    <div className="px-3 pt-4 mt-2 border-t border-white/[0.06]">
      <button
        onClick={onLogout}
        className="group w-full flex items-center px-3 py-2 text-[13px] font-medium text-red-400/70 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-red-400/20"
      >
        <NavIcon icon={item.icon} isActive={false} variant="logout" />
        <span>{item.name}</span>
      </button>
    </div>
  );
}

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:

"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 (
    <div className="relative z-40 bg-header/80 backdrop-blur-xl border-b border-border/40">
      <div className="flex items-center h-14 gap-2 px-3 sm:px-5">
        {/* Mobile menu button */}
        <button
          type="button"
          className="md:hidden flex items-center justify-center w-10 h-10 -ml-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/60 active:bg-muted transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
          onClick={onMenuClick}
          aria-label="Open navigation"
        >
          <Bars3Icon className="h-5 w-5" />
        </button>

        {/* Search trigger */}
        <button
          type="button"
          className="hidden sm:flex items-center gap-2.5 h-9 px-3 w-full max-w-xs rounded-lg bg-muted/50 border border-border/50 text-muted-foreground text-sm hover:bg-muted/80 hover:border-border transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
          aria-label="Search"
        >
          <MagnifyingGlassIcon className="h-3.5 w-3.5 flex-shrink-0" />
          <span className="flex-1 text-left text-xs">Search...</span>
          <kbd className="hidden lg:inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded border border-border/60 bg-background/80 text-[10px] font-mono text-muted-foreground/60">
            <span className="text-[11px]">&#8984;</span>K
          </kbd>
        </button>

        <div className="flex-1" />

        {/* Right side actions */}
        <div className="flex items-center gap-0.5">
          {/* Notification bell */}
          <NotificationBell />

          {/* Help link */}
          <Link
            href="/account/support"
            prefetch
            aria-label="Help"
            className="hidden sm:inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted/60 transition-all duration-200"
            title="Support Center"
          >
            <QuestionMarkCircleIcon className="h-4.5 w-4.5" />
          </Link>

          {/* Divider */}
          <div className="hidden sm:block w-px h-5 bg-border/60 mx-1.5" />

          {/* Profile link */}
          <Link
            href="/account/settings"
            prefetch
            className="group flex items-center gap-2 px-2 py-1 min-h-[40px] text-sm font-medium text-muted-foreground hover:text-foreground rounded-lg transition-all duration-200"
          >
            <div className="h-7 w-7 rounded-lg bg-gradient-to-br from-primary to-accent-gradient flex items-center justify-center text-[11px] font-bold text-primary-foreground shadow-sm group-hover:shadow-md transition-shadow">
              {initials}
            </div>
            <span className="hidden sm:inline text-[13px]">{displayName}</span>
          </Link>
        </div>
      </div>
    </div>
  );
});

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

import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/utils";

const statusIndicatorVariants = cva("inline-flex items-center gap-1.5", {
  variants: {
    size: {
      sm: "text-xs",
      md: "text-sm",
      lg: "text-base",
    },
  },
  defaultVariants: {
    size: "md",
  },
});

const dotVariants = cva("rounded-full flex-shrink-0", {
  variants: {
    status: {
      active: "bg-success",
      warning: "bg-warning",
      error: "bg-danger",
      inactive: "bg-muted-foreground/30",
      pending: "bg-info",
    },
    size: {
      sm: "h-1.5 w-1.5",
      md: "h-2 w-2",
      lg: "h-2.5 w-2.5",
    },
    pulse: {
      true: "animate-pulse",
      false: "",
    },
  },
  defaultVariants: {
    status: "active",
    size: "md",
    pulse: false,
  },
});

export type StatusIndicatorStatus = "active" | "warning" | "error" | "inactive" | "pending";

interface StatusIndicatorProps extends VariantProps<typeof statusIndicatorVariants> {
  status: StatusIndicatorStatus;
  label?: string;
  pulse?: boolean;
  className?: string;
}

export function StatusIndicator({ status, label, size, pulse, className }: StatusIndicatorProps) {
  return (
    <span className={cn(statusIndicatorVariants({ size }), className)}>
      <span className={dotVariants({ status, size, pulse: pulse ?? status === "pending" })} />
      {label && <span className="text-muted-foreground">{label}</span>}
    </span>
  );
}

Step 2: Export from atoms barrel

In apps/portal/src/components/atoms/index.ts, add:

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

import type { ReactNode } from "react";
import { cn } from "@/shared/utils";

type MetricTone = "primary" | "success" | "warning" | "danger" | "info" | "neutral";

const toneStyles: Record<MetricTone, { icon: string; accent: string }> = {
  primary: { icon: "text-primary bg-primary/10", accent: "text-primary" },
  success: { icon: "text-success bg-success/10", accent: "text-success" },
  warning: { icon: "text-warning bg-warning/10", accent: "text-warning" },
  danger: { icon: "text-danger bg-danger/10", accent: "text-danger" },
  info: { icon: "text-info bg-info/10", accent: "text-info" },
  neutral: { icon: "text-muted-foreground bg-muted", accent: "text-muted-foreground" },
};

export interface MetricCardProps {
  icon?: ReactNode;
  label: string;
  value: string | number;
  subtitle?: string;
  tone?: MetricTone;
  trend?: { value: string; positive?: boolean };
  className?: string;
}

export function MetricCard({
  icon,
  label,
  value,
  subtitle,
  tone = "primary",
  trend,
  className,
}: MetricCardProps) {
  const styles = toneStyles[tone];

  return (
    <div
      className={cn(
        "flex items-start gap-3.5 p-4 rounded-xl bg-card border border-border/60",
        "transition-all duration-200 hover:border-border hover:shadow-[var(--cp-shadow-1)]",
        className
      )}
    >
      {icon && (
        <div
          className={cn(
            "flex-shrink-0 h-10 w-10 rounded-lg flex items-center justify-center",
            styles.icon
          )}
        >
          {icon}
        </div>
      )}
      <div className="min-w-0 flex-1">
        <p className="text-xs font-medium text-muted-foreground">{label}</p>
        <div className="flex items-baseline gap-2 mt-0.5">
          <p className="text-2xl font-bold text-foreground tabular-nums font-display tracking-tight">
            {value}
          </p>
          {trend && (
            <span
              className={cn("text-xs font-medium", trend.positive ? "text-success" : "text-danger")}
            >
              {trend.value}
            </span>
          )}
        </div>
        {subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
      </div>
    </div>
  );
}

export function MetricCardSkeleton({ className }: { className?: string }) {
  return (
    <div
      className={cn(
        "flex items-start gap-3.5 p-4 rounded-xl bg-card border border-border/60",
        className
      )}
    >
      <div className="flex-shrink-0 h-10 w-10 rounded-lg cp-skeleton-shimmer" />
      <div className="min-w-0 flex-1 space-y-2">
        <div className="h-3 cp-skeleton-shimmer rounded w-16" />
        <div className="h-7 cp-skeleton-shimmer rounded w-12" />
      </div>
    </div>
  );
}

Step 2: Create barrel file

apps/portal/src/components/molecules/MetricCard/index.ts:

export { MetricCard, MetricCardSkeleton, type MetricCardProps } from "./MetricCard";

Step 3: Export from molecules barrel

In apps/portal/src/components/molecules/index.ts, add:

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

"use client";

import { Squares2X2Icon, ListBulletIcon } from "@heroicons/react/24/outline";
import { cn } from "@/shared/utils";

export type ViewMode = "grid" | "list";

interface ViewToggleProps {
  value: ViewMode;
  onChange: (mode: ViewMode) => void;
  className?: string;
}

export function ViewToggle({ value, onChange, className }: ViewToggleProps) {
  return (
    <div
      className={cn(
        "inline-flex items-center rounded-lg border border-border/60 bg-muted/30 p-0.5",
        className
      )}
    >
      <button
        type="button"
        onClick={() => onChange("grid")}
        className={cn(
          "inline-flex items-center justify-center h-7 w-7 rounded-md transition-all duration-200",
          value === "grid"
            ? "bg-background text-foreground shadow-sm"
            : "text-muted-foreground hover:text-foreground"
        )}
        aria-label="Grid view"
        aria-pressed={value === "grid"}
      >
        <Squares2X2Icon className="h-3.5 w-3.5" />
      </button>
      <button
        type="button"
        onClick={() => onChange("list")}
        className={cn(
          "inline-flex items-center justify-center h-7 w-7 rounded-md transition-all duration-200",
          value === "list"
            ? "bg-background text-foreground shadow-sm"
            : "text-muted-foreground hover:text-foreground"
        )}
        aria-label="List view"
        aria-pressed={value === "list"}
      >
        <ListBulletIcon className="h-3.5 w-3.5" />
      </button>
    </div>
  );
}

Step 2: Export from atoms barrel

In apps/portal/src/components/atoms/index.ts, add:

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:

"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 (
    <div className="space-y-3">
      <div className="flex flex-col sm:flex-row gap-2.5">
        {/* Search */}
        <div className="relative flex-1 max-w-sm">
          <MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/50" />
          <input
            type="text"
            value={searchValue}
            onChange={e => onSearchChange(e.target.value)}
            placeholder={searchPlaceholder}
            className="w-full h-9 pl-9 pr-3 rounded-lg border border-border/60 bg-background text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30 transition-all duration-200"
          />
        </div>

        <div className="flex items-center gap-2">
          {/* Filter select */}
          {filterOptions && onFilterChange && (
            <div className="relative">
              <select
                value={filterValue ?? "all"}
                onChange={e => onFilterChange(e.target.value)}
                className="h-9 pl-3 pr-8 rounded-lg border border-border/60 bg-background text-sm text-foreground appearance-none focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30 transition-all duration-200"
                aria-label={filterLabel}
              >
                {filterOptions.map(opt => (
                  <option key={opt.value} value={opt.value}>
                    {opt.label}
                  </option>
                ))}
              </select>
              <FunnelIcon className="absolute right-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50 pointer-events-none" />
            </div>
          )}

          {/* Custom actions (ViewToggle, etc.) */}
          {children}
        </div>
      </div>

      {/* Active filter pills */}
      {activeFilters && activeFilters.length > 0 && (
        <div className="flex flex-wrap gap-1.5">
          {activeFilters.map(filter => (
            <button
              key={filter.label}
              onClick={filter.onRemove}
              className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-primary/10 text-primary text-xs font-medium hover:bg-primary/20 transition-colors duration-150"
            >
              {filter.label}
              <XMarkIcon className="h-3 w-3" />
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

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

"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 (
    <Link
      href={`/account/subscriptions/${subscription.serviceId}`}
      className={cn(
        "group flex flex-col p-4 rounded-xl bg-card border border-border/60",
        "transition-all duration-200",
        "hover:border-border hover:shadow-[var(--cp-shadow-2)] hover:-translate-y-0.5",
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30",
        className
      )}
    >
      {/* Header: name + status */}
      <div className="flex items-start justify-between gap-2 mb-3">
        <div className="min-w-0 flex-1">
          <h3 className="text-sm font-semibold text-foreground truncate group-hover:text-primary transition-colors">
            {subscription.productName}
          </h3>
          <p className="text-xs text-muted-foreground mt-0.5 font-mono">
            #{subscription.serviceId}
          </p>
        </div>
        <StatusIndicator status={statusIndicator} label={subscription.status} size="sm" />
      </div>

      {/* Price */}
      <div className="mb-3">
        <span className="text-lg font-bold text-foreground tabular-nums font-display">
          {formatCurrency(subscription.amount, subscription.currency)}
        </span>
        {cycleLabel && <span className="text-xs text-muted-foreground ml-1">/{cycleLabel}</span>}
      </div>

      {/* Footer: next due + action */}
      <div className="flex items-center justify-between mt-auto pt-3 border-t border-border/40">
        {subscription.nextDue && (
          <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
            <CalendarDaysIcon className="h-3.5 w-3.5" />
            <span>
              {new Date(subscription.nextDue).toLocaleDateString("en-US", {
                month: "short",
                day: "numeric",
              })}
            </span>
          </div>
        )}
        <span className="inline-flex items-center gap-1 text-xs font-medium text-primary opacity-0 group-hover:opacity-100 transition-opacity duration-200">
          Manage
          <ArrowRightIcon className="h-3 w-3 group-hover:translate-x-0.5 transition-transform" />
        </span>
      </div>
    </Link>
  );
}

export function SubscriptionGridCardSkeleton() {
  return (
    <div className="flex flex-col p-4 rounded-xl bg-card border border-border/60">
      <div className="flex items-start justify-between gap-2 mb-3">
        <div className="space-y-1.5 flex-1">
          <div className="h-4 cp-skeleton-shimmer rounded w-3/4" />
          <div className="h-3 cp-skeleton-shimmer rounded w-16" />
        </div>
        <div className="h-4 cp-skeleton-shimmer rounded-full w-14" />
      </div>
      <div className="h-6 cp-skeleton-shimmer rounded w-20 mb-3" />
      <div className="pt-3 border-t border-border/40">
        <div className="h-3 cp-skeleton-shimmer rounded w-24" />
      </div>
    </div>
  );
}

Step 2: Export from subscriptions components barrel

In apps/portal/src/features/subscriptions/components/index.ts, add:

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:

"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 (
    <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
      <MetricCard
        icon={<CheckCircle className="h-5 w-5" />}
        label="Active"
        value={stats.active}
        tone="success"
      />
      <MetricCard
        icon={<Server className="h-5 w-5" />}
        label="Total"
        value={stats.active + stats.completed + stats.cancelled}
        tone="primary"
      />
      <MetricCard
        icon={<TrendingUp className="h-5 w-5" />}
        label="Completed"
        value={stats.completed}
        tone="info"
      />
      <MetricCard
        icon={<XCircle className="h-5 w-5" />}
        label="Cancelled"
        value={stats.cancelled}
        tone="neutral"
      />
    </div>
  );
}

function SubscriptionMetricsSkeleton() {
  return (
    <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
      {Array.from({ length: 4 }).map((_, i) => (
        <MetricCardSkeleton key={i} />
      ))}
    </div>
  );
}

function SubscriptionGrid({
  subscriptions,
  loading,
}: {
  subscriptions: Subscription[];
  loading: boolean;
}) {
  if (subscriptions.length === 0 && !loading) {
    return (
      <div className="flex flex-col items-center justify-center py-16 text-center">
        <Server className="h-10 w-10 text-muted-foreground/30 mb-3" />
        <p className="text-sm font-medium text-foreground">No subscriptions found</p>
        <p className="text-xs text-muted-foreground mt-1">No active subscriptions at this time</p>
      </div>
    );
  }

  return (
    <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 cp-stagger-children">
      {subscriptions.map(sub => (
        <SubscriptionGridCard key={sub.serviceId} subscription={sub} />
      ))}
      {loading &&
        Array.from({ length: 3 }).map((_, i) => <SubscriptionGridCardSkeleton key={`skel-${i}`} />)}
    </div>
  );
}

export function SubscriptionsListContainer() {
  const [searchTerm, setSearchTerm] = useState("");
  const [statusFilter, setStatusFilter] = useState<SubscriptionStatus | "all">("all");
  const [viewMode, setViewMode] = useState<ViewMode>("grid");

  const {
    data: subscriptionData,
    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 (
      <PageLayout
        icon={<Server />}
        title="Subscriptions"
        description="Manage your active subscriptions"
      >
        <AsyncBlock isLoading={false} error={error}>
          <div className="space-y-6">
            <SubscriptionMetricsSkeleton />
            <SubscriptionTableSkeleton rows={6} />
          </div>
        </AsyncBlock>
      </PageLayout>
    );
  }

  return (
    <PageLayout
      icon={<Server />}
      title="Subscriptions"
      description="Manage your active subscriptions"
      actions={
        <Button as="a" href="/account/services" size="sm">
          Browse Services
        </Button>
      }
    >
      <ErrorBoundary>
        {stats && <SubscriptionMetrics stats={stats} />}

        <div className="bg-card rounded-xl border border-border/60 shadow-[var(--cp-shadow-1)] overflow-hidden">
          <div className="px-4 sm:px-5 py-3.5 border-b border-border/40">
            <SearchFilterBar
              searchValue={searchTerm}
              onSearchChange={setSearchTerm}
              searchPlaceholder="Search subscriptions..."
              filterValue={statusFilter}
              onFilterChange={value => setStatusFilter(value as SubscriptionStatus | "all")}
              filterOptions={statusFilterOptions}
              filterLabel="Filter by status"
              activeFilters={activeFilters.length > 0 ? activeFilters : undefined}
            >
              <ViewToggle value={viewMode} onChange={setViewMode} />
            </SearchFilterBar>
          </div>

          <div className="p-4 sm:p-5">
            {viewMode === "grid" ? (
              <SubscriptionGrid
                subscriptions={filteredSubscriptions}
                loading={isFetching && !!subscriptionData}
              />
            ) : (
              <SubscriptionTable
                subscriptions={filteredSubscriptions}
                loading={isFetching && !!subscriptionData}
                className="border-0 rounded-none shadow-none"
              />
            )}
          </div>
        </div>
      </ErrorBoundary>
    </PageLayout>
  );
}

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:

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

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:

function StatItem({ icon: Icon, label, value, href, tone = "primary", emptyText }: StatItemProps) {
  const styles = toneStyles[tone];

  return (
    <Link
      href={href}
      className={cn(
        "group flex items-center gap-3.5 p-3.5 rounded-xl",
        "bg-card border border-border/60",
        "transition-all duration-200",
        "hover:border-border hover:shadow-[var(--cp-shadow-1)] hover:-translate-y-0.5",
        styles.hoverBorder
      )}
    >
      <div
        className={cn(
          "flex-shrink-0 h-10 w-10 rounded-lg flex items-center justify-center",
          "transition-all duration-200",
          styles.iconBg
        )}
      >
        <Icon className={cn("h-4.5 w-4.5", styles.iconColor)} />
      </div>
      <div className="min-w-0 flex-1">
        <p className="text-xs font-medium text-muted-foreground">{label}</p>
        {value > 0 ? (
          <p className="text-xl font-bold text-foreground tabular-nums mt-0.5 font-display">
            {value}
          </p>
        ) : (
          <p className="text-xs text-muted-foreground mt-1">{emptyText || "None"}</p>
        )}
      </div>
      <ArrowRightIcon className="h-3.5 w-3.5 text-muted-foreground/30 group-hover:text-foreground/60 group-hover:translate-x-0.5 transition-all flex-shrink-0" />
    </Link>
  );
}

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:

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):

<div className="flex flex-col w-[220px] border-r border-sidebar-border/40 bg-sidebar">

In tokens.css, update the sidebar width variable (line 57):

--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):

<div className="flex flex-col w-0 flex-1 overflow-hidden bg-background">

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):

/* 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)