@spree/dashboard-core 6.0.3 → 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 (86) hide show
  1. package/dist/vite/index.js +81 -5
  2. package/package.json +19 -19
  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 +1 -1
  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 +189 -323
  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 +19 -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 +120 -14
  56. package/src/locales/de.json +120 -14
  57. package/src/locales/en.json +120 -14
  58. package/src/locales/es.json +120 -14
  59. package/src/locales/fr.json +120 -14
  60. package/src/locales/ja.json +120 -14
  61. package/src/locales/pl.json +120 -14
  62. package/src/locales/vi.json +120 -14
  63. package/src/locales/zh-CN.json +120 -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 +201 -70
  72. package/src/products/product-form-mapping.ts +1 -0
  73. package/src/products/product-schema.ts +8 -0
  74. package/src/products/product-types.ts +2 -0
  75. package/src/products/use-product-form-data.ts +30 -3
  76. package/src/products/variant-edit-sheet.tsx +3 -3
  77. package/src/products/variants-options-builder.tsx +13 -8
  78. package/src/products/variants-section.tsx +196 -4
  79. package/src/providers/auth-provider.tsx +2 -0
  80. package/src/providers/developer-inspector-provider.tsx +145 -0
  81. package/src/providers/json-link-resolver-provider.tsx +27 -0
  82. package/src/sellers/seller-schema.ts +107 -0
  83. package/src/tables/package-types.tsx +3 -3
  84. package/src/tables/stock-locations.tsx +15 -14
  85. package/src/virtual.d.ts +4 -0
  86. package/src/vite/index.ts +103 -5
@@ -110,7 +110,12 @@ export function VariantsOptionsBuilder({ selected, onChange, extraOptionTypes }:
110
110
  const isEditing = expandedIndex === i
111
111
  if (isEditing && optionType) {
112
112
  return (
113
- <li key={ot.id} className="rounded-lg border border-border-subtle p-3">
113
+ <Item
114
+ variant="outline"
115
+ render={<li />}
116
+ key={ot.id}
117
+ className="flex-col items-stretch border-border-subtle"
118
+ >
114
119
  <OptionPicker
115
120
  optionType={optionType}
116
121
  initialValues={ot.values}
@@ -130,7 +135,7 @@ export function VariantsOptionsBuilder({ selected, onChange, extraOptionTypes }:
130
135
  })
131
136
  }
132
137
  />
133
- </li>
138
+ </Item>
134
139
  )
135
140
  }
136
141
  return (
@@ -157,7 +162,7 @@ export function VariantsOptionsBuilder({ selected, onChange, extraOptionTypes }:
157
162
  </Button>
158
163
  <Button
159
164
  type="button"
160
- variant="ghost"
165
+ variant="destructive-ghost"
161
166
  size="icon-sm"
162
167
  aria-label={t('admin.actions.remove')}
163
168
  onClick={() => removeAt(i)}
@@ -172,14 +177,14 @@ export function VariantsOptionsBuilder({ selected, onChange, extraOptionTypes }:
172
177
  )}
173
178
 
174
179
  {isAdding ? (
175
- <div className="rounded-lg border border-border-subtle p-3">
180
+ <Item variant="outline" className="flex-col items-stretch border-border-subtle">
176
181
  <AddOptionForm
177
182
  availableTypes={availableTypes}
178
183
  allOptionTypes={allOptionTypes}
179
184
  onCancel={() => setIsAdding(false)}
180
185
  onSave={(payload) => upsertAt(null, payload)}
181
186
  />
182
- </div>
187
+ </Item>
183
188
  ) : (
184
189
  <div>
185
190
  <Button type="button" variant="outline" size="sm" onClick={() => setIsAdding(true)}>
@@ -373,7 +378,7 @@ function OptionPicker({ optionType, initialValues, onSave, onCancel }: OptionPic
373
378
  return (
374
379
  <div className="flex flex-col gap-3">
375
380
  <div className="flex items-center justify-between">
376
- <div className="text-sm font-medium">{optionType.label}</div>
381
+ <div className="text-base font-medium">{optionType.label}</div>
377
382
  </div>
378
383
  <div className="flex flex-wrap gap-2">
379
384
  {available.map((v) => {
@@ -461,7 +466,7 @@ function CreateOptionTypeInline({
461
466
 
462
467
  return (
463
468
  <div className="flex flex-col gap-3">
464
- <div className="text-sm font-medium">
469
+ <div className="text-base font-medium">
465
470
  {t('admin.products.variants.create_option_type_title')}
466
471
  </div>
467
472
  <Field>
@@ -530,7 +535,7 @@ function CreateOptionValueInline({ onCancel, onCreate, isPending }: CreateOption
530
535
 
531
536
  return (
532
537
  <div className="flex flex-col gap-3">
533
- <div className="text-sm font-medium">{t('admin.products.variants.create_value_title')}</div>
538
+ <div className="text-base font-medium">{t('admin.products.variants.create_value_title')}</div>
534
539
  <Field>
535
540
  <FieldLabel htmlFor="new-option-value-label">
536
541
  {t('admin.fields.option_value.label.label')}
@@ -33,12 +33,23 @@ import {
33
33
  TableHead,
34
34
  TableHeader,
35
35
  TableRow,
36
+ Tooltip,
37
+ TooltipContent,
38
+ TooltipTrigger,
36
39
  } from '@spree/dashboard-ui'
37
- import { PencilIcon, SwatchBookIcon, TriangleAlertIcon, XIcon } from '@spree/dashboard-ui/icons'
40
+ import {
41
+ ImageIcon,
42
+ ImagePlusIcon,
43
+ PencilIcon,
44
+ SwatchBookIcon,
45
+ TriangleAlertIcon,
46
+ XIcon,
47
+ } from '@spree/dashboard-ui/icons'
38
48
  import { type CSSProperties, useEffect, useMemo, useState } from 'react'
39
49
  import { type UseFormReturn, useFieldArray, useWatch } from 'react-hook-form'
40
50
  import { useTranslation } from 'react-i18next'
41
51
  import type { PanelOptionType as OptionType } from '../api-client'
52
+ import { MediaPickerDialog } from '../components/media-picker-dialog'
42
53
  import { BulkVariantsDialog } from './bulk-variants-dialog'
43
54
  import {
44
55
  useFormOptionTypes as useOptionTypes,
@@ -145,6 +156,7 @@ export function VariantsSection({ form, seedFromType = false, formKey = 'product
145
156
  })
146
157
 
147
158
  const [editingIndex, setEditingIndex] = useState<number | null>(null)
159
+ const [pickingImagesFor, setPickingImagesFor] = useState<number | null>(null)
148
160
  const [sheetOpen, setSheetOpen] = useState(false)
149
161
  const [bulkOpen, setBulkOpen] = useState(false)
150
162
  const [orphanedKeys, setOrphanedKeys] = useState<string[]>([])
@@ -407,7 +419,7 @@ export function VariantsSection({ form, seedFromType = false, formKey = 'product
407
419
  items={fields.map((f) => f._key)}
408
420
  strategy={verticalListSortingStrategy}
409
421
  >
410
- <Table>
422
+ <Table roundedTop>
411
423
  <TableHeader>
412
424
  <TableRow>
413
425
  {!isSimpleProduct && (
@@ -433,6 +445,7 @@ export function VariantsSection({ form, seedFromType = false, formKey = 'product
433
445
  optionTypes={allOptionTypes}
434
446
  onEdit={() => handleEditRow(index)}
435
447
  onRemove={() => handleRowRemove(index)}
448
+ onPickImages={() => setPickingImagesFor(index)}
436
449
  />
437
450
  ))}
438
451
  </TableBody>
@@ -445,6 +458,15 @@ export function VariantsSection({ form, seedFromType = false, formKey = 'product
445
458
 
446
459
  <BulkVariantsDialog form={form} open={bulkOpen} onOpenChange={setBulkOpen} />
447
460
 
461
+ {pickingImagesFor !== null && (
462
+ <VariantImagesPicker
463
+ form={form}
464
+ variantIndex={pickingImagesFor}
465
+ optionTypes={allOptionTypes}
466
+ onClose={() => setPickingImagesFor(null)}
467
+ />
468
+ )}
469
+
448
470
  {editingIndex !== null && (
449
471
  <VariantEditSheet
450
472
  form={form}
@@ -470,6 +492,7 @@ interface SortableVariantRowProps {
470
492
  optionTypes: OptionType[]
471
493
  onEdit: () => void
472
494
  onRemove: () => void
495
+ onPickImages: () => void
473
496
  }
474
497
 
475
498
  function SortableVariantRow({
@@ -480,6 +503,7 @@ function SortableVariantRow({
480
503
  optionTypes,
481
504
  onEdit,
482
505
  onRemove,
506
+ onPickImages,
483
507
  }: SortableVariantRowProps) {
484
508
  const { t } = useTranslation()
485
509
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -510,7 +534,19 @@ function SortableVariantRow({
510
534
  <DragHandle attributes={attributes} listeners={listeners} />
511
535
  </TableCell>
512
536
  )}
513
- <TableCell className="font-medium">{label}</TableCell>
537
+ <TableCell className="font-medium">
538
+ <div className="flex items-center gap-3">
539
+ {!isSimpleProduct && (
540
+ <VariantImageButton
541
+ form={form}
542
+ variantId={variant.id}
543
+ label={label}
544
+ onPick={onPickImages}
545
+ />
546
+ )}
547
+ {label}
548
+ </div>
549
+ </TableCell>
514
550
  <TableCell>
515
551
  <Input
516
552
  aria-label={t('admin.fields.variant.sku.label')}
@@ -532,7 +568,7 @@ function SortableVariantRow({
532
568
  {!isSimpleProduct && (
533
569
  <Button
534
570
  type="button"
535
- variant="ghost"
571
+ variant="destructive-ghost"
536
572
  size="icon-sm"
537
573
  aria-label={t('admin.products.variants.remove_row_aria', { name: label })}
538
574
  onClick={onRemove}
@@ -545,3 +581,159 @@ function SortableVariantRow({
545
581
  </TableRow>
546
582
  )
547
583
  }
584
+
585
+ type MediaRow = NonNullable<ProductFormValues['media']>[number]
586
+
587
+ /** A media row's stable key: its id once saved, before that its upload's. */
588
+ function mediaRowKey(media: MediaRow): string | null {
589
+ return media.id ?? media.signed_id ?? media.uploadId ?? null
590
+ }
591
+
592
+ /**
593
+ * A variant's image: its first assigned image (in gallery
594
+ * order) as a thumbnail, with a count when it has more, or an empty dashed
595
+ * square. Opens the picker of the product's images. An unsaved variant has no
596
+ * id to assign media to yet, so its square waits for the save.
597
+ */
598
+ function VariantImageButton({
599
+ form,
600
+ variantId,
601
+ label,
602
+ onPick,
603
+ }: {
604
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
605
+ form: UseFormReturn<ProductFormValues, any, any>
606
+ variantId?: string | null
607
+ label: string
608
+ onPick: () => void
609
+ }) {
610
+ const { t } = useTranslation()
611
+ const media = useWatch({ control: form.control, name: 'media' }) ?? []
612
+ const assigned = variantId ? media.filter((m) => m.variant_ids?.includes(variantId)) : []
613
+ const first = assigned[0]
614
+ const preview = first?.previewUrl ?? null
615
+
616
+ const button = (
617
+ <button
618
+ type="button"
619
+ // `aria-disabled` rather than `disabled`: a disabled button gets no
620
+ // pointer events, so the tooltip saying why could never open.
621
+ onClick={variantId ? onPick : undefined}
622
+ aria-disabled={!variantId || undefined}
623
+ aria-label={t('admin.products.variants.images_aria', { name: label })}
624
+ className={cn(
625
+ 'relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md bg-card text-muted-foreground transition-colors hover:text-foreground focus-visible:shadow-field-focus aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:hover:text-muted-foreground',
626
+ first ? 'border border-border-card' : 'border border-border border-dashed',
627
+ )}
628
+ >
629
+ {preview ? (
630
+ <img src={preview} alt="" className="size-full object-cover" />
631
+ ) : first ? (
632
+ <ImageIcon className="size-4" />
633
+ ) : (
634
+ <ImagePlusIcon className="size-4" />
635
+ )}
636
+ {assigned.length > 1 && (
637
+ <span className="absolute right-0.5 bottom-0.5 rounded bg-black/60 px-1 text-[10px] text-white tabular-nums leading-4">
638
+ +{assigned.length - 1}
639
+ </span>
640
+ )}
641
+ </button>
642
+ )
643
+
644
+ if (variantId) return button
645
+
646
+ return (
647
+ <Tooltip>
648
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
649
+ <TooltipContent>{t('admin.products.variants.images_unsaved')}</TooltipContent>
650
+ </Tooltip>
651
+ )
652
+ }
653
+
654
+ /**
655
+ * The media picker over this product's own images, with the variant's
656
+ * current ones ticked. Done applies exactly the ticked set — the same
657
+ * many-to-many assignment the media editor's variant pills make, seen from
658
+ * the variant's side — and, like every product edit, saves with the product.
659
+ */
660
+ function VariantImagesPicker({
661
+ form,
662
+ variantIndex,
663
+ optionTypes,
664
+ onClose,
665
+ }: {
666
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
667
+ form: UseFormReturn<ProductFormValues, any, any>
668
+ variantIndex: number
669
+ optionTypes: OptionType[]
670
+ onClose: () => void
671
+ }) {
672
+ const { t } = useTranslation()
673
+ const variant = form.getValues(`variants.${variantIndex}`)
674
+ const variantId = variant?.id
675
+ if (!variant || !variantId) return null
676
+
677
+ const label = variantDisplayLabel(
678
+ variant,
679
+ t('admin.products.variants.default_variant'),
680
+ optionTypes,
681
+ )
682
+ const rows = form.getValues('media') ?? []
683
+ const options = rows.flatMap((m) => {
684
+ const key = mediaRowKey(m)
685
+ if (!key) return []
686
+ return [
687
+ {
688
+ id: key,
689
+ alt: m.alt,
690
+ filename: m.filename,
691
+ media_type: m.media_type,
692
+ small_url: m.previewUrl ?? null,
693
+ },
694
+ ]
695
+ })
696
+ const assigned = new Set(
697
+ rows.filter((m) => m.variant_ids?.includes(variantId)).flatMap((m) => mediaRowKey(m) ?? []),
698
+ )
699
+ const initialSelection = options.filter((option) => assigned.has(option.id))
700
+
701
+ return (
702
+ <MediaPickerDialog
703
+ open
704
+ onOpenChange={(open) => !open && onClose()}
705
+ // Keyed on the files and their alt text, so a file added or renamed
706
+ // since the last opening shows up rather than a cached copy.
707
+ queryKey={`variant-images-${variantId}-${options.map((o) => `${o.id}:${o.alt ?? ''}`).join(',')}`}
708
+ // Local, so the list is the form's — including files added this session.
709
+ search={async (query) => ({
710
+ data: query
711
+ ? options.filter((o) =>
712
+ `${o.alt ?? ''} ${o.filename ?? ''}`.toLowerCase().includes(query.toLowerCase()),
713
+ )
714
+ : options,
715
+ })}
716
+ initialSelection={initialSelection}
717
+ allowEmptyConfirm
718
+ browseControls={false}
719
+ // Listed in the Media card's order, so the first ticked is the one the
720
+ // variant row shows; the badge says which, live.
721
+ firstSelectedBadge={t('admin.products.variants.thumbnail_badge')}
722
+ title={t('admin.products.variants.images_title', { name: label })}
723
+ description={t('admin.products.variants.images_description')}
724
+ confirmLabel={t('admin.actions.done')}
725
+ onConfirm={(picked) => {
726
+ const ticked = new Set(picked.map((media) => media.id))
727
+ const next = (form.getValues('media') ?? []).map((m) => {
728
+ const ids = new Set(m.variant_ids ?? [])
729
+ const key = mediaRowKey(m)
730
+ if (key && ticked.has(key)) ids.add(variantId)
731
+ else ids.delete(variantId)
732
+ return { ...m, variant_ids: [...ids] }
733
+ })
734
+ form.setValue('media', next, { shouldDirty: true })
735
+ onClose()
736
+ }}
737
+ />
738
+ )
739
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  } from '@spree/admin-sdk'
8
8
  import { createContext, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
9
9
  import { getApiClient, type PanelSession } from '../api-client'
10
+ import { clearDeveloperInspector } from '../lib/developer-inspector'
10
11
  import { ADMIN_LOCALE_STORAGE_KEY, sessionLocale, switchLocale } from '../lib/i18n'
11
12
  import { queryClient } from '../lib/query-client'
12
13
 
@@ -122,6 +123,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
122
123
  // landed. Server-side authorization is unaffected either way; this is about
123
124
  // not showing one principal what another may do.
124
125
  queryClient.clear()
126
+ clearDeveloperInspector()
125
127
  setToken(null)
126
128
  setUser(null)
127
129
  clearRefreshTimer()
@@ -0,0 +1,145 @@
1
+ import { toastManager } from '@spree/dashboard-ui'
2
+ import { useHotkey } from '@tanstack/react-hotkeys'
3
+ import {
4
+ createContext,
5
+ lazy,
6
+ type ReactNode,
7
+ Suspense,
8
+ useContext,
9
+ useEffect,
10
+ useMemo,
11
+ useState,
12
+ useSyncExternalStore,
13
+ } from 'react'
14
+ import { useTranslation } from 'react-i18next'
15
+ import {
16
+ getDeveloperInspectorEntries,
17
+ subscribeDeveloperInspector,
18
+ } from '../lib/developer-inspector'
19
+
20
+ // Developer-only, so the drawer and its JSON viewer ship in their own chunk,
21
+ // fetched the first time an admin opens it.
22
+ const DeveloperInspectorDrawer = lazy(() =>
23
+ import('../components/developer-inspector-drawer').then((m) => ({
24
+ default: m.DeveloperInspectorDrawer,
25
+ })),
26
+ )
27
+
28
+ export const DEVELOPER_INSPECTOR_HOTKEY = 'Mod+Shift+D'
29
+
30
+ interface DeveloperInspectorState {
31
+ open: boolean
32
+ setOpen: (open: boolean) => void
33
+ /** Opens the panel's feedback dialog, when it has one; `'bug'` opens it on a bug report. */
34
+ sendFeedback?: (kind?: 'bug') => void
35
+ }
36
+
37
+ const DeveloperInspectorContext = createContext<DeveloperInspectorState | null>(null)
38
+
39
+ /**
40
+ * The developer inspector: the admin client's recent requests in a bottom
41
+ * drawer, for debugging an integration or filing an issue. Opened from the
42
+ * account menu or the keyboard shortcut.
43
+ *
44
+ * Records only what `adminClient` sends, so a panel built on another client
45
+ * mounts it to no effect.
46
+ */
47
+ export function DeveloperInspectorProvider({
48
+ children,
49
+ systemInfoPath,
50
+ onSendFeedback,
51
+ }: {
52
+ children: ReactNode
53
+ /**
54
+ * Offered in the account menu, the drawer and on a server error, when the
55
+ * panel takes feedback.
56
+ */
57
+ onSendFeedback?: (kind?: 'bug') => void
58
+ /** Linked from the drawer header, when the panel has a system info page. */
59
+ systemInfoPath?: string
60
+ }) {
61
+ const [open, setOpen] = useState(false)
62
+ // Latches on first open so the lazy chunk loads only for admins who use it,
63
+ // and the drawer keeps its state between openings.
64
+ const [mounted, setMounted] = useState(false)
65
+
66
+ const value = useMemo<DeveloperInspectorState>(
67
+ () => ({
68
+ open,
69
+ setOpen: (next) => {
70
+ if (next) setMounted(true)
71
+ setOpen(next)
72
+ },
73
+ sendFeedback: onSendFeedback,
74
+ }),
75
+ [open, onSendFeedback],
76
+ )
77
+
78
+ useServerErrorToast(onSendFeedback)
79
+
80
+ useHotkey(DEVELOPER_INSPECTOR_HOTKEY, () => value.setOpen(!open))
81
+
82
+ return (
83
+ <DeveloperInspectorContext.Provider value={value}>
84
+ {children}
85
+ {mounted && (
86
+ <Suspense fallback={null}>
87
+ <DeveloperInspectorDrawer
88
+ open={value.open}
89
+ onOpenChange={value.setOpen}
90
+ systemInfoPath={systemInfoPath}
91
+ />
92
+ </Suspense>
93
+ )}
94
+ </DeveloperInspectorContext.Provider>
95
+ )
96
+ }
97
+
98
+ // One toast for a burst of failures: a failed query is retried, and a page
99
+ // often fires several requests that fail for the same reason.
100
+ const SERVER_ERROR_TOAST_INTERVAL_MS = 10_000
101
+
102
+ /**
103
+ * Offers to report a bug when the Admin API answers with a 5xx: that is a
104
+ * bug in Spree or the installation, not something the admin did wrong.
105
+ */
106
+ function useServerErrorToast(sendFeedback?: (kind?: 'bug') => void) {
107
+ const { t } = useTranslation()
108
+
109
+ useEffect(() => {
110
+ if (!sendFeedback) return
111
+ let seen = getDeveloperInspectorEntries()[0]?.id ?? 0
112
+ let toastedAt = 0
113
+
114
+ return subscribeDeveloperInspector(() => {
115
+ const entries = getDeveloperInspectorEntries()
116
+ const fresh = entries.filter((entry) => entry.id > seen)
117
+ seen = entries[0]?.id ?? seen
118
+ const failure = fresh.find((entry) => entry.status !== null && entry.status >= 500)
119
+ if (!failure || Date.now() - toastedAt < SERVER_ERROR_TOAST_INTERVAL_MS) return
120
+
121
+ toastedAt = Date.now()
122
+ toastManager.add({
123
+ type: 'error',
124
+ title: t('admin.components.server_error.title'),
125
+ description: failure.requestId
126
+ ? t('admin.components.server_error.request_id', { id: failure.requestId })
127
+ : undefined,
128
+ actionProps: {
129
+ children: t('admin.components.server_error.report'),
130
+ onClick: () => sendFeedback('bug'),
131
+ },
132
+ })
133
+ })
134
+ }, [sendFeedback, t])
135
+ }
136
+
137
+ /** The inspector's state when a provider is mounted, `null` otherwise. */
138
+ export function useOptionalDeveloperInspector(): DeveloperInspectorState | null {
139
+ return useContext(DeveloperInspectorContext)
140
+ }
141
+
142
+ /** The recorded requests, newest first. */
143
+ export function useDeveloperInspectorEntries() {
144
+ return useSyncExternalStore(subscribeDeveloperInspector, getDeveloperInspectorEntries)
145
+ }
@@ -0,0 +1,27 @@
1
+ import type { JsonValueLinkResolver } from '@spree/dashboard-ui/spree/json-preview-drawer'
2
+ import { createContext, type ReactNode, useContext } from 'react'
3
+
4
+ const JsonLinkResolverContext = createContext<JsonValueLinkResolver | undefined>(undefined)
5
+
6
+ /**
7
+ * Supplies the resolver every "View as JSON" drawer uses to turn IDs in a
8
+ * payload into links. The admin dashboard mounts it with its prefixed-ID
9
+ * routes; a panel that mounts none gets plain, unlinked JSON.
10
+ */
11
+ export function JsonLinkResolverProvider({
12
+ resolveLink,
13
+ children,
14
+ }: {
15
+ resolveLink: JsonValueLinkResolver
16
+ children: ReactNode
17
+ }) {
18
+ return (
19
+ <JsonLinkResolverContext.Provider value={resolveLink}>
20
+ {children}
21
+ </JsonLinkResolverContext.Provider>
22
+ )
23
+ }
24
+
25
+ export function useJsonLinkResolver(): JsonValueLinkResolver | undefined {
26
+ return useContext(JsonLinkResolverContext)
27
+ }
@@ -0,0 +1,107 @@
1
+ import type { Seller, SellerCreateParams } from '@spree/admin-sdk'
2
+ import { PAYOUT_SCHEDULE_INTERVALS, TAX_REMITTANCES } from '@spree/core'
3
+ import { requiredMessage } from '@spree/dashboard-ui'
4
+ import { z } from 'zod/v4'
5
+ import { blankToNull } from '../lib/form-mappers'
6
+
7
+ export const sellerFormSchema = z.object({
8
+ name: z
9
+ .string()
10
+ .trim()
11
+ .min(1, { error: requiredMessage('name') }),
12
+ slug: z.string().trim().optional(),
13
+ contact_email: z.email().or(z.literal('')).optional(),
14
+ billing_email: z.email().or(z.literal('')).optional(),
15
+ about: z.string().optional(),
16
+ tax_remittance: z.enum(TAX_REMITTANCES).optional(),
17
+ payouts_schedule_interval: z.enum(PAYOUT_SCHEDULE_INTERVALS).or(z.literal('')).optional(),
18
+ minimum_payout_amount: z.string().trim().optional(),
19
+ holiday_mode_until: z.string().optional(),
20
+ // Three-state image fields, one triple per attachment. See ResourceImageField.
21
+ logo_signed_id: z.string().nullable(),
22
+ logo_preview_url: z.string().nullable(),
23
+ logo_cleared: z.boolean(),
24
+ square_logo_signed_id: z.string().nullable(),
25
+ square_logo_preview_url: z.string().nullable(),
26
+ square_logo_cleared: z.boolean(),
27
+ cover_photo_signed_id: z.string().nullable(),
28
+ cover_photo_preview_url: z.string().nullable(),
29
+ cover_photo_cleared: z.boolean(),
30
+ })
31
+
32
+ export type SellerFormValues = z.infer<typeof sellerFormSchema>
33
+
34
+ export const SELLER_DEFAULTS: SellerFormValues = {
35
+ name: '',
36
+ slug: '',
37
+ contact_email: '',
38
+ billing_email: '',
39
+ about: '',
40
+ tax_remittance: 'seller',
41
+ payouts_schedule_interval: '',
42
+ minimum_payout_amount: '',
43
+ holiday_mode_until: '',
44
+ logo_signed_id: null,
45
+ logo_preview_url: null,
46
+ logo_cleared: false,
47
+ square_logo_signed_id: null,
48
+ square_logo_preview_url: null,
49
+ square_logo_cleared: false,
50
+ cover_photo_signed_id: null,
51
+ cover_photo_preview_url: null,
52
+ cover_photo_cleared: false,
53
+ }
54
+
55
+ /**
56
+ * What the form reads from a seller. Structural, so the seller panel's own
57
+ * profile — the same record under another API — seeds it too.
58
+ */
59
+ export type SellerFormSource = Pick<
60
+ Seller,
61
+ | 'name'
62
+ | 'slug'
63
+ | 'contact_email'
64
+ | 'billing_email'
65
+ | 'about_html'
66
+ | 'tax_remittance'
67
+ | 'payouts_schedule_interval'
68
+ | 'minimum_payout_amount'
69
+ | 'holiday_mode_until'
70
+ >
71
+
72
+ /**
73
+ * The form seeded from a saved seller. Images start as the empty triple: the
74
+ * saved ones are shown through the field's `serverUrl`, and the triple only
75
+ * carries what the operator changes this time.
76
+ */
77
+ export function sellerFormValues(seller: SellerFormSource): SellerFormValues {
78
+ return {
79
+ ...SELLER_DEFAULTS,
80
+ name: seller.name,
81
+ slug: seller.slug,
82
+ contact_email: seller.contact_email ?? '',
83
+ billing_email: seller.billing_email ?? '',
84
+ about: seller.about_html ?? '',
85
+ tax_remittance: (seller.tax_remittance ?? 'seller') as SellerFormValues['tax_remittance'],
86
+ payouts_schedule_interval: (seller.payouts_schedule_interval ??
87
+ '') as SellerFormValues['payouts_schedule_interval'],
88
+ minimum_payout_amount: seller.minimum_payout_amount ?? '',
89
+ holiday_mode_until: seller.holiday_mode_until ?? '',
90
+ }
91
+ }
92
+
93
+ export function sellerValuesToParams(values: SellerFormValues): SellerCreateParams {
94
+ return {
95
+ name: values.name,
96
+ slug: blankToNull(values.slug) ?? undefined,
97
+ contact_email: blankToNull(values.contact_email),
98
+ billing_email: blankToNull(values.billing_email),
99
+ about: blankToNull(values.about),
100
+ tax_remittance: values.tax_remittance,
101
+ payouts_schedule_interval: blankToNull(values.payouts_schedule_interval) as
102
+ | SellerCreateParams['payouts_schedule_interval']
103
+ | null,
104
+ minimum_payout_amount: blankToNull(values.minimum_payout_amount),
105
+ holiday_mode_until: blankToNull(values.holiday_mode_until),
106
+ }
107
+ }
@@ -45,7 +45,7 @@ defineTable<PanelPackageType>('package-types', {
45
45
  key: 'dimensions',
46
46
  label: i18n.t('admin.package_types.dimensions_column'),
47
47
  default: true,
48
- className: 'text-sm text-muted-foreground whitespace-nowrap',
48
+ className: 'text-base text-muted-foreground whitespace-nowrap',
49
49
  // Three numbers mean nothing without the unit they were measured in —
50
50
  // the same figures read as inches or centimetres differ sixteenfold by
51
51
  // volume, which is what the freight tiers compare.
@@ -57,7 +57,7 @@ defineTable<PanelPackageType>('package-types', {
57
57
  {
58
58
  key: 'volume',
59
59
  label: i18n.t('admin.package_types.volume_column'),
60
- className: 'text-sm text-muted-foreground whitespace-nowrap',
60
+ className: 'text-base text-muted-foreground whitespace-nowrap',
61
61
  render: (packageType) =>
62
62
  packageType.volume ? `${Number(packageType.volume).toFixed(3)} m³` : '—',
63
63
  },
@@ -67,7 +67,7 @@ defineTable<PanelPackageType>('package-types', {
67
67
  sortable: true,
68
68
  filterable: true,
69
69
  filterType: 'date',
70
- className: 'text-sm text-muted-foreground whitespace-nowrap',
70
+ className: 'text-base text-muted-foreground whitespace-nowrap',
71
71
  render: (packageType) => <RelativeTime iso={packageType.created_at} />,
72
72
  },
73
73
  ],