@spree/dashboard-core 0.13.1 → 1.0.0-beta.2

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 (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -0,0 +1,212 @@
1
+ import { i18n } from '../lib/i18n'
2
+ import { normalizeQuantityRule } from './normalize-quantity'
3
+ import type { MediaType, ProductFormValues, VariantFormValues } from './product-schema'
4
+ import type { PanelMedia, PanelProduct, PanelVariant } from './product-types'
5
+
6
+ /**
7
+ * Between the API's shape and the form's.
8
+ *
9
+ * Shared by both panels for the same reason the cards are: the form is one
10
+ * form, so what fills it and what it sends have to agree, and two copies of
11
+ * this mapping would be two ways to disagree.
12
+ */
13
+
14
+ export function variantToFormValues(variant: PanelVariant, position: number): VariantFormValues {
15
+ return {
16
+ id: variant.id,
17
+ sku: variant.sku ?? null,
18
+ barcode: variant.barcode ?? null,
19
+ position,
20
+ // Derive {name, value} pairs from option_values. The serializer carries
21
+ // option_type_name on each OptionValue, so no extra expand is needed.
22
+ options: (variant.option_values ?? []).map((ov) => ({
23
+ name: ov.option_type_name,
24
+ value: ov.name,
25
+ })),
26
+ weight: variant.weight ?? null,
27
+ height: variant.height ?? null,
28
+ width: variant.width ?? null,
29
+ depth: variant.depth ?? null,
30
+ weight_unit: variant.weight_unit ?? null,
31
+ dimensions_unit: variant.dimensions_unit ?? null,
32
+ hs_code: variant.hs_code ?? null,
33
+ country_of_origin: variant.country_of_origin ?? null,
34
+ customs_description: variant.customs_description ?? null,
35
+ minimum_order_quantity: variant.minimum_order_quantity?.toString() ?? null,
36
+ order_multiple: variant.order_multiple?.toString() ?? null,
37
+ purchase_unit: variant.purchase_unit ?? null,
38
+ units_per_carton: variant.units_per_carton?.toString() ?? null,
39
+ cartons_per_pallet: variant.cartons_per_pallet?.toString() ?? null,
40
+ carton_weight: variant.carton_weight == null ? null : Number(variant.carton_weight),
41
+ carton_package_type_id: variant.carton_package_type_id ?? null,
42
+ track_inventory: variant.track_inventory,
43
+ preorderable: variant.preorderable ?? false,
44
+ preorder_ships_at: variant.preorder_ships_at ?? null,
45
+ backorder_limit: variant.backorder_limit ?? null,
46
+ tax_category_id: variant.tax_category_id ?? null,
47
+ prices: (variant.prices ?? [])
48
+ .filter((p) => p.currency != null)
49
+ .map((p) => ({
50
+ currency: p.currency as string,
51
+ // Keep amounts as the canonical decimal strings the API returns.
52
+ // The bulk price editor displays them with the locale's decimal
53
+ // separator and ships the raw user input unchanged on submit;
54
+ // `Spree::LocalizedNumber.parse` handles locale-aware parsing.
55
+ amount: p.amount != null ? String(p.amount) : '',
56
+ compare_at_amount: p.compare_at_amount != null ? String(p.compare_at_amount) : null,
57
+ })),
58
+ stock_levels: (variant.stock_levels ?? []).map((si) => ({
59
+ id: si.id,
60
+ stock_location_id: si.stock_location_id ?? si.stock_location?.id ?? '',
61
+ stock_location_name:
62
+ si.stock_location?.name ?? i18n.t('admin.products.inventory.unknown_location'),
63
+ count_on_hand: si.count_on_hand,
64
+ backorderable: si.backorderable,
65
+ })),
66
+ }
67
+ }
68
+
69
+ // One mapping for both hydration paths (the form reset and the media-only
70
+ // late paint) so a new media field can't reach one and miss the other.
71
+ // Takes the generated SDK type rather than a hand-listed shape, so a field
72
+ // added to the serializer can't quietly go missing here.
73
+ export function mediaToFormValues(media: PanelMedia, index: number) {
74
+ return {
75
+ id: media.id,
76
+ alt: media.alt ?? null,
77
+ position: media.position ?? index + 1,
78
+ variant_ids: media.variant_ids ?? [],
79
+ media_type: (media.media_type ?? 'image') as MediaType,
80
+ external_video_url: media.external_video_url ?? null,
81
+ focal_point_x: media.focal_point_x ?? null,
82
+ focal_point_y: media.focal_point_y ?? null,
83
+ // Video rows have no image of their own, so the poster is the preview.
84
+ previewUrl:
85
+ media.small_url ?? media.mini_url ?? media.poster_url ?? media.original_url ?? undefined,
86
+ // Only the square renditions: the focal point is picked against the
87
+ // rendered box, and original_url is uncropped, so mixing them in would
88
+ // move the point the merchant set.
89
+ fullPreviewUrl: media.large_url ?? media.xlarge_url ?? media.poster_url ?? null,
90
+ posterUrl: media.poster_url,
91
+ // The file itself, so an uploaded video can actually play. Every sized URL
92
+ // on a video row resolves to its poster, so none of them work here.
93
+ videoUrl: media.video_url,
94
+ downloadUrl: media.download_url,
95
+ }
96
+ }
97
+
98
+ export function productToFormValues(
99
+ product: PanelProduct,
100
+ // Optional media list — passed in from useProductMedia (which is a separate
101
+ // query). When provided we hydrate form.media here so the form.reset cycle
102
+ // captures it atomically instead of via a follow-up setValue that races
103
+ // with the merchant's unsaved edits.
104
+ media?: PanelMedia[],
105
+ ): ProductFormValues {
106
+ const hasVariants = (product.variant_count ?? 0) > 0
107
+ const variantSource = hasVariants
108
+ ? (product.variants ?? [])
109
+ : product.default_variant
110
+ ? [product.default_variant]
111
+ : []
112
+
113
+ return {
114
+ name: product.name,
115
+ // Hydrate the Tiptap editor from the HTML field, not `description` — the
116
+ // serializer squishes that one to tag-stripped plain text, which would
117
+ // collapse paragraphs/line breaks on every reload. Writes send the editor's
118
+ // HTML back under the plain `description` param.
119
+ description: product.description_html ?? '',
120
+ status: (product.status as ProductFormValues['status']) ?? 'draft',
121
+ category_ids: product.categories?.map((category) => category.id) ?? product.category_ids ?? [],
122
+ // Manual membership only — this field is what the picker edits, and the
123
+ // API preserves automatic membership regardless of what it sends. Hydrating
124
+ // automatic collections here would render chips a merchant can remove to
125
+ // no effect.
126
+ collection_ids:
127
+ product.collections?.filter((collection) => !collection.automatic).map((c) => c.id) ??
128
+ product.collection_ids ??
129
+ [],
130
+ tags: product.tags ?? [],
131
+ tax_category_id: product.tax_category_id ?? null,
132
+ product_type_id: product.product_type_id ?? null,
133
+ delivery_profile_id: product.delivery_profile_id ?? null,
134
+ meta_title: product.meta_title ?? '',
135
+ meta_description: product.meta_description ?? '',
136
+ slug: product.slug ?? '',
137
+ variants: variantSource.map((v, i) => variantToFormValues(v, i)),
138
+ custom_fields:
139
+ product.custom_fields?.map((cf) => ({
140
+ id: cf.id,
141
+ custom_field_definition_id: cf.custom_field_definition_id,
142
+ value: cf.value,
143
+ })) ?? [],
144
+ media: media?.map(mediaToFormValues) ?? [],
145
+ product_publications: (product.product_publications ?? []).map((l) => ({
146
+ id: l.id,
147
+ channel_id: l.channel_id,
148
+ published_at: l.published_at ?? null,
149
+ unpublished_at: l.unpublished_at ?? null,
150
+ })),
151
+ }
152
+ }
153
+
154
+ // Strip UI-only fields (stock_location_name) and undefined entries so the
155
+ // PATCH body matches the Admin API VariantUpdateParams shape exactly. The
156
+ // Spree::Product#variants= setter reconciles by id, creates new entries,
157
+ // and removes any persisted variant not present in the array — see
158
+ // docs/plans/6.0-remove-master-variant.md.
159
+ //
160
+ // `index` is the variant's array position; we ship `index + 1` so
161
+ // `acts_as_list` persists the 1-indexed order. Form state stays 0-indexed
162
+ // (matches the React array), the API quirk lives only at this boundary.
163
+ export function variantToWirePayload(v: VariantFormValues, index: number) {
164
+ // DB columns `sku` and `weight` are NOT NULL with defaults ("", 0.0).
165
+ // The other scalar fields (barcode, dimensions, weight_unit,
166
+ // dimensions_unit, tax_category_id) ARE nullable — those we always send
167
+ // even when null so the merchant can clear them. NOT-NULL fields fall
168
+ // back to their schema defaults when blank.
169
+ const payload: Record<string, unknown> = {
170
+ position: index + 1,
171
+ options: v.options,
172
+ sku: v.sku ?? '',
173
+ weight: v.weight ?? 0,
174
+ barcode: v.barcode ?? null,
175
+ height: v.height ?? null,
176
+ width: v.width ?? null,
177
+ depth: v.depth ?? null,
178
+ weight_unit: v.weight_unit ?? null,
179
+ dimensions_unit: v.dimensions_unit ?? null,
180
+ tax_category_id: v.tax_category_id ?? null,
181
+ hs_code: v.hs_code ?? null,
182
+ country_of_origin: v.country_of_origin ?? null,
183
+ customs_description: v.customs_description ?? null,
184
+ minimum_order_quantity: normalizeQuantityRule(v.minimum_order_quantity),
185
+ order_multiple: normalizeQuantityRule(v.order_multiple),
186
+ purchase_unit: v.purchase_unit ?? null,
187
+ units_per_carton: normalizeQuantityRule(v.units_per_carton),
188
+ cartons_per_pallet: normalizeQuantityRule(v.cartons_per_pallet),
189
+ carton_weight: v.carton_weight ?? null,
190
+ carton_package_type_id: v.carton_package_type_id ?? null,
191
+ }
192
+ if (v.id) payload.id = v.id
193
+ if (v.track_inventory != null) payload.track_inventory = v.track_inventory
194
+ if (v.preorderable != null) payload.preorderable = v.preorderable
195
+ if (v.preorder_ships_at !== undefined) payload.preorder_ships_at = v.preorder_ships_at
196
+ if (v.backorder_limit !== undefined) payload.backorder_limit = v.backorder_limit
197
+ // Always send `prices` when the form tracks it — including `[]`. The
198
+ // backend's `Spree::Variant#prices=` treats an empty array as "clear all
199
+ // base prices"; omitting it would otherwise leave the old amounts in
200
+ // place when the merchant clears the last currency from the matrix.
201
+ //
202
+ // Amounts in form state are already canonical `"1234.56"` — the price editor
203
+ // normalizes the merchant's localized input on commit (see
204
+ // `ProductBulkPriceEditor#handleChange`), and untouched values hydrate from
205
+ // the canonical API. So no normalization here — re-normalizing a canonical
206
+ // value under a comma-decimal locale would mangle it (`34.56` → `3456`).
207
+ if (v.prices != null) payload.prices = v.prices
208
+ if (v.stock_levels?.length) {
209
+ payload.stock_levels = v.stock_levels.map(({ stock_location_name, ...rest }) => rest)
210
+ }
211
+ return payload
212
+ }
@@ -0,0 +1,318 @@
1
+ import { requiredMessage } from '@spree/dashboard-ui'
2
+ import i18n from 'i18next'
3
+ import { z } from 'zod/v4'
4
+ import { normalizeQuantityRule } from './normalize-quantity'
5
+ import { isSupportedVideoUrl } from './video-url'
6
+
7
+ /**
8
+ * A purchasing rule as typed. Blank means the rule is unset — a valid
9
+ * answer — so only a non-blank entry that normalizes away is an error.
10
+ */
11
+ const quantityRuleField = z
12
+ .string()
13
+ .nullable()
14
+ .optional()
15
+ .refine((value) => !value?.trim() || normalizeQuantityRule(value) !== null, {
16
+ error: () => i18n.t('admin.fields.variant.quantity_rule_invalid'),
17
+ })
18
+
19
+ export const stockLevelFormSchema = z.object({
20
+ id: z.string().optional(),
21
+ stock_location_id: z.string(),
22
+ stock_location_name: z.string().optional(),
23
+ count_on_hand: z.coerce.number().int(),
24
+ backorderable: z.boolean(),
25
+ })
26
+
27
+ export type StockLevelFormValues = z.infer<typeof stockLevelFormSchema>
28
+
29
+ export const variantOptionPairSchema = z.object({
30
+ name: z.string().min(1),
31
+ value: z.string().min(1),
32
+ })
33
+
34
+ export type VariantOptionPair = z.infer<typeof variantOptionPairSchema>
35
+
36
+ // Form-side prices use raw STRING amounts (the merchant's typed input).
37
+ // The backend's `Spree::LocalizedNumber.parse` handles locale-aware parsing
38
+ // (comma decimals, grouped digits, etc.), so the frontend ships exactly what
39
+ // the merchant typed — no `Number(...)` coercion that mangles `"1.234,56"`
40
+ // into `NaN` and silently drops the price.
41
+ export const variantPriceFormSchema = z.object({
42
+ currency: z.string(),
43
+ amount: z.string(),
44
+ compare_at_amount: z.string().nullable().optional(),
45
+ })
46
+
47
+ export type VariantPriceFormValues = z.infer<typeof variantPriceFormSchema>
48
+
49
+ export const variantFormSchema = z.object({
50
+ // Present for persisted variants. Omit for newly-generated rows so the
51
+ // API can create them. Spree::Product#variants= reconciles by id.
52
+ id: z.string().optional(),
53
+ sku: z.string().nullable().optional(),
54
+ barcode: z.string().nullable().optional(),
55
+ position: z.number().int().nonnegative(),
56
+ options: z.array(variantOptionPairSchema),
57
+ weight: z.coerce.number().nullable().optional(),
58
+ height: z.coerce.number().nullable().optional(),
59
+ width: z.coerce.number().nullable().optional(),
60
+ depth: z.coerce.number().nullable().optional(),
61
+ weight_unit: z.string().nullable().optional(),
62
+ dimensions_unit: z.string().nullable().optional(),
63
+ hs_code: z.string().nullable().optional(),
64
+ country_of_origin: z.string().nullable().optional(),
65
+ customs_description: z.string().nullable().optional(),
66
+ // Held as strings so an unusable entry can be reported rather than
67
+ // silently coerced away: normalizing `0` straight to null would clear the
68
+ // rule while the merchant believes they set one.
69
+ minimum_order_quantity: quantityRuleField,
70
+ order_multiple: quantityRuleField,
71
+ purchase_unit: z.string().nullable().optional(),
72
+ units_per_carton: quantityRuleField,
73
+ cartons_per_pallet: quantityRuleField,
74
+ // The server refuses a carton that weighs nothing, and a 422 on a nested
75
+ // variant lands on a banner naming no variant — so it is caught on the field.
76
+ carton_weight: z.coerce
77
+ .number()
78
+ .positive({ error: () => i18n.t('admin.fields.variant.carton_weight_invalid') })
79
+ .nullable()
80
+ .optional(),
81
+ carton_package_type_id: z.string().nullable().optional(),
82
+ track_inventory: z.boolean().optional(),
83
+ preorderable: z.boolean().optional(),
84
+ preorder_ships_at: z.string().nullable().optional(),
85
+ backorder_limit: z.coerce.number().int().nonnegative().nullable().optional(),
86
+ tax_category_id: z.string().nullable().optional(),
87
+ prices: z.array(variantPriceFormSchema).optional(),
88
+ stock_levels: z.array(stockLevelFormSchema).optional(),
89
+ })
90
+
91
+ export type VariantFormValues = z.infer<typeof variantFormSchema>
92
+
93
+ export const customFieldFormSchema = z.object({
94
+ // Prefixed id of an existing custom-field value. Present when hydrating a
95
+ // persisted record so edits patch the existing row rather than insert.
96
+ id: z.string().optional(),
97
+ custom_field_definition_id: z.string(),
98
+ value: z.unknown(),
99
+ })
100
+
101
+ export type CustomFieldFormValues = z.infer<typeof customFieldFormSchema>
102
+
103
+ // Media in form state. Persisted entries carry an `id`; pre-save entries carry
104
+ // a `signed_id` (from a completed ActiveStorage direct upload) or a
105
+ // `source_media_id` (a file picked from the library). All can hold edits to
106
+ // alt, position, and variant_ids that the parent product PATCH/POST ships
107
+ // inline. previewUrl + uploadId are UI-only; stripped at submit.
108
+ export const MEDIA_TYPES = ['image', 'video', 'external_video'] as const
109
+
110
+ export type MediaType = (typeof MEDIA_TYPES)[number]
111
+
112
+ export const mediaFormSchema = z
113
+ .object({
114
+ id: z.string().optional(),
115
+ signed_id: z.string().optional(),
116
+ // A file picked from the library. The save places a copy sharing the
117
+ // source's file rather than uploading a second one.
118
+ source_media_id: z.string().optional(),
119
+ alt: z.string().nullable().optional(),
120
+ position: z.number().int().nonnegative().optional(),
121
+ variant_ids: z.array(z.string()).optional(),
122
+ media_type: z.enum(MEDIA_TYPES).optional(),
123
+ external_video_url: z.string().nullable().optional(),
124
+ // A video's still frame. Sent as a signed id like the media file itself;
125
+ // `posterUrl` is the UI-only preview and never reaches the API.
126
+ poster_signed_id: z.string().optional(),
127
+ posterUrl: z.string().nullable().optional(),
128
+ // Playback source for an uploaded video. UI-only: a blob URL before save,
129
+ // the served file after. Never sent back.
130
+ videoUrl: z.string().nullable().optional(),
131
+ focal_point_x: z.number().min(0).max(1).nullable().optional(),
132
+ focal_point_y: z.number().min(0).max(1).nullable().optional(),
133
+ // UI-only — strip at submit.
134
+ previewUrl: z.string().optional(),
135
+ // Larger rendition for the edit sheet; previewUrl is grid-sized and
136
+ // visibly pixelates when blown up. Absent before save, where the blob URL
137
+ // in previewUrl is full resolution anyway.
138
+ fullPreviewUrl: z.string().nullable().optional(),
139
+ // Server URL for downloading the original; absent until the row is saved.
140
+ downloadUrl: z.string().nullable().optional(),
141
+ uploadId: z.string().optional(),
142
+ })
143
+ // The server rejects a link it can't embed, and that failure aborts the whole
144
+ // product save. Catch it here so the merchant sees it on the field instead.
145
+ .refine(
146
+ (media) =>
147
+ media.media_type !== 'external_video' || isSupportedVideoUrl(media.external_video_url),
148
+ { message: 'unsupported_video_provider', path: ['external_video_url'] },
149
+ )
150
+
151
+ export type MediaFormValues = z.infer<typeof mediaFormSchema>
152
+
153
+ // A downloadable file staged on the new-product form. Create-time only: each
154
+ // entry is an upload (signed_id) buffered until the product POST. UI-only
155
+ // fields (filename, byteSize, uploadId) are stripped at submit.
156
+ export const digitalFileFormSchema = z.object({
157
+ signed_id: z.string(),
158
+ filename: z.string().nullable().optional(),
159
+ byteSize: z.number().nullable().optional(),
160
+ uploadId: z.string().optional(),
161
+ })
162
+
163
+ export type DigitalFileFormValues = z.infer<typeof digitalFileFormSchema>
164
+
165
+ export const productPublicationFormSchema = z.object({
166
+ id: z.string().optional(),
167
+ channel_id: z.string(),
168
+ published_at: z.string().nullable().optional(),
169
+ unpublished_at: z.string().nullable().optional(),
170
+ })
171
+
172
+ export type ProductPublicationFormValues = z.infer<typeof productPublicationFormSchema>
173
+
174
+ export const productFormSchema = z.object({
175
+ // General
176
+ name: z.string().min(1, { error: requiredMessage('name') }),
177
+ description: z.string().optional(),
178
+
179
+ // Status
180
+ // Every status the API may answer, not just the ones a form sets. A
181
+ // marketplace product also comes back `proposed` or `rejected`, and a schema
182
+ // that rejects those makes the whole form unsavable rather than flagging a
183
+ // field (docs/plans/6.0-seller-product-submission.md).
184
+ status: z.enum(['draft', 'active', 'archived', 'proposed', 'rejected']).optional(),
185
+
186
+ // Categorization
187
+ category_ids: z.array(z.string()).optional(),
188
+ collection_ids: z.array(z.string()).optional(),
189
+ tags: z.array(z.string()).optional(),
190
+
191
+ // Tax
192
+ tax_category_id: z.string().nullable().optional(),
193
+
194
+ // Product type — a creation-time template for the product's associations
195
+ product_type_id: z.string().nullable().optional(),
196
+
197
+ // Fulfillment profile — decides which zones and delivery methods reach this
198
+ // product; null falls back to the store default profile.
199
+ delivery_profile_id: z.string().nullable().optional(),
200
+
201
+ // SEO
202
+ meta_title: z.string().optional(),
203
+ meta_description: z.string().optional(),
204
+ slug: z.string().optional(),
205
+
206
+ // Variants — the single source of truth for purchasable attributes.
207
+ // Spree::Product#variants= matches by id, creates new entries, and
208
+ // removes any persisted variant not present in the array. See
209
+ // docs/plans/6.0-remove-master-variant.md for the wire contract.
210
+ variants: z.array(variantFormSchema).optional(),
211
+
212
+ // Inline custom field values, keyed by definition id (upserted server-side
213
+ // via Spree::HasCustomFields#custom_fields=). Partial updates: omitting a
214
+ // definition leaves its value untouched.
215
+ custom_fields: z.array(customFieldFormSchema).optional(),
216
+
217
+ // Media. On the new product page this starts empty and accumulates
218
+ // pre-save uploads (with signed_id). On the edit page it's hydrated from
219
+ // the persisted assets (with id) so edits to alt/position/variant_ids and
220
+ // new uploads ride the same product PATCH.
221
+ media: z.array(mediaFormSchema).optional(),
222
+
223
+ // Downloadable files, staged pre-save on the new product page and shipped in
224
+ // the product POST. Edit uses the live API-driven card instead, so this stays
225
+ // empty there.
226
+ digital_assets: z.array(digitalFileFormSchema).optional(),
227
+
228
+ product_publications: z.array(productPublicationFormSchema).optional(),
229
+ })
230
+
231
+ export type ProductFormValues = z.infer<typeof productFormSchema>
232
+
233
+ // Defaults for the "new product" page. Starts with a single placeholder
234
+ // variant (no options, empty stock_levels/prices) so the variants matrix
235
+ // renders a "Default variant" row the merchant can edit pre-save. On submit
236
+ // the page strips this row if it carries no meaningful data, letting
237
+ // Spree::Product#variants= auto-create the default variant server-side.
238
+ export function newProductFormDefaults(): ProductFormValues {
239
+ return {
240
+ name: '',
241
+ description: '',
242
+ status: 'draft',
243
+ category_ids: [],
244
+ collection_ids: [],
245
+ tags: [],
246
+ tax_category_id: null,
247
+ product_type_id: null,
248
+ delivery_profile_id: null,
249
+ meta_title: '',
250
+ meta_description: '',
251
+ slug: '',
252
+ variants: [
253
+ {
254
+ sku: null,
255
+ barcode: null,
256
+ position: 0,
257
+ options: [],
258
+ weight: null,
259
+ height: null,
260
+ width: null,
261
+ depth: null,
262
+ weight_unit: null,
263
+ dimensions_unit: null,
264
+ track_inventory: true,
265
+ tax_category_id: null,
266
+ prices: [],
267
+ stock_levels: [],
268
+ },
269
+ ],
270
+ custom_fields: [],
271
+ media: [],
272
+ digital_assets: [],
273
+ product_publications: [],
274
+ }
275
+ }
276
+
277
+ // Returns true if the given variant is essentially the empty default —
278
+ // no merchant-entered data on any variant-only field. Used by the create
279
+ // page to strip the placeholder variant so the backend auto-creates the
280
+ // canonical default variant.
281
+ //
282
+ // Stays in sync with `hasVariantOnlyData` in new.tsx — any field that
283
+ // makes the variant "meaningful" enough to ship inline must also count
284
+ // as "not a placeholder" here. Otherwise simple-product creates would
285
+ // filter the variant out before ever consulting hasVariantOnlyData.
286
+ export function isPlaceholderDefaultVariant(v: VariantFormValues): boolean {
287
+ return (
288
+ !v.id &&
289
+ !v.sku &&
290
+ !v.barcode &&
291
+ v.options.length === 0 &&
292
+ (v.prices ?? []).length === 0 &&
293
+ (v.stock_levels ?? []).length === 0 &&
294
+ v.weight == null &&
295
+ v.height == null &&
296
+ v.width == null &&
297
+ v.depth == null &&
298
+ v.weight_unit == null &&
299
+ v.dimensions_unit == null &&
300
+ v.tax_category_id == null &&
301
+ !v.hs_code &&
302
+ !v.country_of_origin &&
303
+ !v.customs_description &&
304
+ !v.minimum_order_quantity &&
305
+ !v.order_multiple &&
306
+ v.purchase_unit == null &&
307
+ !v.units_per_carton &&
308
+ !v.cartons_per_pallet &&
309
+ v.carton_weight == null &&
310
+ v.carton_package_type_id == null &&
311
+ v.preorderable !== true &&
312
+ v.preorder_ships_at == null &&
313
+ v.backorder_limit == null &&
314
+ // `track_inventory` defaults to `true` in `newProductFormDefaults`. Only
315
+ // count an explicit `false` (merchant toggled it off) as non-default.
316
+ v.track_inventory !== false
317
+ )
318
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * What the shared product form reads off a product, its variants and its
3
+ * media.
4
+ *
5
+ * Structural rather than either SDK's generated type, for the same reason
6
+ * `PanelStockLocation` is: the operator's Admin API and a seller's Seller API
7
+ * serialize the same records, and the cards only ever touch the fields both
8
+ * carry. Typing these against `@spree/admin-sdk` would make the form
9
+ * unusable in a panel that holds no admin credential.
10
+ *
11
+ * Everything a panel may omit is optional, so a card can hide what it was not
12
+ * given rather than render a blank.
13
+ */
14
+
15
+ export interface PanelMedia {
16
+ id: string
17
+ media_type?: string
18
+ alt?: string | null
19
+ position?: number
20
+ variant_ids?: string[]
21
+ focal_point_x?: number | null
22
+ focal_point_y?: number | null
23
+ external_video_url?: string | null
24
+ video_url?: string | null
25
+ video_embed_url?: string | null
26
+ poster_url?: string | null
27
+ original_url?: string | null
28
+ mini_url?: string | null
29
+ small_url?: string | null
30
+ medium_url?: string | null
31
+ large_url?: string | null
32
+ xlarge_url?: string | null
33
+ download_url?: string | null
34
+ }
35
+
36
+ export interface PanelVariant {
37
+ id: string
38
+ sku?: string | null
39
+ barcode?: string | null
40
+ options_text?: string
41
+ position?: number
42
+ price?: { amount?: string | null; currency?: string | null } | null
43
+ prices?: Array<{
44
+ amount?: string | null
45
+ currency?: string | null
46
+ compare_at_amount?: string | null
47
+ }>
48
+ cost_price?: string | null
49
+ cost_currency?: string | null
50
+ weight?: number | null
51
+ height?: number | null
52
+ width?: number | null
53
+ depth?: number | null
54
+ weight_unit?: string | null
55
+ dimensions_unit?: string | null
56
+ hs_code?: string | null
57
+ country_of_origin?: string | null
58
+ customs_description?: string | null
59
+ minimum_order_quantity?: number | null
60
+ order_multiple?: number | null
61
+ purchase_unit?: string | null
62
+ units_per_carton?: number | null
63
+ cartons_per_pallet?: number | null
64
+ carton_weight?: string | number | null
65
+ carton_package_type_id?: string | null
66
+ track_inventory?: boolean
67
+ preorderable?: boolean
68
+ preorder_ships_at?: string | null
69
+ backorder_limit?: number | null
70
+ total_on_hand?: number | null
71
+ available_stock?: number | null
72
+ option_values?: Array<{ id: string; name: string; option_type_name: string }>
73
+ stock_levels?: PanelStockLevel[]
74
+ /** Marketplace configuration; a seller's serializer leaves these out. */
75
+ tax_category_id?: string | null
76
+ delivery_profile_id?: string | null
77
+ }
78
+
79
+ export interface PanelProduct {
80
+ id: string
81
+ name: string
82
+ slug?: string
83
+ status?: string
84
+ description?: string | null
85
+ description_html?: string | null
86
+ meta_title?: string | null
87
+ meta_description?: string | null
88
+ meta_keywords?: string | null
89
+ product_type_id?: string | null
90
+ variant_count?: number
91
+ /**
92
+ * Either shape: the operator's serializer expands the records, a seller's
93
+ * answers plain ids. The mapper reads whichever it was given.
94
+ */
95
+ category_ids?: string[]
96
+ collection_ids?: string[]
97
+ categories?: Array<{ id: string; name?: string }>
98
+ collections?: Array<{ id: string; name?: string; automatic?: boolean }>
99
+ tags?: string[]
100
+ metadata?: Record<string, unknown> | null
101
+ default_variant_id?: string
102
+ price?: { amount?: string | null; currency?: string | null } | null
103
+ variants?: PanelVariant[]
104
+ default_variant?: PanelVariant
105
+ media?: PanelMedia[]
106
+ custom_fields?: Array<{ id?: string; custom_field_definition_id: string; value?: unknown }>
107
+ /** Operator-only surfaces; absent on a seller's serializer. */
108
+ product_publications?: Array<{
109
+ id?: string
110
+ channel_id: string
111
+ published_at?: string | null
112
+ unpublished_at?: string | null
113
+ }>
114
+ tax_category_id?: string | null
115
+ /** Both panels: a seller picks a profile from the marketplace's list. */
116
+ delivery_profile_id?: string | null
117
+ }
118
+
119
+ /**
120
+ * One shelf's worth of a variant — the count and where it sits, which is all
121
+ * the form reads.
122
+ */
123
+ export interface PanelStockLevel {
124
+ id?: string
125
+ stock_location_id: string | null
126
+ stock_location_name?: string | null
127
+ stock_location?: { id?: string; name?: string | null } | null
128
+ count_on_hand: number
129
+ backorderable: boolean
130
+ }