@spree/dashboard-ui 0.1.0

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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/package.json +86 -0
  4. package/src/hooks/use-copy-to-clipboard.ts +44 -0
  5. package/src/hooks/use-mobile.ts +19 -0
  6. package/src/hooks/use-scrolled.ts +30 -0
  7. package/src/index.ts +99 -0
  8. package/src/lib/date-locale.ts +29 -0
  9. package/src/lib/utils.ts +6 -0
  10. package/src/lib/validation-messages.ts +19 -0
  11. package/src/spree/address-block.tsx +74 -0
  12. package/src/spree/back-button.tsx +48 -0
  13. package/src/spree/bulk-dialog.tsx +71 -0
  14. package/src/spree/bulk-price-table.tsx +254 -0
  15. package/src/spree/calculator-summary.tsx +120 -0
  16. package/src/spree/color-picker.tsx +202 -0
  17. package/src/spree/confirm-dialog.tsx +82 -0
  18. package/src/spree/copy-to-clipboard-button.tsx +90 -0
  19. package/src/spree/country-flag.tsx +30 -0
  20. package/src/spree/data-grid/cells.tsx +415 -0
  21. package/src/spree/data-grid/context.ts +59 -0
  22. package/src/spree/data-grid/data-grid.tsx +214 -0
  23. package/src/spree/data-grid/fill-handle.tsx +153 -0
  24. package/src/spree/data-grid/helpers.ts +18 -0
  25. package/src/spree/data-grid/index.ts +5 -0
  26. package/src/spree/data-grid/types.ts +36 -0
  27. package/src/spree/data-grid/use-data-grid-keyboard.ts +254 -0
  28. package/src/spree/data-grid/use-stable-cell-registration.ts +45 -0
  29. package/src/spree/drag-handle.tsx +51 -0
  30. package/src/spree/form-actions.tsx +140 -0
  31. package/src/spree/json-preview-drawer.tsx +254 -0
  32. package/src/spree/json-value-view.tsx +149 -0
  33. package/src/spree/language-menu-items.tsx +54 -0
  34. package/src/spree/metadata/metadata-card.tsx +62 -0
  35. package/src/spree/relative-time.tsx +49 -0
  36. package/src/spree/resource-combobox.tsx +108 -0
  37. package/src/spree/resource-layout.tsx +31 -0
  38. package/src/spree/resource-multi-autocomplete.tsx +180 -0
  39. package/src/spree/resource-name-cell.tsx +44 -0
  40. package/src/spree/route-error-boundary.tsx +102 -0
  41. package/src/spree/row-actions.tsx +85 -0
  42. package/src/spree/row-click-bridge.tsx +17 -0
  43. package/src/spree/secret-input.tsx +137 -0
  44. package/src/spree/storefront-visible-switch.tsx +58 -0
  45. package/src/spree/tag-list.tsx +26 -0
  46. package/src/spree/theme-provider.tsx +83 -0
  47. package/src/spree/theme-toggle.tsx +35 -0
  48. package/src/styles.css +338 -0
  49. package/src/ui/attachment.tsx +187 -0
  50. package/src/ui/avatar.tsx +94 -0
  51. package/src/ui/badge.tsx +161 -0
  52. package/src/ui/breadcrumb.tsx +109 -0
  53. package/src/ui/button.tsx +60 -0
  54. package/src/ui/calendar.tsx +66 -0
  55. package/src/ui/card.tsx +72 -0
  56. package/src/ui/chart.tsx +94 -0
  57. package/src/ui/checkbox.tsx +50 -0
  58. package/src/ui/collapsible.tsx +18 -0
  59. package/src/ui/combobox.tsx +270 -0
  60. package/src/ui/command.tsx +172 -0
  61. package/src/ui/data-table.tsx +60 -0
  62. package/src/ui/date-picker.tsx +253 -0
  63. package/src/ui/date-range-picker.tsx +150 -0
  64. package/src/ui/dialog.tsx +214 -0
  65. package/src/ui/dropdown-menu.tsx +345 -0
  66. package/src/ui/empty.tsx +94 -0
  67. package/src/ui/field.tsx +221 -0
  68. package/src/ui/input-group.tsx +141 -0
  69. package/src/ui/input.tsx +19 -0
  70. package/src/ui/label.tsx +29 -0
  71. package/src/ui/pagination.tsx +123 -0
  72. package/src/ui/popover.tsx +76 -0
  73. package/src/ui/progress.tsx +73 -0
  74. package/src/ui/radio-group.tsx +42 -0
  75. package/src/ui/rich-text-editor.tsx +263 -0
  76. package/src/ui/select.tsx +213 -0
  77. package/src/ui/separator.tsx +31 -0
  78. package/src/ui/sheet.tsx +208 -0
  79. package/src/ui/sidebar.tsx +679 -0
  80. package/src/ui/skeleton.tsx +13 -0
  81. package/src/ui/slot.tsx +35 -0
  82. package/src/ui/sonner.tsx +47 -0
  83. package/src/ui/switch.tsx +39 -0
  84. package/src/ui/textarea.tsx +18 -0
  85. package/src/ui/tooltip.tsx +100 -0
@@ -0,0 +1,62 @@
1
+ import { BracesIcon } from 'lucide-react'
2
+ import { Fragment } from 'react'
3
+ import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'
4
+ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../../ui/empty'
5
+
6
+ interface MetadataCardProps {
7
+ /**
8
+ * Free-form metadata stored on the record. Read-only — apps and integrations
9
+ * write to this; admins just see what's there. Always an object (possibly
10
+ * empty); the API never returns `null`.
11
+ */
12
+ metadata: Record<string, unknown>
13
+ /** Translated copy (passed by the app — this component stays headless). */
14
+ title: string
15
+ emptyTitle: string
16
+ emptyDescription: string
17
+ }
18
+
19
+ export function MetadataCard({ metadata, title, emptyTitle, emptyDescription }: MetadataCardProps) {
20
+ const entries = Object.entries(metadata)
21
+
22
+ return (
23
+ <Card>
24
+ <CardHeader>
25
+ <CardTitle>{title}</CardTitle>
26
+ </CardHeader>
27
+ <CardContent>
28
+ {entries.length === 0 ? (
29
+ <Empty className="border-0 p-0">
30
+ <EmptyHeader>
31
+ <EmptyMedia variant="icon">
32
+ <BracesIcon />
33
+ </EmptyMedia>
34
+ <EmptyTitle>{emptyTitle}</EmptyTitle>
35
+ <EmptyDescription>{emptyDescription}</EmptyDescription>
36
+ </EmptyHeader>
37
+ </Empty>
38
+ ) : (
39
+ <dl className="grid grid-cols-[minmax(160px,1fr)_2fr] gap-x-4 gap-y-2 text-sm">
40
+ {entries.map(([key, value]) => (
41
+ <Fragment key={key}>
42
+ <dt>
43
+ <code className="text-xs text-muted-foreground">{key}</code>
44
+ </dt>
45
+ <dd className="text-foreground/90 break-words font-mono text-xs">
46
+ {formatValue(value)}
47
+ </dd>
48
+ </Fragment>
49
+ ))}
50
+ </dl>
51
+ )}
52
+ </CardContent>
53
+ </Card>
54
+ )
55
+ }
56
+
57
+ function formatValue(value: unknown): string {
58
+ if (value === null || value === undefined) return '—'
59
+ if (typeof value === 'string') return value
60
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
61
+ return JSON.stringify(value)
62
+ }
@@ -0,0 +1,49 @@
1
+ import { format, formatDistanceToNow } from 'date-fns'
2
+ import type { ComponentProps, ReactNode } from 'react'
3
+ import { activeDateLocale } from '../lib/date-locale'
4
+ import { cn } from '../lib/utils'
5
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
6
+
7
+ type TooltipContentProps = ComponentProps<typeof TooltipContent>
8
+
9
+ type RelativeTimeProps = {
10
+ iso: string | null | undefined
11
+ fallback?: ReactNode
12
+ prefix?: ReactNode
13
+ className?: string
14
+ side?: TooltipContentProps['side']
15
+ align?: TooltipContentProps['align']
16
+ }
17
+
18
+ export function RelativeTime({
19
+ iso,
20
+ fallback = '—',
21
+ prefix,
22
+ className,
23
+ side,
24
+ align,
25
+ }: RelativeTimeProps) {
26
+ if (!iso) return <>{fallback}</>
27
+
28
+ const date = new Date(iso)
29
+ if (Number.isNaN(date.getTime())) return <>{fallback}</>
30
+
31
+ const locale = activeDateLocale()
32
+ const relative = formatDistanceToNow(date, { addSuffix: true, locale })
33
+ const absolute = format(date, 'PPpp', { locale })
34
+
35
+ return (
36
+ <Tooltip>
37
+ <TooltipTrigger asChild>
38
+ <time dateTime={iso} className={cn('cursor-default', className)}>
39
+ {prefix}
40
+ {prefix ? ' ' : null}
41
+ {relative}
42
+ </time>
43
+ </TooltipTrigger>
44
+ <TooltipContent side={side} align={align}>
45
+ {absolute}
46
+ </TooltipContent>
47
+ </Tooltip>
48
+ )
49
+ }
@@ -0,0 +1,108 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ Combobox,
4
+ ComboboxContent,
5
+ ComboboxEmpty,
6
+ ComboboxInput,
7
+ ComboboxItem,
8
+ ComboboxList,
9
+ } from '../ui/combobox'
10
+
11
+ /**
12
+ * Anything we can pick. Records must have a stable string `id` so they round-
13
+ * trip through the `value` prop (the form/parent state keeps the ID, not the
14
+ * full record).
15
+ */
16
+ export type ComboboxOption = { id: string }
17
+
18
+ export interface ResourceComboboxProps<T extends ComboboxOption> {
19
+ /** Currently selected ID. */
20
+ value: string | undefined | null
21
+ /** Fires with the picked record's ID, or `undefined` when cleared. */
22
+ onChange: (id: string | undefined, record: T | null) => void
23
+
24
+ /**
25
+ * Records available to render in the dropdown. The caller owns fetching +
26
+ * caching; pass whatever subset of options should currently be visible.
27
+ * Must include the record matching `value` so the trigger can render its
28
+ * label, otherwise the trigger falls back to the raw ID.
29
+ */
30
+ items: T[]
31
+
32
+ /**
33
+ * Called as the user types — receives the raw input string. The caller
34
+ * typically debounces and queries with it. The combobox does NOT manage
35
+ * any internal input/query state.
36
+ */
37
+ onInputChange?: (next: string) => void
38
+
39
+ /** Renders the trigger + item label. */
40
+ getOptionLabel: (option: T) => string
41
+
42
+ /**
43
+ * Optional richer item renderer for the dropdown. Falls back to
44
+ * `getOptionLabel` when omitted.
45
+ */
46
+ renderOption?: (option: T) => ReactNode
47
+
48
+ /** Placeholder for the search input. */
49
+ placeholder?: string
50
+ /** Empty state text shown in the dropdown when items is empty. */
51
+ emptyText?: string
52
+ disabled?: boolean
53
+ }
54
+
55
+ /**
56
+ * Headless search-driven single-select picker. Doesn't fetch anything itself —
57
+ * pass `items` plus an `onInputChange` callback and wire the data layer
58
+ * outside (TanStack Query, SWR, manual state, whatever).
59
+ *
60
+ * `@spree/dashboard-core` ships a convenience `<ResourceCombobox>` (same name,
61
+ * same props shape minus `items`/`onInputChange`, plus a `search`/`hydrate`/
62
+ * `queryKey` interface) that wraps this one with TanStack Query — that's what
63
+ * most callsites use. Reach for the pure version here when the data flow
64
+ * doesn't match the wrapper's shape.
65
+ */
66
+ export function ResourceCombobox<T extends ComboboxOption>({
67
+ value,
68
+ onChange,
69
+ items,
70
+ onInputChange,
71
+ getOptionLabel,
72
+ renderOption,
73
+ placeholder,
74
+ emptyText,
75
+ disabled,
76
+ }: ResourceComboboxProps<T>) {
77
+ const selected = items.find((r) => r.id === value) ?? null
78
+
79
+ return (
80
+ <Combobox
81
+ items={items}
82
+ value={selected}
83
+ onValueChange={(record: T | null) => onChange(record?.id, record)}
84
+ onInputValueChange={onInputChange}
85
+ itemToStringLabel={(record: T | null) => (record ? getOptionLabel(record) : '')}
86
+ itemToStringValue={(record: T | null) => record?.id ?? ''}
87
+ // Filtering is server-side: the caller's `onInputChange` already narrows
88
+ // the items list. Disable Base UI's built-in client filter so it doesn't
89
+ // hide rows whose label doesn't substring-match the typed query.
90
+ filter={null}
91
+ // Mirror multi-autocomplete: `disabled` on the root also disables the
92
+ // dropdown trigger + selection mechanics, not just the input.
93
+ disabled={disabled}
94
+ >
95
+ <ComboboxInput placeholder={placeholder} disabled={disabled} showClear />
96
+ <ComboboxContent>
97
+ <ComboboxEmpty>{emptyText}</ComboboxEmpty>
98
+ <ComboboxList>
99
+ {(record: T) => (
100
+ <ComboboxItem key={record.id} value={record}>
101
+ {renderOption ? renderOption(record) : getOptionLabel(record)}
102
+ </ComboboxItem>
103
+ )}
104
+ </ComboboxList>
105
+ </ComboboxContent>
106
+ </Combobox>
107
+ )
108
+ }
@@ -0,0 +1,31 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ interface ResourceLayoutProps {
4
+ /** Rendered above the two columns. Typically <PageHeader />. */
5
+ header?: ReactNode
6
+ /** Left column content (8/12 on lg+). */
7
+ main: ReactNode
8
+ /** Right column content (4/12 on lg+). Optional — when omitted, main spans full width. */
9
+ sidebar?: ReactNode
10
+ }
11
+
12
+ /**
13
+ * Standard detail-page scaffold: header above, two columns below (8/4 on lg+,
14
+ * stacked under). Replaces the ad-hoc grid duplicated in product/order detail
15
+ * pages and the legacy `_edit_resource.html.erb` skeleton.
16
+ */
17
+ export function ResourceLayout({ header, main, sidebar }: ResourceLayoutProps) {
18
+ return (
19
+ <div className="flex flex-col gap-6">
20
+ {header}
21
+ {sidebar ? (
22
+ <div className="grid grid-cols-12 gap-6">
23
+ <div className="col-span-12 lg:col-span-8 flex flex-col gap-6">{main}</div>
24
+ <div className="col-span-12 lg:col-span-4 flex flex-col gap-6">{sidebar}</div>
25
+ </div>
26
+ ) : (
27
+ <div className="flex flex-col gap-6">{main}</div>
28
+ )}
29
+ </div>
30
+ )
31
+ }
@@ -0,0 +1,180 @@
1
+ import { useCallback } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import {
4
+ Combobox,
5
+ ComboboxChip,
6
+ ComboboxChips,
7
+ ComboboxChipsInput,
8
+ ComboboxContent,
9
+ ComboboxEmpty,
10
+ ComboboxItem,
11
+ ComboboxList,
12
+ ComboboxValue,
13
+ useComboboxAnchor,
14
+ } from '../ui/combobox'
15
+
16
+ /**
17
+ * Anything we can pick. Items must have a stable string `id`. The label
18
+ * (and any other rendering) is up to the caller via `getOptionLabel`.
19
+ */
20
+ export type AutocompleteOption = { id: string }
21
+
22
+ /**
23
+ * Safely resolve an option's label. The caller's `getOptionLabel` typically
24
+ * reads hydrated fields (e.g. `c.email`), which throw or return blank when
25
+ * called against a stub `{ id }`. Detect that case (single own key, the `id`)
26
+ * and fall back to the raw id so chips for un-hydrated selections stay
27
+ * readable until hydration lands.
28
+ */
29
+ function labelOrFallback<T extends AutocompleteOption>(
30
+ option: T,
31
+ getOptionLabel: (option: T) => string,
32
+ ): string {
33
+ const isStub = Object.keys(option).length === 1 && 'id' in option
34
+ if (isStub) return option.id
35
+ try {
36
+ return getOptionLabel(option) || option.id
37
+ } catch {
38
+ return option.id
39
+ }
40
+ }
41
+
42
+ export interface ResourceMultiAutocompleteProps<T extends AutocompleteOption> {
43
+ /** Currently selected IDs (controlled). */
44
+ value: string[]
45
+ onChange: (next: string[]) => void
46
+ /**
47
+ * Optional companion callback fired alongside `onChange` carrying the
48
+ * resolved option records for the selected IDs. Use this to stamp display
49
+ * labels on the parent state without re-fetching. IDs whose record isn't
50
+ * yet hydrated will be absent from this list.
51
+ */
52
+ onResolvedOptionsChange?: (options: T[]) => void
53
+
54
+ /**
55
+ * Records available to render in the dropdown — typically search results
56
+ * for the current query plus any selected records pulled from a cache.
57
+ * The caller owns fetching, caching, and merging.
58
+ */
59
+ items: T[]
60
+
61
+ /**
62
+ * Records matching `value` that we already have details for. Used to
63
+ * render chips with labels (and dispatch onResolvedOptionsChange with
64
+ * the right records). IDs missing from `selectedOptions` render as
65
+ * stubs (just the raw ID) until the caller hydrates them.
66
+ */
67
+ selectedOptions: T[]
68
+
69
+ /** Current text in the search input. */
70
+ inputValue: string
71
+ /** Called when the user types or selects (selection clears the input). */
72
+ onInputChange: (next: string) => void
73
+
74
+ /** Renders the chip and dropdown row label. */
75
+ getOptionLabel: (option: T) => string
76
+
77
+ /** Placeholder for the search input. */
78
+ placeholder?: string
79
+ /** Empty state text shown in the dropdown when items is empty. */
80
+ emptyText?: string
81
+ /** Override emptyText when the consumer's search is in-flight. */
82
+ fetchingText?: string
83
+ /** True while the consumer's data source is loading. */
84
+ isFetching?: boolean
85
+ /** Disable the whole picker. */
86
+ disabled?: boolean
87
+ }
88
+
89
+ /**
90
+ * Headless search-driven multi-select picker. Doesn't fetch anything — the
91
+ * caller passes `items` (visible options) + `selectedOptions` (records
92
+ * matching `value`) + `inputValue`/`onInputChange` for the typed query.
93
+ *
94
+ * `@spree/dashboard-core` ships a convenience `<ResourceMultiAutocomplete>`
95
+ * that wraps this with TanStack Query — that's what most callsites use.
96
+ * Reach for the pure version here when the data flow doesn't match the
97
+ * wrapper's shape.
98
+ */
99
+ export function ResourceMultiAutocomplete<T extends AutocompleteOption>({
100
+ value,
101
+ onChange,
102
+ onResolvedOptionsChange,
103
+ items,
104
+ selectedOptions,
105
+ inputValue,
106
+ onInputChange,
107
+ getOptionLabel,
108
+ placeholder,
109
+ emptyText,
110
+ fetchingText,
111
+ isFetching,
112
+ disabled,
113
+ }: ResourceMultiAutocompleteProps<T>) {
114
+ const { t } = useTranslation()
115
+ const resolvedPlaceholder = placeholder ?? t('admin.common.search_placeholder')
116
+ const resolvedEmptyText = emptyText ?? t('admin.common.no_results')
117
+ const resolvedFetchingText = fetchingText ?? t('admin.common.searching')
118
+ const anchorRef = useComboboxAnchor()
119
+
120
+ const handleChange = useCallback(
121
+ (next: T[]) => {
122
+ onChange(next.map((o) => o.id))
123
+ // Filter out stub `{id}` placeholders for un-hydrated IDs — callers
124
+ // typically stamp these on parent state and stubs would overwrite
125
+ // earlier real records.
126
+ onResolvedOptionsChange?.(next.filter((o) => Object.keys(o).length > 1))
127
+ onInputChange('')
128
+ },
129
+ [onChange, onResolvedOptionsChange, onInputChange],
130
+ )
131
+
132
+ void value // silence unused (value is consumed via selectedOptions)
133
+
134
+ return (
135
+ <Combobox
136
+ multiple
137
+ items={items}
138
+ value={selectedOptions}
139
+ onValueChange={handleChange as never}
140
+ itemToStringLabel={(o) => getOptionLabel(o as T)}
141
+ itemToStringValue={(o) => (o as T).id}
142
+ isItemEqualToValue={(a, b) => (a as T).id === (b as T).id}
143
+ // Filtering is server-side: the caller's data source already narrows
144
+ // the items list. Disable Base UI's client filter so it doesn't hide
145
+ // rows whose label doesn't substring-match the typed query (the typed
146
+ // query is often shorter than the actual label — partial email, etc.).
147
+ filter={null}
148
+ disabled={disabled}
149
+ >
150
+ <ComboboxChips ref={anchorRef}>
151
+ <ComboboxValue>
152
+ {(selected: T[]) =>
153
+ selected.map((opt) => (
154
+ // Stubs (un-hydrated IDs) come through with only `{ id }`. Fall
155
+ // back to the raw ID so the chip stays readable instead of going
156
+ // blank while hydration is in flight; `getOptionLabel` can also
157
+ // throw on stubs whose resolver assumes hydrated fields.
158
+ <ComboboxChip key={opt.id}>{labelOrFallback(opt, getOptionLabel)}</ComboboxChip>
159
+ ))
160
+ }
161
+ </ComboboxValue>
162
+ <ComboboxChipsInput
163
+ placeholder={resolvedPlaceholder}
164
+ value={inputValue}
165
+ onChange={(e) => onInputChange((e.target as HTMLInputElement).value)}
166
+ />
167
+ </ComboboxChips>
168
+ <ComboboxContent anchor={anchorRef}>
169
+ <ComboboxEmpty>{isFetching ? resolvedFetchingText : resolvedEmptyText}</ComboboxEmpty>
170
+ <ComboboxList>
171
+ {(option: T) => (
172
+ <ComboboxItem key={option.id} value={option}>
173
+ {getOptionLabel(option)}
174
+ </ComboboxItem>
175
+ )}
176
+ </ComboboxList>
177
+ </ComboboxContent>
178
+ </Combobox>
179
+ )
180
+ }
@@ -0,0 +1,44 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from '../lib/utils'
3
+
4
+ interface ResourceNameCellProps {
5
+ /** Resource identifier, mirrored into the bridge data attribute. */
6
+ id: string
7
+ /** Data attribute the `useRowClickBridge` listener targets (e.g. `data-payment-method-id`). */
8
+ dataAttr: `data-${string}`
9
+ /** Primary text — the resource's display name. */
10
+ name: ReactNode
11
+ /** Optional secondary line. Falsy values render nothing. */
12
+ secondary?: ReactNode
13
+ /** Extra classes for the primary `<span>` — useful for `tabular-nums` on ID/number columns. */
14
+ nameClassName?: string
15
+ }
16
+
17
+ /**
18
+ * Standard "name + secondary" cell used as the primary clickable target in
19
+ * resource index tables. The wrapping `<button>` carries the bridge data
20
+ * attribute so `useRowClickBridge` resolves the row → detail navigation.
21
+ *
22
+ * Stays purely presentational — routing is the parent table's job via the
23
+ * bridge hook, not this component's. If you need a custom layout (icon
24
+ * thumbnail, status pill inline with name, etc.), compose it inline rather
25
+ * than overloading this cell with props.
26
+ */
27
+ export function ResourceNameCell({
28
+ id,
29
+ dataAttr,
30
+ name,
31
+ secondary,
32
+ nameClassName,
33
+ }: ResourceNameCellProps) {
34
+ return (
35
+ <button
36
+ type="button"
37
+ {...{ [dataAttr]: id }}
38
+ className="flex h-full w-full cursor-pointer flex-col items-start rounded text-left focus-visible:outline-none"
39
+ >
40
+ <span className={cn('font-medium', nameClassName)}>{name}</span>
41
+ {secondary && <span className="text-xs text-muted-foreground">{secondary}</span>}
42
+ </button>
43
+ )
44
+ }
@@ -0,0 +1,102 @@
1
+ import { AlertTriangleIcon, RotateCcwIcon } from 'lucide-react'
2
+ import { Component, type ReactNode } from 'react'
3
+ import { useTranslation } from 'react-i18next'
4
+ import { Button } from '../ui/button'
5
+ import {
6
+ Empty,
7
+ EmptyContent,
8
+ EmptyDescription,
9
+ EmptyHeader,
10
+ EmptyMedia,
11
+ EmptyTitle,
12
+ } from '../ui/empty'
13
+
14
+ interface ErrorStateProps {
15
+ /** Headline. Defaults to "Something went wrong". */
16
+ title?: ReactNode
17
+ /** Smaller text below the title. Pass an Error and we'll show its message. */
18
+ description?: ReactNode
19
+ /** Error object — when supplied, its message is shown if `description` is omitted. */
20
+ error?: Error | null
21
+ /** Called by the retry button. When omitted, the button is hidden. */
22
+ onRetry?: () => void
23
+ /** Override the retry label. Default "Try again". */
24
+ retryLabel?: string
25
+ }
26
+
27
+ /**
28
+ * Friendly error display. Use directly when an async operation has failed
29
+ * (replacing the inline `<p className="text-destructive">Failed to load…</p>`
30
+ * scattered through detail pages), or as a route's `errorComponent`.
31
+ */
32
+ export function ErrorState({ title, description, error, onRetry, retryLabel }: ErrorStateProps) {
33
+ const { t } = useTranslation()
34
+ const resolvedTitle = title ?? t('admin.errors.generic')
35
+ const resolvedRetryLabel = retryLabel ?? t('admin.components.error_state.retry')
36
+ const message = description ?? error?.message ?? t('admin.errors.unexpected_retry')
37
+
38
+ return (
39
+ <Empty>
40
+ <EmptyHeader>
41
+ <EmptyMedia variant="icon">
42
+ <AlertTriangleIcon />
43
+ </EmptyMedia>
44
+ <EmptyTitle>{resolvedTitle}</EmptyTitle>
45
+ <EmptyDescription>{message}</EmptyDescription>
46
+ </EmptyHeader>
47
+ {onRetry && (
48
+ <EmptyContent>
49
+ <Button variant="outline" size="sm" onClick={onRetry}>
50
+ <RotateCcwIcon className="size-4" />
51
+ {resolvedRetryLabel}
52
+ </Button>
53
+ </EmptyContent>
54
+ )}
55
+ </Empty>
56
+ )
57
+ }
58
+
59
+ interface RouteErrorBoundaryProps {
60
+ children: ReactNode
61
+ /** Optional fallback renderer; receives the caught error and a reset function. */
62
+ fallback?: (error: Error, reset: () => void) => ReactNode
63
+ }
64
+
65
+ interface RouteErrorBoundaryState {
66
+ error: Error | null
67
+ }
68
+
69
+ /**
70
+ * Route-level error boundary. Wrap a route component (or any subtree) so a
71
+ * thrown render error shows an `<ErrorState>` instead of crashing the SPA.
72
+ *
73
+ * For TanStack Router data errors (loader/component throws), prefer the
74
+ * route's `errorComponent: ErrorState` — that surface receives `{ error, reset }`
75
+ * directly. This boundary is for cases where a child component throws during
76
+ * render outside the loader path.
77
+ */
78
+ export class RouteErrorBoundary extends Component<
79
+ RouteErrorBoundaryProps,
80
+ RouteErrorBoundaryState
81
+ > {
82
+ state: RouteErrorBoundaryState = { error: null }
83
+
84
+ static getDerivedStateFromError(error: Error): RouteErrorBoundaryState {
85
+ return { error }
86
+ }
87
+
88
+ reset = () => {
89
+ this.setState({ error: null })
90
+ }
91
+
92
+ render() {
93
+ const { error } = this.state
94
+ if (!error) return this.props.children
95
+
96
+ if (this.props.fallback) {
97
+ return this.props.fallback(error, this.reset)
98
+ }
99
+
100
+ return <ErrorState error={error} onRetry={this.reset} />
101
+ }
102
+ }
@@ -0,0 +1,85 @@
1
+ import { CopyIcon, EllipsisVerticalIcon, EyeIcon, PencilIcon, Trash2Icon } from 'lucide-react'
2
+ import type { ReactNode } from 'react'
3
+ import { useTranslation } from 'react-i18next'
4
+ import { Button } from '../ui/button'
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuTrigger,
10
+ } from '../ui/dropdown-menu'
11
+
12
+ export interface RowAction {
13
+ /** Stable key for React reconciliation. Doubles as the i18n action slug
14
+ * (`'edit' | 'clone' | 'delete' | …`) when `label` is omitted: the helper
15
+ * looks up `admin.row_actions.<key>` for built-ins, so callers don't have
16
+ * to repeat the translation key. */
17
+ key: string
18
+ /** Override the auto-resolved label for custom (non-built-in) actions. */
19
+ label?: string
20
+ /** Override the default icon — required for actions other than the
21
+ * three built-ins (edit/clone/delete). */
22
+ icon?: ReactNode
23
+ /** Skips rendering when false. Use for CanCanCan gates. */
24
+ visible?: boolean
25
+ /** Greys out the item without removing it (in-flight mutations). */
26
+ disabled?: boolean
27
+ /** Styles the item red — use for destructive actions. */
28
+ destructive?: boolean
29
+ onSelect: () => void
30
+ }
31
+
32
+ interface RowActionsProps {
33
+ /** Items in render order. Items with `visible: false` are filtered out. */
34
+ actions: RowAction[]
35
+ }
36
+
37
+ const BUILTIN_ICONS: Record<string, ReactNode> = {
38
+ edit: <PencilIcon className="size-4" />,
39
+ clone: <CopyIcon className="size-4" />,
40
+ delete: <Trash2Icon className="size-4" />,
41
+ view: <EyeIcon className="size-4" />,
42
+ }
43
+
44
+ /**
45
+ * Kebab dropdown shared by every `<ResourceTable rowActions={…}>` consumer
46
+ * (products, customers, payment methods, …). Keeps the icon, alignment, and
47
+ * a11y label consistent across the SPA.
48
+ *
49
+ * Built-in keys (`edit`, `clone`, `delete`) auto-resolve their label via
50
+ * `admin.row_actions.<key>` and pick up the matching icon, so the typical
51
+ * call site is:
52
+ *
53
+ * <RowActions actions={[
54
+ * { key: 'edit', onSelect: openEdit },
55
+ * { key: 'delete', destructive: true, onSelect: openConfirm },
56
+ * ]} />
57
+ */
58
+ export function RowActions({ actions }: RowActionsProps) {
59
+ const { t } = useTranslation()
60
+ const visible = actions.filter((a) => a.visible !== false)
61
+ if (visible.length === 0) return null
62
+
63
+ return (
64
+ <DropdownMenu>
65
+ <DropdownMenuTrigger asChild>
66
+ <Button variant="ghost" size="icon-xs" aria-label={t('admin.row_actions.menu_label')}>
67
+ <EllipsisVerticalIcon className="size-4" />
68
+ </Button>
69
+ </DropdownMenuTrigger>
70
+ <DropdownMenuContent align="end">
71
+ {visible.map((action) => (
72
+ <DropdownMenuItem
73
+ key={action.key}
74
+ onClick={action.onSelect}
75
+ disabled={action.disabled}
76
+ className={action.destructive ? 'text-destructive focus:text-destructive' : undefined}
77
+ >
78
+ {action.icon ?? BUILTIN_ICONS[action.key]}
79
+ {action.label ?? t(`admin.row_actions.${action.key}`)}
80
+ </DropdownMenuItem>
81
+ ))}
82
+ </DropdownMenuContent>
83
+ </DropdownMenu>
84
+ )
85
+ }
@@ -0,0 +1,17 @@
1
+ import { useEffect } from 'react'
2
+
3
+ export function useRowClickBridge(dataAttribute: string, onClick: (id: string) => void) {
4
+ useEffect(() => {
5
+ function handler(e: MouseEvent) {
6
+ const target = e.target as HTMLElement | null
7
+ const cell = target?.closest(`[${dataAttribute}]`) as HTMLElement | null
8
+ if (!cell) return
9
+ const id = cell.getAttribute(dataAttribute)
10
+ if (!id) return
11
+ e.preventDefault()
12
+ onClick(id)
13
+ }
14
+ document.addEventListener('click', handler)
15
+ return () => document.removeEventListener('click', handler)
16
+ }, [dataAttribute, onClick])
17
+ }