@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,45 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { useDataGridContext } from './context'
3
+ import type { CellCoords } from './types'
4
+
5
+ interface CellHandlers {
6
+ focus: () => void
7
+ read: () => string
8
+ write: (value: string) => void
9
+ canWrite: (value: string) => boolean
10
+ getElement?: () => HTMLElement | null
11
+ }
12
+
13
+ /**
14
+ * Registers a cell with the data grid exactly once per `(row, col)` slot,
15
+ * forwarding read/write/focus through a ref so per-render closure changes
16
+ * don't re-trigger registration. Without this, every Controller-driven
17
+ * value change would unregister + re-register the cell, which (a) churns
18
+ * the bounds calculation and (b) lands us in setState-during-render loops
19
+ * when the registration effect tries to update grid state.
20
+ *
21
+ * Also reads `registerCell` from the context via a ref so the effect's deps
22
+ * stay limited to coords — the context object itself changes identity on
23
+ * every selection-state update, but `registerCell` is stable beneath it.
24
+ */
25
+ export function useStableCellRegistration(coords: CellCoords, handlers: CellHandlers) {
26
+ const ctx = useDataGridContext()
27
+ const handlersRef = useRef(handlers)
28
+ handlersRef.current = handlers
29
+ const registerRef = useRef(ctx.registerCell)
30
+ registerRef.current = ctx.registerCell
31
+ // Destructure to primitives so the effect can list them as deps without
32
+ // pulling in the unstable `coords` object reference from the caller.
33
+ const { row, col } = coords
34
+
35
+ useEffect(() => {
36
+ return registerRef.current({
37
+ coords: { row, col },
38
+ focus: () => handlersRef.current.focus(),
39
+ read: () => handlersRef.current.read(),
40
+ write: (v) => handlersRef.current.write(v),
41
+ canWrite: (v) => handlersRef.current.canWrite(v),
42
+ getElement: () => handlersRef.current.getElement?.() ?? null,
43
+ })
44
+ }, [row, col])
45
+ }
@@ -0,0 +1,51 @@
1
+ import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core'
2
+ import { GripVerticalIcon } from 'lucide-react'
3
+ import type { ComponentProps } from 'react'
4
+ import { useTranslation } from 'react-i18next'
5
+ import { cn } from '../lib/utils'
6
+
7
+ interface DragHandleProps extends Omit<ComponentProps<'button'>, 'type'> {
8
+ /** `attributes` from `useSortable` / `useDraggable`. */
9
+ attributes: DraggableAttributes
10
+ /** `listeners` from `useSortable` / `useDraggable`. */
11
+ listeners: DraggableSyntheticListeners
12
+ }
13
+
14
+ /**
15
+ * Standard drag handle for `useSortable` rows. Pair with a `useSortable` row
16
+ * by spreading its `attributes` + `listeners` onto this component:
17
+ *
18
+ * ```tsx
19
+ * const { attributes, listeners, setNodeRef, ... } = useSortable({ id })
20
+ * return (
21
+ * <tr ref={setNodeRef}>
22
+ * <td className="w-8 touch-none p-0">
23
+ * <DragHandle attributes={attributes} listeners={listeners} />
24
+ * </td>
25
+ * ...
26
+ * </tr>
27
+ * )
28
+ * ```
29
+ *
30
+ * The handle owns its own cursor styling (`cursor-grab` / `active:cursor-grabbing`),
31
+ * hover affordance, and accessible label — the caller only needs to size the
32
+ * surrounding cell (typically `w-8 touch-none p-0`).
33
+ */
34
+ export function DragHandle({ attributes, listeners, className, ...props }: DragHandleProps) {
35
+ const { t } = useTranslation()
36
+ return (
37
+ <button
38
+ type="button"
39
+ aria-label={t('admin.a11y.drag_to_reorder')}
40
+ className={cn(
41
+ 'flex h-full w-full items-center justify-center rounded-lg p-2 text-muted-foreground cursor-grab active:cursor-grabbing hover:bg-accent',
42
+ className,
43
+ )}
44
+ {...attributes}
45
+ {...listeners}
46
+ {...props}
47
+ >
48
+ <GripVerticalIcon className="size-4" />
49
+ </button>
50
+ )
51
+ }
@@ -0,0 +1,140 @@
1
+ import { useHotkey } from '@tanstack/react-hotkeys'
2
+ import { Loader2Icon } from 'lucide-react'
3
+ import { useEffect } from 'react'
4
+ import { type FieldValues, type UseFormReturn, useFormState } from 'react-hook-form'
5
+ import { useTranslation } from 'react-i18next'
6
+ import { Button } from '../ui/button'
7
+
8
+ /**
9
+ * Submit button wired to a `react-hook-form` instance. Always rendered,
10
+ * disabled until the form is dirty. Replaces the legacy "footer pops up
11
+ * when something changes" pattern with an always-visible header.
12
+ *
13
+ * Reads `isDirty` and `isSubmitting` from the RHF form state via
14
+ * `useFormState`, so the button re-renders independently of the parent page
15
+ * and won't churn on every keystroke.
16
+ *
17
+ * Uses `<button type="submit">` and relies on the surrounding `<form>` to
18
+ * dispatch submission — same flow as the legacy sticky footer. ⌘S keyboard
19
+ * submission is handled separately by `useFormSubmitShortcut`.
20
+ */
21
+ export function FormSaveButton<TFieldValues extends FieldValues>({
22
+ form,
23
+ label,
24
+ savingLabel,
25
+ }: {
26
+ form: UseFormReturn<TFieldValues, any, any>
27
+ label?: string
28
+ savingLabel?: string
29
+ }) {
30
+ const { t } = useTranslation()
31
+ const { isDirty, isSubmitting } = useFormState({ control: form.control })
32
+
33
+ return (
34
+ <Button type="submit" size="sm" disabled={!isDirty || isSubmitting}>
35
+ {isSubmitting && <Loader2Icon className="animate-spin" />}
36
+ {isSubmitting
37
+ ? (savingLabel ?? t('admin.actions.saving'))
38
+ : (label ?? t('admin.actions.save'))}
39
+ </Button>
40
+ )
41
+ }
42
+
43
+ /**
44
+ * Discard button wired to a `react-hook-form` instance. Renders only when
45
+ * the form is dirty (so the header stays uncluttered for read-only pages).
46
+ *
47
+ * Calls `form.reset()` by default — pass `onDiscard` to override (e.g., to
48
+ * reset to a freshly-fetched server value rather than the original mount
49
+ * defaults).
50
+ */
51
+ export function FormDiscardButton<TFieldValues extends FieldValues>({
52
+ form,
53
+ label,
54
+ onDiscard,
55
+ }: {
56
+ form: UseFormReturn<TFieldValues, any, any>
57
+ label?: string
58
+ onDiscard?: () => void
59
+ }) {
60
+ const { t } = useTranslation()
61
+ const { isDirty, isSubmitting } = useFormState({ control: form.control })
62
+
63
+ if (!isDirty) return null
64
+
65
+ return (
66
+ <Button
67
+ type="button"
68
+ size="sm"
69
+ variant="outline"
70
+ disabled={isSubmitting}
71
+ onClick={onDiscard ?? (() => form.reset())}
72
+ >
73
+ {label ?? t('admin.actions.discard')}
74
+ </Button>
75
+ )
76
+ }
77
+
78
+ /**
79
+ * Convenience: render Discard + Save side-by-side. Most pages just want both.
80
+ */
81
+ export function FormActions<TFieldValues extends FieldValues>({
82
+ form,
83
+ saveLabel,
84
+ savingLabel,
85
+ discardLabel,
86
+ onDiscard,
87
+ }: {
88
+ form: UseFormReturn<TFieldValues, any, any>
89
+ saveLabel?: string
90
+ savingLabel?: string
91
+ discardLabel?: string
92
+ onDiscard?: () => void
93
+ }) {
94
+ return (
95
+ <>
96
+ <FormDiscardButton form={form} label={discardLabel} onDiscard={onDiscard} />
97
+ <FormSaveButton form={form} label={saveLabel} savingLabel={savingLabel} />
98
+ </>
99
+ )
100
+ }
101
+
102
+ /**
103
+ * Bind ⌘S / Ctrl+S to submit a `react-hook-form` instance. Mount once per
104
+ * form. Skips when the form is pristine or already submitting — same gate
105
+ * as the visual Save button. Schema validation still runs inside
106
+ * `form.handleSubmit`, so invalid input rejects on its own without us
107
+ * needing to read `isValid` (which is unreliable under the default
108
+ * `onSubmit` validation mode).
109
+ *
110
+ * Also wires `beforeunload` to warn if the user closes the tab or navigates
111
+ * away while the form is dirty. (TanStack Router internal navigation is not
112
+ * intercepted — the SPA's own dirty-route guard would belong elsewhere.)
113
+ */
114
+ export function useFormSubmitShortcut<TFieldValues extends FieldValues>(
115
+ form: UseFormReturn<TFieldValues, any, any>,
116
+ onSubmit: (values: TFieldValues) => void | Promise<void>,
117
+ ) {
118
+ const { isDirty, isSubmitting } = useFormState({ control: form.control })
119
+
120
+ useHotkey(
121
+ 'Mod+S',
122
+ () => {
123
+ if (!isDirty || isSubmitting) return
124
+ void form.handleSubmit(onSubmit)()
125
+ },
126
+ // Mod+S targets a form-level action, so it should fire even while focus
127
+ // is inside an input. Default for Mod-combos already does this; pinned
128
+ // here to be explicit and survive future library default changes.
129
+ { ignoreInputs: false },
130
+ )
131
+
132
+ useEffect(() => {
133
+ if (!isDirty) return
134
+ const handler = (event: BeforeUnloadEvent) => {
135
+ event.preventDefault()
136
+ }
137
+ window.addEventListener('beforeunload', handler)
138
+ return () => window.removeEventListener('beforeunload', handler)
139
+ }, [isDirty])
140
+ }
@@ -0,0 +1,254 @@
1
+ import {
2
+ Button,
3
+ cn,
4
+ Sheet,
5
+ SheetContent,
6
+ SheetDescription,
7
+ SheetHeader,
8
+ SheetTitle,
9
+ useCopyToClipboard,
10
+ } from '@spree/dashboard-ui'
11
+ import {
12
+ CheckIcon,
13
+ ChevronDownIcon,
14
+ ChevronRightIcon,
15
+ CopyIcon,
16
+ ExternalLinkIcon,
17
+ Loader2Icon,
18
+ RotateCcwIcon,
19
+ XIcon,
20
+ } from 'lucide-react'
21
+ import type { ComponentProps } from 'react'
22
+ import { useEffect, useMemo, useState } from 'react'
23
+ import { useTranslation } from 'react-i18next'
24
+ import { type JsonValueLinkResolver, JsonValueView } from './json-value-view'
25
+
26
+ // Re-export so existing imports of `JsonValueLinkResolver` from this module
27
+ // keep working without touching call sites.
28
+ export type { JsonValueLinkResolver } from './json-value-view'
29
+
30
+ export interface JsonPreviewDrawerProps {
31
+ open: boolean
32
+ onOpenChange: (open: boolean) => void
33
+ /** Display title shown in the drawer header. Default "JSON". */
34
+ title?: string
35
+ /**
36
+ * Fetcher that returns the resource JSON. Called every time the drawer
37
+ * opens (so it always shows the latest server state — it's a debug tool)
38
+ * and every time the user clicks the refresh button. The drawer manages
39
+ * its own loading/error state internally; no react-query is involved.
40
+ */
41
+ fetch: () => Promise<unknown>
42
+ /**
43
+ * Resource path the JSON came from, e.g. `'/api/v3/admin/orders/or_xyz'`.
44
+ * Shown in the toolbar and used as the "Open raw" target.
45
+ */
46
+ endpoint?: string
47
+ /**
48
+ * Turn string values (typically prefixed IDs) into clickable links to
49
+ * other admin pages. Omit to render every string as inert text — the
50
+ * default for plugin authors who haven't wired up their own ID→route
51
+ * convention.
52
+ */
53
+ resolveLink?: JsonValueLinkResolver
54
+ }
55
+
56
+ export function JsonPreviewDrawer({
57
+ open,
58
+ onOpenChange,
59
+ title,
60
+ fetch,
61
+ endpoint,
62
+ resolveLink,
63
+ }: JsonPreviewDrawerProps) {
64
+ const { t } = useTranslation()
65
+ const resolvedTitle = title ?? t('admin.components.json_preview_drawer.title')
66
+ const [data, setData] = useState<unknown>(undefined)
67
+ const [error, setError] = useState<Error | null>(null)
68
+ const [isFetching, setIsFetching] = useState(false)
69
+ // True only on the first load of an opened session — `isFetching` covers
70
+ // subsequent refetches and animates the spinner button without blanking
71
+ // the previously rendered JSON.
72
+ const isLoading = isFetching && data === undefined && error === null
73
+
74
+ // Refetch on every open + when the resource being inspected changes + on
75
+ // manual refresh. The drawer is a debug tool, so staleness would be worse
76
+ // than the extra request. `endpoint` stands in for "which resource is this
77
+ // drawer pointing at" since it's a stable per-resource string; `fetch`
78
+ // intentionally isn't in the deps because a fresh inline-arrow identity
79
+ // every render would otherwise loop.
80
+ // biome-ignore lint/correctness/useExhaustiveDependencies: see above
81
+ useEffect(() => {
82
+ if (!open) return
83
+ let cancelled = false
84
+ setIsFetching(true)
85
+ setError(null)
86
+ fetch()
87
+ .then((result) => {
88
+ if (cancelled) return
89
+ setData(result)
90
+ })
91
+ .catch((err) => {
92
+ if (cancelled) return
93
+ setError(err instanceof Error ? err : new Error(String(err)))
94
+ })
95
+ .finally(() => {
96
+ if (cancelled) return
97
+ setIsFetching(false)
98
+ })
99
+ return () => {
100
+ cancelled = true
101
+ }
102
+ }, [open, endpoint])
103
+
104
+ const refetch = () => {
105
+ setIsFetching(true)
106
+ setError(null)
107
+ fetch()
108
+ .then(setData)
109
+ .catch((err) => setError(err instanceof Error ? err : new Error(String(err))))
110
+ .finally(() => setIsFetching(false))
111
+ }
112
+
113
+ const [collapsed, setCollapsed] = useState<boolean | number>(2)
114
+ const { copied: copiedAll, copy } = useCopyToClipboard()
115
+
116
+ const formatted = useMemo(() => (data ? JSON.stringify(data, null, 2) : ''), [data])
117
+
118
+ return (
119
+ <Sheet open={open} onOpenChange={onOpenChange}>
120
+ <SheetContent
121
+ side="right"
122
+ showCloseButton={false}
123
+ className="w-full sm:max-w-2xl bg-zinc-950 text-zinc-100 border-zinc-800 overflow-hidden"
124
+ >
125
+ <SheetHeader className="border-b border-zinc-800 bg-zinc-950 text-zinc-100 rounded-t-xl">
126
+ <SheetTitle className="text-zinc-100 font-mono text-base">{resolvedTitle}</SheetTitle>
127
+ {endpoint && (
128
+ <SheetDescription className="text-zinc-400 font-mono text-xs">
129
+ GET {endpoint}
130
+ </SheetDescription>
131
+ )}
132
+ <ToolbarButton
133
+ size="icon-xs"
134
+ className="absolute top-3 right-3"
135
+ onClick={() => onOpenChange(false)}
136
+ aria-label={t('admin.actions.close')}
137
+ >
138
+ <XIcon className="size-4" />
139
+ </ToolbarButton>
140
+ </SheetHeader>
141
+
142
+ <div className="flex items-center gap-1 border-b border-zinc-800 px-3 py-2 text-xs">
143
+ <ToolbarButton
144
+ size="icon-xs"
145
+ onClick={() => setCollapsed(false)}
146
+ aria-label={t('admin.a11y.expand_all')}
147
+ title={t('admin.a11y.expand_all')}
148
+ >
149
+ <ChevronDownIcon className="size-4" />
150
+ </ToolbarButton>
151
+ <ToolbarButton
152
+ size="icon-xs"
153
+ onClick={() => setCollapsed(true)}
154
+ aria-label={t('admin.a11y.collapse_all')}
155
+ title={t('admin.a11y.collapse_all')}
156
+ >
157
+ <ChevronRightIcon className="size-4" />
158
+ </ToolbarButton>
159
+ <DepthControl value={collapsed} onChange={setCollapsed} />
160
+
161
+ <span className="ml-auto inline-flex items-center gap-1">
162
+ <ToolbarButton size="xs" onClick={() => formatted && copy(formatted)} disabled={!data}>
163
+ {copiedAll ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
164
+ {copiedAll
165
+ ? t('admin.components.json_preview_drawer.copied')
166
+ : t('admin.components.json_preview_drawer.copy_all')}
167
+ </ToolbarButton>
168
+ <ToolbarButton
169
+ size="icon-xs"
170
+ onClick={() => refetch()}
171
+ aria-label={t('admin.a11y.refetch')}
172
+ title={t('admin.a11y.refetch')}
173
+ disabled={isFetching}
174
+ >
175
+ {isFetching ? (
176
+ <Loader2Icon className="size-3.5 animate-spin" />
177
+ ) : (
178
+ <RotateCcwIcon className="size-3.5" />
179
+ )}
180
+ </ToolbarButton>
181
+ {endpoint && (
182
+ <a
183
+ href={endpoint}
184
+ target="_blank"
185
+ rel="noreferrer noopener"
186
+ className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 transition-colors"
187
+ title={t('admin.components.json_preview_drawer.open_raw')}
188
+ >
189
+ <ExternalLinkIcon className="size-3.5" />
190
+ {t('admin.components.json_preview_drawer.raw')}
191
+ </a>
192
+ )}
193
+ </span>
194
+ </div>
195
+
196
+ <div className="themed-scrollbar flex-1 overflow-auto p-4 font-mono text-sm">
197
+ {isLoading ? (
198
+ <p className="text-zinc-500">{t('admin.common.loading')}</p>
199
+ ) : error ? (
200
+ <p className="text-red-400">
201
+ {t('admin.components.json_preview_drawer.failed_to_load', { message: error.message })}
202
+ </p>
203
+ ) : data !== undefined ? (
204
+ // The drawer paints its own dark surface around the renderer
205
+ // (Sheet content + toolbar), so the view itself runs in `bare`
206
+ // mode — no extra padding/background.
207
+ <JsonValueView value={data} collapsed={collapsed} resolveLink={resolveLink} bare />
208
+ ) : null}
209
+ </div>
210
+ </SheetContent>
211
+ </Sheet>
212
+ )
213
+ }
214
+
215
+ function ToolbarButton({ className, ...props }: ComponentProps<typeof Button>) {
216
+ return (
217
+ <Button
218
+ type="button"
219
+ variant="ghost"
220
+ className={cn('text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100', className)}
221
+ {...props}
222
+ />
223
+ )
224
+ }
225
+
226
+ function DepthControl({
227
+ value,
228
+ onChange,
229
+ }: {
230
+ value: boolean | number
231
+ onChange: (next: boolean | number) => void
232
+ }) {
233
+ const { t } = useTranslation()
234
+ const current = typeof value === 'number' ? `${value}` : value === true ? 'all' : 'open'
235
+ return (
236
+ <select
237
+ value={current}
238
+ onChange={(e) => {
239
+ const v = e.target.value
240
+ if (v === 'all') onChange(true)
241
+ else if (v === 'open') onChange(false)
242
+ else onChange(Number(v))
243
+ }}
244
+ className="ml-1 bg-zinc-900 border border-zinc-700 rounded px-1.5 py-1 text-xs text-zinc-300 hover:bg-zinc-800 focus:outline-none focus:ring-1 focus:ring-zinc-600"
245
+ title={t('admin.components.json_preview_drawer.collapse_depth')}
246
+ >
247
+ <option value="open">{t('admin.a11y.expand_all')}</option>
248
+ <option value="1">{t('admin.components.json_preview_drawer.depth', { level: 1 })}</option>
249
+ <option value="2">{t('admin.components.json_preview_drawer.depth', { level: 2 })}</option>
250
+ <option value="3">{t('admin.components.json_preview_drawer.depth', { level: 3 })}</option>
251
+ <option value="all">{t('admin.a11y.collapse_all')}</option>
252
+ </select>
253
+ )
254
+ }
@@ -0,0 +1,149 @@
1
+ import { Link } from '@tanstack/react-router'
2
+ import JsonView from '@uiw/react-json-view'
3
+ import { vscodeTheme } from '@uiw/react-json-view/vscode'
4
+ import { cn } from '../lib/utils'
5
+
6
+ /**
7
+ * Maps a string value (typically a prefixed ID like `or_abc123`) to an
8
+ * in-app route. Returned `to` is a `<Link to>` template; `params` is the
9
+ * positional-param map passed straight through. Return `null` for values
10
+ * that aren't linkable.
11
+ *
12
+ * No built-in prefix conventions ship with this component —
13
+ * `@spree/dashboard` provides Spree's `or`→orders / `prod`→products /
14
+ * `cus`→customers mapping at the call site so dashboard-ui stays
15
+ * Spree-vocabulary-free.
16
+ */
17
+ export type JsonValueLinkResolver = (
18
+ value: string,
19
+ ) => { to: string; params: Record<string, string> } | null
20
+
21
+ const VALUE_TYPE_STYLES: Record<string, { color: string; format: (v: unknown) => string }> = {
22
+ string: { color: 'text-emerald-300', format: (v) => `"${v}"` },
23
+ number: { color: 'text-amber-300', format: String },
24
+ bigint: { color: 'text-amber-300', format: String },
25
+ boolean: { color: 'text-purple-300', format: String },
26
+ }
27
+
28
+ export interface JsonValueViewProps {
29
+ /** JSON-serializable data to render. */
30
+ value: unknown
31
+ /**
32
+ * Collapse depth. `false` expands everything, `true` collapses everything,
33
+ * a `number` collapses below that depth. Default `2`.
34
+ */
35
+ collapsed?: boolean | number
36
+ /** Resolve prefixed-ID strings to in-app links. Omit for plain strings. */
37
+ resolveLink?: JsonValueLinkResolver
38
+ /** Class applied to the outer wrapper. The view always uses a dark theme so
39
+ * give it a dark surface (e.g. `bg-zinc-950`) when embedding into a light
40
+ * page; otherwise the syntax colours don't have enough contrast. */
41
+ className?: string
42
+ /** When true, renders no padding/background — useful when the parent already
43
+ * owns the surface chrome (e.g. inside a `<Card>` with its own padding). */
44
+ bare?: boolean
45
+ }
46
+
47
+ /**
48
+ * Read-only JSON tree viewer with optional ID-to-link resolution. Built on
49
+ * `@uiw/react-json-view` with a vscode-style theme. Pulls in ~30KB gzip of
50
+ * dependencies, so consumers should lazy-load this component via
51
+ * `React.lazy(() => import('@spree/dashboard-ui/spree/json-value-view'))`
52
+ * if it isn't on the critical path.
53
+ *
54
+ * @example Inline preview inside a Card
55
+ * <Card>
56
+ * <CardContent className="bg-zinc-950">
57
+ * <JsonValueView value={delivery.payload} bare />
58
+ * </CardContent>
59
+ * </Card>
60
+ *
61
+ * @example Linked prefixed IDs
62
+ * <JsonValueView value={order} resolveLink={resolveSpreePrefixToRoute} />
63
+ */
64
+ export function JsonValueView({
65
+ value,
66
+ collapsed = 2,
67
+ resolveLink,
68
+ className,
69
+ bare = false,
70
+ }: JsonValueViewProps) {
71
+ return (
72
+ <div
73
+ className={cn(
74
+ 'font-mono text-sm',
75
+ // The vscode theme is dark — when the parent doesn't already paint a
76
+ // dark background, paint one here so the syntax colors stay legible.
77
+ !bare && 'bg-zinc-950 text-zinc-100 rounded-md p-3',
78
+ className,
79
+ )}
80
+ >
81
+ <JsonView
82
+ value={value as object}
83
+ style={{ ...vscodeTheme, '--w-rjv-background-color': 'transparent' } as React.CSSProperties}
84
+ collapsed={collapsed}
85
+ displayDataTypes={false}
86
+ displayObjectSize
87
+ enableClipboard
88
+ shortenTextAfterLength={60}
89
+ components={{
90
+ value: ({ value: v, type, ...rest }) => (
91
+ <ValueRenderer value={v} type={type} resolveLink={resolveLink} {...rest} />
92
+ ),
93
+ }}
94
+ />
95
+ </div>
96
+ )
97
+ }
98
+
99
+ interface ValueRendererProps {
100
+ value: unknown
101
+ type: string
102
+ resolveLink?: JsonValueLinkResolver
103
+ }
104
+
105
+ function ValueRenderer({
106
+ value,
107
+ type,
108
+ resolveLink,
109
+ ...rest
110
+ }: ValueRendererProps & React.HTMLAttributes<HTMLSpanElement>) {
111
+ if (type === 'string' && typeof value === 'string' && resolveLink) {
112
+ const resolved = resolveLink(value)
113
+ if (resolved) {
114
+ return (
115
+ <span {...rest} className="text-emerald-300">
116
+ "
117
+ <Link
118
+ to={resolved.to}
119
+ params={resolved.params}
120
+ className="text-emerald-300 underline decoration-emerald-700 hover:decoration-emerald-300 transition-colors"
121
+ onClick={(e) => e.stopPropagation()}
122
+ >
123
+ {value}
124
+ </Link>
125
+ "
126
+ </span>
127
+ )
128
+ }
129
+ }
130
+ return <JsonValueSpan value={value} type={type} {...rest} />
131
+ }
132
+
133
+ function JsonValueSpan({
134
+ value,
135
+ type,
136
+ ...rest
137
+ }: {
138
+ value: unknown
139
+ type: string
140
+ } & React.HTMLAttributes<HTMLSpanElement>) {
141
+ const style = value === null ? null : VALUE_TYPE_STYLES[type]
142
+ const color = value === null ? 'text-zinc-500' : (style?.color ?? 'text-zinc-300')
143
+ const display = value === null ? 'null' : style ? style.format(value) : String(value)
144
+ return (
145
+ <span {...rest} className={color}>
146
+ {display}
147
+ </span>
148
+ )
149
+ }
@@ -0,0 +1,54 @@
1
+ import { CheckIcon, LanguagesIcon } from 'lucide-react'
2
+ import {
3
+ DropdownMenuItem,
4
+ DropdownMenuSub,
5
+ DropdownMenuSubContent,
6
+ DropdownMenuSubTrigger,
7
+ } from '../ui/dropdown-menu'
8
+
9
+ interface LanguageMenuItemsProps {
10
+ /** Translated "Language" label for the submenu trigger (passed by the app). */
11
+ label: string
12
+ /** Admin UI languages available to switch to ({ code, name } pairs). */
13
+ locales: ReadonlyArray<{ code: string; name: string }>
14
+ /** Currently active language code. */
15
+ value: string
16
+ onSelect: (code: string) => void
17
+ }
18
+
19
+ /**
20
+ * Admin UI language picker rendered inside the user dropdown menu as a nested
21
+ * submenu (a single "Language ▸" row that expands to the list), so the menu
22
+ * stays compact no matter how many languages are installed.
23
+ *
24
+ * Headless: the label, locale list, and change handler are passed in, so this
25
+ * component imports no i18n/data runtime. Renders nothing when fewer than two
26
+ * languages are installed: there's nothing to choose.
27
+ */
28
+ export function LanguageMenuItems({ label, locales, value, onSelect }: LanguageMenuItemsProps) {
29
+ if (locales.length < 2) return null
30
+
31
+ const current = locales.find((l) => l.code === value)
32
+
33
+ return (
34
+ <DropdownMenuSub>
35
+ <DropdownMenuSubTrigger>
36
+ <LanguagesIcon className="size-4" />
37
+ {label}
38
+ {current && <span className="ml-auto text-xs text-muted-foreground">{current.name}</span>}
39
+ </DropdownMenuSubTrigger>
40
+ <DropdownMenuSubContent>
41
+ {locales.map((locale) => (
42
+ <DropdownMenuItem
43
+ key={locale.code}
44
+ closeOnClick={false}
45
+ onClick={() => onSelect(locale.code)}
46
+ >
47
+ {locale.name}
48
+ {value === locale.code && <CheckIcon className="ml-auto size-3.5" />}
49
+ </DropdownMenuItem>
50
+ ))}
51
+ </DropdownMenuSubContent>
52
+ </DropdownMenuSub>
53
+ )
54
+ }