@spree/dashboard-core 6.0.2 → 6.0.4

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 (87) hide show
  1. package/dist/vite/index.js +81 -5
  2. package/package.json +24 -24
  3. package/src/api-client.ts +7 -1
  4. package/src/client.ts +2 -0
  5. package/src/components/address-form-dialog.tsx +75 -28
  6. package/src/components/app-shell.tsx +5 -4
  7. package/src/components/app-sidebar.tsx +2 -1
  8. package/src/components/bulk-action-bar.tsx +6 -5
  9. package/src/components/country-state-fields.tsx +3 -0
  10. package/src/components/currency-select.tsx +6 -4
  11. package/src/components/developer-inspector-drawer.tsx +322 -0
  12. package/src/components/file-upload-field.tsx +64 -37
  13. package/src/components/image-upload-field.tsx +4 -0
  14. package/src/components/import-wizard-dialog.tsx +14 -12
  15. package/src/components/invitation-acceptance.tsx +15 -11
  16. package/src/components/media-editor-dialog.tsx +131 -0
  17. package/src/components/media-picker-dialog.tsx +866 -0
  18. package/src/components/media-picker-sheet.tsx +13 -300
  19. package/src/components/mobile-top-bar.tsx +1 -1
  20. package/src/components/nav-main.tsx +13 -2
  21. package/src/components/page-header.tsx +57 -13
  22. package/src/components/page-tabs.tsx +1 -1
  23. package/src/components/product-stock-summary.tsx +58 -0
  24. package/src/components/read-row.tsx +38 -0
  25. package/src/components/resource-combobox.tsx +1 -1
  26. package/src/components/resource-image-field.tsx +150 -0
  27. package/src/components/resource-picker-sheet.tsx +2 -2
  28. package/src/components/resource-table.tsx +161 -40
  29. package/src/components/section-heading.tsx +2 -2
  30. package/src/components/settings-container.tsx +19 -0
  31. package/src/components/settings-fields.tsx +165 -0
  32. package/src/components/settings-section.tsx +46 -0
  33. package/src/components/settings-sidebar.tsx +35 -18
  34. package/src/components/sheet-top-bar.tsx +7 -8
  35. package/src/components/sidebar-search.tsx +11 -12
  36. package/src/components/sidebar-user.tsx +39 -44
  37. package/src/components/skip-link.tsx +1 -1
  38. package/src/components/store-switcher.tsx +1 -1
  39. package/src/components/table-toolbar.tsx +194 -331
  40. package/src/components/timezone-combobox.tsx +119 -0
  41. package/src/components/top-bar-breadcrumbs.tsx +1 -1
  42. package/src/hooks/use-stock-locations.ts +6 -2
  43. package/src/hooks/use-table-density.ts +50 -0
  44. package/src/index.ts +20 -2
  45. package/src/lib/curl.test.ts +18 -0
  46. package/src/lib/curl.ts +28 -0
  47. package/src/lib/developer-inspector.test.ts +169 -0
  48. package/src/lib/developer-inspector.ts +134 -0
  49. package/src/lib/docs.ts +3 -2
  50. package/src/lib/fulfillment-items.test.ts +51 -0
  51. package/src/lib/fulfillment-items.ts +53 -0
  52. package/src/lib/request-snippets.ts +41 -0
  53. package/src/lib/table-registry.ts +13 -6
  54. package/src/lib/timestamp-columns.tsx +1 -1
  55. package/src/locales/ar.json +128 -14
  56. package/src/locales/de.json +128 -14
  57. package/src/locales/en.json +128 -14
  58. package/src/locales/es.json +128 -14
  59. package/src/locales/fr.json +128 -14
  60. package/src/locales/ja.json +128 -14
  61. package/src/locales/pl.json +130 -14
  62. package/src/locales/vi.json +128 -14
  63. package/src/locales/zh-CN.json +128 -14
  64. package/src/pages/settings-index.tsx +79 -48
  65. package/src/pages/stock-locations.tsx +14 -6
  66. package/src/products/digital-asset-edit-sheet.tsx +1 -1
  67. package/src/products/digital-asset-provider-sheet.tsx +1 -1
  68. package/src/products/digital-assets-card.tsx +7 -7
  69. package/src/products/inventory-section.tsx +2 -2
  70. package/src/products/media-edit-sheet.tsx +136 -133
  71. package/src/products/product-form-cards.tsx +204 -73
  72. package/src/products/product-form-mapping.test.ts +42 -1
  73. package/src/products/product-form-mapping.ts +20 -0
  74. package/src/products/product-schema.ts +8 -0
  75. package/src/products/product-types.ts +2 -0
  76. package/src/products/use-product-form-data.ts +30 -3
  77. package/src/products/variant-edit-sheet.tsx +3 -3
  78. package/src/products/variants-options-builder.tsx +13 -8
  79. package/src/products/variants-section.tsx +196 -4
  80. package/src/providers/auth-provider.tsx +2 -0
  81. package/src/providers/developer-inspector-provider.tsx +145 -0
  82. package/src/providers/json-link-resolver-provider.tsx +27 -0
  83. package/src/sellers/seller-schema.ts +107 -0
  84. package/src/tables/package-types.tsx +3 -3
  85. package/src/tables/stock-locations.tsx +15 -14
  86. package/src/virtual.d.ts +4 -0
  87. package/src/vite/index.ts +103 -5
@@ -0,0 +1,150 @@
1
+ import type { Media } from '@spree/admin-sdk'
2
+ import { Button } from '@spree/dashboard-ui'
3
+ import { LibraryIcon } from '@spree/dashboard-ui/icons'
4
+ import { useMemo, useState } from 'react'
5
+ import type { UseFormReturn } from 'react-hook-form'
6
+ import { useTranslation } from 'react-i18next'
7
+ import { ImageUploadField } from './image-upload-field'
8
+ import { MediaPickerDialog, type MediaPickerDialogProps } from './media-picker-dialog'
9
+
10
+ /**
11
+ * Where "Choose from library" looks and uploads. Only the operator's
12
+ * dashboard has a media library, so a panel without one passes nothing and
13
+ * the field offers an upload alone.
14
+ */
15
+ export type MediaLibrarySource = Pick<MediaPickerDialogProps<Media>, 'search' | 'onUpload'>
16
+
17
+ /**
18
+ * The form fields backing one image: a freshly uploaded file
19
+ * (`<name>_signed_id`), a transient object URL for the just-picked file
20
+ * (`<name>_preview_url`), and a flag marking the persisted attachment for
21
+ * removal (`<name>_cleared`). Together they form the three-state machine the
22
+ * API expects — see the `imageParam` mapper in the schema files.
23
+ *
24
+ * The triple is keyed by the attachment's own name, so a resource carrying
25
+ * `logo`/`cover_photo` composes exactly like one carrying `image`.
26
+ */
27
+ export type ImageFieldsFor<Name extends string> = {
28
+ [K in `${Name}_signed_id` | `${Name}_preview_url`]: K extends `${string}_signed_id`
29
+ ? string | null
30
+ : string | null
31
+ } & { [K in `${Name}_cleared`]: boolean }
32
+
33
+ /** The pair categories and collections carry. */
34
+ export type ImageFieldsShape = ImageFieldsFor<'image'> & ImageFieldsFor<'square_image'>
35
+
36
+ /**
37
+ * Erased shape used inside the adapter only. The public API stays generic over
38
+ * the attachment name; this is what the three field paths are resolved against.
39
+ */
40
+ interface ConcreteImageFields {
41
+ image_signed_id: string | null
42
+ image_preview_url: string | null
43
+ image_cleared: boolean
44
+ }
45
+
46
+ export interface ResourceImageFieldProps<Name extends string, T extends ImageFieldsFor<Name>> {
47
+ form: UseFormReturn<T>
48
+ /** The attachment's name on the model, e.g. `image`, `logo`, `cover_photo`. */
49
+ kind: Name
50
+ serverUrl: string | null
51
+ square?: boolean
52
+ /** Locale namespace holding the `images.*` copy, e.g. `admin.collections`. */
53
+ translationNamespace: string
54
+ /**
55
+ * Copy keys within `<translationNamespace>.images`. Default to the legacy
56
+ * `label`/`square_label` pair the two-image resources use; a resource with
57
+ * more than two attachments names them per field instead.
58
+ */
59
+ labelKey?: string
60
+ helpKey?: string
61
+ mediaLibrary?: MediaLibrarySource
62
+ /** Called with true while a picked image uploads; see `FileUploadField`. */
63
+ onUploadingChange?: (uploading: boolean) => void
64
+ }
65
+
66
+ /**
67
+ * Form adapter over the reusable {@link ImageUploadField}, shared by every
68
+ * resource whose form carries the image triple (categories, collections,
69
+ * sellers). Maps the triple onto the generic controlled `ImageUploadValue`
70
+ * and resolves labels from the owning resource's locale namespace.
71
+ */
72
+ export function ResourceImageField<Name extends string, T extends ImageFieldsFor<Name>>({
73
+ form,
74
+ kind,
75
+ serverUrl,
76
+ square = false,
77
+ translationNamespace,
78
+ labelKey,
79
+ helpKey,
80
+ mediaLibrary,
81
+ onUploadingChange,
82
+ }: ResourceImageFieldProps<Name, T>) {
83
+ const { t } = useTranslation()
84
+ const [picking, setPicking] = useState(false)
85
+
86
+ // The adapter only ever touches this one image triple, and always by the
87
+ // three names derived from `kind`. Narrow to a concrete triple so the
88
+ // field-path operations type-check: RHF's `Path<T>` cannot resolve a
89
+ // template-literal key against a generic mapped type, and `UseFormReturn`
90
+ // is invariant in its field-values type either way.
91
+ const imageForm = useMemo(() => form as unknown as UseFormReturn<ConcreteImageFields>, [form])
92
+
93
+ const signedIdField = `${kind}_signed_id` as 'image_signed_id'
94
+ const previewField = `${kind}_preview_url` as 'image_preview_url'
95
+ const clearedField = `${kind}_cleared` as 'image_cleared'
96
+
97
+ const resolvedLabelKey = labelKey ?? (square ? 'square_label' : 'label')
98
+ const resolvedHelpKey = helpKey ?? (square ? 'square_help' : 'help')
99
+
100
+ return (
101
+ <div className="space-y-2">
102
+ <ImageUploadField
103
+ square={square}
104
+ serverUrl={serverUrl}
105
+ label={t(`${translationNamespace}.images.${resolvedLabelKey}`)}
106
+ help={t(`${translationNamespace}.images.${resolvedHelpKey}`)}
107
+ onUploadingChange={onUploadingChange}
108
+ value={{
109
+ signedId: imageForm.watch(signedIdField),
110
+ previewUrl: imageForm.watch(previewField),
111
+ cleared: imageForm.watch(clearedField),
112
+ }}
113
+ onChange={(next) => {
114
+ imageForm.setValue(signedIdField, next.signedId, { shouldDirty: true })
115
+ imageForm.setValue(previewField, next.previewUrl, { shouldDirty: true })
116
+ imageForm.setValue(clearedField, next.cleared, { shouldDirty: true })
117
+ }}
118
+ />
119
+
120
+ {mediaLibrary && (
121
+ <>
122
+ <Button type="button" variant="outline" size="sm" onClick={() => setPicking(true)}>
123
+ <LibraryIcon className="size-4" />
124
+ {t('admin.media_library.choose_from_library')}
125
+ </Button>
126
+
127
+ <MediaPickerDialog<Media>
128
+ open={picking}
129
+ onOpenChange={setPicking}
130
+ {...mediaLibrary}
131
+ multiple={false}
132
+ queryKey={`image-field-${kind}`}
133
+ // Picking hands over the file's own blob, so this field and the library
134
+ // row point at one file — no upload, no second copy in storage.
135
+ onConfirm={(picked) => {
136
+ const media = picked[0]
137
+ if (!media?.signed_id) return
138
+
139
+ imageForm.setValue(signedIdField, media.signed_id, { shouldDirty: true })
140
+ imageForm.setValue(previewField, media.small_url ?? media.original_url ?? null, {
141
+ shouldDirty: true,
142
+ })
143
+ imageForm.setValue(clearedField, false, { shouldDirty: true })
144
+ }}
145
+ />
146
+ </>
147
+ )}
148
+ </div>
149
+ )
150
+ }
@@ -267,7 +267,7 @@ export function ResourcePickerSheet<T extends PickerOption>({
267
267
  <Loader2Icon className="size-5 animate-spin" />
268
268
  </div>
269
269
  ) : results.length === 0 ? (
270
- <p className="p-6 text-center text-muted-foreground text-sm">
270
+ <p className="p-6 text-center text-muted-foreground text-base">
271
271
  {t('admin.resource_picker.empty')}
272
272
  </p>
273
273
  ) : (
@@ -285,7 +285,7 @@ export function ResourcePickerSheet<T extends PickerOption>({
285
285
  disabled={selectableOnPage.length === 0 || selectingAll}
286
286
  className="pointer-events-none"
287
287
  />
288
- <span className="truncate text-sm">
288
+ <span className="truncate text-base">
289
289
  {t('admin.resource_picker.select_page', { count: selectableOnPage.length })}
290
290
  </span>
291
291
  </button>
@@ -36,6 +36,9 @@ import {
36
36
  TableHeader,
37
37
  TableHeaderRow,
38
38
  TableRow,
39
+ Tabs,
40
+ TabsList,
41
+ TabsTrigger,
39
42
  Tooltip,
40
43
  TooltipContent,
41
44
  TooltipTrigger,
@@ -142,10 +145,27 @@ export const resourceSearchSchema = z.object({
142
145
  return val
143
146
  }, z.array(z.string()))
144
147
  .optional(),
148
+ /** The open tab of a table with `views`; absent means the first. */
149
+ view: z.string().optional(),
145
150
  })
146
151
 
147
152
  export type ResourceSearch = z.infer<typeof resourceSearchSchema>
148
153
 
154
+ /**
155
+ * State for a `ResourceTable` held in the component rather than the URL, so
156
+ * several tables can sit on one page. Spread the result onto the table.
157
+ */
158
+ export function useEmbeddedTableSearch(initial: Partial<ResourceSearch> = {}) {
159
+ const [search, setSearch] = useState<Record<string, unknown>>(initial)
160
+ // Parsed once per change: the table memoizes on the parsed arrays.
161
+ const searchParams = useMemo(() => resourceSearchSchema.parse(search), [search])
162
+ return {
163
+ searchParams,
164
+ onSearchChange: (updates: Record<string, unknown>) =>
165
+ setSearch((previous) => ({ ...previous, ...updates })),
166
+ }
167
+ }
168
+
149
169
  // ============================================================================
150
170
  // Props
151
171
  // ============================================================================
@@ -166,6 +186,20 @@ export interface ResourceActionsContext {
166
186
  totalCount: number | undefined
167
187
  }
168
188
 
189
+ /**
190
+ * One tab of a list split into parts that are never read together, such as
191
+ * the operator's own stock locations and their sellers'. Each tab is a fixed
192
+ * slice of the list, not a filter the merchant can clear.
193
+ */
194
+ export interface TableView {
195
+ key: string
196
+ label: string
197
+ /** Request params sent while this tab is open, on top of `defaultParams`. */
198
+ params?: Record<string, unknown>
199
+ /** Columns this tab leaves out of the table, the column picker and the filters. */
200
+ hiddenColumns?: string[]
201
+ }
202
+
169
203
  interface ResourceTableProps<T> {
170
204
  /** Registry key (e.g., 'products') */
171
205
  tableKey: string
@@ -184,6 +218,12 @@ interface ResourceTableProps<T> {
184
218
  queryFn: (params: never) => Promise<{ data: T[]; meta: PaginationMeta }>
185
219
  /** Current search params from the route */
186
220
  searchParams: ResourceSearch
221
+ /**
222
+ * Takes the table's state changes instead of the URL — for a table embedded
223
+ * in a record page beside others, which would otherwise share one `page`
224
+ * and `filters`. Pair with `useEmbeddedTableSearch`.
225
+ */
226
+ onSearchChange?: (updates: Record<string, unknown>) => void
187
227
  /** Title displayed in the toolbar header. Overrides the table definition's title. */
188
228
  title?: string
189
229
  /**
@@ -203,6 +243,19 @@ interface ResourceTableProps<T> {
203
243
  docsPath?: string
204
244
  /** Default params always sent with every request (e.g., { complete: 1 } for orders) */
205
245
  defaultParams?: Record<string, unknown>
246
+ /** Splits the list into tabs; the first opens by default. */
247
+ views?: TableView[]
248
+ /**
249
+ * Columns left out entirely — not shown, not offered as filters — such as
250
+ * the seller on a seller's own page. Adds to the open view's.
251
+ */
252
+ hiddenColumns?: string[]
253
+ /**
254
+ * Draws the table as a card — border, corners and lift — holding its
255
+ * toolbar and pagination, with a plain, unpinned header row: for a table
256
+ * embedded among a record page's cards.
257
+ */
258
+ framed?: boolean
206
259
  /**
207
260
  * Actions to render in the toolbar (e.g., "Add Product" button). Either a
208
261
  * static node, or a function that receives the current table state — use
@@ -260,11 +313,15 @@ export function ResourceTable<T extends Record<string, any>>({
260
313
  queryKey,
261
314
  queryFn,
262
315
  searchParams,
316
+ onSearchChange,
263
317
  title,
264
318
  hideHeader = false,
265
319
  description,
266
320
  docsPath,
267
- defaultParams,
321
+ defaultParams: baseParams,
322
+ views,
323
+ hiddenColumns: tableHiddenColumns,
324
+ framed = false,
268
325
  actions,
269
326
  reorder,
270
327
  bulkActions,
@@ -296,8 +353,19 @@ export function ResourceTable<T extends Record<string, any>>({
296
353
  search,
297
354
  filters,
298
355
  columns: urlColumns,
356
+ view: urlView,
299
357
  } = searchParams
300
358
 
359
+ const activeView = views?.find((v) => v.key === urlView) ?? views?.[0]
360
+ const hiddenColumns = useMemo(
361
+ () => [...(tableHiddenColumns ?? []), ...(activeView?.hiddenColumns ?? [])],
362
+ [tableHiddenColumns, activeView],
363
+ )
364
+ const defaultParams = useMemo(
365
+ () => (activeView?.params ? { ...baseParams, ...activeView.params } : baseParams),
366
+ [baseParams, activeView],
367
+ )
368
+
301
369
  // When the table is reorderable, sort is locked to the position field
302
370
  // ascending — anything else makes drag-and-drop meaningless. Otherwise
303
371
  // fall through to the URL params, then the table's declared default,
@@ -310,21 +378,24 @@ export function ResourceTable<T extends Record<string, any>>({
310
378
  const sort = reorder ? positionField : (urlSort ?? defaultSort.field)
311
379
  const dir: 'asc' | 'desc' = reorder ? 'asc' : (urlDir ?? defaultSort.direction)
312
380
 
313
- const [searchInput, setSearchInput] = useState(search ?? '')
381
+ const searchable = table.searchable !== false
382
+ const [searchInput, setSearchInput] = useState(searchable ? (search ?? '') : '')
314
383
  const deferredSearch = useDeferredValue(searchInput)
315
384
 
316
385
  // Registry columns + dynamic custom-field columns. Memoized so the toolbar's
317
386
  // internal memos (FilterPanel `items` stability) keep stable deps.
318
387
  const extraColumns = customFieldColumns ?? metafieldColumns
319
388
  const allColumns = useMemo(
320
- () => [...table.columns, ...(extraColumns ?? [])],
321
- [table, extraColumns],
389
+ () => [...table.columns, ...(extraColumns ?? [])].filter((c) => !hiddenColumns.includes(c.key)),
390
+ [table, extraColumns, hiddenColumns],
322
391
  )
323
392
  const displayableColumns = useMemo(
324
393
  () => allColumns.filter((c) => c.displayable !== false),
325
394
  [allColumns],
326
395
  )
327
- const defaultColumnKeys = getDefaultColumnKeys(table)
396
+ const defaultColumnKeys = getDefaultColumnKeys(table).filter(
397
+ (key) => !hiddenColumns.includes(key),
398
+ )
328
399
  const visibleColumnKeys = urlColumns ?? defaultColumnKeys
329
400
 
330
401
  const visibleColumns = useMemo(
@@ -375,7 +446,17 @@ export function ResourceTable<T extends Record<string, any>>({
375
446
  const { data, isLoading } = useQuery({
376
447
  queryKey: [
377
448
  ...queryKeyPrefix,
378
- { page, limit, sort: sortString, search: deferredSearch, filters, expand: columnExpand },
449
+ // The fixed params are part of the key: a tab changing them would
450
+ // otherwise be answered from the previous tab's cache.
451
+ {
452
+ page,
453
+ limit,
454
+ sort: sortString,
455
+ search: deferredSearch,
456
+ filters,
457
+ expand: columnExpand,
458
+ params: defaultParams,
459
+ },
379
460
  ],
380
461
  queryFn: () => {
381
462
  const params: Record<string, unknown> = {
@@ -470,6 +551,10 @@ export function ResourceTable<T extends Record<string, any>>({
470
551
 
471
552
  // Navigation helpers
472
553
  function updateSearch(updates: Record<string, unknown>) {
554
+ if (onSearchChange) {
555
+ onSearchChange(updates)
556
+ return
557
+ }
473
558
  navigate({
474
559
  search: (prev: Record<string, unknown>) => ({ ...prev, ...updates }) as never,
475
560
  })
@@ -491,6 +576,16 @@ export function ResourceTable<T extends Record<string, any>>({
491
576
  })
492
577
  }
493
578
 
579
+ // A filter set on one tab can name a column the next one hides, so a tab
580
+ // starts unfiltered.
581
+ function handleViewChange(key: string) {
582
+ updateSearch({
583
+ view: key === views?.[0]?.key ? undefined : key,
584
+ filters: undefined,
585
+ page: 1,
586
+ })
587
+ }
588
+
494
589
  function handleColumnsChange(cols: string[]) {
495
590
  const isDefault =
496
591
  cols.length === defaultColumnKeys.length && cols.every((c) => defaultColumnKeys.includes(c))
@@ -543,6 +638,31 @@ export function ResourceTable<T extends Record<string, any>>({
543
638
  })
544
639
  : actions
545
640
 
641
+ const toolbar = (
642
+ <TableToolbar
643
+ columns={displayableColumns}
644
+ visibleColumns={visibleColumnKeys}
645
+ onVisibleColumnsChange={handleColumnsChange}
646
+ search={searchInput}
647
+ onSearchChange={handleSearchChange}
648
+ searchable={searchable}
649
+ searchPlaceholder={table.searchPlaceholder ?? t('admin.common.search_placeholder')}
650
+ sort={{ field: sort, direction: dir }}
651
+ onSortChange={handleSortChange}
652
+ filters={filters as FilterRule[]}
653
+ onFiltersChange={handleFiltersChange}
654
+ allColumns={allColumns}
655
+ hideSort={reorderActive}
656
+ />
657
+ )
658
+ const pagination = meta && (
659
+ <Pagination
660
+ meta={meta}
661
+ onPageChange={(p) => updateSearch({ page: p })}
662
+ onPageSizeChange={(size) => updateSearch({ limit: size, page: 1 })}
663
+ />
664
+ )
665
+
546
666
  return (
547
667
  // Below `sm` the table spans the full width: the page gutter plus the
548
668
  // card's own border spends ~34px of a 390px screen on chrome, and a table
@@ -589,26 +709,31 @@ export function ResourceTable<T extends Record<string, any>>({
589
709
  {pageOwnsHeader && resolvedActions && (
590
710
  <div className="flex flex-wrap items-center justify-end gap-2">{resolvedActions}</div>
591
711
  )}
592
- <TableToolbar
593
- columns={displayableColumns}
594
- visibleColumns={visibleColumnKeys}
595
- onVisibleColumnsChange={handleColumnsChange}
596
- search={searchInput}
597
- onSearchChange={handleSearchChange}
598
- searchPlaceholder={table.searchPlaceholder ?? t('admin.common.search_placeholder')}
599
- sort={{ field: sort, direction: dir }}
600
- onSortChange={handleSortChange}
601
- filters={filters as FilterRule[]}
602
- onFiltersChange={handleFiltersChange}
603
- allColumns={allColumns}
604
- hideSort={reorderActive}
605
- />
606
- {/* `overflow-x-clip`, never `overflow-hidden`: the rows clip to the
607
- rounded frame horizontally, but a `hidden` overflow on either axis
608
- makes this a scroll container, and the pinned column row inside
609
- would then stick to THIS box rather than to the page — which is to
610
- say, scroll away with it. */}
611
- <Card className="-mx-4 gap-0 overflow-x-clip rounded-none border-0 bg-transparent py-0 shadow-none sm:mx-0 sm:rounded-xl sm:border">
712
+ {views && views.length > 1 && (
713
+ <Tabs value={activeView?.key} onValueChange={(v) => handleViewChange(v as string)}>
714
+ <TabsList>
715
+ {views.map((v) => (
716
+ <TabsTrigger key={v.key} value={v.key}>
717
+ {v.label}
718
+ </TabsTrigger>
719
+ ))}
720
+ </TabsList>
721
+ </Tabs>
722
+ )}
723
+ {!framed && toolbar}
724
+ {/* Borderless: the list sits on the page with no card around it, its
725
+ header a rounded band and its rows divided by their own rules.
726
+ `overflow-x-clip`, never `overflow-hidden`: a `hidden` overflow on
727
+ either axis makes this a scroll container, and the pinned column row
728
+ inside would then stick to THIS box rather than to the page — which
729
+ is to say, scroll away with it. */}
730
+ <Card
731
+ className={cn(
732
+ 'gap-0 overflow-x-clip',
733
+ !framed && 'rounded-none border-0 bg-transparent shadow-none',
734
+ )}
735
+ >
736
+ {framed && <div className="p-3">{toolbar}</div>}
612
737
  {reorderActive ? (
613
738
  <DndContext
614
739
  sensors={sensors}
@@ -619,7 +744,7 @@ export function ResourceTable<T extends Record<string, any>>({
619
744
  items={rows.map((r) => (r as any).id)}
620
745
  strategy={verticalListSortingStrategy}
621
746
  >
622
- <Table stickyHeader roundedTop roundedBottom>
747
+ <Table stickyHeader={!framed} band={!framed}>
623
748
  <TableHeader>
624
749
  <TableHeaderRow>
625
750
  <TableHead className="w-8" />
@@ -689,7 +814,8 @@ export function ResourceTable<T extends Record<string, any>>({
689
814
  // checkbox sitting beneath it — checking once, then never
690
815
  // unchecking.
691
816
  <div className="pointer-events-none sticky top-header-height z-30 h-0">
692
- <div className="flex h-[2.1875rem] items-center ps-10 pe-4">
817
+ {/* The header band's height, so the actions centre on its checkbox. */}
818
+ <div className="flex h-10 items-center ps-10 pe-4">
693
819
  <div className="pointer-events-auto min-w-0 flex-1">
694
820
  <BulkActionBar
695
821
  selectedIds={Array.from(selectedIds)}
@@ -739,7 +865,7 @@ export function ResourceTable<T extends Record<string, any>>({
739
865
  </section>,
740
866
  document.body,
741
867
  )}
742
- <Table stickyHeader roundedTop roundedBottom>
868
+ <Table stickyHeader={!framed} band={!framed}>
743
869
  <TableHeader>
744
870
  {/* Column headers stay mounted with rows selected, and the bulk
745
871
  bar is laid over them. Swapping them for one `colSpan` cell
@@ -833,10 +959,8 @@ export function ResourceTable<T extends Record<string, any>>({
833
959
  // the panel floats over its neighbours, and without this
834
960
  // a merchant editing a figure has to count rows to see
835
961
  // which record they are editing.
836
- className={cn(
837
- 'has-data-[popup-open]:bg-accent-strong/40',
838
- isSelected && 'bg-accent-strong/75 hover:bg-accent-strong-hover',
839
- )}
962
+ // The table's `band` style paints both from these.
963
+ data-selected={isSelected || undefined}
840
964
  >
841
965
  {selectionEnabled && (
842
966
  <TableCell className="w-8">
@@ -863,14 +987,11 @@ export function ResourceTable<T extends Record<string, any>>({
863
987
  </Table>
864
988
  </>
865
989
  )}
990
+ {/* Nothing to page through in an empty table. No rule of its own: the
991
+ last row's border already divides the two. */}
992
+ {framed && !!meta?.count && <div className="px-3 py-2">{pagination}</div>}
866
993
  </Card>
867
- {meta && (
868
- <Pagination
869
- meta={meta}
870
- onPageChange={(p) => updateSearch({ page: p })}
871
- onPageSizeChange={(size) => updateSearch({ limit: size, page: 1 })}
872
- />
873
- )}
994
+ {!framed && pagination}
874
995
  {/* Clears the fixed bottom bar so it can't cover the pagination or the
875
996
  last row while a selection is active. Carries the same safe-area
876
997
  inset the bar pads itself by, or the home indicator's worth of bar
@@ -49,12 +49,12 @@ export function SectionHeading({
49
49
  <CardTitle className="min-w-0 truncate text-lg">{title}</CardTitle>
50
50
  )}
51
51
  {(description || docsPath) && (
52
- <p className="text-muted-foreground text-sm">
52
+ <p className="text-muted-foreground text-base">
53
53
  {description}
54
54
  {docsPath && (
55
55
  <>
56
56
  {description ? ' ' : null}
57
- <ExternalLink href={docsUrl(docsPath)} className="text-sm">
57
+ <ExternalLink href={docsUrl(docsPath)} className="text-base">
58
58
  {t('admin.common.learn_more')}
59
59
  </ExternalLink>
60
60
  </>
@@ -0,0 +1,19 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /**
4
+ * Settings content padding. The settings nav lives one level up, in the shared
5
+ * app shell's chrome, so this container owns only the content's padding.
6
+ *
7
+ * Every settings page inherits it — a page must NOT add its own
8
+ * `p-4`/`container`/`mx-auto`, or it double-pads and drifts from the rest of
9
+ * the area. Start a settings page at `<div className="flex flex-col gap-6">`.
10
+ */
11
+ export function SettingsContainer({ children }: { children: ReactNode }) {
12
+ return (
13
+ // The same `container` as every other page, so settings content lines up
14
+ // with them.
15
+ <div className="container mx-auto flex flex-1 flex-col gap-4 p-4 lg:gap-6 lg:p-6">
16
+ {children}
17
+ </div>
18
+ )
19
+ }