@spree/dashboard-core 6.0.2 → 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 (87) hide show
  1. package/dist/vite/index.js +81 -5
  2. package/package.json +24 -24
  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 +58 -0
  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 +194 -331
  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 +20 -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 +128 -14
  56. package/src/locales/de.json +128 -14
  57. package/src/locales/en.json +128 -14
  58. package/src/locales/es.json +128 -14
  59. package/src/locales/fr.json +128 -14
  60. package/src/locales/ja.json +128 -14
  61. package/src/locales/pl.json +130 -14
  62. package/src/locales/vi.json +128 -14
  63. package/src/locales/zh-CN.json +128 -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 +204 -73
  72. package/src/products/product-form-mapping.test.ts +42 -1
  73. package/src/products/product-form-mapping.ts +20 -0
  74. package/src/products/product-schema.ts +8 -0
  75. package/src/products/product-types.ts +2 -0
  76. package/src/products/use-product-form-data.ts +30 -3
  77. package/src/products/variant-edit-sheet.tsx +3 -3
  78. package/src/products/variants-options-builder.tsx +13 -8
  79. package/src/products/variants-section.tsx +196 -4
  80. package/src/providers/auth-provider.tsx +2 -0
  81. package/src/providers/developer-inspector-provider.tsx +145 -0
  82. package/src/providers/json-link-resolver-provider.tsx +27 -0
  83. package/src/sellers/seller-schema.ts +107 -0
  84. package/src/tables/package-types.tsx +3 -3
  85. package/src/tables/stock-locations.tsx +15 -14
  86. package/src/virtual.d.ts +4 -0
  87. package/src/vite/index.ts +103 -5
@@ -0,0 +1,165 @@
1
+ import {
2
+ cn,
3
+ Field,
4
+ FieldContent,
5
+ FieldError,
6
+ FieldGroup,
7
+ FieldLabel,
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ Switch,
14
+ } from '@spree/dashboard-ui'
15
+ import type { ComponentProps, ReactNode } from 'react'
16
+ import { type Control, Controller, type FieldPath, type FieldValues } from 'react-hook-form'
17
+
18
+ /**
19
+ * A settings card's rows, with a rule between each. Rows put the label on the
20
+ * left and the control on the right once the card is wide enough, and stack
21
+ * on a narrow screen.
22
+ */
23
+ export function SettingsFieldGroup({ className, ...props }: ComponentProps<typeof FieldGroup>) {
24
+ return (
25
+ <FieldGroup
26
+ className={cn(
27
+ 'gap-0 divide-y divide-border-subtle *:py-4 *:first:pt-0 *:last:pb-0',
28
+ className,
29
+ )}
30
+ {...props}
31
+ />
32
+ )
33
+ }
34
+
35
+ /**
36
+ * One setting: its label and help on the left, its control on the right.
37
+ * `horizontal` keeps it a row at any width, for a narrow column whose
38
+ * controls are short anyway.
39
+ */
40
+ export function SettingsRow({
41
+ label,
42
+ htmlFor,
43
+ help,
44
+ error,
45
+ orientation = 'responsive',
46
+ children,
47
+ }: {
48
+ label: ReactNode
49
+ htmlFor?: string
50
+ help?: ReactNode
51
+ error?: { message?: string }
52
+ orientation?: 'responsive' | 'horizontal'
53
+ children: ReactNode
54
+ }) {
55
+ const horizontal = orientation === 'horizontal'
56
+ return (
57
+ // Centred against the control, which the field would otherwise top-align
58
+ // beside a label and its help.
59
+ <Field
60
+ orientation={orientation}
61
+ className={
62
+ horizontal
63
+ ? 'gap-4 has-[>[data-slot=field-content]]:items-center'
64
+ : '@md/field-group:gap-6 @md/field-group:has-[>[data-slot=field-content]]:items-center'
65
+ }
66
+ >
67
+ <FieldContent>
68
+ <FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
69
+ {help && <span className="text-xs text-muted-foreground">{help}</span>}
70
+ <FieldError errors={[error]} />
71
+ </FieldContent>
72
+ <div
73
+ className={cn(
74
+ 'flex min-w-0 shrink-0 items-center gap-2',
75
+ horizontal ? 'max-w-[60%]' : '@md/field-group:max-w-[50%]',
76
+ )}
77
+ >
78
+ {children}
79
+ </div>
80
+ </Field>
81
+ )
82
+ }
83
+
84
+ interface SelectFieldProps<TValues extends FieldValues> {
85
+ id: string
86
+ label: string
87
+ placeholder?: string
88
+ help?: string
89
+ name: FieldPath<TValues>
90
+ control: Control<TValues>
91
+ options: ReadonlyArray<{ value: string; label: string }>
92
+ }
93
+
94
+ export function SettingsSelectField<TValues extends FieldValues>({
95
+ id,
96
+ label,
97
+ placeholder,
98
+ help,
99
+ name,
100
+ control,
101
+ options,
102
+ }: SelectFieldProps<TValues>) {
103
+ return (
104
+ <Controller
105
+ name={name}
106
+ control={control}
107
+ render={({ field, fieldState }) => (
108
+ <SettingsRow label={label} htmlFor={id} help={help} error={fieldState.error}>
109
+ <Select items={options as never} value={field.value} onValueChange={field.onChange}>
110
+ {/* As wide as its value in a row, rather than half the card. */}
111
+ <SelectTrigger
112
+ id={id}
113
+ aria-invalid={!!fieldState.error || undefined}
114
+ className="@md/field-group:w-auto @md/field-group:min-w-40"
115
+ >
116
+ <SelectValue placeholder={placeholder} />
117
+ </SelectTrigger>
118
+ <SelectContent>
119
+ {options.map((o) => (
120
+ <SelectItem key={o.value} value={o.value}>
121
+ {o.label}
122
+ </SelectItem>
123
+ ))}
124
+ </SelectContent>
125
+ </Select>
126
+ </SettingsRow>
127
+ )}
128
+ />
129
+ )
130
+ }
131
+
132
+ interface SwitchFieldProps<TValues extends FieldValues> {
133
+ id: string
134
+ label: string
135
+ help?: string
136
+ name: FieldPath<TValues>
137
+ control: Control<TValues>
138
+ }
139
+
140
+ export function SettingsSwitchField<TValues extends FieldValues>({
141
+ id,
142
+ label,
143
+ help,
144
+ name,
145
+ control,
146
+ }: SwitchFieldProps<TValues>) {
147
+ return (
148
+ // A switch sits beside its label at every width.
149
+ <Field orientation="horizontal" className="gap-4 has-[>[data-slot=field-content]]:items-center">
150
+ <FieldContent>
151
+ <FieldLabel htmlFor={id} className="cursor-pointer">
152
+ {label}
153
+ </FieldLabel>
154
+ {help && <span className="text-xs text-muted-foreground">{help}</span>}
155
+ </FieldContent>
156
+ <Controller
157
+ name={name}
158
+ control={control}
159
+ render={({ field }) => (
160
+ <Switch id={id} checked={!!field.value} onCheckedChange={field.onChange} />
161
+ )}
162
+ />
163
+ </Field>
164
+ )
165
+ }
@@ -0,0 +1,46 @@
1
+ import { Card, SectionTitle } from '@spree/dashboard-ui'
2
+ import type { ReactNode } from 'react'
3
+
4
+ export interface SettingsSectionProps {
5
+ title: ReactNode
6
+ /** An icon element set in a tile before the title, like a `Section` heading. */
7
+ icon?: ReactNode
8
+ description?: ReactNode
9
+ /** Sits at the end of the heading row, such as an Add or Edit button. */
10
+ action?: ReactNode
11
+ /** Classes for the card that holds `children`. */
12
+ className?: string
13
+ children: ReactNode
14
+ }
15
+
16
+ /**
17
+ * One block of a settings page: a heading above the card that holds its
18
+ * fields, with any description at the top of the card. The heading sits
19
+ * outside the card, so a page of several blocks reads as titled sections
20
+ * rather than a stack of boxes each carrying its own title bar.
21
+ */
22
+ export function SettingsSection({
23
+ title,
24
+ icon,
25
+ description,
26
+ action,
27
+ className,
28
+ children,
29
+ }: SettingsSectionProps) {
30
+ return (
31
+ <section className="flex flex-col gap-3">
32
+ <div className="flex items-center justify-between gap-4">
33
+ <SectionTitle>
34
+ {icon}
35
+ {title}
36
+ </SectionTitle>
37
+ {action && <div className="shrink-0">{action}</div>}
38
+ </div>
39
+ <Card className={className}>
40
+ {/* Inside the card, as the first line of what it holds. */}
41
+ {description && <p className="px-4 pt-4 text-muted-foreground text-base">{description}</p>}
42
+ {children}
43
+ </Card>
44
+ </section>
45
+ )
46
+ }
@@ -75,17 +75,22 @@ export function SettingsSidebar({
75
75
  aria-hidden={!open}
76
76
  data-state={open ? 'open' : 'closed'}
77
77
  className={cn(
78
- // `bg-card`, the content sheet's own colour: this rail is a column OF
79
- // the sheet rather than an extension of the nav rail beside it, so it
80
- // reads as part of the page it is navigating rather than as a second
81
- // band of chrome.
82
- 'z-30 hidden vh-full shrink-0 overflow-hidden bg-muted m-1.5 rounded-lg text-sidebar-foreground transition-[width,border-color] duration-200 ease-out lg:block',
83
- open ? 'lg:w-(--spacing-sidebar-width)' : 'lg:w-0 border-e-0 border-transparent',
78
+ // Part of the chrome, beside the icon rail and outside the content
79
+ // sheet: it takes the sidebar's ground. The sheet's own edge divides
80
+ // it from the page; a hairline of the same weight divides it from the
81
+ // rail.
82
+ 'z-30 hidden vh-full shrink-0 overflow-hidden text-sidebar-foreground transition-[width,border-color] duration-200 ease-out lg:block',
83
+ open
84
+ ? 'border-s border-sidebar-border lg:w-(--spacing-sidebar-width)'
85
+ : 'lg:w-0 border-s-0 border-transparent',
84
86
  )}
85
87
  >
86
88
  <div
87
89
  className={cn(
88
- 'flex h-full w-(--spacing-sidebar-width) flex-col transition-opacity duration-200',
90
+ // `pt-2` is the inset sidebar's own padding, which the icon rail and
91
+ // the sheet both sit inside, so the header lines up with the store
92
+ // switcher and the sheet's top bar.
93
+ 'flex h-full w-(--spacing-sidebar-width) flex-col pt-2 transition-opacity duration-200',
89
94
  open ? 'opacity-100 delay-100' : 'pointer-events-none opacity-0',
90
95
  )}
91
96
  >
@@ -97,7 +102,7 @@ export function SettingsSidebar({
97
102
  fade below is a `mask-image` on the scrolling element, and a mask
98
103
  applies to sticky descendants too — a header inside it would
99
104
  dissolve along with the rows it is meant to stay above. */}
100
- <SidebarHeader className="shrink-0 gap-2 pb-2">
105
+ <SidebarHeader className="shrink-0 gap-2 px-3 pb-2">
101
106
  {/* `h-rail-header-height` matches the store switcher opposite, so the
102
107
  two line up. */}
103
108
  <div className="flex h-rail-header-height items-center gap-1">
@@ -109,7 +114,7 @@ export function SettingsSidebar({
109
114
  >
110
115
  <ArrowLeftIcon className="size-4" />
111
116
  </Link>
112
- <span className="truncate font-medium text-sm">{t('admin.settings_page.title')}</span>
117
+ <span className="truncate font-medium text-base">{t('admin.settings_page.title')}</span>
113
118
  </div>
114
119
 
115
120
  <SettingsNavSearch value={query} onValueChange={setQuery} tabIndex={open ? 0 : -1} />
@@ -124,7 +129,10 @@ export function SettingsSidebar({
124
129
  top until there is something scrolled past, clear again at the
125
130
  bottom once the end is reached. Falls back to a static fade on both
126
131
  edges where scroll-driven animations are unsupported. */}
127
- <div className="quiet-scrollbar scroll-fade min-h-0 flex-1 overflow-y-auto">
132
+ {/* `px-1` on top of the groups' own 8px gives the rows the header's
133
+ 12px gutter, the main nav's. Set here rather than on the groups,
134
+ which the phone sheet shares at its own spacing. */}
135
+ <div className="quiet-scrollbar scroll-fade min-h-0 flex-1 overflow-y-auto px-1">
128
136
  <SettingsNavBody
129
137
  tabIndex={open ? 0 : -1}
130
138
  tenantId={tenantId}
@@ -148,14 +156,16 @@ export function SettingsSidebar({
148
156
  * touch sizing the primary drawer uses.
149
157
  */
150
158
  /**
151
- * Tracks the `lg` breakpoint the settings sheet is bounded by. `useIsMobile`
159
+ * Whether the settings rail is on screen: the `lg` breakpoint the settings
160
+ * sheet is bounded by, and below which the settings index is the area's only
161
+ * navigation. `useIsMobile`
152
162
  * cannot stand in: it is fixed at 768px, and closing the sheet there would
153
163
  * strand it open across the 768-1024px band where it is still the only way to
154
164
  * navigate settings.
155
165
  */
156
166
  const SETTINGS_SHEET_BREAKPOINT = 1024
157
167
 
158
- function useIsSettingsSheetHidden() {
168
+ export function useSettingsRailShown() {
159
169
  return useSyncExternalStore(
160
170
  (onChange) => {
161
171
  const query = window.matchMedia(`(min-width: ${SETTINGS_SHEET_BREAKPOINT}px)`)
@@ -184,7 +194,7 @@ export function SettingsNavSheet({
184
194
  // focus trap armed, with nothing on screen to close it. Widening past the
185
195
  // breakpoint while the sheet is open also reports the close, so the caller's
186
196
  // state does not stay stuck open behind a desktop layout.
187
- const isDesktop = useIsSettingsSheetHidden()
197
+ const isDesktop = useSettingsRailShown()
188
198
 
189
199
  useEffect(() => {
190
200
  if (isDesktop && open) onOpenChange(false)
@@ -246,7 +256,10 @@ function SettingsNavSearch({
246
256
  aria-label={t('admin.settings_page.search_placeholder')}
247
257
  clearLabel={t('admin.common.clear')}
248
258
  tabIndex={tabIndex}
249
- className="h-8 text-sm in-data-[mobile=true]:h-11 in-data-[mobile=true]:text-base"
259
+ // The main nav's search bar, as a real field: its corner, edge, fill,
260
+ // icon inset and text size, and no shadow, so the two rails' search
261
+ // boxes read as one. Phones keep the larger touch size.
262
+ className="h-8 shadow-none [&>[data-align=inline-start]]:pl-2.5 [&>input]:pl-2! [&>input]:text-base in-data-[mobile=true]:h-11 in-data-[mobile=true]:[&>input]:text-base"
250
263
  />
251
264
  )
252
265
  }
@@ -308,14 +321,18 @@ function SettingsNavBody({
308
321
  )}
309
322
 
310
323
  {visible.groups.length === 0 && (
311
- <p className="px-4 py-2 text-sm text-sidebar-foreground/70">
324
+ <p className="px-4 py-2 text-base text-sidebar-foreground/70">
312
325
  {t('admin.settings_page.no_results', { query: query.trim() })}
313
326
  </p>
314
327
  )}
315
328
 
316
329
  {visible.groups.map(({ group, entries }) => (
317
- <SidebarGroup key={group.key}>
318
- <SidebarGroupLabel>{resolveNavLabel(group, t)}</SidebarGroupLabel>
330
+ // Labels sit a size and a shade under the links, with air above each
331
+ // group, so the list reads as sections rather than one long column.
332
+ <SidebarGroup key={group.key} className="not-first:pt-3">
333
+ <SidebarGroupLabel className="h-7 font-normal text-muted-foreground">
334
+ {resolveNavLabel(group, t)}
335
+ </SidebarGroupLabel>
319
336
  <SidebarMenu>
320
337
  {entries.map((entry) => (
321
338
  <SettingsItem
@@ -357,7 +374,7 @@ function SettingsItem({
357
374
  via `useSidebar()`, which would fire spuriously in this secondary nav. */}
358
375
  <SidebarMenuButton asChild isActive={isActive}>
359
376
  <Link to={url} tabIndex={tabIndex}>
360
- <NavIcon icon={Icon} isActive={isActive} />
377
+ <NavIcon icon={Icon} isActive={isActive} muted />
361
378
  {/* `truncate` so a long translated label gives way to the badge
362
379
  rather than pushing it out of the sidebar. */}
363
380
  <span className="truncate">{label}</span>
@@ -21,23 +21,22 @@ export function SheetTopBar({
21
21
  }: {
22
22
  tenantId?: string
23
23
  /**
24
- * Hides the rail trigger. Inside settings the rail is collapsed by the app
25
- * rather than by the merchant, and the settings rail beside it carries its
26
- * own way out — so a control offering to re-expand a rail the area will just
27
- * re-collapse would not do what it says. The trail still shows.
24
+ * Hides the bar. Inside settings the settings nav marks where you are and
25
+ * every record page carries its own back button, so a trail would only
26
+ * repeat them. The rail trigger goes too: the rail is collapsed by the app
27
+ * there, and a control offering to re-expand a rail the area will just
28
+ * re-collapse would not do what it says.
28
29
  */
29
30
  inSettings?: boolean
30
31
  }) {
31
32
  const { isMobile, state } = useSidebar()
32
33
  const collapsed = state === 'collapsed' && !isMobile
33
34
 
34
- if (!collapsed) return null
35
+ if (!collapsed || inSettings) return null
35
36
 
36
37
  return (
37
38
  <div className="hidden min-h-8 items-center gap-2 px-4 pt-3 md:flex">
38
- {!inSettings && (
39
- <SidebarTrigger className="-ms-1 shrink-0 text-muted-foreground hover:bg-accent hover:text-foreground" />
40
- )}
39
+ <SidebarTrigger className="-ms-1 shrink-0 text-muted-foreground hover:bg-accent hover:text-foreground" />
41
40
  <TopBarBreadcrumbs tenantId={tenantId} />
42
41
  </div>
43
42
  )
@@ -6,7 +6,7 @@ import {
6
6
  TooltipTrigger,
7
7
  useSidebar,
8
8
  } from '@spree/dashboard-ui'
9
- import { CommandIcon, SearchIcon } from '@spree/dashboard-ui/icons'
9
+ import { SearchIcon } from '@spree/dashboard-ui/icons'
10
10
  import { useTranslation } from 'react-i18next'
11
11
  import { useOptionalCommandPalette } from '../hooks/use-command-palette'
12
12
 
@@ -30,10 +30,9 @@ export function SidebarSearch() {
30
30
  const palette = useOptionalCommandPalette()
31
31
  const { isMobile, state } = useSidebar()
32
32
  const isCollapsed = state === 'collapsed' && !isMobile
33
- // A short label, not the palette's own placeholder: the rail is 240px wide
34
- // and the descriptive version truncates mid-word. The palette itself keeps
35
- // the longer wording, where there is room to name what is searchable.
36
- const label = t('admin.components.command_palette.search_label')
33
+ // Just "Search": the bar is an affordance, not a field, so it names the
34
+ // action and leaves the palette's own placeholder to say what is searchable.
35
+ const label = t('admin.actions.search')
37
36
 
38
37
  if (!palette) return null
39
38
 
@@ -46,25 +45,25 @@ export function SidebarSearch() {
46
45
  type="button"
47
46
  onClick={() => palette.setOpen(true)}
48
47
  aria-label={isCollapsed ? label : undefined}
49
- // Focus matches the form inputs — the soft blue glow from `--ring`
50
- // rather than a hard offset ring. It looks like a search field, so
51
- // it should focus like one.
48
+ // A field's fill and edge, without its shadow: it reads as the
49
+ // search box it opens, and stays flat on the rail. Focus is the
50
+ // inputs' soft blue glow from `--ring`.
52
51
  className={
53
52
  isCollapsed
54
53
  ? // `size-10` matches what a nav item collapses to, so the rail reads
55
54
  // as one column of equal targets rather than a search box that
56
55
  // shrank. The glyph stays 16px, as `NavIcon` draws it.
57
56
  'flex size-10 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors duration-100 ease-out hover:bg-sidebar-accent-hover hover:text-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)]'
58
- : 'flex w-full cursor-pointer items-center gap-2 rounded-lg border border-border bg-card/60 hover:shadow-xs py-1.5 ps-2.5 pe-1.5 text-muted-foreground text-sm outline-none transition-[color,background-color,border-color,box-shadow] duration-100 ease-out hover:bg-card focus-visible:border-ring focus-visible:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)]'
57
+ : 'flex w-full cursor-pointer items-center gap-2 rounded-xl border border-field-border bg-card py-1.5 ps-2.5 pe-2.5 text-muted-foreground text-base outline-none transition-[color,border-color,box-shadow] duration-100 ease-out hover:text-foreground focus-visible:border-ring focus-visible:shadow-[0_0_0_3px_color-mix(in_srgb,var(--ring)_15%,transparent)]'
59
58
  }
60
59
  >
61
60
  <SearchIcon className="size-4 shrink-0" />
62
61
  {!isCollapsed && (
63
62
  <>
64
63
  <span className="flex-1 truncate text-left">{label}</span>
65
- <kbd className="hidden h-5 items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] text-muted-foreground sm:inline-flex">
66
- {IS_MAC ? <CommandIcon size={11} /> : 'CTRL'}
67
- <span>K</span>
64
+ {/* The shortcut as quiet text, not a keycap: a hint, not a control. */}
65
+ <kbd className="hidden font-sans text-xs tabular-nums text-muted-foreground sm:inline">
66
+ {IS_MAC ? '⌘K' : 'Ctrl K'}
68
67
  </kbd>
69
68
  </>
70
69
  )}
@@ -5,8 +5,8 @@ import {
5
5
  DropdownMenu,
6
6
  DropdownMenuContent,
7
7
  DropdownMenuItem,
8
- DropdownMenuLabel,
9
8
  DropdownMenuSeparator,
9
+ DropdownMenuShortcut,
10
10
  DropdownMenuTrigger,
11
11
  LanguageMenuItems,
12
12
  SidebarMenu,
@@ -16,21 +16,24 @@ import {
16
16
  } from '@spree/dashboard-ui'
17
17
  import {
18
18
  BookOpenIcon,
19
- ExternalLinkIcon,
20
19
  LogOutIcon,
21
- MailIcon,
22
20
  MessageCircleIcon,
21
+ MessageSquareIcon,
23
22
  MoreHorizontalIcon,
24
- UserIcon,
23
+ TerminalIcon,
24
+ UserPenIcon,
25
25
  } from '@spree/dashboard-ui/icons'
26
+ import { useFormatForDisplay } from '@tanstack/react-hotkeys'
26
27
  import { useTranslation } from 'react-i18next'
27
28
  import { useAuth } from '../hooks/use-auth'
28
29
  import { useSwitchAdminLocale } from '../hooks/use-switch-admin-locale'
29
- import { COMMUNITY_URL, CONTACT_URL, DOCS_BASE_URL } from '../lib/docs'
30
+ import { COMMUNITY_URL, DOCS_BASE_URL } from '../lib/docs'
30
31
  import { getInitials } from '../lib/formatters'
31
32
  import { i18n } from '../lib/i18n'
32
- import { storefrontHref } from '../lib/storefront'
33
- import { useOptionalStore } from '../providers/store-provider'
33
+ import {
34
+ DEVELOPER_INSPECTOR_HOTKEY,
35
+ useOptionalDeveloperInspector,
36
+ } from '../providers/developer-inspector-provider'
34
37
 
35
38
  /**
36
39
  * The account menu, sitting at the foot of the primary sidebar.
@@ -39,10 +42,6 @@ import { useOptionalStore } from '../providers/store-provider'
39
42
  * language, the support links and sign out — because removing that bar left
40
43
  * the account with nowhere else to live, and the foot of the nav is where a
41
44
  * merchant already looks for it.
42
- *
43
- * `useOptionalStore` rather than `useStore`: the seller panel mounts this
44
- * under a `TenantProvider` with no store at all, and a hard `useStore` would
45
- * throw there. A panel without a store simply gets no "View store" entry.
46
45
  */
47
46
  export function SidebarUser({
48
47
  uiLocales = [],
@@ -62,8 +61,9 @@ export function SidebarUser({
62
61
  const { isMobile, state } = useSidebar()
63
62
  const { user, logout } = useAuth()
64
63
  const switchAdminLocale = useSwitchAdminLocale()
65
- const store = useOptionalStore()?.store ?? null
66
- const viewStoreHref = storefrontHref(store)
64
+ // Only panels that mount the inspector offer it.
65
+ const inspector = useOptionalDeveloperInspector()
66
+ const formatHotkey = useFormatForDisplay()
67
67
 
68
68
  // The desktop rail's icon mode. The mobile drawer is full width whatever
69
69
  // that says, so honouring it there would hide the name for no reason.
@@ -100,7 +100,7 @@ export function SidebarUser({
100
100
  </Avatar>
101
101
  {!inBar && !isCollapsed && (
102
102
  <>
103
- <span className="min-w-0 flex-1 truncate text-left font-medium text-sm text-foreground">
103
+ <span className="min-w-0 flex-1 truncate text-left font-medium text-base text-foreground">
104
104
  {user.full_name || user.email}
105
105
  </span>
106
106
  <MoreHorizontalIcon className="size-4 shrink-0 text-muted-foreground" />
@@ -129,7 +129,7 @@ export function SidebarUser({
129
129
  {initials}
130
130
  </AvatarFallback>
131
131
  </Avatar>
132
- <div className="grid min-w-0 flex-1 text-sm leading-tight">
132
+ <div className="grid min-w-0 flex-1 text-base leading-tight">
133
133
  <span className="truncate font-medium text-foreground">
134
134
  {user.full_name || user.email}
135
135
  </span>
@@ -139,9 +139,15 @@ export function SidebarUser({
139
139
  </div>
140
140
  </div>
141
141
  <DropdownMenuSeparator />
142
- <DropdownMenuLabel className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
143
- {t('admin.account.preferences')}
144
- </DropdownMenuLabel>
142
+ {onEditProfile && (
143
+ <>
144
+ <DropdownMenuItem onClick={onEditProfile}>
145
+ <UserPenIcon className="size-4" />
146
+ {t('admin.account.edit_profile')}
147
+ </DropdownMenuItem>
148
+ <DropdownMenuSeparator />
149
+ </>
150
+ )}
145
151
  <ThemeMenuItems />
146
152
  {/* Select-style pill; self-hides when < 2 languages are installed. */}
147
153
  <LanguageMenuItems
@@ -150,30 +156,19 @@ export function SidebarUser({
150
156
  value={i18n.language}
151
157
  onSelect={handleSelectLocale}
152
158
  />
153
- <DropdownMenuSeparator />
154
- {onEditProfile && (
159
+ {inspector && (
155
160
  <>
156
- <DropdownMenuItem onClick={onEditProfile}>
157
- <UserIcon className="size-4" />
158
- {t('admin.account.edit_profile')}
159
- </DropdownMenuItem>
160
161
  <DropdownMenuSeparator />
162
+ <DropdownMenuItem onClick={() => inspector.setOpen(true)}>
163
+ <TerminalIcon className="size-4" />
164
+ {t('admin.components.developer_inspector.menu_open')}
165
+ <DropdownMenuShortcut>
166
+ {formatHotkey(DEVELOPER_INSPECTOR_HOTKEY)}
167
+ </DropdownMenuShortcut>
168
+ </DropdownMenuItem>
161
169
  </>
162
170
  )}
163
- {/* The storefront link lost its home when the top bar went, and it
164
- belongs with the other outbound links rather than as a button
165
- competing with the nav. */}
166
- {viewStoreHref && (
167
- <DropdownMenuItem asChild>
168
- <a href={viewStoreHref} target="_blank" rel="noopener noreferrer">
169
- <ExternalLinkIcon className="size-4" />
170
- {t('admin.account.view_store')}
171
- </a>
172
- </DropdownMenuItem>
173
- )}
174
- {/* All three carried over from the old top bar as inert rows — they
175
- looked like links and did nothing. Destinations live in `lib/docs`
176
- beside the docs base so a domain move is one edit. */}
171
+ <DropdownMenuSeparator />
177
172
  <DropdownMenuItem asChild>
178
173
  <a href={DOCS_BASE_URL} target="_blank" rel="noopener noreferrer">
179
174
  <BookOpenIcon className="size-4" />
@@ -186,12 +181,12 @@ export function SidebarUser({
186
181
  {t('admin.account.community')}
187
182
  </a>
188
183
  </DropdownMenuItem>
189
- <DropdownMenuItem asChild>
190
- <a href={CONTACT_URL} target="_blank" rel="noopener noreferrer">
191
- <MailIcon className="size-4" />
192
- {t('admin.account.contact_support')}
193
- </a>
194
- </DropdownMenuItem>
184
+ {inspector?.sendFeedback && (
185
+ <DropdownMenuItem onClick={() => inspector.sendFeedback?.()}>
186
+ <MessageSquareIcon className="size-4" />
187
+ {t('admin.account.send_feedback')}
188
+ </DropdownMenuItem>
189
+ )}
195
190
  <DropdownMenuSeparator />
196
191
  <DropdownMenuItem onClick={logout}>
197
192
  <LogOutIcon className="size-4" />
@@ -24,7 +24,7 @@ export function SkipLink({ targetId = 'main-content' }: { targetId?: string }) {
24
24
  'sr-only',
25
25
  'focus-visible:not-sr-only focus-visible:fixed focus-visible:top-3 focus-visible:left-3',
26
26
  'focus-visible:z-50 focus-visible:rounded-lg focus-visible:bg-card',
27
- 'focus-visible:px-4 focus-visible:py-2 focus-visible:text-sm focus-visible:font-medium',
27
+ 'focus-visible:px-4 focus-visible:py-2 focus-visible:text-base focus-visible:font-medium',
28
28
  'focus-visible:text-foreground focus-visible:shadow-md',
29
29
  'focus-visible:border focus-visible:border-border-control',
30
30
  'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
@@ -51,7 +51,7 @@ export function StoreSwitcher() {
51
51
  <AvatarFallback>{storeInitials}</AvatarFallback>
52
52
  </Avatar>
53
53
  {!isCollapsed && (
54
- <div className="grid flex-1 text-left text-sm leading-tight">
54
+ <div className="grid flex-1 text-left text-base leading-tight">
55
55
  <span className="truncate font-medium text-foreground">{store?.name}</span>
56
56
  </div>
57
57
  )}