@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.
- package/package.json +5 -4
- package/src/api-client.ts +12 -7
- package/src/components/address-form-dialog.tsx +2 -2
- package/src/components/country-combobox.tsx +1 -2
- 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 +13 -3
- package/src/lib/currencies.ts +2 -34
- 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 +4 -0
- package/src/locales/de.json +4 -0
- package/src/locales/en.json +4 -0
- package/src/locales/es.json +4 -0
- package/src/locales/fr.json +4 -0
- package/src/locales/pl.json +4 -0
- package/src/locales/zh-CN.json +4 -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/bulk-variants-dialog.tsx +27 -28
- package/src/products/media-edit-sheet.tsx +67 -8
- 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/sheet-save-handlers.ts +37 -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
|
@@ -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?:
|
|
35
|
+
visibility?: FileUploadVisibility
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export function useFileUpload(options: FileUploadOptions = {}) {
|
package/src/hooks/use-import.ts
CHANGED
|
@@ -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 =
|
|
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.
|
|
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'
|
package/src/lib/currencies.ts
CHANGED
|
@@ -1,34 +1,2 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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 {
|