@spree/dashboard-core 1.0.0-beta.3 → 1.0.0-beta.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 (36) hide show
  1. package/package.json +7 -5
  2. package/src/api-client.ts +43 -0
  3. package/src/components/address-form-dialog.tsx +5 -2
  4. package/src/components/app-sidebar.tsx +16 -2
  5. package/src/components/currency-select.tsx +1 -15
  6. package/src/components/nav-main.tsx +50 -37
  7. package/src/components/sheet-top-bar.tsx +1 -1
  8. package/src/components/table-toolbar.tsx +9 -63
  9. package/src/components/top-bar-breadcrumbs.tsx +9 -2
  10. package/src/hooks/use-switch-admin-locale.ts +11 -2
  11. package/src/lib/currencies.ts +15 -0
  12. package/src/lib/form-mappers.ts +40 -0
  13. package/src/lib/fulfillment-items.test.ts +29 -0
  14. package/src/lib/fulfillment-items.ts +35 -17
  15. package/src/lib/i18n.ts +79 -4
  16. package/src/lib/permissions.ts +1 -1
  17. package/src/lib/settings-nav-registry.ts +27 -0
  18. package/src/lib/slot-registry.ts +18 -0
  19. package/src/lib/table-registry.ts +2 -2
  20. package/src/locales/ar.json +32 -2
  21. package/src/locales/de.json +32 -2
  22. package/src/locales/en.json +32 -2
  23. package/src/locales/es.json +2030 -0
  24. package/src/locales/fr.json +32 -2
  25. package/src/locales/pl.json +32 -2
  26. package/src/locales/zh-CN.json +32 -2
  27. package/src/pages/package-types.tsx +19 -17
  28. package/src/products/normalize-money.test.ts +9 -0
  29. package/src/products/normalize-money.ts +12 -0
  30. package/src/products/product-form-cards.tsx +40 -16
  31. package/src/products/product-form-mapping.test.ts +72 -0
  32. package/src/products/product-form-mapping.ts +3 -1
  33. package/src/products/product-types.ts +1 -0
  34. package/src/products/variants-section.tsx +5 -2
  35. package/src/providers/auth-provider.tsx +3 -3
  36. package/src/schemas/package-type.ts +7 -4
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-beta.3",
4
+ "version": "1.0.0-beta.4",
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",
@@ -25,6 +25,8 @@
25
25
  "types": "./src/index.ts",
26
26
  "exports": {
27
27
  ".": "./src/index.ts",
28
+ "./client": "./src/client.ts",
29
+ "./api-client": "./src/api-client.ts",
28
30
  "./plugin": "./src/plugin.ts",
29
31
  "./vite": {
30
32
  "types": "./src/vite/index.ts",
@@ -65,22 +67,22 @@
65
67
  "@dnd-kit/sortable": "^10.0.0",
66
68
  "@dnd-kit/utilities": "^3.2.2",
67
69
  "@tailwindcss/vite": "^4.2.4",
70
+ "@types/spark-md5": "^3.0.5",
68
71
  "date-fns": "^4.1.0",
69
72
  "date-fns-tz": "^3.2.0",
70
- "lucide-react": "^1.14.0",
73
+ "lucide-react": "^1.47.0",
71
74
  "spark-md5": "^3.0.2",
72
75
  "zod": "^4.4.3"
73
76
  },
74
77
  "devDependencies": {
75
78
  "@types/react": "^19.2.14",
76
79
  "@types/react-dom": "^19.2.3",
77
- "@types/spark-md5": "^3.0.5",
78
80
  "tsup": "^8.5.1",
79
81
  "typescript": "~6.0.3",
80
82
  "vite": "^8.0.16",
81
83
  "vitest": "^4.1.5",
82
- "@spree/admin-sdk": "^1.0.0-beta.2",
83
- "@spree/dashboard-ui": "^1.0.0-beta.3"
84
+ "@spree/admin-sdk": "^1.0.0-beta.3",
85
+ "@spree/dashboard-ui": "^1.0.0-beta.4"
84
86
  },
85
87
  "scripts": {
86
88
  "lint": "biome check src",
package/src/api-client.ts CHANGED
@@ -16,6 +16,34 @@
16
16
 
17
17
  import type { PermissionRule } from '@spree/admin-sdk'
18
18
 
19
+ /** What a panel may change on the signed-in person's own account. */
20
+ export interface PanelAccountParams {
21
+ first_name?: string
22
+ last_name?: string
23
+ /** The panel's UI language, as a bundle code the panel ships (e.g. `de`). */
24
+ selected_locale?: string
25
+ /**
26
+ * ActiveStorage signed id to set the photo, or `null` to remove it. Omit to
27
+ * leave the current one alone — the only field here where `null` means
28
+ * something the API acts on.
29
+ */
30
+ avatar?: string | null
31
+ }
32
+
33
+ /**
34
+ * The account fields both panels' `/me` answer with. Deliberately the subset
35
+ * they share — anything one API alone returns stays on its own client.
36
+ */
37
+ export interface PanelAccountUser {
38
+ id: string
39
+ email: string
40
+ first_name?: string | null
41
+ last_name?: string | null
42
+ full_name?: string | null
43
+ selected_locale?: string | null
44
+ avatar_url?: string | null
45
+ }
46
+
19
47
  /** What the permission provider needs, whichever panel asked. */
20
48
  export interface PanelPermissions {
21
49
  /**
@@ -90,6 +118,21 @@ export interface PanelApiClient {
90
118
  direct_upload: { url: string; headers: Record<string, string> }
91
119
  signed_id: string
92
120
  }>
121
+ /**
122
+ * Writes the signed-in person's own account — their name, photo and the
123
+ * panel's language.
124
+ *
125
+ * Registered rather than imported for the same reason as everything else
126
+ * here: the account menu's language switcher is one shared component, and
127
+ * reaching for `adminClient` inside it would send a seller's language
128
+ * change to an API they hold no credential for.
129
+ *
130
+ * Required, unlike most of this contract: the account menu lives in the
131
+ * shared sidebar, so every panel mounting the shell already offers the
132
+ * language switcher that calls this. An optional marker would describe a
133
+ * host that does not exist and push a runtime guard onto every caller.
134
+ */
135
+ updateAccount(params: PanelAccountParams): Promise<{ user: PanelAccountUser }>
93
136
  stockLocations?: {
94
137
  list(params?: Record<string, unknown>): Promise<{ data: PanelStockLocation[]; meta?: unknown }>
95
138
  get(id: string): Promise<PanelStockLocation>
@@ -226,8 +226,11 @@ export function AddressFormDialog({
226
226
  }, [address?.country_code, storeDefaultCountryCode, form])
227
227
 
228
228
  const countryCode = form.watch('country_code')
229
- const { states, statesRequired } = useCountryStates(countryCode)
230
- const useStateCombobox = statesRequired && states.length > 0
229
+ const { states } = useCountryStates(countryCode)
230
+ // Show the dropdown whenever the country enumerates subdivisions, not only
231
+ // when it strictly requires one — so a country like Pakistan still lets the
232
+ // user pick a province instead of typing a name that may not match.
233
+ const useStateCombobox = states.length > 0
231
234
 
232
235
  async function onSubmit(values: AddressFormValues) {
233
236
  try {
@@ -19,7 +19,7 @@ import { type NavEntry, resolveNavLabel, useNavEntries } from '../lib/nav-regist
19
19
  import type { ActionName, SubjectName } from '../lib/permissions'
20
20
  import { type Permissions, usePermissions } from '../providers/permission-provider'
21
21
  import { useOptionalStore } from '../providers/store-provider'
22
- import { type NavItem, NavMain } from './nav-main'
22
+ import { NavBottom, type NavItem, NavMain } from './nav-main'
23
23
  import { SidebarSearch } from './sidebar-search'
24
24
  import { SidebarUser } from './sidebar-user'
25
25
  import { StoreSwitcher } from './store-switcher'
@@ -146,6 +146,7 @@ export function AppSidebar({
146
146
  const { i18n } = useTranslation()
147
147
  const { storeId } = useParams({ strict: false }) as { storeId?: string }
148
148
  const { navItems, bottomItems, isLoading } = useNavItems(tenantId ?? storeId ?? 'default')
149
+ const { isMobile } = useSidebar()
149
150
 
150
151
  return (
151
152
  <Sidebar collapsible="icon" variant="inset" side={primarySidebarSide(i18n.language)} {...props}>
@@ -173,8 +174,21 @@ export function AppSidebar({
173
174
  Keyed on `isLoading` alone, not on an empty list: entries without a
174
175
  `subject` (Home, Getting Started) skip the permission filter, so the
175
176
  list is never actually empty and a length check would never fire. */}
176
- {isLoading ? <NavSkeleton /> : <NavMain items={navItems} bottomItems={bottomItems} />}
177
+ {isLoading ? (
178
+ <NavSkeleton />
179
+ ) : (
180
+ <>
181
+ <NavMain items={navItems} />
182
+ {/* Pinned to the foot of a full-height drawer, Settings would sit
183
+ alone below a screen of empty space, so on a phone it follows
184
+ the list. */}
185
+ {isMobile && <NavBottom items={bottomItems} className="mb-2" />}
186
+ </>
187
+ )}
177
188
  </SidebarContent>
189
+ {/* Outside the scroll area on desktop, so Settings stays in reach and the
190
+ list fades out above it rather than scrolling it away. */}
191
+ {!isLoading && !isMobile && <NavBottom items={bottomItems} className="py-2" />}
178
192
  <SidebarFooter className="hidden md:flex">
179
193
  <SidebarUser uiLocales={uiLocales} onEditProfile={onEditProfile} />
180
194
  </SidebarFooter>
@@ -13,21 +13,7 @@ import { useTranslation } from 'react-i18next'
13
13
  import { useDisplayName } from '../hooks/use-display-name'
14
14
  import { useOptionalStore } from '../providers/store-provider'
15
15
 
16
- /**
17
- * Every ISO 4217 currency code the runtime knows about — the active-currency
18
- * counterpart to the Rails admin's `Money::Currency.table` list. Used for
19
- * contexts where the merchant can pick *any* currency (a market's currency)
20
- * rather than one the store already supports. Degrades to an empty list on
21
- * runtimes without `Intl.supportedValuesOf`, so callers fall back to
22
- * `supported_currencies`.
23
- */
24
- export const ALL_CURRENCY_CODES: string[] = (() => {
25
- try {
26
- return Intl.supportedValuesOf('currency')
27
- } catch {
28
- return []
29
- }
30
- })()
16
+ export { ALL_CURRENCY_CODES } from '../lib/currencies'
31
17
 
32
18
  interface CurrencySelectProps {
33
19
  /** Classes for the trigger — used to match a neighbouring control's height. */
@@ -1,5 +1,4 @@
1
1
  import {
2
- cn,
3
2
  DropdownMenu,
4
3
  DropdownMenuContent,
5
4
  DropdownMenuItem,
@@ -279,20 +278,25 @@ function NavItemContent({
279
278
  )
280
279
  }
281
280
 
282
- export function NavMain({ items, bottomItems }: { items: NavItem[]; bottomItems?: NavItem[] }) {
283
- const routerState = useRouterState()
284
- const currentPath = routerState.location.pathname
285
- const { state, isMobile, setOpenMobile } = useSidebar()
286
- const isCollapsed = state === 'collapsed'
287
- // Close the drawer as the link is tapped. Done here rather than in an effect
288
- // watching the path: this tree renders *inside* the Sheet, so it remounts on
289
- // every open and an effect could not tell "just opened" from "just navigated".
290
- const handleLinkTap = useCallback(
281
+ // Close the drawer as the link is tapped. Done here rather than in an effect
282
+ // watching the path: this tree renders *inside* the Sheet, so it remounts on
283
+ // every open and an effect could not tell "just opened" from "just navigated".
284
+ function useCloseDrawerOnLinkTap() {
285
+ const { isMobile, setOpenMobile } = useSidebar()
286
+ return useCallback(
291
287
  (event: React.MouseEvent) => {
292
288
  if (isMobile && (event.target as HTMLElement).closest('a')) setOpenMobile(false)
293
289
  },
294
290
  [isMobile, setOpenMobile],
295
291
  )
292
+ }
293
+
294
+ export function NavMain({ items }: { items: NavItem[] }) {
295
+ const routerState = useRouterState()
296
+ const currentPath = routerState.location.pathname
297
+ const { state, isMobile } = useSidebar()
298
+ const isCollapsed = state === 'collapsed'
299
+ const handleLinkTap = useCloseDrawerOnLinkTap()
296
300
  // One controller for the whole rail so only a single hover menu is ever open.
297
301
  const hoverMenu = useHoverMenuController()
298
302
 
@@ -325,35 +329,44 @@ export function NavMain({ items, bottomItems }: { items: NavItem[]; bottomItems?
325
329
  ))}
326
330
  </SidebarMenu>
327
331
  </SidebarGroup>
332
+ </div>
333
+ )
334
+ }
328
335
 
329
- {bottomItems && bottomItems.length > 0 && (
330
- // `mt-auto` pins Settings to the foot of the desktop rail. In a
331
- // full-height drawer that strands it alone below a screen of empty
332
- // space, so on mobile it simply follows the list.
333
- <SidebarGroup className={cn('mb-2', !isMobile && 'mt-auto')}>
334
- <SidebarMenu>
335
- {bottomItems.map((item) => {
336
- const isActive =
337
- currentPath === item.url ||
338
- (item.url !== '/' && isPathWithin(currentPath, item.url))
336
+ /** The entries kept apart from the main list — Settings, and anything registered beside it. */
337
+ export function NavBottom({ items, className }: { items: NavItem[]; className?: string }) {
338
+ const currentPath = useRouterState().location.pathname
339
+ const isCollapsed = useSidebar().state === 'collapsed'
340
+ const handleLinkTap = useCloseDrawerOnLinkTap()
339
341
 
340
- return (
341
- <SidebarMenuItem key={item.title}>
342
- <SidebarMenuButton tooltip={item.title} asChild isActive={isActive}>
343
- {/* `aria-label` for the same reason as the main nav above:
344
- collapsed mode hides the label, and Settings lives here
345
- — the one place the rail is collapsed by default. */}
346
- <Link to={item.url} aria-label={isCollapsed ? item.title : undefined}>
347
- <NavIcon icon={item.icon} isActive={isActive} />
348
- <span>{item.title}</span>
349
- </Link>
350
- </SidebarMenuButton>
351
- </SidebarMenuItem>
352
- )
353
- })}
354
- </SidebarMenu>
355
- </SidebarGroup>
356
- )}
342
+ if (items.length === 0) return null
343
+
344
+ return (
345
+ // biome-ignore lint/a11y/noStaticElementInteractions: delegated link taps only
346
+ // biome-ignore lint/a11y/useKeyWithClickEvents: links keep their own keyboard behaviour
347
+ <div onClick={handleLinkTap} className="contents">
348
+ <SidebarGroup className={className}>
349
+ <SidebarMenu>
350
+ {items.map((item) => {
351
+ const isActive =
352
+ currentPath === item.url || (item.url !== '/' && isPathWithin(currentPath, item.url))
353
+
354
+ return (
355
+ <SidebarMenuItem key={item.title}>
356
+ <SidebarMenuButton tooltip={item.title} asChild isActive={isActive}>
357
+ {/* `aria-label` for the same reason as the main nav: collapsed
358
+ mode hides the label, and Settings lives here — the one
359
+ place the rail is collapsed by default. */}
360
+ <Link to={item.url} aria-label={isCollapsed ? item.title : undefined}>
361
+ <NavIcon icon={item.icon} isActive={isActive} />
362
+ <span>{item.title}</span>
363
+ </Link>
364
+ </SidebarMenuButton>
365
+ </SidebarMenuItem>
366
+ )
367
+ })}
368
+ </SidebarMenu>
369
+ </SidebarGroup>
357
370
  </div>
358
371
  )
359
372
  }
@@ -34,7 +34,7 @@ export function SheetTopBar({
34
34
  if (!collapsed) return null
35
35
 
36
36
  return (
37
- <div className="hidden min-h-8 items-center gap-2 px-4 pt-3 md:flex lg:px-6">
37
+ <div className="hidden min-h-8 items-center gap-2 px-4 pt-3 md:flex">
38
38
  {!inSettings && (
39
39
  <SidebarTrigger className="-ms-1 shrink-0 text-muted-foreground hover:bg-accent hover:text-foreground" />
40
40
  )}
@@ -306,8 +306,6 @@ export function TableToolbar({
306
306
  <PopoverContent align="start" className="w-[min(480px,calc(100vw-1rem))] p-0">
307
307
  <FilterPanel
308
308
  columns={panelColumns}
309
- allColumns={filterableColumns}
310
- quickFilterKeys={quickFilterKeys}
311
309
  filters={filters}
312
310
  onApply={(f) => {
313
311
  onFiltersChange(f)
@@ -1233,22 +1231,14 @@ function InlineValueList({
1233
1231
  */
1234
1232
  function FilterPanel({
1235
1233
  columns,
1236
- allColumns,
1237
- quickFilterKeys,
1238
1234
  filters,
1239
1235
  onApply,
1240
1236
  onChange,
1241
1237
  }: {
1242
- /** Fields offered first — those with no quick control of their own. */
1238
+ /** The fields with no quick control of their own. A field that has one is
1239
+ * filtered there alone — its checkbox list already says "is" and "is not",
1240
+ * so offering it here too only listed the same field twice. */
1243
1241
  columns: ColumnDef[]
1244
- /** Every filterable field. The ones absent from `columns` are listed after
1245
- * them, for the operators their quick control cannot express. */
1246
- allColumns: ColumnDef[]
1247
- /** Which fields have a quick control on the row. Such a field goes to the
1248
- * operator picker rather than the one-click value list: the list writes
1249
- * `eq`, and only `in` is the operator a control displays, so a one-click
1250
- * `eq` would chip while its control still read "All". */
1251
- quickFilterKeys: Set<string>
1252
1242
  filters: FilterRule[]
1253
1243
  /** Commit and close — for the filters that are finished in one action. */
1254
1244
  onApply: (filters: FilterRule[]) => void
@@ -1269,9 +1259,7 @@ function FilterPanel({
1269
1259
  // strand a half-entered range.
1270
1260
  const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null)
1271
1261
 
1272
- // Resolved against every field, not just the offered ones: the switch
1273
- // select can land on a quick-filtered field the list deliberately omits.
1274
- const column = allColumns.find((c) => c.key === field)
1262
+ const column = columns.find((c) => c.key === field)
1275
1263
  const type = column?.filterType ?? 'string'
1276
1264
  const operators = useMemo(
1277
1265
  () => getOperators(type).map((op) => ({ value: op.value, label: t(op.labelKey) })),
@@ -1284,18 +1272,12 @@ function FilterPanel({
1284
1272
  // Types whose values are chosen from a list rather than typed. `multi` ones
1285
1273
  // accumulate a set and need an explicit Apply; the single ones commit on the
1286
1274
  // click that picks them.
1287
- // A field whose quick control already covers the straightforward case is
1288
- // here for the operators that control cannot express, so it skips the
1289
- // shorthands — the value list and the date presets — and goes to the
1290
- // operator picker. Those shorthands write exactly what the control already
1291
- // writes, which is the one thing the panel does not need to offer twice.
1292
- const quickFiltered = Boolean(field) && quickFilterKeys.has(field as string)
1293
- const multi = !quickFiltered && (type === 'tags' || type === 'resource' || type === 'currency')
1275
+ const multi = type === 'tags' || type === 'resource' || type === 'currency'
1294
1276
  // Dates are their own shape: a list of relative presets, with the calendar
1295
1277
  // behind "custom". Same presets the quick filter offers, so a range means
1296
1278
  // the same thing wherever it was set.
1297
- const dated = !quickFiltered && type === 'date'
1298
- const listed = !quickFiltered && (type === 'enum' || type === 'boolean' || multi)
1279
+ const dated = type === 'date'
1280
+ const listed = type === 'enum' || type === 'boolean' || multi
1299
1281
  // The header search filters the list below it, so it earns its place only
1300
1282
  // when there is a list: the fields, or a list-shaped type's values.
1301
1283
  const searchable = (!field || listed) && !dated
@@ -1319,18 +1301,8 @@ function FilterPanel({
1319
1301
  return matching(columns)
1320
1302
  }, [columns, query])
1321
1303
 
1322
- // The fields a quick control already owns, listed under their own heading
1323
- // below the rest. They are the only route to an operator the control cannot
1324
- // express, and burying them entirely is what left `status is not active`
1325
- // unbuildable from anywhere.
1326
- const visibleQuickFields = useMemo(() => {
1327
- const offered = allColumns.filter((c) => quickFilterKeys.has(c.key))
1328
- const needle = query.trim().toLowerCase()
1329
- return needle ? offered.filter((c) => c.label.toLowerCase().includes(needle)) : offered
1330
- }, [allColumns, quickFilterKeys, query])
1331
-
1332
1304
  function chooseField(key: string) {
1333
- const col = allColumns.find((c) => c.key === key)
1305
+ const col = columns.find((c) => c.key === key)
1334
1306
  const firstOperator = getOperators(col?.filterType ?? 'string')[0].value
1335
1307
  setField(key)
1336
1308
  setOperator(firstOperator)
@@ -1507,32 +1479,6 @@ function FilterPanel({
1507
1479
  </button>
1508
1480
  ))}
1509
1481
 
1510
- {/* The fields that already have a control on the row. Set apart under
1511
- a heading rather than mixed in, because picking one here means
1512
- something narrower than picking any field above: not "filter by
1513
- status" — the control does that — but "filter by status in a way
1514
- the control cannot say". */}
1515
- {!field && visibleQuickFields.length > 0 && (
1516
- <>
1517
- {visibleFields.length > 0 && <div className="my-1 border-t" />}
1518
- <p className="px-2 py-1.5 text-xs text-muted-foreground">
1519
- {t('admin.components.table_toolbar.more_conditions')}
1520
- </p>
1521
- {visibleQuickFields.map((col) => (
1522
- <button
1523
- key={col.key}
1524
- type="button"
1525
- data-slot="filter-panel-item"
1526
- className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1527
- onClick={() => chooseField(col.key)}
1528
- >
1529
- {col.label}
1530
- <ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
1531
- </button>
1532
- ))}
1533
- </>
1534
- )}
1535
-
1536
1482
  {/* Stage two, list-shaped types: the values themselves, one click to
1537
1483
  apply. No operator control — "is" is the only thing a list of
1538
1484
  values can mean, and offering "is not" here would double the list's
@@ -1737,7 +1683,7 @@ function FilterPanel({
1737
1683
 
1738
1684
  {/* Nothing matched the search — say so rather than showing a blank
1739
1685
  panel that reads as broken. */}
1740
- {((!field && visibleFields.length === 0 && visibleQuickFields.length === 0) ||
1686
+ {((!field && visibleFields.length === 0) ||
1741
1687
  (field && listed && !multi && listOptions.length === 0)) && (
1742
1688
  <p className="px-2 py-6 text-center text-muted-foreground">
1743
1689
  {t('admin.common.no_results')}
@@ -11,7 +11,11 @@ import { Link, useParams, useRouterState } from '@tanstack/react-router'
11
11
  import { Fragment, type ReactNode, useMemo } from 'react'
12
12
  import { useTranslation } from 'react-i18next'
13
13
  import { isPathWithin, type NavEntry, resolveNavLabel, useNavEntries } from '../lib/nav-registry'
14
- import { type SettingsNavEntry, useSettingsNav } from '../lib/settings-nav-registry'
14
+ import {
15
+ resolveSettingsBreadcrumbLabel,
16
+ type SettingsNavEntry,
17
+ useSettingsNav,
18
+ } from '../lib/settings-nav-registry'
15
19
 
16
20
  interface Crumb {
17
21
  label: string
@@ -190,7 +194,10 @@ function buildCrumbs({
190
194
  const crumbs: Crumb[] = [{ label: t('admin.settings_page.title'), to: settingsRoot }]
191
195
  const entry = bestMatch(settings, (e) => `${settingsRoot}${e.path}`, pathname)
192
196
  if (entry) {
193
- crumbs.push({ label: resolveNavLabel(entry, t), to: `${settingsRoot}${entry.path}` })
197
+ crumbs.push({
198
+ label: resolveSettingsBreadcrumbLabel(entry, t),
199
+ to: `${settingsRoot}${entry.path}`,
200
+ })
194
201
  }
195
202
  return crumbs
196
203
  }
@@ -1,6 +1,6 @@
1
1
  import { toastManager } from '@spree/dashboard-ui'
2
2
  import { useTranslation } from 'react-i18next'
3
- import { adminClient } from '../client'
3
+ import { getApiClient } from '../api-client'
4
4
  import { i18n, markGenuineLocaleChoice, switchLocale } from '../lib/i18n'
5
5
  import { useAuth } from './use-auth'
6
6
 
@@ -36,8 +36,17 @@ export function useSwitchAdminLocale() {
36
36
  return async (code: string): Promise<void> => {
37
37
  // Already adopted on the account AND on screen — nothing to do.
38
38
  if (code === user?.selected_locale && code === i18n.language) return
39
+ // Resolved OUTSIDE the try: `getApiClient` throws a named error when no
40
+ // client is registered, and that is a misconfigured host rather than a
41
+ // failed save — caught below it would surface as "we could not save your
42
+ // language", which sends the reader looking in the wrong place.
43
+ //
44
+ // Through the registered client rather than `adminClient`: this hook runs
45
+ // in the account menu, which both panels mount, and a seller's language
46
+ // change sent to the Admin API is a request they hold no credential for.
47
+ const { updateAccount } = getApiClient()
39
48
  try {
40
- const { user: updated } = await adminClient.me.update({ selected_locale: code })
49
+ const { user: updated } = await updateAccount({ selected_locale: code })
41
50
  updateUser(updated)
42
51
  if (code !== i18n.language) {
43
52
  // Displayed language changes — persist the genuine choice + reload.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Every ISO 4217 currency code the runtime knows about — the active-currency
3
+ * counterpart to the Rails admin's `Money::Currency.table` list. Used for
4
+ * contexts where the merchant can pick *any* currency (a market's currency)
5
+ * rather than one the store already supports. Degrades to an empty list on
6
+ * runtimes without `Intl.supportedValuesOf`, so callers fall back to
7
+ * `supported_currencies`.
8
+ */
9
+ export const ALL_CURRENCY_CODES: string[] = (() => {
10
+ try {
11
+ return Intl.supportedValuesOf('currency')
12
+ } catch {
13
+ return []
14
+ }
15
+ })()
@@ -1,3 +1,5 @@
1
+ import type { PanelAccountParams } from '../api-client'
2
+
1
3
  /**
2
4
  * Treat a blank or whitespace-only string as "not set" — common when mapping
3
5
  * form values to API params, where a blank `<input>` should become `undefined`
@@ -104,3 +106,41 @@ export function editableAddressToStockLocationParams(values: {
104
106
  phone: values.phone,
105
107
  }
106
108
  }
109
+
110
+ /**
111
+ * The signed-in person's own account, mapped from either panel's edit form to
112
+ * the params its API accepts.
113
+ *
114
+ * Shared because the three rules here are the same on both panels and each is
115
+ * easy to get subtly wrong:
116
+ *
117
+ * - A name is sent as typed, empty string included. Blank is how a person
118
+ * removes a name they once gave — the model normalizes it away — so
119
+ * coercing blank to `undefined` would drop the field and leave the old name
120
+ * in place while the form reported success.
121
+ * - A blank language is omitted instead: it means none was chosen, never
122
+ * "clear the one I have".
123
+ * - The photo is a three-state field — a fresh upload sends its signed id, an
124
+ * explicit clear sends `null` to purge the attachment, and an untouched
125
+ * field is left out entirely.
126
+ */
127
+ export function accountFormToParams(values: {
128
+ first_name?: string | null
129
+ last_name?: string | null
130
+ selected_locale?: string | null
131
+ avatar_signed_id: string | null
132
+ avatar_cleared: boolean
133
+ }): PanelAccountParams {
134
+ return {
135
+ first_name: values.first_name ?? undefined,
136
+ last_name: values.last_name ?? undefined,
137
+ selected_locale: blankToUndefined(values.selected_locale),
138
+ ...avatarParam(values.avatar_signed_id, values.avatar_cleared),
139
+ }
140
+ }
141
+
142
+ function avatarParam(signedId: string | null, cleared: boolean) {
143
+ if (signedId) return { avatar: signedId }
144
+ if (cleared) return { avatar: null }
145
+ return {}
146
+ }
@@ -82,6 +82,35 @@ describe('fulfillmentItemRows', () => {
82
82
  })
83
83
  })
84
84
 
85
+ it('names an exchange replacement by its own variant, not the original line item', () => {
86
+ const rows = fulfillmentItemRows(
87
+ fulfillment({
88
+ fulfillment_items: [
89
+ {
90
+ line_item_id: 'li_1',
91
+ variant_id: 'variant_2',
92
+ name: 'Blender',
93
+ options_text: 'White',
94
+ quantity: 1,
95
+ },
96
+ ],
97
+ }),
98
+ [lineItem({ variant_id: 'variant_1' })],
99
+ )
100
+
101
+ expect(rows).toEqual([
102
+ {
103
+ key: 'li_1:variant_2',
104
+ lineItem: expect.objectContaining({ id: 'li_1' }),
105
+ name: 'Blender',
106
+ optionsText: 'White',
107
+ thumbnailUrl: null,
108
+ displayPrice: null,
109
+ quantity: 1,
110
+ },
111
+ ])
112
+ })
113
+
85
114
  it('keys an item with no line item by its variant so it still renders', () => {
86
115
  const rows = fulfillmentItemRows(
87
116
  fulfillment({