@spree/dashboard-core 1.0.0-rc.4 → 1.0.0-rc.6

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 (70) hide show
  1. package/package.json +10 -7
  2. package/src/api-client.ts +76 -7
  3. package/src/components/address-form-dialog.tsx +2 -2
  4. package/src/components/country-combobox.tsx +1 -2
  5. package/src/components/currency-amounts-field.tsx +93 -0
  6. package/src/components/currency-select.tsx +6 -7
  7. package/src/components/file-upload-field.tsx +1 -0
  8. package/src/components/locale-select.tsx +22 -23
  9. package/src/components/nav-main.tsx +7 -2
  10. package/src/components/preferences-form.tsx +2 -8
  11. package/src/components/settings-sidebar.tsx +8 -1
  12. package/src/components/slot.tsx +21 -1
  13. package/src/components/table-toolbar.tsx +102 -39
  14. package/src/hooks/use-display-name.ts +1 -18
  15. package/src/hooks/use-file-upload.ts +2 -1
  16. package/src/hooks/use-import.ts +3 -2
  17. package/src/index.ts +17 -3
  18. package/src/lib/currencies.ts +2 -34
  19. package/src/lib/currency-amounts.test.ts +147 -0
  20. package/src/lib/currency-amounts.ts +172 -0
  21. package/src/lib/currency-parts.ts +2 -21
  22. package/src/lib/filters-to-ransack.ts +4 -1
  23. package/src/lib/form-fields-registry.ts +23 -1
  24. package/src/lib/form-mappers.ts +1 -1
  25. package/src/lib/formatters.ts +6 -10
  26. package/src/lib/fulfillment-items.ts +11 -0
  27. package/src/lib/i18n.ts +1 -12
  28. package/src/lib/iso-codes.ts +9 -26
  29. package/src/lib/money.ts +9 -99
  30. package/src/lib/normalize-money.ts +2 -85
  31. package/src/lib/return-refunds.ts +1 -2
  32. package/src/lib/route-registry.ts +29 -0
  33. package/src/lib/settings-nav-registry.ts +6 -1
  34. package/src/lib/store-config.test.ts +93 -0
  35. package/src/lib/store-config.ts +76 -0
  36. package/src/lib/table-registry.ts +16 -5
  37. package/src/locales/ar.json +51 -0
  38. package/src/locales/de.json +51 -0
  39. package/src/locales/en.json +51 -0
  40. package/src/locales/es.json +51 -0
  41. package/src/locales/fr.json +51 -0
  42. package/src/locales/pl.json +51 -0
  43. package/src/locales/zh-CN.json +51 -0
  44. package/src/pages/package-types.tsx +50 -11
  45. package/src/pages/settings-index.tsx +4 -1
  46. package/src/pages/stock-locations.tsx +31 -6
  47. package/src/products/assignable-variants.ts +35 -0
  48. package/src/products/bulk-variants-dialog.tsx +27 -28
  49. package/src/products/digital-asset-edit-sheet.tsx +192 -0
  50. package/src/products/digital-asset-provider-sheet.tsx +88 -0
  51. package/src/products/digital-assets-card.tsx +451 -0
  52. package/src/products/media-edit-sheet.tsx +68 -20
  53. package/src/products/normalize-quantity.ts +2 -3
  54. package/src/products/product-bulk-price-editor.tsx +1 -3
  55. package/src/products/product-form-cards.tsx +67 -37
  56. package/src/products/product-form-mapping.ts +44 -5
  57. package/src/products/product-schema.test.ts +8 -0
  58. package/src/products/product-schema.ts +14 -7
  59. package/src/products/product-types.ts +1 -1
  60. package/src/products/provider-settings-fields.tsx +110 -0
  61. package/src/products/sheet-save-handlers.ts +37 -0
  62. package/src/products/use-digital-assets.ts +66 -0
  63. package/src/products/variant-edit-sheet.tsx +21 -11
  64. package/src/products/variants-section.tsx +4 -1
  65. package/src/providers/store-provider.tsx +31 -7
  66. package/src/schemas/package-type.ts +14 -12
  67. package/src/schemas/stock-location.ts +2 -3
  68. package/src/tables/stock-locations.tsx +5 -8
  69. package/src/lib/money.test.ts +0 -63
  70. package/src/lib/normalize-money.test.ts +0 -67
@@ -13,13 +13,14 @@ import {
13
13
  useSortable,
14
14
  } from '@dnd-kit/sortable'
15
15
  import { CSS } from '@dnd-kit/utilities'
16
- import type { CurrencyCode } from '@spree/admin-sdk'
16
+ import type { CurrencyCode } from '@spree/core'
17
17
  import type { MediaType, ProductFormValues } from '@spree/dashboard-core'
18
18
  import {
19
19
  getApiClient,
20
20
  MediaPickerSheet,
21
21
  parseVideoUrl,
22
22
  ResourceMultiAutocomplete,
23
+ Slot,
23
24
  TagCombobox,
24
25
  useFileUpload,
25
26
  useOptionalStore,
@@ -86,7 +87,11 @@ import { InventorySection } from './inventory-section'
86
87
  import { MediaEditSheet } from './media-edit-sheet'
87
88
  import { ProductBulkPriceEditor } from './product-bulk-price-editor'
88
89
  import { variantHasTaxCategoryOverride } from './product-form-mapping'
89
- import type { VariantFormValues } from './product-schema'
90
+ import {
91
+ ASSIGNABLE_PRODUCT_STATUSES,
92
+ type MediaFormValues,
93
+ type VariantFormValues,
94
+ } from './product-schema'
90
95
  import type {
91
96
  PanelMedia as Media,
92
97
  PanelProduct as Product,
@@ -175,8 +180,12 @@ export function GeneralCard({
175
180
  // Variants (just a passthrough so the page composition reads top-down)
176
181
  // ---------------------------------------------------------------------------
177
182
 
178
- export function VariantsCard({ form, seedFromType }: FormCardProps & { seedFromType?: boolean }) {
179
- return <VariantsSection form={form} seedFromType={seedFromType} />
183
+ export function VariantsCard({
184
+ form,
185
+ seedFromType,
186
+ formKey = 'product',
187
+ }: FormCardProps & { seedFromType?: boolean; formKey?: string }) {
188
+ return <VariantsSection form={form} seedFromType={seedFromType} formKey={formKey} />
180
189
  }
181
190
 
182
191
  // ---------------------------------------------------------------------------
@@ -260,6 +269,13 @@ export function PricesCard({
260
269
  // merchant sees where uploads will go but can't trigger them.
261
270
  // ---------------------------------------------------------------------------
262
271
 
272
+ /**
273
+ * A media entry appended to the gallery without an upload: a `signed_id` from
274
+ * a finished upload, a library `source_media_id`, or an external video link.
275
+ * `previewUrl` is what the tile shows until the product is saved.
276
+ */
277
+ export type NewMediaEntry = Omit<MediaFormValues, 'position' | 'uploadId'>
278
+
263
279
  interface PendingUpload {
264
280
  id: string
265
281
  file: File
@@ -289,8 +305,11 @@ export function MediaCard({
289
305
  productId,
290
306
  variants,
291
307
  form,
308
+ formKey = 'product',
292
309
  }: {
293
310
  productId?: string
311
+ /** The product form's key — names the media slots and the rows' extension fields. */
312
+ formKey?: string
294
313
  // The MediaEditSheet's "assign to variant" pill row needs the list of
295
314
  // server-persisted variants — only those have an id that can ride the
296
315
  // PATCH's media[].variant_ids. Form-state variants without a server id
@@ -413,20 +432,21 @@ export function MediaCard({
413
432
 
414
433
  // An external video has no file to upload — it goes straight into form state
415
434
  // with its link, and the provider's own still stands in as the preview.
416
- const handleAddVideo = useCallback(
417
- (url: string) => {
435
+ // Appends entries that need no upload of their own — a video link, a
436
+ // library pick, or whatever a `<formKey>.media.actions` widget hands over —
437
+ // positioned after the existing ones. They save with the product.
438
+ const appendMedia = useCallback(
439
+ (entries: NewMediaEntry[]) => {
418
440
  const current = form.getValues('media') ?? []
419
441
  form.setValue(
420
442
  'media',
421
443
  [
422
444
  ...current,
423
- {
424
- media_type: 'external_video' as const,
425
- external_video_url: url,
426
- position: current.length + 1,
427
- previewUrl: parseVideoUrl(url)?.thumbnailUrl ?? undefined,
445
+ ...entries.map((entry, index) => ({
446
+ ...entry,
447
+ position: current.length + index + 1,
428
448
  uploadId: crypto.randomUUID(),
429
- },
449
+ })),
430
450
  ],
431
451
  { shouldDirty: true },
432
452
  )
@@ -434,31 +454,34 @@ export function MediaCard({
434
454
  [form],
435
455
  )
436
456
 
457
+ const handleAddVideo = useCallback(
458
+ (url: string) =>
459
+ appendMedia([
460
+ {
461
+ media_type: 'external_video',
462
+ external_video_url: url,
463
+ previewUrl: parseVideoUrl(url)?.thumbnailUrl ?? undefined,
464
+ },
465
+ ]),
466
+ [appendMedia],
467
+ )
468
+
437
469
  // Files picked from the library become ordinary form entries carrying the
438
470
  // source's id. The product save places them, sharing the file rather than
439
471
  // uploading a second copy.
440
472
  const handleAddFromLibrary = useCallback(
441
- (picked: Media[]) => {
442
- const current = form.getValues('media') ?? []
443
- form.setValue(
444
- 'media',
445
- [
446
- ...current,
447
- ...picked.map((media, index) => ({
448
- source_media_id: media.id,
449
- alt: media.alt ?? undefined,
450
- media_type: (media.media_type ?? 'image') as MediaType,
451
- position: current.length + index + 1,
452
- previewUrl: media.small_url ?? media.original_url ?? undefined,
453
- videoUrl: media.video_url ?? null,
454
- posterUrl: media.poster_url ?? null,
455
- uploadId: crypto.randomUUID(),
456
- })),
457
- ],
458
- { shouldDirty: true },
459
- )
460
- },
461
- [form],
473
+ (picked: Media[]) =>
474
+ appendMedia(
475
+ picked.map((media) => ({
476
+ source_media_id: media.id,
477
+ alt: media.alt ?? undefined,
478
+ media_type: (media.media_type ?? 'image') as MediaType,
479
+ previewUrl: media.small_url ?? media.original_url ?? undefined,
480
+ videoUrl: media.video_url ?? null,
481
+ posterUrl: media.poster_url ?? null,
482
+ })),
483
+ ),
484
+ [appendMedia],
462
485
  )
463
486
 
464
487
  const handleDelete = useCallback(
@@ -620,6 +643,11 @@ export function MediaCard({
620
643
  <FilmIcon />
621
644
  {t('admin.products.media.add_video')}
622
645
  </Button>
646
+
647
+ <Slot
648
+ name={`${formKey}.media.actions`}
649
+ context={{ productId, addMedia: appendMedia }}
650
+ />
623
651
  </div>
624
652
  </CardContent>
625
653
  </Card>
@@ -649,6 +677,8 @@ export function MediaCard({
649
677
  onOpenChange={(open) => {
650
678
  if (!open) setEditingIndex(null)
651
679
  }}
680
+ formKey={formKey}
681
+ productId={productId}
652
682
  />
653
683
  )}
654
684
  </>
@@ -889,8 +919,6 @@ export function SEOCard({ form, product }: FormCardProps & { product?: Product }
889
919
  // Status
890
920
  // ---------------------------------------------------------------------------
891
921
 
892
- const ASSIGNABLE_STATUSES = ['draft', 'active', 'archived']
893
-
894
922
  export function StatusCard({
895
923
  form,
896
924
  reviewActions,
@@ -915,9 +943,11 @@ export function StatusCard({
915
943
  // made, so the operator can move the product on from here like any other —
916
944
  // overriding their own refusal is not a review action, and leaving the
917
945
  // picker disabled there was a dead end with no way out.
918
- const inReview = Boolean(status) && !ASSIGNABLE_STATUSES.includes(status as string)
946
+ const inReview = Boolean(status) && !ASSIGNABLE_PRODUCT_STATUSES.includes(status as string)
919
947
  const awaitingDecision = status === 'proposed'
920
- const values = inReview ? [status as string, ...ASSIGNABLE_STATUSES] : ASSIGNABLE_STATUSES
948
+ const values = inReview
949
+ ? [status as string, ...ASSIGNABLE_PRODUCT_STATUSES]
950
+ : ASSIGNABLE_PRODUCT_STATUSES
921
951
 
922
952
  return (
923
953
  <SharedStatusCard<ProductFormValues>
@@ -1,3 +1,4 @@
1
+ import { extensionFormValues, extensionRowValues } from '../lib/form-fields-registry'
1
2
  import { i18n } from '../lib/i18n'
2
3
  import { normalizeQuantityRule } from './normalize-quantity'
3
4
  import type { MediaType, ProductFormValues, VariantFormValues } from './product-schema'
@@ -11,8 +12,18 @@ import type { PanelMedia, PanelProduct, PanelVariant } from './product-types'
11
12
  * this mapping would be two ways to disagree.
12
13
  */
13
14
 
14
- export function variantToFormValues(variant: PanelVariant, position: number): VariantFormValues {
15
+ /**
16
+ * `formKey` is the product form's key (`'product'`, or `'seller.product'` in
17
+ * the seller panel); the rows' extension fields are registered under
18
+ * `${formKey}.variant` and `${formKey}.media`.
19
+ */
20
+ export function variantToFormValues(
21
+ variant: PanelVariant,
22
+ position: number,
23
+ formKey = 'product',
24
+ ): VariantFormValues {
15
25
  return {
26
+ ...extensionFormValues(`${formKey}.variant`, variant),
16
27
  id: variant.id,
17
28
  sku: variant.sku ?? null,
18
29
  barcode: variant.barcode ?? null,
@@ -76,8 +87,9 @@ export function variantToFormValues(variant: PanelVariant, position: number): Va
76
87
  // late paint) so a new media field can't reach one and miss the other.
77
88
  // Takes the generated SDK type rather than a hand-listed shape, so a field
78
89
  // added to the serializer can't quietly go missing here.
79
- export function mediaToFormValues(media: PanelMedia, index: number) {
90
+ export function mediaToFormValues(media: PanelMedia, index: number, formKey = 'product') {
80
91
  return {
92
+ ...extensionFormValues(`${formKey}.media`, media),
81
93
  id: media.id,
82
94
  alt: media.alt ?? null,
83
95
  position: media.position ?? index + 1,
@@ -108,6 +120,7 @@ export function productToFormValues(
108
120
  // captures it atomically instead of via a follow-up setValue that races
109
121
  // with the merchant's unsaved edits.
110
122
  media?: PanelMedia[],
123
+ formKey = 'product',
111
124
  ): ProductFormValues {
112
125
  const hasVariants = (product.variant_count ?? 0) > 0
113
126
  const variantSource = hasVariants
@@ -140,14 +153,14 @@ export function productToFormValues(
140
153
  meta_title: product.meta_title ?? '',
141
154
  meta_description: product.meta_description ?? '',
142
155
  slug: product.slug ?? '',
143
- variants: variantSource.map((v, i) => variantToFormValues(v, i)),
156
+ variants: variantSource.map((v, i) => variantToFormValues(v, i, formKey)),
144
157
  custom_fields:
145
158
  product.custom_fields?.map((cf) => ({
146
159
  id: cf.id,
147
160
  custom_field_definition_id: cf.custom_field_definition_id,
148
161
  value: cf.value,
149
162
  })) ?? [],
150
- media: media?.map(mediaToFormValues) ?? [],
163
+ media: media?.map((m, i) => mediaToFormValues(m, i, formKey)) ?? [],
151
164
  product_publications: (product.product_publications ?? []).map((l) => ({
152
165
  id: l.id,
153
166
  channel_id: l.channel_id,
@@ -157,6 +170,31 @@ export function productToFormValues(
157
170
  }
158
171
  }
159
172
 
173
+ /**
174
+ * The submitted values with each variant and media row's extension fields put
175
+ * back from live form state. The Zod parse behind a submit strips keys the
176
+ * schema doesn't know from every row; it keeps the rows' order, so row `i` of
177
+ * the parse is row `i` of the form. Call it first in a submit handler, before
178
+ * rows are filtered or reordered.
179
+ */
180
+ export function withExtensionRowValues(
181
+ formKey: string,
182
+ values: ProductFormValues,
183
+ raw: Partial<ProductFormValues>,
184
+ ): ProductFormValues {
185
+ return {
186
+ ...values,
187
+ variants: values.variants?.map((variant, index) => ({
188
+ ...variant,
189
+ ...extensionRowValues(`${formKey}.variant`, raw.variants?.[index]),
190
+ })),
191
+ media: values.media?.map((media, index) => ({
192
+ ...media,
193
+ ...extensionRowValues(`${formKey}.media`, raw.media?.[index]),
194
+ })),
195
+ }
196
+ }
197
+
160
198
  // Strip UI-only fields (stock_location_name) and undefined entries so the
161
199
  // PATCH body matches the Admin API VariantUpdateParams shape exactly. The
162
200
  // Spree::Product#variants= setter reconciles by id, creates new entries,
@@ -187,13 +225,14 @@ export function prepareVariantsTaxForProductSave(
187
225
  })
188
226
  }
189
227
 
190
- export function variantToWirePayload(v: VariantFormValues, index: number) {
228
+ export function variantToWirePayload(v: VariantFormValues, index: number, formKey = 'product') {
191
229
  // DB columns `sku` and `weight` are NOT NULL with defaults ("", 0.0).
192
230
  // The other scalar fields (barcode, dimensions, weight_unit,
193
231
  // dimensions_unit, tax_category_id) ARE nullable — those we always send
194
232
  // even when null so the merchant can clear them. NOT-NULL fields fall
195
233
  // back to their schema defaults when blank.
196
234
  const payload: Record<string, unknown> = {
235
+ ...extensionRowValues(`${formKey}.variant`, v),
197
236
  position: index + 1,
198
237
  options: v.options,
199
238
  sku: v.sku ?? '',
@@ -0,0 +1,8 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { productFormSchema } from './product-schema'
3
+
4
+ describe('productFormSchema status', () => {
5
+ it('accepts a status an extension added, so the form still saves', () => {
6
+ expect(productFormSchema.shape.status.safeParse('pending_compliance').success).toBe(true)
7
+ })
8
+ })
@@ -1,3 +1,4 @@
1
+ import { MEDIA_TYPES, type MediaType, PRODUCT_STATUSES, subsetOf } from '@spree/core'
1
2
  import { requiredMessage } from '@spree/dashboard-ui'
2
3
  import i18n from 'i18next'
3
4
  import { z } from 'zod/v4'
@@ -103,9 +104,7 @@ export type CustomFieldFormValues = z.infer<typeof customFieldFormSchema>
103
104
  // `source_media_id` (a file picked from the library). All can hold edits to
104
105
  // alt, position, and variant_ids that the parent product PATCH/POST ships
105
106
  // inline. previewUrl + uploadId are UI-only; stripped at submit.
106
- export const MEDIA_TYPES = ['image', 'video', 'external_video'] as const
107
-
108
- export type MediaType = (typeof MEDIA_TYPES)[number]
107
+ export { MEDIA_TYPES, type MediaType }
109
108
 
110
109
  export const mediaFormSchema = z
111
110
  .object({
@@ -169,6 +168,13 @@ export const productPublicationFormSchema = z.object({
169
168
 
170
169
  export type ProductPublicationFormValues = z.infer<typeof productPublicationFormSchema>
171
170
 
171
+ /** Statuses a merchant sets by hand; the rest come from the review workflow. */
172
+ export const ASSIGNABLE_PRODUCT_STATUSES = subsetOf(PRODUCT_STATUSES, [
173
+ 'draft',
174
+ 'active',
175
+ 'archived',
176
+ ])
177
+
172
178
  export const productFormSchema = z.object({
173
179
  // General
174
180
  name: z.string().min(1, { error: requiredMessage('name') }),
@@ -176,10 +182,11 @@ export const productFormSchema = z.object({
176
182
 
177
183
  // Status
178
184
  // Every status the API may answer, not just the ones a form sets. A
179
- // marketplace product also comes back `proposed` or `rejected`, and a schema
180
- // that rejects those makes the whole form unsavable rather than flagging a
181
- // field (docs/plans/6.0-seller-product-submission.md).
182
- status: z.enum(['draft', 'active', 'archived', 'proposed', 'rejected']).optional(),
185
+ // marketplace product also comes back `proposed` or `rejected`, an extension
186
+ // may add statuses of its own, and a schema that rejects any of those makes
187
+ // the whole form unsavable rather than flagging a field
188
+ // (docs/plans/6.0-seller-product-submission.md).
189
+ status: z.string().optional(),
183
190
 
184
191
  // Categorization
185
192
  category_ids: z.array(z.string()).optional(),
@@ -1,4 +1,4 @@
1
- import type { CountryCode, CurrencyCode } from '@spree/admin-sdk'
1
+ import type { CountryCode, CurrencyCode } from '@spree/core'
2
2
 
3
3
  /**
4
4
  * What the shared product form reads off a product, its variants and its
@@ -0,0 +1,110 @@
1
+ import type { DigitalAssetProviderSettingField } from '@spree/admin-sdk'
2
+ import {
3
+ Field,
4
+ FieldLabel,
5
+ Input,
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ Switch,
12
+ } from '@spree/dashboard-ui'
13
+
14
+ interface Props {
15
+ schema: DigitalAssetProviderSettingField[]
16
+ values: Record<string, unknown>
17
+ onChange: (values: Record<string, unknown>) => void
18
+ }
19
+
20
+ /**
21
+ * Renders one input per provider-declared setting. The provider owns the field
22
+ * list (its `settings_schema`), so this stays generic: a provider ships no
23
+ * dashboard code, it just names its fields and types. Labels come straight from
24
+ * the field key — a provider names a setting for the merchant, not for i18n.
25
+ */
26
+ export function ProviderSettingsFields({ schema, values, onChange }: Props) {
27
+ if (schema.length === 0) return null
28
+
29
+ function set(key: string, value: unknown) {
30
+ onChange({ ...values, [key]: value })
31
+ }
32
+
33
+ return (
34
+ <>
35
+ {schema.map((field) => {
36
+ const value = values[field.key]
37
+ const id = `provider-setting-${field.key}`
38
+
39
+ if (field.type === 'boolean') {
40
+ return (
41
+ <Field key={field.key} className="flex-row items-center justify-between">
42
+ <FieldLabel htmlFor={id}>{field.key}</FieldLabel>
43
+ <Switch
44
+ id={id}
45
+ checked={Boolean(value)}
46
+ onCheckedChange={(checked) => set(field.key, checked)}
47
+ />
48
+ </Field>
49
+ )
50
+ }
51
+
52
+ if (field.type === 'select') {
53
+ return (
54
+ <Field key={field.key}>
55
+ <FieldLabel htmlFor={id}>{field.key}</FieldLabel>
56
+ <Select
57
+ value={value == null ? '' : String(value)}
58
+ onValueChange={(v) => set(field.key, v)}
59
+ >
60
+ <SelectTrigger id={id}>
61
+ <SelectValue />
62
+ </SelectTrigger>
63
+ <SelectContent>
64
+ {(field.in ?? []).map((option) => (
65
+ <SelectItem key={option} value={option}>
66
+ {option}
67
+ </SelectItem>
68
+ ))}
69
+ </SelectContent>
70
+ </Select>
71
+ </Field>
72
+ )
73
+ }
74
+
75
+ return (
76
+ <Field key={field.key}>
77
+ <FieldLabel htmlFor={id}>{field.key}</FieldLabel>
78
+ <Input
79
+ id={id}
80
+ type={field.type === 'number' ? 'number' : 'text'}
81
+ value={value == null ? '' : String(value)}
82
+ onChange={(e) => {
83
+ if (field.type !== 'number') {
84
+ set(field.key, e.target.value)
85
+ return
86
+ }
87
+ // An empty or invalid number input yields NaN, which would
88
+ // serialize to null and silently clear the setting. Keep it
89
+ // unset instead of writing a bogus value.
90
+ const parsed = e.target.valueAsNumber
91
+ set(field.key, Number.isFinite(parsed) ? parsed : undefined)
92
+ }}
93
+ />
94
+ </Field>
95
+ )
96
+ })}
97
+ </>
98
+ )
99
+ }
100
+
101
+ /** Seed field values from a provider's schema defaults (create flow). */
102
+ export function defaultProviderSettings(
103
+ schema: DigitalAssetProviderSettingField[],
104
+ ): Record<string, unknown> {
105
+ const values: Record<string, unknown> = {}
106
+ for (const field of schema) {
107
+ if (field.default !== undefined) values[field.key] = field.default
108
+ }
109
+ return values
110
+ }
@@ -0,0 +1,37 @@
1
+ import { useCallback, useRef, useState } from 'react'
2
+
3
+ /** Persists a slot widget's own edits; resolves `false` to keep the sheet open. */
4
+ export type SheetSaveHandler = () => Promise<boolean>
5
+
6
+ /**
7
+ * Lets widgets in a product-page sheet's slot save their own records when the
8
+ * sheet's Done is pressed. The sheet's fields ride the product's Save, but a
9
+ * widget writing a different record (a variant's custom fields) has no other
10
+ * moment that matches what the merchant expects: Done keeps, Cancel drops.
11
+ */
12
+ export function useSheetSaveHandlers() {
13
+ const handlers = useRef(new Set<SheetSaveHandler>())
14
+ const [saving, setSaving] = useState(false)
15
+
16
+ /** Registers a handler; the returned function unregisters it. */
17
+ const onSave = useCallback((handler: SheetSaveHandler) => {
18
+ handlers.current.add(handler)
19
+ return () => {
20
+ handlers.current.delete(handler)
21
+ }
22
+ }, [])
23
+
24
+ // A widget that throws keeps the sheet open like one that resolves `false`,
25
+ // rather than escaping as an unhandled rejection from the Done click.
26
+ const saveAll = useCallback(async () => {
27
+ setSaving(true)
28
+ try {
29
+ const results = await Promise.allSettled([...handlers.current].map((handler) => handler()))
30
+ return results.every((result) => result.status === 'fulfilled' && result.value)
31
+ } finally {
32
+ setSaving(false)
33
+ }
34
+ }, [])
35
+
36
+ return { onSave, saveAll, saving }
37
+ }
@@ -0,0 +1,66 @@
1
+ import { getApiClient, i18n, useResourceKey, useResourceMutation } from '@spree/dashboard-core'
2
+ import { useQuery } from '@tanstack/react-query'
3
+ import type { PanelDigitalAssetParams } from '../api-client'
4
+
5
+ // The logical invalidation key (tenant id auto-injected by useResourceMutation).
6
+ const digitalAssetsKey = (productId: string) => ['products', productId, 'digital-assets']
7
+
8
+ function digitalAssetsClient() {
9
+ const client = getApiClient().digitalAssets
10
+ if (!client) throw new Error('This panel cannot manage digital files.')
11
+ return client
12
+ }
13
+
14
+ export function useDigitalAssets(productId: string, page = 1, enabled = true) {
15
+ return useQuery({
16
+ queryKey: useResourceKey('products', productId, 'digital-assets', page),
17
+ queryFn: () => digitalAssetsClient().list(productId, { page, limit: 25 }),
18
+ enabled: enabled && Boolean(productId) && Boolean(getApiClient().digitalAssets),
19
+ })
20
+ }
21
+
22
+ // The selectable sources for a new asset. Almost always just the File default,
23
+ // so a host with no extra provider gets a one-entry list and the card keeps its
24
+ // plain "Add file" button.
25
+ export function useDigitalAssetProviders(productId: string, enabled = true) {
26
+ const providers = getApiClient().digitalAssets?.providers
27
+ return useQuery({
28
+ // Not keyed by product: the registry is the same for all of them.
29
+ queryKey: useResourceKey('digital-asset-providers'),
30
+ queryFn: () => providers!(productId),
31
+ enabled: enabled && Boolean(productId) && Boolean(providers),
32
+ staleTime: Number.POSITIVE_INFINITY, // the registry doesn't change at runtime
33
+ })
34
+ }
35
+
36
+ /**
37
+ * `errorMessage: false` hands every failure to the caller — the card names
38
+ * the file in its own toast, and two toasts for one refusal is noise.
39
+ */
40
+ export function useCreateDigitalAsset(productId: string, options: { errorMessage?: false } = {}) {
41
+ return useResourceMutation({
42
+ mutationFn: (params: PanelDigitalAssetParams) =>
43
+ digitalAssetsClient().create(productId, params),
44
+ invalidate: [digitalAssetsKey(productId)],
45
+ successMessage: i18n.t('admin.messages.digital_asset_saved'),
46
+ ...options,
47
+ })
48
+ }
49
+
50
+ export function useUpdateDigitalAsset(productId: string, options: { errorMessage?: false } = {}) {
51
+ return useResourceMutation({
52
+ mutationFn: ({ id, ...params }: PanelDigitalAssetParams & { id: string }) =>
53
+ digitalAssetsClient().update(productId, id, params),
54
+ invalidate: [digitalAssetsKey(productId)],
55
+ successMessage: i18n.t('admin.messages.digital_asset_saved'),
56
+ ...options,
57
+ })
58
+ }
59
+
60
+ export function useDeleteDigitalAsset(productId: string) {
61
+ return useResourceMutation({
62
+ mutationFn: (id: string) => digitalAssetsClient().delete(productId, id),
63
+ invalidate: [digitalAssetsKey(productId)],
64
+ successMessage: i18n.t('admin.messages.digital_asset_removed'),
65
+ })
66
+ }