@cortexasystem/ui 0.1.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/index.cjs +1326 -643
  2. package/dist/index.d.cts +171 -13
  3. package/dist/index.d.ts +171 -13
  4. package/dist/index.js +1307 -646
  5. package/package.json +3 -3
  6. package/src/assets/isotipo-cortexa-dark.png +0 -0
  7. package/src/assets/isotipo-cortexa-light.png +0 -0
  8. package/src/components/ai/ai-chat.tsx +597 -0
  9. package/src/components/branding/brand-logo.tsx +77 -0
  10. package/src/components/data-display/icons.tsx +81 -0
  11. package/src/components/data-display/profile-avatar.tsx +154 -0
  12. package/src/components/data-display/typography.tsx +46 -0
  13. package/src/components/feedback/empty-state.tsx +63 -0
  14. package/src/components/feedback/loading-state.tsx +93 -0
  15. package/src/components/feedback/module-skeleton.tsx +76 -0
  16. package/src/components/feedback/notification.tsx +111 -0
  17. package/src/components/feedback/skeleton.tsx +9 -0
  18. package/src/components/feedback/spinner.tsx +18 -0
  19. package/src/components/feedback/status-badge.tsx +44 -0
  20. package/src/components/feedback/sync-status-badge.tsx +54 -0
  21. package/src/components/feedback/sync-status-bar.tsx +92 -0
  22. package/src/components/feedback/toaster.tsx +36 -0
  23. package/src/components/forms/searchable-select.tsx +206 -0
  24. package/src/components/forms/select.tsx +142 -0
  25. package/src/components/layout/app-shell.tsx +44 -0
  26. package/src/components/layout/form-section.tsx +21 -0
  27. package/src/components/layout/page-header.tsx +21 -0
  28. package/src/components/layout/theme-toggle.tsx +33 -0
  29. package/src/components/navigation/breadcrumb.tsx +87 -0
  30. package/src/components/navigation/header-user-menu.tsx +108 -0
  31. package/src/components/navigation/navbar.tsx +30 -0
  32. package/src/components/navigation/page-breadcrumb.tsx +44 -0
  33. package/src/components/navigation/sidebar.tsx +104 -0
  34. package/src/components/navigation/steps.tsx +82 -0
  35. package/src/components/overlays/dialog.tsx +94 -0
  36. package/src/components/overlays/drawer.tsx +85 -0
  37. package/src/components/overlays/dropdown-menu.tsx +179 -0
  38. package/src/components/overlays/sheet.tsx +110 -0
  39. package/src/components/primitives/alert.tsx +43 -0
  40. package/src/components/primitives/avatar.tsx +41 -0
  41. package/src/components/primitives/badge.tsx +26 -0
  42. package/src/components/primitives/button.tsx +49 -0
  43. package/src/components/primitives/card.tsx +97 -0
  44. package/src/components/primitives/checkbox.tsx +52 -0
  45. package/src/components/primitives/input.tsx +23 -0
  46. package/src/components/primitives/label.tsx +18 -0
  47. package/src/components/primitives/radio-group.tsx +57 -0
  48. package/src/components/primitives/separator.tsx +23 -0
  49. package/src/components/primitives/switch.tsx +75 -0
  50. package/src/components/primitives/textarea.tsx +18 -0
  51. package/src/components/tables/data-table.tsx +214 -0
  52. package/src/components/tables/data-table.types.ts +9 -0
  53. package/src/components/tables/table-row-actions.tsx +61 -0
  54. package/src/components/tables/table.tsx +88 -0
  55. package/src/declarations.d.ts +14 -0
  56. package/src/index.ts +50 -0
  57. package/src/lib/cn.ts +6 -0
  58. package/src/providers/theme-provider.tsx +90 -0
  59. package/src/styles.css +1 -1
@@ -0,0 +1,81 @@
1
+ import { type LucideIcon } from "lucide-react";
2
+
3
+ import { cn } from "../../lib/cn";
4
+
5
+ type IconTone = "brand" | "info" | "success" | "warning" | "danger" | "neutral";
6
+ type IconSize = "sm" | "md" | "lg";
7
+
8
+ const toneStyles: Record<IconTone, string> = {
9
+ brand: "bg-[var(--color-brand)]/10 text-[var(--color-brand)]",
10
+ info: "bg-[var(--color-info-bg)] text-[var(--color-accent-blue)]",
11
+ success: "bg-[var(--color-success-bg)] text-[var(--color-success)]",
12
+ warning: "bg-[var(--color-warning-bg)] text-[var(--color-warning)]",
13
+ danger: "bg-destructive/10 text-destructive",
14
+ neutral: "bg-muted text-muted-foreground"
15
+ };
16
+
17
+ const sizeStyles: Record<IconSize, { wrapper: string; icon: string }> = {
18
+ sm: { wrapper: "h-8 w-8 rounded-lg", icon: "h-4 w-4" },
19
+ md: { wrapper: "h-10 w-10 rounded-lg", icon: "h-5 w-5" },
20
+ lg: { wrapper: "h-12 w-12 rounded-xl", icon: "h-6 w-6" }
21
+ };
22
+
23
+ interface FeatureIconProps {
24
+ className?: string;
25
+ icon: LucideIcon;
26
+ tone?: IconTone;
27
+ size?: IconSize;
28
+ iconClassName?: string;
29
+ }
30
+
31
+ function FeatureIcon({ className, icon: Icon, tone = "brand", size = "md", iconClassName }: FeatureIconProps) {
32
+ const classes = sizeStyles[size];
33
+
34
+ return (
35
+ <span className={cn("inline-flex items-center justify-center", classes.wrapper, toneStyles[tone], className)}>
36
+ <Icon className={cn(classes.icon, iconClassName)} />
37
+ </span>
38
+ );
39
+ }
40
+
41
+ interface ModuleIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
42
+ icon: LucideIcon;
43
+ label: string;
44
+ description?: string;
45
+ tone?: IconTone;
46
+ }
47
+
48
+ function ModuleIconButton({
49
+ className,
50
+ type = "button",
51
+ icon,
52
+ label,
53
+ description,
54
+ tone = "brand",
55
+ ...props
56
+ }: ModuleIconButtonProps) {
57
+ return (
58
+ <button
59
+ type={type}
60
+ className={cn(
61
+ "group flex flex-col items-center gap-3 rounded-lg border border-border bg-card p-5 text-center transition-all hover:bg-accent/30 hover:border-[var(--color-accent-blue)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
62
+ className
63
+ )}
64
+ {...props}
65
+ >
66
+ <FeatureIcon
67
+ icon={icon}
68
+ tone={tone}
69
+ size="md"
70
+ className="transition-colors group-hover:bg-[var(--color-brand)]/20"
71
+ />
72
+ <span className="grid gap-1">
73
+ <span className="text-sm font-medium text-foreground">{label}</span>
74
+ {description ? <span className="text-xs text-muted-foreground">{description}</span> : null}
75
+ </span>
76
+ </button>
77
+ );
78
+ }
79
+
80
+ export { FeatureIcon, ModuleIconButton };
81
+ export type { FeatureIconProps, ModuleIconButtonProps, IconSize, IconTone };
@@ -0,0 +1,154 @@
1
+ import { type ReactNode } from "react";
2
+
3
+ import { cn } from "../../lib/cn";
4
+ import { Avatar, AvatarFallback, AvatarImage } from "../primitives/avatar";
5
+
6
+ type ProfileAvatarSize = "xs" | "sm" | "md" | "lg";
7
+ type ProfileAvatarStatus = "online" | "away" | "offline" | "inactive";
8
+
9
+ const sizeMap: Record<ProfileAvatarSize, { avatar: string; text: string; dot: string }> = {
10
+ xs: { avatar: "h-8 w-8", text: "text-xs", dot: "h-2.5 w-2.5" },
11
+ sm: { avatar: "h-10 w-10", text: "text-sm", dot: "h-3 w-3" },
12
+ md: { avatar: "h-12 w-12", text: "text-base", dot: "h-3.5 w-3.5" },
13
+ lg: { avatar: "h-16 w-16", text: "text-lg", dot: "h-4 w-4" }
14
+ };
15
+
16
+ const statusMap: Record<ProfileAvatarStatus, string> = {
17
+ online: "bg-[var(--color-success)]",
18
+ away: "bg-[var(--color-warning)]",
19
+ offline: "bg-muted-foreground",
20
+ inactive: "bg-border"
21
+ };
22
+
23
+ function getInitials(name: string) {
24
+ return name
25
+ .trim()
26
+ .split(/\s+/)
27
+ .slice(0, 2)
28
+ .map((part) => part[0]?.toUpperCase() ?? "")
29
+ .join("");
30
+ }
31
+
32
+ interface ProfileAvatarProps {
33
+ className?: string;
34
+ avatarClassName?: string;
35
+ fallbackClassName?: string;
36
+ name: string;
37
+ src?: string;
38
+ alt?: string;
39
+ initials?: string;
40
+ size?: ProfileAvatarSize;
41
+ status?: ProfileAvatarStatus;
42
+ }
43
+
44
+ function ProfileAvatar({
45
+ className,
46
+ avatarClassName,
47
+ fallbackClassName,
48
+ name,
49
+ src,
50
+ alt,
51
+ initials,
52
+ size = "sm",
53
+ status
54
+ }: ProfileAvatarProps) {
55
+ const classes = sizeMap[size];
56
+
57
+ return (
58
+ <div className={cn("relative inline-flex", className)}>
59
+ <Avatar className={cn(classes.avatar, avatarClassName)}>
60
+ {src ? <AvatarImage src={src} alt={alt ?? name} /> : null}
61
+ <AvatarFallback className={cn("bg-[var(--color-brand)] text-white font-medium", classes.text, fallbackClassName)}>
62
+ {initials ?? getInitials(name)}
63
+ </AvatarFallback>
64
+ </Avatar>
65
+ {status ? (
66
+ <span
67
+ aria-hidden="true"
68
+ className={cn("absolute bottom-0 right-0 rounded-full border-2 border-card", classes.dot, statusMap[status])}
69
+ />
70
+ ) : null}
71
+ </div>
72
+ );
73
+ }
74
+
75
+ interface AvatarGroupUser {
76
+ id?: string;
77
+ name: string;
78
+ src?: string;
79
+ initials?: string;
80
+ status?: ProfileAvatarStatus;
81
+ }
82
+
83
+ interface AvatarGroupProps {
84
+ className?: string;
85
+ users: AvatarGroupUser[];
86
+ size?: Exclude<ProfileAvatarSize, "lg">;
87
+ limit?: number;
88
+ extraLabel?: ReactNode;
89
+ }
90
+
91
+ function AvatarGroup({ className, users, size = "xs", limit = 4, extraLabel }: AvatarGroupProps) {
92
+ const visibleUsers = users.slice(0, limit);
93
+ const hiddenCount = Math.max(0, users.length - limit);
94
+ const classes = sizeMap[size];
95
+
96
+ return (
97
+ <div className={cn("flex items-center gap-3", className)}>
98
+ <div className="flex -space-x-2">
99
+ {visibleUsers.map((user) => (
100
+ <ProfileAvatar
101
+ key={user.id ?? user.name}
102
+ name={user.name}
103
+ src={user.src}
104
+ initials={user.initials}
105
+ size={size}
106
+ status={user.status}
107
+ avatarClassName="border-2 border-card"
108
+ />
109
+ ))}
110
+ {hiddenCount > 0 ? (
111
+ <div
112
+ className={cn(
113
+ "flex items-center justify-center rounded-full border-2 border-card bg-muted text-muted-foreground font-medium",
114
+ classes.avatar,
115
+ classes.text
116
+ )}
117
+ >
118
+ +{hiddenCount}
119
+ </div>
120
+ ) : null}
121
+ </div>
122
+ {extraLabel ? <div className="text-sm text-muted-foreground">{extraLabel}</div> : null}
123
+ </div>
124
+ );
125
+ }
126
+
127
+ interface ProfileAvatarRowProps {
128
+ className?: string;
129
+ name: string;
130
+ role?: string;
131
+ src?: string;
132
+ initials?: string;
133
+ status?: ProfileAvatarStatus;
134
+ size?: ProfileAvatarSize;
135
+ aside?: ReactNode;
136
+ }
137
+
138
+ function ProfileAvatarRow({ className, name, role, src, initials, status, size = "sm", aside }: ProfileAvatarRowProps) {
139
+ return (
140
+ <div className={cn("flex items-center justify-between gap-3", className)}>
141
+ <div className="flex items-center gap-3">
142
+ <ProfileAvatar name={name} src={src} initials={initials} status={status} size={size} />
143
+ <div className="grid gap-0.5">
144
+ <p className="text-sm font-medium text-foreground">{name}</p>
145
+ {role ? <p className="text-xs text-muted-foreground">{role}</p> : null}
146
+ </div>
147
+ </div>
148
+ {aside}
149
+ </div>
150
+ );
151
+ }
152
+
153
+ export { ProfileAvatar, AvatarGroup, ProfileAvatarRow };
154
+ export type { AvatarGroupUser, ProfileAvatarProps, ProfileAvatarSize, ProfileAvatarStatus };
@@ -0,0 +1,46 @@
1
+ import { type ComponentPropsWithoutRef, type ElementType, type ReactNode } from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+
4
+ import { cn } from "../../lib/cn";
5
+
6
+ const typographyVariants = cva("text-foreground", {
7
+ variants: {
8
+ variant: {
9
+ h1: "font-display text-4xl font-bold tracking-tight",
10
+ h2: "font-display text-3xl font-bold tracking-tight",
11
+ h3: "font-display text-2xl font-semibold tracking-tight",
12
+ h4: "text-xl font-semibold",
13
+ lead: "text-lg text-muted-foreground",
14
+ body: "text-base",
15
+ small: "text-sm text-muted-foreground",
16
+ muted: "text-sm text-muted-foreground",
17
+ code: "font-mono text-sm"
18
+ }
19
+ },
20
+ defaultVariants: {
21
+ variant: "body"
22
+ }
23
+ });
24
+
25
+ type TypographyProps<T extends ElementType> = {
26
+ as?: T;
27
+ children: ReactNode;
28
+ className?: string;
29
+ } & VariantProps<typeof typographyVariants> &
30
+ Omit<ComponentPropsWithoutRef<T>, "as" | "children" | "className">;
31
+
32
+ export function Typography<T extends ElementType = "p">({
33
+ as,
34
+ className,
35
+ variant,
36
+ children,
37
+ ...props
38
+ }: TypographyProps<T>) {
39
+ const Component = as ?? "p";
40
+
41
+ return (
42
+ <Component className={cn(typographyVariants({ variant }), className)} {...props}>
43
+ {children}
44
+ </Component>
45
+ );
46
+ }
@@ -0,0 +1,63 @@
1
+ import { Inbox, type LucideIcon } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { cn } from "../../lib/cn";
5
+
6
+ interface EmptyStateProps {
7
+ className?: string;
8
+ icon?: LucideIcon;
9
+ title: string;
10
+ description?: ReactNode;
11
+ action?: ReactNode;
12
+ secondaryAction?: ReactNode;
13
+ align?: "left" | "center";
14
+ compact?: boolean;
15
+ }
16
+
17
+ export function EmptyState({
18
+ className,
19
+ icon: Icon = Inbox,
20
+ title,
21
+ description,
22
+ action,
23
+ secondaryAction,
24
+ align = "center",
25
+ compact = false
26
+ }: EmptyStateProps) {
27
+ const isCentered = align === "center";
28
+
29
+ return (
30
+ <section
31
+ className={cn(
32
+ "rounded-2xl border border-dashed bg-card text-card-foreground shadow-sm",
33
+ compact ? "p-5" : "p-8 sm:p-10",
34
+ className
35
+ )}
36
+ >
37
+ <div
38
+ className={cn(
39
+ "flex flex-col gap-5",
40
+ isCentered ? "items-center text-center" : "items-start text-left"
41
+ )}
42
+ >
43
+ <div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--color-brand)]/10 text-[var(--color-brand)]">
44
+ <Icon className="h-6 w-6" />
45
+ </div>
46
+
47
+ <div className="grid max-w-xl gap-2">
48
+ <p className="text-lg font-semibold text-foreground">{title}</p>
49
+ {description ? <div className="text-sm leading-6 text-muted-foreground">{description}</div> : null}
50
+ </div>
51
+
52
+ {action || secondaryAction ? (
53
+ <div className={cn("flex flex-wrap gap-3", isCentered && "justify-center")}>
54
+ {action}
55
+ {secondaryAction}
56
+ </div>
57
+ ) : null}
58
+ </div>
59
+ </section>
60
+ );
61
+ }
62
+
63
+ export type { EmptyStateProps };
@@ -0,0 +1,93 @@
1
+ import { type ReactNode } from "react";
2
+
3
+ import { cn } from "../../lib/cn";
4
+ import { Card, CardContent } from "../primitives/card";
5
+ import { Skeleton } from "./skeleton";
6
+ import { Spinner } from "./spinner";
7
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../tables/table";
8
+
9
+ interface LoadingStateProps {
10
+ className?: string;
11
+ title?: string;
12
+ description?: string;
13
+ size?: "sm" | "md" | "lg";
14
+ icon?: ReactNode;
15
+ }
16
+
17
+ function LoadingState({
18
+ className,
19
+ title = "Cargando datos",
20
+ description = "Estamos preparando la informacion para ti.",
21
+ size = "md",
22
+ icon
23
+ }: LoadingStateProps) {
24
+ return (
25
+ <div
26
+ role="status"
27
+ aria-live="polite"
28
+ className={cn("flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 text-foreground", className)}
29
+ >
30
+ {icon ?? <Spinner size={size} className="text-primary" />}
31
+ <div className="grid gap-0.5">
32
+ <p className="text-sm font-medium">{title}</p>
33
+ <p className="text-xs text-muted-foreground">{description}</p>
34
+ </div>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ interface LoadingCardProps {
40
+ className?: string;
41
+ rows?: number;
42
+ }
43
+
44
+ function LoadingCard({ className, rows = 3 }: LoadingCardProps) {
45
+ return (
46
+ <Card className={className}>
47
+ <CardContent className="grid gap-3 p-5">
48
+ <Skeleton className="h-6 w-40" />
49
+ {Array.from({ length: rows }).map((_, index) => (
50
+ <Skeleton key={index} className={cn("h-4", index === rows - 1 ? "w-2/3" : "w-full")} />
51
+ ))}
52
+ <Skeleton className="mt-1 h-10 w-full rounded-lg" />
53
+ </CardContent>
54
+ </Card>
55
+ );
56
+ }
57
+
58
+ interface LoadingTableRowsProps {
59
+ className?: string;
60
+ columns?: number;
61
+ rows?: number;
62
+ }
63
+
64
+ function LoadingTableRows({ className, columns = 5, rows = 4 }: LoadingTableRowsProps) {
65
+ return (
66
+ <div className={cn("rounded-xl border", className)}>
67
+ <Table>
68
+ <TableHeader>
69
+ <TableRow>
70
+ {Array.from({ length: columns }).map((_, index) => (
71
+ <TableHead key={index}>
72
+ <Skeleton className="h-4 w-20" />
73
+ </TableHead>
74
+ ))}
75
+ </TableRow>
76
+ </TableHeader>
77
+ <TableBody>
78
+ {Array.from({ length: rows }).map((_, rowIndex) => (
79
+ <TableRow key={rowIndex}>
80
+ {Array.from({ length: columns }).map((_, columnIndex) => (
81
+ <TableCell key={columnIndex}>
82
+ <Skeleton className={cn("h-4", columnIndex === 0 ? "w-24" : "w-full")} />
83
+ </TableCell>
84
+ ))}
85
+ </TableRow>
86
+ ))}
87
+ </TableBody>
88
+ </Table>
89
+ </div>
90
+ );
91
+ }
92
+
93
+ export { LoadingState, LoadingCard, LoadingTableRows };
@@ -0,0 +1,76 @@
1
+ import { Skeleton } from "./skeleton";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ type ModuleSkeletonVariant = "cards" | "table" | "form";
5
+
6
+ interface ModuleSkeletonProps {
7
+ className?: string;
8
+ variant?: ModuleSkeletonVariant;
9
+ cardCount?: number;
10
+ showHeaderAction?: boolean;
11
+ }
12
+
13
+ export function ModuleSkeleton({
14
+ className,
15
+ variant = "cards",
16
+ cardCount = 6,
17
+ showHeaderAction = true
18
+ }: ModuleSkeletonProps) {
19
+ return (
20
+ <div role="status" aria-label="Cargando modulo" className={cn("grid gap-6 p-6", className)}>
21
+ <div className="flex flex-wrap items-center justify-between gap-4">
22
+ <div className="grid gap-2">
23
+ <Skeleton className="h-8 w-52" />
24
+ <Skeleton className="h-4 w-72 max-w-full" />
25
+ </div>
26
+ {showHeaderAction ? <Skeleton className="h-10 w-36" /> : null}
27
+ </div>
28
+
29
+ {variant === "cards" ? (
30
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
31
+ {Array.from({ length: cardCount }).map((_, index) => (
32
+ <Skeleton key={index} className="h-32 w-full rounded-xl" />
33
+ ))}
34
+ </div>
35
+ ) : null}
36
+
37
+ {variant === "table" ? (
38
+ <div className="rounded-xl border bg-card p-4">
39
+ <div className="mb-4 flex flex-wrap gap-3">
40
+ <Skeleton className="h-10 w-64 max-w-full" />
41
+ <Skeleton className="h-10 w-36" />
42
+ </div>
43
+ <div className="grid gap-3">
44
+ <Skeleton className="h-10 w-full rounded-lg" />
45
+ {Array.from({ length: 6 }).map((_, index) => (
46
+ <Skeleton key={index} className="h-14 w-full rounded-lg" />
47
+ ))}
48
+ </div>
49
+ </div>
50
+ ) : null}
51
+
52
+ {variant === "form" ? (
53
+ <div className="rounded-xl border bg-card p-5">
54
+ <div className="grid gap-5 md:grid-cols-2">
55
+ {Array.from({ length: 6 }).map((_, index) => (
56
+ <div key={index} className="grid gap-2">
57
+ <Skeleton className="h-4 w-28" />
58
+ <Skeleton className="h-10 w-full" />
59
+ </div>
60
+ ))}
61
+ <div className="grid gap-2 md:col-span-2">
62
+ <Skeleton className="h-4 w-32" />
63
+ <Skeleton className="h-28 w-full rounded-xl" />
64
+ </div>
65
+ </div>
66
+ <div className="mt-6 flex flex-wrap justify-end gap-3">
67
+ <Skeleton className="h-10 w-24" />
68
+ <Skeleton className="h-10 w-36" />
69
+ </div>
70
+ </div>
71
+ ) : null}
72
+ </div>
73
+ );
74
+ }
75
+
76
+ export type { ModuleSkeletonProps, ModuleSkeletonVariant };
@@ -0,0 +1,111 @@
1
+ import { AlertCircle, AlertTriangle, CheckCircle2, Info, Loader2, type LucideIcon } from "lucide-react";
2
+ import { toast } from "sonner";
3
+ import { type ReactNode } from "react";
4
+
5
+ import { cn } from "../../lib/cn";
6
+ import { Button } from "../primitives/button";
7
+
8
+ type NotificationTone = "info" | "success" | "warning" | "danger" | "loading";
9
+
10
+ const notificationStyles: Record<NotificationTone, string> = {
11
+ info: "border-[var(--color-accent-blue)]/20 bg-[linear-gradient(135deg,rgba(59,130,246,0.14),rgba(239,246,255,0.96))] text-[var(--color-accent-blue)]",
12
+ success: "border-[var(--color-success)]/20 bg-[linear-gradient(135deg,rgba(22,163,74,0.14),rgba(220,252,231,0.96))] text-[var(--color-success)]",
13
+ warning: "border-[var(--color-warning)]/20 bg-[linear-gradient(135deg,rgba(217,119,6,0.16),rgba(255,251,235,0.96))] text-[var(--color-warning)]",
14
+ danger: "border-destructive/20 bg-[linear-gradient(135deg,rgba(220,74,65,0.16),rgba(254,242,242,0.96))] text-destructive",
15
+ loading: "border-[var(--color-accent-blue)]/20 bg-[linear-gradient(135deg,rgba(59,130,246,0.14),rgba(239,246,255,0.96))] text-[var(--color-accent-blue)]"
16
+ };
17
+
18
+ const notificationIcons: Record<NotificationTone, LucideIcon> = {
19
+ info: Info,
20
+ success: CheckCircle2,
21
+ warning: AlertTriangle,
22
+ danger: AlertCircle,
23
+ loading: Loader2
24
+ };
25
+
26
+ interface NotificationMessageProps {
27
+ className?: string;
28
+ tone?: NotificationTone;
29
+ title: string;
30
+ description?: ReactNode;
31
+ action?: ReactNode;
32
+ icon?: LucideIcon;
33
+ }
34
+
35
+ function NotificationMessage({
36
+ className,
37
+ tone = "info",
38
+ title,
39
+ description,
40
+ action,
41
+ icon
42
+ }: NotificationMessageProps) {
43
+ const Icon = icon ?? notificationIcons[tone];
44
+
45
+ return (
46
+ <div
47
+ role="status"
48
+ className={cn(
49
+ "flex items-start justify-between gap-3 rounded-xl border px-4 py-3 shadow-sm",
50
+ notificationStyles[tone],
51
+ className
52
+ )}
53
+ >
54
+ <div className="flex items-start gap-3">
55
+ <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/70 shadow-sm">
56
+ <Icon className={cn("h-4 w-4", tone === "loading" && "animate-spin")} />
57
+ </div>
58
+ <div className="grid gap-1">
59
+ <p className="text-sm font-semibold">{title}</p>
60
+ {description ? <div className="text-xs leading-5 opacity-90">{description}</div> : null}
61
+ </div>
62
+ </div>
63
+ {action ? <div className="shrink-0">{action}</div> : null}
64
+ </div>
65
+ );
66
+ }
67
+
68
+ interface NotifyPayload {
69
+ title: string;
70
+ description?: string;
71
+ actionLabel?: string;
72
+ onAction?: () => void;
73
+ duration?: number;
74
+ }
75
+
76
+ function buildToastOptions(payload: NotifyPayload) {
77
+ return {
78
+ description: payload.description,
79
+ duration: payload.duration,
80
+ action: payload.actionLabel
81
+ ? {
82
+ label: payload.actionLabel,
83
+ onClick: payload.onAction ?? (() => undefined)
84
+ }
85
+ : undefined
86
+ };
87
+ }
88
+
89
+ const notify = {
90
+ info: (payload: NotifyPayload) => toast.info(payload.title, buildToastOptions(payload)),
91
+ success: (payload: NotifyPayload) => toast.success(payload.title, buildToastOptions(payload)),
92
+ warning: (payload: NotifyPayload) => toast.warning(payload.title, buildToastOptions(payload)),
93
+ danger: (payload: NotifyPayload) => toast.error(payload.title, buildToastOptions(payload)),
94
+ error: (payload: NotifyPayload) => toast.error(payload.title, buildToastOptions(payload)),
95
+ loading: (payload: NotifyPayload) => toast.loading(payload.title, buildToastOptions(payload))
96
+ };
97
+
98
+ interface NotificationActionProps extends React.ComponentProps<typeof Button> {
99
+ children: ReactNode;
100
+ }
101
+
102
+ function NotificationAction({ children, variant = "outline", size = "sm", ...props }: NotificationActionProps) {
103
+ return (
104
+ <Button variant={variant} size={size} {...props}>
105
+ {children}
106
+ </Button>
107
+ );
108
+ }
109
+
110
+ export { NotificationMessage, NotificationAction, notify };
111
+ export type { NotificationMessageProps, NotificationTone };
@@ -0,0 +1,9 @@
1
+ import type { HTMLAttributes } from "react";
2
+
3
+ import { cn } from "../../lib/cn";
4
+
5
+ function Skeleton({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
6
+ return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
7
+ }
8
+
9
+ export { Skeleton };
@@ -0,0 +1,18 @@
1
+ import { Loader2 } from "lucide-react";
2
+
3
+ import { cn } from "../../lib/cn";
4
+
5
+ interface SpinnerProps {
6
+ className?: string;
7
+ size?: "sm" | "md" | "lg";
8
+ }
9
+
10
+ const sizeMap = {
11
+ sm: "h-4 w-4",
12
+ md: "h-5 w-5",
13
+ lg: "h-7 w-7"
14
+ } as const;
15
+
16
+ export function Spinner({ className, size = "md" }: SpinnerProps) {
17
+ return <Loader2 className={cn("animate-spin text-current", sizeMap[size], className)} />;
18
+ }