@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.
Files changed (68) hide show
  1. package/dist/vite/discover.js +5 -2
  2. package/package.json +13 -16
  3. package/src/api-client.ts +22 -34
  4. package/src/components/address-form-dialog.test.ts +4 -4
  5. package/src/components/address-form-dialog.tsx +10 -10
  6. package/src/components/app-shell.tsx +12 -1
  7. package/src/components/app-sidebar.tsx +18 -2
  8. package/src/components/country-combobox.tsx +52 -29
  9. package/src/components/currency-select.tsx +18 -13
  10. package/src/components/file-upload-field.tsx +4 -4
  11. package/src/components/import-button.tsx +4 -1
  12. package/src/components/import-wizard-dialog.tsx +8 -11
  13. package/src/components/mobile-top-bar.tsx +6 -0
  14. package/src/components/money.tsx +23 -0
  15. package/src/components/preferences-form.tsx +263 -54
  16. package/src/components/resource-combobox.tsx +5 -0
  17. package/src/components/resource-picker-sheet.tsx +18 -12
  18. package/src/components/resource-table.tsx +7 -3
  19. package/src/components/table-toolbar.tsx +8 -5
  20. package/src/hooks/use-display-name.ts +11 -3
  21. package/src/hooks/use-file-upload.ts +51 -0
  22. package/src/hooks/use-import.ts +5 -5
  23. package/src/hooks/use-money-locale.ts +14 -0
  24. package/src/index.ts +8 -3
  25. package/src/lib/currencies.ts +32 -13
  26. package/src/lib/form-mappers.ts +25 -5
  27. package/src/lib/formatters.ts +34 -4
  28. package/src/lib/fulfillment-items.test.ts +2 -1
  29. package/src/lib/fulfillment-items.ts +9 -3
  30. package/src/lib/i18n.ts +4 -4
  31. package/src/lib/iso-codes.ts +47 -0
  32. package/src/lib/money.test.ts +63 -0
  33. package/src/lib/money.ts +99 -0
  34. package/src/{products → lib}/normalize-money.ts +2 -2
  35. package/src/lib/permissions.ts +2 -0
  36. package/src/lib/return-refunds.test.ts +62 -0
  37. package/src/lib/return-refunds.ts +70 -0
  38. package/src/lib/route-registry.ts +58 -15
  39. package/src/lib/settings-nav-registry.test.ts +34 -0
  40. package/src/lib/settings-nav-registry.ts +21 -4
  41. package/src/lib/slot-registry.ts +10 -0
  42. package/src/lib/storefront.ts +2 -2
  43. package/src/locales/ar.json +7 -2
  44. package/src/locales/de.json +7 -2
  45. package/src/locales/en.json +7 -2
  46. package/src/locales/es.json +7 -2
  47. package/src/locales/fr.json +7 -2
  48. package/src/locales/pl.json +7 -2
  49. package/src/locales/zh-CN.json +7 -2
  50. package/src/plugin.ts +5 -5
  51. package/src/products/bulk-variants-dialog.tsx +2 -8
  52. package/src/products/inventory-section.tsx +2 -2
  53. package/src/products/product-bulk-price-editor.tsx +18 -29
  54. package/src/products/product-form-cards.tsx +12 -8
  55. package/src/products/product-form-mapping.ts +15 -11
  56. package/src/products/product-schema.ts +6 -8
  57. package/src/products/product-types.ts +5 -3
  58. package/src/products/use-product-form-data.ts +0 -28
  59. package/src/products/variants-options-builder.tsx +30 -27
  60. package/src/products/variants-section.tsx +31 -25
  61. package/src/providers/store-provider.tsx +9 -9
  62. package/src/schemas/stock-location.ts +3 -2
  63. package/src/tables/package-types.tsx +1 -1
  64. package/src/tables/stock-locations.tsx +1 -1
  65. package/src/vite/discover.ts +19 -2
  66. package/src/hooks/use-direct-upload.ts +0 -117
  67. /package/src/{products → lib}/currency-parts.ts +0 -0
  68. /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 direct-uploads on pick via
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
- <div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-destructive text-sm">
476
- <AlertTriangleIcon className="size-4 shrink-0" />
477
- <span>{error.message}</span>
478
- </div>
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 { PreferenceField as PreferenceFieldDef } from '@spree/admin-sdk'
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
- * `:password` fields are always skipped — the server returns their
30
- * defaults as `null`, and even when it doesn't, autofilling a password
31
- * field is hostile UX. `contextDefaults` fills schema-declared keys whose
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: PreferenceFieldDef[],
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
- out[field.key] = field.default
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: PreferenceFieldDef[]
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?: string[]
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 `preference_schema` payload.
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. Booleans are switches, strings are
85
- * inputs, integers/decimals get number inputs, and unknown types fall
86
- * back to a plain text input.
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
- if (!schema?.length) return null
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
- {schema.map((field) => (
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?: string[]
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
- // Currency-typed preferences (`currency`, `default_currency`,
156
- // `display_currency`, …) get the store's CurrencySelect — same
157
- // localized `CODE — Full Name` rendering as the rest of admin.
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
- // `tiers` preference (Spree::Calculator::TieredPercent /
173
- // TieredFlatRate) — a list of `{ threshold, value }` objects that the
174
- // default array input would render as `[object Object]`. Render a row
175
- // editor so the merchant can add tier breakpoints directly.
176
- if (field.key === 'tiers') {
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
- <Input
380
+ <NumberPreferenceInput
232
381
  id={id}
233
- type="number"
234
- step={field.type === 'integer' ? 1 : 'any'}
235
- // An empty upper-bound field means "no limit" — surface that
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
- isMaxKey(field.key) ? t('admin.components.preferences_form.unlimited') : undefined
387
+ field.nullable ? t('admin.components.preferences_form.unlimited') : undefined
239
388
  }
240
- value={value === null || value === undefined ? '' : String(value)}
241
- onChange={(e) => {
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
- const options = field.choices.map((choice) => ({
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) => Number(a.threshold) - Number(b.threshold))
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
- <button
312
- type="button"
313
- disabled={inList}
316
+ <Item
317
+ render={<button type="button" disabled={inList} />}
314
318
  onClick={() => toggle(option)}
315
- className="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
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
- <span className="min-w-0 flex-1">
324
- <span className="block truncate text-sm">{getOptionLabel(option)}</span>
327
+ <ItemContent className="min-w-0 gap-0">
328
+ <ItemTitle className="block truncate font-normal">
329
+ {getOptionLabel(option)}
330
+ </ItemTitle>
325
331
  {subtitle && (
326
- <span className="block truncate text-muted-foreground text-xs">
332
+ <ItemDescription className="line-clamp-1 text-xs">
327
333
  {subtitle}
328
- </span>
334
+ </ItemDescription>
329
335
  )}
330
- </span>
336
+ </ItemContent>
331
337
  {inList && (
332
- <span className="shrink-0 text-muted-foreground text-xs">
338
+ <ItemActions className="shrink-0 text-muted-foreground text-xs">
333
339
  {t('admin.resource_picker.already_added')}
334
- </span>
340
+ </ItemActions>
335
341
  )}
336
- </button>
342
+ </Item>
337
343
  </li>
338
344
  )
339
345
  })}