@spree/dashboard-core 1.0.0-rc.2 → 1.0.0-rc.4
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/dist/vite/discover.js +5 -2
- package/package.json +13 -16
- package/src/api-client.ts +22 -34
- package/src/components/address-form-dialog.test.ts +4 -4
- package/src/components/address-form-dialog.tsx +10 -10
- package/src/components/app-shell.tsx +12 -1
- package/src/components/app-sidebar.tsx +18 -2
- package/src/components/country-combobox.tsx +52 -29
- package/src/components/currency-select.tsx +18 -13
- package/src/components/file-upload-field.tsx +4 -4
- package/src/components/import-button.tsx +4 -1
- package/src/components/import-wizard-dialog.tsx +8 -11
- package/src/components/mobile-top-bar.tsx +6 -0
- package/src/components/money.tsx +23 -0
- package/src/components/preferences-form.tsx +263 -54
- package/src/components/resource-combobox.tsx +5 -0
- package/src/components/resource-picker-sheet.tsx +18 -12
- package/src/components/resource-table.tsx +7 -3
- package/src/components/table-toolbar.tsx +8 -5
- package/src/hooks/use-display-name.ts +11 -3
- package/src/hooks/use-file-upload.ts +51 -0
- package/src/hooks/use-import.ts +5 -5
- package/src/hooks/use-money-locale.ts +14 -0
- package/src/index.ts +8 -3
- package/src/lib/currencies.ts +32 -13
- package/src/lib/form-mappers.ts +25 -5
- package/src/lib/formatters.ts +34 -4
- package/src/lib/fulfillment-items.test.ts +2 -1
- package/src/lib/fulfillment-items.ts +9 -3
- package/src/lib/i18n.ts +4 -4
- package/src/lib/iso-codes.ts +47 -0
- package/src/lib/money.test.ts +63 -0
- package/src/lib/money.ts +99 -0
- package/src/{products → lib}/normalize-money.ts +2 -2
- package/src/lib/permissions.ts +2 -0
- package/src/lib/return-refunds.test.ts +62 -0
- package/src/lib/return-refunds.ts +70 -0
- package/src/lib/route-registry.ts +58 -15
- package/src/lib/settings-nav-registry.test.ts +34 -0
- package/src/lib/settings-nav-registry.ts +21 -4
- package/src/lib/slot-registry.ts +10 -0
- package/src/lib/storefront.ts +2 -2
- package/src/locales/ar.json +7 -2
- package/src/locales/de.json +7 -2
- package/src/locales/en.json +7 -2
- package/src/locales/es.json +7 -2
- package/src/locales/fr.json +7 -2
- package/src/locales/pl.json +7 -2
- package/src/locales/zh-CN.json +7 -2
- package/src/plugin.ts +5 -5
- package/src/products/bulk-variants-dialog.tsx +2 -8
- package/src/products/inventory-section.tsx +2 -2
- package/src/products/product-bulk-price-editor.tsx +18 -29
- package/src/products/product-form-cards.tsx +12 -8
- package/src/products/product-form-mapping.ts +15 -11
- package/src/products/product-schema.ts +6 -8
- package/src/products/product-types.ts +5 -3
- package/src/products/use-product-form-data.ts +0 -28
- package/src/products/variants-options-builder.tsx +30 -27
- package/src/products/variants-section.tsx +31 -25
- package/src/providers/store-provider.tsx +9 -9
- package/src/schemas/stock-location.ts +3 -2
- package/src/tables/package-types.tsx +1 -1
- package/src/tables/stock-locations.tsx +1 -1
- package/src/vite/discover.ts +19 -2
- package/src/hooks/use-direct-upload.ts +0 -117
- /package/src/{products → lib}/currency-parts.ts +0 -0
- /package/src/{products → lib}/normalize-money.test.ts +0 -0
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
import { createPortal } from 'react-dom'
|
|
57
57
|
import { useTranslation } from 'react-i18next'
|
|
58
58
|
import { z } from 'zod/v4'
|
|
59
|
+
import { runtimeListParams } from '../api-client'
|
|
59
60
|
import { useAuth } from '../hooks/use-auth'
|
|
60
61
|
import { useVisualViewportOffset } from '../hooks/use-visual-viewport-offset'
|
|
61
62
|
import { filtersToRansack } from '../lib/filters-to-ransack'
|
|
@@ -176,8 +177,11 @@ interface ResourceTableProps<T> {
|
|
|
176
177
|
* `[webhookDeliveriesTableKey(endpointId)]`).
|
|
177
178
|
*/
|
|
178
179
|
queryKey: string | readonly unknown[]
|
|
179
|
-
/**
|
|
180
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Function that calls the SDK to fetch data. Its params are built at runtime
|
|
182
|
+
* from the table's columns and filters, so any typed SDK list method fits.
|
|
183
|
+
*/
|
|
184
|
+
queryFn: (params: never) => Promise<{ data: T[]; meta: PaginationMeta }>
|
|
181
185
|
/** Current search params from the route */
|
|
182
186
|
searchParams: ResourceSearch
|
|
183
187
|
/** Title displayed in the toolbar header. Overrides the table definition's title. */
|
|
@@ -401,7 +405,7 @@ export function ResourceTable<T extends Record<string, any>>({
|
|
|
401
405
|
|
|
402
406
|
Object.assign(params, filtersToRansack(filters as FilterRule[], allColumns))
|
|
403
407
|
|
|
404
|
-
return queryFn(params)
|
|
408
|
+
return queryFn(runtimeListParams(params))
|
|
405
409
|
},
|
|
406
410
|
enabled: !!token,
|
|
407
411
|
})
|
|
@@ -1177,17 +1177,19 @@ function InlineValueList({
|
|
|
1177
1177
|
}))
|
|
1178
1178
|
}, [isCurrency, currencies, isTags, tags.data, resources.data, column.filterResource, query])
|
|
1179
1179
|
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1180
|
+
// Values already selected when the list opens ride at the top, so an active
|
|
1181
|
+
// filter is visible without scrolling. The set is frozen at mount: ticking a
|
|
1182
|
+
// box must not move rows under the pointer.
|
|
1183
|
+
const [pinned] = useState(() => selected)
|
|
1182
1184
|
const ordered = useMemo(() => {
|
|
1183
|
-
const chosen = options.filter((option) =>
|
|
1184
|
-
const rest = options.filter((option) => !
|
|
1185
|
+
const chosen = options.filter((option) => pinned.includes(option.value))
|
|
1186
|
+
const rest = options.filter((option) => !pinned.includes(option.value))
|
|
1185
1187
|
// Not truncated: a list cut at ten looks like a list of ten, and a
|
|
1186
1188
|
// vocabulary of forty tags would silently lose thirty with nothing on
|
|
1187
1189
|
// screen to say so. The container scrolls instead, so the scrollbar
|
|
1188
1190
|
// reports how much more there is — and searching narrows it.
|
|
1189
1191
|
return [...chosen, ...rest]
|
|
1190
|
-
}, [options,
|
|
1192
|
+
}, [options, pinned])
|
|
1191
1193
|
|
|
1192
1194
|
if (!isCurrency && (tags.isLoading || resources.isLoading)) {
|
|
1193
1195
|
return (
|
|
@@ -1507,6 +1509,7 @@ function FilterPanel({
|
|
|
1507
1509
|
behind the panel is already showing the result. */}
|
|
1508
1510
|
{field && multi && column && (
|
|
1509
1511
|
<InlineValueList
|
|
1512
|
+
key={column.key}
|
|
1510
1513
|
column={column}
|
|
1511
1514
|
tenantId={tenantId}
|
|
1512
1515
|
query={query}
|
|
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
|
|
|
4
4
|
/**
|
|
5
5
|
* Resolves a code to its display name via `Intl.DisplayNames`, localized to a
|
|
6
6
|
* given UI language. Returns `undefined` when the runtime lacks coverage so
|
|
7
|
-
* callers can apply their own fallback (
|
|
7
|
+
* callers can apply their own fallback (usually the raw code).
|
|
8
8
|
*
|
|
9
9
|
* Standalone (non-hook) variant for module-level use outside React.
|
|
10
10
|
*/
|
|
@@ -25,7 +25,7 @@ export function intlDisplayName(
|
|
|
25
25
|
* type (`currency`, `language`, `region`, …), localized to the **admin UI
|
|
26
26
|
* language** (`i18n.language`) — NOT the store's content locale — so names
|
|
27
27
|
* match the rest of the dashboard chrome. Returns `undefined` on a miss; the
|
|
28
|
-
* caller supplies the fallback (e.g. `?? code
|
|
28
|
+
* caller supplies the fallback (e.g. `?? code`).
|
|
29
29
|
*/
|
|
30
30
|
export function useDisplayName(type: Intl.DisplayNamesType) {
|
|
31
31
|
const { i18n: instance } = useTranslation()
|
|
@@ -37,6 +37,14 @@ export function useDisplayName(type: Intl.DisplayNamesType) {
|
|
|
37
37
|
} catch {
|
|
38
38
|
formatter = undefined
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
// `of` throws a RangeError for a malformed code (a legacy `english`), so
|
|
41
|
+
// such a code reads as unnamed rather than breaking the page.
|
|
42
|
+
return (code: string) => {
|
|
43
|
+
try {
|
|
44
|
+
return formatter?.of(code)
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
}
|
|
41
49
|
}, [locale, type])
|
|
42
50
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useMutation } from '@tanstack/react-query'
|
|
2
|
+
import { getApiClient } from '../api-client'
|
|
3
|
+
|
|
4
|
+
interface UploadResult {
|
|
5
|
+
signedId: string
|
|
6
|
+
previewUrl: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Active Storage's Disk service issues an absolute URL against the Rails
|
|
10
|
+
// origin (e.g. http://localhost:3000/rails/active_storage/disk/...). In dev the
|
|
11
|
+
// SPA runs on a different port and that controller doesn't speak CORS, so the
|
|
12
|
+
// browser blocks the cross-origin PUT before any response ("Failed to fetch").
|
|
13
|
+
// When the URL points at a `/rails/active_storage/` path we drop the host so
|
|
14
|
+
// the request goes through Vite's `/rails` proxy and stays same-origin.
|
|
15
|
+
// Production storage URLs are untouched.
|
|
16
|
+
function sameOriginIfRailsDisk(url: string): string {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = new URL(url)
|
|
19
|
+
if (parsed.pathname.startsWith('/rails/active_storage/')) {
|
|
20
|
+
return parsed.pathname + parsed.search
|
|
21
|
+
}
|
|
22
|
+
return url
|
|
23
|
+
} catch {
|
|
24
|
+
return url
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface FileUploadOptions {
|
|
29
|
+
/**
|
|
30
|
+
* `private` for files only ever served through an authorized link
|
|
31
|
+
* (documents, imports, digital products). Attaching a file never moves it
|
|
32
|
+
* between storage services, so this has to be decided up front.
|
|
33
|
+
*/
|
|
34
|
+
visibility?: 'public' | 'private'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useFileUpload(options: FileUploadOptions = {}) {
|
|
38
|
+
return useMutation({
|
|
39
|
+
mutationFn: async (file: File): Promise<UploadResult> => {
|
|
40
|
+
const uploadFile = getApiClient().uploadFile
|
|
41
|
+
if (!uploadFile) throw new Error('This panel cannot upload files.')
|
|
42
|
+
|
|
43
|
+
const { signed_id } = await uploadFile(file, {
|
|
44
|
+
visibility: options.visibility ?? 'public',
|
|
45
|
+
resolveUploadUrl: sameOriginIfRailsDisk,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return { signedId: signed_id, previewUrl: URL.createObjectURL(file) }
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
}
|
package/src/hooks/use-import.ts
CHANGED
|
@@ -90,12 +90,12 @@ function imports() {
|
|
|
90
90
|
/** Create-time parameters beyond the ones every import shares. */
|
|
91
91
|
export type ImportCreateExtras = Omit<
|
|
92
92
|
PanelImportCreateParams,
|
|
93
|
-
'type' | '
|
|
93
|
+
'type' | 'attachment_signed_id' | 'delimiter' | 'results_url'
|
|
94
94
|
>
|
|
95
95
|
|
|
96
96
|
export interface CreateImportInput {
|
|
97
97
|
type: string
|
|
98
|
-
/**
|
|
98
|
+
/** The `signed_id` of the already uploaded CSV (see `FileUploadField`). */
|
|
99
99
|
signedId: string
|
|
100
100
|
preferredDelimiter?: PanelImportDelimiter
|
|
101
101
|
/**
|
|
@@ -111,7 +111,7 @@ export interface CreateImportInput {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
|
-
* Creates the import from
|
|
114
|
+
* Creates the import from an uploaded CSV; the response is in the
|
|
115
115
|
* `mapping` state and carries the mapping payload.
|
|
116
116
|
*/
|
|
117
117
|
export function useCreateImport() {
|
|
@@ -128,8 +128,8 @@ export function useCreateImport() {
|
|
|
128
128
|
imports().create({
|
|
129
129
|
...params,
|
|
130
130
|
type,
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
attachment_signed_id: signedId,
|
|
132
|
+
delimiter: preferredDelimiter,
|
|
133
133
|
// The import-done email deep-links back to the wizard (`?import=<id>`
|
|
134
134
|
// appended server-side). Only honored when this origin is on the
|
|
135
135
|
// store's allowed-origins list.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The locale money is shown and typed in: the dashboard language of the person
|
|
5
|
+
* using it, whatever the currency or market. Someone who writes `1.234,56`
|
|
6
|
+
* types that for dollars too, and the field reads it back the same way it
|
|
7
|
+
* displayed it. The API only ever receives canonical `"1234.56"`.
|
|
8
|
+
*
|
|
9
|
+
* @returns a BCP 47 locale such as `de` or `en`
|
|
10
|
+
*/
|
|
11
|
+
export function useMoneyLocale(): string {
|
|
12
|
+
const { i18n } = useTranslation()
|
|
13
|
+
return i18n.language || 'en'
|
|
14
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ export * from './components/locale-select'
|
|
|
33
33
|
export * from './components/market-combobox'
|
|
34
34
|
export * from './components/media-picker-sheet'
|
|
35
35
|
export * from './components/mobile-top-bar'
|
|
36
|
+
export * from './components/money'
|
|
36
37
|
export * from './components/nav-main'
|
|
37
38
|
export * from './components/page-header'
|
|
38
39
|
export * from './components/page-tabs'
|
|
@@ -62,12 +63,13 @@ export * from './hooks/use-auto-collapse-sidebar'
|
|
|
62
63
|
export * from './hooks/use-command-palette'
|
|
63
64
|
export * from './hooks/use-countries'
|
|
64
65
|
export * from './hooks/use-custom-fields'
|
|
65
|
-
export * from './hooks/use-direct-upload'
|
|
66
66
|
export * from './hooks/use-display-name'
|
|
67
67
|
export * from './hooks/use-export'
|
|
68
|
+
export * from './hooks/use-file-upload'
|
|
68
69
|
export * from './hooks/use-global-search'
|
|
69
70
|
export * from './hooks/use-host-form'
|
|
70
71
|
export * from './hooks/use-import'
|
|
72
|
+
export * from './hooks/use-money-locale'
|
|
71
73
|
export * from './hooks/use-package-types'
|
|
72
74
|
export * from './hooks/use-resource-mutation'
|
|
73
75
|
export * from './hooks/use-stock-locations'
|
|
@@ -79,6 +81,7 @@ export * from './lib/create-action-registry'
|
|
|
79
81
|
// Registries — pluggable extension points (nav, route, slot, table,
|
|
80
82
|
// settings-nav, form fields, custom field components)
|
|
81
83
|
// ---------------------------------------------------------------------------
|
|
84
|
+
export * from './lib/currency-parts'
|
|
82
85
|
export * from './lib/custom-field-components'
|
|
83
86
|
export * from './lib/date-presets'
|
|
84
87
|
export * from './lib/delivery-form'
|
|
@@ -98,12 +101,16 @@ export * from './lib/fulfillment-items'
|
|
|
98
101
|
// `i18n.addResourceBundle`) get it via the named re-export.
|
|
99
102
|
export * from './lib/i18n'
|
|
100
103
|
export * from './lib/import-types'
|
|
104
|
+
export * from './lib/iso-codes'
|
|
101
105
|
export * from './lib/label-upload-form'
|
|
106
|
+
export * from './lib/money'
|
|
102
107
|
export * from './lib/nav-registry'
|
|
108
|
+
export * from './lib/normalize-money'
|
|
103
109
|
export * from './lib/permissions'
|
|
104
110
|
export * from './lib/progress'
|
|
105
111
|
export * from './lib/query-client'
|
|
106
112
|
export * from './lib/query-keys'
|
|
113
|
+
export * from './lib/return-refunds'
|
|
107
114
|
export * from './lib/route-registry'
|
|
108
115
|
export * from './lib/search-registry'
|
|
109
116
|
export * from './lib/settings-nav-registry'
|
|
@@ -118,9 +125,7 @@ export * from './pages/stock-locations'
|
|
|
118
125
|
// Plugin facade — re-exported for convenience; same API as `/plugin` subpath
|
|
119
126
|
// ---------------------------------------------------------------------------
|
|
120
127
|
export * from './plugin'
|
|
121
|
-
export * from './products/currency-parts'
|
|
122
128
|
export * from './products/normalize-customs'
|
|
123
|
-
export * from './products/normalize-money'
|
|
124
129
|
export * from './products/normalize-quantity'
|
|
125
130
|
export * from './products/product-form-cards'
|
|
126
131
|
export * from './products/product-form-mapping'
|
package/src/lib/currencies.ts
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
|
+
import { CurrencyCode } from '@spree/admin-sdk'
|
|
2
|
+
|
|
3
|
+
// Replaced currencies Spree still accepts so orders priced in them stay
|
|
4
|
+
// valid, but which no merchant should pick for new pricing.
|
|
5
|
+
const RETIRED_CURRENCY_CODES: ReadonlySet<string> = new Set([
|
|
6
|
+
'BYR',
|
|
7
|
+
'CUC',
|
|
8
|
+
'EEK',
|
|
9
|
+
'HRK',
|
|
10
|
+
'LTL',
|
|
11
|
+
'LVL',
|
|
12
|
+
'MRO',
|
|
13
|
+
'MTL',
|
|
14
|
+
'SKK',
|
|
15
|
+
'SLL',
|
|
16
|
+
'STD',
|
|
17
|
+
'TMM',
|
|
18
|
+
'VEF',
|
|
19
|
+
'ZMK',
|
|
20
|
+
'ZWD',
|
|
21
|
+
'ZWL',
|
|
22
|
+
'ZWN',
|
|
23
|
+
'ZWR',
|
|
24
|
+
])
|
|
25
|
+
|
|
1
26
|
/**
|
|
2
|
-
* Every
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* runtimes without `Intl.supportedValuesOf`, so callers fall back to
|
|
7
|
-
* `supported_currencies`.
|
|
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.
|
|
8
31
|
*/
|
|
9
|
-
export const ALL_CURRENCY_CODES:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} catch {
|
|
13
|
-
return []
|
|
14
|
-
}
|
|
15
|
-
})()
|
|
32
|
+
export const ALL_CURRENCY_CODES: readonly CurrencyCode[] = Object.values(CurrencyCode).filter(
|
|
33
|
+
(code) => !RETIRED_CURRENCY_CODES.has(code),
|
|
34
|
+
)
|
package/src/lib/form-mappers.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CountryCode } from '@spree/admin-sdk'
|
|
1
2
|
import type { PanelAccountParams } from '../api-client'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -91,7 +92,7 @@ export function editableAddressToStockLocationParams(values: {
|
|
|
91
92
|
address2?: string | null
|
|
92
93
|
city?: string | null
|
|
93
94
|
postal_code?: string | null
|
|
94
|
-
country_code?:
|
|
95
|
+
country_code?: CountryCode | null
|
|
95
96
|
state_code?: string | null
|
|
96
97
|
phone?: string | null
|
|
97
98
|
}) {
|
|
@@ -135,12 +136,31 @@ export function accountFormToParams(values: {
|
|
|
135
136
|
first_name: values.first_name ?? undefined,
|
|
136
137
|
last_name: values.last_name ?? undefined,
|
|
137
138
|
selected_locale: blankToUndefined(values.selected_locale),
|
|
138
|
-
...
|
|
139
|
+
...signedIdParam('avatar', values.avatar_signed_id, values.avatar_cleared),
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
/**
|
|
144
|
+
* The write for one attachment slot, under the API's `<slot>_signed_id` name:
|
|
145
|
+
* a fresh upload sends its signed id, an explicit clear sends `null` to remove
|
|
146
|
+
* the file, and an untouched field is left out entirely.
|
|
147
|
+
*/
|
|
148
|
+
export function signedIdParam<Slot extends string>(
|
|
149
|
+
slot: Slot,
|
|
150
|
+
signedId: string | null | undefined,
|
|
151
|
+
): Partial<Record<`${Slot}_signed_id`, string>>
|
|
152
|
+
export function signedIdParam<Slot extends string>(
|
|
153
|
+
slot: Slot,
|
|
154
|
+
signedId: string | null | undefined,
|
|
155
|
+
cleared: boolean | undefined,
|
|
156
|
+
): Partial<Record<`${Slot}_signed_id`, string | null>>
|
|
157
|
+
export function signedIdParam<Slot extends string>(
|
|
158
|
+
slot: Slot,
|
|
159
|
+
signedId: string | null | undefined,
|
|
160
|
+
cleared?: boolean,
|
|
161
|
+
): Partial<Record<`${Slot}_signed_id`, string | null>> {
|
|
162
|
+
const key = `${slot}_signed_id` as `${Slot}_signed_id`
|
|
163
|
+
if (signedId) return { [key]: signedId } as Record<`${Slot}_signed_id`, string>
|
|
164
|
+
if (cleared) return { [key]: null } as Record<`${Slot}_signed_id`, null>
|
|
145
165
|
return {}
|
|
146
166
|
}
|
package/src/lib/formatters.ts
CHANGED
|
@@ -1,10 +1,40 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { formatMoney, type Price } from '@spree/admin-sdk'
|
|
2
2
|
import { parseISO } from 'date-fns'
|
|
3
3
|
import { formatInTimeZone } from 'date-fns-tz'
|
|
4
|
+
import i18n from 'i18next'
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
export { formatMoney }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A price's amount in its currency, in the admin's own number format. A unit
|
|
10
|
+
* price keeps a fraction of a cent. `display_amount` is accepted and ignored
|
|
11
|
+
* so callers built against the old shape keep compiling.
|
|
12
|
+
*/
|
|
13
|
+
export function formatPrice(
|
|
14
|
+
price: (Pick<Price, 'amount' | 'currency'> & { display_amount?: string | null }) | null,
|
|
15
|
+
locale: string = i18n.language || 'en',
|
|
16
|
+
) {
|
|
17
|
+
if (!price?.amount || !price.currency) return '—'
|
|
18
|
+
return formatMoney(price.amount, price.currency, locale, { unitPrice: true })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
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.
|
|
25
|
+
*/
|
|
26
|
+
export function formatAmount(
|
|
27
|
+
amount: string | null | undefined,
|
|
28
|
+
currency: string | null | undefined,
|
|
29
|
+
locale?: string,
|
|
30
|
+
fallback = '—',
|
|
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
|
+
}
|
|
8
38
|
}
|
|
9
39
|
|
|
10
40
|
export function formatStoreDateTime(iso: string, timezone: string) {
|
|
@@ -14,7 +14,8 @@ function lineItem(overrides: Partial<GroupableLineItem> = {}): GroupableLineItem
|
|
|
14
14
|
options_text: 'Blue / M',
|
|
15
15
|
quantity: 2,
|
|
16
16
|
thumbnail_url: 'https://example.test/shirt.jpg',
|
|
17
|
-
|
|
17
|
+
price: '10.00',
|
|
18
|
+
currency: 'USD',
|
|
18
19
|
...overrides,
|
|
19
20
|
}
|
|
20
21
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import i18n from 'i18next'
|
|
2
|
+
import { formatMoney } from './formatters'
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Which line items sit in which fulfillment, and which units nobody has
|
|
3
6
|
* claimed yet. The order payload describes the two halves separately —
|
|
@@ -14,8 +17,8 @@ export interface GroupableLineItem {
|
|
|
14
17
|
options_text?: string | null
|
|
15
18
|
quantity: number
|
|
16
19
|
thumbnail_url?: string | null
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
price?: string | null
|
|
21
|
+
currency?: string | null
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
/** The fulfillment-item fields the grouping reads. */
|
|
@@ -130,7 +133,10 @@ function buildRow(
|
|
|
130
133
|
name: copy?.name ?? fallback?.name ?? '',
|
|
131
134
|
optionsText: copy?.options_text ?? fallback?.options_text ?? null,
|
|
132
135
|
thumbnailUrl: copy?.thumbnail_url ?? null,
|
|
133
|
-
displayPrice:
|
|
136
|
+
displayPrice:
|
|
137
|
+
copy?.price && copy.currency
|
|
138
|
+
? formatMoney(copy.price, copy.currency, i18n.language || 'en', { unitPrice: true })
|
|
139
|
+
: null,
|
|
134
140
|
quantity,
|
|
135
141
|
}
|
|
136
142
|
}
|
package/src/lib/i18n.ts
CHANGED
|
@@ -10,7 +10,7 @@ import en from '../locales/en.json'
|
|
|
10
10
|
// made anywhere is honored on the next paint.
|
|
11
11
|
export const ADMIN_LOCALE_STORAGE_KEY = 'spree-admin-locale'
|
|
12
12
|
|
|
13
|
-
// Marks the locale key as auto-applied from a store's `
|
|
13
|
+
// Marks the locale key as auto-applied from a store's `admin_locale`
|
|
14
14
|
// (vs an explicit choice). Holds the storeId whose default is currently in
|
|
15
15
|
// effect, so crossing into a different store can re-apply that store's default
|
|
16
16
|
// while a genuine choice (switcher/profile/login) still wins everywhere. Mirrors
|
|
@@ -69,10 +69,10 @@ function readStoredLocale(): string {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
* Reconcile the admin UI language against a store's `
|
|
72
|
+
* Reconcile the admin UI language against a store's `admin_locale`
|
|
73
73
|
* fallback (legacy `base_controller` parity), for an admin with no genuine
|
|
74
74
|
* personal choice. Encodes the precedence:
|
|
75
|
-
* account selected_locale > genuine stored choice > store
|
|
75
|
+
* account selected_locale > genuine stored choice > store admin_locale > 'en'
|
|
76
76
|
*
|
|
77
77
|
* No-op when a higher tier owns the language (an account locale, or a genuine
|
|
78
78
|
* stored choice — locale key set without an auto-marker). Otherwise drives the
|
|
@@ -83,7 +83,7 @@ function readStoredLocale(): string {
|
|
|
83
83
|
* - already matching → nothing changes.
|
|
84
84
|
* Reloads (via the boot path) only when the displayed language actually changes.
|
|
85
85
|
*
|
|
86
|
-
* @param code the store's current `
|
|
86
|
+
* @param code the store's current `admin_locale`
|
|
87
87
|
* @param storeId the store being entered
|
|
88
88
|
* @param accountLocale the account's `selected_locale` (null when unset)
|
|
89
89
|
* @param supported locale codes the dashboard ships a bundle for
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CountryCode, CurrencyCode, LocaleCode } from '@spree/admin-sdk'
|
|
2
|
+
import i18n from 'i18next'
|
|
3
|
+
import { z } from 'zod/v4'
|
|
4
|
+
|
|
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
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A required country field. A form that allows leaving it blank widens it
|
|
33
|
+
* with `.or(z.literal(''))` and sends `value || null` (or `|| undefined`).
|
|
34
|
+
*/
|
|
35
|
+
export const countryCodeSchema = z.custom<CountryCode>(isCountryCode, {
|
|
36
|
+
error: () => i18n.t('admin.validation.country_required'),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/** A required currency field. See `countryCodeSchema`. */
|
|
40
|
+
export const currencyCodeSchema = z.custom<CurrencyCode>(isCurrencyCode, {
|
|
41
|
+
error: () => i18n.t('admin.validation.currency_required'),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/** A required locale field. See `countryCodeSchema`. */
|
|
45
|
+
export const localeCodeSchema = z.custom<LocaleCode>(isLocaleCode, {
|
|
46
|
+
error: () => i18n.t('admin.validation.locale_required'),
|
|
47
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
fractionToPercent,
|
|
4
|
+
isPositiveMoney,
|
|
5
|
+
percentOf,
|
|
6
|
+
percentToFraction,
|
|
7
|
+
prorateMoney,
|
|
8
|
+
shiftDecimalPoint,
|
|
9
|
+
} from './money'
|
|
10
|
+
|
|
11
|
+
describe('shiftDecimalPoint', () => {
|
|
12
|
+
it('moves the point exactly, without float noise', () => {
|
|
13
|
+
expect(shiftDecimalPoint('0.077', 2)).toBe('7.7')
|
|
14
|
+
expect(shiftDecimalPoint('7.7', -2)).toBe('0.077')
|
|
15
|
+
expect(shiftDecimalPoint('0.07', 2)).toBe('7')
|
|
16
|
+
expect(shiftDecimalPoint('1', 2)).toBe('100')
|
|
17
|
+
expect(shiftDecimalPoint('100', -2)).toBe('1')
|
|
18
|
+
expect(shiftDecimalPoint('-0.5', 2)).toBe('-50')
|
|
19
|
+
expect(shiftDecimalPoint('0', -2)).toBe('0')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('leaves a value that is not a decimal string alone', () => {
|
|
23
|
+
expect(shiftDecimalPoint('abc', 2)).toBe('abc')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('fractionToPercent / percentToFraction', () => {
|
|
28
|
+
it('round-trips and keeps blanks blank', () => {
|
|
29
|
+
expect(fractionToPercent('0.23')).toBe('23')
|
|
30
|
+
expect(percentToFraction('23')).toBe('0.23')
|
|
31
|
+
expect(percentToFraction('7.25')).toBe('0.0725')
|
|
32
|
+
expect(fractionToPercent(null)).toBe('')
|
|
33
|
+
expect(percentToFraction('')).toBe('')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('percentOf', () => {
|
|
38
|
+
it('rounds to the currency decimals', () => {
|
|
39
|
+
expect(percentOf('19.99', '10', 'USD')).toBe('2.00')
|
|
40
|
+
expect(percentOf('100', '12.5', 'USD')).toBe('12.50')
|
|
41
|
+
expect(percentOf('1000', '0.05', 'JPY')).toBe('1')
|
|
42
|
+
expect(percentOf('1.005', '50', 'KWD')).toBe('0.503')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('isPositiveMoney', () => {
|
|
47
|
+
it('is true only for an amount above zero', () => {
|
|
48
|
+
expect(isPositiveMoney('0.01')).toBe(true)
|
|
49
|
+
expect(isPositiveMoney('0.00')).toBe(false)
|
|
50
|
+
expect(isPositiveMoney('-1')).toBe(false)
|
|
51
|
+
expect(isPositiveMoney(null)).toBe(false)
|
|
52
|
+
expect(isPositiveMoney('abc')).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('prorateMoney', () => {
|
|
57
|
+
it('shares an amount across units, rounded to the currency', () => {
|
|
58
|
+
expect(prorateMoney('10.00', 1, 3, 'USD')).toBe('3.33')
|
|
59
|
+
expect(prorateMoney('10.00', 2, 3, 'USD')).toBe('6.67')
|
|
60
|
+
expect(prorateMoney('10.00', 3, 3, 'USD')).toBe('10.00')
|
|
61
|
+
expect(prorateMoney('1000', 1, 3, 'JPY')).toBe('333')
|
|
62
|
+
})
|
|
63
|
+
})
|