@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.
- package/LICENSE +21 -0
- package/README.md +31 -0
- package/dist/vite-plugin/index.js +51 -0
- package/package.json +64 -0
- package/src/api-client.ts +203 -0
- package/src/components/centered-message.tsx +8 -0
- package/src/components/finance/balance-summary.tsx +69 -0
- package/src/components/onboarding-nav-badge.tsx +38 -0
- package/src/components/orders/fulfillment-deliveries.tsx +116 -0
- package/src/components/orders/fulfillment-delivery-dialog.tsx +82 -0
- package/src/components/orders/fulfillment-edit-dialog.tsx +95 -0
- package/src/components/orders/fulfillment-fulfill-form.tsx +132 -0
- package/src/components/orders/fulfillment-label-upload-dialog.tsx +95 -0
- package/src/components/orders/fulfillment-split-dialog.tsx +142 -0
- package/src/components/orders/fulfillments-card.tsx +289 -0
- package/src/components/orders/line-label.ts +28 -0
- package/src/components/orders/order-cancel-dialog.tsx +74 -0
- package/src/components/orders/order-cards.tsx +297 -0
- package/src/components/orders/order-earnings-card.tsx +99 -0
- package/src/components/orders/order-notes-cards.tsx +83 -0
- package/src/components/orders/order-payment-card.tsx +94 -0
- package/src/components/orders/post-sale-cards.tsx +347 -0
- package/src/components/orders/post-sale-create-dialogs.tsx +201 -0
- package/src/components/orders/returns-card.tsx +258 -0
- package/src/components/orders/shipping-label-row.tsx +124 -0
- package/src/components/product-status-card.tsx +111 -0
- package/src/components/read-row.tsx +16 -0
- package/src/components/retryable-error.tsx +23 -0
- package/src/components/seller-address-card.tsx +123 -0
- package/src/components/seller-business-card.tsx +152 -0
- package/src/components/seller-returns-location-card.tsx +151 -0
- package/src/components/seller-switcher.tsx +124 -0
- package/src/components/seller-tax-identifiers-card.tsx +63 -0
- package/src/create-router.ts +32 -0
- package/src/hooks/use-fulfillments.ts +155 -0
- package/src/hooks/use-ledger.ts +46 -0
- package/src/hooks/use-order.ts +62 -0
- package/src/hooks/use-post-sale.ts +202 -0
- package/src/hooks/use-reasons.ts +34 -0
- package/src/hooks/use-stock-locations.ts +23 -0
- package/src/hooks/use-store-timezone.ts +26 -0
- package/src/i18n.ts +26 -0
- package/src/index.ts +27 -0
- package/src/lib/packing-slip.ts +109 -0
- package/src/locales/en.json +841 -0
- package/src/nav/default.ts +108 -0
- package/src/nav/settings.ts +114 -0
- package/src/pages/accept-invitation.tsx +127 -0
- package/src/pages/delivery-methods.tsx +647 -0
- package/src/pages/earnings.tsx +44 -0
- package/src/pages/forgot-password.tsx +100 -0
- package/src/pages/home.tsx +132 -0
- package/src/pages/login.tsx +94 -0
- package/src/pages/onboarding.tsx +539 -0
- package/src/pages/order.tsx +119 -0
- package/src/pages/orders.tsx +33 -0
- package/src/pages/payout.tsx +154 -0
- package/src/pages/payouts.tsx +32 -0
- package/src/pages/policies.tsx +292 -0
- package/src/pages/product.tsx +250 -0
- package/src/pages/products.tsx +228 -0
- package/src/pages/profile.tsx +389 -0
- package/src/pages/reset-password.tsx +105 -0
- package/src/pages/seller-picker.tsx +46 -0
- package/src/pages/team.tsx +468 -0
- package/src/panel-chrome.tsx +36 -0
- package/src/routeTree.gen.ts +590 -0
- package/src/routes/__root.tsx +15 -0
- package/src/routes/_authenticated/$sellerId/earnings/index.tsx +15 -0
- package/src/routes/_authenticated/$sellerId/index.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/onboarding.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/orders/$orderId.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/orders/index.tsx +15 -0
- package/src/routes/_authenticated/$sellerId/payouts/$payoutId.tsx +7 -0
- package/src/routes/_authenticated/$sellerId/payouts/index.tsx +15 -0
- package/src/routes/_authenticated/$sellerId/products/$productId.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/products/index.tsx +23 -0
- package/src/routes/_authenticated/$sellerId/products/new.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/profile.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/settings/delivery-methods.tsx +14 -0
- package/src/routes/_authenticated/$sellerId/settings/index.tsx +18 -0
- package/src/routes/_authenticated/$sellerId/settings/package-types.tsx +21 -0
- package/src/routes/_authenticated/$sellerId/settings/policies.tsx +14 -0
- package/src/routes/_authenticated/$sellerId/settings/stock-locations.tsx +22 -0
- package/src/routes/_authenticated/$sellerId/settings/team.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/settings.tsx +18 -0
- package/src/routes/_authenticated/$sellerId.tsx +49 -0
- package/src/routes/_authenticated/index.tsx +48 -0
- package/src/routes/_authenticated.tsx +13 -0
- package/src/routes/accept-invitation.$invitationId.tsx +19 -0
- package/src/routes/forgot-password.tsx +15 -0
- package/src/routes/login.tsx +15 -0
- package/src/routes/reset-password.tsx +21 -0
- package/src/schemas/fulfillment.ts +31 -0
- package/src/seller-dashboard.tsx +50 -0
- package/src/styles.css +18 -0
- package/src/tables/delivery-methods.tsx +68 -0
- package/src/tables/orders.tsx +116 -0
- package/src/tables/payouts.tsx +87 -0
- package/src/tables/policies.tsx +37 -0
- package/src/tables/products.tsx +56 -0
- package/src/tables/transfers.tsx +118 -0
- 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
|
+
}
|