@spree/dashboard-core 0.13.1 → 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 +52 -27
- 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 +383 -127
- 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 +19 -14
- package/src/components/table-toolbar.tsx +1357 -440
- 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 +241 -16
- 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 +9 -6
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/table-registry.ts +50 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -517
- package/src/locales/de.json +1884 -517
- package/src/locales/en.json +1886 -519
- package/src/locales/fr.json +1884 -517
- package/src/locales/pl.json +1913 -517
- package/src/locales/zh-CN.json +1883 -517
- 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 -209
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { OptionType } from '@spree/admin-sdk'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { autoExpandIndex, composeOptionsText, type SelectedOptionType } from './variants-matrix'
|
|
4
|
+
|
|
5
|
+
// composeOptionsText only reads name/label/position/option_values; cast minimal
|
|
6
|
+
// fixtures rather than fabricating timestamps + ids that the helper ignores.
|
|
7
|
+
const COLOR = {
|
|
8
|
+
name: 'color',
|
|
9
|
+
label: 'Color',
|
|
10
|
+
position: 0,
|
|
11
|
+
option_values: [
|
|
12
|
+
{ name: 'red', label: 'Red' },
|
|
13
|
+
{ name: 'matte-black', label: 'Matte Black' },
|
|
14
|
+
],
|
|
15
|
+
} as OptionType
|
|
16
|
+
|
|
17
|
+
const SIZE = {
|
|
18
|
+
name: 'size',
|
|
19
|
+
label: 'Size',
|
|
20
|
+
position: 1,
|
|
21
|
+
option_values: [
|
|
22
|
+
{ name: 'xs', label: 'XS' },
|
|
23
|
+
{ name: 'l', label: 'L' },
|
|
24
|
+
],
|
|
25
|
+
} as OptionType
|
|
26
|
+
|
|
27
|
+
const MATERIAL = {
|
|
28
|
+
name: 'material',
|
|
29
|
+
label: 'Material',
|
|
30
|
+
position: 2,
|
|
31
|
+
option_values: [{ name: 'steel', label: 'Steel' }],
|
|
32
|
+
} as OptionType
|
|
33
|
+
|
|
34
|
+
describe('composeOptionsText', () => {
|
|
35
|
+
it('returns an empty string when there are no options', () => {
|
|
36
|
+
expect(composeOptionsText([], [COLOR, SIZE])).toBe('')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('formats a single option as "Type: Value"', () => {
|
|
40
|
+
expect(composeOptionsText([{ name: 'color', value: 'red' }], [COLOR])).toBe('Color: Red')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('joins multiple options with ", " in input order', () => {
|
|
44
|
+
expect(
|
|
45
|
+
composeOptionsText(
|
|
46
|
+
[
|
|
47
|
+
{ name: 'color', value: 'matte-black' },
|
|
48
|
+
{ name: 'size', value: 'xs' },
|
|
49
|
+
],
|
|
50
|
+
[COLOR, SIZE],
|
|
51
|
+
),
|
|
52
|
+
).toBe('Color: Matte Black, Size: XS')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('falls back to the type slug when the option type is not in the registry', () => {
|
|
56
|
+
expect(composeOptionsText([{ name: 'material', value: 'steel' }], [COLOR])).toBe(
|
|
57
|
+
'material: steel',
|
|
58
|
+
)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('falls back to the value slug when the value is not in the registry', () => {
|
|
62
|
+
expect(composeOptionsText([{ name: 'color', value: 'fuchsia' }], [COLOR])).toBe(
|
|
63
|
+
'Color: fuchsia',
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('falls back per-component when the type matches but option_values is missing', () => {
|
|
68
|
+
const colorNoValues = { name: 'color', label: 'Color' } as OptionType
|
|
69
|
+
expect(composeOptionsText([{ name: 'color', value: 'red' }], [colorNoValues])).toBe(
|
|
70
|
+
'Color: red',
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('handles registry-empty input by returning bare slugs', () => {
|
|
75
|
+
expect(
|
|
76
|
+
composeOptionsText(
|
|
77
|
+
[
|
|
78
|
+
{ name: 'color', value: 'red' },
|
|
79
|
+
{ name: 'size', value: 'xs' },
|
|
80
|
+
],
|
|
81
|
+
[],
|
|
82
|
+
),
|
|
83
|
+
).toBe('color: red, size: xs')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('reorders by option-type position regardless of input order (backend parity)', () => {
|
|
87
|
+
expect(
|
|
88
|
+
composeOptionsText(
|
|
89
|
+
[
|
|
90
|
+
{ name: 'size', value: 'l' },
|
|
91
|
+
{ name: 'color', value: 'red' },
|
|
92
|
+
],
|
|
93
|
+
[COLOR, SIZE],
|
|
94
|
+
),
|
|
95
|
+
).toBe('Color: Red, Size: L')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('uses Oxford "and" for three or more options (matches Rails to_sentence)', () => {
|
|
99
|
+
expect(
|
|
100
|
+
composeOptionsText(
|
|
101
|
+
[
|
|
102
|
+
{ name: 'color', value: 'red' },
|
|
103
|
+
{ name: 'size', value: 'l' },
|
|
104
|
+
{ name: 'material', value: 'steel' },
|
|
105
|
+
],
|
|
106
|
+
[COLOR, SIZE, MATERIAL],
|
|
107
|
+
),
|
|
108
|
+
).toBe('Color: Red, Size: L, and Material: Steel')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('appends unknown-type options after registry-known ones in input order', () => {
|
|
112
|
+
expect(
|
|
113
|
+
composeOptionsText(
|
|
114
|
+
[
|
|
115
|
+
{ name: 'finish', value: 'matte' },
|
|
116
|
+
{ name: 'color', value: 'red' },
|
|
117
|
+
{ name: 'pattern', value: 'striped' },
|
|
118
|
+
],
|
|
119
|
+
[COLOR],
|
|
120
|
+
),
|
|
121
|
+
).toBe('Color: Red, finish: matte, and pattern: striped')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('autoExpandIndex', () => {
|
|
126
|
+
const row = (id: string, values: string[] = []): SelectedOptionType => ({
|
|
127
|
+
id,
|
|
128
|
+
name: id,
|
|
129
|
+
label: id,
|
|
130
|
+
position: 0,
|
|
131
|
+
values: values.map((name) => ({ name })),
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('offers the first row still awaiting values', () => {
|
|
135
|
+
expect(autoExpandIndex([row('color', ['red']), row('size')], new Set())).toBe(1)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('offers nothing once every row has values', () => {
|
|
139
|
+
expect(autoExpandIndex([row('color', ['red'])], new Set())).toBe(-1)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// A type seeding two option types produces two empty rows. Declining the
|
|
143
|
+
// second must not re-open the first.
|
|
144
|
+
it('moves past a dismissed row to the next empty one', () => {
|
|
145
|
+
const rows = [row('color'), row('size')]
|
|
146
|
+
|
|
147
|
+
expect(autoExpandIndex(rows, new Set(['color']))).toBe(1)
|
|
148
|
+
expect(autoExpandIndex(rows, new Set(['color', 'size']))).toBe(-1)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('offers nothing when there are no rows', () => {
|
|
152
|
+
expect(autoExpandIndex([], new Set())).toBe(-1)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Pure helpers for the variants matrix on the product page.
|
|
2
|
+
//
|
|
3
|
+
// The matrix lets a merchant pick option types + values and writes the
|
|
4
|
+
// resulting cartesian product as RHF form state. These helpers stay pure
|
|
5
|
+
// (no React) so they can evolve independently of the UI shell.
|
|
6
|
+
|
|
7
|
+
import type { VariantFormValues } from '@spree/dashboard-core'
|
|
8
|
+
import type { PanelOptionType as OptionType } from '../api-client'
|
|
9
|
+
|
|
10
|
+
export interface SelectedOptionValue {
|
|
11
|
+
name: string
|
|
12
|
+
label?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SelectedOptionType {
|
|
16
|
+
// OptionType prefixed_id — only used for picker UX (re-selecting the row).
|
|
17
|
+
id: string
|
|
18
|
+
// Canonical option name (e.g. "size"). What the API matches on.
|
|
19
|
+
name: string
|
|
20
|
+
// Display label for the option type (e.g. "Size").
|
|
21
|
+
label: string
|
|
22
|
+
position: number
|
|
23
|
+
// Ordered list of selected option values for this type.
|
|
24
|
+
values: SelectedOptionValue[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Which option row the builder should open on its own: the first one still
|
|
29
|
+
* awaiting values that the merchant hasn't already dismissed. A product type
|
|
30
|
+
* seeds exactly such rows, so this puts the values picker in front of them
|
|
31
|
+
* without a click — while leaving them able to close it.
|
|
32
|
+
*
|
|
33
|
+
* @returns the row's index, or -1 when nothing should auto-expand.
|
|
34
|
+
*/
|
|
35
|
+
export function autoExpandIndex(
|
|
36
|
+
selected: SelectedOptionType[],
|
|
37
|
+
dismissedIds: ReadonlySet<string>,
|
|
38
|
+
): number {
|
|
39
|
+
return selected.findIndex(
|
|
40
|
+
(optionType) => optionType.values.length === 0 && !dismissedIds.has(optionType.id),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface VariantCombination {
|
|
45
|
+
options: { name: string; value: string }[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function cartesianProduct<T>(arrays: T[][]): T[][] {
|
|
49
|
+
return arrays.reduce<T[][]>(
|
|
50
|
+
(acc, arr) => acc.flatMap((combo) => arr.map((v) => [...combo, v])),
|
|
51
|
+
[[]],
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Build the cartesian product of selected option values, preserving the
|
|
56
|
+
// option-type order (matters for the label "Red / Small" vs "Small / Red"
|
|
57
|
+
// and for stable row identity across re-renders).
|
|
58
|
+
export function generateVariantCombinations(selected: SelectedOptionType[]): VariantCombination[] {
|
|
59
|
+
const ordered = [...selected]
|
|
60
|
+
.sort((a, b) => a.position - b.position)
|
|
61
|
+
.filter((ot) => ot.values.length > 0)
|
|
62
|
+
if (ordered.length === 0) return []
|
|
63
|
+
|
|
64
|
+
const valueMatrix = ordered.map((ot) => ot.values.map((v) => v.name))
|
|
65
|
+
return cartesianProduct(valueMatrix).map((combo) => ({
|
|
66
|
+
options: combo.map((value, idx) => ({ name: ordered[idx].name, value })),
|
|
67
|
+
}))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Stable key for an options set, used to match existing variants against
|
|
71
|
+
// newly-generated combinations. Sorted by name so order of insertion can't
|
|
72
|
+
// affect equality.
|
|
73
|
+
export function optionsKey(options: { name: string; value: string }[]): string {
|
|
74
|
+
return [...options]
|
|
75
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
76
|
+
.map((o) => `${o.name}=${o.value}`)
|
|
77
|
+
.join('|')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ReconcileResult {
|
|
81
|
+
// Carries persisted id/sku/prices/stock_levels forward for matched combos,
|
|
82
|
+
// and stamps new entries with defaults. Position is the array index.
|
|
83
|
+
next: VariantFormValues[]
|
|
84
|
+
// Options-keys of variants that no longer match any generated combination
|
|
85
|
+
// AND carry merchant data (typed SKU, prices, stock, weight, etc.). Drives
|
|
86
|
+
// the amber "N variants no longer match…" banner so the merchant can
|
|
87
|
+
// choose "Keep all" (leave them, ride the next save) or "Remove all"
|
|
88
|
+
// (drop them from form state). We use options-keys instead of ids so
|
|
89
|
+
// unsaved orphans — typed values on a single-option row that gets
|
|
90
|
+
// overshadowed by adding a second option type — show up in the banner
|
|
91
|
+
// too. Previously these were silently appended to form state.
|
|
92
|
+
orphanedKeys: string[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Match generated combinations against existing variants by options-set
|
|
96
|
+
// equality. The reconcile is non-destructive: persisted variants that no
|
|
97
|
+
// longer match any combination are KEPT in `next` (appended after the
|
|
98
|
+
// matched/new rows) and surfaced via `orphanedIds`. The UI uses that list
|
|
99
|
+
// to show a confirm-before-drop banner; "Keep all" dismisses the banner
|
|
100
|
+
// without changing the array, "Remove" filters them out.
|
|
101
|
+
//
|
|
102
|
+
// Blank variants ship with empty `prices` + `stock_levels`. The inventory
|
|
103
|
+
// grid renders editable rows for every (variant × location) and creates
|
|
104
|
+
// entries on first edit; the pricing editor does the same per currency.
|
|
105
|
+
// No pre-seeding here means the payload stays clean — only locations the
|
|
106
|
+
// merchant actually touched land in the PATCH.
|
|
107
|
+
export function reconcileVariants(
|
|
108
|
+
existing: VariantFormValues[],
|
|
109
|
+
combinations: VariantCombination[],
|
|
110
|
+
): ReconcileResult {
|
|
111
|
+
const existingByKey = new Map<string, VariantFormValues>()
|
|
112
|
+
existing.forEach((v) => {
|
|
113
|
+
existingByKey.set(optionsKey(v.options), v)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const matchedKeys = new Set<string>()
|
|
117
|
+
const next: VariantFormValues[] = combinations.map((combo, index) => {
|
|
118
|
+
const key = optionsKey(combo.options)
|
|
119
|
+
const match = existingByKey.get(key)
|
|
120
|
+
if (match) {
|
|
121
|
+
matchedKeys.add(key)
|
|
122
|
+
return { ...match, options: combo.options, position: index }
|
|
123
|
+
}
|
|
124
|
+
return blankVariant(combo.options, index)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// Carry unmatched variants forward when the row has merchant data.
|
|
128
|
+
//
|
|
129
|
+
// Persisted rows (have an `id`) always carry forward — if dropped, the
|
|
130
|
+
// next product save would delete them server-side, blowing away SKUs,
|
|
131
|
+
// pricing, and inventory even when the merchant chose "Keep all".
|
|
132
|
+
//
|
|
133
|
+
// Unsaved rows (no `id`) carry forward only when they hold merchant data
|
|
134
|
+
// (SKU, barcode, price, stock count, positive shipping field). Without
|
|
135
|
+
// this, building variants from one option type then adding a second
|
|
136
|
+
// option type would leave every transient first-pass row hanging around
|
|
137
|
+
// in form state even though the matrix already replaced them with the
|
|
138
|
+
// 3×3 (or whatever) Cartesian product.
|
|
139
|
+
//
|
|
140
|
+
// Both persisted and unsaved carry-forwards seed the orphan banner so
|
|
141
|
+
// the merchant gets a chance to drop them. Banner identity is the
|
|
142
|
+
// options-key (uniquely identifies an orphan within the form state) —
|
|
143
|
+
// ids alone would miss the unsaved case.
|
|
144
|
+
const orphanedKeys: string[] = []
|
|
145
|
+
existing.forEach((v) => {
|
|
146
|
+
const key = optionsKey(v.options)
|
|
147
|
+
if (matchedKeys.has(key)) return
|
|
148
|
+
if (!variantHasMerchantData(v) && (v.options.length === 0 || !v.id)) return
|
|
149
|
+
next.push({ ...v, position: next.length })
|
|
150
|
+
orphanedKeys.push(key)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
return { next, orphanedKeys }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Does this variant carry merchant data we'd lose if dropped?
|
|
157
|
+
//
|
|
158
|
+
// Server-propagated default variants come back with `weight: 0` (NOT NULL
|
|
159
|
+
// column with default) and zero-count stock_levels for every active location.
|
|
160
|
+
// Treat those zero/default sentinels as "no data" — only truthy fields
|
|
161
|
+
// count the row as carrying intent. Without this, every saved
|
|
162
|
+
// single-variant product would be treated as an orphan on upgrade and the
|
|
163
|
+
// natural simple→multi-variant flow surfaces a confusing banner.
|
|
164
|
+
//
|
|
165
|
+
// Shipping fields (weight, dimensions) count when *positive* — `weight: 0`
|
|
166
|
+
// is the server's NOT NULL default and the initial form snapshot also
|
|
167
|
+
// writes `weight: 0`, so treating it as data would re-trigger the
|
|
168
|
+
// false-positive banner for every untouched product. A positive value
|
|
169
|
+
// means the merchant typed something (likely via the VariantEditSheet
|
|
170
|
+
// shipping section).
|
|
171
|
+
function variantHasMerchantData(v: VariantFormValues): boolean {
|
|
172
|
+
return (
|
|
173
|
+
!!v.sku ||
|
|
174
|
+
!!v.barcode ||
|
|
175
|
+
(v.prices?.length ?? 0) > 0 ||
|
|
176
|
+
(v.stock_levels?.some((s) => (s.count_on_hand ?? 0) > 0) ?? false) ||
|
|
177
|
+
(v.weight ?? 0) > 0 ||
|
|
178
|
+
(v.height ?? 0) > 0 ||
|
|
179
|
+
(v.width ?? 0) > 0 ||
|
|
180
|
+
(v.depth ?? 0) > 0
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function blankVariant(
|
|
185
|
+
options: { name: string; value: string }[],
|
|
186
|
+
position: number,
|
|
187
|
+
): VariantFormValues {
|
|
188
|
+
return {
|
|
189
|
+
sku: null,
|
|
190
|
+
barcode: null,
|
|
191
|
+
position,
|
|
192
|
+
options,
|
|
193
|
+
weight: null,
|
|
194
|
+
height: null,
|
|
195
|
+
width: null,
|
|
196
|
+
depth: null,
|
|
197
|
+
weight_unit: null,
|
|
198
|
+
dimensions_unit: null,
|
|
199
|
+
track_inventory: true,
|
|
200
|
+
tax_category_id: null,
|
|
201
|
+
prices: [],
|
|
202
|
+
stock_levels: [],
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Mirrors backend `Spree::Variant#options_text`: "Color: Silver, Size: XS" or
|
|
207
|
+
// "Color: Silver, Size: XS, and Material: Steel". Sorts by option-type position
|
|
208
|
+
// (backend `option_values.sort_by(&:option_type.position)`) and joins with the
|
|
209
|
+
// same `to_sentence(words_connector: ', ', two_words_connector: ', ')` rules —
|
|
210
|
+
// `, ` between items and `, and ` before the last when there are 3+. Falls back
|
|
211
|
+
// to slugs when a label isn't in the registry (e.g. a value the merchant just
|
|
212
|
+
// created in this session) and to input order for types not in the registry.
|
|
213
|
+
export function composeOptionsText(
|
|
214
|
+
options: { name: string; value: string }[],
|
|
215
|
+
optionTypes: OptionType[],
|
|
216
|
+
): string {
|
|
217
|
+
const parts = options
|
|
218
|
+
.map((o, idx) => {
|
|
219
|
+
const ot = optionTypes.find((x) => x.name === o.name)
|
|
220
|
+
const typeLabel = ot?.label ?? o.name
|
|
221
|
+
const valueLabel = ot?.option_values?.find((v) => v.name === o.value)?.label ?? o.value
|
|
222
|
+
// Stable position: registry value when present, otherwise the input index
|
|
223
|
+
// pushed above any registry value so unknown types trail in input order.
|
|
224
|
+
const position = ot?.position ?? Number.MAX_SAFE_INTEGER - options.length + idx
|
|
225
|
+
return { text: `${typeLabel}: ${valueLabel}`, position, idx }
|
|
226
|
+
})
|
|
227
|
+
.sort((a, b) => a.position - b.position || a.idx - b.idx)
|
|
228
|
+
.map((p) => p.text)
|
|
229
|
+
|
|
230
|
+
if (parts.length < 3) return parts.join(', ')
|
|
231
|
+
return `${parts.slice(0, -1).join(', ')}, and ${parts[parts.length - 1]}`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function variantDisplayLabel(
|
|
235
|
+
variant: Pick<VariantFormValues, 'options' | 'sku'>,
|
|
236
|
+
fallback: string,
|
|
237
|
+
optionTypes: OptionType[],
|
|
238
|
+
): string {
|
|
239
|
+
if (variant.options.length > 0) {
|
|
240
|
+
return composeOptionsText(variant.options, optionTypes)
|
|
241
|
+
}
|
|
242
|
+
if (variant.sku) return variant.sku
|
|
243
|
+
return fallback
|
|
244
|
+
}
|