@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
@@ -0,0 +1,451 @@
1
+ import type { DigitalAssetProvider } from '@spree/admin-sdk'
2
+ import {
3
+ formatFileSize,
4
+ getApiClient,
5
+ Subject,
6
+ useFileUpload,
7
+ usePermissions,
8
+ } from '@spree/dashboard-core'
9
+ import {
10
+ Button,
11
+ Card,
12
+ CardAction,
13
+ CardContent,
14
+ CardHeader,
15
+ CardTitle,
16
+ DropdownMenu,
17
+ DropdownMenuContent,
18
+ DropdownMenuItem,
19
+ DropdownMenuTrigger,
20
+ FileTypeIcon,
21
+ Pagination,
22
+ RowActions,
23
+ Skeleton,
24
+ Table,
25
+ TableBody,
26
+ TableCell,
27
+ TableHead,
28
+ TableHeader,
29
+ TableRow,
30
+ toastManager,
31
+ useConfirm,
32
+ } from '@spree/dashboard-ui'
33
+ import {
34
+ ChevronDownIcon,
35
+ DownloadIcon,
36
+ FileDownIcon,
37
+ FileIcon,
38
+ PlusIcon,
39
+ UploadIcon,
40
+ } from '@spree/dashboard-ui/icons'
41
+ import { useRef, useState } from 'react'
42
+ import { useTranslation } from 'react-i18next'
43
+ import type { PanelDigitalAsset } from '../api-client'
44
+ import { variantLabel } from './assignable-variants'
45
+ import { DigitalAssetEditSheet } from './digital-asset-edit-sheet'
46
+ import { DigitalAssetProviderSheet } from './digital-asset-provider-sheet'
47
+ import type { PanelVariant } from './product-types'
48
+ import {
49
+ useCreateDigitalAsset,
50
+ useDeleteDigitalAsset,
51
+ useDigitalAssetProviders,
52
+ useDigitalAssets,
53
+ useUpdateDigitalAsset,
54
+ } from './use-digital-assets'
55
+
56
+ export function DigitalAssetsCard({
57
+ productId,
58
+ variants,
59
+ readOnly = false,
60
+ accept,
61
+ notice,
62
+ }: {
63
+ productId?: string
64
+ variants?: PanelVariant[]
65
+ /**
66
+ * Shows the files without any way to change them, for a panel whose host
67
+ * has not opened file uploads to this user — the seller panel when the
68
+ * marketplace keeps digital files to itself. With nothing to show, the
69
+ * card renders nothing at all.
70
+ */
71
+ readOnly?: boolean
72
+ /** The content types the picker offers, when the host restricts them. */
73
+ accept?: string[]
74
+ /**
75
+ * What else a file change does here, shown under the description — the
76
+ * seller panel says that a listing on sale goes back to review.
77
+ */
78
+ notice?: string
79
+ }) {
80
+ const { t } = useTranslation()
81
+ const confirm = useConfirm()
82
+ const { permissions } = usePermissions()
83
+ const canWrite = !readOnly && permissions.can('update', Subject.Product)
84
+ const fileInputRef = useRef<HTMLInputElement>(null)
85
+ const replaceInputRef = useRef<HTMLInputElement>(null)
86
+ // The file whose replacement the picker was opened for.
87
+ const replacingRef = useRef<PanelDigitalAsset | null>(null)
88
+ const [uploading, setUploading] = useState(false)
89
+ const [page, setPage] = useState(1)
90
+ const [editing, setEditing] = useState<PanelDigitalAsset | null>(null)
91
+ // The provider whose settings sheet is open, before its asset is created.
92
+ const [configuring, setConfiguring] = useState<DigitalAssetProvider | null>(null)
93
+
94
+ // Digital files live on private storage: they are only ever served through an
95
+ // authorized, signed link, never from the public bucket.
96
+ const fileUpload = useFileUpload({ visibility: 'private' })
97
+ const { data, isLoading } = useDigitalAssets(productId ?? '', page, Boolean(productId))
98
+ const { data: providersData } = useDigitalAssetProviders(productId ?? '', Boolean(productId))
99
+ // The card toasts upload failures itself, naming the file, so the
100
+ // mutations stay quiet about theirs.
101
+ const createAsset = useCreateDigitalAsset(productId ?? '', { errorMessage: false })
102
+ const deleteAsset = useDeleteDigitalAsset(productId ?? '')
103
+ const updateAsset = useUpdateDigitalAsset(productId ?? '', { errorMessage: false })
104
+
105
+ const assets: PanelDigitalAsset[] = data?.data ?? []
106
+ const meta = data?.meta
107
+ // Across every page, so emptying a later page does not read as no files.
108
+ const hasAssets = (meta?.count ?? assets.length) > 0
109
+ const hasVariants = (variants?.length ?? 0) > 1
110
+ const providers = providersData?.data ?? []
111
+ // Only providers that resolve their own deliverable go in the source menu;
112
+ // the file default is the plain upload button. With none of these, the card
113
+ // keeps its today behaviour — a single "Add file" button.
114
+ const sourceProviders = providers.filter((p) => !p.requires_attachment)
115
+
116
+ // One path for a new file and a replacement: upload to private storage,
117
+ // hand the signed id on, and name the file when either step fails — a
118
+ // refused upload must never be silent.
119
+ async function uploadThen(file: File, persist: (signedId: string) => Promise<unknown>) {
120
+ try {
121
+ const { signedId, previewUrl } = await fileUpload.mutateAsync(file)
122
+ // The preview is for image cards; a download has nothing to show, and
123
+ // an unreleased object URL pins the whole file in memory.
124
+ URL.revokeObjectURL(previewUrl)
125
+ await persist(signedId)
126
+ } catch (err) {
127
+ const message = err instanceof Error ? err.message : t('admin.errors.unexpected')
128
+ toastManager.add({
129
+ type: 'error',
130
+ title: t('admin.digital_assets.upload_failed', { name: file.name, message }),
131
+ })
132
+ }
133
+ }
134
+
135
+ async function handleFiles(files: FileList | File[]) {
136
+ const list = Array.from(files)
137
+ if (list.length === 0 || !productId) return
138
+
139
+ setUploading(true)
140
+ try {
141
+ for (const file of list) {
142
+ await uploadThen(file, (signedId) => createAsset.mutateAsync({ signed_id: signedId }))
143
+ }
144
+ setPage(1)
145
+ } finally {
146
+ setUploading(false)
147
+ }
148
+ }
149
+
150
+ function handleFileInput(event: React.ChangeEvent<HTMLInputElement>) {
151
+ // `event.target.files` is a live FileList — clearing the input's value
152
+ // empties it, so snapshot to a real array before resetting the input
153
+ // (the reset lets the same file be re-selected later).
154
+ const files = Array.from(event.target.files ?? [])
155
+ event.target.value = ''
156
+ if (files.length > 0) void handleFiles(files)
157
+ }
158
+
159
+ // Replacing removes the current file for everyone who bought it, so it is
160
+ // confirmed before the picker opens, like deleting.
161
+ async function openReplace(asset: PanelDigitalAsset) {
162
+ const confirmed = await confirm({
163
+ title: t('admin.digital_assets.replace_title'),
164
+ message: t('admin.digital_assets.replace_description', {
165
+ name: asset.filename ?? t('admin.digital_assets.untitled'),
166
+ }),
167
+ variant: 'destructive',
168
+ })
169
+ if (!confirmed) return
170
+ replacingRef.current = asset
171
+ replaceInputRef.current?.click()
172
+ }
173
+
174
+ // Replaces the file in place, so links buyers already received serve the
175
+ // new one.
176
+ async function handleReplaceInput(event: React.ChangeEvent<HTMLInputElement>) {
177
+ const file = event.target.files?.[0]
178
+ event.target.value = ''
179
+ const asset = replacingRef.current
180
+ replacingRef.current = null
181
+ if (!file || !asset) return
182
+
183
+ setUploading(true)
184
+ try {
185
+ await uploadThen(file, (signedId) =>
186
+ updateAsset.mutateAsync({ id: asset.id, signed_id: signedId }),
187
+ )
188
+ } finally {
189
+ setUploading(false)
190
+ }
191
+ }
192
+
193
+ function handleDrop(event: React.DragEvent) {
194
+ event.preventDefault()
195
+ if (event.dataTransfer.files.length > 0) void handleFiles(event.dataTransfer.files)
196
+ }
197
+
198
+ // A provider with settings opens a sheet to collect them; one without
199
+ // creates its asset immediately, exactly like the file default.
200
+ async function handleAddProvider(provider: DigitalAssetProvider) {
201
+ if (provider.settings_schema.length > 0) {
202
+ setConfiguring(provider)
203
+ return
204
+ }
205
+ try {
206
+ await createAsset.mutateAsync({ provider_type: provider.type })
207
+ setPage(1)
208
+ } catch (err) {
209
+ // The create mutation suppresses its own toast for validation errors.
210
+ const message = err instanceof Error ? err.message : t('admin.errors.unexpected')
211
+ toastManager.add({
212
+ type: 'error',
213
+ title: t('admin.digital_assets.upload_failed', { name: provider.name, message }),
214
+ })
215
+ }
216
+ }
217
+
218
+ async function handleDelete(asset: PanelDigitalAsset) {
219
+ const confirmed = await confirm({
220
+ title: t('admin.digital_assets.delete_title'),
221
+ message: t('admin.digital_assets.delete_description', {
222
+ name: asset.filename ?? t('admin.digital_assets.untitled'),
223
+ }),
224
+ variant: 'destructive',
225
+ })
226
+ if (!confirmed) return
227
+ await deleteAsset.mutateAsync(asset.id)
228
+ if (assets.length === 1 && page > 1) setPage(page - 1)
229
+ }
230
+
231
+ // A blank limit means the store's setting applies — show the number actually
232
+ // in force rather than an empty cell.
233
+ function limitLabel(own: number | null | undefined, effective: number) {
234
+ return own == null ? t('admin.digital_assets.store_default', { count: effective }) : String(own)
235
+ }
236
+
237
+ function assetVariantLabel(asset: PanelDigitalAsset) {
238
+ const variant = variants?.find((v) => v.id === asset.variant_id)
239
+ return variant ? variantLabel(variant) : '—'
240
+ }
241
+
242
+ if (!productId || !getApiClient().digitalAssets) return null
243
+ if (readOnly && !isLoading && !hasAssets) return null
244
+
245
+ return (
246
+ <Card>
247
+ <CardHeader>
248
+ <CardTitle>
249
+ <FileDownIcon className="size-4" />
250
+ {t('admin.digital_assets.title')}
251
+ </CardTitle>
252
+ {/* The header action only appears once files exist — an empty card
253
+ invites the first upload through its dropzone, not a button. */}
254
+ {canWrite && hasAssets && (
255
+ <CardAction>
256
+ {sourceProviders.length === 0 ? (
257
+ <Button
258
+ type="button"
259
+ variant="outline"
260
+ size="sm"
261
+ disabled={uploading}
262
+ onClick={() => fileInputRef.current?.click()}
263
+ >
264
+ <UploadIcon className="mr-2 size-4" />
265
+ {uploading
266
+ ? t('admin.digital_assets.uploading_short')
267
+ : t('admin.digital_assets.upload')}
268
+ </Button>
269
+ ) : (
270
+ <DropdownMenu>
271
+ <DropdownMenuTrigger asChild>
272
+ <Button type="button" variant="outline" size="sm" disabled={uploading}>
273
+ <PlusIcon className="mr-2 size-4" />
274
+ {uploading
275
+ ? t('admin.digital_assets.uploading_short')
276
+ : t('admin.digital_assets.add')}
277
+ <ChevronDownIcon className="ml-2 size-4" />
278
+ </Button>
279
+ </DropdownMenuTrigger>
280
+ <DropdownMenuContent align="end">
281
+ <DropdownMenuItem onClick={() => fileInputRef.current?.click()}>
282
+ <UploadIcon className="mr-2 size-4" />
283
+ {t('admin.digital_assets.source.upload')}
284
+ </DropdownMenuItem>
285
+ {sourceProviders.map((provider) => (
286
+ <DropdownMenuItem
287
+ key={provider.type}
288
+ onClick={() => handleAddProvider(provider)}
289
+ >
290
+ {provider.name}
291
+ </DropdownMenuItem>
292
+ ))}
293
+ </DropdownMenuContent>
294
+ </DropdownMenu>
295
+ )}
296
+ </CardAction>
297
+ )}
298
+ </CardHeader>
299
+
300
+ <CardContent className="flex flex-col gap-4">
301
+ <p className="text-muted-foreground text-sm">{t('admin.digital_assets.description')}</p>
302
+ {notice && canWrite && <p className="text-sm">{notice}</p>}
303
+
304
+ <input
305
+ ref={fileInputRef}
306
+ type="file"
307
+ multiple
308
+ accept={accept?.join(',')}
309
+ className="hidden"
310
+ onChange={handleFileInput}
311
+ />
312
+ <input
313
+ ref={replaceInputRef}
314
+ type="file"
315
+ accept={accept?.join(',')}
316
+ className="hidden"
317
+ onChange={handleReplaceInput}
318
+ />
319
+
320
+ {isLoading ? (
321
+ <Skeleton className="h-24 w-full" />
322
+ ) : !hasAssets && !canWrite ? (
323
+ <p className="text-muted-foreground text-sm">{t('admin.digital_assets.empty')}</p>
324
+ ) : !hasAssets ? (
325
+ <button
326
+ type="button"
327
+ onDrop={handleDrop}
328
+ onDragOver={(e) => e.preventDefault()}
329
+ onClick={() => fileInputRef.current?.click()}
330
+ disabled={uploading}
331
+ className="flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border p-6 text-center transition-colors hover:border-foreground/30 disabled:pointer-events-none disabled:opacity-60"
332
+ >
333
+ <FileIcon className="size-8 text-muted-foreground" />
334
+ <p className="text-muted-foreground text-sm">
335
+ {uploading
336
+ ? t('admin.digital_assets.uploading_short')
337
+ : t('admin.digital_assets.drop_hint')}
338
+ </p>
339
+ </button>
340
+ ) : (
341
+ <div className="overflow-hidden rounded-xl border border-border-subtle">
342
+ <Table>
343
+ <TableHeader>
344
+ <TableRow>
345
+ <TableHead>{t('admin.digital_assets.columns.file')}</TableHead>
346
+ {hasVariants && (
347
+ <TableHead>{t('admin.digital_assets.columns.variant')}</TableHead>
348
+ )}
349
+ <TableHead>{t('admin.digital_assets.columns.size')}</TableHead>
350
+ <TableHead>{t('admin.digital_assets.columns.downloads')}</TableHead>
351
+ <TableHead>{t('admin.digital_assets.columns.days')}</TableHead>
352
+ <TableHead />
353
+ </TableRow>
354
+ </TableHeader>
355
+ <TableBody>
356
+ {assets.map((asset) => (
357
+ <TableRow key={asset.id}>
358
+ <TableCell className="font-medium">
359
+ <span className="flex items-center gap-2">
360
+ <FileTypeIcon
361
+ filename={asset.filename}
362
+ contentType={asset.content_type}
363
+ className="text-muted-foreground size-4 shrink-0"
364
+ />
365
+ {asset.download_url ? (
366
+ <a
367
+ href={asset.download_url}
368
+ target="_blank"
369
+ rel="noopener noreferrer"
370
+ className="hover:underline"
371
+ >
372
+ {asset.filename ?? t('admin.digital_assets.untitled')}
373
+ </a>
374
+ ) : (
375
+ (asset.filename ?? t('admin.digital_assets.untitled'))
376
+ )}
377
+ </span>
378
+ </TableCell>
379
+ {hasVariants && (
380
+ <TableCell className="text-muted-foreground">
381
+ {assetVariantLabel(asset)}
382
+ </TableCell>
383
+ )}
384
+ <TableCell className="tabular-nums">
385
+ {asset.byte_size ? formatFileSize(asset.byte_size) : '—'}
386
+ </TableCell>
387
+ <TableCell className="tabular-nums">
388
+ {limitLabel(asset.authorized_clicks, asset.effective_authorized_clicks)}
389
+ </TableCell>
390
+ <TableCell className="tabular-nums">
391
+ {limitLabel(asset.authorized_days, asset.effective_authorized_days)}
392
+ </TableCell>
393
+ <TableCell className="text-right">
394
+ <RowActions
395
+ actions={[
396
+ {
397
+ key: 'download',
398
+ label: t('admin.digital_assets.download'),
399
+ icon: <DownloadIcon className="size-4" />,
400
+ visible: Boolean(asset.download_url),
401
+ onSelect: () => window.open(asset.download_url ?? '', '_blank'),
402
+ },
403
+ {
404
+ key: 'replace',
405
+ label: t('admin.digital_assets.replace'),
406
+ icon: <UploadIcon className="size-4" />,
407
+ // Only a stored file can be swapped; a provider's
408
+ // own deliverable has none to download.
409
+ visible: canWrite && Boolean(asset.download_url),
410
+ disabled: uploading,
411
+ onSelect: () => openReplace(asset),
412
+ },
413
+ { key: 'edit', visible: canWrite, onSelect: () => setEditing(asset) },
414
+ {
415
+ key: 'delete',
416
+ visible: canWrite,
417
+ destructive: true,
418
+ onSelect: () => handleDelete(asset),
419
+ },
420
+ ]}
421
+ />
422
+ </TableCell>
423
+ </TableRow>
424
+ ))}
425
+ </TableBody>
426
+ </Table>
427
+ {meta && <Pagination meta={meta} onPageChange={setPage} />}
428
+ </div>
429
+ )}
430
+ </CardContent>
431
+
432
+ <DigitalAssetEditSheet
433
+ productId={productId}
434
+ asset={editing}
435
+ variants={variants}
436
+ settingsSchema={
437
+ providers.find((p) => p.type === editing?.provider_type)?.settings_schema ?? []
438
+ }
439
+ open={Boolean(editing)}
440
+ onOpenChange={(open) => !open && setEditing(null)}
441
+ />
442
+
443
+ <DigitalAssetProviderSheet
444
+ productId={productId}
445
+ provider={configuring}
446
+ onOpenChange={(open) => !open && setConfiguring(null)}
447
+ onCreated={() => setPage(1)}
448
+ />
449
+ </Card>
450
+ )
451
+ }
@@ -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,
@@ -14,10 +20,11 @@ import {
14
20
  SheetTitle,
15
21
  } from '@spree/dashboard-ui'
16
22
  import { CheckIcon } from '@spree/dashboard-ui/icons'
17
- import i18n from 'i18next'
18
23
  import { useCallback, useEffect, useRef } from 'react'
19
24
  import type { UseFormReturn } from 'react-hook-form'
25
+ import { variantLabel } from './assignable-variants'
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>
@@ -323,14 +382,3 @@ export function MediaEditSheet({ form, mediaIndex, variants, open, onOpenChange
323
382
  </Sheet>
324
383
  )
325
384
  }
326
-
327
- function variantLabel(v: Variant): string {
328
- const named = v as { options_text?: string; name?: string; sku?: string | null }
329
- return (
330
- named.options_text ||
331
- named.name ||
332
- named.sku ||
333
- v.id ||
334
- i18n.t('admin.products.variants.variant_label')
335
- )
336
- }
@@ -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