@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,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { normalizeMoneyInput } from './normalize-money'
|
|
3
|
+
|
|
4
|
+
describe('normalizeMoneyInput', () => {
|
|
5
|
+
it('passes through canonical en input', () => {
|
|
6
|
+
expect(normalizeMoneyInput('19.99', 'en')).toBe('19.99')
|
|
7
|
+
expect(normalizeMoneyInput('1,234.56', 'en')).toBe('1234.56')
|
|
8
|
+
expect(normalizeMoneyInput('1234.56', 'en')).toBe('1234.56')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('converts de comma-decimal + dot grouping to canonical', () => {
|
|
12
|
+
expect(normalizeMoneyInput('19,99', 'de')).toBe('19.99')
|
|
13
|
+
expect(normalizeMoneyInput('1.234,56', 'de')).toBe('1234.56')
|
|
14
|
+
expect(normalizeMoneyInput('1.000.000,00', 'de')).toBe('1000000.00')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('handles fr narrow-space grouping', () => {
|
|
18
|
+
// fr groups with U+202F (narrow no-break space) and uses a comma decimal.
|
|
19
|
+
expect(normalizeMoneyInput('1 234,56', 'fr')).toBe('1234.56')
|
|
20
|
+
expect(normalizeMoneyInput('73,45', 'fr')).toBe('73.45')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('returns empty string for blank input', () => {
|
|
24
|
+
expect(normalizeMoneyInput('', 'de')).toBe('')
|
|
25
|
+
expect(normalizeMoneyInput(' ', 'de')).toBe('')
|
|
26
|
+
expect(normalizeMoneyInput(null, 'de')).toBe('')
|
|
27
|
+
expect(normalizeMoneyInput(undefined, 'de')).toBe('')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('preserves a leading minus', () => {
|
|
31
|
+
expect(normalizeMoneyInput('-5,00', 'de')).toBe('-5.00')
|
|
32
|
+
expect(normalizeMoneyInput('-5.00', 'en')).toBe('-5.00')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('drops stray currency symbols and whitespace', () => {
|
|
36
|
+
expect(normalizeMoneyInput(' € 1.234,56 ', 'de')).toBe('1234.56')
|
|
37
|
+
expect(normalizeMoneyInput('$19.99', 'en')).toBe('19.99')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('keeps no-decimal values intact', () => {
|
|
41
|
+
expect(normalizeMoneyInput('50', 'de')).toBe('50')
|
|
42
|
+
expect(normalizeMoneyInput('1.000', 'de')).toBe('1000') // de: dot is grouping
|
|
43
|
+
expect(normalizeMoneyInput('1,000', 'en')).toBe('1000') // en: comma is grouping
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('collapses stray separators to a single decimal point', () => {
|
|
47
|
+
// Malformed multi-dot/interior-minus input must canonicalize, not pass through.
|
|
48
|
+
expect(normalizeMoneyInput('1.2.3', 'en')).toBe('1.23')
|
|
49
|
+
expect(normalizeMoneyInput('12-3.4', 'en')).toBe('123.4')
|
|
50
|
+
expect(normalizeMoneyInput('-12-3.4', 'en')).toBe('-123.4')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('returns empty for inputs with no digits', () => {
|
|
54
|
+
expect(normalizeMoneyInput('.', 'en')).toBe('')
|
|
55
|
+
expect(normalizeMoneyInput('-', 'en')).toBe('')
|
|
56
|
+
expect(normalizeMoneyInput('abc', 'en')).toBe('')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Normalize a merchant's locale-formatted money input into the canonical
|
|
2
|
+
// decimal string the Admin API expects (`"1234.56"` — period decimal, no
|
|
3
|
+
// grouping). Industry-standard: localization is a presentation concern, so the
|
|
4
|
+
// dashboard converts here, in the browser, and never relies on the server to
|
|
5
|
+
// parse comma-vs-period. See docs/plans/5.5-client-side-money-normalization.md.
|
|
6
|
+
//
|
|
7
|
+
// The locale is the currency's display locale (its market locale, e.g. EUR →
|
|
8
|
+
// `de`), so the same locale drives both display formatting and this parse.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Derives a locale's decimal and group separators via `Intl`. `fr` groups with
|
|
12
|
+
* a narrow no-break space (U+202F), so we read the real characters rather than
|
|
13
|
+
* assuming `,`/`.`/`" "`.
|
|
14
|
+
*/
|
|
15
|
+
function separatorsFor(locale: string): { decimal: string; group: string } {
|
|
16
|
+
try {
|
|
17
|
+
const parts = new Intl.NumberFormat(locale, { useGrouping: true }).formatToParts(11111.1)
|
|
18
|
+
return {
|
|
19
|
+
decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
|
|
20
|
+
group: parts.find((p) => p.type === 'group')?.value ?? '',
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
return { decimal: '.', group: '' }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Converts a locale-formatted amount string to a canonical decimal string.
|
|
29
|
+
*
|
|
30
|
+
* - `"1.234,56"` under `de` → `"1234.56"`
|
|
31
|
+
* - `"1 234,56"` under `fr` → `"1234.56"` (narrow-space grouping)
|
|
32
|
+
* - `"19.99"` under `en` → `"19.99"`
|
|
33
|
+
* - `""` / whitespace → `""`
|
|
34
|
+
*
|
|
35
|
+
* Strips the locale's group separator and any whitespace, standardizes the
|
|
36
|
+
* decimal separator to `.`, and drops stray characters. Keeps the value as a
|
|
37
|
+
* string throughout — never `Number()`-coerces — to preserve precision.
|
|
38
|
+
*
|
|
39
|
+
* @param raw the merchant's typed value
|
|
40
|
+
* @param locale the display locale the value was entered in (e.g. `de`)
|
|
41
|
+
* @returns canonical `"1234.56"`-form string, or `''` for blank input
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeMoneyInput(raw: string | null | undefined, locale: string): string {
|
|
44
|
+
if (raw == null) return ''
|
|
45
|
+
const trimmed = raw.trim()
|
|
46
|
+
if (trimmed === '') return ''
|
|
47
|
+
|
|
48
|
+
const { decimal, group } = separatorsFor(locale)
|
|
49
|
+
|
|
50
|
+
let out = trimmed
|
|
51
|
+
// Remove the locale's grouping separator (explicit char) plus any whitespace
|
|
52
|
+
// (covers narrow/no-break spaces used by some locales as the group char).
|
|
53
|
+
if (group) out = out.split(group).join('')
|
|
54
|
+
out = out.replace(/\s/g, '')
|
|
55
|
+
// Standardize the decimal separator to `.`.
|
|
56
|
+
if (decimal !== '.') out = out.split(decimal).join('.')
|
|
57
|
+
// Drop anything that isn't a digit, dot, or minus.
|
|
58
|
+
out = out.replace(/[^0-9.-]/g, '')
|
|
59
|
+
|
|
60
|
+
// Enforce the canonical shape: a single optional leading minus, then digits
|
|
61
|
+
// with at most one decimal point. Stray separators (`1.2.3`) or interior
|
|
62
|
+
// minus signs (`12-3.4`) would otherwise reach the API as malformed values.
|
|
63
|
+
const negative = out.startsWith('-')
|
|
64
|
+
const digitsAndDots = out.replace(/-/g, '')
|
|
65
|
+
const firstDot = digitsAndDots.indexOf('.')
|
|
66
|
+
const canonical =
|
|
67
|
+
firstDot === -1
|
|
68
|
+
? digitsAndDots
|
|
69
|
+
: `${digitsAndDots.slice(0, firstDot)}.${digitsAndDots.slice(firstDot + 1).replace(/\./g, '')}`
|
|
70
|
+
|
|
71
|
+
if (canonical === '' || canonical === '.') return ''
|
|
72
|
+
return negative ? `-${canonical}` : canonical
|
|
73
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { normalizeQuantityRule } from './normalize-quantity'
|
|
3
|
+
|
|
4
|
+
describe('normalizeQuantityRule', () => {
|
|
5
|
+
it('reads a whole number', () => {
|
|
6
|
+
expect(normalizeQuantityRule('48')).toBe(48)
|
|
7
|
+
expect(normalizeQuantityRule(24)).toBe(24)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
// Blank means the rule is unset, which is not zero: an empty minimum lets
|
|
11
|
+
// a buyer take one.
|
|
12
|
+
it('treats a blank field as unset', () => {
|
|
13
|
+
expect(normalizeQuantityRule('')).toBeNull()
|
|
14
|
+
expect(normalizeQuantityRule(' ')).toBeNull()
|
|
15
|
+
expect(normalizeQuantityRule(null)).toBeNull()
|
|
16
|
+
expect(normalizeQuantityRule(undefined)).toBeNull()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('rejects values a rule cannot mean', () => {
|
|
20
|
+
expect(normalizeQuantityRule('0')).toBeNull()
|
|
21
|
+
expect(normalizeQuantityRule('-5')).toBeNull()
|
|
22
|
+
expect(normalizeQuantityRule('abc')).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// Truncating would be the same silent adjustment the whole feature exists
|
|
26
|
+
// to prevent, one level up.
|
|
27
|
+
it('refuses a fraction rather than truncating it', () => {
|
|
28
|
+
expect(normalizeQuantityRule('48.9')).toBeNull()
|
|
29
|
+
expect(normalizeQuantityRule('2.5')).toBeNull()
|
|
30
|
+
expect(normalizeQuantityRule(1.5)).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Purchasing-rule normalization for the variant editors, mirroring
|
|
2
|
+
// `Spree::Variant`'s own validations so a merchant hears about a bad value
|
|
3
|
+
// while typing rather than on save.
|
|
4
|
+
|
|
5
|
+
/** What a buyer is quoted in. Stored quantities stay units at every level. */
|
|
6
|
+
export const PURCHASE_UNITS = ['unit', 'carton'] as const
|
|
7
|
+
|
|
8
|
+
export type PurchaseUnit = (typeof PURCHASE_UNITS)[number]
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reads a quantity rule from a number input. Blank means the rule is unset,
|
|
12
|
+
* which is not the same as zero: an empty minimum lets a buyer take one, a
|
|
13
|
+
* minimum of zero is meaningless and the server refuses it.
|
|
14
|
+
*
|
|
15
|
+
* A fraction is refused rather than truncated. These rules are whole units,
|
|
16
|
+
* and this feature exists because silently adjusting a wholesale quantity is
|
|
17
|
+
* a five-figure surprise — a rule of 2.5 saved as 2 is the same mistake one
|
|
18
|
+
* level up.
|
|
19
|
+
*
|
|
20
|
+
* @returns a positive whole number, or null when the field says nothing or
|
|
21
|
+
* cannot be one.
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeQuantityRule(value: unknown): number | null {
|
|
24
|
+
const trimmed = String(value ?? '').trim()
|
|
25
|
+
if (trimmed === '') return null
|
|
26
|
+
|
|
27
|
+
const parsed = Number(trimmed)
|
|
28
|
+
if (!Number.isInteger(parsed) || parsed < 1) return null
|
|
29
|
+
|
|
30
|
+
return parsed
|
|
31
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { ProductFormValues, VariantPriceFormValues } from '@spree/dashboard-core'
|
|
2
|
+
import { type BulkPriceRow, BulkPriceTable } from '@spree/dashboard-ui'
|
|
3
|
+
import { useCallback, useMemo } from 'react'
|
|
4
|
+
import { type UseFormReturn, useWatch } from 'react-hook-form'
|
|
5
|
+
import { useTranslation } from 'react-i18next'
|
|
6
|
+
import { composeOptionsText } from '../products/variants-matrix'
|
|
7
|
+
import { currencyParts } from './currency-parts'
|
|
8
|
+
import { normalizeMoneyInput } from './normalize-money'
|
|
9
|
+
import {
|
|
10
|
+
useFormCurrencyLocale as useCurrencyLocale,
|
|
11
|
+
useFormOptionTypes as useOptionTypes,
|
|
12
|
+
} from './use-product-form-data'
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
16
|
+
form: UseFormReturn<ProductFormValues, any, any>
|
|
17
|
+
/** ISO currency code (e.g. "USD", "EUR"). */
|
|
18
|
+
currency: string
|
|
19
|
+
/** Display label for the product, used as the table's section header. */
|
|
20
|
+
productName: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Form-backed prices spreadsheet for ONE product's variants in ONE currency.
|
|
25
|
+
* Reads from and writes to the parent product form's `variants[].prices`
|
|
26
|
+
* array — works equally for persisted and unsaved variants, since both live
|
|
27
|
+
* in form state the same way. No server fetch, no edit-tracking map: the
|
|
28
|
+
* parent form's `isDirty` is the single source of truth for save gating.
|
|
29
|
+
*
|
|
30
|
+
* Amounts are STRINGS, not numbers. The merchant's raw input flows straight
|
|
31
|
+
* through to the API; the backend's `Spree::LocalizedNumber.parse` handles
|
|
32
|
+
* locale-aware parsing (comma decimals, grouped digits, etc.) so the
|
|
33
|
+
* frontend doesn't have to reimplement it. Frontend coercion via `Number()`
|
|
34
|
+
* would silently mangle inputs like `"1.234,56"` into `NaN` and drop the
|
|
35
|
+
* price entirely.
|
|
36
|
+
*/
|
|
37
|
+
export function ProductBulkPriceEditor({ form, currency, productName }: Props) {
|
|
38
|
+
const { t } = useTranslation()
|
|
39
|
+
const variants = useWatch({ control: form.control, name: 'variants' }) ?? []
|
|
40
|
+
const { data: optionTypesData } = useOptionTypes()
|
|
41
|
+
const optionTypes = useMemo(() => optionTypesData?.data ?? [], [optionTypesData])
|
|
42
|
+
const localeForCurrency = useCurrencyLocale()
|
|
43
|
+
|
|
44
|
+
// Format the grid in the currency's market locale (e.g. EUR → `de`, comma
|
|
45
|
+
// decimal). The same locale normalizes the merchant's input back to canonical
|
|
46
|
+
// form on commit (see `handleChange`), so form state — like the API value it
|
|
47
|
+
// hydrates from — is ALWAYS canonical `"1234.56"`. Untouched prices therefore
|
|
48
|
+
// never get re-normalized on save.
|
|
49
|
+
//
|
|
50
|
+
// Fall back to `en` (canonical period-decimal), NOT the UI language: money
|
|
51
|
+
// formatting/parsing must never depend on the dashboard's language, or a USD
|
|
52
|
+
// value under a German UI would be parsed as `40.00` → 4000.
|
|
53
|
+
const marketLocale = localeForCurrency(currency) || 'en'
|
|
54
|
+
const { symbol, decimal } = useMemo(
|
|
55
|
+
() => currencyParts(currency, marketLocale),
|
|
56
|
+
[currency, marketLocale],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
// Project the form's variants into BulkPriceRow[] for the picked currency.
|
|
60
|
+
// One header row per product (always this product), then one row per variant.
|
|
61
|
+
// Row id is the variant's array index so onChange can write back unambiguously,
|
|
62
|
+
// even for variants without a persisted id.
|
|
63
|
+
const rows = useMemo<BulkPriceRow[]>(() => {
|
|
64
|
+
if (variants.length === 0) return []
|
|
65
|
+
const out: BulkPriceRow[] = [{ id: `header:product`, kind: 'header', groupLabel: productName }]
|
|
66
|
+
variants.forEach((v, idx) => {
|
|
67
|
+
const price = (v.prices ?? []).find((p) => p.currency === currency)
|
|
68
|
+
const label = v.options.length > 0 ? composeOptionsText(v.options, optionTypes) : null
|
|
69
|
+
out.push({
|
|
70
|
+
id: `variant:${idx}`,
|
|
71
|
+
kind: 'item',
|
|
72
|
+
variantLabel: label,
|
|
73
|
+
sku: v.sku ?? null,
|
|
74
|
+
// Display the canonical-decimal string swapped into the user's
|
|
75
|
+
// locale separator. The merchant's raw input is stored as-is in
|
|
76
|
+
// form state — this swap is presentation-only.
|
|
77
|
+
amount: price?.amount ? price.amount.replace('.', decimal) : null,
|
|
78
|
+
compareAt: price?.compare_at_amount ? price.compare_at_amount.replace('.', decimal) : null,
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
return out
|
|
82
|
+
}, [variants, currency, productName, decimal, optionTypes])
|
|
83
|
+
|
|
84
|
+
const handleChange = useCallback(
|
|
85
|
+
(rowId: string, field: 'amount' | 'compareAt', next: string | null) => {
|
|
86
|
+
if (!rowId.startsWith('variant:')) return
|
|
87
|
+
const idx = Number.parseInt(rowId.slice('variant:'.length), 10)
|
|
88
|
+
if (Number.isNaN(idx)) return
|
|
89
|
+
|
|
90
|
+
const current = form.getValues(`variants.${idx}.prices`) ?? []
|
|
91
|
+
const existingIdx = current.findIndex((p) => p.currency === currency)
|
|
92
|
+
// Normalize the merchant's localized input to canonical `"1234.56"` here,
|
|
93
|
+
// on commit — so form state is always canonical (matching the API values
|
|
94
|
+
// it hydrates from) and the save path never re-normalizes. Empty,
|
|
95
|
+
// whitespace, OR malformed input (normalizes to `''`) all mean "no value"
|
|
96
|
+
// — never persist `''` as a real amount.
|
|
97
|
+
const trimmed = next == null ? '' : next.trim()
|
|
98
|
+
const normalized = trimmed === '' ? '' : normalizeMoneyInput(trimmed, marketLocale)
|
|
99
|
+
const raw = normalized === '' ? null : normalized
|
|
100
|
+
|
|
101
|
+
const nextPrices: VariantPriceFormValues[] = [...current]
|
|
102
|
+
|
|
103
|
+
if (existingIdx === -1) {
|
|
104
|
+
// No price entry in this currency yet. Only create one when the user
|
|
105
|
+
// sets the AMOUNT — a compare-at-only edit without a base amount is
|
|
106
|
+
// meaningless (and would otherwise persist a real $0 price). Defer
|
|
107
|
+
// compare-at until amount exists.
|
|
108
|
+
if (raw == null) return
|
|
109
|
+
if (field !== 'amount') return
|
|
110
|
+
nextPrices.push({
|
|
111
|
+
currency,
|
|
112
|
+
amount: raw,
|
|
113
|
+
compare_at_amount: null,
|
|
114
|
+
})
|
|
115
|
+
} else {
|
|
116
|
+
const existing = nextPrices[existingIdx]
|
|
117
|
+
if (field === 'amount') {
|
|
118
|
+
// Clearing the amount removes the price entry for this currency.
|
|
119
|
+
// Coercing to "0" would persist a real $0 price and never allow
|
|
120
|
+
// the merchant to drop a currency from a variant.
|
|
121
|
+
if (raw == null) {
|
|
122
|
+
nextPrices.splice(existingIdx, 1)
|
|
123
|
+
} else {
|
|
124
|
+
nextPrices[existingIdx] = { ...existing, amount: raw }
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
nextPrices[existingIdx] = { ...existing, compare_at_amount: raw }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
form.setValue(`variants.${idx}.prices`, nextPrices, {
|
|
132
|
+
shouldDirty: true,
|
|
133
|
+
})
|
|
134
|
+
},
|
|
135
|
+
[form, currency, marketLocale],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<BulkPriceTable
|
|
140
|
+
rows={rows}
|
|
141
|
+
symbol={symbol}
|
|
142
|
+
decimal={decimal}
|
|
143
|
+
onChange={handleChange}
|
|
144
|
+
labels={{
|
|
145
|
+
variant: t('admin.pages.products.price_lists.edit_prices.columns.variant'),
|
|
146
|
+
sku: t('admin.pages.products.price_lists.edit_prices.columns.sku'),
|
|
147
|
+
price: t('admin.pages.products.price_lists.edit_prices.columns.price'),
|
|
148
|
+
compareAt: t('admin.pages.products.price_lists.edit_prices.columns.compare_at_price'),
|
|
149
|
+
variantDefault: t('admin.pages.products.price_lists.edit_prices.variant_default'),
|
|
150
|
+
loading: t('admin.common.loading'),
|
|
151
|
+
pageOf: t('admin.common.page_of', { page: '{page}', total: '{total}' }),
|
|
152
|
+
prev: t('admin.common.prev'),
|
|
153
|
+
next: t('admin.common.next'),
|
|
154
|
+
emptyMessage: t('admin.pages.products.edit.bulk_prices.no_variants'),
|
|
155
|
+
gridAriaLabel: t('admin.pages.products.price_lists.edit_prices.grid_aria'),
|
|
156
|
+
priceAriaTemplate: t('admin.pages.products.price_lists.edit_prices.price_aria', {
|
|
157
|
+
label: '{label}',
|
|
158
|
+
}),
|
|
159
|
+
compareAtAriaTemplate: t('admin.pages.products.price_lists.edit_prices.compare_at_aria', {
|
|
160
|
+
label: '{label}',
|
|
161
|
+
}),
|
|
162
|
+
}}
|
|
163
|
+
/>
|
|
164
|
+
)
|
|
165
|
+
}
|