@spree/dashboard-core 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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +418 -0
  3. package/package.json +84 -0
  4. package/src/client.ts +25 -0
  5. package/src/components/address-form-dialog.tsx +376 -0
  6. package/src/components/app-sidebar.tsx +70 -0
  7. package/src/components/bulk-action-bar.tsx +504 -0
  8. package/src/components/can.tsx +30 -0
  9. package/src/components/country-combobox.tsx +238 -0
  10. package/src/components/country-state-fields.tsx +86 -0
  11. package/src/components/currency-select.tsx +181 -0
  12. package/src/components/export-button.tsx +156 -0
  13. package/src/components/file-upload-field.tsx +265 -0
  14. package/src/components/image-upload-field.tsx +53 -0
  15. package/src/components/import-button.tsx +202 -0
  16. package/src/components/locale-select.tsx +268 -0
  17. package/src/components/market-combobox.tsx +89 -0
  18. package/src/components/nav-main.tsx +186 -0
  19. package/src/components/page-header.tsx +286 -0
  20. package/src/components/page-tabs.tsx +78 -0
  21. package/src/components/preferences-form.tsx +404 -0
  22. package/src/components/resource-combobox.tsx +126 -0
  23. package/src/components/resource-multi-autocomplete.tsx +176 -0
  24. package/src/components/resource-picker-sheet.tsx +235 -0
  25. package/src/components/resource-table.tsx +652 -0
  26. package/src/components/settings-sidebar.tsx +139 -0
  27. package/src/components/slot.tsx +50 -0
  28. package/src/components/store-date-picker.tsx +18 -0
  29. package/src/components/store-switcher.tsx +70 -0
  30. package/src/components/table-toolbar.tsx +818 -0
  31. package/src/components/tag-combobox.tsx +99 -0
  32. package/src/components/top-bar.tsx +210 -0
  33. package/src/hooks/use-auth.ts +10 -0
  34. package/src/hooks/use-command-palette.tsx +34 -0
  35. package/src/hooks/use-countries.ts +15 -0
  36. package/src/hooks/use-custom-fields.ts +142 -0
  37. package/src/hooks/use-direct-upload.ts +104 -0
  38. package/src/hooks/use-display-name.ts +42 -0
  39. package/src/hooks/use-export.ts +75 -0
  40. package/src/hooks/use-global-search.ts +79 -0
  41. package/src/hooks/use-host-form.ts +34 -0
  42. package/src/hooks/use-import.ts +62 -0
  43. package/src/hooks/use-resource-mutation.ts +99 -0
  44. package/src/hooks/use-switch-admin-locale.ts +55 -0
  45. package/src/index.ts +97 -0
  46. package/src/lib/custom-field-components.ts +56 -0
  47. package/src/lib/download.ts +49 -0
  48. package/src/lib/filters-to-ransack.ts +47 -0
  49. package/src/lib/form-errors.ts +103 -0
  50. package/src/lib/form-fields-registry.ts +102 -0
  51. package/src/lib/form-mappers.ts +30 -0
  52. package/src/lib/formatters.ts +19 -0
  53. package/src/lib/i18n.ts +210 -0
  54. package/src/lib/nav-registry.ts +224 -0
  55. package/src/lib/permissions.ts +55 -0
  56. package/src/lib/query-client.ts +11 -0
  57. package/src/lib/query-keys.ts +69 -0
  58. package/src/lib/route-registry.ts +158 -0
  59. package/src/lib/search-registry.ts +129 -0
  60. package/src/lib/settings-nav-registry.ts +158 -0
  61. package/src/lib/slot-registry.ts +116 -0
  62. package/src/lib/table-registry.ts +274 -0
  63. package/src/lib/timestamp-columns.tsx +58 -0
  64. package/src/locales/ar.json +629 -0
  65. package/src/locales/de.json +629 -0
  66. package/src/locales/en.json +629 -0
  67. package/src/locales/fr.json +629 -0
  68. package/src/locales/pl.json +629 -0
  69. package/src/locales/zh-CN.json +629 -0
  70. package/src/plugin.ts +222 -0
  71. package/src/providers/auth-provider.tsx +188 -0
  72. package/src/providers/permission-provider.tsx +118 -0
  73. package/src/providers/store-provider.tsx +111 -0
  74. package/src/virtual.d.ts +5 -0
  75. package/src/vite/discover.ts +170 -0
  76. package/src/vite/index.ts +270 -0
@@ -0,0 +1,504 @@
1
+ import {
2
+ Button,
3
+ DropdownMenu,
4
+ DropdownMenuContent,
5
+ DropdownMenuItem,
6
+ DropdownMenuTrigger,
7
+ useConfirm,
8
+ } from '@spree/dashboard-ui'
9
+ import type { QueryKey } from '@tanstack/react-query'
10
+ import { useQueryClient } from '@tanstack/react-query'
11
+ import { MoreHorizontalIcon, XIcon } from 'lucide-react'
12
+ import type { ReactNode } from 'react'
13
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
14
+ import { createPortal } from 'react-dom'
15
+ import { useTranslation } from 'react-i18next'
16
+ import { toast } from 'sonner'
17
+ import { usePermissions } from '../providers/permission-provider'
18
+
19
+ /**
20
+ * Context handed to a bulk action's `run` and `form` callbacks. `ids` is the
21
+ * set of selected row IDs (prefixed). `formValues` is whatever the action's
22
+ * `form` component resolved with — `undefined` for actions without a form.
23
+ */
24
+ export interface BulkActionRunContext<TFormValues = unknown> {
25
+ ids: string[]
26
+ formValues?: TFormValues
27
+ }
28
+
29
+ export interface BulkActionFormProps<TFormValues = unknown> {
30
+ ids: string[]
31
+ onSubmit: (values: TFormValues) => void
32
+ onCancel: () => void
33
+ }
34
+
35
+ /**
36
+ * Declarative bulk action passed to `<ResourceTable>`. Three shapes:
37
+ *
38
+ * 1. Immediate — `run` is called as soon as the button is clicked. Use for
39
+ * one-click ops that don't need confirmation (rare).
40
+ * 2. Confirm — `confirm` opens the standard confirm dialog before `run`.
41
+ * Use for destructive or otherwise unambiguous actions.
42
+ * 3. Form — `form` renders a component (usually a Sheet) that collects
43
+ * parameters and resolves with `formValues` passed to `run`. Use for
44
+ * parameterised actions like "add tags…" or "move to group…".
45
+ *
46
+ * Visibility is gated by `subject` + action via CanCanCan. Actions without
47
+ * a subject are always shown.
48
+ */
49
+ export interface BulkAction<TFormValues = unknown> {
50
+ key: string
51
+ label: string
52
+ icon?: ReactNode
53
+ /** Optional Subject for CanCanCan visibility check. Pairs with `action`. */
54
+ subject?: string
55
+ /** CanCanCan action keyword (default: `'update'`). */
56
+ action?: string
57
+ /** Confirm dialog options. `{n}` in `title`/`message` is replaced by the count. */
58
+ confirm?: {
59
+ title?: string
60
+ message: string
61
+ confirmLabel?: string
62
+ variant?: 'default' | 'destructive'
63
+ }
64
+ /** Render-prop for actions that need to collect form values before running. */
65
+ form?: (props: BulkActionFormProps<TFormValues>) => ReactNode
66
+ /** The mutation. Resolves with anything; errors surface a toast. */
67
+ run: (ctx: BulkActionRunContext<TFormValues>) => Promise<unknown>
68
+ /**
69
+ * Extra query keys to invalidate after `run` succeeds. The table's own
70
+ * `queryKey` is always invalidated — list this when the mutation also
71
+ * affects records in other resources (e.g. assigning customers to a
72
+ * group mutates the customer rows AND every group's `customers_count`).
73
+ */
74
+ invalidate?: QueryKey[]
75
+ /** Toast message on success. Supports `{n}` substitution. */
76
+ successMessage?: string
77
+ /** Toast message on failure. */
78
+ errorMessage?: string
79
+ }
80
+
81
+ interface BulkActionBarProps {
82
+ selectedIds: string[]
83
+ // The bar treats `formValues` opaquely (forwards from `form` to `run`) so it
84
+ // doesn't care what the action's type parameter is. Concrete callers keep
85
+ // the precise type via `BulkAction<MyFormValues>`.
86
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
87
+ actions: BulkAction<any>[]
88
+ onClear: () => void
89
+ /** Called after a successful run to refresh the table / clear selection. */
90
+ onDone: () => void
91
+ /**
92
+ * When set, the bar centers itself horizontally inside this element's
93
+ * bounding rect on viewports wide enough to comfortably show it inside
94
+ * (>= +md+ breakpoint). On smaller viewports it falls back to centering
95
+ * against the visual viewport. Without this the bar always anchors to
96
+ * the viewport.
97
+ */
98
+ anchorRef?: React.RefObject<HTMLElement | null>
99
+ }
100
+
101
+ function interpolate(template: string, n: number) {
102
+ return template.replace(/\{n\}/g, String(n))
103
+ }
104
+
105
+ export function BulkActionBar({
106
+ selectedIds,
107
+ actions,
108
+ onClear,
109
+ onDone,
110
+ anchorRef,
111
+ }: BulkActionBarProps) {
112
+ const { t } = useTranslation()
113
+ const confirm = useConfirm()
114
+ const queryClient = useQueryClient()
115
+ const { permissions } = usePermissions()
116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
+ const [pendingForm, setPendingForm] = useState<BulkAction<any> | null>(null)
118
+ const [running, setRunning] = useState(false)
119
+
120
+ const count = selectedIds.length
121
+
122
+ // The bar unmounts visually when nothing is selected, but state survives
123
+ // across mounts. Drop any pending form sheet so it doesn't resurface when
124
+ // the user re-selects a *different* set of rows.
125
+ useEffect(() => {
126
+ if (count === 0) setPendingForm(null)
127
+ }, [count])
128
+
129
+ if (count === 0) return null
130
+
131
+ // Filter by CanCanCan when subject is declared.
132
+ const visibleActions = actions.filter((a) =>
133
+ a.subject ? permissions.can(a.action ?? 'update', a.subject) : true,
134
+ )
135
+
136
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
137
+ async function execute(action: BulkAction<any>, formValues?: unknown) {
138
+ setRunning(true)
139
+ try {
140
+ await action.run({ ids: selectedIds, formValues })
141
+ // Invalidate the action's declared cross-resource keys BEFORE `onDone`
142
+ // (which invalidates the host table's own key). Without this, pages
143
+ // like Customer Groups that cache `customers_count` won't refresh
144
+ // when the user navigates back — they'd show stale counts until
145
+ // either `staleTime` elapses or the user reloads.
146
+ for (const key of action.invalidate ?? []) {
147
+ queryClient.invalidateQueries({ queryKey: key })
148
+ }
149
+ toast.success(
150
+ interpolate(
151
+ action.successMessage ?? t('admin.components.bulk_action_bar.default_success'),
152
+ count,
153
+ ),
154
+ )
155
+ onDone()
156
+ } catch (err) {
157
+ const message =
158
+ err instanceof Error ? err.message : t('admin.components.bulk_action_bar.default_error')
159
+ toast.error(action.errorMessage ?? message)
160
+ } finally {
161
+ setRunning(false)
162
+ }
163
+ }
164
+
165
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
166
+ async function handleClick(action: BulkAction<any>) {
167
+ if (action.form) {
168
+ setPendingForm(action)
169
+ return
170
+ }
171
+ if (action.confirm) {
172
+ const ok = await confirm({
173
+ title: action.confirm.title ? interpolate(action.confirm.title, count) : undefined,
174
+ message: interpolate(action.confirm.message, count),
175
+ confirmLabel: action.confirm.confirmLabel,
176
+ variant: action.confirm.variant,
177
+ })
178
+ if (!ok) return
179
+ }
180
+ await execute(action)
181
+ }
182
+
183
+ return (
184
+ <BulkActionBarLayout
185
+ count={count}
186
+ actions={visibleActions}
187
+ anchorRef={anchorRef}
188
+ onClear={onClear}
189
+ onClickAction={handleClick}
190
+ running={running}
191
+ pendingForm={pendingForm}
192
+ selectedIds={selectedIds}
193
+ onClosePendingForm={() => setPendingForm(null)}
194
+ onSubmitPendingForm={async (values) => {
195
+ const action = pendingForm
196
+ if (!action) return
197
+ setPendingForm(null)
198
+ await execute(action, values)
199
+ }}
200
+ />
201
+ )
202
+ }
203
+
204
+ interface BulkActionBarLayoutProps {
205
+ count: number
206
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
207
+ actions: BulkAction<any>[]
208
+ anchorRef?: React.RefObject<HTMLElement | null>
209
+ onClear: () => void
210
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
211
+ onClickAction: (action: BulkAction<any>) => void
212
+ running: boolean
213
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
214
+ pendingForm: BulkAction<any> | null
215
+ selectedIds: string[]
216
+ onClosePendingForm: () => void
217
+ onSubmitPendingForm: (values: unknown) => void
218
+ }
219
+
220
+ /**
221
+ * Splits the action list into "fits in the bar" + "overflow menu" based on
222
+ * the bar's actual rendered width.
223
+ *
224
+ * Why a separate component: hoisting the measurement effects + refs out of
225
+ * +BulkActionBar+ keeps the action-running logic readable and confines
226
+ * resize-observer setup to one place.
227
+ */
228
+ function BulkActionBarLayout({
229
+ count,
230
+ actions,
231
+ anchorRef,
232
+ onClear,
233
+ onClickAction,
234
+ running,
235
+ pendingForm,
236
+ selectedIds,
237
+ onClosePendingForm,
238
+ onSubmitPendingForm,
239
+ }: BulkActionBarLayoutProps) {
240
+ const { t } = useTranslation()
241
+
242
+ // The bar is centered (+w-fit+) so the parent doesn't constrain it; instead
243
+ // we cap against the viewport with a sensible margin.
244
+ const containerRef = useRef<HTMLDivElement | null>(null)
245
+ const measureRef = useRef<HTMLDivElement | null>(null)
246
+ // Width of the bar's leading section (clear button + label + divider).
247
+ const leadingRef = useRef<HTMLDivElement | null>(null)
248
+ const [visibleCount, setVisibleCount] = useState(actions.length)
249
+ // JS-computed position so it's immune to transformed ancestors.
250
+ const [barLeft, setBarLeft] = useState(0)
251
+ const [barTop, setBarTop] = useState(0)
252
+
253
+ // Max-width state so the bar never extends past its host region (the
254
+ // table card on desktop, the visual viewport on mobile).
255
+ const [maxWidth, setMaxWidth] = useState<number | null>(null)
256
+
257
+ // Reposition + remeasure on viewport resize, scroll, and font load.
258
+ useLayoutEffect(() => {
259
+ function reposition() {
260
+ const el = containerRef.current
261
+ if (!el) return
262
+ const width = el.offsetWidth
263
+ const height = el.offsetHeight
264
+ // visualViewport accounts for the mobile browser URL bar collapsing;
265
+ // falls back to window dimensions on browsers without the API.
266
+ const vv = window.visualViewport
267
+ const vpWidth = vv?.width ?? window.innerWidth
268
+ const vpHeight = vv?.height ?? window.innerHeight
269
+ const vpLeft = vv?.offsetLeft ?? 0
270
+ const vpTop = vv?.offsetTop ?? 0
271
+
272
+ // Pin to viewport bottom. The bar should always stay reachable —
273
+ // anchoring to the table card pinned it to the table's bottom, which
274
+ // sits off-screen when the table is taller than the viewport.
275
+ let left = vpLeft + Math.round((vpWidth - width) / 2)
276
+ // Cap width at viewport - 2rem on mobile.
277
+ let cap = vpWidth - 32
278
+ // If an anchor element is provided, clamp the horizontal center AND
279
+ // the max width to the anchor's bounding rect, so on desktop the bar
280
+ // reads as part of the table region rather than free-floating across
281
+ // the whole page.
282
+ const anchor = anchorRef?.current
283
+ if (anchor && vpWidth >= 768) {
284
+ const rect = anchor.getBoundingClientRect()
285
+ left = rect.left + Math.round((rect.width - width) / 2)
286
+ cap = Math.round(rect.width * 0.9) // 90% of the table card width
287
+ }
288
+ setBarLeft(left)
289
+ setBarTop(vpTop + vpHeight - height - 16) // 16px from bottom
290
+ setMaxWidth(cap)
291
+ }
292
+ reposition()
293
+ window.addEventListener('resize', reposition)
294
+ window.addEventListener('scroll', reposition, true)
295
+ window.visualViewport?.addEventListener('resize', reposition)
296
+ window.visualViewport?.addEventListener('scroll', reposition)
297
+ // Track the anchor's own size/position changes (sidebar collapse,
298
+ // pagination footer height swaps, etc.).
299
+ let ro: ResizeObserver | undefined
300
+ if (anchorRef?.current) {
301
+ ro = new ResizeObserver(reposition)
302
+ ro.observe(anchorRef.current)
303
+ }
304
+ return () => {
305
+ window.removeEventListener('resize', reposition)
306
+ window.removeEventListener('scroll', reposition, true)
307
+ window.visualViewport?.removeEventListener('resize', reposition)
308
+ window.visualViewport?.removeEventListener('scroll', reposition)
309
+ ro?.disconnect()
310
+ }
311
+ // Re-run when the anchor element resolves (refs are null on first render
312
+ // and populated on the next commit). The +ResizeObserver+ + window
313
+ // listeners re-attach to whatever anchor the consumer passed.
314
+ }, [anchorRef?.current])
315
+
316
+ // Measure once on mount + whenever the container or action set changes.
317
+ // +useLayoutEffect+ so the user never sees a flash of overflowing actions.
318
+ useLayoutEffect(() => {
319
+ function recompute() {
320
+ const measure = measureRef.current
321
+ const leading = leadingRef.current
322
+ if (!measure || !leading) return
323
+
324
+ // Compute available action-area width against the same cap that the
325
+ // bar's +max-width+ uses (table card width on desktop, viewport on
326
+ // mobile) so the overflow count matches what the user will see.
327
+ const anchor = anchorRef?.current
328
+ const vpWidth = document.documentElement.clientWidth
329
+ const cap =
330
+ anchor && vpWidth >= 768
331
+ ? Math.round(anchor.getBoundingClientRect().width * 0.9)
332
+ : vpWidth - 32
333
+ // 16px for the bar's own +p-1+ + border + subpixel safety pad.
334
+ const available = cap - 16 - leading.offsetWidth
335
+ // +⋯+ button (icon-sm = 28px) + gap on each side + safety margin.
336
+ // 64px keeps the dropdown comfortably visible on narrow viewports
337
+ // and gives the menu room to position without spilling off-screen.
338
+ const moreWidth = 64
339
+ const children = Array.from(measure.children) as HTMLElement[]
340
+ const widths = children.map((el) => el.offsetWidth)
341
+
342
+ let used = 0
343
+ let n = 0
344
+ for (let i = 0; i < widths.length; i++) {
345
+ // If this is the last action and the rest fit without +⋯+, take all.
346
+ const reserve = i === widths.length - 1 ? 0 : moreWidth
347
+ if (used + widths[i] + reserve <= available) {
348
+ used += widths[i]
349
+ n = i + 1
350
+ } else {
351
+ break
352
+ }
353
+ }
354
+ setVisibleCount(n)
355
+ }
356
+
357
+ recompute()
358
+ const ro = new ResizeObserver(recompute)
359
+ if (containerRef.current) ro.observe(containerRef.current)
360
+ if (anchorRef?.current) ro.observe(anchorRef.current)
361
+ window.addEventListener('resize', recompute)
362
+ return () => {
363
+ ro.disconnect()
364
+ window.removeEventListener('resize', recompute)
365
+ }
366
+ // Re-run only when the anchor element resolves (refs are null on first
367
+ // render and populated on commit). Changes to +actions+ don't need an
368
+ // explicit dep — the +ResizeObserver+ on the container fires when
369
+ // children render/unrender, which re-triggers +recompute+.
370
+ }, [anchorRef?.current])
371
+
372
+ const visible = actions.slice(0, visibleCount)
373
+ const overflow = actions.slice(visibleCount)
374
+
375
+ // JS-driven positioning: +position: fixed+ silently re-anchors to a
376
+ // transformed/contained ancestor (sidebar wrapper, Card, etc.) on this
377
+ // app. Portaling to +document.body+ should sidestep that, but in
378
+ // practice the bar still landed at the table's bottom on mobile. So we
379
+ // set explicit +top/left+ in pixels from +window+ measurements every
380
+ // frame the viewport changes. No CSS positioning, no ambiguity.
381
+ const bar = (
382
+ <>
383
+ <div
384
+ ref={containerRef}
385
+ style={{
386
+ position: 'fixed',
387
+ left: `${barLeft}px`,
388
+ top: `${barTop}px`,
389
+ maxWidth: maxWidth != null ? `${maxWidth}px` : 'calc(100vw - 2rem)',
390
+ }}
391
+ className="z-50 flex w-fit items-center gap-0.5 rounded-xl border bg-popover p-1 shadow-md"
392
+ >
393
+ <div ref={leadingRef} className="flex items-center gap-0.5">
394
+ <Button
395
+ type="button"
396
+ variant="ghost"
397
+ size="icon-sm"
398
+ onClick={onClear}
399
+ aria-label={t('admin.actions.clear')}
400
+ className="text-muted-foreground"
401
+ >
402
+ <XIcon className="size-4" />
403
+ </Button>
404
+ <span className="whitespace-nowrap px-1.5 text-sm">
405
+ {t('admin.components.bulk_action_bar.selected', { count })}
406
+ </span>
407
+ <div className="mx-1 h-5 w-px bg-border" aria-hidden />
408
+ </div>
409
+
410
+ {visible.map((action) => (
411
+ <BulkActionButton
412
+ key={action.key}
413
+ action={action}
414
+ disabled={running}
415
+ onClick={() => onClickAction(action)}
416
+ />
417
+ ))}
418
+
419
+ {overflow.length > 0 && (
420
+ <DropdownMenu>
421
+ <DropdownMenuTrigger asChild>
422
+ <Button
423
+ type="button"
424
+ variant="ghost"
425
+ size="icon-sm"
426
+ aria-label={t('admin.actions.more_actions')}
427
+ >
428
+ <MoreHorizontalIcon className="size-4" />
429
+ </Button>
430
+ </DropdownMenuTrigger>
431
+ <DropdownMenuContent align="end">
432
+ {overflow.map((action) => (
433
+ <DropdownMenuItem
434
+ key={action.key}
435
+ onClick={() => onClickAction(action)}
436
+ disabled={running}
437
+ className="py-1.5"
438
+ >
439
+ {action.icon}
440
+ {action.label}
441
+ </DropdownMenuItem>
442
+ ))}
443
+ </DropdownMenuContent>
444
+ </DropdownMenu>
445
+ )}
446
+
447
+ {/* Off-screen measuring layer: rendered with the same button styling
448
+ as the visible row so +offsetWidth+ reflects the real width.
449
+ +aria-hidden+ + +inert+-equivalent (no pointer events, no focus). */}
450
+ <div
451
+ ref={measureRef}
452
+ aria-hidden
453
+ className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-0.5"
454
+ >
455
+ {actions.map((action) => (
456
+ <BulkActionButton
457
+ key={action.key}
458
+ action={action}
459
+ disabled
460
+ onClick={() => undefined}
461
+ tabIndex={-1}
462
+ />
463
+ ))}
464
+ </div>
465
+ </div>
466
+ {pendingForm?.form?.({
467
+ ids: selectedIds,
468
+ onCancel: onClosePendingForm,
469
+ onSubmit: onSubmitPendingForm,
470
+ })}
471
+ </>
472
+ )
473
+
474
+ if (typeof document === 'undefined') return bar
475
+ return createPortal(bar, document.body)
476
+ }
477
+
478
+ function BulkActionButton({
479
+ action,
480
+ disabled,
481
+ onClick,
482
+ tabIndex,
483
+ }: {
484
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
485
+ action: BulkAction<any>
486
+ disabled: boolean
487
+ onClick: () => void
488
+ tabIndex?: number
489
+ }) {
490
+ return (
491
+ <Button
492
+ type="button"
493
+ variant="ghost"
494
+ size="sm"
495
+ onClick={onClick}
496
+ disabled={disabled}
497
+ tabIndex={tabIndex}
498
+ className="gap-1.5 whitespace-nowrap px-2"
499
+ >
500
+ {action.icon}
501
+ {action.label}
502
+ </Button>
503
+ )
504
+ }
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { ActionName, SubjectName } from '../lib/permissions'
3
+ import { usePermissions } from '../providers/permission-provider'
4
+
5
+ interface CanProps {
6
+ /** Action name (e.g. "update", "destroy", "manage") */
7
+ I: ActionName
8
+ /** Subject class name (e.g. "Spree::Product") */
9
+ a: SubjectName
10
+ children: ReactNode
11
+ fallback?: ReactNode
12
+ }
13
+
14
+ /**
15
+ * Declarative permission check. Renders `children` if the current user
16
+ * can perform `I` on `a`, otherwise renders `fallback` (or nothing).
17
+ *
18
+ * Example:
19
+ * <Can I="destroy" a="Spree::Product">
20
+ * <Button onClick={handleDelete}>Delete</Button>
21
+ * </Can>
22
+ *
23
+ * Note: This is purely for UX. The backend still enforces CanCanCan
24
+ * `authorize!` on every request — never rely on the frontend check
25
+ * for security.
26
+ */
27
+ export function Can({ I, a, children, fallback = null }: CanProps) {
28
+ const { permissions } = usePermissions()
29
+ return <>{permissions.can(I, a) ? children : fallback}</>
30
+ }