@spree/dashboard-core 1.0.0-rc.4 → 1.0.0-rc.6

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 (70) hide show
  1. package/package.json +10 -7
  2. package/src/api-client.ts +76 -7
  3. package/src/components/address-form-dialog.tsx +2 -2
  4. package/src/components/country-combobox.tsx +1 -2
  5. package/src/components/currency-amounts-field.tsx +93 -0
  6. package/src/components/currency-select.tsx +6 -7
  7. package/src/components/file-upload-field.tsx +1 -0
  8. package/src/components/locale-select.tsx +22 -23
  9. package/src/components/nav-main.tsx +7 -2
  10. package/src/components/preferences-form.tsx +2 -8
  11. package/src/components/settings-sidebar.tsx +8 -1
  12. package/src/components/slot.tsx +21 -1
  13. package/src/components/table-toolbar.tsx +102 -39
  14. package/src/hooks/use-display-name.ts +1 -18
  15. package/src/hooks/use-file-upload.ts +2 -1
  16. package/src/hooks/use-import.ts +3 -2
  17. package/src/index.ts +17 -3
  18. package/src/lib/currencies.ts +2 -34
  19. package/src/lib/currency-amounts.test.ts +147 -0
  20. package/src/lib/currency-amounts.ts +172 -0
  21. package/src/lib/currency-parts.ts +2 -21
  22. package/src/lib/filters-to-ransack.ts +4 -1
  23. package/src/lib/form-fields-registry.ts +23 -1
  24. package/src/lib/form-mappers.ts +1 -1
  25. package/src/lib/formatters.ts +6 -10
  26. package/src/lib/fulfillment-items.ts +11 -0
  27. package/src/lib/i18n.ts +1 -12
  28. package/src/lib/iso-codes.ts +9 -26
  29. package/src/lib/money.ts +9 -99
  30. package/src/lib/normalize-money.ts +2 -85
  31. package/src/lib/return-refunds.ts +1 -2
  32. package/src/lib/route-registry.ts +29 -0
  33. package/src/lib/settings-nav-registry.ts +6 -1
  34. package/src/lib/store-config.test.ts +93 -0
  35. package/src/lib/store-config.ts +76 -0
  36. package/src/lib/table-registry.ts +16 -5
  37. package/src/locales/ar.json +51 -0
  38. package/src/locales/de.json +51 -0
  39. package/src/locales/en.json +51 -0
  40. package/src/locales/es.json +51 -0
  41. package/src/locales/fr.json +51 -0
  42. package/src/locales/pl.json +51 -0
  43. package/src/locales/zh-CN.json +51 -0
  44. package/src/pages/package-types.tsx +50 -11
  45. package/src/pages/settings-index.tsx +4 -1
  46. package/src/pages/stock-locations.tsx +31 -6
  47. package/src/products/assignable-variants.ts +35 -0
  48. package/src/products/bulk-variants-dialog.tsx +27 -28
  49. package/src/products/digital-asset-edit-sheet.tsx +192 -0
  50. package/src/products/digital-asset-provider-sheet.tsx +88 -0
  51. package/src/products/digital-assets-card.tsx +451 -0
  52. package/src/products/media-edit-sheet.tsx +68 -20
  53. package/src/products/normalize-quantity.ts +2 -3
  54. package/src/products/product-bulk-price-editor.tsx +1 -3
  55. package/src/products/product-form-cards.tsx +67 -37
  56. package/src/products/product-form-mapping.ts +44 -5
  57. package/src/products/product-schema.test.ts +8 -0
  58. package/src/products/product-schema.ts +14 -7
  59. package/src/products/product-types.ts +1 -1
  60. package/src/products/provider-settings-fields.tsx +110 -0
  61. package/src/products/sheet-save-handlers.ts +37 -0
  62. package/src/products/use-digital-assets.ts +66 -0
  63. package/src/products/variant-edit-sheet.tsx +21 -11
  64. package/src/products/variants-section.tsx +4 -1
  65. package/src/providers/store-provider.tsx +31 -7
  66. package/src/schemas/package-type.ts +14 -12
  67. package/src/schemas/stock-location.ts +2 -3
  68. package/src/tables/stock-locations.tsx +5 -8
  69. package/src/lib/money.test.ts +0 -63
  70. package/src/lib/normalize-money.test.ts +0 -67
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spree/dashboard-core",
3
3
  "license": "MIT",
4
- "version": "1.0.0-rc.4",
4
+ "version": "1.0.0-rc.6",
5
5
  "description": "Spree Dashboard framework — registries (table, nav, slot, settings-nav), providers (auth, permission, store), admin SDK client, generic infra hooks, and the defineDashboardPlugin extension API. Developer Preview.",
6
6
  "author": "Vendo Connect Inc.",
7
7
  "homepage": "https://spreecommerce.org",
@@ -63,23 +63,26 @@
63
63
  "@dnd-kit/core": "^6.3.1",
64
64
  "@dnd-kit/sortable": "^10.0.0",
65
65
  "@dnd-kit/utilities": "^3.2.2",
66
- "@hookform/resolvers": "^5.2.2",
66
+ "@hookform/resolvers": "^6.0.0",
67
67
  "@tailwindcss/vite": "^4.2.4",
68
- "@tanstack/react-hotkeys": "^0.14.3",
68
+ "@tanstack/react-hotkeys": "^0.16.0",
69
69
  "date-fns": "^4.4.0",
70
70
  "date-fns-tz": "^3.2.0",
71
- "lucide-react": "^1.58.0",
72
- "zod": "^4.6.7"
71
+ "lucide-react": "^1.60.0",
72
+ "zod": "^4.6.7",
73
+ "@spree/core": "^0.3.0"
73
74
  },
74
75
  "devDependencies": {
76
+ "@tanstack/react-query": "^5.104.5",
77
+ "@tanstack/react-router": "^1.170.45",
75
78
  "@types/react": "^19.2.14",
76
79
  "@types/react-dom": "^19.2.3",
77
80
  "tsup": "^9.0.0",
78
81
  "typescript": "~7.0.2",
79
82
  "vite": "^8.3.4",
80
83
  "vitest": "^5.0.5",
81
- "@spree/admin-sdk": "^1.0.0-rc.4",
82
- "@spree/dashboard-ui": "^1.0.0-rc.4"
84
+ "@spree/dashboard-ui": "^1.0.0-rc.6",
85
+ "@spree/admin-sdk": "^1.0.0-rc.6"
83
86
  },
84
87
  "scripts": {
85
88
  "lint": "biome check src",
package/src/api-client.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { PackageTypeKind, PickupStockPolicy } from '@spree/core'
1
2
  /**
2
3
  * The client registry.
3
4
  *
@@ -14,7 +15,13 @@
14
15
  * promise the seller panel methods its API does not have.
15
16
  */
16
17
 
17
- import type { CountryCode, PermissionRule, UploadFileOptions } from '@spree/admin-sdk'
18
+ import type {
19
+ DigitalAssetProvider,
20
+ PaginationMeta,
21
+ PermissionRule,
22
+ UploadFileOptions,
23
+ } from '@spree/admin-sdk'
24
+ import type { CountryCode, DimensionUnit, WeightUnit } from '@spree/core'
18
25
 
19
26
  /** What a panel may change on the signed-in person's own account. */
20
27
  export interface PanelAccountParams {
@@ -227,6 +234,29 @@ export interface PanelApiClient {
227
234
  }
228
235
  /** Removing a file already on a product. */
229
236
  deleteProductMedia?(productId: string, mediaId: string): Promise<void>
237
+ /**
238
+ * The downloadable files a digital product delivers to its buyers.
239
+ *
240
+ * Registered rather than imported for the same reason as everything else
241
+ * here: the Digital files card is one component serving both panels.
242
+ * `providers` is operator-only — a provider is the marketplace's own code
243
+ * reaching into its internal systems — so a seller's client leaves it out
244
+ * and the card offers file uploads alone.
245
+ */
246
+ digitalAssets?: {
247
+ list(
248
+ productId: string,
249
+ params?: { page?: number; limit?: number },
250
+ ): Promise<{ data: PanelDigitalAsset[]; meta?: PaginationMeta }>
251
+ create(productId: string, params: PanelDigitalAssetParams): Promise<PanelDigitalAsset>
252
+ update(
253
+ productId: string,
254
+ id: string,
255
+ params: PanelDigitalAssetParams,
256
+ ): Promise<PanelDigitalAsset>
257
+ delete(productId: string, id: string): Promise<void>
258
+ providers?(productId: string): Promise<{ data: DigitalAssetProvider[] }>
259
+ }
230
260
  /**
231
261
  * The store-wide media library, for placing a file already uploaded onto
232
262
  * another product. Operator-only: a seller sees their own product's files,
@@ -354,10 +384,10 @@ export interface PanelPackageTypeParams {
354
384
  length?: number | null
355
385
  width?: number | null
356
386
  height?: number | null
357
- dimensions_unit?: PanelPackageDimensionUnit | null
387
+ dimensions_unit?: DimensionUnit | null
358
388
  weight?: number | null
359
389
  max_weight?: number | null
360
- weight_unit?: PanelPackageWeightUnit | null
390
+ weight_unit?: WeightUnit | null
361
391
  default?: boolean
362
392
  /**
363
393
  * Not nullable: both SDKs take a merge, so clearing a key is sending the
@@ -386,9 +416,12 @@ export interface PanelPackageTypeWrites extends PanelPackageTypeReads {
386
416
  delete?(id: string): Promise<void>
387
417
  }
388
418
 
389
- export type PanelPackageTypeKind = 'box' | 'envelope' | 'carton' | 'pallet' | 'container'
390
- export type PanelPackageDimensionUnit = 'mm' | 'cm' | 'in' | 'ft'
391
- export type PanelPackageWeightUnit = 'g' | 'kg' | 'lb' | 'oz'
419
+ /** @deprecated Use `PackageTypeKind` from `@spree/core`. */
420
+ export type PanelPackageTypeKind = PackageTypeKind
421
+ /** @deprecated Use `DimensionUnit` from `@spree/core`. */
422
+ export type PanelPackageDimensionUnit = DimensionUnit
423
+ /** @deprecated Use `WeightUnit` from `@spree/core`. */
424
+ export type PanelPackageWeightUnit = WeightUnit
392
425
 
393
426
  /**
394
427
  * A new row must say what it is and what to call it — packaging without
@@ -492,6 +525,42 @@ export interface PanelExport {
492
525
  filename?: string | null
493
526
  }
494
527
 
528
+ /**
529
+ * A downloadable file, as the Digital files card reads it. Structural, like
530
+ * the other records here: both serializers carry these fields, and only the
531
+ * operator's adds `provider_settings`.
532
+ */
533
+ export interface PanelDigitalAsset {
534
+ id: string
535
+ variant_id: string | null
536
+ filename: string | null
537
+ content_type: string | null
538
+ byte_size: number | null
539
+ /** Null means the store's download settings apply. */
540
+ authorized_clicks: number | null
541
+ authorized_days: number | null
542
+ /** The limits in force, after the store-settings fallback. */
543
+ effective_authorized_clicks: number
544
+ effective_authorized_days: number
545
+ provider_type: string | null
546
+ provider_name: string
547
+ download_url: string | null
548
+ provider_settings?: Record<string, unknown>
549
+ }
550
+
551
+ /**
552
+ * What either panel writes. The provider fields are the operator's alone; a
553
+ * seller's API refuses them.
554
+ */
555
+ export interface PanelDigitalAssetParams {
556
+ signed_id?: string
557
+ variant_id?: string
558
+ authorized_clicks?: number | null
559
+ authorized_days?: number | null
560
+ provider_type?: string
561
+ provider_settings?: Record<string, unknown>
562
+ }
563
+
495
564
  /** A library file, as the picker lists it. */
496
565
  export interface PanelMediaRecord {
497
566
  id: string
@@ -598,7 +667,7 @@ export type PanelStockLocationParams = Partial<
598
667
  * because plugins register their own, but the pickup policy is a fixed pair
599
668
  * the server validates, and both SDKs type it that way.
600
669
  */
601
- pickup_stock_policy?: 'local' | 'any'
670
+ pickup_stock_policy?: PickupStockPolicy
602
671
  }
603
672
 
604
673
  /**
@@ -1,5 +1,5 @@
1
1
  import { zodResolver } from '@hookform/resolvers/zod'
2
- import type { CountryCode } from '@spree/admin-sdk'
2
+ import { type CountryCode, isCountryCode } from '@spree/core'
3
3
  import {
4
4
  Button,
5
5
  Checkbox,
@@ -22,7 +22,7 @@ import { Controller, type DefaultValues, useForm } from 'react-hook-form'
22
22
  import { useTranslation } from 'react-i18next'
23
23
  import { z } from 'zod/v4'
24
24
  import { mapSpreeErrorsToForm } from '../lib/form-errors'
25
- import { countryCodeSchema, isCountryCode } from '../lib/iso-codes'
25
+ import { countryCodeSchema } from '../lib/iso-codes'
26
26
  import { useOptionalStore } from '../providers/store-provider'
27
27
  import { CountryCombobox } from './country-combobox'
28
28
  import { StateCombobox, useCountryStates } from './country-state-fields'
@@ -1,4 +1,4 @@
1
- import type { CountryCode } from '@spree/admin-sdk'
1
+ import { type CountryCode, isCountryCode } from '@spree/core'
2
2
  import {
3
3
  Combobox,
4
4
  ComboboxButtonTrigger,
@@ -19,7 +19,6 @@ import { useCallback, useMemo, useState } from 'react'
19
19
  import { useTranslation } from 'react-i18next'
20
20
  import { useCountries } from '../hooks/use-countries'
21
21
  import { useDisplayName } from '../hooks/use-display-name'
22
- import { isCountryCode } from '../lib/iso-codes'
23
22
 
24
23
  export type CountryOption = { iso: CountryCode; iso3: string }
25
24
 
@@ -0,0 +1,93 @@
1
+ import { currencyParts, isCurrencyCode } from '@spree/core'
2
+ import {
3
+ Field,
4
+ FieldError,
5
+ FieldLabel,
6
+ InputGroup,
7
+ InputGroupAddon,
8
+ InputGroupInput,
9
+ InputGroupText,
10
+ } from '@spree/dashboard-ui'
11
+ import { useMemo } from 'react'
12
+ import { useTranslation } from 'react-i18next'
13
+ import { amountForCurrency, applyCurrencyAmount } from '../lib/currency-amounts'
14
+
15
+ /**
16
+ * The amount an amount-based delivery calculator charges, one input per
17
+ * currency the store sells in, written to the per-currency `amounts`
18
+ * preference. A currency left blank has no amount, and the method is not
19
+ * offered to those carts. A single-currency store sees one plain amount
20
+ * field.
21
+ */
22
+ export function CurrencyAmountsField({
23
+ values,
24
+ onChange,
25
+ currencies,
26
+ defaultCurrency,
27
+ error,
28
+ }: {
29
+ values: Record<string, unknown>
30
+ onChange: (next: Record<string, unknown>) => void
31
+ /** The currencies the store sells in. */
32
+ currencies: readonly string[]
33
+ defaultCurrency: string
34
+ error?: { message?: string }
35
+ }) {
36
+ const { t, i18n } = useTranslation()
37
+
38
+ // The store's currency list is the source of truth, but a method may already
39
+ // carry an amount in a currency the store has since stopped selling in —
40
+ // dropping that input would silently discard the amount on the next save.
41
+ const amounts = (values.amounts ?? {}) as Record<string, unknown>
42
+ const extraCodes = Object.keys(amounts).join(',')
43
+ const codes = useMemo(() => {
44
+ const supported = currencies.length > 0 ? currencies : [defaultCurrency]
45
+ const extra = extraCodes
46
+ .split(',')
47
+ .filter(isCurrencyCode)
48
+ .filter((code) => !supported.includes(code))
49
+ return [defaultCurrency, ...supported.filter((code) => code !== defaultCurrency), ...extra]
50
+ }, [currencies, defaultCurrency, extraCodes])
51
+
52
+ const multiCurrency = codes.length > 1
53
+
54
+ function amountFor(code: string): string {
55
+ const raw = amountForCurrency(values, code, defaultCurrency)
56
+ return raw === null ? '' : String(raw)
57
+ }
58
+
59
+ return (
60
+ <Field>
61
+ <FieldLabel htmlFor={`calculator-amount-${defaultCurrency}`}>
62
+ {t('admin.currency_amounts.label')}
63
+ </FieldLabel>
64
+ <div className="flex flex-col gap-2">
65
+ {codes.map((code) => (
66
+ <InputGroup key={code}>
67
+ <InputGroupAddon>
68
+ <InputGroupText>{currencyParts(code, i18n.language).symbol}</InputGroupText>
69
+ </InputGroupAddon>
70
+ <InputGroupInput
71
+ id={`calculator-amount-${code}`}
72
+ type="number"
73
+ step="any"
74
+ min="0"
75
+ aria-label={t('admin.currency_amounts.amount_in_currency', { currency: code })}
76
+ value={amountFor(code)}
77
+ onChange={(event) =>
78
+ onChange(applyCurrencyAmount(values, code, event.target.value, defaultCurrency))
79
+ }
80
+ />
81
+ <InputGroupAddon align="inline-end">
82
+ <InputGroupText>{code}</InputGroupText>
83
+ </InputGroupAddon>
84
+ </InputGroup>
85
+ ))}
86
+ </div>
87
+ {multiCurrency && (
88
+ <span className="text-muted-foreground text-xs">{t('admin.currency_amounts.help')}</span>
89
+ )}
90
+ <FieldError errors={[error]} />
91
+ </Field>
92
+ )
93
+ }
@@ -1,4 +1,4 @@
1
- import type { CurrencyCode } from '@spree/admin-sdk'
1
+ import { type CurrencyCode, isCurrencyCode } from '@spree/core'
2
2
  import {
3
3
  Combobox,
4
4
  ComboboxButtonTrigger,
@@ -12,10 +12,9 @@ import {
12
12
  import { useMemo, useState } from 'react'
13
13
  import { useTranslation } from 'react-i18next'
14
14
  import { useDisplayName } from '../hooks/use-display-name'
15
- import { isCurrencyCode } from '../lib/iso-codes'
16
15
  import { useOptionalStore } from '../providers/store-provider'
17
16
 
18
- export { ALL_CURRENCY_CODES } from '../lib/currencies'
17
+ export { ACTIVE_CURRENCY_CODES as ALL_CURRENCY_CODES } from '@spree/core'
19
18
 
20
19
  interface CurrencySelectProps {
21
20
  /** Classes for the trigger — used to match a neighbouring control's height. */
@@ -33,8 +32,8 @@ interface CurrencySelectProps {
33
32
  /** Fires on every selection change. */
34
33
  onChange?: (currency: CurrencyCode) => void
35
34
  /**
36
- * Currency codes to pick from. Defaults to the current store's
37
- * `supported_currencies`. Pass `ALL_CURRENCY_CODES` (or any custom list) for
35
+ * Currency codes to pick from. Defaults to the currencies of the current
36
+ * store's markets. Pass `ALL_CURRENCY_CODES` (or any custom list) for
38
37
  * contexts where the merchant chooses a currency the store doesn't yet
39
38
  * support — e.g. a market's currency.
40
39
  */
@@ -55,8 +54,8 @@ interface CurrencySelectProps {
55
54
  }
56
55
 
57
56
  /**
58
- * Picker for a currency code. Defaults to the current store's
59
- * `supported_currencies` (and the store's `default_currency`), so callers
57
+ * Picker for a currency code. Defaults to the currencies of the
58
+ * current store's markets (the default market's first), so callers
60
59
  * choosing among already-configured currencies don't have to wire anything up.
61
60
  * Pass `options` (e.g. `ALL_CURRENCY_CODES`) when the merchant may pick any
62
61
  * currency, such as a market's currency. Each option reads `CODE — Full Name`
@@ -270,6 +270,7 @@ export function FileUploadField({
270
270
  </AttachmentContent>
271
271
  <AttachmentActions>
272
272
  <AttachmentAction
273
+ type="button"
273
274
  aria-label={t('admin.actions.remove')}
274
275
  onClick={handleRemove}
275
276
  disabled={disabled || !!pending}
@@ -13,13 +13,13 @@ import {
13
13
  ComboboxValue,
14
14
  useComboboxAnchor,
15
15
  } from '@spree/dashboard-ui'
16
- import { useMemo, useState } from 'react'
16
+ import { useCallback, useMemo, useState } from 'react'
17
17
  import { useTranslation } from 'react-i18next'
18
18
  import { useDisplayName } from '../hooks/use-display-name'
19
19
  import { useStore } from '../providers/store-provider'
20
20
 
21
21
  interface BaseProps {
22
- /** Locale codes to pick from. Defaults to the current store's `supported_locales`. */
22
+ /** Locale codes to pick from. Defaults to the languages of the current store's markets. */
23
23
  options?: string[]
24
24
  /** Locale to filter out (e.g. the default locale when picking additional supported ones). */
25
25
  excludeCode?: string
@@ -63,16 +63,6 @@ interface MultiProps extends BaseProps {
63
63
 
64
64
  type LocaleSelectProps = SingleProps | MultiProps
65
65
 
66
- /**
67
- * Resolves a locale code (`en`, `pt-BR`) to its display name (`English`,
68
- * `Portuguese (Brazil)`) in the admin UI language, falling back to the code
69
- * itself when the runtime lacks coverage.
70
- */
71
- function useLocaleDisplayName() {
72
- const displayName = useDisplayName('language')
73
- return (code: string) => displayName(code) ?? code
74
- }
75
-
76
66
  /**
77
67
  * Mirrors `CurrencySelect`'s row format: `CODE — Localized Name`. Codes the
78
68
  * runtime cannot name (`Intl.DisplayNames` returns the code back, as it does
@@ -96,6 +86,16 @@ function localeFilter(labelFor: (locale: string) => string) {
96
86
  }
97
87
  }
98
88
 
89
+ /**
90
+ * Returns a function that labels a locale code the way the dashboard shows
91
+ * locales everywhere: `EN — English`, named in the admin UI language. For
92
+ * plain-text contexts (option lists, search); render `LocaleLabel` in JSX.
93
+ */
94
+ export function useLocaleLabel() {
95
+ const displayName = useDisplayName('language')
96
+ return useCallback((code: string) => formatOption(code, displayName(code) ?? code), [displayName])
97
+ }
98
+
99
99
  /**
100
100
  * Static text label for a locale code: `EN — English`. Use this in tables
101
101
  * and read-only contexts where the picker isn't needed but the same
@@ -103,16 +103,16 @@ function localeFilter(labelFor: (locale: string) => string) {
103
103
  * has no entry.
104
104
  */
105
105
  export function LocaleLabel({ code }: { code: string }) {
106
- const displayNameFor = useLocaleDisplayName()
106
+ const labelFor = useLocaleLabel()
107
107
  if (!code) return null
108
- return <>{formatOption(code, displayNameFor(code))}</>
108
+ return <>{labelFor(code)}</>
109
109
  }
110
110
 
111
111
  /**
112
- * Picker for one of the current store's `supported_locales`. Single-select by
113
- * default; pass `multiple` for a chips multi-select. Each option reads
114
- * `CODE — Localized Name` (e.g. `EN — English`), localized via the browser's
115
- * `Intl.DisplayNames` against the store's `default_locale`. Use this anywhere
112
+ * Picker for one of the languages the current store's markets offer.
113
+ * Single-select by default; pass `multiple` for a chips multi-select. Each
114
+ * option reads `CODE — Localized Name` (e.g. `EN — English`), named in the
115
+ * admin UI language via the browser's `Intl.DisplayNames`. Use this anywhere
116
116
  * the merchant is choosing a storefront language (market default, supported
117
117
  * locales list, customer preference, etc.).
118
118
  *
@@ -124,7 +124,7 @@ export function LocaleLabel({ code }: { code: string }) {
124
124
  export function LocaleSelect(props: LocaleSelectProps) {
125
125
  const { t } = useTranslation()
126
126
  const { locales: storeLocales } = useStore()
127
- const displayNameFor = useLocaleDisplayName()
127
+ const localeLabel = useLocaleLabel()
128
128
 
129
129
  // Union of `props.options` (everything the picker should show) and the
130
130
  // currently selected values — so editing an existing record doesn't
@@ -145,15 +145,14 @@ export function LocaleSelect(props: LocaleSelectProps) {
145
145
  // per character; a lookup map keeps it to a single pass per list change.
146
146
  const labels = useMemo(() => {
147
147
  const map = new Map<string, string>()
148
- for (const locale of items) map.set(locale, formatOption(locale, displayNameFor(locale)))
148
+ for (const locale of items) map.set(locale, localeLabel(locale))
149
149
  return map
150
- }, [items, displayNameFor])
150
+ }, [items, localeLabel])
151
151
 
152
152
  // `CODE — Name` label for a locale, from the precomputed map (falls back to a
153
153
  // direct lookup for a value not in the current option list, e.g. a selected
154
154
  // chip the list later dropped).
155
- const labelFor = (locale: string) =>
156
- labels.get(locale) ?? formatOption(locale, displayNameFor(locale))
155
+ const labelFor = (locale: string) => labels.get(locale) ?? localeLabel(locale)
157
156
 
158
157
  if (props.multiple) {
159
158
  return (
@@ -183,6 +183,11 @@ function CollapsedDropdown({
183
183
  <DropdownMenuItem key={subItem.title} asChild>
184
184
  <Link to={subItem.url} className="no-underline">
185
185
  {subItem.title}
186
+ {subItem.badge && (
187
+ <span className="ms-auto shrink-0 ps-3">
188
+ <subItem.badge />
189
+ </span>
190
+ )}
186
191
  </Link>
187
192
  </DropdownMenuItem>
188
193
  ))}
@@ -261,9 +266,9 @@ function NavItemContent({
261
266
  <SidebarMenuSubItem key={subItem.title}>
262
267
  <SidebarMenuSubButton asChild isActive={subActive}>
263
268
  <Link to={subItem.url}>
264
- <span>{subItem.title}</span>
269
+ <span className="truncate">{subItem.title}</span>
265
270
  {subItem.badge && (
266
- <span className="ml-auto">
271
+ <span className="ml-auto shrink-0">
267
272
  <subItem.badge />
268
273
  </span>
269
274
  )}
@@ -1,10 +1,5 @@
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'
1
+ import type { PreferencePropertySchema, PreferenceSchema } from '@spree/admin-sdk'
2
+ import { type CurrencyCode, compareMoney, currencyParts, isDecimalString } from '@spree/core'
8
3
  import {
9
4
  Button,
10
5
  Field,
@@ -29,7 +24,6 @@ import { PlusIcon, TrashIcon } from '@spree/dashboard-ui/icons'
29
24
  import { useId, useMemo, useState } from 'react'
30
25
  import { useTranslation } from 'react-i18next'
31
26
  import { useMoneyLocale } from '../hooks/use-money-locale'
32
- import { currencyParts } from '../lib/currency-parts'
33
27
  import { CountryCombobox, CountryMultiCombobox } from './country-combobox'
34
28
  import { CurrencySelect } from './currency-select'
35
29
  import { StoreDatePicker } from './store-date-picker'
@@ -358,8 +358,15 @@ function SettingsItem({
358
358
  <SidebarMenuButton asChild isActive={isActive}>
359
359
  <Link to={url} tabIndex={tabIndex}>
360
360
  <NavIcon icon={Icon} isActive={isActive} />
361
- <span>{label}</span>
361
+ {/* `truncate` so a long translated label gives way to the badge
362
+ rather than pushing it out of the sidebar. */}
363
+ <span className="truncate">{label}</span>
362
364
  {entry.comingSoon && <SidebarMenuBadge className="ms-auto">Soon</SidebarMenuBadge>}
365
+ {entry.badge && (
366
+ <span className="ms-auto shrink-0">
367
+ <entry.badge />
368
+ </span>
369
+ )}
363
370
  </Link>
364
371
  </SidebarMenuButton>
365
372
  </SidebarMenuItem>
@@ -1,7 +1,8 @@
1
1
  import { Fragment } from 'react'
2
+ import { FormProvider, type UseFormReturn } from 'react-hook-form'
2
3
  import { type SlotAmbientContext, useSlotEntries } from '../lib/slot-registry'
3
4
 
4
- interface SlotProps<TContext> {
5
+ export interface SlotProps<TContext> {
5
6
  /** Slot name, e.g. "product.form_sidebar" or "order.dropdown". */
6
7
  name: string
7
8
  /** Slot-specific context passed to every registered component. */
@@ -48,3 +49,22 @@ export function Slot<TContext extends object = object>({
48
49
  </>
49
50
  )
50
51
  }
52
+
53
+ /**
54
+ * A slot inside a form that isn't wrapped in `FormProvider` — a create or edit
55
+ * sheet. Widgets rendered here reach the form with `useHostForm()`, so fields
56
+ * they bind save with the sheet's own submit.
57
+ */
58
+ export function HostFormSlot<TContext extends object = object>({
59
+ form,
60
+ ...slot
61
+ }: SlotProps<TContext> & {
62
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
63
+ form: UseFormReturn<any, any, any>
64
+ }) {
65
+ return (
66
+ <FormProvider {...form}>
67
+ <Slot {...slot} />
68
+ </FormProvider>
69
+ )
70
+ }