- 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.
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-sansand--font-displayvariables)
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(:rootand.darkblocks)
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]">⌘</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)