@spree/dashboard-core 6.0.3 → 6.0.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 (86) hide show
  1. package/dist/vite/index.js +81 -5
  2. package/package.json +19 -19
  3. package/src/api-client.ts +7 -1
  4. package/src/client.ts +2 -0
  5. package/src/components/address-form-dialog.tsx +75 -28
  6. package/src/components/app-shell.tsx +5 -4
  7. package/src/components/app-sidebar.tsx +2 -1
  8. package/src/components/bulk-action-bar.tsx +6 -5
  9. package/src/components/country-state-fields.tsx +3 -0
  10. package/src/components/currency-select.tsx +6 -4
  11. package/src/components/developer-inspector-drawer.tsx +322 -0
  12. package/src/components/file-upload-field.tsx +64 -37
  13. package/src/components/image-upload-field.tsx +4 -0
  14. package/src/components/import-wizard-dialog.tsx +14 -12
  15. package/src/components/invitation-acceptance.tsx +15 -11
  16. package/src/components/media-editor-dialog.tsx +131 -0
  17. package/src/components/media-picker-dialog.tsx +866 -0
  18. package/src/components/media-picker-sheet.tsx +13 -300
  19. package/src/components/mobile-top-bar.tsx +1 -1
  20. package/src/components/nav-main.tsx +13 -2
  21. package/src/components/page-header.tsx +57 -13
  22. package/src/components/page-tabs.tsx +1 -1
  23. package/src/components/product-stock-summary.tsx +1 -1
  24. package/src/components/read-row.tsx +38 -0
  25. package/src/components/resource-combobox.tsx +1 -1
  26. package/src/components/resource-image-field.tsx +150 -0
  27. package/src/components/resource-picker-sheet.tsx +2 -2
  28. package/src/components/resource-table.tsx +161 -40
  29. package/src/components/section-heading.tsx +2 -2
  30. package/src/components/settings-container.tsx +19 -0
  31. package/src/components/settings-fields.tsx +165 -0
  32. package/src/components/settings-section.tsx +46 -0
  33. package/src/components/settings-sidebar.tsx +35 -18
  34. package/src/components/sheet-top-bar.tsx +7 -8
  35. package/src/components/sidebar-search.tsx +11 -12
  36. package/src/components/sidebar-user.tsx +39 -44
  37. package/src/components/skip-link.tsx +1 -1
  38. package/src/components/store-switcher.tsx +1 -1
  39. package/src/components/table-toolbar.tsx +189 -323
  40. package/src/components/timezone-combobox.tsx +119 -0
  41. package/src/components/top-bar-breadcrumbs.tsx +1 -1
  42. package/src/hooks/use-stock-locations.ts +6 -2
  43. package/src/hooks/use-table-density.ts +50 -0
  44. package/src/index.ts +19 -2
  45. package/src/lib/curl.test.ts +18 -0
  46. package/src/lib/curl.ts +28 -0
  47. package/src/lib/developer-inspector.test.ts +169 -0
  48. package/src/lib/developer-inspector.ts +134 -0
  49. package/src/lib/docs.ts +3 -2
  50. package/src/lib/fulfillment-items.test.ts +51 -0
  51. package/src/lib/fulfillment-items.ts +53 -0
  52. package/src/lib/request-snippets.ts +41 -0
  53. package/src/lib/table-registry.ts +13 -6
  54. package/src/lib/timestamp-columns.tsx +1 -1
  55. package/src/locales/ar.json +120 -14
  56. package/src/locales/de.json +120 -14
  57. package/src/locales/en.json +120 -14
  58. package/src/locales/es.json +120 -14
  59. package/src/locales/fr.json +120 -14
  60. package/src/locales/ja.json +120 -14
  61. package/src/locales/pl.json +120 -14
  62. package/src/locales/vi.json +120 -14
  63. package/src/locales/zh-CN.json +120 -14
  64. package/src/pages/settings-index.tsx +79 -48
  65. package/src/pages/stock-locations.tsx +14 -6
  66. package/src/products/digital-asset-edit-sheet.tsx +1 -1
  67. package/src/products/digital-asset-provider-sheet.tsx +1 -1
  68. package/src/products/digital-assets-card.tsx +7 -7
  69. package/src/products/inventory-section.tsx +2 -2
  70. package/src/products/media-edit-sheet.tsx +136 -133
  71. package/src/products/product-form-cards.tsx +201 -70
  72. package/src/products/product-form-mapping.ts +1 -0
  73. package/src/products/product-schema.ts +8 -0
  74. package/src/products/product-types.ts +2 -0
  75. package/src/products/use-product-form-data.ts +30 -3
  76. package/src/products/variant-edit-sheet.tsx +3 -3
  77. package/src/products/variants-options-builder.tsx +13 -8
  78. package/src/products/variants-section.tsx +196 -4
  79. package/src/providers/auth-provider.tsx +2 -0
  80. package/src/providers/developer-inspector-provider.tsx +145 -0
  81. package/src/providers/json-link-resolver-provider.tsx +27 -0
  82. package/src/sellers/seller-schema.ts +107 -0
  83. package/src/tables/package-types.tsx +3 -3
  84. package/src/tables/stock-locations.tsx +15 -14
  85. package/src/virtual.d.ts +4 -0
  86. package/src/vite/index.ts +103 -5
@@ -5,7 +5,81 @@ import { fileURLToPath } from "node:url";
5
5
  import tailwindcss from "@tailwindcss/vite";
6
6
  import { discoverDashboardPlugins } from "./discover.js";
7
7
  function spreeDashboardPlugin(options = {}) {
8
- return [dashboardProseMirrorDedupePlugin(), dashboardTailwindSourcePlugin(options), tailwindcss()];
8
+ return [
9
+ dashboardProseMirrorDedupePlugin(),
10
+ dashboardTailwindSourcePlugin(options),
11
+ tailwindcss(),
12
+ dashboardFontPreloadPlugin(),
13
+ dashboardPackagesPlugin()
14
+ ];
15
+ }
16
+ function dashboardPackagesPlugin() {
17
+ return {
18
+ name: "spree:dashboard-packages",
19
+ config(config) {
20
+ const root = path.resolve(config.root ?? process.cwd());
21
+ return { define: { __SPREE_PACKAGES__: JSON.stringify(installedSpreePackages(root)) } };
22
+ }
23
+ };
24
+ }
25
+ function installedSpreePackages(root) {
26
+ const versions = {};
27
+ const visit = (packageRoot, includeDev) => {
28
+ const manifest = readJson(path.join(packageRoot, "package.json"));
29
+ const dependencies = {
30
+ ...manifest?.dependencies,
31
+ ...manifest?.peerDependencies,
32
+ ...includeDev ? manifest?.devDependencies : {}
33
+ };
34
+ const require2 = createRequire(path.join(packageRoot, "package.json"));
35
+ for (const name of Object.keys(dependencies)) {
36
+ if (!name.startsWith("@spree/") || name in versions) continue;
37
+ const dependencyRoot = resolvePackageRoot(name, require2);
38
+ const dependency = dependencyRoot && readJson(path.join(dependencyRoot, "package.json"));
39
+ if (!dependencyRoot || !dependency || dependency.private) continue;
40
+ versions[name] = dependency.version;
41
+ visit(dependencyRoot, false);
42
+ }
43
+ };
44
+ visit(root, true);
45
+ return Object.fromEntries(Object.entries(versions).sort(([a], [b]) => a.localeCompare(b)));
46
+ }
47
+ function readJson(file) {
48
+ try {
49
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+ const PRELOADED_FONT_FILE = "inter-latin-opsz-normal.woff2";
55
+ function dashboardFontPreloadPlugin() {
56
+ let base = "/";
57
+ return {
58
+ name: "spree:dashboard-font-preload",
59
+ apply: "build",
60
+ configResolved(config) {
61
+ base = config.base;
62
+ },
63
+ transformIndexHtml(_html, ctx) {
64
+ const font = Object.values(ctx.bundle ?? {}).find(
65
+ (file) => file.type === "asset" && file.names.includes(PRELOADED_FONT_FILE)
66
+ );
67
+ if (!font) return;
68
+ return [
69
+ {
70
+ tag: "link",
71
+ attrs: {
72
+ rel: "preload",
73
+ href: `${base}${font.fileName}`,
74
+ as: "font",
75
+ type: "font/woff2",
76
+ crossorigin: ""
77
+ },
78
+ injectTo: "head"
79
+ }
80
+ ];
81
+ }
82
+ };
9
83
  }
10
84
  function dashboardProseMirrorDedupePlugin() {
11
85
  return {
@@ -129,12 +203,13 @@ function formatPluginErrorOverlay(errors) {
129
203
  return lines.join("\n");
130
204
  }
131
205
  function resolvePackageSourceDir(pkg, require2) {
206
+ const packageRoot = resolvePackageRoot(pkg, require2);
207
+ return packageRoot && path.join(packageRoot, "src");
208
+ }
209
+ function resolvePackageRoot(pkg, require2) {
132
210
  try {
133
211
  const entryPath = require2.resolve(pkg);
134
- const entryFile = entryPath.startsWith("file:") ? fileURLToPath(entryPath) : entryPath;
135
- const packageRoot = findPackageRoot(entryFile);
136
- if (!packageRoot) return null;
137
- return path.join(packageRoot, "src");
212
+ return findPackageRoot(entryPath.startsWith("file:") ? fileURLToPath(entryPath) : entryPath);
138
213
  } catch {
139
214
  return null;
140
215
  }
@@ -151,5 +226,6 @@ function findPackageRoot(fromFile) {
151
226
  export {
152
227
  VIRTUAL_PLUGIN_MODULE_ID,
153
228
  generateVirtualPluginModule,
229
+ installedSpreePackages,
154
230
  spreeDashboardPlugin
155
231
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spree/dashboard-core",
3
3
  "license": "MIT",
4
- "version": "6.0.3",
4
+ "version": "6.0.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.",
6
6
  "author": "Vendo Connect Inc.",
7
7
  "homepage": "https://spreecommerce.org",
@@ -49,45 +49,45 @@
49
49
  "LICENSE"
50
50
  ],
51
51
  "peerDependencies": {
52
- "@spree/admin-sdk": ">=6.0.3 <7.0.0-0",
53
- "@spree/dashboard-ui": ">=6.0.3 <7.0.0-0",
52
+ "@spree/admin-sdk": ">=6.0.4 <7.0.0-0",
53
+ "@spree/dashboard-ui": ">=6.0.4 <7.0.0-0",
54
54
  "@tanstack/react-query": "^5.100.9",
55
55
  "@tanstack/react-router": "^1.169.2",
56
- "i18next": "^26.2.0",
56
+ "i18next": "^26.2.0 || ^27.0.0",
57
57
  "react": "^19.2.6",
58
58
  "react-dom": "^19.2.6",
59
59
  "react-hook-form": "^7.75.0",
60
- "react-i18next": "^17.0.8"
60
+ "react-i18next": "^17.0.8 || ^18.0.0"
61
61
  },
62
62
  "dependencies": {
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": "^6.0.0",
67
- "@tailwindcss/vite": "^4.2.4",
68
- "@tanstack/react-hotkeys": "0.16.0",
66
+ "@hookform/resolvers": "^7.0.5",
67
+ "@tailwindcss/vite": "^4.3.5",
68
+ "@tanstack/react-hotkeys": "0.23.0",
69
69
  "date-fns": "4.4.0",
70
70
  "date-fns-tz": "3.2.0",
71
- "lucide-react": "1.60.0",
71
+ "lucide-react": "1.71.0",
72
72
  "zod": "^4.6.7",
73
- "@spree/core": "^6.0.3"
73
+ "@spree/core": "^6.0.4"
74
74
  },
75
75
  "devDependencies": {
76
- "@tanstack/react-query": "^5.104.5",
77
- "@tanstack/react-router": "^1.170.45",
76
+ "@tanstack/react-query": "^5.107.1",
77
+ "@tanstack/react-router": "^1.170.46",
78
78
  "@types/react": "^19.2.14",
79
79
  "@types/react-dom": "^19.2.3",
80
- "i18next": "^26.5.3",
80
+ "i18next": "^27.1.1",
81
81
  "react": "^19.3.1",
82
82
  "react-dom": "^19.3.1",
83
- "react-hook-form": "^7.90.0",
84
- "react-i18next": "^17.1.0",
83
+ "react-hook-form": "^7.91.0",
84
+ "react-i18next": "^18.0.1",
85
85
  "tsup": "^9.0.0",
86
86
  "typescript": "~7.0.2",
87
- "vite": "^8.3.4",
88
- "vitest": "^5.0.5",
89
- "@spree/dashboard-ui": "^6.0.3",
90
- "@spree/admin-sdk": "^6.0.3"
87
+ "vite": "^8.3.7",
88
+ "vitest": "^5.1.0",
89
+ "@spree/admin-sdk": "^6.0.4",
90
+ "@spree/dashboard-ui": "^6.0.4"
91
91
  },
92
92
  "scripts": {
93
93
  "lint": "biome check src",
package/src/api-client.ts CHANGED
@@ -212,7 +212,7 @@ export interface PanelApiClient {
212
212
  * A download is a bare `fetch`, not an SDK call, so it bypasses whatever
213
213
  * the client normally attaches — which on the seller panel is
214
214
  * `X-Spree-Seller-Id`, and without it every Seller API request is refused
215
- * before the action runs. The admin panel needs none, so it registers none.
215
+ * before the action runs; on the admin panel, `X-Spree-Store-Id`.
216
216
  */
217
217
  downloadHeaders?(): Record<string, string>
218
218
  /**
@@ -265,6 +265,12 @@ export interface PanelApiClient {
265
265
  */
266
266
  mediaLibrary?: {
267
267
  list(params?: Record<string, unknown>): Promise<{ data: PanelMediaRecord[] }>
268
+ /** Adds an uploaded blob to the library, so the picker can offer uploads. */
269
+ create?(params: {
270
+ signed_id: string
271
+ alt?: string
272
+ media_type?: 'image' | 'video'
273
+ }): Promise<PanelMediaRecord>
268
274
  }
269
275
  /**
270
276
  * CSV imports, for whichever catalog this panel manages.
package/src/client.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createAdminClient } from '@spree/admin-sdk'
2
+ import { recordRequest } from './lib/developer-inspector'
2
3
 
3
4
  // `VITE_SPREE_API_URL` controls whether the SDK builds relative or absolute URLs.
4
5
  // Three deployment topologies, one code path:
@@ -22,4 +23,5 @@ const baseUrl = import.meta.env.VITE_SPREE_API_URL || ''
22
23
  export const adminClient = createAdminClient({
23
24
  baseUrl,
24
25
  // No initial token — auth-provider bootstraps via /auth/refresh on mount.
26
+ onRequestComplete: recordRequest,
25
27
  })
@@ -16,6 +16,7 @@ import {
16
16
  SheetFooter,
17
17
  SheetHeader,
18
18
  SheetTitle,
19
+ useConfirm,
19
20
  } from '@spree/dashboard-ui'
20
21
  import { useEffect, useRef } from 'react'
21
22
  import { Controller, type DefaultValues, useForm } from 'react-hook-form'
@@ -53,10 +54,7 @@ const addressFormFields = {
53
54
  .string()
54
55
  .trim()
55
56
  .min(1, { error: requiredMessage('city') }),
56
- postal_code: z
57
- .string()
58
- .trim()
59
- .min(1, { error: requiredMessage('address.postal_code') }),
57
+ postal_code: z.string(),
60
58
  country_code: countryCodeSchema,
61
59
  state_code: z.string(),
62
60
  phone: z.string(),
@@ -99,6 +97,33 @@ const businessSchema = z.object({
99
97
 
100
98
  type AddressFormValues = z.infer<typeof personalSchema>
101
99
 
100
+ /**
101
+ * The fields whose requirement depends on the chosen country. Checked in the
102
+ * schema rather than on submit, so they report alongside every other missing
103
+ * field instead of on a second attempt.
104
+ */
105
+ function withCountryRules(
106
+ schema: typeof personalSchema | typeof businessSchema,
107
+ rules: { statesRequired: boolean; postalCodeRequired: boolean },
108
+ ) {
109
+ return schema.superRefine((values, ctx) => {
110
+ if (rules.statesRequired && !values.state_code.trim()) {
111
+ ctx.addIssue({
112
+ code: 'custom',
113
+ path: ['state_code'],
114
+ message: requiredMessage('state_code')(),
115
+ })
116
+ }
117
+ if (rules.postalCodeRequired && !values.postal_code.trim()) {
118
+ ctx.addIssue({
119
+ code: 'custom',
120
+ path: ['postal_code'],
121
+ message: requiredMessage('address.postal_code')(),
122
+ })
123
+ }
124
+ })
125
+ }
126
+
102
127
  /** Address rows validate on `state`; every address form binds `state_code`. */
103
128
  const ADDRESS_SERVER_FIELD_ALIASES = { state: 'state_code' } as const satisfies Partial<
104
129
  Record<string, keyof AddressFormValues>
@@ -200,15 +225,43 @@ export function AddressFormDialog({
200
225
  const resolvedTitle = title ?? t('admin.components.address_form_dialog.edit_title')
201
226
  // Optional: the seller panel has no store context, so a new address there
202
227
  // stays country-empty rather than refusing to render.
203
- const storeDefaultCountryCode = useOptionalStore()?.defaultCountry
228
+ const storeContext = useOptionalStore()
229
+ const storeDefaultCountryCode = storeContext?.defaultCountry
230
+ // Left unmarked rather than marked optional when the store asks for a
231
+ // phone: a stock location never requires one, so "required" would be wrong
232
+ // there.
233
+ const phoneOptional = !storeContext?.store?.address_requires_phone
204
234
  const form = useForm<AddressFormValues>({
205
235
  defaultValues: buildDefaults(address, storeDefaultCountryCode),
206
- resolver: zodResolver(business ? businessSchema : personalSchema),
236
+ resolver: (values, context, options) =>
237
+ zodResolver(
238
+ withCountryRules(business ? businessSchema : personalSchema, countryRules.current),
239
+ )(values, context, options),
207
240
  })
208
- const { errors } = form.formState
241
+ const { errors, isDirty } = form.formState
242
+ const confirm = useConfirm()
243
+ // Saving a filled-in address waits for a change. A blank one stays
244
+ // saveable, so pressing Save shows what is still missing.
245
+ const saveNeedsChange = Boolean(address?.address1)
246
+
247
+ // Closing with an edit asks first, whichever way the sheet is closed.
248
+ async function requestClose() {
249
+ if (isDirty && !isPending) {
250
+ const ok = await confirm({
251
+ title: t('admin.actions.discard_changes'),
252
+ message: t('admin.common.unsaved_changes'),
253
+ variant: 'destructive',
254
+ confirmLabel: t('admin.actions.discard'),
255
+ })
256
+ if (!ok) return
257
+ }
258
+ onOpenChange(false)
259
+ }
209
260
 
210
261
  const countryCode = form.watch('country_code')
211
- const { states, statesRequired } = useCountryStates(countryCode)
262
+ const { states, statesRequired, postalCodeRequired } = useCountryStates(countryCode)
263
+ const countryRules = useRef({ statesRequired, postalCodeRequired })
264
+ countryRules.current = { statesRequired, postalCodeRequired }
212
265
 
213
266
  // The parent keys the dialog on the address id so a fresh instance mounts
214
267
  // for each open, but `address` can also stream in async (loaded after the
@@ -239,14 +292,6 @@ export function AddressFormDialog({
239
292
  const useStateCombobox = states.length > 0
240
293
 
241
294
  async function onSubmit(values: AddressFormValues) {
242
- if (statesRequired && !values.state_code.trim()) {
243
- form.setError('state_code', {
244
- type: 'manual',
245
- message: requiredMessage('state_code')(),
246
- })
247
- return
248
- }
249
-
250
295
  try {
251
296
  await onSave({
252
297
  first_name: values.first_name,
@@ -285,7 +330,7 @@ export function AddressFormDialog({
285
330
  }
286
331
 
287
332
  return (
288
- <Sheet open={open} onOpenChange={(o) => onOpenChange(o as boolean)}>
333
+ <Sheet open={open} onOpenChange={(o) => (o ? onOpenChange(true) : requestClose())}>
289
334
  <SheetContent side="right">
290
335
  <SheetHeader>
291
336
  <SheetTitle>{resolvedTitle}</SheetTitle>
@@ -300,14 +345,14 @@ export function AddressFormDialog({
300
345
  >
301
346
  <div className="flex-1 overflow-y-auto p-4">
302
347
  {errors.root?.message && (
303
- <p className="mb-4 text-sm text-destructive" role="alert">
348
+ <p className="mb-4 text-base text-destructive" role="alert">
304
349
  {errors.root.message}
305
350
  </p>
306
351
  )}
307
352
  <FieldGroup>
308
353
  {showLabel && (
309
354
  <Field>
310
- <FieldLabel htmlFor="addr-label">
355
+ <FieldLabel htmlFor="addr-label" optional>
311
356
  {t('admin.fields.address.label.label')}
312
357
  </FieldLabel>
313
358
  <Input
@@ -366,7 +411,7 @@ export function AddressFormDialog({
366
411
  <FieldError errors={[errors.address1]} />
367
412
  </Field>
368
413
  <Field>
369
- <FieldLabel htmlFor="addr-a2">
414
+ <FieldLabel htmlFor="addr-a2" optional>
370
415
  {t('admin.fields.address.address2.label')}
371
416
  </FieldLabel>
372
417
  <Input
@@ -410,7 +455,7 @@ export function AddressFormDialog({
410
455
  </Field>
411
456
  {useStateCombobox ? (
412
457
  <Field>
413
- <FieldLabel htmlFor="addr-state">
458
+ <FieldLabel htmlFor="addr-state" optional={!statesRequired}>
414
459
  {t('admin.fields.state_code.label')}
415
460
  </FieldLabel>
416
461
  <Controller
@@ -432,7 +477,7 @@ export function AddressFormDialog({
432
477
  </Field>
433
478
  ) : (
434
479
  <Field>
435
- <FieldLabel htmlFor="addr-state-abbr">
480
+ <FieldLabel htmlFor="addr-state-abbr" optional={!statesRequired}>
436
481
  {t('admin.fields.state_code.label')}
437
482
  </FieldLabel>
438
483
  <Input
@@ -445,7 +490,7 @@ export function AddressFormDialog({
445
490
  )}
446
491
  </div>
447
492
  <Field>
448
- <FieldLabel htmlFor="addr-zip">
493
+ <FieldLabel htmlFor="addr-zip" optional={!postalCodeRequired}>
449
494
  {t('admin.fields.address.postal_code.label')}
450
495
  </FieldLabel>
451
496
  <Input
@@ -456,7 +501,9 @@ export function AddressFormDialog({
456
501
  <FieldError errors={[errors.postal_code]} />
457
502
  </Field>
458
503
  <Field>
459
- <FieldLabel htmlFor="addr-phone">{t('admin.fields.phone.label')}</FieldLabel>
504
+ <FieldLabel htmlFor="addr-phone" optional={phoneOptional}>
505
+ {t('admin.fields.phone.label')}
506
+ </FieldLabel>
460
507
  <Input
461
508
  id="addr-phone"
462
509
  aria-invalid={!!errors.phone || undefined}
@@ -473,7 +520,7 @@ export function AddressFormDialog({
473
520
  render={({ field }) => (
474
521
  <label
475
522
  htmlFor="addr-default-billing"
476
- className="flex cursor-pointer items-center gap-2 text-sm"
523
+ className="flex cursor-pointer items-center gap-2 text-base"
477
524
  >
478
525
  <Checkbox
479
526
  id="addr-default-billing"
@@ -492,7 +539,7 @@ export function AddressFormDialog({
492
539
  render={({ field }) => (
493
540
  <label
494
541
  htmlFor="addr-default-shipping"
495
- className="flex cursor-pointer items-center gap-2 text-sm"
542
+ className="flex cursor-pointer items-center gap-2 text-base"
496
543
  >
497
544
  <Checkbox
498
545
  id="addr-default-shipping"
@@ -509,10 +556,10 @@ export function AddressFormDialog({
509
556
  </FieldGroup>
510
557
  </div>
511
558
  <SheetFooter>
512
- <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
559
+ <Button type="button" variant="outline" onClick={requestClose}>
513
560
  {t('admin.actions.cancel')}
514
561
  </Button>
515
- <Button type="submit" disabled={isPending}>
562
+ <Button type="submit" disabled={isPending || (saveNeedsChange && !isDirty)}>
516
563
  {isPending ? t('admin.actions.saving') : t('admin.actions.save')}
517
564
  </Button>
518
565
  </SheetFooter>
@@ -103,10 +103,11 @@ export function AppShell({
103
103
  a scroll container of its own to pin inside, so it can never ride up
104
104
  over the rail's switcher.
105
105
 
106
- `flex-row` so the secondary sidebar sits flush against the primary and
107
- spans the sheet's full height. */}
106
+ `flex-row` so the page's own columns sit side by side. */}
107
+ {/* The settings nav sits in the chrome between the rail and the sheet,
108
+ on the sidebar's ground, so the sheet holds only the page. */}
109
+ <SettingsSidebar open={inSettings} tenantId={tenantId} />
108
110
  <SidebarInset className="min-h-0 flex-row overflow-hidden">
109
- <SettingsSidebar open={inSettings} tenantId={tenantId} />
110
111
  {/* `tabIndex={-1}`: Page Up/Down, Home and End act on the focused
111
112
  element, and this is the element that scrolls. Without it focus
112
113
  stays on <body>, which has nothing to scroll now that the sheet
@@ -138,7 +139,7 @@ export function AppShell({
138
139
  <div
139
140
  ref={scrollerRef}
140
141
  tabIndex={-1}
141
- className="themed-scrollbar flex min-w-0 flex-1 flex-col overflow-y-auto me-[5px] outline-none"
142
+ className="themed-scrollbar flex min-w-0 flex-1 flex-col overflow-y-auto outline-none"
142
143
  >
143
144
  <MobileTopBar
144
145
  uiLocales={uiLocales}
@@ -12,6 +12,7 @@ import {
12
12
  } from '@spree/dashboard-ui'
13
13
  import { PackageIcon } from '@spree/dashboard-ui/icons'
14
14
  import { useParams } from '@tanstack/react-router'
15
+ import type { TFunction } from 'i18next'
15
16
  import type { ComponentProps, ReactNode } from 'react'
16
17
  import { useAuth } from '../hooks/use-auth'
17
18
  import { primarySidebarSide, useTranslation } from '../lib/i18n'
@@ -28,7 +29,7 @@ import { StoreSwitcher } from './store-switcher'
28
29
  * `labelKey` is resolved here rather than at registration so labels follow a
29
30
  * language change — a literal `label` is frozen at import time.
30
31
  */
31
- function entryToNavItem(entry: NavEntry, tenantId: string, t: (key: string) => string): NavItem {
32
+ function entryToNavItem(entry: NavEntry, tenantId: string, t: TFunction): NavItem {
32
33
  const pathFor = (path: string) => (path === '/' ? `/${tenantId}` : `/${tenantId}${path}`)
33
34
  return {
34
35
  title: resolveNavLabel(entry, t),
@@ -328,7 +328,7 @@ function BulkActionBarLayout({
328
328
  {/* No clear button: the bar sits beside the header's select-all
329
329
  checkbox, which already clears the selection. */}
330
330
  <div ref={leadingRef} className="flex items-center gap-0.5">
331
- <span className="whitespace-nowrap px-1.5 text-sm">
331
+ <span className="whitespace-nowrap px-1.5 text-base">
332
332
  {t('admin.components.bulk_action_bar.selected', { count })}
333
333
  </span>
334
334
  <div className="mx-1 h-5 w-px bg-border" aria-hidden />
@@ -351,6 +351,7 @@ function BulkActionBarLayout({
351
351
  variant="ghost"
352
352
  size="icon-sm"
353
353
  aria-label={t('admin.actions.more_actions')}
354
+ className="hover:bg-accent-strong aria-expanded:bg-accent-strong"
354
355
  >
355
356
  <EllipsisVerticalIcon className="size-4" />
356
357
  </Button>
@@ -424,10 +425,10 @@ function BulkActionButton({
424
425
  disabled={disabled}
425
426
  tabIndex={tabIndex}
426
427
  // `shrink-0` so the zero-width measuring layer can't squeeze these and
427
- // report a width smaller than the button really renders at. Hover is the
428
- // ghost variant's own `bg-accent` — no override needed now that the bar's
429
- // muted background sits well clear of it.
430
- className="shrink-0 gap-1.5 whitespace-nowrap px-2"
428
+ // report a width smaller than the button really renders at. Hover one
429
+ // step past the ghost variant's `bg-accent`, which is barely apart from
430
+ // the bar's own grey.
431
+ className="shrink-0 gap-1.5 whitespace-nowrap px-2 hover:bg-accent-strong aria-expanded:bg-accent-strong"
431
432
  >
432
433
  {action.icon}
433
434
  {action.label}
@@ -29,6 +29,9 @@ export function useCountryStates(countryCode: string | null | undefined) {
29
29
  return {
30
30
  states: ((country?.states ?? []) as StateOption[]).filter((s) => Boolean(s.abbr)),
31
31
  statesRequired: country?.states_required ?? false,
32
+ // Assumed required until the country list loads, as the server does for
33
+ // an address without a country.
34
+ postalCodeRequired: country?.zipcode_required ?? true,
32
35
  }
33
36
  }
34
37
 
@@ -8,6 +8,7 @@ import {
8
8
  ComboboxList,
9
9
  ComboboxSearch,
10
10
  ComboboxTriggerPlaceholder,
11
+ CurrencyLabel,
11
12
  } from '@spree/dashboard-ui'
12
13
  import { useMemo, useState } from 'react'
13
14
  import { useTranslation } from 'react-i18next'
@@ -58,8 +59,9 @@ interface CurrencySelectProps {
58
59
  * current store's markets (the default market's first), so callers
59
60
  * choosing among already-configured currencies don't have to wire anything up.
60
61
  * Pass `options` (e.g. `ALL_CURRENCY_CODES`) when the merchant may pick any
61
- * currency, such as a market's currency. Each option reads `CODE — Full Name`
62
- * (e.g. `USD — US Dollar`), with the name localized to the admin UI language.
62
+ * currency, such as a market's currency. Each option shows the currency's
63
+ * symbol, code and name (`[$] USD US Dollar`), the name localized to the admin
64
+ * UI language.
63
65
  *
64
66
  * Always searchable, whatever the list length. The picker used to fall back to
65
67
  * a plain `<Select>` under a dozen options, which meant the same field behaved
@@ -158,7 +160,7 @@ export function CurrencySelect({
158
160
  aria-invalid={invalid || undefined}
159
161
  >
160
162
  {value ? (
161
- <span className="truncate">{renderOption(value)}</span>
163
+ <CurrencyLabel code={value} name={displayNameFor(value)} />
162
164
  ) : (
163
165
  <ComboboxTriggerPlaceholder>
164
166
  {t('admin.components.currency_select.placeholder')}
@@ -175,7 +177,7 @@ export function CurrencySelect({
175
177
  <ComboboxList>
176
178
  {(code: CurrencyCode) => (
177
179
  <ComboboxItem key={code} value={code}>
178
- {renderOption(code)}
180
+ <CurrencyLabel code={code} name={displayNameFor(code)} />
179
181
  </ComboboxItem>
180
182
  )}
181
183
  </ComboboxList>