@spree/dashboard-core 1.0.0-rc.1 → 1.0.0-rc.3
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 +9 -14
- package/package.json +3 -5
- 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 +16 -10
- package/src/components/file-upload-field.tsx +4 -4
- package/src/components/import-button.tsx +4 -1
- package/src/components/mobile-top-bar.tsx +6 -0
- package/src/components/money.tsx +23 -0
- package/src/components/preferences-form.tsx +247 -52
- package/src/components/resource-table.tsx +7 -3
- 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 +5 -2
- 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 +45 -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/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/slot-registry.ts +10 -0
- package/src/lib/storefront.ts +2 -2
- package/src/locales/ar.json +4 -1
- package/src/locales/de.json +4 -1
- package/src/locales/en.json +4 -1
- package/src/locales/es.json +4 -1
- package/src/locales/fr.json +4 -1
- package/src/locales/pl.json +4 -1
- package/src/locales/zh-CN.json +4 -1
- package/src/plugin.ts +5 -5
- package/src/products/bulk-variants-dialog.tsx +1 -1
- 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/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 +33 -28
- 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
|
@@ -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,108 @@ 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
|
+
/** Whether the setting may be left empty (it reads back as null). */
|
|
50
|
+
nullable: boolean
|
|
51
|
+
/** The property's own JSON Schema, for widgets that need more (list items, object properties). */
|
|
52
|
+
schema: PreferencePropertySchema
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DECIMAL_PATTERN = '^-?\\d+(\\.\\d+)?$'
|
|
56
|
+
const DECIMAL_REGEX = new RegExp(DECIMAL_PATTERN)
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The schema with only the settings `keep` accepts — for a form that edits
|
|
60
|
+
* the others with a control of its own.
|
|
61
|
+
*/
|
|
62
|
+
export function filterPreferenceSchema(
|
|
63
|
+
schema: PreferenceSchema,
|
|
64
|
+
keep: (key: string, property: PreferencePropertySchema) => boolean,
|
|
65
|
+
): PreferenceSchema {
|
|
66
|
+
return {
|
|
67
|
+
...schema,
|
|
68
|
+
properties: Object.fromEntries(
|
|
69
|
+
Object.entries(schema.properties).filter(([key, property]) => keep(key, property)),
|
|
70
|
+
),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fieldType(property: PreferencePropertySchema): string {
|
|
75
|
+
if (property['x-spree-secret']) return 'password'
|
|
76
|
+
const type = (Array.isArray(property.type) ? property.type : [property.type]).find(
|
|
77
|
+
(t) => t && t !== 'null',
|
|
78
|
+
)
|
|
79
|
+
if (type === 'string') {
|
|
80
|
+
if (property.format === 'date') return 'date'
|
|
81
|
+
if (property.format === 'date-time') return 'datetime'
|
|
82
|
+
if (property.format === 'money') return 'money'
|
|
83
|
+
if (property.pattern === DECIMAL_PATTERN) return 'decimal'
|
|
84
|
+
if (property['x-spree-widget'] === 'textarea') return 'text'
|
|
85
|
+
return 'string'
|
|
86
|
+
}
|
|
87
|
+
if (type === 'object') return property.properties ? 'object' : 'hash'
|
|
88
|
+
return type ?? 'string'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Flattens a preference schema into the fields a form renders, in the order
|
|
93
|
+
* the type declares them.
|
|
94
|
+
*/
|
|
95
|
+
export function preferenceFields(
|
|
96
|
+
schema: PreferenceSchema | null | undefined,
|
|
97
|
+
): PreferenceFieldDef[] {
|
|
98
|
+
return Object.entries(schema?.properties ?? {}).map(([key, property]) => ({
|
|
99
|
+
key,
|
|
100
|
+
type: fieldType(property),
|
|
101
|
+
format: property.format ?? property.items?.format,
|
|
102
|
+
default: property.default ?? null,
|
|
103
|
+
choices: property.enum?.filter((choice): choice is string => typeof choice === 'string'),
|
|
104
|
+
nullable: Array.isArray(property.type) && property.type.includes('null'),
|
|
105
|
+
schema: property,
|
|
106
|
+
}))
|
|
107
|
+
}
|
|
108
|
+
|
|
24
109
|
/**
|
|
25
110
|
* Hydrates a `preferences` hash with each field's default. Used to seed
|
|
26
111
|
* create-mode forms when the user picks a provider/calculator so the
|
|
27
112
|
* `<PreferencesForm>` shows sensible starting values instead of blanks.
|
|
28
113
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* own default is null/undefined — used by callers that know about runtime
|
|
114
|
+
* Secrets are always skipped — the schema never carries their defaults, and
|
|
115
|
+
* autofilling a password field is hostile UX. `contextDefaults` fills keys
|
|
116
|
+
* whose own default is absent — used by callers that know about runtime
|
|
33
117
|
* context the schema can't express (e.g. the store's default currency).
|
|
34
118
|
*/
|
|
35
119
|
export function defaultPreferences(
|
|
36
|
-
schema:
|
|
120
|
+
schema: PreferenceSchema | null | undefined,
|
|
37
121
|
contextDefaults: Record<string, unknown> = {},
|
|
38
122
|
): Record<string, unknown> {
|
|
39
123
|
const out: Record<string, unknown> = {}
|
|
40
|
-
for (const field of schema) {
|
|
124
|
+
for (const field of preferenceFields(schema)) {
|
|
41
125
|
if (field.type === 'password') continue
|
|
42
126
|
if (field.default !== null && field.default !== undefined) {
|
|
43
|
-
|
|
127
|
+
// A decimal default declared as a Ruby integer arrives as a JSON number,
|
|
128
|
+
// and the API refuses money sent back that way.
|
|
129
|
+
out[field.key] =
|
|
130
|
+
(field.type === 'decimal' || field.type === 'money') && typeof field.default === 'number'
|
|
131
|
+
? String(field.default)
|
|
132
|
+
: field.default
|
|
44
133
|
continue
|
|
45
134
|
}
|
|
46
135
|
const ctx = contextDefaults[field.key]
|
|
@@ -50,7 +139,7 @@ export function defaultPreferences(
|
|
|
50
139
|
}
|
|
51
140
|
|
|
52
141
|
interface PreferencesFormProps {
|
|
53
|
-
schema:
|
|
142
|
+
schema: PreferenceSchema | null | undefined
|
|
54
143
|
values: Record<string, unknown>
|
|
55
144
|
onChange: (next: Record<string, unknown>) => void
|
|
56
145
|
/** When true, replaces sensitive `password`-typed fields with a stub label. */
|
|
@@ -64,7 +153,7 @@ interface PreferencesFormProps {
|
|
|
64
153
|
* would let a currency the store does not trade in be saved, and a
|
|
65
154
|
* calculator set to one quotes no rate at all.
|
|
66
155
|
*/
|
|
67
|
-
currencyOptions?:
|
|
156
|
+
currencyOptions?: readonly CurrencyCode[]
|
|
68
157
|
/**
|
|
69
158
|
* Render date/datetime pickers inline instead of in a Popover portal.
|
|
70
159
|
* Set this when the form is inside a `<Sheet>` — Base UI's Popover fails
|
|
@@ -74,16 +163,16 @@ interface PreferencesFormProps {
|
|
|
74
163
|
}
|
|
75
164
|
|
|
76
165
|
/**
|
|
77
|
-
* Renders a generic configuration form from a
|
|
166
|
+
* Renders a generic configuration form from a type's preference schema.
|
|
78
167
|
* Used by the Payment Methods edit sheet and the Promotion editor's action
|
|
79
168
|
* and rule cards — anywhere we let admins tune a STI subclass's settings
|
|
80
169
|
* without hard-coding a per-subclass form.
|
|
81
170
|
*
|
|
82
171
|
* The schema itself is the source of truth; this component intentionally
|
|
83
172
|
* stays "dumb" — server-side validation surfaces errors back via the
|
|
84
|
-
* mutation hook's error handling.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
173
|
+
* mutation hook's error handling. The widget follows the schema: booleans
|
|
174
|
+
* are switches, currencies and countries pickers, amounts number inputs
|
|
175
|
+
* that send the exact decimal string, and anything else a text input.
|
|
87
176
|
*/
|
|
88
177
|
export function PreferencesForm({
|
|
89
178
|
schema,
|
|
@@ -94,7 +183,8 @@ export function PreferencesForm({
|
|
|
94
183
|
currencyOptions,
|
|
95
184
|
inlineDatePickers,
|
|
96
185
|
}: PreferencesFormProps) {
|
|
97
|
-
|
|
186
|
+
const fields = useMemo(() => preferenceFields(schema), [schema])
|
|
187
|
+
if (!fields.length) return null
|
|
98
188
|
|
|
99
189
|
function setValue(key: string, value: unknown) {
|
|
100
190
|
if (Object.is(values[key], value)) return
|
|
@@ -103,7 +193,7 @@ export function PreferencesForm({
|
|
|
103
193
|
|
|
104
194
|
return (
|
|
105
195
|
<FieldGroup>
|
|
106
|
-
{
|
|
196
|
+
{fields.map((field) => (
|
|
107
197
|
<PreferenceField
|
|
108
198
|
key={field.key}
|
|
109
199
|
field={field}
|
|
@@ -113,6 +203,7 @@ export function PreferencesForm({
|
|
|
113
203
|
redactPasswords={redactPasswords}
|
|
114
204
|
currencyOptions={currencyOptions}
|
|
115
205
|
inlineDatePickers={inlineDatePickers}
|
|
206
|
+
currency={typeof values.currency === 'string' ? values.currency : undefined}
|
|
116
207
|
/>
|
|
117
208
|
))}
|
|
118
209
|
</FieldGroup>
|
|
@@ -125,8 +216,10 @@ interface PreferenceFieldProps {
|
|
|
125
216
|
label?: string
|
|
126
217
|
onChange: (value: unknown) => void
|
|
127
218
|
redactPasswords?: boolean
|
|
128
|
-
currencyOptions?:
|
|
219
|
+
currencyOptions?: readonly CurrencyCode[]
|
|
129
220
|
inlineDatePickers?: boolean
|
|
221
|
+
/** The currency a money preference is in, when the record names one. */
|
|
222
|
+
currency?: string
|
|
130
223
|
}
|
|
131
224
|
|
|
132
225
|
export function PreferenceField({
|
|
@@ -137,8 +230,10 @@ export function PreferenceField({
|
|
|
137
230
|
redactPasswords,
|
|
138
231
|
currencyOptions,
|
|
139
232
|
inlineDatePickers,
|
|
233
|
+
currency,
|
|
140
234
|
}: PreferenceFieldProps) {
|
|
141
235
|
const { t, i18n } = useTranslation()
|
|
236
|
+
const moneyLocale = useMoneyLocale()
|
|
142
237
|
const id = `preference-${field.key}`
|
|
143
238
|
// Localize the field label from the preference key when no explicit
|
|
144
239
|
// override is given, falling back to a humanized key for custom/extension
|
|
@@ -152,10 +247,9 @@ export function PreferenceField({
|
|
|
152
247
|
const descriptionKey = `${preferenceKey}_description`
|
|
153
248
|
const description = i18n.exists(descriptionKey) ? t(descriptionKey) : undefined
|
|
154
249
|
|
|
155
|
-
//
|
|
156
|
-
// `
|
|
157
|
-
|
|
158
|
-
if (isCurrencyKey(field.key)) {
|
|
250
|
+
// Currencies get the store's CurrencySelect — same localized
|
|
251
|
+
// `CODE — Full Name` rendering as the rest of admin.
|
|
252
|
+
if (field.type === 'string' && field.format === 'currency') {
|
|
159
253
|
return (
|
|
160
254
|
<Field>
|
|
161
255
|
<FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
|
|
@@ -169,11 +263,33 @@ export function PreferenceField({
|
|
|
169
263
|
)
|
|
170
264
|
}
|
|
171
265
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
266
|
+
if (field.type === 'string' && field.format === 'iso-country') {
|
|
267
|
+
return (
|
|
268
|
+
<Field>
|
|
269
|
+
<FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
|
|
270
|
+
<CountryCombobox id={id} value={(value as string) ?? null} onValueChange={onChange} />
|
|
271
|
+
</Field>
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (field.type === 'array' && field.format === 'iso-country') {
|
|
276
|
+
return (
|
|
277
|
+
<Field>
|
|
278
|
+
<FieldLabel>{displayLabel}</FieldLabel>
|
|
279
|
+
<CountryMultiCombobox
|
|
280
|
+
value={Array.isArray(value) ? (value as string[]) : []}
|
|
281
|
+
onValueChange={onChange}
|
|
282
|
+
/>
|
|
283
|
+
</Field>
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// A tier ladder (the tiered calculators) — a list of
|
|
288
|
+
// `{ threshold, value }` objects that the default array input would render
|
|
289
|
+
// as `[object Object]`. Render a row editor so the merchant can add tier
|
|
290
|
+
// breakpoints directly.
|
|
291
|
+
const itemProperties = field.schema.items?.properties
|
|
292
|
+
if (field.type === 'array' && itemProperties?.threshold && itemProperties?.value) {
|
|
177
293
|
return <TiersEditor value={value} onChange={onChange} />
|
|
178
294
|
}
|
|
179
295
|
|
|
@@ -223,27 +339,52 @@ export function PreferenceField({
|
|
|
223
339
|
</Field>
|
|
224
340
|
)
|
|
225
341
|
|
|
342
|
+
case 'money':
|
|
343
|
+
return (
|
|
344
|
+
<Field>
|
|
345
|
+
<FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
|
|
346
|
+
<InputGroup>
|
|
347
|
+
{currency && (
|
|
348
|
+
<InputGroupAddon>
|
|
349
|
+
<InputGroupText>{currencyParts(currency, moneyLocale).symbol}</InputGroupText>
|
|
350
|
+
</InputGroupAddon>
|
|
351
|
+
)}
|
|
352
|
+
<InputGroupInput
|
|
353
|
+
id={id}
|
|
354
|
+
type="number"
|
|
355
|
+
step="any"
|
|
356
|
+
placeholder={
|
|
357
|
+
field.nullable ? t('admin.components.preferences_form.unlimited') : undefined
|
|
358
|
+
}
|
|
359
|
+
value={value === null || value === undefined ? '' : String(value)}
|
|
360
|
+
onChange={(e) => {
|
|
361
|
+
const raw = e.target.value
|
|
362
|
+
if (raw === '') return onChange(null)
|
|
363
|
+
// The API refuses money sent as a JSON number.
|
|
364
|
+
onChange(isDecimalString(raw) ? raw : null)
|
|
365
|
+
}}
|
|
366
|
+
/>
|
|
367
|
+
</InputGroup>
|
|
368
|
+
{description && <FieldDescription>{description}</FieldDescription>}
|
|
369
|
+
</Field>
|
|
370
|
+
)
|
|
371
|
+
|
|
226
372
|
case 'integer':
|
|
227
373
|
case 'decimal':
|
|
228
374
|
return (
|
|
229
375
|
<Field>
|
|
230
376
|
<FieldLabel htmlFor={id}>{displayLabel}</FieldLabel>
|
|
231
|
-
<
|
|
377
|
+
<NumberPreferenceInput
|
|
232
378
|
id={id}
|
|
233
|
-
type
|
|
234
|
-
|
|
235
|
-
// An empty
|
|
379
|
+
integer={field.type === 'integer'}
|
|
380
|
+
nullable={!!field.nullable}
|
|
381
|
+
// An empty optional field means "no limit" — surface that
|
|
236
382
|
// rather than leaving it looking like a required blank.
|
|
237
383
|
placeholder={
|
|
238
|
-
|
|
384
|
+
field.nullable ? t('admin.components.preferences_form.unlimited') : undefined
|
|
239
385
|
}
|
|
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
|
-
}}
|
|
386
|
+
value={value}
|
|
387
|
+
onChange={onChange}
|
|
247
388
|
/>
|
|
248
389
|
</Field>
|
|
249
390
|
)
|
|
@@ -336,6 +477,69 @@ export function PreferenceField({
|
|
|
336
477
|
}
|
|
337
478
|
}
|
|
338
479
|
|
|
480
|
+
/**
|
|
481
|
+
* A number setting. Keeps what is typed locally and passes on only values the
|
|
482
|
+
* server accepts: decimals as exact strings (`.5` becomes `0.5`), integers as
|
|
483
|
+
* numbers, and an empty field as `null` only when the setting may be unset —
|
|
484
|
+
* a required one keeps its last value until a number is typed.
|
|
485
|
+
*/
|
|
486
|
+
function NumberPreferenceInput({
|
|
487
|
+
id,
|
|
488
|
+
integer,
|
|
489
|
+
nullable,
|
|
490
|
+
placeholder,
|
|
491
|
+
value,
|
|
492
|
+
onChange,
|
|
493
|
+
}: {
|
|
494
|
+
id: string
|
|
495
|
+
integer: boolean
|
|
496
|
+
nullable: boolean
|
|
497
|
+
placeholder?: string
|
|
498
|
+
value: unknown
|
|
499
|
+
onChange: (value: unknown) => void
|
|
500
|
+
}) {
|
|
501
|
+
const external = value === null || value === undefined ? '' : String(value)
|
|
502
|
+
const [text, setText] = useState(external)
|
|
503
|
+
const [lastExternal, setLastExternal] = useState(external)
|
|
504
|
+
if (external !== lastExternal) {
|
|
505
|
+
setLastExternal(external)
|
|
506
|
+
setText(external)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function commit(raw: string) {
|
|
510
|
+
setText(raw)
|
|
511
|
+
const trimmed = raw.trim()
|
|
512
|
+
if (trimmed === '') {
|
|
513
|
+
if (nullable) onChange(null)
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
const number = Number(trimmed)
|
|
517
|
+
if (!Number.isFinite(number)) return
|
|
518
|
+
if (integer) {
|
|
519
|
+
if (Number.isInteger(number)) onChange(number)
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
// `.5` or `1e-7` is a number the server would refuse as typed; send its
|
|
523
|
+
// plain decimal form.
|
|
524
|
+
onChange(
|
|
525
|
+
DECIMAL_REGEX.test(trimmed)
|
|
526
|
+
? trimmed
|
|
527
|
+
: number.toLocaleString('en-US', { useGrouping: false, maximumFractionDigits: 20 }),
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return (
|
|
532
|
+
<Input
|
|
533
|
+
id={id}
|
|
534
|
+
type="number"
|
|
535
|
+
step={integer ? 1 : 'any'}
|
|
536
|
+
placeholder={placeholder}
|
|
537
|
+
value={text}
|
|
538
|
+
onChange={(e) => commit(e.target.value)}
|
|
539
|
+
/>
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
339
543
|
function humanizeKey(key: string): string {
|
|
340
544
|
return key
|
|
341
545
|
.split('_')
|
|
@@ -343,19 +547,6 @@ function humanizeKey(key: string): string {
|
|
|
343
547
|
.join(' ')
|
|
344
548
|
}
|
|
345
549
|
|
|
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
550
|
interface TierValue {
|
|
360
551
|
threshold: string
|
|
361
552
|
value: string
|
|
@@ -516,5 +707,9 @@ function parseTiers(value: unknown, idPrefix: string): TierRowState[] {
|
|
|
516
707
|
threshold: threshold === null || threshold === undefined ? '' : String(threshold),
|
|
517
708
|
value: v === null || v === undefined ? '' : String(v),
|
|
518
709
|
}))
|
|
519
|
-
.sort((a, b) =>
|
|
710
|
+
.sort((a, b) =>
|
|
711
|
+
isDecimalString(a.threshold) && isDecimalString(b.threshold)
|
|
712
|
+
? compareMoney(a.threshold, b.threshold)
|
|
713
|
+
: 0,
|
|
714
|
+
)
|
|
520
715
|
}
|
|
@@ -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
|
})
|
|
@@ -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
|
+
}
|