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.
This commit is contained in:
parent
9d6c7dcde0
commit
1f7f77775b
2
apps/portal/next-env.d.ts
vendored
2
apps/portal/next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
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.
|
||||
|
||||
@ -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);
|
||||
|
||||
195
apps/portal/src/features/dashboard/components/ActivityFeed.tsx
Normal file
195
apps/portal/src/features/dashboard/components/ActivityFeed.tsx
Normal file
@ -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<React.SVGProps<SVGSVGElement>>
|
||||
> = {
|
||||
invoice_created: DocumentTextIcon,
|
||||
invoice_paid: CheckCircleIcon,
|
||||
service_activated: ServerIcon,
|
||||
case_created: ChatBubbleLeftRightIcon,
|
||||
case_closed: CheckCircleIcon,
|
||||
};
|
||||
|
||||
const ICON_STYLES: Record<Activity["type"], { bg: string; color: string }> = {
|
||||
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 = (
|
||||
<div className="flex items-start gap-3 py-3">
|
||||
{/* Icon with timeline connector */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center",
|
||||
styles.bg,
|
||||
isClickable && "group-hover:scale-105 transition-transform"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-4 w-4", styles.color)} />
|
||||
</div>
|
||||
{/* Timeline connector */}
|
||||
{!isLast && (
|
||||
<div className="absolute top-10 left-1/2 -translate-x-1/2 w-px h-[calc(100%-0.5rem)] bg-border/60" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium text-foreground leading-snug",
|
||||
isClickable && "group-hover:text-primary transition-colors"
|
||||
)}
|
||||
>
|
||||
{activity.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{timeAgo}</p>
|
||||
</div>
|
||||
|
||||
{/* Arrow for clickable items */}
|
||||
{isClickable && (
|
||||
<ChevronRightIcon className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary group-hover:translate-x-0.5 transition-all flex-shrink-0 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isClickable) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(navPath!)}
|
||||
className="group w-full text-left rounded-lg hover:bg-muted/50 transition-colors px-2 -mx-2"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="px-2 -mx-2">{content}</div>;
|
||||
}
|
||||
|
||||
function ActivityItemSkeleton({ isLast = false }: { isLast?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-3 px-2 -mx-2 animate-pulse">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted" />
|
||||
{!isLast && (
|
||||
<div className="absolute top-10 left-1/2 -translate-x-1/2 w-px h-[calc(100%-0.5rem)] bg-muted" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-0.5 space-y-1.5">
|
||||
<div className="h-4 bg-muted rounded w-3/4" />
|
||||
<div className="h-3 bg-muted rounded w-16" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyActivity() {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-xl bg-gradient-to-br from-muted/30 to-muted/10 border border-border/40 text-center py-10 px-6">
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-12 -right-12 w-24 h-24 rounded-full bg-primary/5 blur-2xl" />
|
||||
<div className="absolute -bottom-12 -left-12 w-24 h-24 rounded-full bg-info/5 blur-2xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="w-14 h-14 rounded-xl bg-muted/50 flex items-center justify-center mx-auto mb-4">
|
||||
<ArrowTrendingUpIcon className="h-7 w-7 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-foreground">No recent activity</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1 max-w-[200px] mx-auto">
|
||||
Your account activity will appear here as you use our services.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ActivityFeed - Beautiful activity timeline for dashboard
|
||||
*/
|
||||
export function ActivityFeed({
|
||||
activities,
|
||||
maxItems = 5,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: ActivityFeedProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-foreground">Recent Activity</h3>
|
||||
</div>
|
||||
<div className="bg-surface border border-border/60 rounded-xl p-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<ActivityItemSkeleton key={i} isLast={i === 2} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleActivities = activities.slice(0, maxItems);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-foreground">Recent Activity</h3>
|
||||
</div>
|
||||
|
||||
{visibleActivities.length === 0 ? (
|
||||
<EmptyActivity />
|
||||
) : (
|
||||
<div className="bg-surface border border-border/60 rounded-xl p-4">
|
||||
{visibleActivities.map((activity, index) => (
|
||||
<ActivityItem
|
||||
key={activity.id}
|
||||
activity={activity}
|
||||
isLast={index === visibleActivities.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
apps/portal/src/features/dashboard/components/QuickStats.tsx
Normal file
163
apps/portal/src/features/dashboard/components/QuickStats.tsx
Normal file
@ -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<React.SVGProps<SVGSVGElement>>;
|
||||
label: string;
|
||||
value: number;
|
||||
href: string;
|
||||
tone?: StatTone;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
const toneStyles: Record<StatTone, { iconBg: string; iconColor: string; hoverBorder: string }> = {
|
||||
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 (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"group flex items-center gap-4 p-4 rounded-xl",
|
||||
"bg-surface border border-border/60",
|
||||
"transition-all duration-[var(--cp-duration-normal)]",
|
||||
"hover:shadow-md",
|
||||
styles.hoverBorder
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 h-11 w-11 rounded-xl flex items-center justify-center",
|
||||
"transition-colors",
|
||||
styles.iconBg
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-5 w-5", styles.iconColor)} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</p>
|
||||
{value > 0 ? (
|
||||
<p className="text-2xl font-bold text-foreground tabular-nums mt-0.5">{value}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mt-1">{emptyText || "None"}</p>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRightIcon className="h-4 w-4 text-muted-foreground/40 group-hover:text-foreground group-hover:translate-x-0.5 transition-all flex-shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItemSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-4 p-4 rounded-xl bg-surface border border-border/60 animate-pulse">
|
||||
<div className="flex-shrink-0 h-11 w-11 rounded-xl bg-muted" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="h-3 bg-muted rounded w-20" />
|
||||
<div className="h-7 bg-muted rounded w-10" />
|
||||
</div>
|
||||
<div className="h-4 w-4 bg-muted rounded flex-shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* QuickStats - Beautiful stats display for dashboard
|
||||
*/
|
||||
export function QuickStats({
|
||||
activeSubscriptions,
|
||||
openCases,
|
||||
recentOrders,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: QuickStatsProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-foreground">Account Overview</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<StatItemSkeleton />
|
||||
<StatItemSkeleton />
|
||||
<StatItemSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-foreground">Account Overview</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<StatItem
|
||||
icon={ServerIcon}
|
||||
label="Active Services"
|
||||
value={activeSubscriptions}
|
||||
href="/subscriptions"
|
||||
tone="primary"
|
||||
emptyText="No active services"
|
||||
/>
|
||||
<StatItem
|
||||
icon={ChatBubbleLeftRightIcon}
|
||||
label="Open Support Cases"
|
||||
value={openCases}
|
||||
href="/support/cases"
|
||||
tone={openCases > 0 ? "warning" : "info"}
|
||||
emptyText="No open cases"
|
||||
/>
|
||||
{recentOrders !== undefined && (
|
||||
<StatItem
|
||||
icon={ClipboardDocumentListIcon}
|
||||
label="Recent Orders"
|
||||
value={recentOrders}
|
||||
href="/orders"
|
||||
tone="success"
|
||||
emptyText="No recent orders"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
apps/portal/src/features/dashboard/components/TaskCard.tsx
Normal file
175
apps/portal/src/features/dashboard/components/TaskCard.tsx
Normal file
@ -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<React.SVGProps<SVGSVGElement>>;
|
||||
/** 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 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 h-12 w-12 rounded-xl flex items-center justify-center",
|
||||
styles.iconBg
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className={cn("h-6 w-6", styles.iconColor)} />
|
||||
</div>
|
||||
|
||||
{/* Content - Larger text */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{description}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const actionButton = (
|
||||
<Button
|
||||
variant={styles.buttonVariant}
|
||||
size="sm"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onAction?.();
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
loadingText={loadingText}
|
||||
rightIcon={!isLoading ? <ChevronRightIcon className="h-4 w-4" /> : undefined}
|
||||
className="shrink-0"
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Link href={detailHref} data-task-id={id} className={cn(cardClasses, "block")}>
|
||||
<div className="flex items-center gap-5 w-full">
|
||||
{cardContent}
|
||||
{/* Action button */}
|
||||
<div className="shrink-0">{actionButton}</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-task-id={id} className={cardClasses}>
|
||||
{cardContent}
|
||||
{/* Action button */}
|
||||
<div className="shrink-0">{actionButton}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton for TaskCard
|
||||
*/
|
||||
export function TaskCardSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-5 p-5 rounded-2xl bg-muted/30 border border-border/60 border-l-4 border-l-muted animate-pulse">
|
||||
<div className="flex-shrink-0 h-12 w-12 rounded-xl bg-muted" />
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="h-5 bg-muted rounded w-1/3" />
|
||||
<div className="h-4 bg-muted rounded w-2/3" />
|
||||
</div>
|
||||
<div className="h-9 w-24 bg-muted rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
apps/portal/src/features/dashboard/components/TaskList.tsx
Normal file
172
apps/portal/src/features/dashboard/components/TaskList.tsx
Normal file
@ -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 (
|
||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-success/10 via-primary/5 to-info/10 border border-success/20">
|
||||
{/* Decorative background elements */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-24 -right-24 w-48 h-48 rounded-full bg-success/10 blur-3xl" />
|
||||
<div className="absolute -bottom-24 -left-24 w-48 h-48 rounded-full bg-primary/10 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative text-center py-10 px-6">
|
||||
{/* Animated checkmark */}
|
||||
<div className="relative w-20 h-20 mx-auto mb-5">
|
||||
<div className="absolute inset-0 rounded-full bg-success/20 animate-ping" />
|
||||
<div className="relative w-20 h-20 rounded-full bg-success/15 flex items-center justify-center">
|
||||
<CheckCircleIcon className="h-10 w-10 text-success" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-foreground">You're all caught up!</h3>
|
||||
<p className="text-sm text-muted-foreground mt-2 max-w-md mx-auto">
|
||||
No outstanding tasks right now. Take a moment to explore or manage your account.
|
||||
</p>
|
||||
|
||||
{/* Quick action cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-8 max-w-lg mx-auto">
|
||||
<Link
|
||||
href="/catalog"
|
||||
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-primary/40 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
|
||||
<Squares2X2Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">Browse Catalog</span>
|
||||
<ArrowRightIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-0.5 transition-all" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/billing/invoices"
|
||||
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-info/40 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-info/10 flex items-center justify-center group-hover:bg-info/20 transition-colors">
|
||||
<DocumentTextIcon className="h-5 w-5 text-info" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">View Invoices</span>
|
||||
<ArrowRightIcon className="h-4 w-4 text-muted-foreground group-hover:text-info group-hover:translate-x-0.5 transition-all" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/support"
|
||||
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-warning/40 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-warning/10 flex items-center justify-center group-hover:bg-warning/20 transition-colors">
|
||||
<ChatBubbleLeftRightIcon className="h-5 w-5 text-warning" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">Get Support</span>
|
||||
<ArrowRightIcon className="h-4 w-4 text-muted-foreground group-hover:text-warning group-hover:translate-x-0.5 transition-all" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading state for task list
|
||||
*/
|
||||
function TaskListLoading({ count = 2 }: { count?: number }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<TaskCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskList component that renders prioritized tasks
|
||||
*/
|
||||
export function TaskList({ tasks, isLoading = false, maxTasks = 4 }: TaskListProps) {
|
||||
const [loadingTaskId, setLoadingTaskId] = useState<string | null>(null);
|
||||
const createInvoiceSsoLink = useCreateInvoiceSsoLink();
|
||||
const createPaymentMethodsSsoLink = useCreatePaymentMethodsSsoLink();
|
||||
|
||||
if (isLoading) {
|
||||
return <TaskListLoading count={2} />;
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return <AllCaughtUp />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{visibleTasks.map(task => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
id={task.id}
|
||||
icon={task.icon}
|
||||
title={task.title}
|
||||
description={task.description}
|
||||
actionLabel={task.actionLabel}
|
||||
detailHref={task.detailHref}
|
||||
onAction={task.requiresSsoAction ? () => void handleTaskAction(task) : undefined}
|
||||
tone={task.tone}
|
||||
isLoading={loadingTaskId === task.id}
|
||||
loadingText="Opening..."
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./useDashboardSummary";
|
||||
export * from "./useDashboardTasks";
|
||||
|
||||
207
apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts
Normal file
207
apps/portal/src/features/dashboard/hooks/useDashboardTasks.ts
Normal file
@ -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<React.SVGProps<SVGSVGElement>>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -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<string | null>(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 (
|
||||
<PageLayout title="Dashboard" description="Overview of your account" loading>
|
||||
<div className="space-y-6">
|
||||
<LoadingStats />
|
||||
<LoadingTable />
|
||||
<p className="text-muted-foreground">Loading dashboard...</p>
|
||||
<div className="space-y-8">
|
||||
{/* Greeting skeleton */}
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-muted rounded w-24 mb-3" />
|
||||
<div className="h-10 bg-muted rounded w-56 mb-2" />
|
||||
<div className="h-4 bg-muted rounded w-40" />
|
||||
</div>
|
||||
{/* Tasks skeleton */}
|
||||
<div className="space-y-4">
|
||||
<div className="h-24 bg-muted rounded-2xl" />
|
||||
<div className="h-24 bg-muted rounded-2xl" />
|
||||
</div>
|
||||
{/* Bottom section skeleton */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="h-44 bg-muted rounded-2xl" />
|
||||
<div className="h-44 bg-muted rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
@ -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 (
|
||||
<PageLayout
|
||||
title="Dashboard"
|
||||
description="Overview of your account"
|
||||
actions={<TasksChip summaryLoading={summaryLoading} summary={summary} />}
|
||||
>
|
||||
{/* Greeting */}
|
||||
<div className="mb-6">
|
||||
<PageLayout title="Dashboard" description="Overview of your account">
|
||||
{/* Greeting Section */}
|
||||
<div className="mb-8">
|
||||
<p className="text-sm font-medium text-muted-foreground">Welcome back</p>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-foreground truncate mt-1">
|
||||
{user?.firstname || user?.email?.split("@")[0] || "User"}
|
||||
</h2>
|
||||
</div>
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-foreground mt-1">{displayName}</h2>
|
||||
|
||||
{/* Overview Stats Card */}
|
||||
<div className="cp-card rounded-2xl">
|
||||
<div className="mb-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">Overview</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Quick snapshot of your account</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 divide-y sm:divide-y-0 sm:divide-x divide-border/60 -mx-4 sm:-mx-0">
|
||||
<StatCard
|
||||
title="Recent Orders"
|
||||
value={summary?.stats?.recentOrders ?? 0}
|
||||
icon={ClipboardDocumentListIconSolid}
|
||||
tone="primary"
|
||||
href="/orders"
|
||||
/>
|
||||
<StatCard
|
||||
title="Pending Invoices"
|
||||
value={summary?.stats?.unpaidInvoices ?? 0}
|
||||
icon={CreditCardIconSolid}
|
||||
tone={(summary?.stats?.unpaidInvoices ?? 0) > 0 ? "warning" : "neutral"}
|
||||
href="/billing/invoices"
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Services"
|
||||
value={summary?.stats?.activeSubscriptions ?? 0}
|
||||
icon={ServerIconSolid}
|
||||
tone="info"
|
||||
href="/subscriptions"
|
||||
/>
|
||||
<StatCard
|
||||
title="Support Cases"
|
||||
value={summary?.stats?.openCases ?? 0}
|
||||
icon={ChatBubbleLeftRightIconSolid}
|
||||
tone={(summary?.stats?.openCases ?? 0) > 0 ? "info" : "neutral"}
|
||||
href="/support/cases"
|
||||
/>
|
||||
<StatCard
|
||||
title="Browse Catalog"
|
||||
value="→"
|
||||
icon={Squares2X2IconSolid}
|
||||
tone="primary"
|
||||
href="/catalog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing Card - only shown when there's an upcoming invoice */}
|
||||
{upcomingInvoice && (
|
||||
<div className="cp-card rounded-2xl" id="attention">
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Upcoming Payment</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Invoice due soon</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/billing/invoices"
|
||||
className="text-sm font-medium text-primary hover:text-primary-hover transition-colors"
|
||||
{/* Task status badge */}
|
||||
{taskCount > 0 ? (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium",
|
||||
hasUrgentTask ? "bg-error/10 text-error" : "bg-warning/10 text-warning"
|
||||
)}
|
||||
>
|
||||
View all invoices
|
||||
</Link>
|
||||
{hasUrgentTask && <ExclamationTriangleIcon className="h-4 w-4" />}
|
||||
{taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/60">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning">
|
||||
Due soon
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">Invoice #{upcomingInvoice.id}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-bold text-foreground">
|
||||
{formatCurrency(upcomingInvoice.amount, {
|
||||
currency: upcomingInvoice.currency,
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Due{" "}
|
||||
{formatDistanceToNow(new Date(upcomingInvoice.dueDate), {
|
||||
addSuffix: true,
|
||||
})}{" "}
|
||||
· {format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={`/billing/invoices/${upcomingInvoice.id}`}
|
||||
className="text-sm font-medium text-primary hover:text-primary-hover transition-colors"
|
||||
>
|
||||
View details
|
||||
</Link>
|
||||
<Button
|
||||
onClick={() => handlePayNow(upcomingInvoice.id)}
|
||||
isLoading={paymentLoading}
|
||||
loadingText="Opening…"
|
||||
rightIcon={!paymentLoading ? <ChevronRightIcon className="h-4 w-4" /> : undefined}
|
||||
>
|
||||
Pay now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Error Display */}
|
||||
{paymentError && (
|
||||
<ErrorState
|
||||
title="Payment Error"
|
||||
message={paymentError}
|
||||
variant="inline"
|
||||
onRetry={() => setPaymentError(null)}
|
||||
retryLabel="Dismiss"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Recent Activity Card */}
|
||||
<div className="cp-card rounded-2xl">
|
||||
<div className="flex items-center justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Recent Activity</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Your latest account updates</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActivityTimeline
|
||||
activities={summary?.recentActivity || []}
|
||||
onItemClick={handleActivityClick}
|
||||
maxItems={8}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mt-2">Everything is up to date</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tasks Section - Main focus area */}
|
||||
<section className="mb-10" aria-labelledby="tasks-heading">
|
||||
<h3 id="tasks-heading" className="sr-only">
|
||||
Your Tasks
|
||||
</h3>
|
||||
<TaskList tasks={tasks} isLoading={tasksLoading} maxTasks={4} />
|
||||
</section>
|
||||
|
||||
{/* Bottom Section: Quick Stats + Recent Activity */}
|
||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-6" aria-label="Account overview">
|
||||
<QuickStats
|
||||
activeSubscriptions={summary?.stats?.activeSubscriptions ?? 0}
|
||||
openCases={summary?.stats?.openCases ?? 0}
|
||||
recentOrders={summary?.stats?.recentOrders}
|
||||
isLoading={summaryLoading}
|
||||
/>
|
||||
<ActivityFeed
|
||||
activities={summary?.recentActivity || []}
|
||||
maxItems={5}
|
||||
isLoading={summaryLoading}
|
||||
/>
|
||||
</section>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<button
|
||||
onClick={() => {
|
||||
const first = tasks[0];
|
||||
if (first.href.startsWith("#")) {
|
||||
const el = document.querySelector(first.href);
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
} else {
|
||||
router.push(first.href);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center rounded-full bg-muted text-foreground px-2.5 py-1 text-xs font-medium hover:bg-muted/80 transition-colors"
|
||||
title={tasks.map(t => t.label).join(" • ")}
|
||||
>
|
||||
{count} tasks
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user