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

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 (61) hide show
  1. package/package.json +5 -4
  2. package/src/api-client.ts +12 -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-select.tsx +6 -7
  6. package/src/components/file-upload-field.tsx +1 -0
  7. package/src/components/locale-select.tsx +22 -23
  8. package/src/components/nav-main.tsx +7 -2
  9. package/src/components/preferences-form.tsx +2 -8
  10. package/src/components/settings-sidebar.tsx +8 -1
  11. package/src/components/slot.tsx +21 -1
  12. package/src/components/table-toolbar.tsx +102 -39
  13. package/src/hooks/use-display-name.ts +1 -18
  14. package/src/hooks/use-file-upload.ts +2 -1
  15. package/src/hooks/use-import.ts +3 -2
  16. package/src/index.ts +13 -3
  17. package/src/lib/currencies.ts +2 -34
  18. package/src/lib/currency-parts.ts +2 -21
  19. package/src/lib/filters-to-ransack.ts +4 -1
  20. package/src/lib/form-fields-registry.ts +23 -1
  21. package/src/lib/form-mappers.ts +1 -1
  22. package/src/lib/formatters.ts +6 -10
  23. package/src/lib/fulfillment-items.ts +11 -0
  24. package/src/lib/i18n.ts +1 -12
  25. package/src/lib/iso-codes.ts +9 -26
  26. package/src/lib/money.ts +9 -99
  27. package/src/lib/normalize-money.ts +2 -85
  28. package/src/lib/return-refunds.ts +1 -2
  29. package/src/lib/route-registry.ts +29 -0
  30. package/src/lib/settings-nav-registry.ts +6 -1
  31. package/src/lib/store-config.test.ts +93 -0
  32. package/src/lib/store-config.ts +76 -0
  33. package/src/lib/table-registry.ts +16 -5
  34. package/src/locales/ar.json +4 -0
  35. package/src/locales/de.json +4 -0
  36. package/src/locales/en.json +4 -0
  37. package/src/locales/es.json +4 -0
  38. package/src/locales/fr.json +4 -0
  39. package/src/locales/pl.json +4 -0
  40. package/src/locales/zh-CN.json +4 -0
  41. package/src/pages/package-types.tsx +50 -11
  42. package/src/pages/settings-index.tsx +4 -1
  43. package/src/pages/stock-locations.tsx +31 -6
  44. package/src/products/bulk-variants-dialog.tsx +27 -28
  45. package/src/products/media-edit-sheet.tsx +67 -8
  46. package/src/products/normalize-quantity.ts +2 -3
  47. package/src/products/product-bulk-price-editor.tsx +1 -3
  48. package/src/products/product-form-cards.tsx +67 -37
  49. package/src/products/product-form-mapping.ts +44 -5
  50. package/src/products/product-schema.test.ts +8 -0
  51. package/src/products/product-schema.ts +14 -7
  52. package/src/products/product-types.ts +1 -1
  53. package/src/products/sheet-save-handlers.ts +37 -0
  54. package/src/products/variant-edit-sheet.tsx +21 -11
  55. package/src/products/variants-section.tsx +4 -1
  56. package/src/providers/store-provider.tsx +31 -7
  57. package/src/schemas/package-type.ts +14 -12
  58. package/src/schemas/stock-location.ts +2 -3
  59. package/src/tables/stock-locations.tsx +5 -8
  60. package/src/lib/money.test.ts +0 -63
  61. package/src/lib/normalize-money.test.ts +0 -67
@@ -1,3 +1,4 @@
1
+ import { DimensionUnit, normalizeMoneyInput, WeightUnit } from '@spree/core'
1
2
  import type { ProductFormValues, VariantFormValues } from '@spree/dashboard-core'
2
3
  import { useCountries, useCountryDisplayName } from '@spree/dashboard-core'
3
4
  import {
@@ -23,9 +24,6 @@ import {
23
24
  } from './use-product-form-data'
24
25
  import { composeOptionsText } from './variants-matrix'
25
26
 
26
- const WEIGHT_UNITS = ['g', 'kg', 'lb', 'oz'] as const
27
- const DIMENSION_UNITS = ['mm', 'cm', 'in'] as const
28
-
29
27
  interface Props {
30
28
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
29
  form: UseFormReturn<ProductFormValues, any, any>
@@ -171,39 +169,24 @@ export function BulkVariantsDialog({ form, open, onOpenChange }: Props) {
171
169
  set(`variants.${idx}.cartons_per_pallet`, change.value)
172
170
  break
173
171
  case 'cartonWeight': {
174
- if (change.value == null || change.value.trim() === '') {
175
- set(`variants.${idx}.carton_weight`, null)
176
- break
177
- }
178
- const parsed = Number(change.value.trim().replace(',', '.'))
179
- if (!Number.isFinite(parsed) || parsed < 0) break
180
- set(`variants.${idx}.carton_weight`, parsed)
172
+ const parsed = readMeasure(change.value)
173
+ if (parsed !== undefined) set(`variants.${idx}.carton_weight`, parsed)
181
174
  break
182
175
  }
183
176
  case 'preorderable':
184
177
  set(`variants.${idx}.preorderable`, change.value)
185
178
  break
186
179
  case 'backorderLimit': {
187
- if (change.value == null || change.value.trim() === '') {
188
- set(`variants.${idx}.backorder_limit`, null)
189
- break
180
+ const parsed = readMeasure(change.value)
181
+ if (parsed !== undefined) {
182
+ set(`variants.${idx}.backorder_limit`, parsed === null ? null : Math.trunc(parsed))
190
183
  }
191
- const parsed = Number(change.value.trim().replace(',', '.'))
192
- if (!Number.isFinite(parsed) || parsed < 0) break
193
- set(`variants.${idx}.backorder_limit`, Math.trunc(parsed))
194
184
  break
195
185
  }
196
186
  default: {
197
- // weight / height / width / depth — decimals. Accept a comma
198
- // decimal (the cell ships raw input); ignore anything that doesn't
199
- // parse to a non-negative number rather than mangling it.
200
- if (change.value == null || change.value.trim() === '') {
201
- set(`variants.${idx}.${change.field}`, null)
202
- break
203
- }
204
- const parsed = Number(change.value.trim().replace(',', '.'))
205
- if (!Number.isFinite(parsed) || parsed < 0) break
206
- set(`variants.${idx}.${change.field}`, parsed)
187
+ // weight / height / width / depth
188
+ const parsed = readMeasure(change.value)
189
+ if (parsed !== undefined) set(`variants.${idx}.${change.field}`, parsed)
207
190
  break
208
191
  }
209
192
  }
@@ -242,8 +225,8 @@ export function BulkVariantsDialog({ form, open, onOpenChange }: Props) {
242
225
  <BulkVariantsTable
243
226
  rows={rows}
244
227
  onChange={handleChange}
245
- weightUnitOptions={unitOptions(WEIGHT_UNITS)}
246
- dimensionUnitOptions={unitOptions(DIMENSION_UNITS)}
228
+ weightUnitOptions={unitOptions(Object.values(WeightUnit))}
229
+ dimensionUnitOptions={unitOptions(Object.values(DimensionUnit))}
247
230
  taxCategoryOptions={
248
231
  taxCategories.length > 0
249
232
  ? taxCategories.map((c) => ({ value: c.id, label: c.name }))
@@ -293,3 +276,19 @@ export function BulkVariantsDialog({ form, open, onOpenChange }: Props) {
293
276
  </Dialog>
294
277
  )
295
278
  }
279
+
280
+ // Cells show numbers with a period, so they read back the same way, with
281
+ // comma grouping (`1,500`) or a lone decimal comma (`2,5`) also accepted.
282
+ const MEASURE_INPUT = /^(?:(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|\d+,\d+)$/
283
+
284
+ /**
285
+ * A measurement typed or pasted into a cell: `null` when cleared, `undefined`
286
+ * when it is not a non-negative number, so the cell keeps its value rather
287
+ * than being mangled.
288
+ */
289
+ function readMeasure(value: string | null | undefined): number | null | undefined {
290
+ const typed = value?.trim() ?? ''
291
+ if (typed === '') return null
292
+ if (!MEASURE_INPUT.test(typed)) return undefined
293
+ return Number(normalizeMoneyInput(typed, 'en'))
294
+ }
@@ -1,5 +1,11 @@
1
1
  import type { ProductFormValues } from '@spree/dashboard-core'
2
- import { ImageUploadField, parseVideoUrl, useTranslation } from '@spree/dashboard-core'
2
+ import {
3
+ extensionFieldNames,
4
+ ImageUploadField,
5
+ parseVideoUrl,
6
+ Slot,
7
+ useTranslation,
8
+ } from '@spree/dashboard-core'
3
9
  import {
4
10
  Button,
5
11
  Field,
@@ -18,6 +24,7 @@ import i18n from 'i18next'
18
24
  import { useCallback, useEffect, useRef } from 'react'
19
25
  import type { UseFormReturn } from 'react-hook-form'
20
26
  import type { PanelVariant as Variant } from './product-types'
27
+ import { useSheetSaveHandlers } from './sheet-save-handlers'
21
28
 
22
29
  // The fields this sheet edits. Snapshot, cancel-restore and the type all derive
23
30
  // from this one list, so a new editable field can't reach the form and then be
@@ -39,13 +46,25 @@ interface Props {
39
46
  variants: Variant[]
40
47
  open: boolean
41
48
  onOpenChange: (open: boolean) => void
49
+ /** The product form's key — names the slot and the row's extension fields. */
50
+ formKey: string
51
+ productId?: string
42
52
  }
43
53
 
44
54
  // Form-backed media editor. The sheet edits `form.media[index]` directly —
45
55
  // changes ride the parent product PATCH/POST, no per-asset API calls.
46
- // Cancel restores a snapshot taken on open; Done closes (parent form Save
47
- // commits). Same model as the per-variant Sheet.
48
- export function MediaEditSheet({ form, mediaIndex, variants, open, onOpenChange }: Props) {
56
+ // Cancel restores a snapshot taken on open; Done runs the slot widgets' own
57
+ // saves, then closes (parent form Save commits). Same model as the per-variant
58
+ // Sheet.
59
+ export function MediaEditSheet({
60
+ form,
61
+ mediaIndex,
62
+ variants,
63
+ open,
64
+ onOpenChange,
65
+ formKey,
66
+ productId,
67
+ }: Props) {
49
68
  const { t } = useTranslation()
50
69
 
51
70
  // Snapshot on open so Cancel can restore. Re-snapshot if mediaIndex changes
@@ -58,7 +77,11 @@ export function MediaEditSheet({ form, mediaIndex, variants, open, onOpenChange
58
77
  // freshly-uploaded row that has no id yet we fall back to the current
59
78
  // index — uploads don't surface a drag handle so the race is impossible.
60
79
  type MediaRow = NonNullable<ProductFormValues['media']>[number]
61
- type Snapshot = Pick<MediaRow, (typeof EDITED_FIELDS)[number]> & { key: string | null }
80
+ type Snapshot = Pick<MediaRow, (typeof EDITED_FIELDS)[number]> & {
81
+ key: string | null
82
+ // Fields a `<formKey>.media.form_fields` widget edits, restored the same way.
83
+ extensions: Record<string, unknown>
84
+ }
62
85
  const snapshotRef = useRef<Snapshot | null>(null)
63
86
  const rowKey = useCallback(
64
87
  (m: MediaRow): string | null => m.id ?? m.signed_id ?? m.uploadId ?? null,
@@ -84,8 +107,18 @@ export function MediaEditSheet({ form, mediaIndex, variants, open, onOpenChange
84
107
  // Copy the array — the snapshot must not alias the row's own value.
85
108
  variant_ids: [...(row.variant_ids ?? [])],
86
109
  key: rowKey(row),
110
+ extensions: structuredClone(
111
+ Object.fromEntries(
112
+ extensionFieldNames(`${formKey}.media`).map((name) => [
113
+ name,
114
+ (row as Record<string, unknown>)[name],
115
+ ]),
116
+ ),
117
+ ),
87
118
  }
88
- }, [open, mediaIndex, form, rowKey])
119
+ }, [open, mediaIndex, form, rowKey, formKey])
120
+
121
+ const { onSave, saveAll, saving } = useSheetSaveHandlers()
89
122
 
90
123
  const entry = form.watch(`media.${mediaIndex}`)
91
124
  if (!entry) return null
@@ -180,6 +213,8 @@ export function MediaEditSheet({ form, mediaIndex, variants, open, onOpenChange
180
213
  }
181
214
 
182
215
  const handleCancel = () => {
216
+ // A save already sent can't be called back, so the sheet stays until it settles.
217
+ if (saving) return
183
218
  const snap = snapshotRef.current
184
219
  if (snap) {
185
220
  // Resolve the row by stable key — the media array may have reordered
@@ -203,6 +238,13 @@ export function MediaEditSheet({ form, mediaIndex, variants, open, onOpenChange
203
238
  shouldTouch: false,
204
239
  })
205
240
  }
241
+ for (const [name, value] of Object.entries(snap.extensions)) {
242
+ // Extension names are outside the typed row.
243
+ form.setValue(`media.${targetIndex}.${name}` as any, value, {
244
+ shouldDirty: false,
245
+ shouldTouch: false,
246
+ })
247
+ }
206
248
  }
207
249
  onOpenChange(false)
208
250
  }
@@ -309,13 +351,30 @@ export function MediaEditSheet({ form, mediaIndex, variants, open, onOpenChange
309
351
  </div>
310
352
  </Field>
311
353
  )}
354
+
355
+ <Slot
356
+ name={`${formKey}.media.form_fields`}
357
+ context={{
358
+ productId,
359
+ media: entry,
360
+ mediaIndex,
361
+ fieldPrefix: `media.${mediaIndex}`,
362
+ onSave,
363
+ }}
364
+ />
312
365
  </div>
313
366
 
314
367
  <SheetFooter>
315
- <Button type="button" variant="outline" onClick={handleCancel}>
368
+ <Button type="button" variant="outline" disabled={saving} onClick={handleCancel}>
316
369
  {t('admin.actions.cancel')}
317
370
  </Button>
318
- <Button type="button" onClick={() => onOpenChange(false)}>
371
+ <Button
372
+ type="button"
373
+ disabled={saving}
374
+ onClick={async () => {
375
+ if (await saveAll()) onOpenChange(false)
376
+ }}
377
+ >
319
378
  {t('admin.actions.done')}
320
379
  </Button>
321
380
  </SheetFooter>
@@ -2,10 +2,9 @@
2
2
  // `Spree::Variant`'s own validations so a merchant hears about a bad value
3
3
  // while typing rather than on save.
4
4
 
5
- /** What a buyer is quoted in. Stored quantities stay units at every level. */
6
- export const PURCHASE_UNITS = ['unit', 'carton'] as const
5
+ import { PURCHASE_UNITS, type PurchaseUnit } from '@spree/core'
7
6
 
8
- export type PurchaseUnit = (typeof PURCHASE_UNITS)[number]
7
+ export { PURCHASE_UNITS, type PurchaseUnit }
9
8
 
10
9
  /**
11
10
  * Reads a quantity rule from a number input. Blank means the rule is unset,
@@ -1,12 +1,10 @@
1
- import type { CurrencyCode } from '@spree/admin-sdk'
1
+ import { type CurrencyCode, currencyParts, normalizeMoneyInput } from '@spree/core'
2
2
  import type { ProductFormValues, VariantPriceFormValues } from '@spree/dashboard-core'
3
3
  import { type BulkPriceRow, BulkPriceTable } from '@spree/dashboard-ui'
4
4
  import { useCallback, useMemo } from 'react'
5
5
  import { type UseFormReturn, useWatch } from 'react-hook-form'
6
6
  import { useTranslation } from 'react-i18next'
7
7
  import { useMoneyLocale } from '../hooks/use-money-locale'
8
- import { currencyParts } from '../lib/currency-parts'
9
- import { normalizeMoneyInput } from '../lib/normalize-money'
10
8
  import { composeOptionsText } from '../products/variants-matrix'
11
9
  import { useFormOptionTypes as useOptionTypes } from './use-product-form-data'
12
10
 
@@ -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,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
+ }