@spree/dashboard-core 0.13.0 → 1.0.0-beta.1
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/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +97 -36
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +425 -131
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +63 -22
- package/src/components/table-toolbar.tsx +1373 -442
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +261 -14
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +21 -0
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/storefront.ts +19 -0
- package/src/lib/table-registry.ts +56 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -516
- package/src/locales/de.json +1883 -515
- package/src/locales/en.json +1886 -516
- package/src/locales/fr.json +1884 -516
- package/src/locales/pl.json +1913 -516
- package/src/locales/zh-CN.json +1883 -516
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- package/src/components/top-bar.tsx +0 -210
|
@@ -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
|
+
}
|