@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.
- package/package.json +10 -7
- package/src/api-client.ts +76 -7
- package/src/components/address-form-dialog.tsx +2 -2
- package/src/components/country-combobox.tsx +1 -2
- package/src/components/currency-amounts-field.tsx +93 -0
- package/src/components/currency-select.tsx +6 -7
- package/src/components/file-upload-field.tsx +1 -0
- package/src/components/locale-select.tsx +22 -23
- package/src/components/nav-main.tsx +7 -2
- package/src/components/preferences-form.tsx +2 -8
- package/src/components/settings-sidebar.tsx +8 -1
- package/src/components/slot.tsx +21 -1
- package/src/components/table-toolbar.tsx +102 -39
- package/src/hooks/use-display-name.ts +1 -18
- package/src/hooks/use-file-upload.ts +2 -1
- package/src/hooks/use-import.ts +3 -2
- package/src/index.ts +17 -3
- package/src/lib/currencies.ts +2 -34
- package/src/lib/currency-amounts.test.ts +147 -0
- package/src/lib/currency-amounts.ts +172 -0
- package/src/lib/currency-parts.ts +2 -21
- package/src/lib/filters-to-ransack.ts +4 -1
- package/src/lib/form-fields-registry.ts +23 -1
- package/src/lib/form-mappers.ts +1 -1
- package/src/lib/formatters.ts +6 -10
- package/src/lib/fulfillment-items.ts +11 -0
- package/src/lib/i18n.ts +1 -12
- package/src/lib/iso-codes.ts +9 -26
- package/src/lib/money.ts +9 -99
- package/src/lib/normalize-money.ts +2 -85
- package/src/lib/return-refunds.ts +1 -2
- package/src/lib/route-registry.ts +29 -0
- package/src/lib/settings-nav-registry.ts +6 -1
- package/src/lib/store-config.test.ts +93 -0
- package/src/lib/store-config.ts +76 -0
- package/src/lib/table-registry.ts +16 -5
- package/src/locales/ar.json +51 -0
- package/src/locales/de.json +51 -0
- package/src/locales/en.json +51 -0
- package/src/locales/es.json +51 -0
- package/src/locales/fr.json +51 -0
- package/src/locales/pl.json +51 -0
- package/src/locales/zh-CN.json +51 -0
- package/src/pages/package-types.tsx +50 -11
- package/src/pages/settings-index.tsx +4 -1
- package/src/pages/stock-locations.tsx +31 -6
- package/src/products/assignable-variants.ts +35 -0
- package/src/products/bulk-variants-dialog.tsx +27 -28
- package/src/products/digital-asset-edit-sheet.tsx +192 -0
- package/src/products/digital-asset-provider-sheet.tsx +88 -0
- package/src/products/digital-assets-card.tsx +451 -0
- package/src/products/media-edit-sheet.tsx +68 -20
- package/src/products/normalize-quantity.ts +2 -3
- package/src/products/product-bulk-price-editor.tsx +1 -3
- package/src/products/product-form-cards.tsx +67 -37
- package/src/products/product-form-mapping.ts +44 -5
- package/src/products/product-schema.test.ts +8 -0
- package/src/products/product-schema.ts +14 -7
- package/src/products/product-types.ts +1 -1
- package/src/products/provider-settings-fields.tsx +110 -0
- package/src/products/sheet-save-handlers.ts +37 -0
- package/src/products/use-digital-assets.ts +66 -0
- package/src/products/variant-edit-sheet.tsx +21 -11
- package/src/products/variants-section.tsx +4 -1
- package/src/providers/store-provider.tsx +31 -7
- package/src/schemas/package-type.ts +14 -12
- package/src/schemas/stock-location.ts +2 -3
- package/src/tables/stock-locations.tsx +5 -8
- package/src/lib/money.test.ts +0 -63
- 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
|
-
|
|
2
|
-
|
|
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(
|
|
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.
|
|
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()
|
package/src/lib/form-mappers.ts
CHANGED
package/src/lib/formatters.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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,
|
|
23
|
-
*
|
|
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 (
|
|
33
|
-
|
|
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.
|
package/src/lib/iso-codes.ts
CHANGED
|
@@ -1,32 +1,15 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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/
|
|
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 {
|