Shared Components
Reusable components shared across multiple pages of the MetaForge Digital Twin Dashboard. Organized into three categories: Charts, Data Display, and Feedback.
Charts
Lightweight chart components for embedding in cards and dashboards. Built with Recharts and raw SVG.
src/components/charts/risk-gauge.tsx
Semicircular gauge using Recharts PieChart. Color changes based on value thresholds: green (0–25), yellow (25–50), orange (50–75), red (75–100). The numeric value is displayed in the center.
import { PieChart, Pie, Cell } from "recharts";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface RiskGaugeProps {
/** Value from 0 to 100. */
value: number;
/** Diameter in pixels. @default 140 */
size?: number;
/** Label rendered below the value. */
label?: string;
/** Additional class names for the wrapper. */
className?: string;
}
// ---------------------------------------------------------------------------
// Color helpers
// ---------------------------------------------------------------------------
function gaugeColor(value: number): string {
if (value <= 25) return "#22c55e"; // green-500
if (value <= 50) return "#eab308"; // yellow-500
if (value <= 75) return "#f97316"; // orange-500
return "#ef4444"; // red-500
}
function gaugeBgColor(): string {
return "#e5e7eb"; // gray-200
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function RiskGauge({
value,
size = 140,
label,
className,
}: RiskGaugeProps) {
const clamped = Math.max(0, Math.min(100, value));
const color = gaugeColor(clamped);
// Recharts Pie for semicircle: startAngle=180, endAngle=0
const data = [
{ name: "value", value: clamped },
{ name: "remainder", value: 100 - clamped },
];
return (
<div
className={cn("relative flex flex-col items-center", className)}
style={{ width: size, height: size * 0.65 }}
>
<PieChart width={size} height={size * 0.65}>
<Pie
data={data}
cx={size / 2}
cy={size * 0.6}
startAngle={180}
endAngle={0}
innerRadius={size * 0.3}
outerRadius={size * 0.44}
dataKey="value"
stroke="none"
isAnimationActive
animationDuration={800}
>
<Cell fill={color} />
<Cell fill={gaugeBgColor()} />
</Pie>
</PieChart>
{/* Center label */}
<div
className="absolute flex flex-col items-center justify-center"
style={{
top: size * 0.22,
left: "50%",
transform: "translateX(-50%)",
}}
>
<span
className="font-bold leading-none"
style={{ fontSize: size * 0.18, color }}
>
{clamped}
</span>
{label && (
<span
className="mt-0.5 text-muted-foreground"
style={{ fontSize: Math.max(10, size * 0.08) }}
>
{label}
</span>
)}
</div>
</div>
);
}
src/components/charts/progress-ring.tsx
Circular progress ring rendered with SVG. Animates the stroke-dasharray on mount.
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ProgressRingProps {
/** Completion percentage (0--100). */
value: number;
/** Diameter in pixels. @default 64 */
size?: number;
/** Stroke width in pixels. @default 6 */
strokeWidth?: number;
/** Stroke color. Defaults to the current primary color. */
color?: string;
/** Label displayed in the center. Falls back to `value%`. */
label?: string;
/** Additional class names for the wrapper `<div>`. */
className?: string;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ProgressRing({
value,
size = 64,
strokeWidth = 6,
color,
label,
className,
}: ProgressRingProps) {
const clamped = Math.max(0, Math.min(100, value));
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (clamped / 100) * circumference;
const center = size / 2;
return (
<div
className={cn("relative inline-flex items-center justify-center", className)}
style={{ width: size, height: size }}
>
<svg width={size} height={size} className="-rotate-90">
{/* Background circle */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-muted/30"
/>
{/* Progress arc */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={color ?? "currentColor"}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className={cn(!color && "text-primary")}
style={{
transition: "stroke-dashoffset 0.6s ease-in-out",
}}
/>
</svg>
{/* Center label */}
<span
className="absolute text-xs font-semibold"
style={{ fontSize: Math.max(10, size * 0.2) }}
>
{label ?? `${clamped}%`}
</span>
</div>
);
}
src/components/charts/sparkline.tsx
Minimal line chart using Recharts. No axes, labels, or grid – just the line with an area fill. Ideal for inline trend indicators.
import {
AreaChart,
Area,
ResponsiveContainer,
} from "recharts";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SparklineProps {
/** Ordered data points (y-values). */
data: number[];
/** Width in pixels. @default 80 */
width?: number;
/** Height in pixels. @default 32 */
height?: number;
/** Line and fill color. @default "currentColor" mapped to chart-1. */
color?: string;
/** Additional class names for the wrapper. */
className?: string;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function Sparkline({
data,
width = 80,
height = 32,
color = "var(--color-chart-1)",
className,
}: SparklineProps) {
const chartData = data.map((value, index) => ({ index, value }));
return (
<div className={cn("inline-block", className)} style={{ width, height }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 2, right: 2, bottom: 2, left: 2 }}
>
<defs>
<linearGradient id={`sparkGrad-${color}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0.05} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={1.5}
fill={`url(#sparkGrad-${color})`}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
src/components/charts/status-dot.tsx
Small colored dot indicator with optional pulse animation. Used for online/offline/warning/error states across the dashboard.
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type StatusDotVariant = "online" | "offline" | "warning" | "error";
export interface StatusDotProps {
/** Visual status. */
status: StatusDotVariant;
/** Diameter. @default "md" */
size?: "sm" | "md" | "lg";
/** Enable a subtle pulse animation. @default false */
pulse?: boolean;
/** Additional class names. */
className?: string;
}
// ---------------------------------------------------------------------------
// Mappings
// ---------------------------------------------------------------------------
const COLOR_MAP: Record<StatusDotVariant, string> = {
online: "bg-green-500",
offline: "bg-red-500",
warning: "bg-amber-500",
error: "bg-red-500",
};
const SIZE_MAP: Record<NonNullable<StatusDotProps["size"]>, string> = {
sm: "h-1.5 w-1.5",
md: "h-2.5 w-2.5",
lg: "h-3.5 w-3.5",
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function StatusDot({
status,
size = "md",
pulse = false,
className,
}: StatusDotProps) {
return (
<span
className={cn(
"inline-block shrink-0 rounded-full",
COLOR_MAP[status],
SIZE_MAP[size],
pulse && "animate-pulse-dot",
className,
)}
role="status"
aria-label={status}
/>
);
}
Data Display
Components for rendering metrics, statuses, timelines, and empty states.
src/components/data-display/metric-card.tsx
Stats card displaying a title, a large value, an optional change percentage with directional arrow, and an optional sparkline. Built on top of the Card UI component.
import { type LucideIcon, TrendingUp, TrendingDown, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Sparkline } from "@/components/charts/sparkline";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MetricCardProps {
/** Short label above the value. */
title: string;
/** The primary metric value (number or formatted string). */
value: string | number;
/** Percentage change (positive = improvement, negative = decline). */
change?: number;
/** Optional icon rendered top-right. */
icon?: LucideIcon;
/** Trend direction. When omitted, inferred from `change`. */
trend?: "up" | "down" | "flat";
/** Data points for an inline sparkline below the value. */
sparklineData?: number[];
/** Additional class names for the Card wrapper. */
className?: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function resolveTrend(
change: number | undefined,
trend: MetricCardProps["trend"],
): "up" | "down" | "flat" {
if (trend) return trend;
if (change == null || change === 0) return "flat";
return change > 0 ? "up" : "down";
}
const TREND_ICON: Record<"up" | "down" | "flat", LucideIcon> = {
up: TrendingUp,
down: TrendingDown,
flat: Minus,
};
const TREND_COLOR: Record<"up" | "down" | "flat", string> = {
up: "text-green-600 dark:text-green-400",
down: "text-red-600 dark:text-red-400",
flat: "text-muted-foreground",
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function MetricCard({
title,
value,
change,
icon: Icon,
trend,
sparklineData,
className,
}: MetricCardProps) {
const resolved = resolveTrend(change, trend);
const TrendIcon = TREND_ICON[resolved];
return (
<Card className={cn("relative overflow-hidden", className)}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{/* Change row */}
{change != null && (
<div className={cn("mt-1 flex items-center gap-1 text-xs", TREND_COLOR[resolved])}>
<TrendIcon className="h-3 w-3" />
<span>
{change > 0 ? "+" : ""}
{change.toFixed(1)}%
</span>
</div>
)}
{/* Optional sparkline */}
{sparklineData && sparklineData.length > 1 && (
<div className="mt-3">
<Sparkline data={sparklineData} width={160} height={28} />
</div>
)}
</CardContent>
</Card>
);
}
src/components/data-display/status-badge.tsx
Colored badge that maps various domain-specific statuses to badge variants. Accepts arbitrary status strings and automatically selects the appropriate color through a lookup table. Falls back to the default variant for unknown statuses.
import { cn } from "@/lib/utils";
import { Badge, type BadgeProps } from "@/components/ui/badge";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface StatusBadgeProps extends Omit<BadgeProps, "variant"> {
/** The status string to render (e.g. "running", "passed", "critical"). */
status: string;
/** Force a specific badge variant. When omitted, auto-detected from status. */
variant?: BadgeProps["variant"];
}
// ---------------------------------------------------------------------------
// Status-to-variant mapping
// ---------------------------------------------------------------------------
type BadgeVariant = NonNullable<BadgeProps["variant"]>;
const STATUS_VARIANT_MAP: Record<string, BadgeVariant> = {
// Session statuses
running: "default",
completed: "secondary",
failed: "destructive",
pending: "outline",
cancelled: "outline",
// Lifecycle statuses
active: "default",
deprecated: "outline",
"end-of-life": "destructive",
eol: "destructive",
nrnd: "outline", // Not Recommended for New Designs
// Risk levels
low: "secondary",
medium: "outline",
high: "destructive",
critical: "destructive",
// Compliance statuses
compliant: "secondary",
"non-compliant": "destructive",
"in-progress": "default",
"not-started": "outline",
// Agent statuses
online: "default",
offline: "outline",
idle: "secondary",
busy: "default",
error: "destructive",
// Approval statuses
approved: "secondary",
rejected: "destructive",
"awaiting-review": "outline",
// Testing statuses
passed: "secondary",
skipped: "outline",
};
const STATUS_LABEL_MAP: Record<string, string> = {
eol: "EOL",
nrnd: "NRND",
"end-of-life": "End of Life",
"non-compliant": "Non-Compliant",
"in-progress": "In Progress",
"not-started": "Not Started",
"awaiting-review": "Awaiting Review",
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function StatusBadge({
status,
variant,
className,
...props
}: StatusBadgeProps) {
const resolvedVariant: BadgeVariant =
variant ?? STATUS_VARIANT_MAP[status.toLowerCase()] ?? "outline";
const label =
STATUS_LABEL_MAP[status.toLowerCase()] ??
status.charAt(0).toUpperCase() + status.slice(1);
return (
<Badge
variant={resolvedVariant}
className={cn("whitespace-nowrap", className)}
{...props}
>
{label}
</Badge>
);
}
src/components/data-display/activity-timeline.tsx
Vertical timeline rendering a list of events with colored dots, timestamps, and optional agent attribution. Color-coded by severity level.
import { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type TimelineLevel = "info" | "warn" | "error" | "success";
export interface TimelineEvent {
/** Unique key. */
id: string;
/** ISO 8601 timestamp. */
timestamp: string;
/** Short event title. */
title: string;
/** Optional longer description. */
description?: string;
/** Name of the agent that produced this event. */
agent?: string;
/** Severity level — determines dot color. */
level?: TimelineLevel;
}
export interface ActivityTimelineProps {
events: TimelineEvent[];
/** Additional class names for the wrapper. */
className?: string;
}
// ---------------------------------------------------------------------------
// Color mappings
// ---------------------------------------------------------------------------
const DOT_COLOR: Record<TimelineLevel, string> = {
info: "bg-blue-500",
warn: "bg-amber-500",
error: "bg-red-500",
success: "bg-green-500",
};
const TEXT_COLOR: Record<TimelineLevel, string> = {
info: "text-blue-600 dark:text-blue-400",
warn: "text-amber-600 dark:text-amber-400",
error: "text-red-600 dark:text-red-400",
success: "text-green-600 dark:text-green-400",
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ActivityTimeline({ events, className }: ActivityTimelineProps) {
if (events.length === 0) return null;
return (
<div className={cn("relative space-y-0", className)}>
{/* Vertical line */}
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-border" />
{events.map((event) => {
const level = event.level ?? "info";
const relativeTime = formatDistanceToNow(new Date(event.timestamp), {
addSuffix: true,
});
return (
<div key={event.id} className="relative flex gap-3 pb-6 last:pb-0">
{/* Dot */}
<div className="relative z-10 mt-1.5">
<span
className={cn(
"block h-[9px] w-[9px] rounded-full ring-2 ring-background",
DOT_COLOR[level],
)}
/>
</div>
{/* Content */}
<div className="flex-1 space-y-0.5">
<div className="flex items-baseline gap-2">
<p className="text-sm font-medium leading-tight">
{event.title}
</p>
{event.agent && (
<span
className={cn(
"text-[10px] font-semibold uppercase tracking-wider",
TEXT_COLOR[level],
)}
>
{event.agent}
</span>
)}
</div>
{event.description && (
<p className="text-xs text-muted-foreground">
{event.description}
</p>
)}
<p className="text-[11px] text-muted-foreground/60">
{relativeTime}
</p>
</div>
</div>
);
})}
</div>
);
}
src/components/data-display/empty-state.tsx
Centered empty-state placeholder with an icon, title, description, and an optional call-to-action button.
import { type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface EmptyStateAction {
label: string;
onClick: () => void;
}
export interface EmptyStateProps {
/** Large icon rendered above the title. */
icon: LucideIcon;
/** Heading text. */
title: string;
/** Explanatory body text. */
description: string;
/** Optional CTA button. */
action?: EmptyStateAction;
/** Additional class names. */
className?: string;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function EmptyState({
icon: Icon,
title,
description,
action,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
"flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center animate-fade-in",
className,
)}
>
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<Icon className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="mt-4 text-lg font-semibold">{title}</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
{description}
</p>
{action && (
<Button onClick={action.onClick} className="mt-4" size="sm">
{action.label}
</Button>
)}
</div>
);
}
Usage Example
import { Inbox } from "lucide-react";
import { EmptyState } from "@/components/data-display/empty-state";
function NoSessions() {
return (
<EmptyState
icon={Inbox}
title="No sessions yet"
description="Start a new orchestration session to see it listed here."
action={{
label: "New Session",
onClick: () => console.log("create session"),
}}
/>
);
}
src/components/data-display/loading-skeleton.tsx
Preset skeleton layouts for common page elements. Each variant composes the base Skeleton component from @/components/ui/skeleton.
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
// ---------------------------------------------------------------------------
// TableSkeleton
// ---------------------------------------------------------------------------
export interface TableSkeletonProps {
/** Number of skeleton rows to render. @default 5 */
rows?: number;
/** Number of columns. @default 4 */
columns?: number;
className?: string;
}
export function TableSkeleton({
rows = 5,
columns = 4,
className,
}: TableSkeletonProps) {
return (
<div className={cn("w-full space-y-2", className)}>
{/* Header row */}
<div className="flex gap-4">
{Array.from({ length: columns }).map((_, col) => (
<Skeleton key={`head-${col}`} className="h-8 flex-1" />
))}
</div>
{/* Body rows */}
{Array.from({ length: rows }).map((_, row) => (
<div key={`row-${row}`} className="flex gap-4">
{Array.from({ length: columns }).map((_, col) => (
<Skeleton key={`cell-${row}-${col}`} className="h-6 flex-1" />
))}
</div>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// CardSkeleton
// ---------------------------------------------------------------------------
export interface CardSkeletonProps {
className?: string;
}
export function CardSkeleton({ className }: CardSkeletonProps) {
return (
<div
className={cn(
"rounded-xl border bg-card p-6 shadow space-y-4",
className,
)}
>
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4 rounded-full" />
</div>
<Skeleton className="h-8 w-20" />
<Skeleton className="h-3 w-16" />
</div>
);
}
// ---------------------------------------------------------------------------
// ChartSkeleton
// ---------------------------------------------------------------------------
export interface ChartSkeletonProps {
/** Height of the chart placeholder. @default 200 */
height?: number;
className?: string;
}
export function ChartSkeleton({ height = 200, className }: ChartSkeletonProps) {
return (
<div className={cn("rounded-xl border bg-card p-6 shadow space-y-4", className)}>
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-6 w-24 rounded-md" />
</div>
<Skeleton className="w-full rounded-md" style={{ height }} />
</div>
);
}
// ---------------------------------------------------------------------------
// PageSkeleton
// ---------------------------------------------------------------------------
export interface PageSkeletonProps {
className?: string;
}
export function PageSkeleton({ className }: PageSkeletonProps) {
return (
<div className={cn("space-y-6 p-6", className)}>
{/* Page header skeleton */}
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-72" />
</div>
{/* Metric cards row */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<CardSkeleton key={`metric-${i}`} />
))}
</div>
{/* Main content area */}
<div className="grid gap-6 lg:grid-cols-3">
<ChartSkeleton className="lg:col-span-2" height={260} />
<div className="space-y-4">
<CardSkeleton />
<CardSkeleton />
</div>
</div>
</div>
);
}
Feedback
Components for communicating system status, connection state, and toast notifications.
src/components/feedback/connection-status.tsx
WebSocket connection indicator that reads status from the websocket-store. Renders a colored dot with a label, and exposes details (connected URL, reconnect attempts) via a tooltip.
import { cn } from "@/lib/utils";
import { useWebSocketStore, type WSStatus } from "@/store/websocket-store";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function dotColor(status: WSStatus): string {
switch (status) {
case "connected":
return "bg-green-500";
case "connecting":
return "bg-amber-500 animate-pulse-dot";
case "disconnected":
return "bg-red-500";
case "error":
return "bg-red-500";
default:
return "bg-muted-foreground";
}
}
function statusLabel(status: WSStatus): string {
switch (status) {
case "connected":
return "Connected";
case "connecting":
return "Connecting";
case "disconnected":
return "Disconnected";
case "error":
return "Error";
default:
return "Unknown";
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export interface ConnectionStatusProps {
/** Additional class names for the outer wrapper. */
className?: string;
}
export function ConnectionStatus({ className }: ConnectionStatusProps) {
const status = useWebSocketStore((s) => s.status);
const reconnectAttempts = useWebSocketStore((s) => s.reconnectAttempts);
const wsUrl = import.meta.env.VITE_WS_URL ?? "ws://localhost:3000/ws";
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"inline-flex items-center gap-2 rounded-md px-2 py-1 text-sm",
className,
)}
>
<span className={cn("h-2 w-2 shrink-0 rounded-full", dotColor(status))} />
<span className="text-xs text-muted-foreground">
{statusLabel(status)}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="space-y-1 text-xs">
<p>
<span className="font-medium">Status:</span> {statusLabel(status)}
</p>
<p>
<span className="font-medium">URL:</span>{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
{wsUrl}
</code>
</p>
{reconnectAttempts > 0 && (
<p>
<span className="font-medium">Reconnect attempts:</span>{" "}
{reconnectAttempts}
</p>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
src/components/feedback/toast-provider.tsx
Wrapper around Sonner’s Toaster that integrates with the MetaForge ui-store for theme-aware toasts. Also exports convenience helper functions for common toast patterns.
import { Toaster, toast } from "sonner";
import { useUIStore, resolveTheme } from "@/store/ui-store";
// ---------------------------------------------------------------------------
// ToastProvider Component
// ---------------------------------------------------------------------------
export function ToastProvider() {
const theme = useUIStore((s) => s.theme);
const resolved = resolveTheme(theme);
return (
<Toaster
theme={resolved}
position="bottom-right"
toastOptions={{
classNames: {
toast:
"group toast bg-background text-foreground border-border shadow-lg",
title: "text-foreground font-medium",
description: "text-muted-foreground",
actionButton: "bg-primary text-primary-foreground",
cancelButton: "bg-muted text-muted-foreground",
},
}}
closeButton
richColors
/>
);
}
// ---------------------------------------------------------------------------
// Helper functions
// ---------------------------------------------------------------------------
/**
* Show a success toast.
*
* @example
* showSuccess("Session created", "Session #42 is now running.");
*/
export function showSuccess(title: string, description?: string) {
toast.success(title, { description });
}
/**
* Show an error toast.
*
* @example
* showError("Connection lost", "Attempting to reconnect...");
*/
export function showError(title: string, description?: string) {
toast.error(title, { description });
}
/**
* Show an informational toast.
*
* @example
* showInfo("BOM updated", "3 components were refreshed from the supplier API.");
*/
export function showInfo(title: string, description?: string) {
toast.info(title, { description });
}
/**
* Show a warning toast.
*
* @example
* showWarning("High risk detected", "Component U5 has a single-source supplier.");
*/
export function showWarning(title: string, description?: string) {
toast.warning(title, { description });
}
Usage Example
import { ToastProvider, showSuccess, showError } from "@/components/feedback/toast-provider";
// In your root layout:
function RootLayout() {
return (
<>
{/* ... layout content ... */}
<ToastProvider />
</>
);
}
// Anywhere in the app:
async function handleApprove(id: string) {
try {
await approveItem(id);
showSuccess("Approved", `Item ${id} has been approved.`);
} catch {
showError("Approval failed", "Please try again.");
}
}