@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
@@ -0,0 +1,172 @@
1
+ import { isDecimalString } from '@spree/core'
2
+
3
+ /**
4
+ * Delivery calculators whose price is a plain amount, quoted per currency
5
+ * through the `amounts` preference. Every other calculator (percent, tiered)
6
+ * is currency-neutral.
7
+ */
8
+ export const AMOUNT_BASED_CALCULATORS: readonly string[] = [
9
+ 'flat_rate',
10
+ 'per_item',
11
+ 'digital_delivery',
12
+ ]
13
+
14
+ /** The preferences the per-currency editor owns on those calculators. */
15
+ export const CURRENCY_AMOUNT_KEYS: readonly string[] = ['amount', 'currency', 'amounts']
16
+
17
+ /**
18
+ * A stored preference as a decimal string. The API answers with strings; a
19
+ * number still sitting in preferences saved before 6.0 is read as its digits.
20
+ */
21
+ function toDecimal(value: unknown): string | null {
22
+ const text =
23
+ typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : ''
24
+ return isDecimalString(text) ? text : null
25
+ }
26
+
27
+ /** One stored delivery amount and the ISO currency it was entered in. */
28
+ export type ListedAmount = {
29
+ amount: string
30
+ currency: string
31
+ }
32
+
33
+ function amountsHash(
34
+ preferences: Record<string, unknown> | null | undefined,
35
+ ): Record<string, unknown> {
36
+ const amounts = preferences?.amounts
37
+ if (!amounts || typeof amounts !== 'object' || Array.isArray(amounts)) return {}
38
+ return amounts as Record<string, unknown>
39
+ }
40
+
41
+ function hashKey(amounts: Record<string, unknown>, currency: string): string | undefined {
42
+ const code = currency.toUpperCase()
43
+ return Object.keys(amounts).find((key) => key.toUpperCase() === code)
44
+ }
45
+
46
+ function hashEntry(amounts: Record<string, unknown>, currency: string): unknown {
47
+ const key = hashKey(amounts, currency)
48
+ return key === undefined ? undefined : amounts[key]
49
+ }
50
+
51
+ function writeHashAmount(
52
+ amounts: Record<string, unknown>,
53
+ currency: string,
54
+ value: string | null,
55
+ ): void {
56
+ const existing = hashKey(amounts, currency)
57
+ if (existing !== undefined) delete amounts[existing]
58
+ if (value !== null) amounts[currency.toUpperCase()] = value
59
+ }
60
+
61
+ /** The currency the pre-6.0 single `amount` belongs to. */
62
+ function legacyCurrency(
63
+ preferences: Record<string, unknown> | null | undefined,
64
+ defaultCurrency: string,
65
+ ): string {
66
+ if (typeof preferences?.currency === 'string' && preferences.currency.trim() !== '') {
67
+ return preferences.currency.trim().toUpperCase()
68
+ }
69
+ return defaultCurrency.toUpperCase()
70
+ }
71
+
72
+ /** Whether the per-currency hash holds an amount: from then on it alone decides. */
73
+ function hasAmounts(amounts: Record<string, unknown>): boolean {
74
+ return Object.values(amounts).some((value) => toDecimal(value) !== null)
75
+ }
76
+
77
+ /**
78
+ * The amount a calculator-priced method quotes for one currency — the same
79
+ * rule the calculator uses. Once the per-currency `amounts` hash holds an
80
+ * amount it alone decides; the legacy single `amount` quotes its own
81
+ * currency (the `currency` preference, or the store default when that
82
+ * preference is blank) only while the hash is empty.
83
+ *
84
+ * A pre-6.0 method that names EUR therefore returns 7 for EUR and nothing
85
+ * for USD.
86
+ */
87
+ export function amountForCurrency(
88
+ preferences: Record<string, unknown> | null | undefined,
89
+ currency: string,
90
+ defaultCurrency: string,
91
+ ): string | null {
92
+ const code = currency.toUpperCase()
93
+ const amounts = amountsHash(preferences)
94
+ if (hasAmounts(amounts)) return toDecimal(hashEntry(amounts, code))
95
+
96
+ if (legacyCurrency(preferences, defaultCurrency) !== code) return null
97
+ return toDecimal(preferences?.amount)
98
+ }
99
+
100
+ /**
101
+ * Writes one currency's amount the way the delivery-method editor does: into
102
+ * the per-currency `amounts` hash, the store default like every other
103
+ * currency, and a cleared currency removed so the method is not offered in
104
+ * it.
105
+ *
106
+ * The first edit of a method priced with the legacy single `amount` moves
107
+ * that figure into the hash under its own currency, since the hash alone
108
+ * decides once it holds an amount, and clears the single amount. Amounts are
109
+ * written as the exact decimal string typed, which is what the API accepts.
110
+ */
111
+ export function applyCurrencyAmount(
112
+ preferences: Record<string, unknown>,
113
+ currency: string,
114
+ raw: string,
115
+ defaultCurrency: string,
116
+ ): Record<string, unknown> {
117
+ const current = amountsHash(preferences)
118
+ const nextAmounts: Record<string, unknown> = hasAmounts(current) ? { ...current } : {}
119
+
120
+ if (!hasAmounts(current)) {
121
+ const legacy = toDecimal(preferences.amount)
122
+ if (legacy !== null) nextAmounts[legacyCurrency(preferences, defaultCurrency)] = legacy
123
+ }
124
+
125
+ writeHashAmount(nextAmounts, currency, toDecimal(raw))
126
+ return { ...preferences, amount: null, amounts: nextAmounts }
127
+ }
128
+
129
+ /**
130
+ * Whether a calculator-priced method states an amount in any currency. One
131
+ * without any would never be offered.
132
+ */
133
+ export function hasCurrencyAmount(
134
+ preferences: Record<string, unknown> | null | undefined,
135
+ ): boolean {
136
+ return hasAmounts(amountsHash(preferences)) || toDecimal(preferences?.amount) !== null
137
+ }
138
+
139
+ /**
140
+ * Every amount a calculator-priced method quotes, each with the currency it
141
+ * belongs to. The per-currency `amounts` hash decides once it holds an
142
+ * amount; the legacy single `amount` stands for its own currency (the
143
+ * `currency` preference, or the store default) only while the hash is empty.
144
+ *
145
+ * A method priced only in a non-default currency therefore returns that
146
+ * currency's row, not a number that the caller would stamp with the store
147
+ * default's symbol.
148
+ */
149
+ export function listedAmounts(
150
+ preferences: Record<string, unknown> | null | undefined,
151
+ defaultCurrency: string,
152
+ ): ListedAmount[] {
153
+ const listed: ListedAmount[] = []
154
+ const defaultCode = defaultCurrency.toUpperCase()
155
+
156
+ for (const [code, value] of Object.entries(amountsHash(preferences))) {
157
+ const parsed = toDecimal(value)
158
+ if (parsed === null) continue
159
+ listed.push({ amount: parsed, currency: code.toUpperCase() })
160
+ }
161
+
162
+ const legacyAmount = toDecimal(preferences?.amount)
163
+ if (listed.length === 0 && legacyAmount !== null) {
164
+ listed.push({ amount: legacyAmount, currency: legacyCurrency(preferences, defaultCurrency) })
165
+ }
166
+
167
+ return listed.sort((left, right) => {
168
+ if (left.currency === defaultCode) return -1
169
+ if (right.currency === defaultCode) return 1
170
+ return left.currency.localeCompare(right.currency)
171
+ })
172
+ }
@@ -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 {