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

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 (61) hide show
  1. package/package.json +5 -4
  2. package/src/api-client.ts +12 -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-select.tsx +6 -7
  6. package/src/components/file-upload-field.tsx +1 -0
  7. package/src/components/locale-select.tsx +22 -23
  8. package/src/components/nav-main.tsx +7 -2
  9. package/src/components/preferences-form.tsx +2 -8
  10. package/src/components/settings-sidebar.tsx +8 -1
  11. package/src/components/slot.tsx +21 -1
  12. package/src/components/table-toolbar.tsx +102 -39
  13. package/src/hooks/use-display-name.ts +1 -18
  14. package/src/hooks/use-file-upload.ts +2 -1
  15. package/src/hooks/use-import.ts +3 -2
  16. package/src/index.ts +13 -3
  17. package/src/lib/currencies.ts +2 -34
  18. package/src/lib/currency-parts.ts +2 -21
  19. package/src/lib/filters-to-ransack.ts +4 -1
  20. package/src/lib/form-fields-registry.ts +23 -1
  21. package/src/lib/form-mappers.ts +1 -1
  22. package/src/lib/formatters.ts +6 -10
  23. package/src/lib/fulfillment-items.ts +11 -0
  24. package/src/lib/i18n.ts +1 -12
  25. package/src/lib/iso-codes.ts +9 -26
  26. package/src/lib/money.ts +9 -99
  27. package/src/lib/normalize-money.ts +2 -85
  28. package/src/lib/return-refunds.ts +1 -2
  29. package/src/lib/route-registry.ts +29 -0
  30. package/src/lib/settings-nav-registry.ts +6 -1
  31. package/src/lib/store-config.test.ts +93 -0
  32. package/src/lib/store-config.ts +76 -0
  33. package/src/lib/table-registry.ts +16 -5
  34. package/src/locales/ar.json +4 -0
  35. package/src/locales/de.json +4 -0
  36. package/src/locales/en.json +4 -0
  37. package/src/locales/es.json +4 -0
  38. package/src/locales/fr.json +4 -0
  39. package/src/locales/pl.json +4 -0
  40. package/src/locales/zh-CN.json +4 -0
  41. package/src/pages/package-types.tsx +50 -11
  42. package/src/pages/settings-index.tsx +4 -1
  43. package/src/pages/stock-locations.tsx +31 -6
  44. package/src/products/bulk-variants-dialog.tsx +27 -28
  45. package/src/products/media-edit-sheet.tsx +67 -8
  46. package/src/products/normalize-quantity.ts +2 -3
  47. package/src/products/product-bulk-price-editor.tsx +1 -3
  48. package/src/products/product-form-cards.tsx +67 -37
  49. package/src/products/product-form-mapping.ts +44 -5
  50. package/src/products/product-schema.test.ts +8 -0
  51. package/src/products/product-schema.ts +14 -7
  52. package/src/products/product-types.ts +1 -1
  53. package/src/products/sheet-save-handlers.ts +37 -0
  54. package/src/products/variant-edit-sheet.tsx +21 -11
  55. package/src/products/variants-section.tsx +4 -1
  56. package/src/providers/store-provider.tsx +31 -7
  57. package/src/schemas/package-type.ts +14 -12
  58. package/src/schemas/stock-location.ts +2 -3
  59. package/src/tables/stock-locations.tsx +5 -8
  60. package/src/lib/money.test.ts +0 -63
  61. package/src/lib/normalize-money.test.ts +0 -67
@@ -1,24 +1,7 @@
1
1
  import { useMemo } from 'react'
2
2
  import { useTranslation } from 'react-i18next'
3
3
 
4
- /**
5
- * Resolves a code to its display name via `Intl.DisplayNames`, localized to a
6
- * given UI language. Returns `undefined` when the runtime lacks coverage so
7
- * callers can apply their own fallback (usually the raw code).
8
- *
9
- * Standalone (non-hook) variant for module-level use outside React.
10
- */
11
- export function intlDisplayName(
12
- type: Intl.DisplayNamesType,
13
- locale: string,
14
- code: string,
15
- ): string | undefined {
16
- try {
17
- return new Intl.DisplayNames([locale, 'en'], { type }).of(code)
18
- } catch {
19
- return undefined
20
- }
21
- }
4
+ export { intlDisplayName } from '@spree/core'
22
5
 
23
6
  /**
24
7
  * Hook returning a memoized `code -> name` resolver for one `Intl.DisplayNames`
@@ -1,3 +1,4 @@
1
+ import type { FileUploadVisibility } from '@spree/core'
1
2
  import { useMutation } from '@tanstack/react-query'
2
3
  import { getApiClient } from '../api-client'
3
4
 
@@ -31,7 +32,7 @@ interface FileUploadOptions {
31
32
  * (documents, imports, digital products). Attaching a file never moves it
32
33
  * between storage services, so this has to be decided up front.
33
34
  */
34
- visibility?: 'public' | 'private'
35
+ visibility?: FileUploadVisibility
35
36
  }
36
37
 
37
38
  export function useFileUpload(options: FileUploadOptions = {}) {
@@ -1,3 +1,4 @@
1
+ import { IMPORT_STATUSES, subsetOf } from '@spree/core'
1
2
  import { toastManager } from '@spree/dashboard-ui'
2
3
  import {
3
4
  type QueryClient,
@@ -20,7 +21,7 @@ import { useTenantId } from '../providers/tenant-provider'
20
21
  import { useAuth } from './use-auth'
21
22
 
22
23
  /** Statuses the wizard polls through — row creation and processing. */
23
- const ACTIVE_STATUSES = new Set(['completed_mapping', 'processing'])
24
+ const ACTIVE_STATUSES = subsetOf(IMPORT_STATUSES, ['completed_mapping', 'processing'])
24
25
 
25
26
  const IMPORT_POLL_INTERVAL_MS = 2000
26
27
  const ROWS_POLL_INTERVAL_MS = 5000
@@ -49,7 +50,7 @@ const IMPORT_TOUCHED_RESOURCES = [
49
50
 
50
51
  /** Whether the import's pipeline is still running (the poll's continue predicate). */
51
52
  export function isImportActive(status: string | undefined): boolean {
52
- return !!status && ACTIVE_STATUSES.has(status)
53
+ return !!status && ACTIVE_STATUSES.includes(status)
53
54
  }
54
55
 
55
56
  /**
package/src/index.ts CHANGED
@@ -11,6 +11,17 @@
11
11
  // import { AuthProvider } from '@spree/dashboard-core/providers/auth-provider'
12
12
  // import { useResourceMutation } from '@spree/dashboard-core/hooks/use-resource-mutation'
13
13
 
14
+ // Money helpers from @spree/core, offered here as before for plugin authors
15
+ export {
16
+ currencyParts,
17
+ fractionToPercent,
18
+ isPositiveMoney,
19
+ normalizeMoneyInput,
20
+ percentOf,
21
+ percentToFraction,
22
+ prorateMoney,
23
+ shiftDecimalPoint,
24
+ } from '@spree/core'
14
25
  export * from './api-client'
15
26
  // ---------------------------------------------------------------------------
16
27
  // Admin SDK client (Vite-aware singleton; reads VITE_SPREE_API_URL at build)
@@ -81,7 +92,6 @@ export * from './lib/create-action-registry'
81
92
  // Registries — pluggable extension points (nav, route, slot, table,
82
93
  // settings-nav, form fields, custom field components)
83
94
  // ---------------------------------------------------------------------------
84
- export * from './lib/currency-parts'
85
95
  export * from './lib/custom-field-components'
86
96
  export * from './lib/date-presets'
87
97
  export * from './lib/delivery-form'
@@ -103,9 +113,7 @@ export * from './lib/i18n'
103
113
  export * from './lib/import-types'
104
114
  export * from './lib/iso-codes'
105
115
  export * from './lib/label-upload-form'
106
- export * from './lib/money'
107
116
  export * from './lib/nav-registry'
108
- export * from './lib/normalize-money'
109
117
  export * from './lib/permissions'
110
118
  export * from './lib/progress'
111
119
  export * from './lib/query-client'
@@ -115,6 +123,7 @@ export * from './lib/route-registry'
115
123
  export * from './lib/search-registry'
116
124
  export * from './lib/settings-nav-registry'
117
125
  export * from './lib/slot-registry'
126
+ export * from './lib/store-config'
118
127
  export * from './lib/table-registry'
119
128
  export { ensureTimestampColumns } from './lib/timestamp-columns'
120
129
  export * from './lib/type-labels'
@@ -131,6 +140,7 @@ export * from './products/product-form-cards'
131
140
  export * from './products/product-form-mapping'
132
141
  export * from './products/product-schema'
133
142
  export * from './products/product-types'
143
+ export type { SheetSaveHandler } from './products/sheet-save-handlers'
134
144
  export * from './products/use-product-form-data'
135
145
  export * from './products/variants-matrix'
136
146
  export * from './products/video-url'
@@ -1,34 +1,2 @@
1
- import { CurrencyCode } from '@spree/admin-sdk'
2
-
3
- // Replaced currencies Spree still accepts so orders priced in them stay
4
- // valid, but which no merchant should pick for new pricing.
5
- const RETIRED_CURRENCY_CODES: ReadonlySet<string> = new Set([
6
- 'BYR',
7
- 'CUC',
8
- 'EEK',
9
- 'HRK',
10
- 'LTL',
11
- 'LVL',
12
- 'MRO',
13
- 'MTL',
14
- 'SKK',
15
- 'SLL',
16
- 'STD',
17
- 'TMM',
18
- 'VEF',
19
- 'ZMK',
20
- 'ZWD',
21
- 'ZWL',
22
- 'ZWN',
23
- 'ZWR',
24
- ])
25
-
26
- /**
27
- * Every currency Spree accepts that is still in use. Used for contexts where
28
- * the merchant can pick *any* currency (a market's currency) rather than one
29
- * the store already supports. The list ships with the SDK, so a currency a
30
- * newer server adds appears once the dashboard is upgraded with it.
31
- */
32
- export const ALL_CURRENCY_CODES: readonly CurrencyCode[] = Object.values(CurrencyCode).filter(
33
- (code) => !RETIRED_CURRENCY_CODES.has(code),
34
- )
1
+ /** @deprecated Import `ACTIVE_CURRENCY_CODES` from `@spree/core`. Removed in 6.1. */
2
+ export { ACTIVE_CURRENCY_CODES as ALL_CURRENCY_CODES } from '@spree/core'
@@ -1,21 +1,2 @@
1
- // Resolve a currency code into its symbol + decimal separator under the
2
- // current locale, with a safe fallback for unknown/invalid currencies.
3
- // Shared between the server-backed BulkPriceEditor and the form-backed
4
- // ProductBulkPriceEditor.
5
- export function currencyParts(
6
- currencyCode: string,
7
- locale: string,
8
- ): { symbol: string; decimal: string } {
9
- try {
10
- const parts = new Intl.NumberFormat(locale, {
11
- style: 'currency',
12
- currency: currencyCode,
13
- }).formatToParts(1234.56)
14
- return {
15
- symbol: parts.find((p) => p.type === 'currency')?.value ?? currencyCode,
16
- decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
17
- }
18
- } catch {
19
- return { symbol: currencyCode, decimal: '.' }
20
- }
21
- }
1
+ /** @deprecated Import from `@spree/core`. Removed in 6.1. */
2
+ export { currencyParts } from '@spree/core'
@@ -1,3 +1,4 @@
1
+ import { FILTER_PREDICATES } from '@spree/core'
1
2
  import { type ColumnDef, type FilterRule, parseFilterIds } from './table-registry'
2
3
 
3
4
  /**
@@ -20,7 +21,9 @@ import { type ColumnDef, type FilterRule, parseFilterIds } from './table-registr
20
21
  // `q[xxx_in][]=...` for each item. The chip stores the IDs as a CSV string
21
22
  // in `FilterRule.value` so the URL serializer (which already JSON-stringifies
22
23
  // FilterRule[]) stays simple.
23
- const ARRAY_OPERATORS = new Set(['in', 'not_in'])
24
+ const ARRAY_OPERATORS = new Set<string>(
25
+ Object.values(FILTER_PREDICATES).flatMap((predicates) => predicates.list),
26
+ )
24
27
 
25
28
  const NEGATING_OPERATORS = new Set(['not_in', 'not_eq'])
26
29
 
@@ -30,7 +30,8 @@ const registrations = new Map<string, FormFieldRegistration[]>()
30
30
 
31
31
  /**
32
32
  * Registry of extension fields per built-in form. Form keys match the form's
33
- * slot prefix — `'product'` for the product detail form. Registered names
33
+ * slot prefix — `'product'` for the product detail form, `'product.media'` and
34
+ * `'product.variant'` for the rows of its media and variant lists. Registered names
34
35
  * must be unique per form; duplicates throw so two plugins can't silently
35
36
  * fight over one attribute.
36
37
  */
@@ -96,6 +97,27 @@ export function extensionSubmitValues(
96
97
  return out
97
98
  }
98
99
 
100
+ /** Names of every extension field registered on `formKey`. */
101
+ export function extensionFieldNames(formKey: string): string[] {
102
+ return (registrations.get(formKey) ?? []).map((registration) => registration.name)
103
+ }
104
+
105
+ /**
106
+ * Extension field values carried by one row of a nested list — a media or
107
+ * variant row of the product form, registered under `'product.media'` /
108
+ * `'product.variant'`. Only keys the row actually holds are returned, so a
109
+ * row added after hydration (a fresh upload) sends nothing it never had.
110
+ */
111
+ export function extensionRowValues(formKey: string, row: unknown): Record<string, unknown> {
112
+ if (!row || typeof row !== 'object') return {}
113
+
114
+ const out: Record<string, unknown> = {}
115
+ for (const name of extensionFieldNames(formKey)) {
116
+ if (name in row) out[name] = (row as Record<string, unknown>)[name]
117
+ }
118
+ return out
119
+ }
120
+
99
121
  /** Test-only: clear the registry. Not exported from the package index. */
100
122
  export function __resetFormFieldsRegistry(): void {
101
123
  registrations.clear()
@@ -1,4 +1,4 @@
1
- import type { CountryCode } from '@spree/admin-sdk'
1
+ import type { CountryCode } from '@spree/core'
2
2
  import type { PanelAccountParams } from '../api-client'
3
3
 
4
4
  /**
@@ -1,4 +1,5 @@
1
- import { formatMoney, type Price } from '@spree/admin-sdk'
1
+ import type { Price } from '@spree/admin-sdk'
2
+ import { formatMoney } from '@spree/core'
2
3
  import { parseISO } from 'date-fns'
3
4
  import { formatInTimeZone } from 'date-fns-tz'
4
5
  import i18n from 'i18next'
@@ -19,9 +20,8 @@ export function formatPrice(
19
20
  }
20
21
 
21
22
  /**
22
- * Formats an API amount (a decimal string) in its currency, falling back to
23
- * the bare number for a currency `Intl` does not know, and to `fallback` when
24
- * either is missing.
23
+ * Formats an API amount (a decimal string) in its currency, or answers
24
+ * `fallback` when either is missing or `Intl` cannot format the pair.
25
25
  */
26
26
  export function formatAmount(
27
27
  amount: string | null | undefined,
@@ -29,12 +29,8 @@ export function formatAmount(
29
29
  locale?: string,
30
30
  fallback = '—',
31
31
  ): string {
32
- if (amount === null || amount === undefined || amount === '' || !currency) return fallback
33
- try {
34
- return formatMoney(amount, currency, locale) || fallback
35
- } catch {
36
- return `${amount} ${currency}`
37
- }
32
+ if (!currency) return fallback
33
+ return formatMoney(amount, currency, locale) || fallback
38
34
  }
39
35
 
40
36
  export function formatStoreDateTime(iso: string, timezone: string) {
@@ -1,3 +1,4 @@
1
+ import { ORDER_FULFILLMENT_STATUSES, subsetOf } from '@spree/core'
1
2
  import i18n from 'i18next'
2
3
  import { formatMoney } from './formatters'
3
4
 
@@ -86,6 +87,16 @@ function claimedQuantities(fulfillments: GroupableFulfillment[]): Map<string, nu
86
87
  */
87
88
  export const GONE_STATUSES = ['fulfilled', 'delivered']
88
89
 
90
+ /** The order-level fulfillment statuses the order lists filter on. */
91
+ export const FILTERABLE_ORDER_FULFILLMENT_STATUSES = subsetOf(ORDER_FULFILLMENT_STATUSES, [
92
+ 'backorder',
93
+ 'canceled',
94
+ 'partial',
95
+ 'unfulfilled',
96
+ 'fulfilled',
97
+ 'delivered',
98
+ ])
99
+
89
100
  export function fulfilledQuantities(fulfillments: GroupableFulfillment[]): Map<string, number> {
90
101
  const fulfilled = new Map<string, number>()
91
102
 
package/src/lib/i18n.ts CHANGED
@@ -1,6 +1,6 @@
1
+ import { localeEndonym } from '@spree/core'
1
2
  import i18n from 'i18next'
2
3
  import { initReactI18next } from 'react-i18next'
3
- import { intlDisplayName } from '../hooks/use-display-name'
4
4
  // Relative path, not `@/...`: dashboard-core ships source; the consuming
5
5
  // project's `@/*` alias is theirs alone (see Phase 1's rationale).
6
6
  import en from '../locales/en.json'
@@ -153,17 +153,6 @@ function localeCodesFromBundlePaths(paths: string[]): string[] {
153
153
  return ['en', ...paths.map((path) => path.replace(/^.*\/locales\//, '').replace('.json', ''))]
154
154
  }
155
155
 
156
- // Each language's endonym (its own name: `Deutsch`, `中文`). `Intl.DisplayNames`
157
- // answers in the language's own convention, so some come back lowercase
158
- // (`français`, `polski`); capitalize the first letter by THAT language's casing
159
- // rules rather than the current locale's. Scripts without case are unaffected,
160
- // and an unknown code falls back to itself.
161
- function localeEndonym(code: string): string {
162
- const name = intlDisplayName('language', code, code) ?? code
163
-
164
- return name.charAt(0).toLocaleUpperCase(code) + name.slice(1)
165
- }
166
-
167
156
  // The languages the running panel can display, as it declared them. `null`
168
157
  // until a panel does, which accepts every code — the behaviour before panels
169
158
  // declared anything.
@@ -1,32 +1,15 @@
1
- import type { CountryCode, CurrencyCode, LocaleCode } from '@spree/admin-sdk'
1
+ import {
2
+ type CountryCode,
3
+ type CurrencyCode,
4
+ isCountryCode,
5
+ isCurrencyCode,
6
+ isLocaleCode,
7
+ type LocaleCode,
8
+ } from '@spree/core'
2
9
  import i18n from 'i18next'
3
10
  import { z } from 'zod/v4'
4
11
 
5
- /**
6
- * Whether a value has the shape of an ISO 3166-1 alpha-2 country code.
7
- *
8
- * Shape only: the connected server holds the authoritative list and rejects
9
- * a code it does not know, while the SDK's `CountryCode` list is fixed when it
10
- * is built. Checking against that copy would hide or reject a code an older or
11
- * newer server accepts. This is the one place a plain string becomes a
12
- * `CountryCode`.
13
- */
14
- export function isCountryCode(value: unknown): value is CountryCode {
15
- return typeof value === 'string' && /^[A-Z]{2}$/.test(value)
16
- }
17
-
18
- /** Whether a value has the shape of an ISO 4217 currency code. See `isCountryCode`. */
19
- export function isCurrencyCode(value: unknown): value is CurrencyCode {
20
- return typeof value === 'string' && /^[A-Z]{3}$/.test(value)
21
- }
22
-
23
- /**
24
- * Whether a value has the shape of a locale code: an ISO 639-1 language,
25
- * optionally with a region (`en`, `pt-BR`, `es-419`). See `isCountryCode`.
26
- */
27
- export function isLocaleCode(value: unknown): value is LocaleCode {
28
- return typeof value === 'string' && /^[a-z]{2,3}(-[A-Z0-9]{2,3})?$/.test(value)
29
- }
12
+ export { isCountryCode, isCurrencyCode, isLocaleCode }
30
13
 
31
14
  /**
32
15
  * A required country field. A form that allows leaving it blank widens it
package/src/lib/money.ts CHANGED
@@ -1,99 +1,9 @@
1
- import { compareMoney, decimalPlaces, isDecimalString, sumMoney } from '@spree/admin-sdk'
2
-
3
- /** Whether an amount is a decimal string above zero. Blank or malformed is not. */
4
- export function isPositiveMoney(value: string | null | undefined): boolean {
5
- return isDecimalString(value) && compareMoney(value, '0') > 0
6
- }
7
-
8
- /**
9
- * Moves the decimal point of a decimal string by whole places, exactly, and
10
- * writes the result without trailing zeros ("0.077" shifted by 2 is "7.7").
11
- * Anything that is not a decimal string comes back unchanged.
12
- */
13
- export function shiftDecimalPoint(value: string, places: number): string {
14
- if (!isDecimalString(value)) return value
15
-
16
- const negative = value.startsWith('-')
17
- const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.')
18
- let digits = whole + fraction
19
- let point = whole.length + places
20
- if (point < 0) {
21
- digits = '0'.repeat(-point) + digits
22
- point = 0
23
- }
24
- digits = digits.padEnd(point, '0')
25
-
26
- const integer = digits.slice(0, point).replace(/^0+(?=\d)/, '') || '0'
27
- const decimals = digits.slice(point).replace(/0+$/, '')
28
- const text = decimals ? `${integer}.${decimals}` : integer
29
- return negative && /[1-9]/.test(text) ? `-${text}` : text
30
- }
31
-
32
- /** A fraction rate ("0.23") as the percentage a merchant reads ("23"). Blank stays blank. */
33
- export function fractionToPercent(fraction: string | null | undefined): string {
34
- return fraction == null || fraction === '' ? '' : shiftDecimalPoint(fraction, 2)
35
- }
36
-
37
- /** A typed percentage ("23") as the fraction rate the API stores ("0.23"). Blank stays blank. */
38
- export function percentToFraction(percent: string | null | undefined): string {
39
- return percent == null || percent === '' ? '' : shiftDecimalPoint(percent.trim(), -2)
40
- }
41
-
42
- function scaled(value: string): { units: bigint; scale: number } {
43
- const negative = value.startsWith('-')
44
- const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.')
45
- const units = BigInt(whole + fraction)
46
- return { units: negative ? -units : units, scale: fraction.length }
47
- }
48
-
49
- /**
50
- * numerator / denominator, where the numerator carries `scale` decimals,
51
- * rounded half away from zero to the currency's decimals.
52
- */
53
- function roundToCurrency(
54
- numerator: bigint,
55
- scale: number,
56
- denominator: bigint,
57
- currency: string,
58
- ): string {
59
- const places = decimalPlaces(currency)
60
- const negative = numerator < 0n !== denominator < 0n
61
- let dividend = numerator < 0n ? -numerator : numerator
62
- let divisor = denominator < 0n ? -denominator : denominator
63
- if (scale > places) divisor *= 10n ** BigInt(scale - places)
64
- else dividend *= 10n ** BigInt(places - scale)
65
-
66
- let units = dividend / divisor
67
- if ((dividend % divisor) * 2n >= divisor) units += 1n
68
-
69
- const text = shiftDecimalPoint((negative ? -units : units).toString(), -places)
70
- return sumMoney([text], currency)
71
- }
72
-
73
- /**
74
- * A percentage of an amount, exactly, rounded half away from zero to the
75
- * currency's decimals ("10" percent of "19.99" USD is "2.00").
76
- */
77
- export function percentOf(amount: string, percent: string, currency: string): string {
78
- const left = scaled(amount)
79
- const right = scaled(percent)
80
- // The percentage is a hundredth, so the product carries two more places.
81
- return roundToCurrency(left.units * right.units, left.scale + right.scale + 2, 1n, currency)
82
- }
83
-
84
- /**
85
- * The share of an amount that `part` of `whole` units carries ("10.00" for 1
86
- * of 3 is "3.33"), rounded half away from zero to the currency's decimals.
87
- */
88
- export function prorateMoney(
89
- amount: string,
90
- part: number,
91
- whole: number,
92
- currency: string,
93
- ): string {
94
- if (!Number.isInteger(part) || !Number.isInteger(whole) || whole === 0) {
95
- throw new Error(`Cannot prorate ${part} of ${whole}`)
96
- }
97
- const { units, scale } = scaled(amount)
98
- return roundToCurrency(units * BigInt(part), scale, BigInt(whole), currency)
99
- }
1
+ /** @deprecated Import these from `@spree/core`. Removed in 6.1. */
2
+ export {
3
+ fractionToPercent,
4
+ isPositiveMoney,
5
+ percentOf,
6
+ percentToFraction,
7
+ prorateMoney,
8
+ shiftDecimalPoint,
9
+ } from '@spree/core'
@@ -1,85 +1,2 @@
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 admin's own (`useMoneyLocale`), the same one the field is
8
- // displayed in, so a value reads back exactly as it was shown.
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
- * - `"19.50"` under `de` → `"19.50"` (a period can't be grouping before two digits)
34
- * - `""` / whitespace → `""`
35
- *
36
- * Strips the locale's group separator and any whitespace, standardizes the
37
- * decimal separator to `.`, and drops stray characters. Keeps the value as a
38
- * string throughout — never `Number()`-coerces — to preserve precision.
39
- *
40
- * @param raw the merchant's typed value
41
- * @param locale the display locale the value was entered in (e.g. `de`)
42
- * @returns canonical `"1234.56"`-form string, or `''` for blank input
43
- */
44
- export function normalizeMoneyInput(raw: string | null | undefined, locale: string): string {
45
- if (raw == null) return ''
46
- const trimmed = raw.trim()
47
- if (trimmed === '') return ''
48
-
49
- const { decimal, group } = separatorsFor(locale)
50
-
51
- let out = trimmed
52
- // A group separator is always followed by exactly three digits, so a lone
53
- // one that is not (`19.50` under `de`) is the merchant typing the other
54
- // decimal mark. `1.500` stays ambiguous and is read the locale's way.
55
- const groupParts = group.trim() === '' ? [] : out.split(group)
56
- if (
57
- groupParts.length === 2 &&
58
- !out.includes(decimal) &&
59
- groupParts[1].replace(/\D/g, '').length !== 3
60
- ) {
61
- out = groupParts.join(decimal)
62
- }
63
- // Remove the locale's grouping separator (explicit char) plus any whitespace
64
- // (covers narrow/no-break spaces used by some locales as the group char).
65
- if (group) out = out.split(group).join('')
66
- out = out.replace(/\s/g, '')
67
- // Standardize the decimal separator to `.`.
68
- if (decimal !== '.') out = out.split(decimal).join('.')
69
- // Drop anything that isn't a digit, dot, or minus.
70
- out = out.replace(/[^0-9.-]/g, '')
71
-
72
- // Enforce the canonical shape: a single optional leading minus, then digits
73
- // with at most one decimal point. Stray separators (`1.2.3`) or interior
74
- // minus signs (`12-3.4`) would otherwise reach the API as malformed values.
75
- const negative = out.startsWith('-')
76
- const digitsAndDots = out.replace(/-/g, '')
77
- const firstDot = digitsAndDots.indexOf('.')
78
- const canonical =
79
- firstDot === -1
80
- ? digitsAndDots
81
- : `${digitsAndDots.slice(0, firstDot)}.${digitsAndDots.slice(firstDot + 1).replace(/\./g, '')}`
82
-
83
- if (canonical === '' || canonical === '.') return ''
84
- return negative ? `-${canonical}` : canonical
85
- }
1
+ /** @deprecated Import from `@spree/core`. Removed in 6.1. */
2
+ export { normalizeMoneyInput } from '@spree/core'
@@ -1,6 +1,5 @@
1
- import { compareMoney, isDecimalString, isZeroMoney, sumMoney } from '@spree/admin-sdk'
1
+ import { compareMoney, isDecimalString, isZeroMoney, prorateMoney, sumMoney } from '@spree/core'
2
2
  import { formatMoney } from './formatters'
3
- import { prorateMoney } from './money'
4
3
 
5
4
  /** True when a received return is owed nothing, so completing it moves no money. */
6
5
  export function returnOwesNothing(refundableTotal: string): boolean {
@@ -183,6 +183,35 @@ export function matchPluginRoute(
183
183
  return null
184
184
  }
185
185
 
186
+ /**
187
+ * Whether a registry route outranks the file route the router matched for the
188
+ * same URL, so it renders in that route's place. The router sends
189
+ * `/sellers/shopify` to `/sellers/$sellerId`, never to the catch-all, which
190
+ * would leave a plugin page under a first-party section unreachable.
191
+ *
192
+ * Uses the router's own ranking: segment by segment, a fixed segment beats a
193
+ * `$param`. A registry route therefore takes over only an address where it is
194
+ * more specific, never a first-party page at its own path or through a
195
+ * pattern like `/sellers/$id`.
196
+ *
197
+ * @param pluginPath the registry route's path, relative to `/$storeId`
198
+ * @param routerPath the matched file route's path, relative to `/$storeId`
199
+ */
200
+ export function outranksRouterMatch(pluginPath: string, routerPath: string): boolean {
201
+ const pluginSegments = pathSegments(pluginPath)
202
+ const routerSegments = pathSegments(routerPath)
203
+ for (const [index, pluginSegment] of pluginSegments.entries()) {
204
+ const pluginFixed = !pluginSegment.startsWith('$')
205
+ const routerFixed = !(routerSegments[index] ?? '$').startsWith('$')
206
+ if (pluginFixed !== routerFixed) return pluginFixed
207
+ }
208
+ return false
209
+ }
210
+
211
+ function pathSegments(path: string): string[] {
212
+ return path.split('/').filter(Boolean)
213
+ }
214
+
186
215
  function isStoreRoute(entry: RouteEntry): entry is StoreRouteEntry {
187
216
  return entry.scope === undefined || entry.scope === 'store'
188
217
  }
@@ -1,5 +1,5 @@
1
1
  import type { LucideIcon } from 'lucide-react'
2
- import { useSyncExternalStore } from 'react'
2
+ import { type ComponentType, useSyncExternalStore } from 'react'
3
3
  import { ensureLabelled, resolveNavDescription, resolveNavLabel } from './nav-registry'
4
4
  import type { ActionName, SubjectName } from './permissions'
5
5
 
@@ -75,6 +75,11 @@ export interface SettingsNavEntry {
75
75
  action?: ActionName
76
76
  /** When true, the page is disabled in the sidebar with a "Soon" badge. */
77
77
  comingSoon?: boolean
78
+ /**
79
+ * Rendered after the label in the sidebar and on the settings landing page.
80
+ * A component — not an element — so it can call hooks and return null.
81
+ */
82
+ badge?: ComponentType
78
83
  }
79
84
 
80
85
  export interface SettingsNavGroup {