@spree/seller-dashboard 1.0.0-rc.5 → 1.0.0-rc.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spree/seller-dashboard",
3
- "version": "1.0.0-rc.5",
3
+ "version": "1.0.0-rc.7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Spree marketplace seller panel — the seller-facing counterpart to the admin dashboard.",
@@ -36,47 +36,48 @@
36
36
  "LICENSE"
37
37
  ],
38
38
  "dependencies": {
39
- "@hookform/resolvers": "^5.2.2",
39
+ "@hookform/resolvers": "^6.0.0",
40
40
  "@dnd-kit/core": "^6.3.1",
41
41
  "@dnd-kit/sortable": "^10.0.0",
42
42
  "@dnd-kit/utilities": "^3.2.2",
43
- "@tanstack/router-plugin": "^1.168.46",
43
+ "@tanstack/router-plugin": "^1.168.47",
44
44
  "@tanstack/virtual-file-routes": "^1.162.0",
45
- "lucide-react": "^1.58.0",
45
+ "lucide-react": "^1.60.0",
46
46
  "zod": "^4.6.7",
47
- "@spree/seller-sdk": "1.0.0-rc.4"
47
+ "@spree/core": "^0.3.0",
48
+ "@spree/seller-sdk": "1.0.0-rc.6"
48
49
  },
49
50
  "peerDependencies": {
50
- "@tanstack/react-query": "^5.104.3",
51
- "@tanstack/react-router": "^1.170.44",
52
- "i18next": "^26.4.7",
51
+ "@tanstack/react-query": "^5.104.5",
52
+ "@tanstack/react-router": "^1.170.45",
53
+ "i18next": "^26.5.3",
53
54
  "react": "^19.3.1",
54
55
  "react-dom": "^19.3.1",
55
56
  "react-hook-form": "^7.90.0",
56
- "react-i18next": "^17.0.20",
57
- "@spree/admin-sdk": "^1.0.0-rc.4",
58
- "@spree/dashboard-core": "^1.0.0-rc.4",
59
- "@spree/dashboard-ui": "^1.0.0-rc.4"
57
+ "react-i18next": "^17.1.0",
58
+ "@spree/admin-sdk": "^1.0.0-rc.6",
59
+ "@spree/dashboard-core": "^1.0.0-rc.6",
60
+ "@spree/dashboard-ui": "^1.0.0-rc.6"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@biomejs/biome": "^2.4.14",
63
- "@tanstack/react-query": "^5.104.3",
64
- "@tanstack/react-router": "^1.170.44",
64
+ "@tanstack/react-query": "^5.104.5",
65
+ "@tanstack/react-router": "^1.170.45",
65
66
  "@types/node": "^26.6.5",
66
67
  "@types/react": "^19.2.2",
67
68
  "@types/react-dom": "^19.2.1",
68
69
  "@vitejs/plugin-react": "^6.0.1",
69
- "i18next": "^26.4.7",
70
+ "i18next": "^26.5.3",
70
71
  "react": "^19.3.1",
71
72
  "react-dom": "^19.3.1",
72
73
  "react-hook-form": "^7.90.0",
73
- "react-i18next": "^17.0.20",
74
+ "react-i18next": "^17.1.0",
74
75
  "tsup": "^9.0.0",
75
76
  "typescript": "~7.0.2",
76
77
  "vite": "^8.3.4",
77
- "@spree/admin-sdk": "1.0.0-rc.4",
78
- "@spree/dashboard-core": "1.0.0-rc.4",
79
- "@spree/dashboard-ui": "1.0.0-rc.4"
78
+ "@spree/admin-sdk": "1.0.0-rc.6",
79
+ "@spree/dashboard-core": "1.0.0-rc.6",
80
+ "@spree/dashboard-ui": "1.0.0-rc.6"
80
81
  },
81
82
  "scripts": {
82
83
  "build": "pnpm generate:routes && tsup",
package/src/api-client.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { runtimeListParams, setApiClient } from '@spree/dashboard-core'
1
+ import { i18n, runtimeListParams, setApiClient } from '@spree/dashboard-core'
2
2
  import type { SellerApiClient, SellerExportCreateParams } from '@spree/seller-sdk'
3
3
  import { createSellerClient } from '@spree/seller-sdk'
4
4
 
@@ -146,6 +146,17 @@ export function createSellerApiClient({
146
146
  update: (id, params) => sellerClient().packageTypes.update(id, params),
147
147
  delete: (id) => sellerClient().packageTypes.delete(id),
148
148
  },
149
+ digitalAssets: {
150
+ list: (productId, params) => sellerClient().products.digitalAssets.list(productId, params),
151
+ // Sellers upload files only, so a file is the one thing a new asset must carry.
152
+ create: (productId, { signed_id, ...params }) => {
153
+ if (!signed_id) throw new Error(i18n.t('admin.errors.unexpected'))
154
+ return sellerClient().products.digitalAssets.create(productId, { ...params, signed_id })
155
+ },
156
+ update: (productId, id, params) =>
157
+ sellerClient().products.digitalAssets.update(productId, id, params),
158
+ delete: (productId, id) => sellerClient().products.digitalAssets.delete(productId, id),
159
+ },
149
160
  })
150
161
 
151
162
  return client
@@ -1,6 +1,7 @@
1
+ import { isZeroMoney } from '@spree/core'
1
2
  import { formatMoney, useMoneyLocale } from '@spree/dashboard-core'
2
3
  import { FulfillmentEditDialog as SharedFulfillmentEditDialog } from '@spree/dashboard-ui'
3
- import { type Fulfillment, isZeroMoney, SpreeError } from '@spree/seller-sdk'
4
+ import { type Fulfillment, SpreeError } from '@spree/seller-sdk'
4
5
  import { useState } from 'react'
5
6
  import { useTranslation } from 'react-i18next'
6
7
  import { useFulfillmentActions } from '../../hooks/use-fulfillments'
@@ -1,4 +1,5 @@
1
1
  import { zodResolver } from '@hookform/resolvers/zod'
2
+ import type { CurrencyCode } from '@spree/core'
2
3
  import {
3
4
  EMPTY_FILE_UPLOAD_VALUE,
4
5
  FileUploadField,
@@ -9,7 +10,6 @@ import {
9
10
  mapSpreeErrorsToForm,
10
11
  } from '@spree/dashboard-core'
11
12
  import { LabelUploadDialog } from '@spree/dashboard-ui'
12
- import type { CurrencyCode } from '@spree/seller-sdk'
13
13
  import { useState } from 'react'
14
14
  import { useForm } from 'react-hook-form'
15
15
  import { useTranslation } from 'react-i18next'
@@ -1,3 +1,4 @@
1
+ import { FULFILLMENT_STATUSES, subsetOf } from '@spree/core'
1
2
  import { fulfillmentItemRows } from '@spree/dashboard-core'
2
3
  import {
3
4
  Badge,
@@ -44,8 +45,8 @@ import { ShippingLabelRow } from './shipping-label-row'
44
45
  // A parcel that has not gone out is the only one still to ship or cancel.
45
46
  // Reading these off "not unfulfilled" once badged a canceled parcel as a
46
47
  // success.
47
- const CAN_SHIP = ['unfulfilled']
48
- const CAN_CANCEL = ['unfulfilled']
48
+ const CAN_SHIP = subsetOf(FULFILLMENT_STATUSES, ['unfulfilled'])
49
+ const CAN_CANCEL = subsetOf(FULFILLMENT_STATUSES, ['unfulfilled'])
49
50
 
50
51
  /** Every parcel owed on this order, and what the seller can do with each. */
51
52
  export function FulfillmentsCard({ order }: { order: Order }) {
@@ -1,3 +1,4 @@
1
+ import { isZeroMoney } from '@spree/core'
1
2
  import {
2
3
  AddressFormDialog,
3
4
  formatMoney,
@@ -21,7 +22,6 @@ import {
21
22
  } from '@spree/dashboard-ui'
22
23
  import { EllipsisVerticalIcon, PencilIcon } from '@spree/dashboard-ui/icons'
23
24
  import type { Order, OrderAddressParams } from '@spree/seller-sdk'
24
- import { isZeroMoney } from '@spree/seller-sdk'
25
25
  import { type ReactNode, useState } from 'react'
26
26
  import { useTranslation } from 'react-i18next'
27
27
  import { sellerClient } from '../../api-client'
@@ -1,3 +1,4 @@
1
+ import { CLAIM_STATUSES, EXCHANGE_STATUSES, subsetOf, sumMoney } from '@spree/core'
1
2
  import { currencyParts, formatMoney, isPositiveMoney, useMoneyLocale } from '@spree/dashboard-core'
2
3
  import {
3
4
  Button,
@@ -23,7 +24,6 @@ import {
23
24
  ShieldAlertIcon,
24
25
  } from '@spree/dashboard-ui/icons'
25
26
  import type { Claim, Order } from '@spree/seller-sdk'
26
- import { sumMoney } from '@spree/seller-sdk'
27
27
  import i18n from 'i18next'
28
28
  import { useState } from 'react'
29
29
  import { useTranslation } from 'react-i18next'
@@ -36,8 +36,8 @@ import {
36
36
  import { lineLabel } from './line-label'
37
37
  import { CreateClaimDialog } from './post-sale-create-dialogs'
38
38
 
39
- const EXCHANGE_ACTIONABLE = ['requested', 'approved', 'received']
40
- const CLAIM_ACTIONABLE = ['open', 'approved']
39
+ const EXCHANGE_ACTIONABLE = subsetOf(EXCHANGE_STATUSES, ['requested', 'approved', 'received'])
40
+ const CLAIM_ACTIONABLE = subsetOf(CLAIM_STATUSES, ['open', 'approved'])
41
41
 
42
42
  /**
43
43
  * Goods swapped for different ones.
@@ -1,3 +1,4 @@
1
+ import { RETURN_STATUSES, subsetOf } from '@spree/core'
1
2
  import {
2
3
  currencyParts,
3
4
  formatMoney,
@@ -35,7 +36,7 @@ import { CreateReturnDialog, fulfilledUnits } from './post-sale-create-dialogs'
35
36
 
36
37
  // Refunded and canceled returns are finished; offering a menu on them would
37
38
  // open onto nothing.
38
- const ACTIONABLE = ['requested', 'approved', 'received']
39
+ const ACTIONABLE = subsetOf(RETURN_STATUSES, ['requested', 'approved', 'received'])
39
40
 
40
41
  /**
41
42
  * Goods coming back on this order.
@@ -1,6 +1,6 @@
1
+ import type { CurrencyCode } from '@spree/core'
1
2
  import { useResourceKeyBuilder } from '@spree/dashboard-core'
2
3
  import { toastManager } from '@spree/dashboard-ui'
3
- import type { CurrencyCode } from '@spree/seller-sdk'
4
4
  import { useMutation, useQueryClient } from '@tanstack/react-query'
5
5
  import { sellerClient } from '../api-client'
6
6
 
@@ -1,3 +1,4 @@
1
+ import type { ClaimResolution } from '@spree/core'
1
2
  import { useResourceKey, useResourceKeyBuilder } from '@spree/dashboard-core'
2
3
  import { toastManager } from '@spree/dashboard-ui'
3
4
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@@ -182,7 +183,7 @@ export function useClaimActions(orderId: string) {
182
183
  replacementLineItemIds,
183
184
  }: {
184
185
  claimId: string
185
- resolution: 'refund' | 'replacement' | 'refund_and_replacement'
186
+ resolution: ClaimResolution
186
187
  refundMethod?: 'original_payment' | 'store_credit'
187
188
  amount?: string
188
189
  replacementLineItemIds?: string[]
@@ -750,7 +750,8 @@
750
750
  "variant_remove_confirm_description": "يُزال من المنتج عند الحفظ.",
751
751
  "variant_remove_confirm_title": "إزالة هذا الخيار؟",
752
752
  "variants": "الخيارات",
753
- "variants_empty": "يُباع هذا المنتج كقطعة واحدة بالسعر أعلاه. أضف خيارًا لبيعه بخيارات مثل المقاس أو اللون."
753
+ "variants_empty": "يُباع هذا المنتج كقطعة واحدة بالسعر أعلاه. أضف خيارًا لبيعه بخيارات مثل المقاس أو اللون.",
754
+ "digital_files_review_notice": "إضافة ملف أو استبداله يعيد هذا المنتج إلى المراجعة، ويتوقف بيعه حتى يعتمده السوق مجددًا."
754
755
  },
755
756
  "profile": {
756
757
  "about": "نبذة",
@@ -738,7 +738,8 @@
738
738
  "variant_remove_confirm_description": "Sie wird beim Speichern vom Produkt entfernt.",
739
739
  "variant_remove_confirm_title": "Diese Variante entfernen?",
740
740
  "variants": "Varianten",
741
- "variants_empty": "Dieses Produkt wird als ein Artikel zum obigen Preis verkauft. Fügen Sie eine Variante hinzu, um es in Optionen wie Größe oder Farbe anzubieten."
741
+ "variants_empty": "Dieses Produkt wird als ein Artikel zum obigen Preis verkauft. Fügen Sie eine Variante hinzu, um es in Optionen wie Größe oder Farbe anzubieten.",
742
+ "digital_files_review_notice": "Das Hinzufügen oder Ersetzen einer Datei schickt dieses Produkt zurück in die Prüfung; es ist nicht verkäuflich, bis der Marktplatz es erneut freigibt."
742
743
  },
743
744
  "profile": {
744
745
  "about": "Über uns",
@@ -738,7 +738,8 @@
738
738
  "variant_remove_confirm_description": "It comes off the product when you save.",
739
739
  "variant_remove_confirm_title": "Remove this variant?",
740
740
  "variants": "Variants",
741
- "variants_empty": "This product is sold as one item, at the price above. Add a variant to sell it in options such as size or colour."
741
+ "variants_empty": "This product is sold as one item, at the price above. Add a variant to sell it in options such as size or colour.",
742
+ "digital_files_review_notice": "Adding or replacing a file sends this product back to review, and it is off sale until the marketplace approves it again."
742
743
  },
743
744
  "profile": {
744
745
  "about": "About",
@@ -738,7 +738,8 @@
738
738
  "variant_remove_confirm_description": "Elle sera retirée du produit à l’enregistrement.",
739
739
  "variant_remove_confirm_title": "Retirer cette variante ?",
740
740
  "variants": "Variantes",
741
- "variants_empty": "Ce produit est vendu comme un article unique, au prix ci-dessus. Ajoutez une variante pour le vendre en plusieurs options, par exemple la taille ou la couleur."
741
+ "variants_empty": "Ce produit est vendu comme un article unique, au prix ci-dessus. Ajoutez une variante pour le vendre en plusieurs options, par exemple la taille ou la couleur.",
742
+ "digital_files_review_notice": "Ajouter ou remplacer un fichier renvoie ce produit en révision ; il n’est plus en vente jusqu’à ce que la place de marché l’approuve de nouveau."
742
743
  },
743
744
  "profile": {
744
745
  "about": "À propos",
@@ -744,7 +744,8 @@
744
744
  "variant_remove_confirm_description": "Zniknie z produktu po zapisaniu.",
745
745
  "variant_remove_confirm_title": "Usunąć ten wariant?",
746
746
  "variants": "Warianty",
747
- "variants_empty": "Ten produkt sprzedajesz jako jedną pozycję, w cenie powyżej. Dodaj wariant, aby sprzedawać go w opcjach takich jak rozmiar czy kolor."
747
+ "variants_empty": "Ten produkt sprzedajesz jako jedną pozycję, w cenie powyżej. Dodaj wariant, aby sprzedawać go w opcjach takich jak rozmiar czy kolor.",
748
+ "digital_files_review_notice": "Dodanie lub zastąpienie pliku wysyła ten produkt ponownie do weryfikacji; do ponownego zatwierdzenia przez marketplace nie jest w sprzedaży."
748
749
  },
749
750
  "profile": {
750
751
  "about": "O sprzedawcy",
@@ -735,7 +735,8 @@
735
735
  "variant_remove_confirm_description": "保存后它会从商品中移除。",
736
736
  "variant_remove_confirm_title": "移除此变体?",
737
737
  "variants": "变体",
738
- "variants_empty": "此商品按上述价格作为单一款式销售。添加变体即可按尺码、颜色等选项销售。"
738
+ "variants_empty": "此商品按上述价格作为单一款式销售。添加变体即可按尺码、颜色等选项销售。",
739
+ "digital_files_review_notice": "添加或替换文件会将此商品重新送审,在平台再次批准前将暂停销售。"
739
740
  },
740
741
  "profile": {
741
742
  "about": "简介",
@@ -1,6 +1,10 @@
1
1
  import {
2
+ AMOUNT_BASED_CALCULATORS,
3
+ CURRENCY_AMOUNT_KEYS,
4
+ CurrencyAmountsField,
2
5
  defaultPreferences,
3
6
  filterPreferenceSchema,
7
+ hasCurrencyAmount,
4
8
  PageHeader,
5
9
  PreferencesForm,
6
10
  type ResourceSearch,
@@ -281,13 +285,15 @@ function DeliveryMethodSheet({
281
285
 
282
286
  form.setValue('calculator', {
283
287
  type: flatRate.type,
284
- preferences: defaultPreferences(renderableSchema(flatRate.schema)),
288
+ preferences: calculatorDefaults(flatRate.type, flatRate.schema),
285
289
  })
286
290
  }, [calculators, methodId, form])
287
291
 
288
- const calculatorSchema = renderableSchema(
292
+ const calculatorSchema = calculatorFormSchema(
293
+ calculatorType,
289
294
  calculators?.data.find((calculator) => calculator.type === calculatorType)?.schema,
290
295
  )
296
+ const amountBased = AMOUNT_BASED_CALCULATORS.includes(calculatorType)
291
297
 
292
298
  const save = useMutation({
293
299
  mutationFn: (values: DeliveryMethodFormValues) => {
@@ -354,7 +360,17 @@ function DeliveryMethodSheet({
354
360
  <SheetDescription>{t('delivery_methods.sheet.description')}</SheetDescription>
355
361
  </SheetHeader>
356
362
  <form
357
- onSubmit={form.handleSubmit((values) => save.mutateAsync(values).catch(() => undefined))}
363
+ onSubmit={form.handleSubmit((values) => {
364
+ // An amount-based calculator with no amount in any currency would never be offered.
365
+ const { type, preferences } = values.calculator
366
+ if (AMOUNT_BASED_CALCULATORS.includes(type) && !hasCurrencyAmount(preferences)) {
367
+ form.setError('calculator.preferences', {
368
+ message: t('admin.currency_amounts.required'),
369
+ })
370
+ return
371
+ }
372
+ return save.mutateAsync(values).catch(() => undefined)
373
+ })}
358
374
  className="flex min-h-0 flex-1 flex-col"
359
375
  >
360
376
  <div className="flex flex-1 flex-col gap-6 overflow-y-auto p-4">
@@ -488,11 +504,10 @@ function DeliveryMethodSheet({
488
504
  value={field.value}
489
505
  onValueChange={(next) => {
490
506
  field.onChange(next)
491
- const schema = renderableSchema(
492
- calculators?.data.find((calculator) => calculator.type === next)
493
- ?.schema,
494
- )
495
- form.setValue('calculator.preferences', defaultPreferences(schema))
507
+ const schema = calculators?.data.find(
508
+ (calculator) => calculator.type === next,
509
+ )?.schema
510
+ form.setValue('calculator.preferences', calculatorDefaults(next, schema))
496
511
  }}
497
512
  >
498
513
  <SelectTrigger id="delivery-method-calculator">
@@ -522,13 +537,24 @@ function DeliveryMethodSheet({
522
537
  <Controller
523
538
  control={form.control}
524
539
  name="calculator.preferences"
525
- render={({ field }) => (
526
- <PreferencesForm
527
- schema={calculatorSchema}
528
- values={field.value}
529
- onChange={field.onChange}
530
- currencyOptions={currencyOptions}
531
- />
540
+ render={({ field, fieldState }) => (
541
+ <>
542
+ {amountBased && sellerProfile && (
543
+ <CurrencyAmountsField
544
+ values={field.value}
545
+ onChange={field.onChange}
546
+ currencies={currencyOptions}
547
+ defaultCurrency={sellerProfile.default_currency}
548
+ error={fieldState.error}
549
+ />
550
+ )}
551
+ <PreferencesForm
552
+ schema={calculatorSchema}
553
+ values={field.value}
554
+ onChange={field.onChange}
555
+ currencyOptions={currencyOptions}
556
+ />
557
+ </>
532
558
  )}
533
559
  />
534
560
  </CardContent>
@@ -634,12 +660,33 @@ function DeliveryMethodSheet({
634
660
  /**
635
661
  * The part of a preference schema the shared form can render.
636
662
  *
637
- * Drops keyed maps — today the flat-rate calculator's
638
- * per-currency `amounts` map, which the operator edits through a dedicated
639
- * multi-currency control and the generic form would show as the hash itself
640
- * in a text box. A seller quotes in the store's currency, so `amount` and
641
- * `currency` are the whole question.
663
+ * Drops keyed maps, which the generic form would show as the hash itself in a
664
+ * text box.
665
+ */
666
+ /**
667
+ * A calculator's settings for the generic form: an amount-based calculator's
668
+ * amounts are edited per currency by the amount editor instead.
642
669
  */
670
+ function calculatorFormSchema(
671
+ type: string,
672
+ schema: PreferenceSchema | undefined,
673
+ ): PreferenceSchema | undefined {
674
+ const renderable = renderableSchema(schema)
675
+ if (!renderable || !AMOUNT_BASED_CALCULATORS.includes(type)) return renderable
676
+
677
+ return filterPreferenceSchema(renderable, (key) => !CURRENCY_AMOUNT_KEYS.includes(key))
678
+ }
679
+
680
+ /**
681
+ * A calculator's starting settings. The single amount's default is 0, which
682
+ * would read as a price someone set, so an amount-based calculator starts with
683
+ * no amount in any currency.
684
+ */
685
+ function calculatorDefaults(type: string, schema: PreferenceSchema | undefined) {
686
+ const defaults = defaultPreferences(calculatorFormSchema(type, schema))
687
+ return AMOUNT_BASED_CALCULATORS.includes(type) ? { ...defaults, amount: null } : defaults
688
+ }
689
+
643
690
  function renderableSchema(schema: PreferenceSchema | undefined): PreferenceSchema | undefined {
644
691
  if (!schema) return undefined
645
692
 
@@ -1,7 +1,9 @@
1
1
  import { zodResolver } from '@hookform/resolvers/zod'
2
2
  import {
3
3
  CategorizationCard,
4
+ DigitalAssetsCard,
4
5
  extensionFormValues,
6
+ extensionRowValues,
5
7
  extensionSubmitValues,
6
8
  GeneralCard,
7
9
  InventoryCard,
@@ -15,8 +17,10 @@ import {
15
17
  productFormSchema,
16
18
  productToFormValues,
17
19
  Slot,
20
+ useAssignableVariants,
18
21
  VariantsCard,
19
22
  variantToWirePayload,
23
+ withExtensionRowValues,
20
24
  } from '@spree/dashboard-core'
21
25
  import { FormActions, ResourceLayout, StatusBadge, toastManager } from '@spree/dashboard-ui'
22
26
  import type { ProductCreateParams } from '@spree/seller-sdk'
@@ -29,6 +33,7 @@ import { sellerClient } from '../api-client'
29
33
  import { CenteredMessage } from '../components/centered-message'
30
34
  import { ProductStatusCard } from '../components/product-status-card'
31
35
  import { RetryableError } from '../components/retryable-error'
36
+ import { useAccount } from '../hooks/use-account'
32
37
 
33
38
  /** Everything the form edits, in one request. */
34
39
  const PRODUCT_EXPAND = [
@@ -94,6 +99,11 @@ export function ProductPage({ mode }: { mode: 'new' | 'edit' }) {
94
99
  defaultValues: { ...newProductFormDefaults(), ...extensionFormValues('seller.product', null) },
95
100
  })
96
101
 
102
+ const assignableVariants = useAssignableVariants(form.control, product?.variants)
103
+ // Whether this marketplace lets sellers upload files, and of which types,
104
+ // comes with the account the panel already holds.
105
+ const marketplace = useAccount().data?.store
106
+
97
107
  // Reset once the record arrives, so the inputs show what was last saved
98
108
  // rather than the blank defaults the form mounted with.
99
109
  //
@@ -106,7 +116,7 @@ export function ProductPage({ mode }: { mode: 'new' | 'edit' }) {
106
116
  if (form.formState.isDirty) return
107
117
 
108
118
  form.reset({
109
- ...productToFormValues(product as PanelProduct, product.media),
119
+ ...productToFormValues(product as PanelProduct, product.media, 'seller.product'),
110
120
  ...extensionFormValues('seller.product', product),
111
121
  })
112
122
  }, [product, form])
@@ -137,6 +147,7 @@ export function ProductPage({ mode }: { mode: 'new' | 'edit' }) {
137
147
  // cards offer both — an allow-list that omitted them let a seller
138
148
  // set a crop or paste a video URL and watch it vanish on save.
139
149
  media: (values.media ?? []).map((item) => ({
150
+ ...extensionRowValues('seller.product.media', item),
140
151
  id: item.id,
141
152
  signed_id: item.signed_id ?? undefined,
142
153
  poster_signed_id: item.poster_signed_id ?? undefined,
@@ -149,7 +160,7 @@ export function ProductPage({ mode }: { mode: 'new' | 'edit' }) {
149
160
  variant_ids: item.variant_ids,
150
161
  })),
151
162
  variants: (values.variants ?? []).map((variant, index) =>
152
- variantToWirePayload(variant, index),
163
+ variantToWirePayload(variant, index, 'seller.product'),
153
164
  ) as ProductCreateParams['variants'],
154
165
  }
155
166
 
@@ -181,7 +192,7 @@ export function ProductPage({ mode }: { mode: 'new' | 'edit' }) {
181
192
  // Raw form state, not `values`: the Zod parse strips keys the schema
182
193
  // doesn't know, which is every field a slot widget registered.
183
194
  await save.mutateAsync({
184
- values,
195
+ values: withExtensionRowValues('seller.product', values, form.getValues()),
185
196
  extensionValues: extensionSubmitValues('seller.product', form),
186
197
  })
187
198
  } catch (err) {
@@ -236,8 +247,13 @@ export function ProductPage({ mode }: { mode: 'new' | 'edit' }) {
236
247
  main={
237
248
  <>
238
249
  <GeneralCard form={form} />
239
- <VariantsCard form={form} />
240
- <MediaCard productId={productId} variants={product?.variants ?? []} form={form} />
250
+ <VariantsCard form={form} formKey="seller.product" />
251
+ <MediaCard
252
+ productId={productId}
253
+ variants={assignableVariants}
254
+ form={form}
255
+ formKey="seller.product"
256
+ />
241
257
  <PricesCard
242
258
  form={form}
243
259
  productName={product?.name ?? ''}
@@ -245,6 +261,18 @@ export function ProductPage({ mode }: { mode: 'new' | 'edit' }) {
245
261
  defaultCurrency={profile?.default_currency}
246
262
  />
247
263
  <InventoryCard form={form} />
264
+ <DigitalAssetsCard
265
+ productId={productId}
266
+ variants={assignableVariants}
267
+ readOnly={!marketplace?.seller_digital_assets_enabled}
268
+ accept={marketplace?.seller_digital_asset_content_types}
269
+ notice={
270
+ product?.status === 'active' && marketplace?.review_seller_digital_asset_changes
271
+ ? t('products.digital_files_review_notice')
272
+ : undefined
273
+ }
274
+ />
275
+ <Slot name="seller.product.form_main" context={{ product, mode }} />
248
276
  </>
249
277
  }
250
278
  sidebar={
@@ -1,3 +1,4 @@
1
+ import { PRODUCT_STATUSES, subsetOf } from '@spree/core'
1
2
  import type {
2
3
  BulkAction,
3
4
  BulkActionFormProps,
@@ -40,7 +41,7 @@ import '../tables/products'
40
41
  * approves it, so the only moves offered here are the two a seller makes
41
42
  * alone (docs/plans/6.0-seller-product-submission.md).
42
43
  */
43
- const BULK_STATUSES = ['draft', 'archived'] as const
44
+ const BULK_STATUSES = subsetOf(PRODUCT_STATUSES, ['draft', 'archived'])
44
45
 
45
46
  type BulkStatus = (typeof BULK_STATUSES)[number]
46
47
  type StatusFormValues = { status: BulkStatus }
@@ -17,5 +17,5 @@ export const Route = createFileRoute('/_authenticated/$sellerId/settings/package
17
17
  function PackageTypesRoute() {
18
18
  const search = Route.useSearch() as z.infer<typeof packageTypesSearchSchema>
19
19
 
20
- return <PackageTypesPage search={search} />
20
+ return <PackageTypesPage search={search} formKey="seller.package_type" />
21
21
  }
@@ -18,5 +18,5 @@ export const Route = createFileRoute('/_authenticated/$sellerId/settings/stock-l
18
18
  function StockLocationsRoute() {
19
19
  const search = Route.useSearch() as z.infer<typeof stockLocationsSearchSchema>
20
20
 
21
- return <StockLocationsPage search={search} />
21
+ return <StockLocationsPage search={search} formKey="seller.stock_location" />
22
22
  }
@@ -1,4 +1,10 @@
1
- import { defineTable, formatMoney, formatStoreDateTime } from '@spree/dashboard-core'
1
+ import { ORDER_PAYMENT_STATUSES } from '@spree/core'
2
+ import {
3
+ defineTable,
4
+ FILTERABLE_ORDER_FULFILLMENT_STATUSES,
5
+ formatMoney,
6
+ formatStoreDateTime,
7
+ } from '@spree/dashboard-core'
2
8
  import { ResourceNameCell, StatusBadge } from '@spree/dashboard-ui'
3
9
  import { PackageIcon } from '@spree/dashboard-ui/icons'
4
10
  import type { Order } from '@spree/seller-sdk'
@@ -17,26 +23,6 @@ function OrderDate({ iso }: { iso: string }) {
17
23
  return <>{formatStoreDateTime(iso, timezone)}</>
18
24
  }
19
25
 
20
- /** Mirrors the operator dashboard's order status vocabularies. */
21
- const FULFILLMENT_STATUSES = [
22
- 'unfulfilled',
23
- 'backorder',
24
- 'partial',
25
- 'fulfilled',
26
- 'delivered',
27
- 'canceled',
28
- ] as const
29
- const PAYMENT_STATUSES = [
30
- 'none',
31
- 'authorized',
32
- 'partially_paid',
33
- 'paid',
34
- 'partially_refunded',
35
- 'refunded',
36
- 'overcharged',
37
- 'voided',
38
- ] as const
39
-
40
26
  defineTable<Order>('seller-orders', {
41
27
  title: i18n.t('orders.title'),
42
28
  searchParam: 'number_cont',
@@ -69,7 +55,7 @@ defineTable<Order>('seller-orders', {
69
55
  label: i18n.t('orders.columns.fulfillment'),
70
56
  filterable: true,
71
57
  filterType: 'enum',
72
- filterOptions: FULFILLMENT_STATUSES.map((status) => ({
58
+ filterOptions: FILTERABLE_ORDER_FULFILLMENT_STATUSES.map((status) => ({
73
59
  value: status,
74
60
  label: i18n.t(`orders.fulfillment_statuses.${status}`),
75
61
  })),
@@ -90,7 +76,7 @@ defineTable<Order>('seller-orders', {
90
76
  label: i18n.t('orders.columns.payment'),
91
77
  filterable: true,
92
78
  filterType: 'enum',
93
- filterOptions: PAYMENT_STATUSES.map((status) => ({
79
+ filterOptions: ORDER_PAYMENT_STATUSES.map((status) => ({
94
80
  value: status,
95
81
  label: i18n.t(`orders.payment_statuses.${status}`),
96
82
  })),
@@ -1,3 +1,4 @@
1
+ import { SELLER_PAYOUT_STATUSES } from '@spree/core'
1
2
  import { defineTable, formatMoney, formatStoreDateTime } from '@spree/dashboard-core'
2
3
  import { ResourceNameCell, StatusBadge } from '@spree/dashboard-ui'
3
4
  import { HandCoinsIcon } from '@spree/dashboard-ui/icons'
@@ -18,8 +19,6 @@ function LedgerDate({ iso }: { iso: string }) {
18
19
  return <>{formatStoreDateTime(iso, timezone)}</>
19
20
  }
20
21
 
21
- const STATUSES = ['pending', 'processing', 'completed', 'failed', 'unresolved'] as const
22
-
23
22
  defineTable<Payout>('seller-payouts', {
24
23
  title: i18n.t('payouts.title'),
25
24
  searchParam: 'search',
@@ -50,7 +49,7 @@ defineTable<Payout>('seller-payouts', {
50
49
  sortable: true,
51
50
  filterable: true,
52
51
  filterType: 'enum',
53
- filterOptions: STATUSES.map((status) => ({
52
+ filterOptions: SELLER_PAYOUT_STATUSES.map((status) => ({
54
53
  value: status,
55
54
  label: i18n.t(`earnings.statuses.${status}`),
56
55
  })),
@@ -1,12 +1,10 @@
1
+ import { PRODUCT_STATUSES } from '@spree/core'
1
2
  import { defineTable, formatPrice } from '@spree/dashboard-core'
2
3
  import { ResourceNameCell, StatusBadge, Thumbnail } from '@spree/dashboard-ui'
3
4
  import { PackageIcon } from '@spree/dashboard-ui/icons'
4
5
  import type { Product } from '@spree/seller-sdk'
5
6
  import i18n from 'i18next'
6
7
 
7
- /** Mirrors the operator dashboard's product statuses. */
8
- const PRODUCT_STATUSES = ['active', 'draft', 'proposed', 'rejected', 'archived'] as const
9
-
10
8
  defineTable<Product>('seller-products', {
11
9
  title: i18n.t('products.title'),
12
10
  searchParam: 'search',
@@ -1,3 +1,4 @@
1
+ import { SELLER_TRANSFER_KINDS, SELLER_TRANSFER_STATUSES } from '@spree/core'
1
2
  import { defineTable, formatMoney, formatStoreDateTime, useTenantId } from '@spree/dashboard-core'
2
3
  import { StatusBadge } from '@spree/dashboard-ui'
3
4
  import { BanknoteIcon } from '@spree/dashboard-ui/icons'
@@ -43,9 +44,6 @@ function LedgerDate({ iso }: { iso: string }) {
43
44
  return <>{formatStoreDateTime(iso, timezone)}</>
44
45
  }
45
46
 
46
- const STATUSES = ['pending', 'processing', 'completed', 'failed', 'unresolved'] as const
47
- const KINDS = ['earning', 'refund_reversal'] as const
48
-
49
47
  defineTable<Transfer>('seller-transfers', {
50
48
  title: i18n.t('earnings.transfers.title'),
51
49
  // Without this the toolbar sends `name_cont`, which the ledger does not
@@ -78,7 +76,7 @@ defineTable<Transfer>('seller-transfers', {
78
76
  label: i18n.t('earnings.columns.kind'),
79
77
  filterable: true,
80
78
  filterType: 'enum',
81
- filterOptions: KINDS.map((kind) => ({
79
+ filterOptions: SELLER_TRANSFER_KINDS.map((kind) => ({
82
80
  value: kind,
83
81
  label: i18n.t(`earnings.kinds.${kind}`),
84
82
  })),
@@ -93,7 +91,7 @@ defineTable<Transfer>('seller-transfers', {
93
91
  sortable: true,
94
92
  filterable: true,
95
93
  filterType: 'enum',
96
- filterOptions: STATUSES.map((status) => ({
94
+ filterOptions: SELLER_TRANSFER_STATUSES.map((status) => ({
97
95
  value: status,
98
96
  label: i18n.t(`earnings.statuses.${status}`),
99
97
  })),