From 1f7f77775b1a5e3fb8ee32f2109ac52b3c7580b8 Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 16 Dec 2025 17:10:14 +0900 Subject: [PATCH] Update dashboard components and styles for improved functionality and consistency - Refactored DashboardView to integrate new TaskList, QuickStats, and ActivityFeed components for enhanced task management and user engagement. - Updated global styles in tokens.css to standardize color usage and improve accessibility across the application. - Adjusted color tokens for better visual coherence, including updates to brand and semantic colors. - Enhanced loading states and skeleton screens for a smoother user experience during data fetching. --- apps/portal/next-env.d.ts | 2 +- apps/portal/src/app/globals.css | 408 ++++++++++++++---- .../dashboard/components/ActivityFeed.tsx | 195 +++++++++ .../dashboard/components/QuickStats.tsx | 163 +++++++ .../dashboard/components/TaskCard.tsx | 175 ++++++++ .../dashboard/components/TaskList.tsx | 172 ++++++++ .../features/dashboard/components/index.ts | 4 + .../src/features/dashboard/hooks/index.ts | 1 + .../dashboard/hooks/useDashboardTasks.ts | 207 +++++++++ .../dashboard/views/DashboardView.tsx | 305 ++++--------- apps/portal/src/styles/tokens.css | 237 +++++++--- 11 files changed, 1486 insertions(+), 383 deletions(-) create mode 100644 apps/portal/src/features/dashboard/components/ActivityFeed.tsx create mode 100644 apps/portal/src/features/dashboard/components/QuickStats.tsx create mode 100644 apps/portal/src/features/dashboard/components/TaskCard.tsx create mode 100644 apps/portal/src/features/dashboard/components/TaskList.tsx create mode 100644 apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts diff --git a/apps/portal/next-env.d.ts b/apps/portal/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/apps/portal/next-env.d.ts +++ b/apps/portal/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/portal/src/app/globals.css b/apps/portal/src/app/globals.css index e8d8e260..35b0e4f9 100644 --- a/apps/portal/src/app/globals.css +++ b/apps/portal/src/app/globals.css @@ -9,149 +9,306 @@ :root { --radius: 0.625rem; - /* Core neutrals (light) */ - /* Background and cards are both white for clean look */ + /* ============= CORE NEUTRALS ============= */ --background: oklch(1 0 0); /* pure white for main content */ --foreground: oklch(0.16 0 0); --card: oklch(1 0 0); /* pure white for cards */ --card-foreground: var(--foreground); - --card-muted: oklch(0.98 0.003 250); /* subtle gray for nested/muted cards */ + --card-muted: oklch(0.98 0.008 234.4); /* subtle brand-tinted gray */ --popover: oklch(1 0 0); --popover-foreground: var(--foreground); - /* Primary brand - matches logo cyan blue */ - --primary: oklch(0.68 0.14 220); /* bright cyan blue from logo */ + /* ============= BRAND COLORS (from logo) ============= */ + /* Primary Light Blue - Brand Accent */ + --primary: oklch(0.6884 0.1342 234.4); --primary-foreground: oklch(0.99 0 0); - /* Interaction shades */ - --primary-hover: oklch(0.63 0.14 220); - --primary-active: oklch(0.58 0.14 220); + --primary-hover: oklch(0.62 0.1342 234.4); + --primary-active: oklch(0.56 0.1342 234.4); - /* Subtle surfaces & text */ - --secondary: oklch(0.95 0.003 250); /* subtle cool gray */ + /* Subtle surfaces & text - tinted with primary brand hue */ + --secondary: oklch(0.95 0.015 234.4); --secondary-foreground: oklch(0.29 0 0); - --muted: oklch(0.96 0.003 250); /* subtle cool gray for surfaces */ + --muted: oklch(0.96 0.008 234.4); --muted-foreground: oklch(0.5 0 0); /* Light accent (tinted with brand) */ - --accent: oklch(0.95 0.05 220); /* light cyan tint */ + --accent: oklch(0.95 0.04 234.4); --accent-foreground: var(--foreground); - /* Feedback (now with full semantic set) */ - --destructive: oklch(0.62 0.21 27); - --destructive-foreground: oklch(0.99 0 0); - --destructive-soft: oklch(0.96 0.05 27); + /* ============= SEMANTIC STATUS COLORS ============= */ + /* Three-tier system: text (dark readable), bg (almost white), border (subtle accent) */ + /* Reserve brand cyan for primary interaction; status colors are secondary */ - --success: oklch(0.67 0.14 150); + /* Success - #166534 text, #F0FDF4 bg, #BBF7D0 border */ + --success: oklch(0.42 0.1 145); /* dark green for text/icons */ --success-foreground: oklch(0.99 0 0); - --success-soft: oklch(0.95 0.05 150); + --success-bg: oklch(0.98 0.02 145); /* very light green bg */ + --success-border: oklch(0.93 0.08 150); /* subtle green border */ - --warning: oklch(0.78 0.16 90); - --warning-foreground: oklch(0.16 0 0); - --warning-soft: oklch(0.96 0.07 90); - - --info: oklch(0.68 0.14 220); /* matches primary */ + /* Info - uses primary brand hue */ + --info: oklch(0.48 0.1 234.4); --info-foreground: oklch(0.99 0 0); - --info-soft: oklch(0.95 0.06 220); + --info-bg: oklch(0.97 0.02 234.4); + --info-border: oklch(0.91 0.05 234.4); - /* UI chrome */ - --border: oklch(0.9 0 0); - --border-muted: oklch(0.92 0 0); - --input: oklch(0.88 0 0); /* slightly darker for inputs */ - --ring: oklch(0.72 0.12 220); + /* Warning - #92400E text, #FFFBEB bg, #FDE68A border */ + --warning: oklch(0.45 0.12 55); /* dark amber for text/icons */ + --warning-foreground: oklch(0.99 0 0); + --warning-bg: oklch(0.99 0.02 90); /* very light amber bg */ + --warning-border: oklch(0.92 0.12 90); /* subtle amber border */ + + /* Error - #9F1239 text, #FFF1F2 bg, #FECDD3 border (Overdue) */ + --error: oklch(0.42 0.16 0); /* dark rose for text/icons */ + --error-foreground: oklch(0.99 0 0); + --error-bg: oklch(0.98 0.01 0); /* very light rose bg */ + --error-border: oklch(0.89 0.06 0); /* subtle rose border */ + + /* Destructive - #991B1B text, #FEF2F2 bg, #FECACA border (Terminated) */ + --destructive: oklch(0.43 0.15 25); /* dark red for text/icons */ + --destructive-foreground: oklch(0.99 0 0); + --destructive-bg: oklch(0.98 0.01 20); /* very light red bg */ + --destructive-border: oklch(0.89 0.06 20); /* subtle red border */ + + /* Neutral - uses sidebar brand hue */ + --neutral: oklch(0.36 0.03 272.34); + --neutral-foreground: oklch(0.99 0 0); + --neutral-bg: oklch(0.97 0.008 272.34); + --neutral-border: oklch(0.87 0.02 272.34); + + /* ============= GRANULAR STATUS COLORS ============= */ + /* Three-tier: text (dark readable), bg (almost white), border (subtle accent) */ + + /* Active - uses primary brand hue */ + --status-active: oklch(0.48 0.1 234.4); + --status-active-bg: oklch(0.97 0.02 234.4); + --status-active-border: oklch(0.91 0.05 234.4); + + /* Completed - uses sidebar brand hue */ + --status-completed: oklch(0.2754 0.1199 272.34); + --status-completed-bg: oklch(0.96 0.02 272.34); + --status-completed-border: oklch(0.87 0.07 272.34); + + /* Paid - #0F766E text, #ECFDF5 bg, #A7F3D0 border (teal, closer to brand) */ + --status-paid: oklch(0.48 0.08 175); + --status-paid-bg: oklch(0.98 0.02 165); + --status-paid-border: oklch(0.92 0.08 160); + + /* Success - #166534 text, #F0FDF4 bg, #BBF7D0 border */ + --status-success: oklch(0.42 0.1 145); + --status-success-bg: oklch(0.98 0.02 145); + --status-success-border: oklch(0.93 0.08 150); + + /* Pending - #92400E text, #FFFBEB bg, #FDE68A border */ + --status-pending: oklch(0.45 0.12 55); + --status-pending-bg: oklch(0.99 0.02 90); + --status-pending-border: oklch(0.92 0.12 90); + + /* Suspended - uses sidebar brand hue */ + --status-suspended: oklch(0.36 0.03 272.34); + --status-suspended-bg: oklch(0.97 0.008 272.34); + --status-suspended-border: oklch(0.87 0.02 272.34); + + /* Unpaid - #9A3412 text, #FFF7ED bg, #FED7AA border */ + --status-unpaid: oklch(0.46 0.13 45); + --status-unpaid-bg: oklch(0.98 0.02 70); + --status-unpaid-border: oklch(0.9 0.08 65); + + /* Terminated - #991B1B text, #FEF2F2 bg, #FECACA border */ + --status-terminated: oklch(0.43 0.15 25); + --status-terminated-bg: oklch(0.98 0.01 20); + --status-terminated-border: oklch(0.89 0.06 20); + + /* Overdue - #9F1239 text, #FFF1F2 bg, #FECDD3 border */ + --status-overdue: oklch(0.42 0.16 0); + --status-overdue-bg: oklch(0.98 0.01 0); + --status-overdue-border: oklch(0.89 0.06 0); + + /* ============= SOFT COLOR VARIANTS (for badges/pills) ============= */ + /* These use the -bg colors for backgrounds with text colors for contrast */ + --success-soft: var(--success-bg); + --info-soft: var(--info-bg); + --warning-soft: var(--warning-bg); + --destructive-soft: var(--destructive-bg); + --neutral-soft: var(--neutral-bg); + + /* ============= UI CHROME ============= */ + --border: oklch(0.9 0.005 234.4); + --border-muted: oklch(0.92 0.003 234.4); + --input: oklch(0.88 0.005 234.4); + --ring: oklch(0.72 0.12 234.4); --ring-subtle: color-mix(in oklch, var(--ring) 45%, transparent); - /* Charts */ - --chart-1: oklch(0.68 0.14 220); /* cyan from logo */ - --chart-2: oklch(0.28 0.1 265); /* navy from logo */ - --chart-3: oklch(0.64 0.19 147); - --chart-4: oklch(0.7 0.16 82); - --chart-5: oklch(0.68 0.16 28); + /* ============= CHARTS ============= */ + /* Harmonious palette based on brand and status colors */ + --chart-1: oklch(0.6884 0.1342 234.4); /* Primary brand blue */ + --chart-2: oklch(0.65 0.12 145); /* Success - green */ + --chart-3: oklch(0.75 0.14 85); /* Pending - amber */ + --chart-4: oklch(0.55 0.16 38); /* Overdue - orange-red */ + --chart-5: oklch(0.2754 0.1199 272.34); /* Sidebar brand navy */ - /* Sidebar - Brand navy blue from logo */ - --sidebar: oklch(0.28 0.1 265); /* deep navy blue from logo */ - --sidebar-foreground: oklch(1 0 0); /* pure white text */ - --sidebar-primary: oklch(1 0 0); /* white for emphasis */ - --sidebar-primary-foreground: oklch(0.28 0.1 265); - --sidebar-accent: oklch(0.35 0.09 265); /* lighter navy for hover */ + /* ============= SIDEBAR ============= */ + /* Primary Dark Blue - Brand Anchor (Logo Color) */ + --sidebar: oklch(0.2754 0.1199 272.34); + --sidebar-foreground: oklch(1 0 0); + --sidebar-primary: oklch(0.6884 0.1342 234.4); /* primary brand blue for accent */ + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.36 0.1199 272.34); --sidebar-accent-foreground: oklch(1 0 0); - --sidebar-border: oklch(0.35 0.08 265); /* lighter navy border */ - --sidebar-ring: oklch(0.72 0.12 220); + --sidebar-border: oklch(0.36 0.1 272.34); + --sidebar-ring: oklch(0.72 0.12 234.4); } .dark { - /* Core neutrals (dark) */ - --background: oklch(0.15 0 0); + /* ============= CORE NEUTRALS (DARK) ============= */ + --background: oklch(0.15 0.01 272.34); --foreground: oklch(0.98 0 0); - --card: oklch(0.18 0 0); + --card: oklch(0.18 0.01 272.34); --card-foreground: var(--foreground); - --card-muted: oklch(0.22 0 0); /* subtle lighter gray for nested cards */ - --popover: oklch(0.18 0 0); + --card-muted: oklch(0.22 0.01 272.34); + --popover: oklch(0.18 0.01 272.34); --popover-foreground: var(--foreground); - /* Primary brand - lighter cyan for dark mode */ - --primary: oklch(0.75 0.12 220); + /* ============= BRAND COLORS (DARK MODE) ============= */ + /* Lighter brand blue for dark mode visibility */ + --primary: oklch(0.76 0.12 234.4); --primary-foreground: oklch(0.15 0 0); - --primary-hover: oklch(0.7 0.12 220); - --primary-active: oklch(0.65 0.12 220); + --primary-hover: oklch(0.7 0.12 234.4); + --primary-active: oklch(0.65 0.12 234.4); - /* Subtle surfaces & text */ - --secondary: oklch(0.22 0 0); + /* Subtle surfaces & text - tinted with sidebar hue */ + --secondary: oklch(0.22 0.01 272.34); --secondary-foreground: oklch(0.9 0 0); - --muted: oklch(0.25 0 0); + --muted: oklch(0.25 0.01 272.34); --muted-foreground: oklch(0.74 0 0); /* Accent (tinted with brand) */ - --accent: oklch(0.24 0.04 220); + --accent: oklch(0.24 0.03 234.4); --accent-foreground: oklch(0.92 0 0); - /* Feedback */ - --destructive: oklch(0.7 0.21 27); - --destructive-foreground: oklch(0.15 0 0); - --destructive-soft: oklch(0.25 0.05 27); + /* ============= SEMANTIC STATUS COLORS (DARK) ============= */ + /* Three-tier: text (lighter for dark bg), bg (dark subtle), border (mid accent) */ - --success: oklch(0.76 0.14 150); + /* Success - lighter green for dark mode */ + --success: oklch(0.72 0.1 145); --success-foreground: oklch(0.15 0 0); - --success-soft: oklch(0.24 0.05 150); + --success-bg: oklch(0.24 0.04 145); + --success-border: oklch(0.38 0.08 150); - --warning: oklch(0.86 0.16 90); - --warning-foreground: oklch(0.15 0 0); - --warning-soft: oklch(0.26 0.07 90); - - --info: oklch(0.75 0.12 220); + /* Info - uses primary brand hue */ + --info: oklch(0.72 0.1 234.4); --info-foreground: oklch(0.15 0 0); - --info-soft: oklch(0.24 0.05 220); + --info-bg: oklch(0.24 0.04 234.4); + --info-border: oklch(0.38 0.07 234.4); - /* UI chrome */ - --border: oklch(0.32 0 0); - --border-muted: oklch(0.28 0 0); - --input: oklch(0.35 0 0); /* slightly lighter for inputs */ - --ring: oklch(0.78 0.1 220); + /* Warning - lighter amber for dark mode */ + --warning: oklch(0.78 0.12 55); + --warning-foreground: oklch(0.15 0 0); + --warning-bg: oklch(0.26 0.04 90); + --warning-border: oklch(0.42 0.1 90); + + /* Error - lighter rose for dark mode */ + --error: oklch(0.72 0.14 0); + --error-foreground: oklch(0.15 0 0); + --error-bg: oklch(0.24 0.03 0); + --error-border: oklch(0.38 0.08 0); + + /* Destructive - lighter red for dark mode */ + --destructive: oklch(0.7 0.14 25); + --destructive-foreground: oklch(0.15 0 0); + --destructive-bg: oklch(0.24 0.03 20); + --destructive-border: oklch(0.38 0.08 20); + + /* Neutral - uses sidebar brand hue */ + --neutral: oklch(0.7 0.03 272.34); + --neutral-foreground: oklch(0.15 0 0); + --neutral-bg: oklch(0.24 0.02 272.34); + --neutral-border: oklch(0.38 0.03 272.34); + + /* ============= GRANULAR STATUS COLORS (DARK) ============= */ + /* Active - uses primary brand hue */ + --status-active: oklch(0.72 0.1 234.4); + --status-active-bg: oklch(0.24 0.04 234.4); + --status-active-border: oklch(0.38 0.07 234.4); + + /* Completed - uses sidebar brand hue */ + --status-completed: oklch(0.55 0.1 272.34); + --status-completed-bg: oklch(0.24 0.04 272.34); + --status-completed-border: oklch(0.38 0.07 272.34); + + /* Paid */ + --status-paid: oklch(0.7 0.08 175); + --status-paid-bg: oklch(0.24 0.04 165); + --status-paid-border: oklch(0.4 0.08 160); + + /* Success */ + --status-success: oklch(0.72 0.1 145); + --status-success-bg: oklch(0.24 0.04 145); + --status-success-border: oklch(0.38 0.08 150); + + /* Pending */ + --status-pending: oklch(0.78 0.12 55); + --status-pending-bg: oklch(0.26 0.04 90); + --status-pending-border: oklch(0.42 0.1 90); + + /* Suspended - uses sidebar brand hue */ + --status-suspended: oklch(0.7 0.03 272.34); + --status-suspended-bg: oklch(0.24 0.02 272.34); + --status-suspended-border: oklch(0.38 0.03 272.34); + + /* Unpaid */ + --status-unpaid: oklch(0.76 0.12 45); + --status-unpaid-bg: oklch(0.26 0.04 70); + --status-unpaid-border: oklch(0.42 0.08 65); + + /* Terminated */ + --status-terminated: oklch(0.7 0.14 25); + --status-terminated-bg: oklch(0.24 0.03 20); + --status-terminated-border: oklch(0.38 0.08 20); + + /* Overdue */ + --status-overdue: oklch(0.72 0.14 0); + --status-overdue-bg: oklch(0.24 0.03 0); + --status-overdue-border: oklch(0.38 0.08 0); + + /* ============= SOFT COLOR VARIANTS (for badges/pills) ============= */ + --success-soft: var(--success-bg); + --info-soft: var(--info-bg); + --warning-soft: var(--warning-bg); + --destructive-soft: var(--destructive-bg); + --neutral-soft: var(--neutral-bg); + + /* ============= UI CHROME (DARK) ============= */ + --border: oklch(0.32 0.02 272.34); + --border-muted: oklch(0.28 0.01 272.34); + --input: oklch(0.35 0.02 272.34); + --ring: oklch(0.78 0.11 234.4); --ring-subtle: color-mix(in oklch, var(--ring) 40%, transparent); - /* Charts */ - --chart-1: oklch(0.75 0.12 220); /* cyan from logo */ - --chart-2: oklch(0.45 0.08 265); /* navy from logo */ - --chart-3: oklch(0.7 0.18 147); - --chart-4: oklch(0.74 0.17 82); - --chart-5: oklch(0.72 0.17 28); + /* ============= CHARTS (DARK) ============= */ + --chart-1: oklch(0.76 0.12 234.4); /* Primary brand blue */ + --chart-2: oklch(0.72 0.12 145); /* Success - green */ + --chart-3: oklch(0.82 0.14 85); /* Pending - amber */ + --chart-4: oklch(0.65 0.15 38); /* Overdue - orange-red */ + --chart-5: oklch(0.5 0.1 272.34); /* Sidebar brand navy */ - /* Sidebar - Dark navy from logo */ - --sidebar: oklch(0.2 0.08 265); /* darker navy */ - --sidebar-foreground: oklch(1 0 0); /* pure white */ - --sidebar-primary: oklch(1 0 0); - --sidebar-primary-foreground: oklch(0.2 0.08 265); - --sidebar-accent: oklch(0.28 0.07 265); /* lighter on hover */ + /* ============= SIDEBAR (DARK) ============= */ + --sidebar: oklch(0.22 0.1199 272.34); + --sidebar-foreground: oklch(1 0 0); + --sidebar-primary: oklch(0.76 0.12 234.4); /* lighter primary brand blue */ + --sidebar-primary-foreground: oklch(0.15 0 0); + --sidebar-accent: oklch(0.32 0.1 272.34); --sidebar-accent-foreground: oklch(1 0 0); - --sidebar-border: oklch(0.28 0.06 265); + --sidebar-border: oklch(0.3 0.08 272.34); --sidebar-ring: var(--ring); } -/* Tailwind v4 token map (add the new ones) */ +/* Tailwind v4 token map */ @theme { + /* Core */ --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -160,6 +317,7 @@ --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); + /* Brand */ --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-primary-hover: var(--primary-hover); @@ -174,34 +332,94 @@ --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-destructive-soft: var(--destructive-soft); - + /* Semantic status colors (three-tier: text, bg, border + soft for badges) */ --color-success: var(--success); --color-success-foreground: var(--success-foreground); + --color-success-bg: var(--success-bg); + --color-success-border: var(--success-border); --color-success-soft: var(--success-soft); - --color-warning: var(--warning); - --color-warning-foreground: var(--warning-foreground); - --color-warning-soft: var(--warning-soft); - --color-info: var(--info); --color-info-foreground: var(--info-foreground); + --color-info-bg: var(--info-bg); + --color-info-border: var(--info-border); --color-info-soft: var(--info-soft); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-warning-bg: var(--warning-bg); + --color-warning-border: var(--warning-border); + --color-warning-soft: var(--warning-soft); + + --color-error: var(--error); + --color-error-foreground: var(--error-foreground); + --color-error-bg: var(--error-bg); + --color-error-border: var(--error-border); + + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-destructive-bg: var(--destructive-bg); + --color-destructive-border: var(--destructive-border); + --color-destructive-soft: var(--destructive-soft); + + --color-neutral: var(--neutral); + --color-neutral-foreground: var(--neutral-foreground); + --color-neutral-bg: var(--neutral-bg); + --color-neutral-border: var(--neutral-border); + --color-neutral-soft: var(--neutral-soft); + + /* Granular status colors (three-tier: text, bg, border) */ + --color-status-active: var(--status-active); + --color-status-active-bg: var(--status-active-bg); + --color-status-active-border: var(--status-active-border); + + --color-status-completed: var(--status-completed); + --color-status-completed-bg: var(--status-completed-bg); + --color-status-completed-border: var(--status-completed-border); + + --color-status-paid: var(--status-paid); + --color-status-paid-bg: var(--status-paid-bg); + --color-status-paid-border: var(--status-paid-border); + + --color-status-success: var(--status-success); + --color-status-success-bg: var(--status-success-bg); + --color-status-success-border: var(--status-success-border); + + --color-status-pending: var(--status-pending); + --color-status-pending-bg: var(--status-pending-bg); + --color-status-pending-border: var(--status-pending-border); + + --color-status-suspended: var(--status-suspended); + --color-status-suspended-bg: var(--status-suspended-bg); + --color-status-suspended-border: var(--status-suspended-border); + + --color-status-unpaid: var(--status-unpaid); + --color-status-unpaid-bg: var(--status-unpaid-bg); + --color-status-unpaid-border: var(--status-unpaid-border); + + --color-status-terminated: var(--status-terminated); + --color-status-terminated-bg: var(--status-terminated-bg); + --color-status-terminated-border: var(--status-terminated-border); + + --color-status-overdue: var(--status-overdue); + --color-status-overdue-bg: var(--status-overdue-bg); + --color-status-overdue-border: var(--status-overdue-border); + + /* UI chrome */ --color-border: var(--border); --color-border-muted: var(--border-muted); --color-input: var(--input); --color-ring: var(--ring); --color-ring-subtle: var(--ring-subtle); + /* Charts */ --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); + /* Sidebar */ --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); diff --git a/apps/portal/src/features/dashboard/components/ActivityFeed.tsx b/apps/portal/src/features/dashboard/components/ActivityFeed.tsx new file mode 100644 index 00000000..5eb0b719 --- /dev/null +++ b/apps/portal/src/features/dashboard/components/ActivityFeed.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { + DocumentTextIcon, + CheckCircleIcon, + ServerIcon, + ChatBubbleLeftRightIcon, + ArrowTrendingUpIcon, + ChevronRightIcon, +} from "@heroicons/react/24/outline"; +import type { Activity } from "@customer-portal/domain/dashboard"; +import { formatActivityDate, getActivityNavigationPath } from "../utils/dashboard.utils"; +import { cn } from "@/lib/utils"; + +interface ActivityFeedProps { + activities: Activity[]; + maxItems?: number; + isLoading?: boolean; + className?: string; +} + +const ICON_COMPONENTS: Record< + Activity["type"], + React.ComponentType> +> = { + invoice_created: DocumentTextIcon, + invoice_paid: CheckCircleIcon, + service_activated: ServerIcon, + case_created: ChatBubbleLeftRightIcon, + case_closed: CheckCircleIcon, +}; + +const ICON_STYLES: Record = { + invoice_created: { bg: "bg-info/10", color: "text-info" }, + invoice_paid: { bg: "bg-success/10", color: "text-success" }, + service_activated: { bg: "bg-primary/10", color: "text-primary" }, + case_created: { bg: "bg-warning/10", color: "text-warning" }, + case_closed: { bg: "bg-success/10", color: "text-success" }, +}; + +interface ActivityItemProps { + activity: Activity; + isLast?: boolean; +} + +function ActivityItem({ activity, isLast = false }: ActivityItemProps) { + const router = useRouter(); + const Icon = ICON_COMPONENTS[activity.type] ?? DocumentTextIcon; + const styles = ICON_STYLES[activity.type] ?? { bg: "bg-muted", color: "text-muted-foreground" }; + const timeAgo = formatActivityDate(activity.date); + const navPath = getActivityNavigationPath(activity); + const isClickable = Boolean(navPath); + + const content = ( +
+ {/* Icon with timeline connector */} +
+
+ +
+ {/* Timeline connector */} + {!isLast && ( +
+ )} +
+ + {/* Content */} +
+

+ {activity.title} +

+

{timeAgo}

+
+ + {/* Arrow for clickable items */} + {isClickable && ( + + )} +
+ ); + + if (isClickable) { + return ( + + ); + } + + return
{content}
; +} + +function ActivityItemSkeleton({ isLast = false }: { isLast?: boolean }) { + return ( +
+
+
+ {!isLast && ( +
+ )} +
+
+
+
+
+
+ ); +} + +function EmptyActivity() { + return ( +
+ {/* Decorative elements */} +
+
+
+
+ +
+
+ +
+

No recent activity

+

+ Your account activity will appear here as you use our services. +

+
+
+ ); +} + +/** + * ActivityFeed - Beautiful activity timeline for dashboard + */ +export function ActivityFeed({ + activities, + maxItems = 5, + isLoading = false, + className, +}: ActivityFeedProps) { + if (isLoading) { + return ( +
+
+

Recent Activity

+
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ ); + } + + const visibleActivities = activities.slice(0, maxItems); + + return ( +
+
+

Recent Activity

+
+ + {visibleActivities.length === 0 ? ( + + ) : ( +
+ {visibleActivities.map((activity, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/portal/src/features/dashboard/components/QuickStats.tsx b/apps/portal/src/features/dashboard/components/QuickStats.tsx new file mode 100644 index 00000000..5f6bfe68 --- /dev/null +++ b/apps/portal/src/features/dashboard/components/QuickStats.tsx @@ -0,0 +1,163 @@ +"use client"; + +import Link from "next/link"; +import { + ServerIcon, + ChatBubbleLeftRightIcon, + ClipboardDocumentListIcon, + ArrowRightIcon, +} from "@heroicons/react/24/outline"; +import { cn } from "@/lib/utils"; + +interface QuickStatsProps { + activeSubscriptions: number; + openCases: number; + recentOrders?: number; + isLoading?: boolean; + className?: string; +} + +type StatTone = "primary" | "info" | "warning" | "success"; + +interface StatItemProps { + icon: React.ComponentType>; + label: string; + value: number; + href: string; + tone?: StatTone; + emptyText?: string; +} + +const toneStyles: Record = { + primary: { + iconBg: "bg-primary/10 group-hover:bg-primary/20", + iconColor: "text-primary", + hoverBorder: "hover:border-primary/30", + }, + info: { + iconBg: "bg-info/10 group-hover:bg-info/20", + iconColor: "text-info", + hoverBorder: "hover:border-info/30", + }, + warning: { + iconBg: "bg-warning/10 group-hover:bg-warning/20", + iconColor: "text-warning", + hoverBorder: "hover:border-warning/30", + }, + success: { + iconBg: "bg-success/10 group-hover:bg-success/20", + iconColor: "text-success", + hoverBorder: "hover:border-success/30", + }, +}; + +function StatItem({ icon: Icon, label, value, href, tone = "primary", emptyText }: StatItemProps) { + const styles = toneStyles[tone]; + + return ( + +
+ +
+
+

{label}

+ {value > 0 ? ( +

{value}

+ ) : ( +

{emptyText || "None"}

+ )} +
+ + + ); +} + +function StatItemSkeleton() { + return ( +
+
+
+
+
+
+
+
+ ); +} + +/** + * QuickStats - Beautiful stats display for dashboard + */ +export function QuickStats({ + activeSubscriptions, + openCases, + recentOrders, + isLoading = false, + className, +}: QuickStatsProps) { + if (isLoading) { + return ( +
+
+

Account Overview

+
+
+ + + +
+
+ ); + } + + return ( +
+
+

Account Overview

+
+
+ + 0 ? "warning" : "info"} + emptyText="No open cases" + /> + {recentOrders !== undefined && ( + + )} +
+
+ ); +} diff --git a/apps/portal/src/features/dashboard/components/TaskCard.tsx b/apps/portal/src/features/dashboard/components/TaskCard.tsx new file mode 100644 index 00000000..ba446914 --- /dev/null +++ b/apps/portal/src/features/dashboard/components/TaskCard.tsx @@ -0,0 +1,175 @@ +"use client"; + +import Link from "next/link"; +import { ChevronRightIcon } from "@heroicons/react/24/outline"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/atoms/button"; + +export type TaskTone = "critical" | "warning" | "info" | "neutral"; + +export interface TaskCardProps { + /** Unique identifier for the task */ + id: string; + /** Icon component to display */ + icon: React.ComponentType>; + /** Task title */ + title: string; + /** Task description */ + description: string; + /** Action button label */ + actionLabel: string; + /** Link destination for the card click (navigates to detail page) */ + detailHref?: string; + /** Click handler for the action button */ + onAction?: () => void; + /** Visual tone based on priority */ + tone?: TaskTone; + /** Loading state for the action button */ + isLoading?: boolean; + /** Loading text for the action button */ + loadingText?: string; + /** Additional className */ + className?: string; +} + +const toneStyles: Record< + TaskTone, + { + card: string; + border: string; + iconBg: string; + iconColor: string; + buttonVariant: "default" | "outline"; + } +> = { + critical: { + card: "bg-error/5 hover:bg-error/10", + border: "border-l-error", + iconBg: "bg-error/15", + iconColor: "text-error", + buttonVariant: "default", + }, + warning: { + card: "bg-warning/5 hover:bg-warning/10", + border: "border-l-warning", + iconBg: "bg-warning/15", + iconColor: "text-warning", + buttonVariant: "outline", + }, + info: { + card: "bg-info/5 hover:bg-info/10", + border: "border-l-info", + iconBg: "bg-info/15", + iconColor: "text-info", + buttonVariant: "outline", + }, + neutral: { + card: "bg-primary/5 hover:bg-primary/10", + border: "border-l-primary", + iconBg: "bg-primary/15", + iconColor: "text-primary", + buttonVariant: "outline", + }, +}; + +export function TaskCard({ + id, + icon: Icon, + title, + description, + actionLabel, + detailHref, + onAction, + tone = "neutral", + isLoading = false, + loadingText, + className, +}: TaskCardProps) { + const styles = toneStyles[tone]; + + const cardContent = ( + <> + {/* Icon - Larger for prominence */} + + + {/* Content - Larger text */} +
+

{title}

+

{description}

+
+ + ); + + const actionButton = ( + + ); + + const cardClasses = cn( + "group flex items-center gap-5 p-5 rounded-2xl border border-border/60", + "border-l-4", + styles.border, + styles.card, + "transition-all duration-[var(--cp-duration-normal)]", + "shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-3)]", + detailHref && "cursor-pointer", + className + ); + + if (detailHref) { + return ( + +
+ {cardContent} + {/* Action button */} +
{actionButton}
+
+ + ); + } + + return ( +
+ {cardContent} + {/* Action button */} +
{actionButton}
+
+ ); +} + +/** + * Loading skeleton for TaskCard + */ +export function TaskCardSkeleton() { + return ( +
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/portal/src/features/dashboard/components/TaskList.tsx b/apps/portal/src/features/dashboard/components/TaskList.tsx new file mode 100644 index 00000000..36edbc6d --- /dev/null +++ b/apps/portal/src/features/dashboard/components/TaskList.tsx @@ -0,0 +1,172 @@ +"use client"; + +import Link from "next/link"; +import { + CheckCircleIcon, + Squares2X2Icon, + DocumentTextIcon, + ChatBubbleLeftRightIcon, + ArrowRightIcon, +} from "@heroicons/react/24/outline"; +import { TaskCard, TaskCardSkeleton } from "./TaskCard"; +import type { DashboardTask } from "../hooks/useDashboardTasks"; +import { + useCreateInvoiceSsoLink, + useCreatePaymentMethodsSsoLink, +} from "@/features/billing/hooks/useBilling"; +import { useState } from "react"; +import { log } from "@/lib/logger"; + +interface TaskListProps { + tasks: DashboardTask[]; + isLoading?: boolean; + maxTasks?: number; +} + +/** + * Beautiful empty state when all tasks are completed + */ +function AllCaughtUp() { + return ( +
+ {/* Decorative background elements */} +
+
+
+
+ +
+ {/* Animated checkmark */} +
+
+
+ +
+
+ +

You're all caught up!

+

+ No outstanding tasks right now. Take a moment to explore or manage your account. +

+ + {/* Quick action cards */} +
+ +
+ +
+ Browse Catalog + + + + +
+ +
+ View Invoices + + + + +
+ +
+ Get Support + + +
+
+
+ ); +} + +/** + * Loading state for task list + */ +function TaskListLoading({ count = 2 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +} + +/** + * TaskList component that renders prioritized tasks + */ +export function TaskList({ tasks, isLoading = false, maxTasks = 4 }: TaskListProps) { + const [loadingTaskId, setLoadingTaskId] = useState(null); + const createInvoiceSsoLink = useCreateInvoiceSsoLink(); + const createPaymentMethodsSsoLink = useCreatePaymentMethodsSsoLink(); + + if (isLoading) { + return ; + } + + if (tasks.length === 0) { + return ; + } + + const visibleTasks = tasks.slice(0, maxTasks); + + const handleTaskAction = async (task: DashboardTask) => { + // If task doesn't require SSO action, just let the link handle navigation + if (!task.requiresSsoAction) { + return; + } + + setLoadingTaskId(task.id); + + try { + if (task.type === "invoice" && task.metadata?.invoiceId) { + const ssoLink = await createInvoiceSsoLink.mutateAsync({ + invoiceId: task.metadata.invoiceId, + target: "pay", + }); + window.open(ssoLink.url, "_blank", "noopener,noreferrer"); + } else if (task.type === "payment_method") { + const ssoLink = await createPaymentMethodsSsoLink.mutateAsync(); + window.open(ssoLink.url, "_blank", "noopener,noreferrer"); + } + } catch (error) { + log.error("Failed to handle task action", { + taskId: task.id, + taskType: task.type, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + setLoadingTaskId(null); + } + }; + + return ( +
+ {visibleTasks.map(task => ( + void handleTaskAction(task) : undefined} + tone={task.tone} + isLoading={loadingTaskId === task.id} + loadingText="Opening..." + /> + ))} +
+ ); +} diff --git a/apps/portal/src/features/dashboard/components/index.ts b/apps/portal/src/features/dashboard/components/index.ts index 9c9111da..3ab8c65e 100644 --- a/apps/portal/src/features/dashboard/components/index.ts +++ b/apps/portal/src/features/dashboard/components/index.ts @@ -3,3 +3,7 @@ export * from "./QuickAction"; export * from "./DashboardActivityItem"; export * from "./AccountStatusCard"; export * from "./ActivityTimeline"; +export * from "./TaskCard"; +export * from "./TaskList"; +export * from "./QuickStats"; +export * from "./ActivityFeed"; diff --git a/apps/portal/src/features/dashboard/hooks/index.ts b/apps/portal/src/features/dashboard/hooks/index.ts index 2b8fbac9..1c598b0c 100644 --- a/apps/portal/src/features/dashboard/hooks/index.ts +++ b/apps/portal/src/features/dashboard/hooks/index.ts @@ -1 +1,2 @@ export * from "./useDashboardSummary"; +export * from "./useDashboardTasks"; diff --git a/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts b/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts new file mode 100644 index 00000000..5569eb61 --- /dev/null +++ b/apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts @@ -0,0 +1,207 @@ +"use client"; + +import { useMemo } from "react"; +import { formatDistanceToNow, format } from "date-fns"; +import { + ExclamationCircleIcon, + CreditCardIcon, + ClockIcon, + SparklesIcon, +} from "@heroicons/react/24/outline"; +import type { DashboardSummary } from "@customer-portal/domain/dashboard"; +import type { PaymentMethodList } from "@customer-portal/domain/payments"; +import type { OrderSummary } from "@customer-portal/domain/orders"; +import type { TaskTone } from "../components/TaskCard"; +import { useDashboardSummary } from "./useDashboardSummary"; +import { usePaymentMethods } from "@/features/billing/hooks/useBilling"; +import { useOrdersList } from "@/features/orders/hooks/useOrdersList"; +import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency"; + +/** + * Task type for dashboard actions + */ +export type DashboardTaskType = "invoice" | "payment_method" | "order" | "onboarding"; + +/** + * Dashboard task structure + */ +export interface DashboardTask { + id: string; + priority: 1 | 2 | 3 | 4; + type: DashboardTaskType; + title: string; + description: string; + /** Label for the action button */ + actionLabel: string; + /** Link for card click (navigates to detail page) */ + detailHref?: string; + /** Whether the action opens an external SSO link */ + requiresSsoAction?: boolean; + tone: TaskTone; + icon: React.ComponentType>; + metadata?: { + invoiceId?: number; + orderId?: string; + amount?: number; + currency?: string; + }; +} + +interface ComputeTasksParams { + summary: DashboardSummary | undefined; + paymentMethods: PaymentMethodList | undefined; + orders: OrderSummary[] | undefined; + formatCurrency: (amount: number, options?: { currency?: string }) => string; +} + +/** + * Compute dashboard tasks based on user's account state + */ +function computeTasks({ + summary, + paymentMethods, + orders, + formatCurrency, +}: ComputeTasksParams): DashboardTask[] { + const tasks: DashboardTask[] = []; + + if (!summary) return tasks; + + // Priority 1: Unpaid invoices + if (summary.nextInvoice) { + const dueDate = new Date(summary.nextInvoice.dueDate); + const isOverdue = dueDate < new Date(); + const dueText = isOverdue + ? `Overdue since ${format(dueDate, "MMM d")}` + : `Due ${formatDistanceToNow(dueDate, { addSuffix: true })}`; + + tasks.push({ + id: `invoice-${summary.nextInvoice.id}`, + priority: 1, + type: "invoice", + title: isOverdue ? "Pay overdue invoice" : "Pay upcoming invoice", + description: `Invoice #${summary.nextInvoice.id} · ${formatCurrency(summary.nextInvoice.amount, { currency: summary.nextInvoice.currency })} · ${dueText}`, + actionLabel: "Pay now", + detailHref: `/billing/invoices/${summary.nextInvoice.id}`, + requiresSsoAction: true, + tone: "critical", + icon: ExclamationCircleIcon, + metadata: { + invoiceId: summary.nextInvoice.id, + amount: summary.nextInvoice.amount, + currency: summary.nextInvoice.currency, + }, + }); + } + + // Priority 2: No payment method + if (paymentMethods && paymentMethods.totalCount === 0) { + tasks.push({ + id: "add-payment-method", + priority: 2, + type: "payment_method", + title: "Add a payment method", + description: "Required to place orders and process invoices", + actionLabel: "Add method", + detailHref: "/billing/payments", + requiresSsoAction: true, + tone: "warning", + icon: CreditCardIcon, + }); + } + + // Priority 3: Pending orders (Draft, Pending, or Activated but not yet complete) + if (orders && orders.length > 0) { + const pendingOrders = orders.filter( + o => + o.status === "Draft" || + o.status === "Pending" || + (o.status === "Activated" && o.activationStatus !== "Completed") + ); + + if (pendingOrders.length > 0) { + const order = pendingOrders[0]; + const statusText = + order.status === "Pending" + ? "awaiting review" + : order.status === "Draft" + ? "in draft" + : "being activated"; + + tasks.push({ + id: `order-${order.id}`, + priority: 3, + type: "order", + title: "Order in progress", + description: `${order.orderType || "Your"} order is ${statusText}`, + actionLabel: "View details", + detailHref: `/orders/${order.id}`, + tone: "info", + icon: ClockIcon, + metadata: { orderId: order.id }, + }); + } + } + + // Priority 4: No subscriptions (onboarding) - only show if no other tasks + if (summary.stats.activeSubscriptions === 0 && tasks.length === 0) { + tasks.push({ + id: "start-subscription", + priority: 4, + type: "onboarding", + title: "Start your first service", + description: "Browse our catalog and subscribe to internet, SIM, or VPN", + actionLabel: "Browse catalog", + detailHref: "/catalog", + tone: "neutral", + icon: SparklesIcon, + }); + } + + return tasks.sort((a, b) => a.priority - b.priority); +} + +export interface UseDashboardTasksResult { + tasks: DashboardTask[]; + isLoading: boolean; + hasError: boolean; + taskCount: number; +} + +/** + * Hook to compute and return prioritized dashboard tasks + */ +export function useDashboardTasks(): UseDashboardTasksResult { + const { formatCurrency } = useFormatCurrency(); + + const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary(); + + const { + data: paymentMethods, + isLoading: paymentMethodsLoading, + error: paymentMethodsError, + } = usePaymentMethods(); + + const { data: orders, isLoading: ordersLoading, error: ordersError } = useOrdersList(); + + const isLoading = summaryLoading || paymentMethodsLoading || ordersLoading; + const hasError = Boolean(summaryError || paymentMethodsError || ordersError); + + const tasks = useMemo( + () => + computeTasks({ + summary, + paymentMethods, + orders, + formatCurrency, + }), + [summary, paymentMethods, orders, formatCurrency] + ); + + return { + tasks, + isLoading, + hasError, + taskCount: tasks.length, + }; +} diff --git a/apps/portal/src/features/dashboard/views/DashboardView.tsx b/apps/portal/src/features/dashboard/views/DashboardView.tsx index 287c3266..bf8eb822 100644 --- a/apps/portal/src/features/dashboard/views/DashboardView.tsx +++ b/apps/portal/src/features/dashboard/views/DashboardView.tsx @@ -1,83 +1,49 @@ "use client"; -import { useState, useEffect } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import type { Activity, DashboardSummary } from "@customer-portal/domain/dashboard"; -import { ChevronRightIcon } from "@heroicons/react/24/outline"; -import { - CreditCardIcon as CreditCardIconSolid, - ServerIcon as ServerIconSolid, - ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid, - ClipboardDocumentListIcon as ClipboardDocumentListIconSolid, - Squares2X2Icon as Squares2X2IconSolid, -} from "@heroicons/react/24/solid"; -import { format, formatDistanceToNow } from "date-fns"; +import { useEffect } from "react"; +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { useAuthStore } from "@/features/auth/services/auth.store"; -import { useDashboardSummary } from "@/features/dashboard/hooks"; -import { StatCard, ActivityTimeline } from "@/features/dashboard/components"; -import { LoadingStats, LoadingTable } from "@/components/atoms"; +import { useDashboardSummary, useDashboardTasks } from "@/features/dashboard/hooks"; +import { TaskList, QuickStats, ActivityFeed } from "@/features/dashboard/components"; import { ErrorState } from "@/components/atoms/error-state"; import { PageLayout } from "@/components/templates"; -import { Button } from "@/components/atoms/button"; -import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency"; -import { log } from "@/lib/logger"; -import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling"; +import { cn } from "@/lib/utils"; export function DashboardView() { - const { formatCurrency } = useFormatCurrency(); - const router = useRouter(); const { user, isAuthenticated, loading: authLoading, clearLoading } = useAuthStore(); // Clear auth loading state when dashboard loads (after successful login) useEffect(() => { clearLoading(); }, [clearLoading]); + const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary(); - const upcomingInvoice = summary?.nextInvoice ?? null; - const createSsoLinkMutation = useCreateInvoiceSsoLink(); + const { tasks, isLoading: tasksLoading, taskCount } = useDashboardTasks(); - const [paymentLoading, setPaymentLoading] = useState(false); - const [paymentError, setPaymentError] = useState(null); + // Combined loading state + const isLoading = authLoading || summaryLoading || !isAuthenticated; - // Handle Pay Now functionality - const handlePayNow = (invoiceId: number) => { - setPaymentLoading(true); - setPaymentError(null); - - void (async () => { - try { - const ssoLink = await createSsoLinkMutation.mutateAsync({ invoiceId, target: "pay" }); - window.open(ssoLink.url, "_blank", "noopener,noreferrer"); - } catch (error) { - log.error("Failed to create payment link", { - invoiceId, - error: error instanceof Error ? error.message : String(error), - }); - setPaymentError(error instanceof Error ? error.message : "Failed to open payment page"); - } finally { - setPaymentLoading(false); - } - })(); - }; - - // Handle activity item clicks - const handleActivityClick = (activity: Activity) => { - if (activity.type === "invoice_created" || activity.type === "invoice_paid") { - if (activity.relatedId) { - router.push(`/billing/invoices/${activity.relatedId}`); - } - } - }; - - if (authLoading || summaryLoading || !isAuthenticated) { + if (isLoading) { return ( -
- - -

Loading dashboard...

+
+ {/* Greeting skeleton */} +
+
+
+
+
+ {/* Tasks skeleton */} +
+
+
+
+ {/* Bottom section skeleton */} +
+
+
+
); @@ -101,182 +67,59 @@ export function DashboardView() { ); } + // Get user's display name + const displayName = user?.firstname || user?.email?.split("@")[0] || "there"; + + // Determine urgency level for task badge + const hasUrgentTask = tasks.some(t => t.tone === "critical"); + return ( - } - > - {/* Greeting */} -
+ + {/* Greeting Section */} +

Welcome back

-

- {user?.firstname || user?.email?.split("@")[0] || "User"} -

-
+

{displayName}

- {/* Overview Stats Card */} -
-
-

Overview

-

Quick snapshot of your account

-
- -
- - 0 ? "warning" : "neutral"} - href="/billing/invoices" - /> - - 0 ? "info" : "neutral"} - href="/support/cases" - /> - -
-
- - {/* Billing Card - only shown when there's an upcoming invoice */} - {upcomingInvoice && ( -
-
-
-

Upcoming Payment

-

Invoice due soon

-
- 0 ? ( +
+ - View all invoices - + {hasUrgentTask && } + {taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`} +
- -
-
-
- - Due soon - - Invoice #{upcomingInvoice.id} -
-
- {formatCurrency(upcomingInvoice.amount, { - currency: upcomingInvoice.currency, - })} -
-

- Due{" "} - {formatDistanceToNow(new Date(upcomingInvoice.dueDate), { - addSuffix: true, - })}{" "} - · {format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")} -

-
-
- - View details - - -
-
-
- )} - - {/* Payment Error Display */} - {paymentError && ( - setPaymentError(null)} - retryLabel="Dismiss" - /> - )} - - {/* Recent Activity Card */} -
-
-
-

Recent Activity

-

Your latest account updates

-
-
- - + ) : ( +

Everything is up to date

+ )}
+ + {/* Tasks Section - Main focus area */} +
+

+ Your Tasks +

+ +
+ + {/* Bottom Section: Quick Stats + Recent Activity */} +
+ + +
); } - -// Helpers -function TasksChip({ - summaryLoading, - summary, -}: { - summaryLoading: boolean; - summary: DashboardSummary | undefined; -}) { - const router = useRouter(); - if (summaryLoading) return null; - const tasks: Array<{ label: string; href: string }> = []; - if (summary?.nextInvoice) tasks.push({ label: "Pay upcoming invoice", href: "#attention" }); - const count = tasks.length; - if (count === 0) return null; - return ( - - ); -} diff --git a/apps/portal/src/styles/tokens.css b/apps/portal/src/styles/tokens.css index 496da54b..f8de21e9 100644 --- a/apps/portal/src/styles/tokens.css +++ b/apps/portal/src/styles/tokens.css @@ -93,46 +93,118 @@ --cp-transition-slow: var(--cp-duration-slow); /* ============= COLOR (LIGHT) ============= */ - /* Core neutrals (light) - clean white main area */ + /* Core neutrals */ --cp-bg: oklch(1 0 0); /* pure white for main content area */ --cp-fg: oklch(0.16 0 0); --cp-surface: oklch(1 0 0); /* pure white for cards */ - --cp-muted: oklch(0.97 0.003 250); /* subtle cool gray for table headers */ - --cp-border: oklch(0.9 0 0); - --cp-border-muted: oklch(0.92 0 0); + --cp-muted: oklch(0.97 0.008 234.4); /* subtle brand-tinted gray */ + --cp-border: oklch(0.9 0.005 234.4); + --cp-border-muted: oklch(0.92 0.003 234.4); - /* Brand & focus - matches logo cyan blue */ - --cp-primary: oklch(0.68 0.14 220); /* bright cyan blue from logo */ + /* ============= BRAND COLORS (from logo) ============= */ + /* Primary Light Blue - Brand Accent */ + --cp-primary: oklch(0.6884 0.1342 234.4); --cp-on-primary: oklch(0.99 0 0); - --cp-primary-hover: oklch(0.63 0.14 220); - --cp-primary-active: oklch(0.58 0.14 220); - --cp-ring: oklch(0.72 0.12 220); + --cp-primary-hover: oklch(0.62 0.1342 234.4); + --cp-primary-active: oklch(0.56 0.1342 234.4); + --cp-ring: oklch(0.72 0.12 234.4); - /* Semantic */ - --cp-success: oklch(0.67 0.14 150); + /* ============= SEMANTIC STATUS COLORS ============= */ + /* Three-tier system: text (dark readable), bg (almost white), border (subtle accent) */ + + /* Success - #166534 text, #F0FDF4 bg, #BBF7D0 border */ + --cp-success: oklch(0.42 0.1 145); --cp-on-success: oklch(0.99 0 0); - --cp-success-soft: oklch(0.95 0.05 150); + --cp-success-bg: oklch(0.98 0.02 145); + --cp-success-border: oklch(0.93 0.08 150); - --cp-warning: oklch(0.78 0.16 90); - --cp-on-warning: oklch(0.16 0 0); - --cp-warning-soft: oklch(0.96 0.07 90); - - --cp-error: oklch(0.62 0.21 27); - --cp-on-error: oklch(0.99 0 0); - --cp-error-soft: oklch(0.96 0.05 27); - - --cp-info: oklch(0.68 0.14 220); /* matches primary */ + /* Info - uses primary brand hue */ + --cp-info: oklch(0.48 0.1 234.4); --cp-on-info: oklch(0.99 0 0); - --cp-info-soft: oklch(0.95 0.06 220); + --cp-info-bg: oklch(0.97 0.02 234.4); + --cp-info-border: oklch(0.91 0.05 234.4); - /* Sidebar - Brand navy blue from logo */ - --cp-sidebar-bg: oklch(0.28 0.1 265); /* deep navy blue from logo */ - --cp-sidebar-border: oklch(0.35 0.08 265); /* lighter navy for borders */ - --cp-sidebar-text: oklch(1 0 0); /* pure white text */ - --cp-sidebar-text-hover: oklch(1 0 0); /* pure white on hover */ - --cp-sidebar-hover-bg: oklch(0.35 0.09 265); /* lighter navy on hover */ - --cp-sidebar-active-bg: oklch(1 0 0 / 0.18); /* white overlay for active */ - --cp-sidebar-active-text: oklch(1 0 0); /* white for active text */ + /* Warning - #92400E text, #FFFBEB bg, #FDE68A border */ + --cp-warning: oklch(0.45 0.12 55); + --cp-on-warning: oklch(0.99 0 0); + --cp-warning-bg: oklch(0.99 0.02 90); + --cp-warning-border: oklch(0.92 0.12 90); + + /* Error - #9F1239 text, #FFF1F2 bg, #FECDD3 border */ + --cp-error: oklch(0.42 0.16 0); + --cp-on-error: oklch(0.99 0 0); + --cp-error-bg: oklch(0.98 0.01 0); + --cp-error-border: oklch(0.89 0.06 0); + + /* Destructive - #991B1B text, #FEF2F2 bg, #FECACA border */ + --cp-destructive: oklch(0.43 0.15 25); + --cp-on-destructive: oklch(0.99 0 0); + --cp-destructive-bg: oklch(0.98 0.01 20); + --cp-destructive-border: oklch(0.89 0.06 20); + + /* Neutral - uses sidebar brand hue */ + --cp-neutral: oklch(0.36 0.03 272.34); + --cp-on-neutral: oklch(0.99 0 0); + --cp-neutral-bg: oklch(0.97 0.008 272.34); + --cp-neutral-border: oklch(0.87 0.02 272.34); + + /* ============= GRANULAR STATUS COLORS ============= */ + /* Three-tier: text (dark readable), bg (almost white), border (subtle accent) */ + + /* Active - uses primary brand hue */ + --cp-status-active: oklch(0.48 0.1 234.4); + --cp-status-active-bg: oklch(0.97 0.02 234.4); + --cp-status-active-border: oklch(0.91 0.05 234.4); + + /* Completed - uses sidebar brand hue */ + --cp-status-completed: oklch(0.2754 0.1199 272.34); + --cp-status-completed-bg: oklch(0.96 0.02 272.34); + --cp-status-completed-border: oklch(0.87 0.07 272.34); + + /* Paid - #0F766E text, #ECFDF5 bg, #A7F3D0 border (teal, closer to brand) */ + --cp-status-paid: oklch(0.48 0.08 175); + --cp-status-paid-bg: oklch(0.98 0.02 165); + --cp-status-paid-border: oklch(0.92 0.08 160); + + /* Success - #166534 text, #F0FDF4 bg, #BBF7D0 border */ + --cp-status-success: oklch(0.42 0.1 145); + --cp-status-success-bg: oklch(0.98 0.02 145); + --cp-status-success-border: oklch(0.93 0.08 150); + + /* Pending - #92400E text, #FFFBEB bg, #FDE68A border */ + --cp-status-pending: oklch(0.45 0.12 55); + --cp-status-pending-bg: oklch(0.99 0.02 90); + --cp-status-pending-border: oklch(0.92 0.12 90); + + /* Suspended - uses sidebar brand hue */ + --cp-status-suspended: oklch(0.36 0.03 272.34); + --cp-status-suspended-bg: oklch(0.97 0.008 272.34); + --cp-status-suspended-border: oklch(0.87 0.02 272.34); + + /* Unpaid - #9A3412 text, #FFF7ED bg, #FED7AA border */ + --cp-status-unpaid: oklch(0.46 0.13 45); + --cp-status-unpaid-bg: oklch(0.98 0.02 70); + --cp-status-unpaid-border: oklch(0.9 0.08 65); + + /* Terminated - #991B1B text, #FEF2F2 bg, #FECACA border */ + --cp-status-terminated: oklch(0.43 0.15 25); + --cp-status-terminated-bg: oklch(0.98 0.01 20); + --cp-status-terminated-border: oklch(0.89 0.06 20); + + /* Overdue - #9F1239 text, #FFF1F2 bg, #FECDD3 border */ + --cp-status-overdue: oklch(0.42 0.16 0); + --cp-status-overdue-bg: oklch(0.98 0.01 0); + --cp-status-overdue-border: oklch(0.89 0.06 0); + + /* ============= SIDEBAR ============= */ + /* Primary Dark Blue - Brand Anchor (Logo Color) */ + --cp-sidebar-bg: oklch(0.2754 0.1199 272.34); + --cp-sidebar-border: oklch(0.36 0.1 272.34); + --cp-sidebar-text: oklch(1 0 0); + --cp-sidebar-text-hover: oklch(1 0 0); + --cp-sidebar-hover-bg: oklch(0.36 0.1199 272.34); + --cp-sidebar-active-bg: oklch(0.6884 0.1342 234.4 / 0.25); /* primary brand blue tint */ + --cp-sidebar-active-text: oklch(1 0 0); /* Header */ --cp-header-bg: oklch(1 0 0 / 0.95); /* slightly more opaque white */ @@ -165,42 +237,95 @@ /* ============= DARK MODE OVERRIDES ============= */ .dark { - --cp-bg: oklch(0.15 0 0); + --cp-bg: oklch(0.15 0.01 272.34); --cp-fg: oklch(0.98 0 0); - --cp-surface: oklch(0.18 0 0); - --cp-muted: oklch(0.22 0 0); - --cp-border: oklch(0.32 0 0); - --cp-border-muted: oklch(0.28 0 0); + --cp-surface: oklch(0.18 0.01 272.34); + --cp-muted: oklch(0.22 0.01 272.34); + --cp-border: oklch(0.32 0.02 272.34); + --cp-border-muted: oklch(0.28 0.01 272.34); - --cp-primary: oklch(0.75 0.12 220); /* lighter cyan for dark mode */ + /* Brand - lighter for dark mode visibility */ + --cp-primary: oklch(0.76 0.12 234.4); --cp-on-primary: oklch(0.15 0 0); - --cp-primary-hover: oklch(0.7 0.12 220); - --cp-primary-active: oklch(0.65 0.12 220); - --cp-ring: oklch(0.78 0.1 220); + --cp-primary-hover: oklch(0.7 0.12 234.4); + --cp-primary-active: oklch(0.65 0.12 234.4); + --cp-ring: oklch(0.78 0.11 234.4); - --cp-success: oklch(0.76 0.14 150); + /* Semantic colors - dark mode (three-tier) */ + --cp-success: oklch(0.72 0.1 145); --cp-on-success: oklch(0.15 0 0); - --cp-success-soft: oklch(0.24 0.05 150); + --cp-success-bg: oklch(0.24 0.04 145); + --cp-success-border: oklch(0.38 0.08 150); - --cp-warning: oklch(0.86 0.16 90); - --cp-on-warning: oklch(0.15 0 0); - --cp-warning-soft: oklch(0.26 0.07 90); - - --cp-error: oklch(0.7 0.21 27); - --cp-on-error: oklch(0.15 0 0); - --cp-error-soft: oklch(0.25 0.05 27); - - --cp-info: oklch(0.75 0.12 220); + --cp-info: oklch(0.72 0.1 234.4); --cp-on-info: oklch(0.15 0 0); - --cp-info-soft: oklch(0.24 0.05 220); + --cp-info-bg: oklch(0.24 0.04 234.4); + --cp-info-border: oklch(0.38 0.07 234.4); + + --cp-warning: oklch(0.78 0.12 55); + --cp-on-warning: oklch(0.15 0 0); + --cp-warning-bg: oklch(0.26 0.04 90); + --cp-warning-border: oklch(0.42 0.1 90); + + --cp-error: oklch(0.72 0.14 0); + --cp-on-error: oklch(0.15 0 0); + --cp-error-bg: oklch(0.24 0.03 0); + --cp-error-border: oklch(0.38 0.08 0); + + --cp-destructive: oklch(0.7 0.14 25); + --cp-on-destructive: oklch(0.15 0 0); + --cp-destructive-bg: oklch(0.24 0.03 20); + --cp-destructive-border: oklch(0.38 0.08 20); + + --cp-neutral: oklch(0.7 0.03 272.34); + --cp-on-neutral: oklch(0.15 0 0); + --cp-neutral-bg: oklch(0.24 0.02 272.34); + --cp-neutral-border: oklch(0.38 0.03 272.34); + + /* Granular status colors - dark mode (three-tier) */ + --cp-status-active: oklch(0.72 0.1 234.4); + --cp-status-active-bg: oklch(0.24 0.04 234.4); + --cp-status-active-border: oklch(0.38 0.07 234.4); + + --cp-status-completed: oklch(0.55 0.1 272.34); + --cp-status-completed-bg: oklch(0.24 0.04 272.34); + --cp-status-completed-border: oklch(0.38 0.07 272.34); + + --cp-status-paid: oklch(0.7 0.08 175); + --cp-status-paid-bg: oklch(0.24 0.04 165); + --cp-status-paid-border: oklch(0.4 0.08 160); + + --cp-status-success: oklch(0.72 0.1 145); + --cp-status-success-bg: oklch(0.24 0.04 145); + --cp-status-success-border: oklch(0.38 0.08 150); + + --cp-status-pending: oklch(0.78 0.12 55); + --cp-status-pending-bg: oklch(0.26 0.04 90); + --cp-status-pending-border: oklch(0.42 0.1 90); + + --cp-status-suspended: oklch(0.7 0.03 272.34); + --cp-status-suspended-bg: oklch(0.24 0.02 272.34); + --cp-status-suspended-border: oklch(0.38 0.03 272.34); + + --cp-status-unpaid: oklch(0.76 0.12 45); + --cp-status-unpaid-bg: oklch(0.26 0.04 70); + --cp-status-unpaid-border: oklch(0.42 0.08 65); + + --cp-status-terminated: oklch(0.7 0.14 25); + --cp-status-terminated-bg: oklch(0.24 0.03 20); + --cp-status-terminated-border: oklch(0.38 0.08 20); + + --cp-status-overdue: oklch(0.72 0.14 0); + --cp-status-overdue-bg: oklch(0.24 0.03 0); + --cp-status-overdue-border: oklch(0.38 0.08 0); /* Sidebar - Dark navy from logo */ - --cp-sidebar-bg: oklch(0.2 0.08 265); /* darker navy */ - --cp-sidebar-border: oklch(0.28 0.06 265); - --cp-sidebar-hover-bg: oklch(0.28 0.07 265); - --cp-sidebar-text: oklch(1 0 0); /* pure white */ + --cp-sidebar-bg: oklch(0.22 0.1199 272.34); + --cp-sidebar-border: oklch(0.3 0.08 272.34); + --cp-sidebar-hover-bg: oklch(0.32 0.1 272.34); + --cp-sidebar-text: oklch(1 0 0); --cp-sidebar-text-hover: oklch(1 0 0); - --cp-sidebar-active-bg: oklch(1 0 0 / 0.18); + --cp-sidebar-active-bg: oklch(0.6884 0.1342 234.4 / 0.25); /* primary brand blue tint */ --cp-sidebar-active-text: oklch(1 0 0); --cp-header-bg: oklch(0.18 0 0 / 0.95);