@spree/dashboard-core 1.0.0-rc.4 → 1.0.0-rc.6

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 (70) hide show
  1. package/package.json +10 -7
  2. package/src/api-client.ts +76 -7
  3. package/src/components/address-form-dialog.tsx +2 -2
  4. package/src/components/country-combobox.tsx +1 -2
  5. package/src/components/currency-amounts-field.tsx +93 -0
  6. package/src/components/currency-select.tsx +6 -7
  7. package/src/components/file-upload-field.tsx +1 -0
  8. package/src/components/locale-select.tsx +22 -23
  9. package/src/components/nav-main.tsx +7 -2
  10. package/src/components/preferences-form.tsx +2 -8
  11. package/src/components/settings-sidebar.tsx +8 -1
  12. package/src/components/slot.tsx +21 -1
  13. package/src/components/table-toolbar.tsx +102 -39
  14. package/src/hooks/use-display-name.ts +1 -18
  15. package/src/hooks/use-file-upload.ts +2 -1
  16. package/src/hooks/use-import.ts +3 -2
  17. package/src/index.ts +17 -3
  18. package/src/lib/currencies.ts +2 -34
  19. package/src/lib/currency-amounts.test.ts +147 -0
  20. package/src/lib/currency-amounts.ts +172 -0
  21. package/src/lib/currency-parts.ts +2 -21
  22. package/src/lib/filters-to-ransack.ts +4 -1
  23. package/src/lib/form-fields-registry.ts +23 -1
  24. package/src/lib/form-mappers.ts +1 -1
  25. package/src/lib/formatters.ts +6 -10
  26. package/src/lib/fulfillment-items.ts +11 -0
  27. package/src/lib/i18n.ts +1 -12
  28. package/src/lib/iso-codes.ts +9 -26
  29. package/src/lib/money.ts +9 -99
  30. package/src/lib/normalize-money.ts +2 -85
  31. package/src/lib/return-refunds.ts +1 -2
  32. package/src/lib/route-registry.ts +29 -0
  33. package/src/lib/settings-nav-registry.ts +6 -1
  34. package/src/lib/store-config.test.ts +93 -0
  35. package/src/lib/store-config.ts +76 -0
  36. package/src/lib/table-registry.ts +16 -5
  37. package/src/locales/ar.json +51 -0
  38. package/src/locales/de.json +51 -0
  39. package/src/locales/en.json +51 -0
  40. package/src/locales/es.json +51 -0
  41. package/src/locales/fr.json +51 -0
  42. package/src/locales/pl.json +51 -0
  43. package/src/locales/zh-CN.json +51 -0
  44. package/src/pages/package-types.tsx +50 -11
  45. package/src/pages/settings-index.tsx +4 -1
  46. package/src/pages/stock-locations.tsx +31 -6
  47. package/src/products/assignable-variants.ts +35 -0
  48. package/src/products/bulk-variants-dialog.tsx +27 -28
  49. package/src/products/digital-asset-edit-sheet.tsx +192 -0
  50. package/src/products/digital-asset-provider-sheet.tsx +88 -0
  51. package/src/products/digital-assets-card.tsx +451 -0
  52. package/src/products/media-edit-sheet.tsx +68 -20
  53. package/src/products/normalize-quantity.ts +2 -3
  54. package/src/products/product-bulk-price-editor.tsx +1 -3
  55. package/src/products/product-form-cards.tsx +67 -37
  56. package/src/products/product-form-mapping.ts +44 -5
  57. package/src/products/product-schema.test.ts +8 -0
  58. package/src/products/product-schema.ts +14 -7
  59. package/src/products/product-types.ts +1 -1
  60. package/src/products/provider-settings-fields.tsx +110 -0
  61. package/src/products/sheet-save-handlers.ts +37 -0
  62. package/src/products/use-digital-assets.ts +66 -0
  63. package/src/products/variant-edit-sheet.tsx +21 -11
  64. package/src/products/variants-section.tsx +4 -1
  65. package/src/providers/store-provider.tsx +31 -7
  66. package/src/schemas/package-type.ts +14 -12
  67. package/src/schemas/stock-location.ts +2 -3
  68. package/src/tables/stock-locations.tsx +5 -8
  69. package/src/lib/money.test.ts +0 -63
  70. package/src/lib/normalize-money.test.ts +0 -67
@@ -1,5 +1,6 @@
1
+ import { DimensionUnit, WeightUnit } from '@spree/core'
1
2
  import type { ProductFormValues, VariantFormValues } from '@spree/dashboard-core'
2
- import { CountryCombobox, StoreDatePicker } from '@spree/dashboard-core'
3
+ import { CountryCombobox, Slot, StoreDatePicker } from '@spree/dashboard-core'
3
4
  import {
4
5
  Alert,
5
6
  AlertDescription,
@@ -36,6 +37,7 @@ import { useTenantId } from '../providers/tenant-provider'
36
37
  import { cartonSize } from './carton-size'
37
38
  import { normalizeCustomsDescription, normalizeHsCode } from './normalize-customs'
38
39
  import { PURCHASE_UNITS } from './normalize-quantity'
40
+ import { useSheetSaveHandlers } from './sheet-save-handlers'
39
41
  import {
40
42
  useFormCartonPackageTypes as useCartonPackageTypes,
41
43
  useFormOptionTypes as useOptionTypes,
@@ -43,13 +45,12 @@ import {
43
45
  } from './use-product-form-data'
44
46
  import { variantDisplayLabel } from './variants-matrix'
45
47
 
46
- const WEIGHT_UNITS = ['g', 'kg', 'lb', 'oz'] as const
47
- const DIMENSION_UNITS = ['mm', 'cm', 'in'] as const
48
-
49
48
  interface Props {
50
49
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
50
  form: UseFormReturn<ProductFormValues, any, any>
52
51
  variantIndex: number
52
+ /** The product form's key — names the `<formKey>.variant.form_fields` slot. */
53
+ formKey: string
53
54
  open: boolean
54
55
  onOpenChange: (open: boolean) => void
55
56
  }
@@ -58,7 +59,7 @@ interface Props {
58
59
  // form — no own form state. Save closes the sheet (changes are already in
59
60
  // form state; the product Save button commits them). Cancel restores the
60
61
  // snapshot captured when the sheet opened.
61
- export function VariantEditSheet({ form, variantIndex, open, onOpenChange }: Props) {
62
+ export function VariantEditSheet({ form, variantIndex, formKey, open, onOpenChange }: Props) {
62
63
  const { t } = useTranslation()
63
64
  const { data: taxCategoriesResponse } = useTaxCategories()
64
65
  const taxCategories = taxCategoriesResponse?.data ?? []
@@ -112,6 +113,8 @@ export function VariantEditSheet({ form, variantIndex, open, onOpenChange }: Pro
112
113
  : null
113
114
  }, [open, variantIndex, form])
114
115
 
116
+ const { onSave, saveAll, saving } = useSheetSaveHandlers()
117
+
115
118
  const variant = form.watch(`variants.${variantIndex}`)
116
119
  if (!variant) return null
117
120
 
@@ -122,6 +125,8 @@ export function VariantEditSheet({ form, variantIndex, open, onOpenChange }: Pro
122
125
  )
123
126
 
124
127
  const handleCancel = () => {
128
+ // A save already sent can't be called back, so the sheet stays until it settles.
129
+ if (saving) return
125
130
  const snap = snapshotRef.current
126
131
  if (snap) {
127
132
  // Resolve the target index by stable id when we have one — the array
@@ -144,8 +149,8 @@ export function VariantEditSheet({ form, variantIndex, open, onOpenChange }: Pro
144
149
  onOpenChange(false)
145
150
  }
146
151
 
147
- const handleDone = () => {
148
- onOpenChange(false)
152
+ const handleDone = async () => {
153
+ if (await saveAll()) onOpenChange(false)
149
154
  }
150
155
 
151
156
  return (
@@ -230,7 +235,7 @@ export function VariantEditSheet({ form, variantIndex, open, onOpenChange }: Pro
230
235
  <SelectValue>{(v) => (v as string) || '—'}</SelectValue>
231
236
  </SelectTrigger>
232
237
  <SelectContent>
233
- {WEIGHT_UNITS.map((u) => (
238
+ {Object.values(WeightUnit).map((u) => (
234
239
  <SelectItem key={u} value={u}>
235
240
  {u}
236
241
  </SelectItem>
@@ -295,7 +300,7 @@ export function VariantEditSheet({ form, variantIndex, open, onOpenChange }: Pro
295
300
  <SelectValue>{(v) => (v as string) || '—'}</SelectValue>
296
301
  </SelectTrigger>
297
302
  <SelectContent>
298
- {DIMENSION_UNITS.map((u) => (
303
+ {Object.values(DimensionUnit).map((u) => (
299
304
  <SelectItem key={u} value={u}>
300
305
  {u}
301
306
  </SelectItem>
@@ -709,13 +714,18 @@ export function VariantEditSheet({ form, variantIndex, open, onOpenChange }: Pro
709
714
  </Field>
710
715
  </FormSection>
711
716
  )}
717
+
718
+ <Slot
719
+ name={`${formKey}.variant.form_fields`}
720
+ context={{ variant, variantIndex, fieldPrefix: `variants.${variantIndex}`, onSave }}
721
+ />
712
722
  </div>
713
723
 
714
724
  <SheetFooter>
715
- <Button type="button" variant="outline" onClick={handleCancel}>
725
+ <Button type="button" variant="outline" disabled={saving} onClick={handleCancel}>
716
726
  {t('admin.actions.cancel')}
717
727
  </Button>
718
- <Button type="button" onClick={handleDone}>
728
+ <Button type="button" disabled={saving} onClick={handleDone}>
719
729
  {t('admin.actions.done')}
720
730
  </Button>
721
731
  </SheetFooter>
@@ -64,6 +64,8 @@ interface Props {
64
64
  * whatever it already has.
65
65
  */
66
66
  seedFromType?: boolean
67
+ /** The product form's key — names the variant sheet's slot. */
68
+ formKey?: string
67
69
  }
68
70
 
69
71
  // Derive the initial selected option types from current variant rows so the
@@ -125,7 +127,7 @@ function seedFromProductType(
125
127
  .filter((entry): entry is SelectedOptionType => entry !== null)
126
128
  }
127
129
 
128
- export function VariantsSection({ form, seedFromType = false }: Props) {
130
+ export function VariantsSection({ form, seedFromType = false, formKey = 'product' }: Props) {
129
131
  const { t } = useTranslation()
130
132
  const { data: optionTypesData } = useOptionTypes()
131
133
  const allOptionTypes = useMemo(() => optionTypesData?.data ?? [], [optionTypesData])
@@ -447,6 +449,7 @@ export function VariantsSection({ form, seedFromType = false }: Props) {
447
449
  <VariantEditSheet
448
450
  form={form}
449
451
  variantIndex={editingIndex}
452
+ formKey={formKey}
450
453
  open={sheetOpen}
451
454
  onOpenChange={(o) => {
452
455
  setSheetOpen(o)
@@ -1,9 +1,11 @@
1
- import type { CurrencyCode, LocaleCode, Store } from '@spree/admin-sdk'
1
+ import type { Store } from '@spree/admin-sdk'
2
+ import type { CurrencyCode, LocaleCode } from '@spree/core'
2
3
  import { useQuery } from '@tanstack/react-query'
3
- import { createContext, type ReactNode, useContext, useEffect, useRef } from 'react'
4
+ import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef } from 'react'
4
5
  import { adminClient } from '../client'
5
6
  import { useAuth } from '../hooks/use-auth'
6
7
  import { coreLocaleCodes, reconcileStoreDefaultLocale } from '../lib/i18n'
8
+ import { listAllMarkets, marketLocalization, STORE_CONFIG_STALE_TIME } from '../lib/store-config'
7
9
 
8
10
  /**
9
11
  * Resource segment of the store query key: the current store lives in the
@@ -19,14 +21,17 @@ interface StoreContextValue {
19
21
  store: Store | null
20
22
  storeId: string
21
23
  isLoading: boolean
24
+ /** Currencies of the store's markets, the default market's first. */
22
25
  currencies: CurrencyCode[]
23
- /** Locales this store is configured to translate content into. */
26
+ /** Languages the store's markets offer — what content is translated into. */
24
27
  locales: LocaleCode[]
25
28
  /** Every locale a merchant may translate content into — the full canonical
26
29
  * set, independent of which locales the store currently uses. Drives locale
27
30
  * pickers so a new locale can always be added. */
28
31
  availableLocales: LocaleCode[]
32
+ /** The default market's currency. */
29
33
  defaultCurrency: CurrencyCode
34
+ /** The default market's language. */
30
35
  defaultLocale: LocaleCode
31
36
  /** ISO country the store sells from — used to pre-fill new address forms. */
32
37
  defaultCountry: string
@@ -75,6 +80,26 @@ export function StoreProvider({ storeId, children }: { storeId: string; children
75
80
  })
76
81
  const store = query.data ?? null
77
82
 
83
+ // Same key and request as the dashboard's market list, so the shell and every
84
+ // market picker share one cached fetch that market writes invalidate.
85
+ const marketsQuery = useQuery({
86
+ queryKey: ['markets', storeId, 'all'],
87
+ queryFn: listAllMarkets,
88
+ staleTime: STORE_CONFIG_STALE_TIME,
89
+ })
90
+ // Until the markets arrive, or if they cannot be read, the store payload
91
+ // stands in: the server derives the same values from the same markets, and a
92
+ // hardcoded USD/en would be wrong for most stores.
93
+ const localization = useMemo(() => {
94
+ if (marketsQuery.data) return marketLocalization(marketsQuery.data.data)
95
+ return {
96
+ currencies: store?.supported_currencies ?? [],
97
+ locales: store?.supported_locales ?? [],
98
+ defaultCurrency: store?.default_currency,
99
+ defaultLocale: store?.default_locale,
100
+ }
101
+ }, [marketsQuery.data, store])
102
+
78
103
  // Reconcile the admin language against this store's default — adopting it,
79
104
  // or dropping a now-stale auto-applied default — only when no account locale
80
105
  // or genuine personal choice owns it (legacy base_controller parity).
@@ -90,11 +115,10 @@ export function StoreProvider({ storeId, children }: { storeId: string; children
90
115
  )
91
116
  }, [store, storeId])
92
117
 
93
- const currencies = store?.supported_currencies ?? []
94
- const locales = store?.supported_locales ?? []
118
+ const { currencies, locales } = localization
95
119
  const availableLocales = store?.available_locales ?? []
96
- const defaultCurrency = store?.default_currency ?? 'USD'
97
- const defaultLocale = store?.default_locale ?? 'en'
120
+ const defaultCurrency = localization.defaultCurrency ?? 'USD'
121
+ const defaultLocale = localization.defaultLocale ?? 'en'
98
122
  const defaultCountry = store?.default_country_code ?? ''
99
123
  // A store's zone preference defaults to the host app's `Time.zone.name`,
100
124
  // which may be a Rails name ("Eastern Time (US & Canada)") that Intl and
@@ -1,17 +1,19 @@
1
+ import { DimensionUnit, PACKAGE_TYPE_KINDS, type PackageTypeKind, WeightUnit } from '@spree/core'
1
2
  import { requiredMessage } from '@spree/dashboard-ui'
2
3
  import i18n from 'i18next'
3
4
  import { z } from 'zod/v4'
4
5
  import type { PanelPackageType, PanelPackageTypeCreateParams } from '../api-client'
5
6
 
6
- // The vocabulary the server validates against; typing anything else could
7
- // only fail on save.
8
- export const PACKAGE_TYPE_KINDS = ['box', 'envelope', 'carton', 'pallet', 'container'] as const
9
- export const PACKAGE_DIMENSION_UNITS = ['mm', 'cm', 'in', 'ft'] as const
10
- export const PACKAGE_WEIGHT_UNITS = ['g', 'kg', 'lb', 'oz'] as const
7
+ export { PACKAGE_TYPE_KINDS, type PackageTypeKind }
11
8
 
12
- export type PackageTypeKind = (typeof PACKAGE_TYPE_KINDS)[number]
13
- export type PackageDimensionUnit = (typeof PACKAGE_DIMENSION_UNITS)[number]
14
- export type PackageWeightUnit = (typeof PACKAGE_WEIGHT_UNITS)[number]
9
+ /** @deprecated Use `Object.values(DimensionUnit)` from `@spree/admin-sdk`. */
10
+ export const PACKAGE_DIMENSION_UNITS = Object.values(DimensionUnit)
11
+ /** @deprecated Use `Object.values(WeightUnit)` from `@spree/admin-sdk`. */
12
+ export const PACKAGE_WEIGHT_UNITS = Object.values(WeightUnit)
13
+ /** @deprecated Use `DimensionUnit` from `@spree/admin-sdk`. */
14
+ export type PackageDimensionUnit = DimensionUnit
15
+ /** @deprecated Use `WeightUnit` from `@spree/admin-sdk`. */
16
+ export type PackageWeightUnit = WeightUnit
15
17
 
16
18
  // Blank is how a merchant says "not measured", so every number is optional
17
19
  // and an empty string reaches the API as null rather than zero — a box
@@ -33,10 +35,10 @@ export const packageTypeFormSchema = z.object({
33
35
  length: optionalNumber,
34
36
  width: optionalNumber,
35
37
  height: optionalNumber,
36
- dimensions_unit: z.enum(PACKAGE_DIMENSION_UNITS).optional(),
38
+ dimensions_unit: z.enum(DimensionUnit).optional(),
37
39
  weight: optionalNumber,
38
40
  max_weight: optionalNumber,
39
- weight_unit: z.enum(PACKAGE_WEIGHT_UNITS).optional(),
41
+ weight_unit: z.enum(WeightUnit).optional(),
40
42
  default: z.boolean().optional(),
41
43
  })
42
44
 
@@ -89,10 +91,10 @@ export function packageTypeToFormValues(packageType: PanelPackageType): PackageT
89
91
  length: packageType.length ?? '',
90
92
  width: packageType.width ?? '',
91
93
  height: packageType.height ?? '',
92
- dimensions_unit: (packageType.dimensions_unit ?? undefined) as PackageDimensionUnit | undefined,
94
+ dimensions_unit: (packageType.dimensions_unit ?? undefined) as DimensionUnit | undefined,
93
95
  weight: packageType.weight ?? '',
94
96
  max_weight: packageType.max_weight ?? '',
95
- weight_unit: (packageType.weight_unit ?? undefined) as PackageWeightUnit | undefined,
97
+ weight_unit: (packageType.weight_unit ?? undefined) as WeightUnit | undefined,
96
98
  default: packageType.default ?? false,
97
99
  }
98
100
  }
@@ -1,3 +1,4 @@
1
+ import { PICKUP_STOCK_POLICIES, type PickupStockPolicy, STOCK_LOCATION_KINDS } from '@spree/core'
1
2
  import { requiredMessage } from '@spree/dashboard-ui'
2
3
  import { z } from 'zod/v4'
3
4
  import type { PanelStockLocation, PanelStockLocationCreateParams } from '../api-client'
@@ -7,9 +8,7 @@ import { countryCodeSchema } from '../lib/iso-codes'
7
8
  // Labels live in `en.json` under `admin.stock_locations.kinds.*` and
8
9
  // `admin.stock_locations.pickup_stock_policies.*`. Consumers map these
9
10
  // values to translated labels at render time.
10
- export const STOCK_LOCATION_KINDS = ['warehouse', 'store', 'fulfillment_center'] as const
11
- export const PICKUP_STOCK_POLICIES = ['local', 'any'] as const
12
- export type PickupStockPolicy = (typeof PICKUP_STOCK_POLICIES)[number]
11
+ export { PICKUP_STOCK_POLICIES, type PickupStockPolicy, STOCK_LOCATION_KINDS }
13
12
 
14
13
  export const stockLocationFormSchema = z.object({
15
14
  name: z
@@ -1,3 +1,4 @@
1
+ import { STOCK_LOCATION_KINDS } from '@spree/core'
1
2
  import { ActiveBadge, ResourceNameCell } from '@spree/dashboard-ui'
2
3
  import { WarehouseIcon } from '@spree/dashboard-ui/icons'
3
4
  import i18n from 'i18next'
@@ -35,14 +36,10 @@ defineTable<PanelStockLocation>('stock-locations', {
35
36
  filterable: true,
36
37
  default: true,
37
38
  filterType: 'enum',
38
- filterOptions: [
39
- { value: 'warehouse', label: i18n.t('admin.stock_locations.kinds.warehouse') },
40
- { value: 'store', label: i18n.t('admin.stock_locations.kinds.store') },
41
- {
42
- value: 'fulfillment_center',
43
- label: i18n.t('admin.stock_locations.kinds.fulfillment_center'),
44
- },
45
- ],
39
+ filterOptions: STOCK_LOCATION_KINDS.map((kind) => ({
40
+ value: kind,
41
+ label: i18n.t(`admin.stock_locations.kinds.${kind}`),
42
+ })),
46
43
  quickFilter: true,
47
44
  render: (sl) => {
48
45
  const key = `admin.stock_locations.kinds.${sl.kind}`
@@ -1,63 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import {
3
- fractionToPercent,
4
- isPositiveMoney,
5
- percentOf,
6
- percentToFraction,
7
- prorateMoney,
8
- shiftDecimalPoint,
9
- } from './money'
10
-
11
- describe('shiftDecimalPoint', () => {
12
- it('moves the point exactly, without float noise', () => {
13
- expect(shiftDecimalPoint('0.077', 2)).toBe('7.7')
14
- expect(shiftDecimalPoint('7.7', -2)).toBe('0.077')
15
- expect(shiftDecimalPoint('0.07', 2)).toBe('7')
16
- expect(shiftDecimalPoint('1', 2)).toBe('100')
17
- expect(shiftDecimalPoint('100', -2)).toBe('1')
18
- expect(shiftDecimalPoint('-0.5', 2)).toBe('-50')
19
- expect(shiftDecimalPoint('0', -2)).toBe('0')
20
- })
21
-
22
- it('leaves a value that is not a decimal string alone', () => {
23
- expect(shiftDecimalPoint('abc', 2)).toBe('abc')
24
- })
25
- })
26
-
27
- describe('fractionToPercent / percentToFraction', () => {
28
- it('round-trips and keeps blanks blank', () => {
29
- expect(fractionToPercent('0.23')).toBe('23')
30
- expect(percentToFraction('23')).toBe('0.23')
31
- expect(percentToFraction('7.25')).toBe('0.0725')
32
- expect(fractionToPercent(null)).toBe('')
33
- expect(percentToFraction('')).toBe('')
34
- })
35
- })
36
-
37
- describe('percentOf', () => {
38
- it('rounds to the currency decimals', () => {
39
- expect(percentOf('19.99', '10', 'USD')).toBe('2.00')
40
- expect(percentOf('100', '12.5', 'USD')).toBe('12.50')
41
- expect(percentOf('1000', '0.05', 'JPY')).toBe('1')
42
- expect(percentOf('1.005', '50', 'KWD')).toBe('0.503')
43
- })
44
- })
45
-
46
- describe('isPositiveMoney', () => {
47
- it('is true only for an amount above zero', () => {
48
- expect(isPositiveMoney('0.01')).toBe(true)
49
- expect(isPositiveMoney('0.00')).toBe(false)
50
- expect(isPositiveMoney('-1')).toBe(false)
51
- expect(isPositiveMoney(null)).toBe(false)
52
- expect(isPositiveMoney('abc')).toBe(false)
53
- })
54
- })
55
-
56
- describe('prorateMoney', () => {
57
- it('shares an amount across units, rounded to the currency', () => {
58
- expect(prorateMoney('10.00', 1, 3, 'USD')).toBe('3.33')
59
- expect(prorateMoney('10.00', 2, 3, 'USD')).toBe('6.67')
60
- expect(prorateMoney('10.00', 3, 3, 'USD')).toBe('10.00')
61
- expect(prorateMoney('1000', 1, 3, 'JPY')).toBe('333')
62
- })
63
- })
@@ -1,67 +0,0 @@
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('reads a lone group separator not followed by three digits as the decimal', () => {
18
- expect(normalizeMoneyInput('19.50', 'de')).toBe('19.50')
19
- expect(normalizeMoneyInput('0.5', 'de')).toBe('0.5')
20
- expect(normalizeMoneyInput('19.50 €', 'de')).toBe('19.50')
21
- expect(normalizeMoneyInput('19,50', 'en')).toBe('19.50')
22
- expect(normalizeMoneyInput('1.500 €', 'de')).toBe('1500')
23
- expect(normalizeMoneyInput('1.234.567', 'de')).toBe('1234567')
24
- })
25
-
26
- it('handles fr narrow-space grouping', () => {
27
- // fr groups with U+202F (narrow no-break space) and uses a comma decimal.
28
- expect(normalizeMoneyInput('1 234,56', 'fr')).toBe('1234.56')
29
- expect(normalizeMoneyInput('73,45', 'fr')).toBe('73.45')
30
- })
31
-
32
- it('returns empty string for blank input', () => {
33
- expect(normalizeMoneyInput('', 'de')).toBe('')
34
- expect(normalizeMoneyInput(' ', 'de')).toBe('')
35
- expect(normalizeMoneyInput(null, 'de')).toBe('')
36
- expect(normalizeMoneyInput(undefined, 'de')).toBe('')
37
- })
38
-
39
- it('preserves a leading minus', () => {
40
- expect(normalizeMoneyInput('-5,00', 'de')).toBe('-5.00')
41
- expect(normalizeMoneyInput('-5.00', 'en')).toBe('-5.00')
42
- })
43
-
44
- it('drops stray currency symbols and whitespace', () => {
45
- expect(normalizeMoneyInput(' € 1.234,56 ', 'de')).toBe('1234.56')
46
- expect(normalizeMoneyInput('$19.99', 'en')).toBe('19.99')
47
- })
48
-
49
- it('keeps no-decimal values intact', () => {
50
- expect(normalizeMoneyInput('50', 'de')).toBe('50')
51
- expect(normalizeMoneyInput('1.000', 'de')).toBe('1000') // de: dot is grouping
52
- expect(normalizeMoneyInput('1,000', 'en')).toBe('1000') // en: comma is grouping
53
- })
54
-
55
- it('collapses stray separators to a single decimal point', () => {
56
- // Malformed multi-dot/interior-minus input must canonicalize, not pass through.
57
- expect(normalizeMoneyInput('1.2.3', 'en')).toBe('1.23')
58
- expect(normalizeMoneyInput('12-3.4', 'en')).toBe('123.4')
59
- expect(normalizeMoneyInput('-12-3.4', 'en')).toBe('-123.4')
60
- })
61
-
62
- it('returns empty for inputs with no digits', () => {
63
- expect(normalizeMoneyInput('.', 'en')).toBe('')
64
- expect(normalizeMoneyInput('-', 'en')).toBe('')
65
- expect(normalizeMoneyInput('abc', 'en')).toBe('')
66
- })
67
- })