@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
|
@@ -75,7 +75,7 @@ interface ImportButtonProps {
|
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* Toolbar entry point for CSV imports: opens a Sheet with the upload form
|
|
78
|
-
* (file, delimiter, template download). The CSV
|
|
78
|
+
* (file, delimiter, template download). The CSV uploads on pick via
|
|
79
79
|
* `FileUploadField`; submitting creates the import from the signed blob id
|
|
80
80
|
* and hands it to `onCreated`.
|
|
81
81
|
*/
|
|
@@ -190,6 +190,9 @@ export function ImportButton({
|
|
|
190
190
|
// Windows browsers report `.csv` as `application/vnd.ms-excel`,
|
|
191
191
|
// which the server's content-type validation rejects.
|
|
192
192
|
transformFile={(picked) => new File([picked], picked.name, { type: 'text/csv' })}
|
|
193
|
+
// Imports are kept on private storage: a CSV can carry
|
|
194
|
+
// customer data.
|
|
195
|
+
private
|
|
193
196
|
/>
|
|
194
197
|
|
|
195
198
|
<Field>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
+
Alert,
|
|
3
|
+
AlertDescription,
|
|
2
4
|
Badge,
|
|
3
5
|
Button,
|
|
4
6
|
Card,
|
|
@@ -33,6 +35,7 @@ import {
|
|
|
33
35
|
CheckCircle2Icon,
|
|
34
36
|
ChevronLeftIcon,
|
|
35
37
|
ChevronRightIcon,
|
|
38
|
+
CircleAlertIcon,
|
|
36
39
|
DownloadIcon,
|
|
37
40
|
RotateCcwIcon,
|
|
38
41
|
XIcon,
|
|
@@ -107,13 +110,7 @@ export function ImportWizardDialog({ importId, onClose, onViewRecords }: ImportW
|
|
|
107
110
|
}}
|
|
108
111
|
modal
|
|
109
112
|
>
|
|
110
|
-
<DialogContent
|
|
111
|
-
// Edge-to-edge minus a 3-unit gutter — see BulkPriceEditorDialog for
|
|
112
|
-
// why every inset/translate/max is overridden.
|
|
113
|
-
className="!inset-3 !w-auto !max-w-none !translate-x-0 !translate-y-0 flex flex-col p-0"
|
|
114
|
-
style={{ maxHeight: 'none' }}
|
|
115
|
-
showCloseButton={false}
|
|
116
|
-
>
|
|
113
|
+
<DialogContent fullScreen showCloseButton={false}>
|
|
117
114
|
{importId && (
|
|
118
115
|
<ImportWizard
|
|
119
116
|
importId={importId}
|
|
@@ -472,10 +469,10 @@ function MappingStep({
|
|
|
472
469
|
</div>
|
|
473
470
|
|
|
474
471
|
{error && (
|
|
475
|
-
<
|
|
476
|
-
<
|
|
477
|
-
<
|
|
478
|
-
</
|
|
472
|
+
<Alert variant="destructive">
|
|
473
|
+
<CircleAlertIcon />
|
|
474
|
+
<AlertDescription>{error.message}</AlertDescription>
|
|
475
|
+
</Alert>
|
|
479
476
|
)}
|
|
480
477
|
</CardContent>
|
|
481
478
|
</Card>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SidebarTrigger, Skeleton } from '@spree/dashboard-ui'
|
|
2
2
|
import { SearchIcon } from '@spree/dashboard-ui/icons'
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
3
4
|
import { useTranslation } from 'react-i18next'
|
|
4
5
|
import { useOptionalCommandPalette } from '../hooks/use-command-palette'
|
|
5
6
|
import { useOptionalStore } from '../providers/store-provider'
|
|
@@ -23,9 +24,12 @@ export function MobileTopBar({
|
|
|
23
24
|
uiLocales = [],
|
|
24
25
|
onEditProfile,
|
|
25
26
|
title,
|
|
27
|
+
notifications,
|
|
26
28
|
}: {
|
|
27
29
|
uiLocales?: ReadonlyArray<{ code: string; name: string }>
|
|
28
30
|
onEditProfile?: () => void
|
|
31
|
+
/** Shown before the account menu, such as a notifications bell. */
|
|
32
|
+
notifications?: ReactNode
|
|
29
33
|
/** Name shown beside the controls. Defaults to the store's, for panels that have one. */
|
|
30
34
|
title?: string
|
|
31
35
|
} = {}) {
|
|
@@ -69,6 +73,8 @@ export function MobileTopBar({
|
|
|
69
73
|
</button>
|
|
70
74
|
)}
|
|
71
75
|
|
|
76
|
+
{notifications}
|
|
77
|
+
|
|
72
78
|
<SidebarUser variant="bar" uiLocales={uiLocales} onEditProfile={onEditProfile} />
|
|
73
79
|
</div>
|
|
74
80
|
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useMoneyLocale } from '../hooks/use-money-locale'
|
|
2
|
+
import { formatAmount } from '../lib/formatters'
|
|
3
|
+
import { useOptionalStore } from '../providers/store-provider'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An API amount shown in the admin's own number format. Without a currency it
|
|
7
|
+
* is shown in the store's default currency, for figures the API states in it
|
|
8
|
+
* (a customer's total spent); the seller panel, which mounts no store, always
|
|
9
|
+
* passes one.
|
|
10
|
+
*/
|
|
11
|
+
export function Money({
|
|
12
|
+
amount,
|
|
13
|
+
currency,
|
|
14
|
+
fallback,
|
|
15
|
+
}: {
|
|
16
|
+
amount: string | null | undefined
|
|
17
|
+
currency?: string | null
|
|
18
|
+
fallback?: string
|
|
19
|
+
}) {
|
|
20
|
+
const locale = useMoneyLocale()
|
|
21
|
+
const store = useOptionalStore()
|
|
22
|
+
return <>{formatAmount(amount, currency ?? store?.defaultCurrency, locale, fallback)}</>
|
|
23
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CurrencyCode } from '@spree/admin-sdk'
|
|
2
|
+
import {
|
|
3
|
+
compareMoney,
|
|
4
|
+
isDecimalString,
|
|
5
|
+
type PreferencePropertySchema,
|
|
6
|
+
type PreferenceSchema,
|
|
7
|
+
} from '@spree/admin-sdk'
|
|
2
8
|
import {
|
|
3
9
|
Button,
|
|
4
10
|
Field,
|
|
@@ -6,6 +12,10 @@ import {
|
|
|
6
12
|
FieldGroup,
|
|
7
13
|
FieldLabel,
|
|
8
14
|
Input,
|
|
15
|
+
InputGroup,
|
|
16
|
+
InputGroupAddon,
|
|
17
|
+
InputGroupInput,
|
|
18
|
+
InputGroupText,
|
|
9
19
|
SecretInput,
|
|
10
20
|
Select,
|
|
11
21
|
SelectContent,
|
|
@@ -18,29 +28,111 @@ import {
|
|
|
18
28
|
import { PlusIcon, TrashIcon } from '@spree/dashboard-ui/icons'
|
|
19
29
|
import { useId, useMemo, useState } from 'react'
|
|
20
30
|
import { useTranslation } from 'react-i18next'
|
|
31
|
+
import { useMoneyLocale } from '../hooks/use-money-locale'
|
|
32
|
+
import { currencyParts } from '../lib/currency-parts'
|
|
33
|
+
import { CountryCombobox, CountryMultiCombobox } from './country-combobox'
|
|
21
34
|
import { CurrencySelect } from './currency-select'
|
|
22
35
|
import { StoreDatePicker } from './store-date-picker'
|
|
23
36
|
|
|
37
|
+
/**
|
|
38
|
+
* One setting of a {@link PreferenceSchema}, flattened into what a form
|
|
39
|
+
* needs to pick its widget.
|
|
40
|
+
*/
|
|
41
|
+
export interface PreferenceFieldDef {
|
|
42
|
+
key: string
|
|
43
|
+
/** `string`, `text`, `password`, `boolean`, `integer`, `decimal`, `date`, `datetime`, `array`, `hash` or `object`. */
|
|
44
|
+
type: string
|
|
45
|
+
/** The schema's `format` (`money`, `currency`, `iso-country`, …), on the value or, for a list, on its items. */
|
|
46
|
+
format?: string
|
|
47
|
+
default: unknown
|
|
48
|
+
choices?: string[]
|
|
49
|
+
/** Labels the declaration gave its choices, for values that cannot name themselves (an endpoint URL). */
|
|
50
|
+
choiceLabels?: Record<string, string>
|
|
51
|
+
/** Whether the setting may be left empty (it reads back as null). */
|
|
52
|
+
nullable: boolean
|
|
53
|
+
/** The property's own JSON Schema, for widgets that need more (list items, object properties). */
|
|
54
|
+
schema: PreferencePropertySchema
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DECIMAL_PATTERN = '^-?\\d+(\\.\\d+)?$'
|
|
58
|
+
const DECIMAL_REGEX = new RegExp(DECIMAL_PATTERN)
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The schema with only the settings `keep` accepts — for a form that edits
|
|
62
|
+
* the others with a control of its own.
|
|
63
|
+
*/
|
|
64
|
+
export function filterPreferenceSchema(
|
|
65
|
+
schema: PreferenceSchema,
|
|
66
|
+
keep: (key: string, property: PreferencePropertySchema) => boolean,
|
|
67
|
+
): PreferenceSchema {
|
|
68
|
+
return {
|
|
69
|
+
...schema,
|
|
70
|
+
properties: Object.fromEntries(
|
|
71
|
+
Object.entries(schema.properties).filter(([key, property]) => keep(key, property)),
|
|
72
|
+
),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fieldType(property: PreferencePropertySchema): string {
|
|
77
|
+
if (property['x-spree-secret']) return 'password'
|
|
78
|
+
const type = (Array.isArray(property.type) ? property.type : [property.type]).find(
|
|
79
|
+
(t) => t && t !== 'null',
|
|
80
|
+
)
|
|
81
|
+
if (type === 'string') {
|
|
82
|
+
if (property.format === 'date') return 'date'
|
|
83
|
+
if (property.format === 'date-time') return 'datetime'
|
|
84
|
+
if (property.format === 'money') return 'money'
|
|
85
|
+
if (property.pattern === DECIMAL_PATTERN) return 'decimal'
|
|
86
|
+
if (property['x-spree-widget'] === 'textarea') return 'text'
|
|
87
|
+
return 'string'
|
|
88
|
+
}
|
|
89
|
+
if (type === 'object') return property.properties ? 'object' : 'hash'
|
|
90
|
+
return type ?? 'string'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Flattens a preference schema into the fields a form renders, in the order
|
|
95
|
+
* the type declares them.
|
|
96
|
+
*/
|
|
97
|
+
export function preferenceFields(
|
|
98
|
+
schema: PreferenceSchema | null | undefined,
|
|
99
|
+
): PreferenceFieldDef[] {
|
|
100
|
+
return Object.entries(schema?.properties ?? {}).map(([key, property]) => ({
|
|
101
|
+
key,
|
|
102
|
+
type: fieldType(property),
|
|
103
|
+
format: property.format ?? property.items?.format,
|
|
104
|
+
default: property.default ?? null,
|
|
105
|
+
choices: property.enum?.filter((choice): choice is string => typeof choice === 'string'),
|
|
106
|
+
choiceLabels: property['x-spree-choice-labels'],
|
|
107
|
+
nullable: Array.isArray(property.type) && property.type.includes('null'),
|
|
108
|
+
schema: property,
|
|
109
|
+
}))
|
|
110
|
+
}
|
|
111
|
+
|
|
24
112
|
/**
|
|
25
113
|
* Hydrates a `preferences` hash with each field's default. Used to seed
|
|
26
114
|
* create-mode forms when the user picks a provider/calculator so the
|
|
27
115
|
* `<PreferencesForm>` shows sensible starting values instead of blanks.
|
|
28
116
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* own default is null/undefined — used by callers that know about runtime
|
|
117
|
+
* Secrets are always skipped — the schema never carries their defaults, and
|
|
118
|
+
* autofilling a password field is hostile UX. `contextDefaults` fills keys
|
|
119
|
+
* whose own default is absent — used by callers that know about runtime
|
|
33
120
|
* context the schema can't express (e.g. the store's default currency).
|
|
34
121
|
*/
|
|
35
122
|
export function defaultPreferences(
|
|
36
|
-
schema:
|
|
123
|
+
schema: PreferenceSchema | null | undefined,
|
|
37
124
|
contextDefaults: Record<string, unknown> = {},
|
|
38
125
|
): Record<string, unknown> {
|
|
39
126
|
const out: Record<string, unknown> = {}
|
|
40
|
-
for (const field of schema) {
|
|
127
|
+
for (const field of preferenceFields(schema)) {
|
|
41
128
|
if (field.type === 'password') continue
|
|
42
129
|
if (field.default !== null && field.default !== undefined) {
|
|
43
|
-
|
|
130
|
+
// A decimal default declared as a Ruby integer arrives as a JSON number,
|
|
131
|
+
// and the API refuses money sent back that way.
|
|
132
|
+
out[field.key] =
|
|
133
|
+
(field.type === 'decimal' || field.type === 'money') && typeof field.default === 'number'
|
|
134
|
+
? String(field.default)
|
|
135
|
+
: field.default
|
|
44
136
|
continue
|
|
45
137
|
}
|
|
46
138
|
const ctx = contextDefaults[field.key]
|
|
@@ -50,7 +142,7 @@ export function defaultPreferences(
|
|
|
50
142
|
}
|
|
51
143
|
|
|
52
144
|
interface PreferencesFormProps {
|
|
53
|
-
schema:
|
|
145
|
+
schema: PreferenceSchema | null | undefined
|
|
54
146
|
values: Record<string, unknown>
|
|
55
147
|
onChange: (next: Record<string, unknown>) => void
|
|
56
148
|
/** When true, replaces sensitive `password`-typed fields with a stub label. */
|
|
@@ -64,7 +156,7 @@ interface PreferencesFormProps {
|
|
|
64
156
|
* would let a currency the store does not trade in be saved, and a
|
|
65
157
|
* calculator set to one quotes no rate at all.
|
|
66
158
|
*/
|
|
67
|
-
currencyOptions?:
|
|
159
|
+
currencyOptions?: readonly CurrencyCode[]
|
|
68
160
|
/**
|
|
69
161
|
* Render date/datetime pickers inline instead of in a Popover portal.
|
|
70
162
|
* Set this when the form is inside a `<Sheet>` — Base UI's Popover fails
|
|
@@ -74,16 +166,16 @@ interface PreferencesFormProps {
|
|
|
74
166
|
}
|
|
75
167
|
|
|
76
168
|
/**
|
|
77
|
-
* Renders a generic configuration form from a
|
|
169
|
+
* Renders a generic configuration form from a type's preference schema.
|
|
78
170
|
* Used by the Payment Methods edit sheet and the Promotion editor's action
|
|
79
171
|
* and rule cards — anywhere we let admins tune a STI subclass's settings
|
|
80
172
|
* without hard-coding a per-subclass form.
|
|
81
173
|
*
|
|
82
174
|
* The schema itself is the source of truth; this component intentionally
|
|
83
175
|
* stays "dumb" — server-side validation surfaces errors back via the
|
|
84
|
-
* mutation hook's error handling.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
176
|
+
* mutation hook's error handling. The widget follows the schema: booleans
|
|
177
|
+
* are switches, currencies and countries pickers, amounts number inputs
|
|
178
|
+
* that send the exact decimal string, and anything else a text input.
|
|
87
179
|
*/
|
|
88
180
|
export function PreferencesForm({
|
|
89
181
|
schema,
|
|
@@ -94,7 +186,8 @@ export function PreferencesForm({
|
|
|
94
186
|
currencyOptions,
|
|
95
187
|
inlineDatePickers,
|
|
96
188
|
}: PreferencesFormProps) {
|
|
97
|
-
|
|
189
|
+
const fields = useMemo(() => preferenceFields(schema), [schema])
|
|
190
|
+
if (!fields.length) return null
|
|
98
191
|
|
|
99
192
|
function setValue(key: string, value: unknown) {
|
|
100
193
|
if (Object.is(values[key], value)) return
|
|
@@ -103,7 +196,7 @@ export function PreferencesForm({
|
|
|
103
196
|
|
|
104
197
|
return (
|
|
105
198
|
<FieldGroup>
|
|
106
|
-
{
|
|
199
|
+
{fields.map((field) => (
|
|
107
200
|
<PreferenceField
|
|
108
201
|
key={field.key}
|
|
109
202
|
field={field}
|
|
@@ -113,6 +206,7 @@ export function PreferencesForm({
|
|
|
113
206
|
redactPasswords={redactPasswords}
|
|
114
207
|
currencyOptions={currencyOptions}
|
|
115
208
|
inlineDatePickers={inlineDatePickers}
|
|
209
|
+
currency={typeof values.currency === 'string' ? values.currency : undefined}
|
|
116
210
|
/>
|
|
117
211
|
))}
|
|
118
212
|
</FieldGroup>
|
|
@@ -125,8 +219,10 @@ interface PreferenceFieldProps {
|
|
|
125
219
|
label?: string
|
|
126
220
|
onChange: (value: unknown) => void
|
|
127
221
|
redactPasswords?: boolean
|
|
128
|
-
currencyOptions?:
|
|
222
|
+
currencyOptions?: readonly CurrencyCode[]
|
|
129
223
|
inlineDatePickers?: boolean
|
|
224
|
+
/** The currency a money preference is in, when the record names one. */
|
|
225
|
+
currency?: string
|
|
130
226
|
}
|
|
131
227
|
|
|
132
228
|
export function PreferenceField({
|
|
@@ -137,8 +233,10 @@ export function PreferenceField({
|
|
|
137
233
|
redactPasswords,
|
|
138
234
|
currencyOptions,
|
|
139
235
|
inlineDatePickers,
|
|
236
|
+
currency,
|
|
140
237
|
}: PreferenceFieldProps) {
|
|
141
238
|
const { t, i18n } = useTranslation()
|
|
239
|
+
const moneyLocale = useMoneyLocale()
|
|
142
240
|
const id = `preference-${field.key}`
|
|
143
241
|
// Localize the field label from the preference key when no explicit
|
|
144
242
|
// override is given, falling back to a humanized key for custom/extension
|
|
@@ -152,10 +250,9 @@ export function PreferenceField({
|
|
|
152
250
|
const descriptionKey = `${preferenceKey}_description`
|
|
153
251
|
const description = i18n.exists(descriptionKey) ? t(descriptionKey) : undefined
|
|
154
252
|
|
|
155
|
-
//
|
|
156
|
-
// `
|
|
157
|
-
|
|
158
|
-
if (isCurrencyKey(field.key)) {
|
|
253
|
+
// Currencies get the store's CurrencySelect — same localized
|
|
254
|
+
// `CODE — Full Name` rendering as the rest of admin.
|
|
255
|
+
if (field.type === 'string' && field.format === 'currency') {
|
|
159
256
|
return (
|
|
160
257
|
<Field>
|
|
161
258
|
<FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
|
|
@@ -169,11 +266,33 @@ export function PreferenceField({
|
|
|
169
266
|
)
|
|
170
267
|
}
|
|
171
268
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
269
|
+
if (field.type === 'string' && field.format === 'iso-country') {
|
|
270
|
+
return (
|
|
271
|
+
<Field>
|
|
272
|
+
<FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
|
|
273
|
+
<CountryCombobox id={id} value={(value as string) ?? null} onValueChange={onChange} />
|
|
274
|
+
</Field>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (field.type === 'array' && field.format === 'iso-country') {
|
|
279
|
+
return (
|
|
280
|
+
<Field>
|
|
281
|
+
<FieldLabel>{displayLabel}</FieldLabel>
|
|
282
|
+
<CountryMultiCombobox
|
|
283
|
+
value={Array.isArray(value) ? (value as string[]) : []}
|
|
284
|
+
onValueChange={onChange}
|
|
285
|
+
/>
|
|
286
|
+
</Field>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// A tier ladder (the tiered calculators) — a list of
|
|
291
|
+
// `{ threshold, value }` objects that the default array input would render
|
|
292
|
+
// as `[object Object]`. Render a row editor so the merchant can add tier
|
|
293
|
+
// breakpoints directly.
|
|
294
|
+
const itemProperties = field.schema.items?.properties
|
|
295
|
+
if (field.type === 'array' && itemProperties?.threshold && itemProperties?.value) {
|
|
177
296
|
return <TiersEditor value={value} onChange={onChange} />
|
|
178
297
|
}
|
|
179
298
|
|
|
@@ -223,27 +342,52 @@ export function PreferenceField({
|
|
|
223
342
|
</Field>
|
|
224
343
|
)
|
|
225
344
|
|
|
345
|
+
case 'money':
|
|
346
|
+
return (
|
|
347
|
+
<Field>
|
|
348
|
+
<FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
|
|
349
|
+
<InputGroup>
|
|
350
|
+
{currency && (
|
|
351
|
+
<InputGroupAddon>
|
|
352
|
+
<InputGroupText>{currencyParts(currency, moneyLocale).symbol}</InputGroupText>
|
|
353
|
+
</InputGroupAddon>
|
|
354
|
+
)}
|
|
355
|
+
<InputGroupInput
|
|
356
|
+
id={id}
|
|
357
|
+
type="number"
|
|
358
|
+
step="any"
|
|
359
|
+
placeholder={
|
|
360
|
+
field.nullable ? t('admin.components.preferences_form.unlimited') : undefined
|
|
361
|
+
}
|
|
362
|
+
value={value === null || value === undefined ? '' : String(value)}
|
|
363
|
+
onChange={(e) => {
|
|
364
|
+
const raw = e.target.value
|
|
365
|
+
if (raw === '') return onChange(null)
|
|
366
|
+
// The API refuses money sent as a JSON number.
|
|
367
|
+
onChange(isDecimalString(raw) ? raw : null)
|
|
368
|
+
}}
|
|
369
|
+
/>
|
|
370
|
+
</InputGroup>
|
|
371
|
+
{description && <FieldDescription>{description}</FieldDescription>}
|
|
372
|
+
</Field>
|
|
373
|
+
)
|
|
374
|
+
|
|
226
375
|
case 'integer':
|
|
227
376
|
case 'decimal':
|
|
228
377
|
return (
|
|
229
378
|
<Field>
|
|
230
379
|
<FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
|
|
231
|
-
<
|
|
380
|
+
<NumberPreferenceInput
|
|
232
381
|
id={id}
|
|
233
|
-
type
|
|
234
|
-
|
|
235
|
-
// An empty
|
|
382
|
+
integer={field.type === 'integer'}
|
|
383
|
+
nullable={!!field.nullable}
|
|
384
|
+
// An empty optional field means "no limit" — surface that
|
|
236
385
|
// rather than leaving it looking like a required blank.
|
|
237
386
|
placeholder={
|
|
238
|
-
|
|
387
|
+
field.nullable ? t('admin.components.preferences_form.unlimited') : undefined
|
|
239
388
|
}
|
|
240
|
-
value={value
|
|
241
|
-
onChange={
|
|
242
|
-
const raw = e.target.value
|
|
243
|
-
if (raw === '') return onChange(null)
|
|
244
|
-
const parsed = field.type === 'integer' ? parseInt(raw, 10) : parseFloat(raw)
|
|
245
|
-
onChange(Number.isNaN(parsed) ? null : parsed)
|
|
246
|
-
}}
|
|
389
|
+
value={value}
|
|
390
|
+
onChange={onChange}
|
|
247
391
|
/>
|
|
248
392
|
</Field>
|
|
249
393
|
)
|
|
@@ -291,12 +435,23 @@ export function PreferenceField({
|
|
|
291
435
|
// the set is what the server validates against, so typing anything
|
|
292
436
|
// else can only fail on save.
|
|
293
437
|
if (field.choices?.length) {
|
|
294
|
-
|
|
438
|
+
// A translation wins where one exists, so the locale files stay in
|
|
439
|
+
// charge of the values that read as words; then the label the
|
|
440
|
+
// declaration supplied, for the ones that do not — a URL cannot serve
|
|
441
|
+
// as its own i18n key, let alone as a label.
|
|
442
|
+
const declared = field.choices.map((choice) => ({
|
|
295
443
|
value: choice,
|
|
296
444
|
label: i18n.exists(`${preferenceKey}_options.${choice}`)
|
|
297
445
|
? t(`${preferenceKey}_options.${choice}`)
|
|
298
|
-
: humanizeKey(choice),
|
|
446
|
+
: (field.choiceLabels?.[choice] ?? humanizeKey(choice)),
|
|
299
447
|
}))
|
|
448
|
+
// A stored value outside the set is offered as its own option rather
|
|
449
|
+
// than dropped, so one written past the picker by an API client is not
|
|
450
|
+
// silently rewritten to the first choice.
|
|
451
|
+
const options =
|
|
452
|
+
typeof value === 'string' && value !== '' && !declared.some((o) => o.value === value)
|
|
453
|
+
? [...declared, { value, label: value }]
|
|
454
|
+
: declared
|
|
300
455
|
|
|
301
456
|
return (
|
|
302
457
|
<Field>
|
|
@@ -336,6 +491,69 @@ export function PreferenceField({
|
|
|
336
491
|
}
|
|
337
492
|
}
|
|
338
493
|
|
|
494
|
+
/**
|
|
495
|
+
* A number setting. Keeps what is typed locally and passes on only values the
|
|
496
|
+
* server accepts: decimals as exact strings (`.5` becomes `0.5`), integers as
|
|
497
|
+
* numbers, and an empty field as `null` only when the setting may be unset —
|
|
498
|
+
* a required one keeps its last value until a number is typed.
|
|
499
|
+
*/
|
|
500
|
+
function NumberPreferenceInput({
|
|
501
|
+
id,
|
|
502
|
+
integer,
|
|
503
|
+
nullable,
|
|
504
|
+
placeholder,
|
|
505
|
+
value,
|
|
506
|
+
onChange,
|
|
507
|
+
}: {
|
|
508
|
+
id: string
|
|
509
|
+
integer: boolean
|
|
510
|
+
nullable: boolean
|
|
511
|
+
placeholder?: string
|
|
512
|
+
value: unknown
|
|
513
|
+
onChange: (value: unknown) => void
|
|
514
|
+
}) {
|
|
515
|
+
const external = value === null || value === undefined ? '' : String(value)
|
|
516
|
+
const [text, setText] = useState(external)
|
|
517
|
+
const [lastExternal, setLastExternal] = useState(external)
|
|
518
|
+
if (external !== lastExternal) {
|
|
519
|
+
setLastExternal(external)
|
|
520
|
+
setText(external)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function commit(raw: string) {
|
|
524
|
+
setText(raw)
|
|
525
|
+
const trimmed = raw.trim()
|
|
526
|
+
if (trimmed === '') {
|
|
527
|
+
if (nullable) onChange(null)
|
|
528
|
+
return
|
|
529
|
+
}
|
|
530
|
+
const number = Number(trimmed)
|
|
531
|
+
if (!Number.isFinite(number)) return
|
|
532
|
+
if (integer) {
|
|
533
|
+
if (Number.isInteger(number)) onChange(number)
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
// `.5` or `1e-7` is a number the server would refuse as typed; send its
|
|
537
|
+
// plain decimal form.
|
|
538
|
+
onChange(
|
|
539
|
+
DECIMAL_REGEX.test(trimmed)
|
|
540
|
+
? trimmed
|
|
541
|
+
: number.toLocaleString('en-US', { useGrouping: false, maximumFractionDigits: 20 }),
|
|
542
|
+
)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return (
|
|
546
|
+
<Input
|
|
547
|
+
id={id}
|
|
548
|
+
type="number"
|
|
549
|
+
step={integer ? 1 : 'any'}
|
|
550
|
+
placeholder={placeholder}
|
|
551
|
+
value={text}
|
|
552
|
+
onChange={(e) => commit(e.target.value)}
|
|
553
|
+
/>
|
|
554
|
+
)
|
|
555
|
+
}
|
|
556
|
+
|
|
339
557
|
function humanizeKey(key: string): string {
|
|
340
558
|
return key
|
|
341
559
|
.split('_')
|
|
@@ -343,19 +561,6 @@ function humanizeKey(key: string): string {
|
|
|
343
561
|
.join(' ')
|
|
344
562
|
}
|
|
345
563
|
|
|
346
|
-
function isCurrencyKey(key: string): boolean {
|
|
347
|
-
return key === 'currency' || key.endsWith('_currency')
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Upper-bound preferences (`max_quantity`, `max_uses`, `usage_max`,
|
|
351
|
-
// `maximum_amount`, …) read "Unlimited" when left blank. Matched on the key
|
|
352
|
-
// name because the wire schema doesn't carry a nullable flag — safe since the
|
|
353
|
-
// placeholder only shows for an empty value, and an empty max legitimately
|
|
354
|
-
// means no ceiling.
|
|
355
|
-
function isMaxKey(key: string): boolean {
|
|
356
|
-
return /(?:^|_)max(?:imum)?(?:_|$)/.test(key)
|
|
357
|
-
}
|
|
358
|
-
|
|
359
564
|
interface TierValue {
|
|
360
565
|
threshold: string
|
|
361
566
|
value: string
|
|
@@ -516,5 +721,9 @@ function parseTiers(value: unknown, idPrefix: string): TierRowState[] {
|
|
|
516
721
|
threshold: threshold === null || threshold === undefined ? '' : String(threshold),
|
|
517
722
|
value: v === null || v === undefined ? '' : String(v),
|
|
518
723
|
}))
|
|
519
|
-
.sort((a, b) =>
|
|
724
|
+
.sort((a, b) =>
|
|
725
|
+
isDecimalString(a.threshold) && isDecimalString(b.threshold)
|
|
726
|
+
? compareMoney(a.threshold, b.threshold)
|
|
727
|
+
: 0,
|
|
728
|
+
)
|
|
520
729
|
}
|
|
@@ -109,6 +109,11 @@ export function ResourceCombobox<T extends ComboboxOption>({
|
|
|
109
109
|
// elsewhere (a customer created on another page) never invalidates it.
|
|
110
110
|
// Every search refetches; cached rows for a repeated term still paint first.
|
|
111
111
|
staleTime: 0,
|
|
112
|
+
// Keeps the last results on screen while the next query loads, so the
|
|
113
|
+
// list does not blank on every keystroke. Only within one tenant: the
|
|
114
|
+
// second argument is the query those results came from.
|
|
115
|
+
placeholderData: (previous, previousQuery) =>
|
|
116
|
+
previousQuery?.queryKey[1] === tenantId ? previous : undefined,
|
|
112
117
|
})
|
|
113
118
|
|
|
114
119
|
// Hydrate the currently selected ID into a record so the trigger shows
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Button,
|
|
3
3
|
Checkbox,
|
|
4
|
+
Item,
|
|
5
|
+
ItemActions,
|
|
6
|
+
ItemContent,
|
|
7
|
+
ItemDescription,
|
|
8
|
+
ItemTitle,
|
|
4
9
|
type PaginationMeta,
|
|
5
10
|
SearchInput,
|
|
6
11
|
Sheet,
|
|
@@ -308,11 +313,10 @@ export function ResourcePickerSheet<T extends PickerOption>({
|
|
|
308
313
|
const subtitle = getOptionSubtitle?.(option)
|
|
309
314
|
return (
|
|
310
315
|
<li key={option.id}>
|
|
311
|
-
<
|
|
312
|
-
type="button"
|
|
313
|
-
disabled={inList}
|
|
316
|
+
<Item
|
|
317
|
+
render={<button type="button" disabled={inList} />}
|
|
314
318
|
onClick={() => toggle(option)}
|
|
315
|
-
className="flex
|
|
319
|
+
className="flex-nowrap gap-3 rounded-none px-4 py-2.5 text-left hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
|
|
316
320
|
>
|
|
317
321
|
<Checkbox
|
|
318
322
|
checked={checked}
|
|
@@ -320,20 +324,22 @@ export function ResourcePickerSheet<T extends PickerOption>({
|
|
|
320
324
|
className="pointer-events-none"
|
|
321
325
|
/>
|
|
322
326
|
{getOptionImageUrl && <Thumbnail src={imageUrl} size="sm" />}
|
|
323
|
-
<
|
|
324
|
-
<
|
|
327
|
+
<ItemContent className="min-w-0 gap-0">
|
|
328
|
+
<ItemTitle className="block truncate font-normal">
|
|
329
|
+
{getOptionLabel(option)}
|
|
330
|
+
</ItemTitle>
|
|
325
331
|
{subtitle && (
|
|
326
|
-
<
|
|
332
|
+
<ItemDescription className="line-clamp-1 text-xs">
|
|
327
333
|
{subtitle}
|
|
328
|
-
</
|
|
334
|
+
</ItemDescription>
|
|
329
335
|
)}
|
|
330
|
-
</
|
|
336
|
+
</ItemContent>
|
|
331
337
|
{inList && (
|
|
332
|
-
<
|
|
338
|
+
<ItemActions className="shrink-0 text-muted-foreground text-xs">
|
|
333
339
|
{t('admin.resource_picker.already_added')}
|
|
334
|
-
</
|
|
340
|
+
</ItemActions>
|
|
335
341
|
)}
|
|
336
|
-
</
|
|
342
|
+
</Item>
|
|
337
343
|
</li>
|
|
338
344
|
)
|
|
339
345
|
})}
|