@spree/dashboard-core 0.13.1 → 1.0.0-beta.1

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 (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -0,0 +1,83 @@
1
+ import {
2
+ SidebarMenu,
3
+ SidebarMenuItem,
4
+ Tooltip,
5
+ TooltipContent,
6
+ TooltipTrigger,
7
+ useSidebar,
8
+ } from '@spree/dashboard-ui'
9
+ import { CommandIcon, SearchIcon } from '@spree/dashboard-ui/icons'
10
+ import { useTranslation } from 'react-i18next'
11
+ import { useOptionalCommandPalette } from '../hooks/use-command-palette'
12
+
13
+ const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform ?? '')
14
+
15
+ /**
16
+ * Opens the global ⌘K palette, sitting under the store switcher.
17
+ *
18
+ * The shortcut itself is registered by the palette's provider, not here, so it
19
+ * works wherever focus is — this is only the visible affordance for it.
20
+ *
21
+ * Collapsed to the icon rail it becomes a plain square button: a search field
22
+ * with no room for text reads as a broken input, whereas an icon reads as a
23
+ * button that will open something.
24
+ *
25
+ * Renders nothing when no palette is mounted — the seller panel has none yet,
26
+ * and a search box that opens nothing is worse than no search box.
27
+ */
28
+ export function SidebarSearch() {
29
+ const { t } = useTranslation()
30
+ const palette = useOptionalCommandPalette()
31
+ const { isMobile, state } = useSidebar()
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')
37
+
38
+ if (!palette) return null
39
+
40
+ return (
41
+ <SidebarMenu>
42
+ <SidebarMenuItem>
43
+ <Tooltip>
44
+ <TooltipTrigger asChild>
45
+ <button
46
+ type="button"
47
+ onClick={() => palette.setOpen(true)}
48
+ 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.
52
+ className={
53
+ isCollapsed
54
+ ? // `size-10` matches what a nav item collapses to, so the rail reads
55
+ // as one column of equal targets rather than a search box that
56
+ // shrank. The glyph stays 16px, as `NavIcon` draws it.
57
+ '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)]'
59
+ }
60
+ >
61
+ <SearchIcon className="size-4 shrink-0" />
62
+ {!isCollapsed && (
63
+ <>
64
+ <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>
68
+ </kbd>
69
+ </>
70
+ )}
71
+ </button>
72
+ </TooltipTrigger>
73
+ {/* Only while collapsed: expanded, the label is already on screen and
74
+ a tooltip would repeat it. Matches how `SidebarMenuButton` gates
75
+ the nav items' own tooltips. */}
76
+ <TooltipContent side="right" align="center" hidden={!isCollapsed}>
77
+ {label}
78
+ </TooltipContent>
79
+ </Tooltip>
80
+ </SidebarMenuItem>
81
+ </SidebarMenu>
82
+ )
83
+ }
@@ -0,0 +1,213 @@
1
+ import {
2
+ Avatar,
3
+ AvatarFallback,
4
+ AvatarImage,
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuLabel,
9
+ DropdownMenuSeparator,
10
+ DropdownMenuTrigger,
11
+ LanguageMenuItems,
12
+ SidebarMenu,
13
+ SidebarMenuItem,
14
+ ThemeMenuItems,
15
+ useSidebar,
16
+ } from '@spree/dashboard-ui'
17
+ import {
18
+ BookOpenIcon,
19
+ ExternalLinkIcon,
20
+ LogOutIcon,
21
+ MailIcon,
22
+ MessageCircleIcon,
23
+ MoreHorizontalIcon,
24
+ UserIcon,
25
+ } from '@spree/dashboard-ui/icons'
26
+ import { useTranslation } from 'react-i18next'
27
+ import { useAuth } from '../hooks/use-auth'
28
+ import { useSwitchAdminLocale } from '../hooks/use-switch-admin-locale'
29
+ import { COMMUNITY_URL, CONTACT_URL, DOCS_BASE_URL } from '../lib/docs'
30
+ import { getInitials } from '../lib/formatters'
31
+ import { i18n } from '../lib/i18n'
32
+ import { storefrontHref } from '../lib/storefront'
33
+ import { useOptionalStore } from '../providers/store-provider'
34
+
35
+ /**
36
+ * The account menu, sitting at the foot of the primary sidebar.
37
+ *
38
+ * It holds what the top bar's user menu held — who is signed in, theme,
39
+ * language, the support links and sign out — because removing that bar left
40
+ * the account with nowhere else to live, and the foot of the nav is where a
41
+ * 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
+ */
47
+ export function SidebarUser({
48
+ uiLocales = [],
49
+ onEditProfile,
50
+ variant = 'sidebar',
51
+ }: {
52
+ uiLocales?: ReadonlyArray<{ code: string; name: string }>
53
+ onEditProfile?: () => void
54
+ /**
55
+ * `bar` renders just the avatar as a compact button, for the mobile top
56
+ * bar. The menu itself is identical — a phone should not get a smaller set
57
+ * of account actions than a desktop, only a smaller way in to them.
58
+ */
59
+ variant?: 'sidebar' | 'bar'
60
+ }) {
61
+ const { t } = useTranslation()
62
+ const { isMobile, state } = useSidebar()
63
+ const { user, logout } = useAuth()
64
+ const switchAdminLocale = useSwitchAdminLocale()
65
+ const store = useOptionalStore()?.store ?? null
66
+ const viewStoreHref = storefrontHref(store)
67
+
68
+ // The desktop rail's icon mode. The mobile drawer is full width whatever
69
+ // that says, so honouring it there would hide the name for no reason.
70
+ const isCollapsed = state === 'collapsed' && !isMobile
71
+
72
+ if (!user) return null
73
+
74
+ const initials = getInitials(user.full_name, user.email)
75
+
76
+ // Persists to the account, mirrors it into the auth context, and reloads —
77
+ // see useSwitchAdminLocale for why all three steps are required.
78
+ const handleSelectLocale = (code: string) => {
79
+ void switchAdminLocale(code)
80
+ }
81
+
82
+ const inBar = variant === 'bar'
83
+
84
+ const trigger = (
85
+ <DropdownMenuTrigger asChild>
86
+ <button
87
+ type="button"
88
+ aria-label={t('admin.a11y.user_menu')}
89
+ className={
90
+ inBar
91
+ ? 'inline-flex size-11 shrink-0 items-center justify-center rounded-lg outline-hidden transition-colors duration-100 hover:bg-accent data-[state=open]:bg-accent'
92
+ : 'flex w-full items-center gap-2 rounded-xl p-1.5 outline-hidden transition-colors duration-100 hover:bg-sidebar-accent data-[state=open]:bg-sidebar-accent'
93
+ }
94
+ >
95
+ <Avatar className="size-7 shrink-0 rounded-lg">
96
+ {user.avatar_url && <AvatarImage src={user.avatar_url} alt="" />}
97
+ <AvatarFallback className="rounded-lg bg-primary text-xs text-primary-foreground dark:bg-accent dark:text-foreground">
98
+ {initials}
99
+ </AvatarFallback>
100
+ </Avatar>
101
+ {!inBar && !isCollapsed && (
102
+ <>
103
+ <span className="min-w-0 flex-1 truncate text-left font-medium text-sm text-foreground">
104
+ {user.full_name || user.email}
105
+ </span>
106
+ <MoreHorizontalIcon className="size-4 shrink-0 text-muted-foreground" />
107
+ </>
108
+ )}
109
+ </button>
110
+ </DropdownMenuTrigger>
111
+ )
112
+
113
+ const menu = (
114
+ <DropdownMenu>
115
+ {trigger}
116
+ {/* In the rail the trigger sits at the bottom, so the menu opens
117
+ upward or to the side or it would run off-screen. In the top bar
118
+ it hangs below the avatar like any other header menu. */}
119
+ <DropdownMenuContent
120
+ className="w-56"
121
+ side={inBar ? 'bottom' : isMobile ? 'top' : 'right'}
122
+ align="end"
123
+ sideOffset={8}
124
+ >
125
+ <div className="flex items-center gap-2 p-1.5">
126
+ <Avatar className="size-8 rounded-lg">
127
+ {user.avatar_url && <AvatarImage src={user.avatar_url} alt="" />}
128
+ <AvatarFallback className="rounded-lg bg-primary text-xs text-primary-foreground dark:bg-accent dark:text-foreground">
129
+ {initials}
130
+ </AvatarFallback>
131
+ </Avatar>
132
+ <div className="grid min-w-0 flex-1 text-sm leading-tight">
133
+ <span className="truncate font-medium text-foreground">
134
+ {user.full_name || user.email}
135
+ </span>
136
+ {user.full_name && (
137
+ <span className="truncate text-muted-foreground text-xs">{user.email}</span>
138
+ )}
139
+ </div>
140
+ </div>
141
+ <DropdownMenuSeparator />
142
+ <DropdownMenuLabel className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
143
+ {t('admin.account.preferences')}
144
+ </DropdownMenuLabel>
145
+ <ThemeMenuItems />
146
+ {/* Select-style pill; self-hides when < 2 languages are installed. */}
147
+ <LanguageMenuItems
148
+ label={t('admin.account.language.label')}
149
+ locales={uiLocales}
150
+ value={i18n.language}
151
+ onSelect={handleSelectLocale}
152
+ />
153
+ <DropdownMenuSeparator />
154
+ {onEditProfile && (
155
+ <>
156
+ <DropdownMenuItem onClick={onEditProfile}>
157
+ <UserIcon className="size-4" />
158
+ {t('admin.account.edit_profile')}
159
+ </DropdownMenuItem>
160
+ <DropdownMenuSeparator />
161
+ </>
162
+ )}
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. */}
177
+ <DropdownMenuItem asChild>
178
+ <a href={DOCS_BASE_URL} target="_blank" rel="noopener noreferrer">
179
+ <BookOpenIcon className="size-4" />
180
+ {t('admin.account.documentation')}
181
+ </a>
182
+ </DropdownMenuItem>
183
+ <DropdownMenuItem asChild>
184
+ <a href={COMMUNITY_URL} target="_blank" rel="noopener noreferrer">
185
+ <MessageCircleIcon className="size-4" />
186
+ {t('admin.account.community')}
187
+ </a>
188
+ </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>
195
+ <DropdownMenuSeparator />
196
+ <DropdownMenuItem onClick={logout}>
197
+ <LogOutIcon className="size-4" />
198
+ {t('admin.account.log_out')}
199
+ </DropdownMenuItem>
200
+ </DropdownMenuContent>
201
+ </DropdownMenu>
202
+ )
203
+
204
+ // The bar places the button itself; only the rail needs the menu wrapped in
205
+ // sidebar row markup.
206
+ if (inBar) return menu
207
+
208
+ return (
209
+ <SidebarMenu>
210
+ <SidebarMenuItem>{menu}</SidebarMenuItem>
211
+ </SidebarMenu>
212
+ )
213
+ }
@@ -0,0 +1,36 @@
1
+ import { useTranslation } from 'react-i18next'
2
+
3
+ /**
4
+ * "Skip to content", the first thing in the tab order.
5
+ *
6
+ * The sidebar carries thirty-odd links, and they precede the page on every
7
+ * route. Without this a keyboard user tabs through all of them again after
8
+ * each navigation before reaching what they came for.
9
+ *
10
+ * Visible only while focused: it is a keyboard affordance, and a pointer user
11
+ * has no use for it. It stays in the DOM and in the tab order at all times
12
+ * though — `display: none` or `hidden` would take it out of both, which is the
13
+ * usual way this control ends up doing nothing.
14
+ */
15
+ export function SkipLink({ targetId = 'main-content' }: { targetId?: string }) {
16
+ const { t } = useTranslation()
17
+
18
+ return (
19
+ <a
20
+ href={`#${targetId}`}
21
+ className={[
22
+ // Off-screen until focused, rather than hidden: reachable by Tab,
23
+ // invisible to everyone else.
24
+ 'sr-only',
25
+ 'focus-visible:not-sr-only focus-visible:fixed focus-visible:top-3 focus-visible:left-3',
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',
28
+ 'focus-visible:text-foreground focus-visible:shadow-md',
29
+ 'focus-visible:border focus-visible:border-border-control',
30
+ 'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
31
+ ].join(' ')}
32
+ >
33
+ {t('admin.common.skip_to_content')}
34
+ </a>
35
+ )
36
+ }
@@ -1,5 +1,5 @@
1
1
  import { DatePicker, type DatePickerProps } from '@spree/dashboard-ui'
2
- import { useStore } from '../providers/store-provider'
2
+ import { useOptionalStore } from '../providers/store-provider'
3
3
 
4
4
  /**
5
5
  * Drop-in `<DatePicker>` that pulls the timezone from `<StoreProvider>` so
@@ -11,7 +11,11 @@ import { useStore } from '../providers/store-provider'
11
11
  * timezone explicitly (rare).
12
12
  */
13
13
  function StoreDatePicker(props: Omit<DatePickerProps, 'timezone'>) {
14
- const { timezone } = useStore()
14
+ // Optional: a panel with no store context (a seller's) falls back to the
15
+ // browser's zone rather than refusing to render a date field.
16
+ const store = useOptionalStore()
17
+ const timezone = store?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'
18
+
15
19
  return <DatePicker {...props} timezone={timezone} />
16
20
  }
17
21
 
@@ -11,10 +11,11 @@ import {
11
11
  Skeleton,
12
12
  useSidebar,
13
13
  } from '@spree/dashboard-ui'
14
+ import { CheckIcon, ChevronsUpDownIcon } from '@spree/dashboard-ui/icons'
14
15
  import { Link } from '@tanstack/react-router'
15
- import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
16
16
  import { useTranslation } from 'react-i18next'
17
17
  import { useAuth } from '../hooks/use-auth'
18
+ import { getInitials } from '../lib/formatters'
18
19
  import { useStore } from '../providers/store-provider'
19
20
 
20
21
  /**
@@ -26,19 +27,22 @@ import { useStore } from '../providers/store-provider'
26
27
  export function StoreSwitcher() {
27
28
  const { t } = useTranslation()
28
29
  const { isMobile, state } = useSidebar()
29
- const isCollapsed = state === 'collapsed'
30
+ // `collapsed` is the desktop rail's icon mode. The mobile drawer is full
31
+ // width whatever that state says — and the rail auto-collapses inside the
32
+ // settings area — so honouring it there hides the store name for no reason.
33
+ const isCollapsed = state === 'collapsed' && !isMobile
30
34
 
31
35
  const { store, isLoading } = useStore()
32
36
  const { user } = useAuth()
33
37
 
34
- if (isLoading) return <Skeleton className="h-header-height w-full rounded-xl" />
38
+ if (isLoading) return <Skeleton className="h-rail-header-height w-full rounded-xl" />
35
39
 
36
40
  const stores = user?.stores ?? []
37
41
 
38
- const storeInitials = store?.name
39
- .split(' ')
40
- .map((name) => name[0])
41
- .join('')
42
+ // `getInitials` rather than one letter per word: a store called "Shop With a
43
+ // Very Long Name" produced six characters and burst the avatar. This caps at
44
+ // two — first word and last — and is what the account avatar already uses.
45
+ const storeInitials = getInitials(store?.name, '?')
42
46
 
43
47
  const header = (
44
48
  <>
@@ -57,7 +61,7 @@ export function StoreSwitcher() {
57
61
  if (stores.length < 2) {
58
62
  return (
59
63
  <SidebarMenu>
60
- <SidebarMenuItem className="h-header-height flex items-center">
64
+ <SidebarMenuItem className="h-rail-header-height flex items-center">
61
65
  <div className="flex w-full items-center gap-2 p-1.5">{header}</div>
62
66
  </SidebarMenuItem>
63
67
  </SidebarMenu>
@@ -66,7 +70,7 @@ export function StoreSwitcher() {
66
70
 
67
71
  return (
68
72
  <SidebarMenu>
69
- <SidebarMenuItem className="h-header-height flex items-center">
73
+ <SidebarMenuItem className="h-rail-header-height flex items-center">
70
74
  <DropdownMenu>
71
75
  <DropdownMenuTrigger asChild className="flex w-full items-center">
72
76
  <button
@@ -88,11 +92,12 @@ export function StoreSwitcher() {
88
92
  >
89
93
  {stores.map((availableStore) => (
90
94
  <DropdownMenuItem key={availableStore.id} asChild>
91
- <Link
92
- to="/$storeId"
93
- params={{ storeId: availableStore.id }}
94
- className="no-underline"
95
- >
95
+ {/* Built as a runtime path rather than a typed literal: core
96
+ is the framework behind more than one panel, and a panel
97
+ with no `/$storeId` route (a seller's) would fail to
98
+ typecheck against a route it never registers. Same approach
99
+ as the nav registries, which take paths as strings. */}
100
+ <Link to={`/${availableStore.id}` as never} className="no-underline">
96
101
  <span className="flex-1 truncate">{availableStore.name}</span>
97
102
  {availableStore.id === store?.id && <CheckIcon className="size-4" />}
98
103
  </Link>