@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.
Files changed (58) hide show
  1. package/dist/vite/discover.js +9 -14
  2. package/package.json +3 -5
  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 +16 -10
  10. package/src/components/file-upload-field.tsx +4 -4
  11. package/src/components/import-button.tsx +4 -1
  12. package/src/components/mobile-top-bar.tsx +6 -0
  13. package/src/components/money.tsx +23 -0
  14. package/src/components/preferences-form.tsx +247 -52
  15. package/src/components/resource-table.tsx +7 -3
  16. package/src/hooks/use-display-name.ts +11 -3
  17. package/src/hooks/use-file-upload.ts +51 -0
  18. package/src/hooks/use-import.ts +5 -5
  19. package/src/hooks/use-money-locale.ts +14 -0
  20. package/src/index.ts +8 -3
  21. package/src/lib/currencies.ts +5 -2
  22. package/src/lib/form-mappers.ts +25 -5
  23. package/src/lib/formatters.ts +34 -4
  24. package/src/lib/fulfillment-items.test.ts +2 -1
  25. package/src/lib/fulfillment-items.ts +9 -3
  26. package/src/lib/i18n.ts +4 -4
  27. package/src/lib/iso-codes.ts +45 -0
  28. package/src/lib/money.test.ts +63 -0
  29. package/src/lib/money.ts +99 -0
  30. package/src/{products → lib}/normalize-money.ts +2 -2
  31. package/src/lib/return-refunds.test.ts +62 -0
  32. package/src/lib/return-refunds.ts +70 -0
  33. package/src/lib/route-registry.ts +58 -15
  34. package/src/lib/slot-registry.ts +10 -0
  35. package/src/lib/storefront.ts +2 -2
  36. package/src/locales/ar.json +4 -1
  37. package/src/locales/de.json +4 -1
  38. package/src/locales/en.json +4 -1
  39. package/src/locales/es.json +4 -1
  40. package/src/locales/fr.json +4 -1
  41. package/src/locales/pl.json +4 -1
  42. package/src/locales/zh-CN.json +4 -1
  43. package/src/plugin.ts +5 -5
  44. package/src/products/bulk-variants-dialog.tsx +1 -1
  45. package/src/products/product-bulk-price-editor.tsx +18 -29
  46. package/src/products/product-form-cards.tsx +12 -8
  47. package/src/products/product-form-mapping.ts +15 -11
  48. package/src/products/product-schema.ts +6 -8
  49. package/src/products/product-types.ts +5 -3
  50. package/src/products/use-product-form-data.ts +0 -28
  51. package/src/providers/store-provider.tsx +9 -9
  52. package/src/schemas/stock-location.ts +3 -2
  53. package/src/tables/package-types.tsx +1 -1
  54. package/src/tables/stock-locations.tsx +1 -1
  55. package/src/vite/discover.ts +33 -28
  56. package/src/hooks/use-direct-upload.ts +0 -117
  57. /package/src/{products → lib}/currency-parts.ts +0 -0
  58. /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 { 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,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
- * `: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
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: PreferenceFieldDef[],
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
- out[field.key] = field.default
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: PreferenceFieldDef[]
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?: string[]
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 `preference_schema` payload.
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. Booleans are switches, strings are
85
- * inputs, integers/decimals get number inputs, and unknown types fall
86
- * back to a plain text input.
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
- if (!schema?.length) return null
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
- {schema.map((field) => (
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?: string[]
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
- // 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)) {
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
- // `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') {
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
- <Input
377
+ <NumberPreferenceInput
232
378
  id={id}
233
- type="number"
234
- step={field.type === 'integer' ? 1 : 'any'}
235
- // An empty upper-bound field means "no limit" — surface that
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
- isMaxKey(field.key) ? t('admin.components.preferences_form.unlimited') : undefined
384
+ field.nullable ? t('admin.components.preferences_form.unlimited') : undefined
239
385
  }
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
- }}
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) => Number(a.threshold) - Number(b.threshold))
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
- /** Function that calls the SDK to fetch data */
180
- queryFn: (params: Record<string, unknown>) => Promise<{ data: T[]; meta: PaginationMeta }>
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 (the API name, the raw code, etc.).
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`, `?? country.name`).
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
- return (code: string) => formatter?.of(code)
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
+ }
@@ -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' | 'attachment' | 'preferred_delimiter' | 'results_url'
93
+ 'type' | 'attachment_signed_id' | 'delimiter' | 'results_url'
94
94
  >
95
95
 
96
96
  export interface CreateImportInput {
97
97
  type: string
98
- /** Signed blob id of the already direct-uploaded CSV (see `FileUploadField`). */
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 a direct-uploaded CSV; the response is in the
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
- attachment: signedId,
132
- preferred_delimiter: preferredDelimiter,
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
+ }