diff --git a/src/components/admin/EmptyState.tsx b/src/components/admin/EmptyState.tsx new file mode 100644 index 0000000..65ea6b7 --- /dev/null +++ b/src/components/admin/EmptyState.tsx @@ -0,0 +1,107 @@ +"use client"; + +import Link from "next/link"; +import type { ReactNode } from "react"; +import { Inbox } from "lucide-react"; + +type EmptyStateAction = { + /** Visible label for the call-to-action. */ + label: string; + /** If provided, renders a Next.js Link. Mutually exclusive with onClick. */ + href?: string; + /** If provided, renders a button. */ + onClick?: () => void; +}; + +type EmptyStateProps = { + /** Optional icon element (typically a lucide-react icon). */ + icon?: ReactNode; + /** Required heading explaining the empty state. */ + title: string; + /** Optional supporting copy shown below the title. */ + description?: string; + /** Optional single primary action. */ + action?: EmptyStateAction; + /** Extra class names appended to the root. */ + className?: string; +}; + +/** + * EmptyState — generic empty state for admin pages. + * + * Centers an icon + title + description and (optionally) a single primary CTA + * styled with `.ha-btn-primary`. Uses the new admin design tokens for + * colors and Fraunces for the title. + */ +export default function EmptyState({ + icon, + title, + description, + action, + className = "", +}: EmptyStateProps) { + return ( +
+ + +

+ {title} +

+ + {description && ( +

+ {description} +

+ )} + + {action && ( +
+ +
+ )} +
+ ); +} + +function DefaultEmptyIcon() { + return ; +} + +function EmptyStateActionButton({ action }: { action: EmptyStateAction }) { + // Pick Link vs button based on whether an href is provided. Both render + // with the same .ha-btn-primary class so the CTA is visually consistent. + if (action.href) { + return ( + + {action.label} + + ); + } + return ( + + ); +} diff --git a/src/components/admin/KPIStat.tsx b/src/components/admin/KPIStat.tsx new file mode 100644 index 0000000..38bc30e --- /dev/null +++ b/src/components/admin/KPIStat.tsx @@ -0,0 +1,97 @@ +"use client"; + +import type { ReactNode } from "react"; +import { ArrowDown, ArrowRight, ArrowUp } from "lucide-react"; + +type KPITone = "default" | "primary" | "accent" | "warning" | "danger"; + +type KPIStatProps = { + /** Eyebrow label above the value (e.g. "Today's Orders") */ + label: string; + /** The primary value to display. Accepts a node so callers can pass formatted strings, JSX, etc. */ + value: ReactNode; + /** Optional icon element (typically a lucide-react icon) shown in the leading tile. */ + icon?: ReactNode; + /** Controls the icon tile's background + icon color. Defaults to "default". */ + tone?: KPITone; + /** Optional trend indicator rendered next to the value. */ + trend?: { value: string; direction: "up" | "down" | "flat" }; + /** Extra class names appended to the card. */ + className?: string; +}; + +// Tone -> icon-tile background / icon color. Pulled from --admin-* tokens +// introduced in the Phase 1 palette unification. No hardcoded hex values. +const toneStyles: Record = { + default: { bg: "var(--admin-bg-subtle)", fg: "var(--admin-text-secondary)" }, + primary: { bg: "var(--admin-primary-soft)", fg: "var(--admin-primary)" }, + accent: { bg: "var(--admin-accent-soft)", fg: "var(--admin-accent)" }, + warning: { bg: "var(--admin-warning-soft)", fg: "var(--admin-warning)" }, + danger: { bg: "var(--admin-danger-soft)", fg: "var(--admin-danger)" }, +}; + +// Trend direction -> icon + color. "up" reads as positive (primary), +// "down" as negative (danger), "flat" as neutral. +const trendStyles: Record<"up" | "down" | "flat", { Icon: typeof ArrowUp; color: string }> = { + up: { Icon: ArrowUp, color: "var(--admin-primary)" }, + down: { Icon: ArrowDown, color: "var(--admin-danger)" }, + flat: { Icon: ArrowRight, color: "var(--admin-text-muted)" }, +}; + +/** + * KPIStat — compact metric card for admin surfaces. + * + * Extracted from the inline pattern previously duplicated four times in + * `DashboardClient.tsx`. Reuses the `.admin-stat-card*` classes from + * `admin-design-system.css` for the shell, then applies tone-aware + * background/foreground via inline `var(--admin-*)` styles. + */ +export default function KPIStat({ + label, + value, + icon, + tone = "default", + trend, + className = "", +}: KPIStatProps) { + const { bg, fg } = toneStyles[tone]; + + return ( +
+
+ {icon && ( +
+ {icon} +
+ )} +
+ {label} +
+ {value} + {trend && } +
+
+
+
+ ); +} + +function TrendIndicator({ + trend, +}: { + trend: { value: string; direction: "up" | "down" | "flat" }; +}) { + const { Icon, color } = trendStyles[trend.direction]; + return ( + + + ); +} diff --git a/src/components/admin/LoadingState.tsx b/src/components/admin/LoadingState.tsx new file mode 100644 index 0000000..e0e15a1 --- /dev/null +++ b/src/components/admin/LoadingState.tsx @@ -0,0 +1,83 @@ +"use client"; + +type LoadingStateProps = { + /** Optional eyebrow text rendered above the skeleton (e.g. "Loading orders..."). */ + label?: string; + /** Number of skeleton rows to show. Defaults to 3. */ + rows?: number; + /** Extra class names appended to the wrapper. */ + className?: string; +}; + +/** + * LoadingState — generic skeleton for admin list/card surfaces. + * + * Renders an optional centered "Loading…" eyebrow above `rows` skeleton + * blocks, each composed of a label-sized bar and a value-sized bar. The + * shimmer comes from the shared `.ha-skeleton` class in + * `admin-design-system.css` so motion / reduced-motion stay consistent + * with the rest of the admin shell. + */ +export default function LoadingState({ + label, + rows = 3, + className = "", +}: LoadingStateProps) { + const safeRows = Math.max(1, Math.floor(rows)); + + return ( +
+ {label && ( + + {label} + + )} + +
+ {Array.from({ length: safeRows }).map((_, i) => ( + // Index is fine here — the list shape is stable per render. + // eslint-disable-next-line react/no-array-index-key + + ))} +
+
+ ); +} + +function SkeletonRow() { + return ( +
+ {/* Icon-tile placeholder */} + + {/* Label + value placeholders */} +
+ + +
+
+ ); +} diff --git a/src/components/admin/design-system/AdminBadge.tsx b/src/components/admin/design-system/AdminBadge.tsx index 6bb8d92..22ea2b1 100644 --- a/src/components/admin/design-system/AdminBadge.tsx +++ b/src/components/admin/design-system/AdminBadge.tsx @@ -1,33 +1,139 @@ "use client"; +import type { CSSProperties, ReactNode } from "react"; + +/** + * Visual variants for `AdminBadge`. Kept for backward compatibility — + * existing callers (status pills, add-on flags, etc.) continue to pass + * one of these values and are routed onto the new tone palette below. + */ type BadgeVariant = "default" | "success" | "warning" | "danger" | "info"; +/** + * New tone palette. Matches the spec in + * `docs/superpowers/specs/2026-06-17-admin-redesign.md` §3. The previous + * `--admin-warning-light` / `--admin-danger-light` references have been + * replaced with the new `--admin-warning-soft` / `--admin-danger-soft` + * tokens. No hardcoded hex values remain in this file. + */ +type BadgeTone = + | "neutral" + | "primary" + | "accent" + | "warning" + | "danger" + | "success" + | "info"; + type AdminBadgeProps = { - children: React.ReactNode; + children: ReactNode; + /** Legacy variant name. Falls back to "default". Mapped onto `tone` internally. */ variant?: BadgeVariant; + /** New tone name. Takes precedence over `variant` when both are provided. */ + tone?: BadgeTone; + /** Optional leading dot indicator. */ dot?: boolean; + /** Extra classes — typically size overrides like `"text-xs px-2 py-0.5"`. */ className?: string; }; -const variantClasses: Record = { - default: { bg: "bg-[var(--admin-border-light)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-text-muted)]" }, - success: { bg: "bg-[var(--admin-accent-light)]", text: "text-[var(--admin-accent-text)]", dot: "bg-[var(--admin-accent)]" }, - warning: { bg: "bg-[var(--admin-warning-light)]", text: "text-[var(--admin-warning)]", dot: "bg-[var(--admin-warning)]" }, - danger: { bg: "bg-[var(--admin-danger-light)]", text: "text-[var(--admin-danger)]", dot: "bg-[var(--admin-danger)]" }, - info: { bg: "bg-[var(--admin-border)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-info)]" }, +type ToneStyle = { + bg: string; + text: string; + /** Optional border color; falls back to the background when unset. */ + border?: string; + dot: string; }; -export default function AdminBadge({ - children, - variant = "default", +// New tone palette. Every value references a `--admin-*` design token +// declared in `admin-design-system.css`. +const toneStyles: Record = { + neutral: { + bg: "var(--admin-bg-subtle)", + text: "var(--admin-text-secondary)", + border: "var(--admin-border)", + dot: "var(--admin-text-muted)", + }, + primary: { + bg: "var(--admin-primary-soft)", + text: "var(--admin-primary)", + border: "var(--admin-primary)", + dot: "var(--admin-primary)", + }, + accent: { + bg: "var(--admin-accent-soft)", + text: "var(--admin-accent)", + border: "var(--admin-accent)", + dot: "var(--admin-accent)", + }, + success: { + // "success" shares the primary botanical green per the unified palette. + bg: "var(--admin-primary-soft)", + text: "var(--admin-primary)", + border: "var(--admin-primary)", + dot: "var(--admin-primary)", + }, + warning: { + bg: "var(--admin-warning-soft)", + text: "var(--admin-warning)", + border: "var(--admin-warning)", + dot: "var(--admin-warning)", + }, + danger: { + bg: "var(--admin-danger-soft)", + text: "var(--admin-danger)", + border: "var(--admin-danger)", + dot: "var(--admin-danger)", + }, + info: { + bg: "var(--admin-bg-subtle)", + text: "var(--admin-text-secondary)", + border: "var(--admin-border)", + dot: "var(--admin-text-secondary)", + }, +}; + +// Map the legacy `variant` prop onto the new `tone` palette. The mapping +// preserves the visual intent of pre-refactor call sites. +const variantToTone: Record = { + default: "neutral", + success: "success", + warning: "warning", + danger: "danger", + info: "info", +}; + +export default function AdminBadge({ + children, + variant = "default", + tone, dot = false, - className = "" + className = "", }: AdminBadgeProps) { - const { bg, text, dot: dotColor } = variantClasses[variant]; + // `tone` wins when provided; otherwise translate the legacy variant. + const resolvedTone: BadgeTone = tone ?? variantToTone[variant]; + const { bg, text, border, dot: dotColor } = toneStyles[resolvedTone]; + + // Border color tracks tone when the tone is one of the chromatic ones; + // otherwise it falls back to the neutral hairline so the pill keeps a + // subtle outline against the cream canvas. + const style: CSSProperties = { + backgroundColor: bg, + color: text, + borderColor: border ?? "var(--admin-border)", + }; return ( - - {dot && } + + {dot && ( + + )} {children} ); @@ -39,19 +145,21 @@ type AdminStatusBadgeProps = { className?: string; }; -const statusConfig: Record = { - active: { variant: "success", dot: true, label: "Active" }, - inactive: { variant: "default", dot: true, label: "Inactive" }, - pending: { variant: "warning", dot: true, label: "Pending" }, - draft: { variant: "default", dot: true, label: "Draft" }, - completed: { variant: "success", dot: true, label: "Completed" }, - cancelled: { variant: "danger", dot: true, label: "Cancelled" }, +// Reuse the new `tone` prop directly so the status pill text style always +// matches the active AdminBadge palette. +const statusConfig: Record = { + active: { tone: "success", dot: true, label: "Active" }, + inactive: { tone: "neutral", dot: true, label: "Inactive" }, + pending: { tone: "warning", dot: true, label: "Pending" }, + draft: { tone: "neutral", dot: true, label: "Draft" }, + completed: { tone: "success", dot: true, label: "Completed" }, + cancelled: { tone: "danger", dot: true, label: "Cancelled" }, }; export function AdminStatusBadge({ status, className = "" }: AdminStatusBadgeProps) { const config = statusConfig[status] || statusConfig.inactive; return ( - + {config.label} ); @@ -60,15 +168,26 @@ export function AdminStatusBadge({ status, className = "" }: AdminStatusBadgePro // Count badge (circular) type AdminCountBadgeProps = { count: number; + tone?: BadgeTone; + /** @deprecated Use `tone` instead. */ variant?: BadgeVariant; className?: string; }; -export function AdminCountBadge({ count, variant = "default", className = "" }: AdminCountBadgeProps) { - const { bg, text } = variantClasses[variant]; +export function AdminCountBadge({ + count, + tone, + variant = "default", + className = "", +}: AdminCountBadgeProps) { + const resolvedTone: BadgeTone = tone ?? variantToTone[variant]; + const { bg, text } = toneStyles[resolvedTone]; return ( - + {count} ); -} \ No newline at end of file +} diff --git a/src/components/admin/design-system/index.tsx b/src/components/admin/design-system/index.tsx index 88d85f0..885ecc5 100644 --- a/src/components/admin/design-system/index.tsx +++ b/src/components/admin/design-system/index.tsx @@ -12,6 +12,12 @@ export { default as AdminActionMenu, AdminActionButton } from "./AdminActionMenu export { default as AdminPagination, AdminSimplePagination } from "./AdminPagination"; export { default as AdminBadge, AdminStatusBadge, AdminCountBadge } from "./AdminBadge"; +// Phase 2 pattern primitives (admin-level, not in design-system/ folder) +// Re-exported here so callers can `import { KPIStat, EmptyState, LoadingState } from "@/components/admin/design-system"`. +export { default as KPIStat } from "../KPIStat"; +export { default as EmptyState } from "../EmptyState"; +export { default as LoadingState } from "../LoadingState"; + // Design system components export { default as AdminButton, AdminIconButton } from "./AdminButton"; export { default as PageHeader } from "./PageHeader";