@startsimpli/ui 0.1.0 → 0.1.3

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 (80) hide show
  1. package/dist/chunk-27YUQBOE.mjs +3954 -0
  2. package/dist/chunk-27YUQBOE.mjs.map +1 -0
  3. package/dist/chunk-G2AM3DBU.mjs +1026 -0
  4. package/dist/chunk-G2AM3DBU.mjs.map +1 -0
  5. package/dist/chunk-G4XBXCFH.mjs +63 -0
  6. package/dist/chunk-G4XBXCFH.mjs.map +1 -0
  7. package/dist/chunk-LZOMFHX3.mjs +35 -0
  8. package/dist/chunk-LZOMFHX3.mjs.map +1 -0
  9. package/dist/chunk-QYXFLOO7.mjs +210 -0
  10. package/dist/chunk-QYXFLOO7.mjs.map +1 -0
  11. package/dist/components/index.d.mts +472 -0
  12. package/dist/components/index.d.ts +472 -0
  13. package/dist/components/index.js +5149 -0
  14. package/dist/components/index.js.map +1 -0
  15. package/dist/components/index.mjs +6 -0
  16. package/dist/components/index.mjs.map +1 -0
  17. package/dist/components/unified-table/index.d.mts +725 -0
  18. package/dist/components/unified-table/index.d.ts +725 -0
  19. package/dist/components/unified-table/index.js +4000 -0
  20. package/dist/components/unified-table/index.js.map +1 -0
  21. package/dist/components/unified-table/index.mjs +5 -0
  22. package/dist/components/unified-table/index.mjs.map +1 -0
  23. package/dist/index.d.mts +26 -0
  24. package/dist/index.d.ts +26 -0
  25. package/dist/index.js +5448 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/index.mjs +12 -0
  28. package/dist/index.mjs.map +1 -0
  29. package/dist/theme/index.d.mts +20 -0
  30. package/dist/theme/index.d.ts +20 -0
  31. package/dist/theme/index.js +245 -0
  32. package/dist/theme/index.js.map +1 -0
  33. package/dist/theme/index.mjs +9 -0
  34. package/dist/theme/index.mjs.map +1 -0
  35. package/dist/utils/index.d.mts +38 -0
  36. package/dist/utils/index.d.ts +38 -0
  37. package/dist/utils/index.js +72 -0
  38. package/dist/utils/index.js.map +1 -0
  39. package/dist/utils/index.mjs +4 -0
  40. package/dist/utils/index.mjs.map +1 -0
  41. package/package.json +62 -21
  42. package/src/__mocks__/next/navigation.js +18 -0
  43. package/src/components/__tests__/safe-html.test.tsx +45 -0
  44. package/src/components/__tests__/states.test.tsx +94 -0
  45. package/src/components/__tests__/status-badge.test.tsx +101 -0
  46. package/src/components/__tests__/toast.test.tsx +124 -0
  47. package/src/components/badge/StatusBadge.tsx +55 -0
  48. package/src/components/badge/index.ts +2 -0
  49. package/src/components/dialog/BaseDialog.tsx +184 -0
  50. package/src/components/dialog/index.ts +8 -0
  51. package/src/components/index.ts +25 -0
  52. package/src/components/loading/DashboardSkeleton.tsx +27 -0
  53. package/src/components/loading/TableSkeleton.tsx +63 -0
  54. package/src/components/loading/index.ts +4 -0
  55. package/src/components/safe-html.tsx +18 -0
  56. package/src/components/states/EmptyState.tsx +48 -0
  57. package/src/components/states/ErrorState.tsx +76 -0
  58. package/src/components/states/index.ts +4 -0
  59. package/src/components/toast/Toaster.tsx +72 -0
  60. package/src/components/toast/index.ts +5 -0
  61. package/src/components/toast/use-notify.ts +45 -0
  62. package/src/components/toast/use-toast.ts +150 -0
  63. package/src/components/ui/api-error-boundary.tsx +64 -0
  64. package/src/components/ui/feature-gate.tsx +87 -0
  65. package/src/components/ui/index.ts +4 -0
  66. package/src/components/ui/page-loader.tsx +31 -0
  67. package/src/components/ui/query-provider.tsx +30 -0
  68. package/src/components/unified-table/components/Toolbar/StandardTableToolbar.tsx +1 -1
  69. package/src/components/unified-table/hooks/useFilters.ts +1 -0
  70. package/src/components/unified-table/hooks/usePagination.ts +1 -0
  71. package/src/components/unified-table/hooks/useSelection.ts +2 -1
  72. package/src/components/unified-table/hooks/useTableKeyboard.ts +2 -1
  73. package/src/components/unified-table/hooks/useTablePreferences.ts +1 -0
  74. package/src/components/unified-table/hooks/useTableState.ts +1 -0
  75. package/src/components/unified-table/hooks/useTableURL.test.tsx +1 -1
  76. package/src/components/unified-table/index.ts +4 -0
  77. package/src/components/wizard/StepIndicator.tsx +60 -0
  78. package/src/components/wizard/index.ts +2 -0
  79. package/src/theme/tailwind.config.d.ts +3 -0
  80. package/tailwind.preset.js +87 -0
@@ -0,0 +1,27 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+
4
+ export interface DashboardSkeletonProps {
5
+ cards?: number
6
+ className?: string
7
+ }
8
+
9
+ export function DashboardSkeleton({
10
+ cards = 4,
11
+ className,
12
+ }: DashboardSkeletonProps) {
13
+ return (
14
+ <div className={cn('grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4', className)}>
15
+ {Array.from({ length: cards }).map((_, i) => (
16
+ <div key={i} className="rounded-lg border bg-background p-6">
17
+ <div className="flex items-center justify-between mb-4">
18
+ <div className="h-4 w-24 animate-pulse rounded-md bg-gray-200" />
19
+ <div className="h-8 w-8 animate-pulse rounded-md bg-gray-200" />
20
+ </div>
21
+ <div className="h-8 w-20 animate-pulse rounded-md bg-gray-200 mb-2" />
22
+ <div className="h-3 w-32 animate-pulse rounded-md bg-gray-200" />
23
+ </div>
24
+ ))}
25
+ </div>
26
+ )
27
+ }
@@ -0,0 +1,63 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+
4
+ export interface TableSkeletonProps {
5
+ rows?: number
6
+ columns?: number
7
+ className?: string
8
+ }
9
+
10
+ export function TableSkeleton({
11
+ rows = 5,
12
+ columns = 4,
13
+ className,
14
+ }: TableSkeletonProps) {
15
+ return (
16
+ <div className={cn('bg-background rounded-lg border', className)}>
17
+ {/* Header skeleton */}
18
+ <div className="p-4 border-b">
19
+ <div className="flex justify-between items-center gap-4">
20
+ <div className="h-10 w-full max-w-sm animate-pulse rounded-md bg-gray-200" />
21
+ <div className="flex gap-2">
22
+ <div className="h-10 w-24 animate-pulse rounded-md bg-gray-200" />
23
+ </div>
24
+ </div>
25
+ </div>
26
+
27
+ {/* Table body skeleton */}
28
+ <div className="overflow-x-auto">
29
+ <table className="w-full">
30
+ <thead className="border-b bg-muted/50">
31
+ <tr>
32
+ {Array.from({ length: columns }).map((_, i) => (
33
+ <th key={i} className="p-4 text-left">
34
+ <div className="h-4 w-24 animate-pulse rounded-md bg-gray-200" />
35
+ </th>
36
+ ))}
37
+ </tr>
38
+ </thead>
39
+ <tbody>
40
+ {Array.from({ length: rows }).map((_, rowIndex) => (
41
+ <tr key={rowIndex} className="border-b last:border-b-0">
42
+ {Array.from({ length: columns }).map((_, colIndex) => (
43
+ <td key={colIndex} className="p-4">
44
+ <div className="h-4 w-full max-w-[200px] animate-pulse rounded-md bg-gray-200" />
45
+ </td>
46
+ ))}
47
+ </tr>
48
+ ))}
49
+ </tbody>
50
+ </table>
51
+ </div>
52
+
53
+ {/* Pagination skeleton */}
54
+ <div className="p-4 border-t flex justify-between items-center">
55
+ <div className="h-4 w-40 animate-pulse rounded-md bg-gray-200" />
56
+ <div className="flex gap-2">
57
+ <div className="h-9 w-20 animate-pulse rounded-md bg-gray-200" />
58
+ <div className="h-9 w-20 animate-pulse rounded-md bg-gray-200" />
59
+ </div>
60
+ </div>
61
+ </div>
62
+ )
63
+ }
@@ -0,0 +1,4 @@
1
+ export { TableSkeleton } from './TableSkeleton'
2
+ export type { TableSkeletonProps } from './TableSkeleton'
3
+ export { DashboardSkeleton } from './DashboardSkeleton'
4
+ export type { DashboardSkeletonProps } from './DashboardSkeleton'
@@ -0,0 +1,18 @@
1
+ import DOMPurify from 'isomorphic-dompurify';
2
+ import { type ElementType, type HTMLAttributes } from 'react';
3
+
4
+ interface SafeHtmlProps extends HTMLAttributes<HTMLElement> {
5
+ html: string;
6
+ as?: ElementType;
7
+ }
8
+
9
+ /**
10
+ * Renders user-supplied or external HTML safely by running it through DOMPurify.
11
+ * Strips script tags, event handlers, javascript: URLs, and other XSS vectors.
12
+ * Use this instead of dangerouslySetInnerHTML anywhere app content is rendered.
13
+ */
14
+ export function SafeHtml({ html, as: Tag = 'div', ...props }: SafeHtmlProps) {
15
+ const sanitized = DOMPurify.sanitize(html, { ALLOW_DATA_ATTR: false });
16
+ // eslint-disable-next-line react/no-danger
17
+ return <Tag {...props} dangerouslySetInnerHTML={{ __html: sanitized }} />;
18
+ }
@@ -0,0 +1,48 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+
4
+ export interface EmptyStateAction {
5
+ label: string
6
+ onClick: () => void
7
+ }
8
+
9
+ export interface EmptyStateProps {
10
+ title: string
11
+ description?: string
12
+ action?: EmptyStateAction
13
+ className?: string
14
+ }
15
+
16
+ export function EmptyState({
17
+ title,
18
+ description,
19
+ action,
20
+ className,
21
+ }: EmptyStateProps) {
22
+ return (
23
+ <div
24
+ className={cn(
25
+ 'flex flex-col items-center justify-center py-12 px-4 text-center',
26
+ className
27
+ )}
28
+ role="status"
29
+ aria-live="polite"
30
+ >
31
+ <h3 className="text-xl font-semibold text-foreground mb-3">{title}</h3>
32
+ {description && (
33
+ <p className="text-base text-muted-foreground mb-8 max-w-md leading-relaxed">
34
+ {description}
35
+ </p>
36
+ )}
37
+ {action && (
38
+ <button
39
+ onClick={action.onClick}
40
+ className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2 transition-colors"
41
+ aria-label={action.label}
42
+ >
43
+ {action.label}
44
+ </button>
45
+ )}
46
+ </div>
47
+ )
48
+ }
@@ -0,0 +1,76 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+
4
+ export interface ErrorStateProps {
5
+ title?: string
6
+ message: string
7
+ onRetry?: () => void
8
+ className?: string
9
+ }
10
+
11
+ export function ErrorState({
12
+ title = 'Something went wrong',
13
+ message,
14
+ onRetry,
15
+ className,
16
+ }: ErrorStateProps) {
17
+ return (
18
+ <div
19
+ className={cn(
20
+ 'flex flex-col items-center justify-center py-12 px-4 text-center',
21
+ className
22
+ )}
23
+ role="alert"
24
+ aria-live="assertive"
25
+ >
26
+ <div
27
+ className="w-16 h-16 mb-4 rounded-full bg-red-50 flex items-center justify-center"
28
+ aria-hidden="true"
29
+ >
30
+ <svg
31
+ xmlns="http://www.w3.org/2000/svg"
32
+ width="32"
33
+ height="32"
34
+ viewBox="0 0 24 24"
35
+ fill="none"
36
+ stroke="currentColor"
37
+ strokeWidth="2"
38
+ strokeLinecap="round"
39
+ strokeLinejoin="round"
40
+ className="text-red-500"
41
+ >
42
+ <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
43
+ <line x1="12" y1="9" x2="12" y2="13" />
44
+ <line x1="12" y1="17" x2="12.01" y2="17" />
45
+ </svg>
46
+ </div>
47
+ <h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
48
+ <p className="text-sm text-muted-foreground mb-6 max-w-sm">{message}</p>
49
+ {onRetry && (
50
+ <button
51
+ onClick={onRetry}
52
+ className="inline-flex items-center justify-center rounded-md text-sm font-medium border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground h-9 px-4 py-2 transition-colors"
53
+ aria-label="Retry loading content"
54
+ >
55
+ <svg
56
+ xmlns="http://www.w3.org/2000/svg"
57
+ width="16"
58
+ height="16"
59
+ viewBox="0 0 24 24"
60
+ fill="none"
61
+ stroke="currentColor"
62
+ strokeWidth="2"
63
+ strokeLinecap="round"
64
+ strokeLinejoin="round"
65
+ className="mr-2"
66
+ aria-hidden="true"
67
+ >
68
+ <path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
69
+ <path d="M21 3v5h-5" />
70
+ </svg>
71
+ Try Again
72
+ </button>
73
+ )}
74
+ </div>
75
+ )
76
+ }
@@ -0,0 +1,4 @@
1
+ export { ErrorState } from './ErrorState'
2
+ export type { ErrorStateProps } from './ErrorState'
3
+ export { EmptyState } from './EmptyState'
4
+ export type { EmptyStateProps, EmptyStateAction } from './EmptyState'
@@ -0,0 +1,72 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../lib/utils'
5
+ import { useToast, type ToastVariant } from './use-toast'
6
+
7
+ const variantClasses: Record<ToastVariant, string> = {
8
+ default: 'bg-background border-border text-foreground',
9
+ destructive: 'bg-destructive border-destructive text-destructive-foreground',
10
+ success: 'bg-green-50 border-green-200 text-green-900',
11
+ warning: 'bg-yellow-50 border-yellow-200 text-yellow-900',
12
+ info: 'bg-blue-50 border-blue-200 text-blue-900',
13
+ }
14
+
15
+ export function Toaster() {
16
+ const { toasts, dismiss } = useToast()
17
+
18
+ if (toasts.length === 0) return null
19
+
20
+ return (
21
+ <div
22
+ className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-full max-w-sm"
23
+ role="region"
24
+ aria-label="Notifications"
25
+ >
26
+ {toasts.map((t) => (
27
+ <div
28
+ key={t.id}
29
+ className={cn(
30
+ 'rounded-lg border p-4 shadow-lg transition-all animate-in slide-in-from-bottom-2 fade-in-0',
31
+ variantClasses[t.variant ?? 'default']
32
+ )}
33
+ role="status"
34
+ aria-live="polite"
35
+ >
36
+ <div className="flex items-start justify-between gap-2">
37
+ <div className="flex-1">
38
+ {t.title && (
39
+ <div className="text-sm font-semibold">{t.title}</div>
40
+ )}
41
+ {t.description && (
42
+ <div className="text-sm opacity-90 mt-1">{t.description}</div>
43
+ )}
44
+ </div>
45
+ <button
46
+ onClick={() => dismiss(t.id)}
47
+ className="shrink-0 rounded-sm opacity-70 hover:opacity-100 transition-opacity"
48
+ aria-label="Dismiss notification"
49
+ >
50
+ <svg
51
+ xmlns="http://www.w3.org/2000/svg"
52
+ width="16"
53
+ height="16"
54
+ viewBox="0 0 24 24"
55
+ fill="none"
56
+ stroke="currentColor"
57
+ strokeWidth="2"
58
+ strokeLinecap="round"
59
+ strokeLinejoin="round"
60
+ aria-hidden="true"
61
+ >
62
+ <line x1="18" y1="6" x2="6" y2="18" />
63
+ <line x1="6" y1="6" x2="18" y2="18" />
64
+ </svg>
65
+ </button>
66
+ </div>
67
+ {t.action && <div className="mt-2">{t.action}</div>}
68
+ </div>
69
+ ))}
70
+ </div>
71
+ )
72
+ }
@@ -0,0 +1,5 @@
1
+ export { useToast, toast, clearAllToasts } from './use-toast'
2
+ export type { ToasterToast, ToastVariant } from './use-toast'
3
+ export { Toaster } from './Toaster'
4
+ export { useNotify, notify } from './use-notify'
5
+ export type { NotifyOptions } from './use-notify'
@@ -0,0 +1,45 @@
1
+ import { toast } from './use-toast'
2
+
3
+ export interface NotifyOptions {
4
+ title?: string
5
+ description?: string
6
+ duration?: number
7
+ }
8
+
9
+ function success(messageOrOptions: string | NotifyOptions) {
10
+ const opts =
11
+ typeof messageOrOptions === 'string'
12
+ ? { title: messageOrOptions }
13
+ : messageOrOptions
14
+ return toast({ variant: 'success', ...opts })
15
+ }
16
+
17
+ function error(messageOrOptions: string | NotifyOptions) {
18
+ const opts =
19
+ typeof messageOrOptions === 'string'
20
+ ? { title: messageOrOptions }
21
+ : messageOrOptions
22
+ return toast({ variant: 'destructive', ...opts })
23
+ }
24
+
25
+ function warning(messageOrOptions: string | NotifyOptions) {
26
+ const opts =
27
+ typeof messageOrOptions === 'string'
28
+ ? { title: messageOrOptions }
29
+ : messageOrOptions
30
+ return toast({ variant: 'warning', ...opts })
31
+ }
32
+
33
+ function info(messageOrOptions: string | NotifyOptions) {
34
+ const opts =
35
+ typeof messageOrOptions === 'string'
36
+ ? { title: messageOrOptions }
37
+ : messageOrOptions
38
+ return toast({ variant: 'info', ...opts })
39
+ }
40
+
41
+ export const notify = { success, error, warning, info }
42
+
43
+ export function useNotify() {
44
+ return notify
45
+ }
@@ -0,0 +1,150 @@
1
+ import * as React from 'react'
2
+
3
+ const TOAST_LIMIT = 5
4
+ const TOAST_AUTO_DISMISS = 5000
5
+
6
+ export type ToastVariant = 'default' | 'destructive' | 'success' | 'warning' | 'info'
7
+
8
+ export interface ToasterToast {
9
+ id: string
10
+ title?: string
11
+ description?: string
12
+ action?: React.ReactNode
13
+ variant?: ToastVariant
14
+ duration?: number
15
+ }
16
+
17
+ type Action =
18
+ | { type: 'ADD_TOAST'; toast: ToasterToast }
19
+ | { type: 'UPDATE_TOAST'; toast: Partial<ToasterToast> & { id: string } }
20
+ | { type: 'DISMISS_TOAST'; toastId?: string }
21
+ | { type: 'REMOVE_TOAST'; toastId?: string }
22
+
23
+ interface State {
24
+ toasts: ToasterToast[]
25
+ }
26
+
27
+ const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
28
+
29
+ function addToRemoveQueue(toastId: string, delay: number) {
30
+ if (toastTimeouts.has(toastId)) {
31
+ return
32
+ }
33
+
34
+ const timeout = setTimeout(() => {
35
+ toastTimeouts.delete(toastId)
36
+ dispatch({ type: 'REMOVE_TOAST', toastId })
37
+ }, delay)
38
+
39
+ toastTimeouts.set(toastId, timeout)
40
+ }
41
+
42
+ export function reducer(state: State, action: Action): State {
43
+ switch (action.type) {
44
+ case 'ADD_TOAST':
45
+ return {
46
+ ...state,
47
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
48
+ }
49
+
50
+ case 'UPDATE_TOAST':
51
+ return {
52
+ ...state,
53
+ toasts: state.toasts.map((t) =>
54
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
55
+ ),
56
+ }
57
+
58
+ case 'DISMISS_TOAST': {
59
+ const { toastId } = action
60
+
61
+ if (toastId) {
62
+ addToRemoveQueue(toastId, 300)
63
+ } else {
64
+ state.toasts.forEach((t) => addToRemoveQueue(t.id, 300))
65
+ }
66
+
67
+ return state
68
+ }
69
+
70
+ case 'REMOVE_TOAST':
71
+ if (action.toastId === undefined) {
72
+ return { ...state, toasts: [] }
73
+ }
74
+ return {
75
+ ...state,
76
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
77
+ }
78
+ }
79
+ }
80
+
81
+ const listeners: Array<(state: State) => void> = []
82
+
83
+ let memoryState: State = { toasts: [] }
84
+
85
+ function dispatch(action: Action) {
86
+ memoryState = reducer(memoryState, action)
87
+ listeners.forEach((listener) => listener(memoryState))
88
+ }
89
+
90
+ let count = 0
91
+
92
+ function genId() {
93
+ count = (count + 1) % Number.MAX_VALUE
94
+ return count.toString()
95
+ }
96
+
97
+ type ToastInput = Omit<ToasterToast, 'id'>
98
+
99
+ function toast(props: ToastInput) {
100
+ const id = genId()
101
+ const duration = props.duration ?? TOAST_AUTO_DISMISS
102
+
103
+ dispatch({
104
+ type: 'ADD_TOAST',
105
+ toast: { ...props, id },
106
+ })
107
+
108
+ // Auto-dismiss
109
+ if (duration > 0) {
110
+ addToRemoveQueue(id, duration)
111
+ }
112
+
113
+ const update = (updateProps: Partial<ToasterToast>) =>
114
+ dispatch({ type: 'UPDATE_TOAST', toast: { ...updateProps, id } })
115
+
116
+ const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
117
+
118
+ return { id, dismiss, update }
119
+ }
120
+
121
+ function clearAllToasts() {
122
+ // Clear all pending timeouts
123
+ toastTimeouts.forEach((timeout) => clearTimeout(timeout))
124
+ toastTimeouts.clear()
125
+ dispatch({ type: 'REMOVE_TOAST', toastId: undefined })
126
+ }
127
+
128
+ function useToast() {
129
+ const [state, setState] = React.useState<State>(memoryState)
130
+
131
+ React.useEffect(() => {
132
+ listeners.push(setState)
133
+ return () => {
134
+ const index = listeners.indexOf(setState)
135
+ if (index > -1) {
136
+ listeners.splice(index, 1)
137
+ }
138
+ }
139
+ }, [state])
140
+
141
+ return {
142
+ ...state,
143
+ toast,
144
+ dismiss: (toastId?: string) =>
145
+ dispatch({ type: 'DISMISS_TOAST', toastId }),
146
+ clear: clearAllToasts,
147
+ }
148
+ }
149
+
150
+ export { useToast, toast, clearAllToasts }
@@ -0,0 +1,64 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { ErrorState } from "../states/ErrorState"
5
+
6
+ interface ApiErrorBoundaryState {
7
+ hasError: boolean
8
+ error: Error | null
9
+ }
10
+
11
+ interface ApiErrorBoundaryProps {
12
+ children: React.ReactNode
13
+ /** Custom fallback to render on error. Receives error and reset function. */
14
+ fallback?: (props: { error: Error; reset: () => void }) => React.ReactNode
15
+ /** Called when the error boundary catches an error. */
16
+ onError?: (error: Error, info: React.ErrorInfo) => void
17
+ }
18
+
19
+ /**
20
+ * Error boundary for API-driven page content.
21
+ *
22
+ * Catches unhandled errors from children and renders ErrorState with a retry button.
23
+ *
24
+ * @example
25
+ * <ApiErrorBoundary>
26
+ * <InvestorsTable />
27
+ * </ApiErrorBoundary>
28
+ */
29
+ export class ApiErrorBoundary extends React.Component<
30
+ ApiErrorBoundaryProps,
31
+ ApiErrorBoundaryState
32
+ > {
33
+ constructor(props: ApiErrorBoundaryProps) {
34
+ super(props)
35
+ this.state = { hasError: false, error: null }
36
+ }
37
+
38
+ static getDerivedStateFromError(error: Error): ApiErrorBoundaryState {
39
+ return { hasError: true, error }
40
+ }
41
+
42
+ componentDidCatch(error: Error, info: React.ErrorInfo) {
43
+ this.props.onError?.(error, info)
44
+ }
45
+
46
+ reset = () => {
47
+ this.setState({ hasError: false, error: null })
48
+ }
49
+
50
+ render() {
51
+ if (this.state.hasError && this.state.error) {
52
+ if (this.props.fallback) {
53
+ return this.props.fallback({ error: this.state.error, reset: this.reset })
54
+ }
55
+ return (
56
+ <ErrorState
57
+ message={this.state.error.message || "An unexpected error occurred"}
58
+ onRetry={this.reset}
59
+ />
60
+ )
61
+ }
62
+ return this.props.children
63
+ }
64
+ }
@@ -0,0 +1,87 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"
5
+ import { cn } from "../../lib/utils"
6
+
7
+ export interface FeatureGateProps {
8
+ /** Whether the feature is available. When false, applies mode behavior. */
9
+ enabled: boolean
10
+ /**
11
+ * How to render when enabled=false:
12
+ * - 'disable' (default): renders children as disabled with a tooltip
13
+ * - 'hide': renders nothing
14
+ * - 'badge': renders children with a 'Coming soon' badge overlay
15
+ */
16
+ mode?: "disable" | "hide" | "badge"
17
+ /** Tooltip text shown in 'disable' mode. Defaults to 'Coming soon'. */
18
+ tooltip?: string
19
+ children: React.ReactNode
20
+ className?: string
21
+ }
22
+
23
+ /**
24
+ * Gates a UI element behind a feature flag.
25
+ *
26
+ * Use this to wrap any button, link, or UI section that is not yet implemented.
27
+ * This prevents shipping clickable elements with no handlers.
28
+ *
29
+ * @example
30
+ * // Disable a button with a tooltip
31
+ * <FeatureGate enabled={false}>
32
+ * <Button onClick={handleReply}>Reply</Button>
33
+ * </FeatureGate>
34
+ *
35
+ * @example
36
+ * // Hide completely
37
+ * <FeatureGate enabled={false} mode="hide">
38
+ * <Button>Archive</Button>
39
+ * </FeatureGate>
40
+ */
41
+ export function FeatureGate({
42
+ enabled,
43
+ mode = "disable",
44
+ tooltip = "Coming soon",
45
+ children,
46
+ className,
47
+ }: FeatureGateProps) {
48
+ if (enabled) {
49
+ return <>{children}</>
50
+ }
51
+
52
+ if (mode === "hide") {
53
+ return null
54
+ }
55
+
56
+ if (mode === "badge") {
57
+ return (
58
+ <div className={cn("relative inline-flex", className)}>
59
+ <div className="opacity-60 pointer-events-none select-none">{children}</div>
60
+ <span className="absolute -top-1.5 -right-1.5 rounded-full bg-muted text-muted-foreground text-[10px] font-medium px-1.5 py-0.5 leading-none border">
61
+ Soon
62
+ </span>
63
+ </div>
64
+ )
65
+ }
66
+
67
+ // mode === 'disable' (default): wrap in tooltip, disable interaction
68
+ return (
69
+ <TooltipProvider>
70
+ <Tooltip>
71
+ <TooltipTrigger asChild>
72
+ <span
73
+ className={cn("inline-flex cursor-not-allowed", className)}
74
+ aria-disabled="true"
75
+ >
76
+ <span className="pointer-events-none opacity-50 select-none">
77
+ {children}
78
+ </span>
79
+ </span>
80
+ </TooltipTrigger>
81
+ <TooltipContent>
82
+ <p>{tooltip}</p>
83
+ </TooltipContent>
84
+ </Tooltip>
85
+ </TooltipProvider>
86
+ )
87
+ }
@@ -22,3 +22,7 @@ export * from './table'
22
22
  export * from './tabs'
23
23
  export * from './textarea'
24
24
  export * from './tooltip'
25
+ export * from './feature-gate'
26
+ export * from './api-error-boundary'
27
+ export * from './page-loader'
28
+ export * from './query-provider'