@spree/seller-dashboard 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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -0
  3. package/dist/vite-plugin/index.js +51 -0
  4. package/package.json +64 -0
  5. package/src/api-client.ts +203 -0
  6. package/src/components/centered-message.tsx +8 -0
  7. package/src/components/finance/balance-summary.tsx +69 -0
  8. package/src/components/onboarding-nav-badge.tsx +38 -0
  9. package/src/components/orders/fulfillment-deliveries.tsx +116 -0
  10. package/src/components/orders/fulfillment-delivery-dialog.tsx +82 -0
  11. package/src/components/orders/fulfillment-edit-dialog.tsx +95 -0
  12. package/src/components/orders/fulfillment-fulfill-form.tsx +132 -0
  13. package/src/components/orders/fulfillment-label-upload-dialog.tsx +95 -0
  14. package/src/components/orders/fulfillment-split-dialog.tsx +142 -0
  15. package/src/components/orders/fulfillments-card.tsx +289 -0
  16. package/src/components/orders/line-label.ts +28 -0
  17. package/src/components/orders/order-cancel-dialog.tsx +74 -0
  18. package/src/components/orders/order-cards.tsx +297 -0
  19. package/src/components/orders/order-earnings-card.tsx +99 -0
  20. package/src/components/orders/order-notes-cards.tsx +83 -0
  21. package/src/components/orders/order-payment-card.tsx +94 -0
  22. package/src/components/orders/post-sale-cards.tsx +347 -0
  23. package/src/components/orders/post-sale-create-dialogs.tsx +201 -0
  24. package/src/components/orders/returns-card.tsx +258 -0
  25. package/src/components/orders/shipping-label-row.tsx +124 -0
  26. package/src/components/product-status-card.tsx +111 -0
  27. package/src/components/read-row.tsx +16 -0
  28. package/src/components/retryable-error.tsx +23 -0
  29. package/src/components/seller-address-card.tsx +123 -0
  30. package/src/components/seller-business-card.tsx +152 -0
  31. package/src/components/seller-returns-location-card.tsx +151 -0
  32. package/src/components/seller-switcher.tsx +124 -0
  33. package/src/components/seller-tax-identifiers-card.tsx +63 -0
  34. package/src/create-router.ts +32 -0
  35. package/src/hooks/use-fulfillments.ts +155 -0
  36. package/src/hooks/use-ledger.ts +46 -0
  37. package/src/hooks/use-order.ts +62 -0
  38. package/src/hooks/use-post-sale.ts +202 -0
  39. package/src/hooks/use-reasons.ts +34 -0
  40. package/src/hooks/use-stock-locations.ts +23 -0
  41. package/src/hooks/use-store-timezone.ts +26 -0
  42. package/src/i18n.ts +26 -0
  43. package/src/index.ts +27 -0
  44. package/src/lib/packing-slip.ts +109 -0
  45. package/src/locales/en.json +841 -0
  46. package/src/nav/default.ts +108 -0
  47. package/src/nav/settings.ts +114 -0
  48. package/src/pages/accept-invitation.tsx +127 -0
  49. package/src/pages/delivery-methods.tsx +647 -0
  50. package/src/pages/earnings.tsx +44 -0
  51. package/src/pages/forgot-password.tsx +100 -0
  52. package/src/pages/home.tsx +132 -0
  53. package/src/pages/login.tsx +94 -0
  54. package/src/pages/onboarding.tsx +539 -0
  55. package/src/pages/order.tsx +119 -0
  56. package/src/pages/orders.tsx +33 -0
  57. package/src/pages/payout.tsx +154 -0
  58. package/src/pages/payouts.tsx +32 -0
  59. package/src/pages/policies.tsx +292 -0
  60. package/src/pages/product.tsx +250 -0
  61. package/src/pages/products.tsx +228 -0
  62. package/src/pages/profile.tsx +389 -0
  63. package/src/pages/reset-password.tsx +105 -0
  64. package/src/pages/seller-picker.tsx +46 -0
  65. package/src/pages/team.tsx +468 -0
  66. package/src/panel-chrome.tsx +36 -0
  67. package/src/routeTree.gen.ts +590 -0
  68. package/src/routes/__root.tsx +15 -0
  69. package/src/routes/_authenticated/$sellerId/earnings/index.tsx +15 -0
  70. package/src/routes/_authenticated/$sellerId/index.tsx +6 -0
  71. package/src/routes/_authenticated/$sellerId/onboarding.tsx +6 -0
  72. package/src/routes/_authenticated/$sellerId/orders/$orderId.tsx +6 -0
  73. package/src/routes/_authenticated/$sellerId/orders/index.tsx +15 -0
  74. package/src/routes/_authenticated/$sellerId/payouts/$payoutId.tsx +7 -0
  75. package/src/routes/_authenticated/$sellerId/payouts/index.tsx +15 -0
  76. package/src/routes/_authenticated/$sellerId/products/$productId.tsx +6 -0
  77. package/src/routes/_authenticated/$sellerId/products/index.tsx +23 -0
  78. package/src/routes/_authenticated/$sellerId/products/new.tsx +6 -0
  79. package/src/routes/_authenticated/$sellerId/profile.tsx +6 -0
  80. package/src/routes/_authenticated/$sellerId/settings/delivery-methods.tsx +14 -0
  81. package/src/routes/_authenticated/$sellerId/settings/index.tsx +18 -0
  82. package/src/routes/_authenticated/$sellerId/settings/package-types.tsx +21 -0
  83. package/src/routes/_authenticated/$sellerId/settings/policies.tsx +14 -0
  84. package/src/routes/_authenticated/$sellerId/settings/stock-locations.tsx +22 -0
  85. package/src/routes/_authenticated/$sellerId/settings/team.tsx +6 -0
  86. package/src/routes/_authenticated/$sellerId/settings.tsx +18 -0
  87. package/src/routes/_authenticated/$sellerId.tsx +49 -0
  88. package/src/routes/_authenticated/index.tsx +48 -0
  89. package/src/routes/_authenticated.tsx +13 -0
  90. package/src/routes/accept-invitation.$invitationId.tsx +19 -0
  91. package/src/routes/forgot-password.tsx +15 -0
  92. package/src/routes/login.tsx +15 -0
  93. package/src/routes/reset-password.tsx +21 -0
  94. package/src/schemas/fulfillment.ts +31 -0
  95. package/src/seller-dashboard.tsx +50 -0
  96. package/src/styles.css +18 -0
  97. package/src/tables/delivery-methods.tsx +68 -0
  98. package/src/tables/orders.tsx +116 -0
  99. package/src/tables/payouts.tsx +87 -0
  100. package/src/tables/policies.tsx +37 -0
  101. package/src/tables/products.tsx +56 -0
  102. package/src/tables/transfers.tsx +118 -0
  103. package/src/vite/index.ts +87 -0
@@ -0,0 +1,468 @@
1
+ import { Can, getInitials, PageHeader, Slot } from '@spree/dashboard-core'
2
+ import {
3
+ Avatar,
4
+ AvatarFallback,
5
+ AvatarImage,
6
+ Button,
7
+ Card,
8
+ CardContent,
9
+ CardHeader,
10
+ CardTitle,
11
+ Dialog,
12
+ DialogBody,
13
+ DialogContent,
14
+ DialogDescription,
15
+ DialogFooter,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ Empty,
19
+ EmptyDescription,
20
+ EmptyHeader,
21
+ EmptyMedia,
22
+ EmptyTitle,
23
+ Field,
24
+ FieldError,
25
+ FieldLabel,
26
+ Input,
27
+ RelativeTime,
28
+ RowActions,
29
+ Skeleton,
30
+ Table,
31
+ TableBody,
32
+ TableCell,
33
+ TableHead,
34
+ TableHeader,
35
+ TableRow,
36
+ toastManager,
37
+ useConfirm,
38
+ useCopyToClipboard,
39
+ } from '@spree/dashboard-ui'
40
+ import {
41
+ ClockIcon,
42
+ LinkIcon,
43
+ MailIcon,
44
+ PlusIcon,
45
+ SendIcon,
46
+ UserMinusIcon,
47
+ UsersRoundIcon,
48
+ XIcon,
49
+ } from '@spree/dashboard-ui/icons'
50
+ import type { Invitation, TeamMember } from '@spree/seller-sdk'
51
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
52
+ import { useParams } from '@tanstack/react-router'
53
+ import { useState } from 'react'
54
+ import { useForm } from 'react-hook-form'
55
+ import { useTranslation } from 'react-i18next'
56
+ import { sellerClient } from '../api-client'
57
+
58
+ /**
59
+ * Who can sign in and run this seller.
60
+ *
61
+ * Deliberately the same shape as the marketplace operator's staff page —
62
+ * members in a table, pending offers in their own card — because it is the
63
+ * same job seen from the other side, and someone who has used one should not
64
+ * have to learn the other.
65
+ *
66
+ * Everyone holds the seller's own seeded role, which carries the whole seller
67
+ * vocabulary, so inviting asks only for an address. Narrower seller roles are
68
+ * a design of their own.
69
+ */
70
+ export function TeamPage() {
71
+ const { t } = useTranslation()
72
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
73
+ const [inviteOpen, setInviteOpen] = useState(false)
74
+
75
+ const members = useQuery({
76
+ queryKey: ['seller', sellerId, 'team'],
77
+ queryFn: () => sellerClient().team.list(),
78
+ })
79
+
80
+ const invitations = useQuery({
81
+ queryKey: ['seller', sellerId, 'invitations'],
82
+ queryFn: () => sellerClient().invitations.list(),
83
+ })
84
+
85
+ const pending = invitations.data?.data ?? []
86
+
87
+ return (
88
+ <div className="flex flex-col gap-6">
89
+ <PageHeader
90
+ title={t('team.title')}
91
+ subtitle={t('team.subtitle')}
92
+ actions={
93
+ <>
94
+ {/* A marketplace adds its own actions here via
95
+ `defineDashboardPlugin({ slots: { 'seller.team.actions': [...] } })`. */}
96
+ <Slot name="seller.team.actions" context={{ sellerId }} />
97
+ {/* Purely a UX gate — the API refuses the write regardless. Same
98
+ `<Can>` the operator's dashboard uses, reading the CanCanCan rules
99
+ the seller `/me` serializes. */}
100
+ <Can I="update" a="seller_profile">
101
+ <Button size="sm" onClick={() => setInviteOpen(true)}>
102
+ <PlusIcon className="size-4" />
103
+ {t('team.invite_cta')}
104
+ </Button>
105
+ </Can>
106
+ </>
107
+ }
108
+ />
109
+
110
+ <MembersCard members={members.data?.data ?? []} loading={members.isLoading} />
111
+
112
+ {/* Hidden entirely when there is nothing outstanding — an empty card
113
+ would imply something is wrong. */}
114
+ {(invitations.isLoading || pending.length > 0) && (
115
+ <PendingInvitationsCard invitations={pending} loading={invitations.isLoading} />
116
+ )}
117
+
118
+ {/* Extra cards below the built-ins — a marketplace's own team-related
119
+ content (roles they add, an audit trail) lands here. */}
120
+ <Slot name="seller.team.after" context={{ sellerId }} />
121
+
122
+ <InviteDialog open={inviteOpen} onOpenChange={setInviteOpen} />
123
+ </div>
124
+ )
125
+ }
126
+
127
+ function MembersCard({ members, loading }: { members: TeamMember[]; loading: boolean }) {
128
+ const { t } = useTranslation()
129
+
130
+ return (
131
+ <Card>
132
+ <CardHeader>
133
+ <CardTitle>{t('team.members_section')}</CardTitle>
134
+ </CardHeader>
135
+ <CardContent className="p-0">
136
+ {loading ? (
137
+ <div className="flex flex-col gap-3 p-4">
138
+ <Skeleton className="h-10 w-full" />
139
+ <Skeleton className="h-10 w-full" />
140
+ </div>
141
+ ) : members.length === 0 ? (
142
+ <Empty>
143
+ <EmptyHeader>
144
+ <EmptyMedia variant="icon">
145
+ <UsersRoundIcon />
146
+ </EmptyMedia>
147
+ <EmptyTitle>{t('team.empty')}</EmptyTitle>
148
+ <EmptyDescription>{t('team.empty_description')}</EmptyDescription>
149
+ </EmptyHeader>
150
+ </Empty>
151
+ ) : (
152
+ <Table>
153
+ <TableHeader>
154
+ <TableRow>
155
+ <TableHead>{t('team.table.member')}</TableHead>
156
+ <TableHead className="w-12" />
157
+ </TableRow>
158
+ </TableHeader>
159
+ <TableBody>
160
+ {members.map((member) => (
161
+ <MemberRow key={member.id} member={member} lastOne={members.length === 1} />
162
+ ))}
163
+ </TableBody>
164
+ </Table>
165
+ )}
166
+ </CardContent>
167
+ </Card>
168
+ )
169
+ }
170
+
171
+ function MemberRow({ member, lastOne }: { member: TeamMember; lastOne: boolean }) {
172
+ const { t } = useTranslation()
173
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
174
+ const queryClient = useQueryClient()
175
+ const confirm = useConfirm()
176
+
177
+ const remove = useMutation({
178
+ mutationFn: (id: string) => sellerClient().team.remove(id),
179
+ onSuccess: () => {
180
+ toastManager.add({ type: 'success', title: t('team.messages.removed') })
181
+ void queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'team'] })
182
+ },
183
+ // Read the server's own message rather than assuming which rule was hit —
184
+ // "last member" is one refusal among several the API may grow.
185
+ onError: (err) =>
186
+ toastManager.add({
187
+ type: 'error',
188
+ title: err instanceof Error ? err.message : t('common.error'),
189
+ }),
190
+ })
191
+
192
+ async function handleRemove() {
193
+ const ok = await confirm({
194
+ title: t('team.confirm.remove_title'),
195
+ message: t('team.confirm.remove_message', { name: member.full_name || member.email }),
196
+ variant: 'destructive',
197
+ confirmLabel: t('team.actions.remove'),
198
+ })
199
+ if (ok) remove.mutate(member.id)
200
+ }
201
+
202
+ return (
203
+ <TableRow>
204
+ <TableCell>
205
+ <div className="flex items-center gap-3">
206
+ <Avatar className="size-8">
207
+ {member.avatar_url && <AvatarImage src={member.avatar_url} alt="" />}
208
+ <AvatarFallback className="bg-muted text-xs">
209
+ {getInitials(member.full_name, member.email)}
210
+ </AvatarFallback>
211
+ </Avatar>
212
+ <div className="flex flex-col leading-tight">
213
+ <span className="font-medium text-foreground">{member.full_name || member.email}</span>
214
+ {member.full_name && (
215
+ <span className="text-muted-foreground text-xs">{member.email}</span>
216
+ )}
217
+ </div>
218
+ </div>
219
+ </TableCell>
220
+ <TableCell className="text-right">
221
+ <RowActions
222
+ actions={[
223
+ {
224
+ key: 'remove',
225
+ label: t('team.actions.remove'),
226
+ icon: <UserMinusIcon className="size-4" />,
227
+ destructive: true,
228
+ // A seller nobody can sign in to can only be reopened by the
229
+ // marketplace operator, so the server refuses this too.
230
+ disabled: lastOne || remove.isPending,
231
+ onSelect: handleRemove,
232
+ },
233
+ ]}
234
+ />
235
+ </TableCell>
236
+ </TableRow>
237
+ )
238
+ }
239
+
240
+ function PendingInvitationsCard({
241
+ invitations,
242
+ loading,
243
+ }: {
244
+ invitations: Invitation[]
245
+ loading: boolean
246
+ }) {
247
+ const { t } = useTranslation()
248
+
249
+ return (
250
+ <Card>
251
+ <CardHeader>
252
+ <CardTitle>{t('team.invitations_section')}</CardTitle>
253
+ </CardHeader>
254
+ <CardContent className="p-0">
255
+ {loading ? (
256
+ <div className="p-4">
257
+ <Skeleton className="h-10 w-full" />
258
+ </div>
259
+ ) : (
260
+ <Table>
261
+ <TableHeader>
262
+ <TableRow>
263
+ <TableHead>{t('team.table.email')}</TableHead>
264
+ <TableHead>{t('team.table.expires')}</TableHead>
265
+ <TableHead className="w-12" />
266
+ </TableRow>
267
+ </TableHeader>
268
+ <TableBody>
269
+ {invitations.map((invitation) => (
270
+ <InvitationRow key={invitation.id} invitation={invitation} />
271
+ ))}
272
+ </TableBody>
273
+ </Table>
274
+ )}
275
+ </CardContent>
276
+ </Card>
277
+ )
278
+ }
279
+
280
+ function InvitationRow({ invitation }: { invitation: Invitation }) {
281
+ const { t } = useTranslation()
282
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
283
+ const queryClient = useQueryClient()
284
+ const confirm = useConfirm()
285
+ const { copy } = useCopyToClipboard()
286
+
287
+ const invalidate = () =>
288
+ queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'invitations'] })
289
+
290
+ const resend = useMutation({
291
+ mutationFn: (id: string) => sellerClient().invitations.resend(id),
292
+ onSuccess: () => {
293
+ toastManager.add({ type: 'success', title: t('team.messages.resent') })
294
+ void invalidate()
295
+ },
296
+ onError: (err) =>
297
+ toastManager.add({
298
+ type: 'error',
299
+ title: err instanceof Error ? err.message : t('common.error'),
300
+ }),
301
+ })
302
+
303
+ const revoke = useMutation({
304
+ mutationFn: (id: string) => sellerClient().invitations.revoke(id),
305
+ onSuccess: () => {
306
+ toastManager.add({ type: 'success', title: t('team.messages.revoked') })
307
+ void invalidate()
308
+ },
309
+ onError: (err) =>
310
+ toastManager.add({
311
+ type: 'error',
312
+ title: err instanceof Error ? err.message : t('common.error'),
313
+ }),
314
+ })
315
+
316
+ async function handleCopyLink() {
317
+ // Path-only when no panel origin is configured; resolve against wherever
318
+ // this panel is actually mounted. `BASE_URL` matters: served at /sellers
319
+ // (the single-node topology), origin alone would produce a link that
320
+ // misses the mount and 404s.
321
+ const url = invitation.acceptance_url.startsWith('/')
322
+ ? new URL(
323
+ `.${invitation.acceptance_url}`,
324
+ `${window.location.origin}${import.meta.env.BASE_URL}`,
325
+ ).toString()
326
+ : invitation.acceptance_url
327
+
328
+ await copy(url)
329
+ toastManager.add({ type: 'success', title: t('team.messages.link_copied') })
330
+ }
331
+
332
+ async function handleRevoke() {
333
+ const ok = await confirm({
334
+ title: t('team.confirm.revoke_title'),
335
+ message: t('team.confirm.revoke_message', { email: invitation.email }),
336
+ variant: 'destructive',
337
+ confirmLabel: t('team.actions.revoke'),
338
+ })
339
+ if (ok) revoke.mutate(invitation.id)
340
+ }
341
+
342
+ return (
343
+ <TableRow>
344
+ <TableCell>
345
+ <div className="flex items-center gap-2">
346
+ <MailIcon className="size-4 text-muted-foreground" />
347
+ <span className="font-medium">{invitation.email}</span>
348
+ </div>
349
+ </TableCell>
350
+ <TableCell className="text-muted-foreground text-sm">
351
+ {invitation.expires_at ? (
352
+ <span className="inline-flex items-center gap-1">
353
+ <ClockIcon className="size-3.5" />
354
+ <RelativeTime iso={invitation.expires_at} />
355
+ </span>
356
+ ) : (
357
+ '—'
358
+ )}
359
+ </TableCell>
360
+ <TableCell className="text-right">
361
+ <RowActions
362
+ actions={[
363
+ {
364
+ key: 'resend',
365
+ label: t('team.actions.resend'),
366
+ icon: <SendIcon className="size-4" />,
367
+ disabled: resend.isPending,
368
+ onSelect: () => resend.mutate(invitation.id),
369
+ },
370
+ {
371
+ key: 'copy',
372
+ label: t('team.actions.copy_link'),
373
+ icon: <LinkIcon className="size-4" />,
374
+ onSelect: handleCopyLink,
375
+ },
376
+ {
377
+ key: 'revoke',
378
+ label: t('team.actions.revoke'),
379
+ icon: <XIcon className="size-4" />,
380
+ destructive: true,
381
+ disabled: revoke.isPending,
382
+ onSelect: handleRevoke,
383
+ },
384
+ ]}
385
+ />
386
+ </TableCell>
387
+ </TableRow>
388
+ )
389
+ }
390
+
391
+ function InviteDialog({
392
+ open,
393
+ onOpenChange,
394
+ }: {
395
+ open: boolean
396
+ onOpenChange: (open: boolean) => void
397
+ }) {
398
+ const { t } = useTranslation()
399
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
400
+ const queryClient = useQueryClient()
401
+ const form = useForm<{ email: string }>({ defaultValues: { email: '' } })
402
+
403
+ const invite = useMutation({
404
+ mutationFn: (email: string) => sellerClient().team.invite({ email }),
405
+ onSuccess: () => {
406
+ form.reset({ email: '' })
407
+ onOpenChange(false)
408
+ toastManager.add({ type: 'success', title: t('team.messages.invited') })
409
+ // The new row lands in pending invitations, not in members — they are
410
+ // not a member until they accept.
411
+ void queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'invitations'] })
412
+ },
413
+ onError: (err) =>
414
+ toastManager.add({
415
+ type: 'error',
416
+ title: err instanceof Error ? err.message : t('common.error'),
417
+ }),
418
+ })
419
+
420
+ const { errors } = form.formState
421
+
422
+ return (
423
+ <Dialog
424
+ open={open}
425
+ onOpenChange={(next) => {
426
+ // Clear a half-typed address on dismiss, so reopening starts fresh
427
+ // rather than showing what was abandoned.
428
+ if (!next) form.reset({ email: '' })
429
+ onOpenChange(next)
430
+ }}
431
+ >
432
+ <DialogContent>
433
+ <DialogHeader>
434
+ <DialogTitle>{t('team.invite_title')}</DialogTitle>
435
+ <DialogDescription>{t('team.invite_description')}</DialogDescription>
436
+ </DialogHeader>
437
+
438
+ <form onSubmit={form.handleSubmit((values) => invite.mutate(values.email))}>
439
+ {/* DialogBody owns the padding and the scroll region; the footer
440
+ sits outside it so its divider spans the dialog. */}
441
+ <DialogBody>
442
+ <Field>
443
+ <FieldLabel htmlFor="invite-email">{t('team.invite_email')}</FieldLabel>
444
+ <Input
445
+ id="invite-email"
446
+ type="email"
447
+ autoFocus
448
+ placeholder={t('team.invite_email_placeholder')}
449
+ aria-invalid={!!errors.email || undefined}
450
+ {...form.register('email', { required: true })}
451
+ />
452
+ <FieldError errors={[errors.email]} />
453
+ </Field>
454
+ </DialogBody>
455
+
456
+ <DialogFooter>
457
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
458
+ {t('common.cancel')}
459
+ </Button>
460
+ <Button type="submit" disabled={invite.isPending}>
461
+ {invite.isPending ? t('team.invite_sending') : t('team.invite_submit')}
462
+ </Button>
463
+ </DialogFooter>
464
+ </form>
465
+ </DialogContent>
466
+ </Dialog>
467
+ )
468
+ }
@@ -0,0 +1,36 @@
1
+ import { AppShell, AppShellProvider, TenantProvider } from '@spree/dashboard-core'
2
+ import { useParams, useRouterState } from '@tanstack/react-router'
3
+ import type { ReactNode } from 'react'
4
+ import { SellerSwitcher } from './components/seller-switcher'
5
+
6
+ /**
7
+ * The panel's frame: the nav rail, and the page as a sheet beside it.
8
+ *
9
+ * The frame is the operator dashboard's, not a copy of it — `AppShell` takes
10
+ * the tenant and its sidebar header as props precisely so a second panel can
11
+ * mount it. A seller who has used one recognises the other, and a fix to any
12
+ * of it reaches both.
13
+ *
14
+ * What the operator's chrome has that this does not is the command palette: it
15
+ * needs a search surface this panel does not have yet, so no provider is
16
+ * mounted and the rail's search affordance hides itself accordingly.
17
+ */
18
+ export function PanelChrome({ children }: { children: ReactNode }) {
19
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
20
+ const inSettings = useRouterState({
21
+ select: (state) => state.location.pathname.startsWith(`/${sellerId}/settings`),
22
+ })
23
+
24
+ return (
25
+ // Scopes every shared query key to this seller, the way `StoreProvider`
26
+ // does for the operator's dashboard — without it a cached list could
27
+ // survive a switch and be shown under the next seller.
28
+ <TenantProvider id={sellerId}>
29
+ <AppShellProvider>
30
+ <AppShell tenantId={sellerId} sidebarHeader={<SellerSwitcher />} inSettings={inSettings}>
31
+ {children}
32
+ </AppShell>
33
+ </AppShellProvider>
34
+ </TenantProvider>
35
+ )
36
+ }