@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,389 @@
1
+ import { PageHeader } from '@spree/dashboard-core'
2
+ import {
3
+ Button,
4
+ Card,
5
+ CardAction,
6
+ CardContent,
7
+ CardHeader,
8
+ CardTitle,
9
+ Field,
10
+ FieldError,
11
+ FieldGroup,
12
+ FieldLabel,
13
+ Input,
14
+ RelativeTime,
15
+ ResourceLayout,
16
+ Sheet,
17
+ SheetContent,
18
+ SheetFooter,
19
+ SheetHeader,
20
+ SheetTitle,
21
+ StatusBadge,
22
+ Textarea,
23
+ toastManager,
24
+ } from '@spree/dashboard-ui'
25
+ import { PackageIcon, PencilIcon, StoreIcon, UsersIcon } from '@spree/dashboard-ui/icons'
26
+ import type { Profile } from '@spree/seller-sdk'
27
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
28
+ import { useParams } from '@tanstack/react-router'
29
+ import { useEffect, useState } from 'react'
30
+ import { useForm } from 'react-hook-form'
31
+ import { useTranslation } from 'react-i18next'
32
+ import { sellerClient } from '../api-client'
33
+ import { CenteredMessage } from '../components/centered-message'
34
+ import { ReadRow } from '../components/read-row'
35
+ import { SellerAddressCard } from '../components/seller-address-card'
36
+ import { SellerBusinessCard } from '../components/seller-business-card'
37
+ import { SellerReturnsLocationCard } from '../components/seller-returns-location-card'
38
+ import { SellerTaxIdentifiersCard } from '../components/seller-tax-identifiers-card'
39
+
40
+ interface ProfileValues {
41
+ name: string
42
+ contact_email: string
43
+ billing_email: string
44
+ about: string
45
+ }
46
+
47
+ /**
48
+ * The seller's own record, laid out as the operator sees it on their seller
49
+ * page — brand hero, standing, contact — so the two views of one seller
50
+ * describe the same thing in the same shape.
51
+ *
52
+ * What the operator's page has that this deliberately does not: the lifecycle
53
+ * actions (approve, suspend, reject, delete), the settlement *editor*, and the
54
+ * invite sheet. Those are the marketplace's decisions about this seller, not
55
+ * the seller's own. Settlement terms are still shown, read-only: a seller who
56
+ * cannot see how they are paid cannot query it.
57
+ */
58
+ export function ProfilePage() {
59
+ const { t } = useTranslation()
60
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
61
+ const [editing, setEditing] = useState(false)
62
+
63
+ const {
64
+ data: profile,
65
+ isLoading,
66
+ error,
67
+ } = useQuery({
68
+ queryKey: ['seller', sellerId, 'profile'],
69
+ queryFn: () => sellerClient().profile.get(),
70
+ })
71
+
72
+ // Only for the team count in "at a glance" — the profile payload does not
73
+ // carry one, and a second small read beats widening the serializer for a
74
+ // number the team page already fetches.
75
+ const { data: team } = useQuery({
76
+ queryKey: ['seller', sellerId, 'team'],
77
+ queryFn: () => sellerClient().team.list(),
78
+ })
79
+
80
+ if (isLoading) return <CenteredMessage>{t('common.loading')}</CenteredMessage>
81
+ if (error || !profile) return <CenteredMessage>{t('common.error')}</CenteredMessage>
82
+
83
+ return (
84
+ <>
85
+ <ResourceLayout
86
+ header={<PageHeader title={profile.name} subtitle={t('profile.subtitle')} />}
87
+ main={<BrandCard profile={profile} onEdit={() => setEditing(true)} />}
88
+ sidebar={
89
+ <>
90
+ <StandingCard profile={profile} />
91
+ <AtAGlanceCard profile={profile} teamCount={team?.data?.length} />
92
+ <ContactCard profile={profile} onEdit={() => setEditing(true)} />
93
+ <SellerBusinessCard profile={profile} />
94
+ <SellerTaxIdentifiersCard />
95
+ <SellerAddressCard profile={profile} />
96
+ <SellerReturnsLocationCard />
97
+ <SettlementCard profile={profile} />
98
+ </>
99
+ }
100
+ />
101
+
102
+ <EditProfileSheet profile={profile} open={editing} onOpenChange={setEditing} />
103
+ </>
104
+ )
105
+ }
106
+
107
+ /**
108
+ * The storefront face of the seller: cover, logo, handle and description.
109
+ *
110
+ * Copied from the operator's card, including why the empty states look as
111
+ * they do — a seller with no cover gets a slim band rather than a tall empty
112
+ * block, and the logo tile straddles it with its own z-index so the band's
113
+ * edge does not cut across it.
114
+ */
115
+ function BrandCard({ profile, onEdit }: { profile: Profile; onEdit: () => void }) {
116
+ const { t } = useTranslation()
117
+
118
+ return (
119
+ <Card className="overflow-hidden">
120
+ <div className="relative">
121
+ {profile.cover_photo_url ? (
122
+ <img
123
+ src={profile.cover_photo_url}
124
+ alt=""
125
+ className="h-40 w-full bg-accent object-cover"
126
+ />
127
+ ) : (
128
+ <div className="h-20 w-full border-border border-b bg-muted" />
129
+ )}
130
+ <Button size="sm" variant="outline" className="absolute top-3 right-3" onClick={onEdit}>
131
+ <PencilIcon className="size-4" />
132
+ {t('profile.edit')}
133
+ </Button>
134
+ </div>
135
+
136
+ <CardContent className="flex flex-col gap-4 px-5 pb-5">
137
+ <div className="-mt-10 relative z-10 flex size-20 items-center justify-center overflow-hidden rounded-xl border border-border bg-card shadow-sm">
138
+ {/* The square logo is the one cropped for a tile like this; the main
139
+ logo is the fallback, since a seller may have set only one. */}
140
+ {profile.square_logo_url || profile.logo_url ? (
141
+ <img
142
+ src={profile.square_logo_url ?? profile.logo_url ?? undefined}
143
+ alt=""
144
+ className="size-full object-cover"
145
+ />
146
+ ) : (
147
+ <StoreIcon className="size-7 text-muted-foreground" />
148
+ )}
149
+ </div>
150
+
151
+ {/* The name is already the page title; the handle is what it lacks. */}
152
+ <p className="text-muted-foreground text-sm">/{profile.slug}</p>
153
+
154
+ {profile.about_html ? (
155
+ // Server-sanitized on every write path (Spree::RichTextSanitizer),
156
+ // which is what makes rendering it here safe.
157
+ <div
158
+ className="prose prose-sm dark:prose-invert max-w-none"
159
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized server-side
160
+ dangerouslySetInnerHTML={{ __html: profile.about_html }}
161
+ />
162
+ ) : (
163
+ <p className="text-muted-foreground text-sm">{t('profile.no_about')}</p>
164
+ )}
165
+ </CardContent>
166
+ </Card>
167
+ )
168
+ }
169
+
170
+ /** Where the seller stands with the marketplace, and what that means. */
171
+ function StandingCard({ profile }: { profile: Profile }) {
172
+ const { t } = useTranslation()
173
+
174
+ return (
175
+ <Card>
176
+ <CardHeader>
177
+ <CardTitle>{t('profile.section_standing')}</CardTitle>
178
+ <CardAction>
179
+ <StatusBadge status={profile.status} />
180
+ </CardAction>
181
+ </CardHeader>
182
+ <CardContent className="flex flex-col gap-2">
183
+ <p className="text-muted-foreground text-sm">
184
+ {t(`profile.status_help.${profile.status}`, {
185
+ defaultValue: t('profile.status_help.pending'),
186
+ })}
187
+ </p>
188
+ {profile.on_holiday && (
189
+ <p className="text-muted-foreground text-sm">{t('profile.on_holiday_notice')}</p>
190
+ )}
191
+ </CardContent>
192
+ </Card>
193
+ )
194
+ }
195
+
196
+ function AtAGlanceCard({ profile, teamCount }: { profile: Profile; teamCount?: number }) {
197
+ const { t } = useTranslation()
198
+
199
+ return (
200
+ <Card>
201
+ <CardHeader>
202
+ <CardTitle>{t('profile.at_a_glance')}</CardTitle>
203
+ </CardHeader>
204
+ <CardContent className="flex flex-col gap-3">
205
+ <div className="flex items-center justify-between text-sm">
206
+ <span className="flex items-center gap-2 text-muted-foreground">
207
+ <PackageIcon className="size-4" />
208
+ {t('profile.products_count')}
209
+ </span>
210
+ <span>{profile.products_count}</span>
211
+ </div>
212
+ <div className="flex items-center justify-between text-sm">
213
+ <span className="flex items-center gap-2 text-muted-foreground">
214
+ <UsersIcon className="size-4" />
215
+ {t('profile.team_count')}
216
+ </span>
217
+ <span>{teamCount ?? '—'}</span>
218
+ </div>
219
+ <ReadRow label={t('profile.joined')}>
220
+ <RelativeTime iso={profile.created_at} />
221
+ </ReadRow>
222
+ </CardContent>
223
+ </Card>
224
+ )
225
+ }
226
+
227
+ function ContactCard({ profile, onEdit }: { profile: Profile; onEdit: () => void }) {
228
+ const { t } = useTranslation()
229
+
230
+ return (
231
+ <Card>
232
+ <CardHeader>
233
+ <CardTitle>{t('profile.section_contact')}</CardTitle>
234
+ <CardAction>
235
+ <Button variant="outline" size="sm" onClick={onEdit}>
236
+ <PencilIcon className="size-4" />
237
+ {t('profile.edit')}
238
+ </Button>
239
+ </CardAction>
240
+ </CardHeader>
241
+ <CardContent className="flex flex-col gap-3">
242
+ <ReadRow label={t('profile.contact_email')}>
243
+ {profile.contact_email ? (
244
+ <a href={`mailto:${profile.contact_email}`}>{profile.contact_email}</a>
245
+ ) : null}
246
+ </ReadRow>
247
+ <ReadRow label={t('profile.billing_email')}>
248
+ {profile.billing_email ? (
249
+ <a href={`mailto:${profile.billing_email}`}>{profile.billing_email}</a>
250
+ ) : null}
251
+ </ReadRow>
252
+ </CardContent>
253
+ </Card>
254
+ )
255
+ }
256
+
257
+ /**
258
+ * How and when this seller gets paid. Read-only on purpose: these are the
259
+ * marketplace's terms, and the seller API refuses to write them — showing an
260
+ * editor here would offer a control the server would reject.
261
+ */
262
+ function SettlementCard({ profile }: { profile: Profile }) {
263
+ const { t } = useTranslation()
264
+
265
+ return (
266
+ <Card>
267
+ <CardHeader>
268
+ <CardTitle>{t('profile.section_settlement')}</CardTitle>
269
+ </CardHeader>
270
+ <CardContent className="flex flex-col gap-3">
271
+ <p className="text-muted-foreground text-xs">{t('profile.operator_managed')}</p>
272
+ <ReadRow label={t('profile.tax_remittance')}>{profile.tax_remittance}</ReadRow>
273
+ <ReadRow label={t('profile.payout_schedule')}>{profile.payouts_schedule_interval}</ReadRow>
274
+ <ReadRow label={t('profile.minimum_payout')}>{profile.minimum_payout_amount}</ReadRow>
275
+ </CardContent>
276
+ </Card>
277
+ )
278
+ }
279
+
280
+ function EditProfileSheet({
281
+ profile,
282
+ open,
283
+ onOpenChange,
284
+ }: {
285
+ profile: Profile
286
+ open: boolean
287
+ onOpenChange: (open: boolean) => void
288
+ }) {
289
+ const { t } = useTranslation()
290
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
291
+ const queryClient = useQueryClient()
292
+
293
+ const form = useForm<ProfileValues>({
294
+ defaultValues: { name: '', contact_email: '', billing_email: '', about: '' },
295
+ })
296
+
297
+ // The sheet stays mounted across opens, so re-seed from the latest record
298
+ // each time it opens rather than only on first mount.
299
+ useEffect(() => {
300
+ if (!open) return
301
+
302
+ form.reset({
303
+ name: profile.name,
304
+ contact_email: profile.contact_email ?? '',
305
+ billing_email: profile.billing_email ?? '',
306
+ about: profile.about_html ?? '',
307
+ })
308
+ }, [open, profile, form])
309
+
310
+ const save = useMutation({
311
+ mutationFn: (values: ProfileValues) =>
312
+ sellerClient().profile.update({
313
+ name: values.name,
314
+ contact_email: values.contact_email || null,
315
+ billing_email: values.billing_email || null,
316
+ about: values.about || null,
317
+ }),
318
+ onSuccess: (updated) => {
319
+ queryClient.setQueryData(['seller', sellerId, 'profile'], updated)
320
+ onOpenChange(false)
321
+ toastManager.add({ type: 'success', title: t('profile.saved') })
322
+ },
323
+ onError: (err) =>
324
+ toastManager.add({
325
+ type: 'error',
326
+ title: err instanceof Error ? err.message : t('common.error'),
327
+ }),
328
+ })
329
+
330
+ const { errors } = form.formState
331
+
332
+ return (
333
+ <Sheet open={open} onOpenChange={onOpenChange}>
334
+ <SheetContent>
335
+ <SheetHeader>
336
+ <SheetTitle>{t('profile.edit_title')}</SheetTitle>
337
+ </SheetHeader>
338
+
339
+ {/* `stopPropagation` after handleSubmit: this form is portalled and
340
+ a parent form would otherwise see the bubbling submit. */}
341
+ <form
342
+ onSubmit={(event) => {
343
+ form.handleSubmit((values) => save.mutate(values))(event)
344
+ event.stopPropagation()
345
+ }}
346
+ className="flex min-h-0 flex-1 flex-col"
347
+ >
348
+ <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
349
+ <FieldGroup>
350
+ <Field>
351
+ <FieldLabel htmlFor="name">{t('profile.name')}</FieldLabel>
352
+ <Input
353
+ id="name"
354
+ aria-invalid={!!errors.name || undefined}
355
+ {...form.register('name', { required: true })}
356
+ />
357
+ <FieldError errors={[errors.name]} />
358
+ </Field>
359
+
360
+ <Field>
361
+ <FieldLabel htmlFor="about">{t('profile.about')}</FieldLabel>
362
+ <Textarea id="about" rows={6} {...form.register('about')} />
363
+ </Field>
364
+
365
+ <Field>
366
+ <FieldLabel htmlFor="contact_email">{t('profile.contact_email')}</FieldLabel>
367
+ <Input id="contact_email" type="email" {...form.register('contact_email')} />
368
+ </Field>
369
+
370
+ <Field>
371
+ <FieldLabel htmlFor="billing_email">{t('profile.billing_email')}</FieldLabel>
372
+ <Input id="billing_email" type="email" {...form.register('billing_email')} />
373
+ </Field>
374
+ </FieldGroup>
375
+ </div>
376
+
377
+ <SheetFooter>
378
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
379
+ {t('common.cancel')}
380
+ </Button>
381
+ <Button type="submit" disabled={save.isPending}>
382
+ {save.isPending ? t('profile.saving') : t('profile.save')}
383
+ </Button>
384
+ </SheetFooter>
385
+ </form>
386
+ </SheetContent>
387
+ </Sheet>
388
+ )
389
+ }
@@ -0,0 +1,105 @@
1
+ import { useAuth } from '@spree/dashboard-core'
2
+ import { Button, Field, FieldError, FieldGroup, FieldLabel, Input } from '@spree/dashboard-ui'
3
+ import { Link, useNavigate } from '@tanstack/react-router'
4
+ import { useState } from 'react'
5
+ import { useForm } from 'react-hook-form'
6
+ import { useTranslation } from 'react-i18next'
7
+ import { CenteredMessage } from '../components/centered-message'
8
+
9
+ interface ResetValues {
10
+ password: string
11
+ password_confirmation: string
12
+ }
13
+
14
+ /**
15
+ * Choosing a new password from the emailed link.
16
+ *
17
+ * The token in the URL is the whole credential — nobody is signed in yet.
18
+ * Spending it returns a seller session, so they land in the panel rather than
19
+ * being bounced to a login form.
20
+ */
21
+ export function ResetPasswordPage({ token }: { token?: string }) {
22
+ const { t } = useTranslation()
23
+ const { resetPassword } = useAuth()
24
+ const navigate = useNavigate()
25
+ const [error, setError] = useState<string | null>(null)
26
+ const form = useForm<ResetValues>({
27
+ defaultValues: { password: '', password_confirmation: '' },
28
+ })
29
+
30
+ if (!token) return <CenteredMessage>{t('reset_password.invalid_link')}</CenteredMessage>
31
+
32
+ async function onSubmit(values: ResetValues) {
33
+ setError(null)
34
+ try {
35
+ await resetPassword(token ?? '', values)
36
+ // The index route decides where to go from there — it already knows how
37
+ // to skip the picker for someone who runs exactly one seller.
38
+ navigate({ to: '/', replace: true })
39
+ } catch {
40
+ // A rejected token and a refused password are reported the same way the
41
+ // API reports them: it will not say whether the address exists.
42
+ setError(t('reset_password.failed'))
43
+ }
44
+ }
45
+
46
+ const { errors, isSubmitting } = form.formState
47
+
48
+ return (
49
+ <div className="flex min-h-screen items-center justify-center p-6">
50
+ <div className="w-full max-w-sm">
51
+ <h1 className="font-medium text-xl">{t('reset_password.title')}</h1>
52
+ <p className="mt-1 mb-6 text-muted-foreground text-sm">{t('reset_password.subtitle')}</p>
53
+
54
+ <form onSubmit={form.handleSubmit(onSubmit)}>
55
+ <FieldGroup>
56
+ {error && (
57
+ <p className="text-destructive text-sm" role="alert">
58
+ {error}
59
+ </p>
60
+ )}
61
+
62
+ <Field>
63
+ <FieldLabel htmlFor="password">{t('reset_password.password')}</FieldLabel>
64
+ <Input
65
+ id="password"
66
+ type="password"
67
+ autoComplete="new-password"
68
+ autoFocus
69
+ aria-invalid={!!errors.password || undefined}
70
+ {...form.register('password', { required: true, minLength: 8 })}
71
+ />
72
+ <FieldError errors={[errors.password]} />
73
+ </Field>
74
+
75
+ <Field>
76
+ <FieldLabel htmlFor="password_confirmation">
77
+ {t('reset_password.password_confirmation')}
78
+ </FieldLabel>
79
+ <Input
80
+ id="password_confirmation"
81
+ type="password"
82
+ autoComplete="new-password"
83
+ aria-invalid={!!errors.password_confirmation || undefined}
84
+ {...form.register('password_confirmation', {
85
+ required: true,
86
+ validate: (value, values) =>
87
+ value === values.password || t('reset_password.mismatch'),
88
+ })}
89
+ />
90
+ <FieldError errors={[errors.password_confirmation]} />
91
+ </Field>
92
+
93
+ <Button type="submit" disabled={isSubmitting}>
94
+ {isSubmitting ? t('reset_password.submitting') : t('reset_password.submit')}
95
+ </Button>
96
+
97
+ <Link to="/forgot-password" className="text-sm underline underline-offset-4">
98
+ {t('reset_password.request_new_link')}
99
+ </Link>
100
+ </FieldGroup>
101
+ </form>
102
+ </div>
103
+ </div>
104
+ )
105
+ }
@@ -0,0 +1,46 @@
1
+ import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from '@spree/dashboard-ui'
2
+ import { useQuery } from '@tanstack/react-query'
3
+ import { Link } from '@tanstack/react-router'
4
+ import { useTranslation } from 'react-i18next'
5
+ import { sellerClient } from '../api-client'
6
+ import { CenteredMessage } from '../components/centered-message'
7
+
8
+ /**
9
+ * Which seller to act as.
10
+ *
11
+ * Only shown to a seller who runs more than one — the panel acts as exactly
12
+ * one at a time, because every request carries a single `X-Spree-Seller-Id`.
13
+ */
14
+ export function SellerPicker() {
15
+ const { t } = useTranslation()
16
+ const { data, isLoading, error } = useQuery({
17
+ queryKey: ['seller', 'me'],
18
+ queryFn: () => sellerClient().me(),
19
+ })
20
+
21
+ if (isLoading) return <CenteredMessage>{t('common.loading')}</CenteredMessage>
22
+ if (error) return <CenteredMessage>{t('common.error')}</CenteredMessage>
23
+
24
+ const sellers = data?.sellers ?? []
25
+
26
+ return (
27
+ <div className="flex min-h-screen items-center justify-center p-6">
28
+ <Card className="w-full max-w-md">
29
+ <CardHeader>
30
+ <CardTitle>{t('seller_picker.title')}</CardTitle>
31
+ </CardHeader>
32
+ <CardContent className="flex flex-col gap-2">
33
+ <p className="mb-2 text-muted-foreground text-sm">{t('seller_picker.subtitle')}</p>
34
+ {sellers.map((seller) => (
35
+ <Button key={seller.id} variant="outline" className="justify-between" asChild>
36
+ <Link to="/$sellerId" params={{ sellerId: seller.id }}>
37
+ <span>{seller.name}</span>
38
+ <Badge variant="outline">{seller.status}</Badge>
39
+ </Link>
40
+ </Button>
41
+ ))}
42
+ </CardContent>
43
+ </Card>
44
+ </div>
45
+ )
46
+ }