@spree/seller-dashboard 6.0.3 → 6.0.4

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 (59) hide show
  1. package/package.json +23 -23
  2. package/src/api-client.ts +2 -0
  3. package/src/components/account-dialog.tsx +1 -1
  4. package/src/components/centered-message.tsx +1 -1
  5. package/src/components/finance/balance-summary.tsx +1 -1
  6. package/src/components/orders/fulfillment-deliveries.tsx +1 -1
  7. package/src/components/orders/fulfillments-card.tsx +30 -20
  8. package/src/components/orders/order-cards.tsx +115 -102
  9. package/src/components/orders/order-payment-card.tsx +2 -3
  10. package/src/components/orders/post-sale-cards.tsx +79 -47
  11. package/src/components/orders/post-sale-create-dialogs.tsx +15 -0
  12. package/src/components/orders/returns-card.tsx +44 -36
  13. package/src/components/orders/shipping-label-row.tsx +1 -1
  14. package/src/components/orders/use-product-link.tsx +10 -0
  15. package/src/components/product-status-card.tsx +2 -2
  16. package/src/components/seller-address-card.tsx +2 -2
  17. package/src/components/seller-returns-location-card.tsx +2 -7
  18. package/src/components/seller-switcher.tsx +1 -1
  19. package/src/hooks/use-fulfillments.ts +33 -22
  20. package/src/hooks/use-profile.ts +38 -0
  21. package/src/locales/ar.json +35 -31
  22. package/src/locales/de.json +35 -31
  23. package/src/locales/en.json +35 -31
  24. package/src/locales/fr.json +35 -31
  25. package/src/locales/ja.json +35 -31
  26. package/src/locales/pl.json +35 -31
  27. package/src/locales/vi.json +35 -31
  28. package/src/locales/zh-CN.json +35 -31
  29. package/src/nav/default.ts +0 -10
  30. package/src/nav/settings.ts +53 -0
  31. package/src/pages/delivery-methods.tsx +47 -49
  32. package/src/pages/forgot-password.tsx +5 -5
  33. package/src/pages/general-settings.tsx +233 -0
  34. package/src/pages/home.tsx +1 -1
  35. package/src/pages/login.tsx +3 -3
  36. package/src/pages/onboarding.tsx +26 -23
  37. package/src/pages/order.tsx +2 -1
  38. package/src/pages/orders.tsx +8 -15
  39. package/src/pages/payout.tsx +7 -4
  40. package/src/pages/payouts.tsx +23 -2
  41. package/src/pages/policies.tsx +12 -8
  42. package/src/pages/product.tsx +18 -4
  43. package/src/pages/products.tsx +0 -4
  44. package/src/pages/profile-settings.tsx +221 -0
  45. package/src/pages/reset-password.tsx +3 -3
  46. package/src/pages/seller-picker.tsx +1 -1
  47. package/src/pages/taxes.tsx +40 -0
  48. package/src/pages/team.tsx +1 -1
  49. package/src/routeTree.gen.ts +63 -0
  50. package/src/routes/_authenticated/$sellerId/profile.tsx +5 -3
  51. package/src/routes/_authenticated/$sellerId/settings/general.tsx +6 -0
  52. package/src/routes/_authenticated/$sellerId/settings/profile.tsx +6 -0
  53. package/src/routes/_authenticated/$sellerId/settings/taxes.tsx +6 -0
  54. package/src/routes/_authenticated/$sellerId/settings.tsx +3 -7
  55. package/src/schemas/account.ts +1 -1
  56. package/src/tables/products.tsx +6 -5
  57. package/src/components/read-row.tsx +0 -16
  58. package/src/components/seller-business-card.tsx +0 -152
  59. package/src/pages/profile.tsx +0 -395
@@ -1,152 +0,0 @@
1
- import {
2
- Button,
3
- Card,
4
- CardAction,
5
- CardContent,
6
- CardHeader,
7
- CardTitle,
8
- Field,
9
- FieldLabel,
10
- Input,
11
- Sheet,
12
- SheetContent,
13
- SheetFooter,
14
- SheetHeader,
15
- SheetTitle,
16
- toastManager,
17
- } from '@spree/dashboard-ui'
18
- import { PencilIcon } from '@spree/dashboard-ui/icons'
19
- import type { Profile } from '@spree/seller-sdk'
20
- import { useMutation, useQueryClient } from '@tanstack/react-query'
21
- import { useParams } from '@tanstack/react-router'
22
- import { useState } from 'react'
23
- import { useTranslation } from 'react-i18next'
24
- import { sellerClient } from '../api-client'
25
-
26
- function errorMessage(error: unknown, fallback: string) {
27
- return error instanceof Error && error.message ? error.message : fallback
28
- }
29
-
30
- /**
31
- * The business behind the seller, as the marketplace's commission invoice must
32
- * address it: the legal name and registration number.
33
- *
34
- * The tax registrations sit in their own card beside this one, on the same
35
- * panel a company's are managed from — a business trading in several regimes
36
- * holds a registration in each, so they are a collection rather than a field.
37
- *
38
- * Separate from the billing address, which is only where post goes.
39
- */
40
- export function SellerBusinessCard({ profile }: { profile: Profile }) {
41
- const { t } = useTranslation()
42
- const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
43
- const queryClient = useQueryClient()
44
- const [editing, setEditing] = useState(false)
45
-
46
- const [legalName, setLegalName] = useState(profile.legal_name ?? '')
47
- const [registrationNumber, setRegistrationNumber] = useState(profile.registration_number ?? '')
48
- // Shown in the sheet rather than as a toast: the toast stack deliberately
49
- // sits below the sheet overlay, so a rejection raised here would be hidden
50
- // behind the very form that caused it.
51
- const [formError, setFormError] = useState<string | null>(null)
52
-
53
- // Opening the sheet is the seller starting over, so it shows what is on file
54
- // rather than whatever they last typed — the card never remounts, so an
55
- // abandoned edit would otherwise outlive the sheet that made it.
56
- function setSheetOpen(open: boolean) {
57
- if (open) {
58
- setLegalName(profile.legal_name ?? '')
59
- setRegistrationNumber(profile.registration_number ?? '')
60
- setFormError(null)
61
- }
62
- setEditing(open)
63
- }
64
-
65
- const save = useMutation({
66
- mutationFn: () => {
67
- setFormError(null)
68
- return sellerClient().profile.update({
69
- legal_name: legalName || null,
70
- registration_number: registrationNumber || null,
71
- })
72
- },
73
- onSuccess: (updated) => {
74
- queryClient.setQueryData(['seller', sellerId, 'profile'], updated)
75
- // A checklist kind may read these, so it has to re-evaluate.
76
- void queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'onboarding'] })
77
- setEditing(false)
78
- toastManager.add({ type: 'success', title: t('profile.saved') })
79
- },
80
- onError: (err) => setFormError(errorMessage(err, t('common.error'))),
81
- })
82
-
83
- const rows = [
84
- { label: t('profile.legal_name'), value: profile.legal_name },
85
- { label: t('profile.registration_number'), value: profile.registration_number },
86
- ]
87
-
88
- return (
89
- <>
90
- <Card>
91
- <CardHeader>
92
- <CardTitle>{t('profile.business_details')}</CardTitle>
93
- <CardAction>
94
- <Button variant="outline" size="sm" onClick={() => setSheetOpen(true)}>
95
- <PencilIcon className="size-4" />
96
- {t('profile.edit')}
97
- </Button>
98
- </CardAction>
99
- </CardHeader>
100
- <CardContent className="flex flex-col gap-2 text-sm">
101
- {rows.map((row) => (
102
- <div key={row.label} className="flex justify-between gap-4">
103
- <span className="text-muted-foreground">{row.label}</span>
104
- <span className="text-right">{row.value || '—'}</span>
105
- </div>
106
- ))}
107
- </CardContent>
108
- </Card>
109
-
110
- <Sheet open={editing} onOpenChange={setSheetOpen}>
111
- <SheetContent>
112
- <SheetHeader>
113
- <SheetTitle>{t('profile.business_details')}</SheetTitle>
114
- </SheetHeader>
115
- <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
116
- {formError && (
117
- <p role="alert" className="text-destructive text-sm">
118
- {formError}
119
- </p>
120
- )}
121
- <Field>
122
- <FieldLabel htmlFor="legal-name">{t('profile.legal_name')}</FieldLabel>
123
- <Input
124
- id="legal-name"
125
- value={legalName}
126
- onChange={(event) => setLegalName(event.target.value)}
127
- />
128
- </Field>
129
- <Field>
130
- <FieldLabel htmlFor="registration-number">
131
- {t('profile.registration_number')}
132
- </FieldLabel>
133
- <Input
134
- id="registration-number"
135
- value={registrationNumber}
136
- onChange={(event) => setRegistrationNumber(event.target.value)}
137
- />
138
- </Field>
139
- </div>
140
- <SheetFooter>
141
- <Button variant="outline" size="sm" onClick={() => setSheetOpen(false)}>
142
- {t('common.cancel')}
143
- </Button>
144
- <Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
145
- {t('common.save')}
146
- </Button>
147
- </SheetFooter>
148
- </SheetContent>
149
- </Sheet>
150
- </>
151
- )
152
- }
@@ -1,395 +0,0 @@
1
- import { PageHeader, Slot } 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={
88
- <>
89
- <BrandCard profile={profile} onEdit={() => setEditing(true)} />
90
- <Slot name="seller.profile.form_main" context={{ profile }} />
91
- </>
92
- }
93
- sidebar={
94
- <>
95
- <StandingCard profile={profile} />
96
- <AtAGlanceCard profile={profile} teamCount={team?.data?.length} />
97
- <ContactCard profile={profile} onEdit={() => setEditing(true)} />
98
- <SellerBusinessCard profile={profile} />
99
- <SellerTaxIdentifiersCard />
100
- <SellerAddressCard profile={profile} />
101
- <SellerReturnsLocationCard />
102
- <SettlementCard profile={profile} />
103
- <Slot name="seller.profile.form_sidebar" context={{ profile }} />
104
- </>
105
- }
106
- />
107
-
108
- <EditProfileSheet profile={profile} open={editing} onOpenChange={setEditing} />
109
- </>
110
- )
111
- }
112
-
113
- /**
114
- * The storefront face of the seller: cover, logo, handle and description.
115
- *
116
- * Copied from the operator's card, including why the empty states look as
117
- * they do — a seller with no cover gets a slim band rather than a tall empty
118
- * block, and the logo tile straddles it with its own z-index so the band's
119
- * edge does not cut across it.
120
- */
121
- function BrandCard({ profile, onEdit }: { profile: Profile; onEdit: () => void }) {
122
- const { t } = useTranslation()
123
-
124
- return (
125
- <Card className="overflow-hidden">
126
- <div className="relative">
127
- {profile.cover_photo_url ? (
128
- <img
129
- src={profile.cover_photo_url}
130
- alt=""
131
- className="h-40 w-full bg-accent object-cover"
132
- />
133
- ) : (
134
- <div className="h-20 w-full border-border border-b bg-muted" />
135
- )}
136
- <Button size="sm" variant="outline" className="absolute top-3 right-3" onClick={onEdit}>
137
- <PencilIcon className="size-4" />
138
- {t('profile.edit')}
139
- </Button>
140
- </div>
141
-
142
- <CardContent className="flex flex-col gap-4 px-5 pb-5">
143
- <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">
144
- {/* The square logo is the one cropped for a tile like this; the main
145
- logo is the fallback, since a seller may have set only one. */}
146
- {profile.square_logo_url || profile.logo_url ? (
147
- <img
148
- src={profile.square_logo_url ?? profile.logo_url ?? undefined}
149
- alt=""
150
- className="size-full object-cover"
151
- />
152
- ) : (
153
- <StoreIcon className="size-7 text-muted-foreground" />
154
- )}
155
- </div>
156
-
157
- {/* The name is already the page title; the handle is what it lacks. */}
158
- <p className="text-muted-foreground text-sm">/{profile.slug}</p>
159
-
160
- {profile.about_html ? (
161
- // Server-sanitized on every write path (Spree::RichTextSanitizer),
162
- // which is what makes rendering it here safe.
163
- <div
164
- className="prose prose-sm dark:prose-invert max-w-none"
165
- // biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized server-side
166
- dangerouslySetInnerHTML={{ __html: profile.about_html }}
167
- />
168
- ) : (
169
- <p className="text-muted-foreground text-sm">{t('profile.no_about')}</p>
170
- )}
171
- </CardContent>
172
- </Card>
173
- )
174
- }
175
-
176
- /** Where the seller stands with the marketplace, and what that means. */
177
- function StandingCard({ profile }: { profile: Profile }) {
178
- const { t } = useTranslation()
179
-
180
- return (
181
- <Card>
182
- <CardHeader>
183
- <CardTitle>{t('profile.section_standing')}</CardTitle>
184
- <CardAction>
185
- <StatusBadge status={profile.status} />
186
- </CardAction>
187
- </CardHeader>
188
- <CardContent className="flex flex-col gap-2">
189
- <p className="text-muted-foreground text-sm">
190
- {t(`profile.status_help.${profile.status}`, {
191
- defaultValue: t('profile.status_help.pending'),
192
- })}
193
- </p>
194
- {profile.on_holiday && (
195
- <p className="text-muted-foreground text-sm">{t('profile.on_holiday_notice')}</p>
196
- )}
197
- </CardContent>
198
- </Card>
199
- )
200
- }
201
-
202
- function AtAGlanceCard({ profile, teamCount }: { profile: Profile; teamCount?: number }) {
203
- const { t } = useTranslation()
204
-
205
- return (
206
- <Card>
207
- <CardHeader>
208
- <CardTitle>{t('profile.at_a_glance')}</CardTitle>
209
- </CardHeader>
210
- <CardContent className="flex flex-col gap-3">
211
- <div className="flex items-center justify-between text-sm">
212
- <span className="flex items-center gap-2 text-muted-foreground">
213
- <PackageIcon className="size-4" />
214
- {t('profile.products_count')}
215
- </span>
216
- <span>{profile.products_count}</span>
217
- </div>
218
- <div className="flex items-center justify-between text-sm">
219
- <span className="flex items-center gap-2 text-muted-foreground">
220
- <UsersIcon className="size-4" />
221
- {t('profile.team_count')}
222
- </span>
223
- <span>{teamCount ?? '—'}</span>
224
- </div>
225
- <ReadRow label={t('profile.joined')}>
226
- <RelativeTime iso={profile.created_at} />
227
- </ReadRow>
228
- </CardContent>
229
- </Card>
230
- )
231
- }
232
-
233
- function ContactCard({ profile, onEdit }: { profile: Profile; onEdit: () => void }) {
234
- const { t } = useTranslation()
235
-
236
- return (
237
- <Card>
238
- <CardHeader>
239
- <CardTitle>{t('profile.section_contact')}</CardTitle>
240
- <CardAction>
241
- <Button variant="outline" size="sm" onClick={onEdit}>
242
- <PencilIcon className="size-4" />
243
- {t('profile.edit')}
244
- </Button>
245
- </CardAction>
246
- </CardHeader>
247
- <CardContent className="flex flex-col gap-3">
248
- <ReadRow label={t('profile.contact_email')}>
249
- {profile.contact_email ? (
250
- <a href={`mailto:${profile.contact_email}`}>{profile.contact_email}</a>
251
- ) : null}
252
- </ReadRow>
253
- <ReadRow label={t('profile.billing_email')}>
254
- {profile.billing_email ? (
255
- <a href={`mailto:${profile.billing_email}`}>{profile.billing_email}</a>
256
- ) : null}
257
- </ReadRow>
258
- </CardContent>
259
- </Card>
260
- )
261
- }
262
-
263
- /**
264
- * How and when this seller gets paid. Read-only on purpose: these are the
265
- * marketplace's terms, and the seller API refuses to write them — showing an
266
- * editor here would offer a control the server would reject.
267
- */
268
- function SettlementCard({ profile }: { profile: Profile }) {
269
- const { t } = useTranslation()
270
-
271
- return (
272
- <Card>
273
- <CardHeader>
274
- <CardTitle>{t('profile.section_settlement')}</CardTitle>
275
- </CardHeader>
276
- <CardContent className="flex flex-col gap-3">
277
- <p className="text-muted-foreground text-xs">{t('profile.operator_managed')}</p>
278
- <ReadRow label={t('profile.tax_remittance')}>{profile.tax_remittance}</ReadRow>
279
- <ReadRow label={t('profile.payout_schedule')}>{profile.payouts_schedule_interval}</ReadRow>
280
- <ReadRow label={t('profile.minimum_payout')}>{profile.minimum_payout_amount}</ReadRow>
281
- </CardContent>
282
- </Card>
283
- )
284
- }
285
-
286
- function EditProfileSheet({
287
- profile,
288
- open,
289
- onOpenChange,
290
- }: {
291
- profile: Profile
292
- open: boolean
293
- onOpenChange: (open: boolean) => void
294
- }) {
295
- const { t } = useTranslation()
296
- const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
297
- const queryClient = useQueryClient()
298
-
299
- const form = useForm<ProfileValues>({
300
- defaultValues: { name: '', contact_email: '', billing_email: '', about: '' },
301
- })
302
-
303
- // The sheet stays mounted across opens, so re-seed from the latest record
304
- // each time it opens rather than only on first mount.
305
- useEffect(() => {
306
- if (!open) return
307
-
308
- form.reset({
309
- name: profile.name,
310
- contact_email: profile.contact_email ?? '',
311
- billing_email: profile.billing_email ?? '',
312
- about: profile.about_html ?? '',
313
- })
314
- }, [open, profile, form])
315
-
316
- const save = useMutation({
317
- mutationFn: (values: ProfileValues) =>
318
- sellerClient().profile.update({
319
- name: values.name,
320
- contact_email: values.contact_email || null,
321
- billing_email: values.billing_email || null,
322
- about: values.about || null,
323
- }),
324
- onSuccess: (updated) => {
325
- queryClient.setQueryData(['seller', sellerId, 'profile'], updated)
326
- onOpenChange(false)
327
- toastManager.add({ type: 'success', title: t('profile.saved') })
328
- },
329
- onError: (err) =>
330
- toastManager.add({
331
- type: 'error',
332
- title: err instanceof Error ? err.message : t('common.error'),
333
- }),
334
- })
335
-
336
- const { errors } = form.formState
337
-
338
- return (
339
- <Sheet open={open} onOpenChange={onOpenChange}>
340
- <SheetContent>
341
- <SheetHeader>
342
- <SheetTitle>{t('profile.edit_title')}</SheetTitle>
343
- </SheetHeader>
344
-
345
- {/* `stopPropagation` after handleSubmit: this form is portalled and
346
- a parent form would otherwise see the bubbling submit. */}
347
- <form
348
- onSubmit={(event) => {
349
- form.handleSubmit((values) => save.mutate(values))(event)
350
- event.stopPropagation()
351
- }}
352
- className="flex min-h-0 flex-1 flex-col"
353
- >
354
- <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
355
- <FieldGroup>
356
- <Field>
357
- <FieldLabel htmlFor="name">{t('profile.name')}</FieldLabel>
358
- <Input
359
- id="name"
360
- aria-invalid={!!errors.name || undefined}
361
- {...form.register('name', { required: true })}
362
- />
363
- <FieldError errors={[errors.name]} />
364
- </Field>
365
-
366
- <Field>
367
- <FieldLabel htmlFor="about">{t('profile.about')}</FieldLabel>
368
- <Textarea id="about" rows={6} {...form.register('about')} />
369
- </Field>
370
-
371
- <Field>
372
- <FieldLabel htmlFor="contact_email">{t('profile.contact_email')}</FieldLabel>
373
- <Input id="contact_email" type="email" {...form.register('contact_email')} />
374
- </Field>
375
-
376
- <Field>
377
- <FieldLabel htmlFor="billing_email">{t('profile.billing_email')}</FieldLabel>
378
- <Input id="billing_email" type="email" {...form.register('billing_email')} />
379
- </Field>
380
- </FieldGroup>
381
- </div>
382
-
383
- <SheetFooter>
384
- <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
385
- {t('common.cancel')}
386
- </Button>
387
- <Button type="submit" disabled={save.isPending}>
388
- {save.isPending ? t('profile.saving') : t('profile.save')}
389
- </Button>
390
- </SheetFooter>
391
- </form>
392
- </SheetContent>
393
- </Sheet>
394
- )
395
- }