@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
@@ -1,4 +1,5 @@
1
1
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
+ import { useCallback } from 'react'
2
3
  import type {
3
4
  PanelCollection,
4
5
  PanelDeliveryProfile,
@@ -162,9 +163,35 @@ export function useFormCartonPackageTypes() {
162
163
  }
163
164
 
164
165
  /**
165
- * Removing a file already on the product. A panel whose client registers no
166
- * deletion answers a no-op mutation, so the gallery still drops the row from
167
- * the form and the save reconciles it.
166
+ * Deletes the media a product form marked for removal, after the product
167
+ * itself saved. Resolves to how many deletes failed, so the form can say so.
168
+ */
169
+ export function useCommitRemovedProductMedia(productId: string) {
170
+ const client = getApiClient()
171
+ const queryClient = useQueryClient()
172
+ const mediaKey = useResourceKey('products', productId, 'media')
173
+
174
+ return useCallback(
175
+ async (mediaIds: string[] | undefined) => {
176
+ // Independent deletes, so all at once: a bulk removal of twenty files
177
+ // waits on one round trip rather than twenty.
178
+ const results = await Promise.allSettled(
179
+ (mediaIds ?? []).map((mediaId) => client.deleteProductMedia?.(productId, mediaId)),
180
+ )
181
+ const failed = results.filter((result) => result.status === 'rejected').length
182
+ if (mediaIds?.length) await queryClient.invalidateQueries({ queryKey: mediaKey })
183
+ return failed
184
+ },
185
+ [client, productId, queryClient, mediaKey],
186
+ )
187
+ }
188
+
189
+ /**
190
+ * Removing a file already on the product, straight away.
191
+ *
192
+ * @deprecated The product form now removes files on save — mark them in
193
+ * `removed_media_ids` and commit with `useCommitRemovedProductMedia`. Kept
194
+ * until the next minor release.
168
195
  */
169
196
  export function useFormDeleteProductMedia(productId: string) {
170
197
  const client = getApiClient()
@@ -315,7 +315,7 @@ export function VariantEditSheet({ form, variantIndex, formKey, open, onOpenChan
315
315
 
316
316
  {cartonTypesSupported && (
317
317
  <FormSection title={t('admin.products.variants.sheet.packing')}>
318
- <p className="text-sm text-muted-foreground">
318
+ <p className="text-base text-muted-foreground">
319
319
  {t('admin.products.variants.sheet.packing_help')}
320
320
  </p>
321
321
  {knownEmpty && (
@@ -447,7 +447,7 @@ export function VariantEditSheet({ form, variantIndex, formKey, open, onOpenChan
447
447
  )}
448
448
 
449
449
  <FormSection title={t('admin.products.variants.sheet.customs')}>
450
- <p className="text-sm text-muted-foreground">
450
+ <p className="text-base text-muted-foreground">
451
451
  {t('admin.products.variants.sheet.customs_help')}
452
452
  </p>
453
453
  <div className="grid grid-cols-2 gap-3">
@@ -503,7 +503,7 @@ export function VariantEditSheet({ form, variantIndex, formKey, open, onOpenChan
503
503
  </FormSection>
504
504
 
505
505
  <FormSection title={t('admin.products.variants.sheet.ordering')}>
506
- <p className="text-sm text-muted-foreground">
506
+ <p className="text-base text-muted-foreground">
507
507
  {t('admin.products.variants.sheet.ordering_help')}
508
508
  </p>
509
509
  <div className="grid grid-cols-2 gap-3">
@@ -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
+ }