@spree/dashboard-core 0.13.1 → 1.0.0-beta.2
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.
- package/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +52 -27
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +383 -127
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +19 -14
- package/src/components/table-toolbar.tsx +1357 -440
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +241 -16
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +9 -6
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/table-registry.ts +50 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -517
- package/src/locales/de.json +1884 -517
- package/src/locales/en.json +1886 -519
- package/src/locales/fr.json +1884 -517
- package/src/locales/pl.json +1913 -517
- package/src/locales/zh-CN.json +1883 -517
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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>
|