@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,539 @@
1
+ import {
2
+ EMPTY_FILE_UPLOAD_VALUE,
3
+ FileUploadField,
4
+ type FileUploadValue,
5
+ PageHeader,
6
+ progressPercentage,
7
+ } from '@spree/dashboard-core'
8
+ import {
9
+ Accordion,
10
+ AccordionContent,
11
+ AccordionItem,
12
+ AccordionTrigger,
13
+ Badge,
14
+ Button,
15
+ Card,
16
+ CardContent,
17
+ CardHeader,
18
+ CardTitle,
19
+ Field,
20
+ FieldLabel,
21
+ Input,
22
+ Progress,
23
+ ScrollArea,
24
+ StatusBadge as SharedStatusBadge,
25
+ Textarea,
26
+ toastManager,
27
+ } from '@spree/dashboard-ui'
28
+ import {
29
+ CheckCircle2Icon,
30
+ ChevronRightIcon,
31
+ CircleIcon,
32
+ ClockIcon,
33
+ ExternalLinkIcon,
34
+ XCircleIcon,
35
+ } from '@spree/dashboard-ui/icons'
36
+ import type { RequirementStatus } from '@spree/seller-sdk'
37
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
38
+ import { Link, useParams } from '@tanstack/react-router'
39
+ import { useState } from 'react'
40
+ import { useTranslation } from 'react-i18next'
41
+ import { sellerClient } from '../api-client'
42
+ import { CenteredMessage } from '../components/centered-message'
43
+ import { SellerAddressCard } from '../components/seller-address-card'
44
+ import { SellerReturnsLocationCard } from '../components/seller-returns-location-card'
45
+
46
+ /**
47
+ * What the marketplace asks of this seller before admitting them.
48
+ *
49
+ * Deliberately one page rather than a wizard: the checklist is not ordered by
50
+ * dependency, a seller can do these in any order, and several are things they
51
+ * have to go away and come back for (a document to find, an account to open).
52
+ * A wizard would trap them at step two.
53
+ *
54
+ * Every line and the progress bar render from `GET /seller/onboarding` and
55
+ * nothing else — the same evaluator the operator reads, so the two can never
56
+ * disagree about what is outstanding.
57
+ */
58
+ export function OnboardingPage() {
59
+ const { t } = useTranslation()
60
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
61
+ const queryClient = useQueryClient()
62
+
63
+ const { data, isLoading, error } = useQuery({
64
+ queryKey: ['seller', sellerId, 'onboarding'],
65
+ queryFn: () => sellerClient().onboarding.get(),
66
+ })
67
+
68
+ const submit = useMutation({
69
+ mutationFn: () => sellerClient().onboarding.submitForReview(),
70
+ onSuccess: (next) => {
71
+ queryClient.setQueryData(['seller', sellerId, 'onboarding'], next)
72
+ toastManager.add({ type: 'success', title: t('onboarding.submitted') })
73
+ },
74
+ // The server names what is outstanding; showing our own wording instead
75
+ // would tell the seller less than the API already knows.
76
+ onError: (err) =>
77
+ toastManager.add({
78
+ type: 'error',
79
+ title: err instanceof Error ? err.message : t('common.error'),
80
+ }),
81
+ })
82
+
83
+ if (isLoading) return <CenteredMessage>{t('common.loading')}</CenteredMessage>
84
+ if (error || !data) return <CenteredMessage>{t('common.error')}</CenteredMessage>
85
+
86
+ const { progress, requirements, status } = data
87
+ const blocking = requirements.filter((requirement) => requirement.blocking)
88
+ const submitted = status === 'ready_for_review'
89
+ const outstanding = firstOutstanding(requirements)
90
+ const openByDefault = outstanding ? [outstanding.id] : []
91
+
92
+ return (
93
+ <div className="flex flex-col gap-6">
94
+ <PageHeader title={t('onboarding.title')} subtitle={t('onboarding.subtitle')} />
95
+
96
+ <Card>
97
+ <CardHeader className="flex-row items-center justify-between gap-4">
98
+ <CardTitle>{t('onboarding.progress_title')}</CardTitle>
99
+ <Badge variant={progress.done >= progress.total ? 'success' : 'secondary'}>
100
+ {t('onboarding.progress_count', { done: progress.done, total: progress.total })}
101
+ </Badge>
102
+ </CardHeader>
103
+ <CardContent className="flex flex-col gap-4">
104
+ <Progress value={progressPercentage(progress)} />
105
+
106
+ {submitted ? (
107
+ <p className="text-muted-foreground text-sm">{t('onboarding.under_review')}</p>
108
+ ) : (
109
+ <div className="flex items-center justify-between gap-4">
110
+ <p className="text-muted-foreground text-sm">
111
+ {blocking.length === 0
112
+ ? t('onboarding.ready')
113
+ : t('onboarding.remaining', { count: blocking.length })}
114
+ </p>
115
+ <Button
116
+ disabled={blocking.length > 0 || submit.isPending}
117
+ onClick={() => submit.mutate()}
118
+ >
119
+ {submit.isPending ? t('onboarding.submitting') : t('onboarding.submit')}
120
+ </Button>
121
+ </div>
122
+ )}
123
+ </CardContent>
124
+ </Card>
125
+
126
+ {requirements.length > 0 && (
127
+ // One card holding every requirement, matching the operator's getting
128
+ // started checklist: this is a single list read top to bottom, and a
129
+ // frame around each line made them read as unrelated things.
130
+ //
131
+ // `multiple` because these are independent — a seller does them in any
132
+ // order — so opening one must not close the one they were reading.
133
+ // Opens on the first outstanding line, which answers "what next".
134
+ <Card className="overflow-hidden py-0">
135
+ <Accordion multiple defaultValue={openByDefault}>
136
+ {requirements.map((requirement) => (
137
+ <AccordionItem key={requirement.id} value={requirement.id}>
138
+ <AccordionTrigger>
139
+ <StatusIcon status={requirement.status} />
140
+ <div className="flex min-w-0 flex-col">
141
+ <span
142
+ className={
143
+ requirement.status === 'complete' ? 'text-muted-foreground' : undefined
144
+ }
145
+ >
146
+ {requirement.name}
147
+ </span>
148
+ {!requirement.required && (
149
+ <span className="font-normal text-muted-foreground text-xs">
150
+ {t('onboarding.optional')}
151
+ </span>
152
+ )}
153
+ </div>
154
+ <StatusBadge status={requirement.status} />
155
+ </AccordionTrigger>
156
+ <AccordionContent className="flex flex-col gap-3 ps-12">
157
+ {requirement.description && (
158
+ <p className="text-muted-foreground text-sm">{requirement.description}</p>
159
+ )}
160
+ <RequirementAction requirement={requirement} />
161
+ </AccordionContent>
162
+ </AccordionItem>
163
+ ))}
164
+ </Accordion>
165
+ </Card>
166
+ )}
167
+ </div>
168
+ )
169
+ }
170
+
171
+ function firstOutstanding(requirements: RequirementStatus[]): RequirementStatus | undefined {
172
+ return requirements.find((requirement) => requirement.status !== 'complete')
173
+ }
174
+
175
+ /**
176
+ * What the seller does about one line, keyed off `kind`.
177
+ *
178
+ * The server decides which kinds exist and what each means; this only decides
179
+ * how to ask. An unrecognised kind — one a provider gem added — still renders
180
+ * its name, description and any `action_url`, so a new kind is useful here
181
+ * before this file knows about it.
182
+ */
183
+ function RequirementAction({ requirement }: { requirement: RequirementStatus }) {
184
+ const { t } = useTranslation()
185
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
186
+ const queryClient = useQueryClient()
187
+ const [note, setNote] = useState('')
188
+ const [file, setFile] = useState<FileUploadValue>(EMPTY_FILE_UPLOAD_VALUE)
189
+ const [fields, setFields] = useState<Record<string, string>>({})
190
+
191
+ // Only the billing address needs it, and it is already loaded by the
192
+ // profile page and the sidebar switcher — this reads the same cache entry.
193
+ // The returns address does not: it lives on a stock location, which its own
194
+ // card fetches.
195
+ const { data: profile } = useQuery({
196
+ queryKey: ['seller', sellerId, 'profile'],
197
+ queryFn: () => sellerClient().profile.get(),
198
+ enabled: requirement.kind === 'billing_address',
199
+ })
200
+
201
+ const invalidate = () =>
202
+ queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'onboarding'] })
203
+
204
+ const acceptTerms = useMutation({
205
+ mutationFn: () => sellerClient().profile.update({ accept_terms: true }),
206
+ onSuccess: () => {
207
+ toastManager.add({ type: 'success', title: t('onboarding.terms_accepted') })
208
+ void invalidate()
209
+ },
210
+ onError: (err) =>
211
+ toastManager.add({
212
+ type: 'error',
213
+ title: err instanceof Error ? err.message : t('common.error'),
214
+ }),
215
+ })
216
+
217
+ // Asked for at the moment the seller clicks, never earlier: a provider's
218
+ // hosted link is short-lived and single-use, so one fetched while drawing
219
+ // this page would often be dead by the time it was used. The seller comes
220
+ // back here — this page is where the checklist says what is outstanding.
221
+ const connectPayoutAccount = useMutation({
222
+ mutationFn: () => {
223
+ const here = window.location.href.split('?')[0]
224
+
225
+ return sellerClient().onboarding.payoutAccount({
226
+ refresh_url: here,
227
+ return_url: here,
228
+ })
229
+ },
230
+ onSuccess: ({ url }) => {
231
+ if (url) window.location.href = url
232
+ },
233
+ onError: (err) =>
234
+ toastManager.add({
235
+ type: 'error',
236
+ title: err instanceof Error ? err.message : t('common.error'),
237
+ }),
238
+ })
239
+
240
+ // One submit for every kind that takes one — an attestation the seller
241
+ // ticks, a document they upload, a manual check they say is ready. What
242
+ // differs is which controls render above it, not what posting means.
243
+ const submit = useMutation({
244
+ mutationFn: () =>
245
+ sellerClient().requirementSubmissions.create(requirement.id, {
246
+ note: note || undefined,
247
+ file: file.signedId || undefined,
248
+ }),
249
+ onSuccess: () => {
250
+ setNote('')
251
+ setFile(EMPTY_FILE_UPLOAD_VALUE)
252
+ toastManager.add({ type: 'success', title: t('onboarding.submitted_requirement') })
253
+ void invalidate()
254
+ },
255
+ onError: (err) =>
256
+ toastManager.add({
257
+ type: 'error',
258
+ title: err instanceof Error ? err.message : t('common.error'),
259
+ }),
260
+ })
261
+
262
+ // Custom fields are the seller's own profile data, so they save through the
263
+ // profile like the addresses do rather than as a submission.
264
+ const saveFields = useMutation({
265
+ mutationFn: () =>
266
+ sellerClient().profile.update({
267
+ custom_fields: Object.entries(fields).map(([id, value]) => ({
268
+ custom_field_definition_id: id,
269
+ value,
270
+ })),
271
+ }),
272
+ onSuccess: () => {
273
+ toastManager.add({ type: 'success', title: t('profile.saved') })
274
+ void invalidate()
275
+ },
276
+ onError: (err) =>
277
+ toastManager.add({
278
+ type: 'error',
279
+ title: err instanceof Error ? err.message : t('common.error'),
280
+ }),
281
+ })
282
+
283
+ const submission = requirement.submission
284
+
285
+ // A rejection the seller cannot read is a rejection they cannot act on.
286
+ const rejection = submission?.status === 'rejected' && submission.review_note && (
287
+ <p className="rounded-md bg-destructive/10 p-3 text-destructive text-sm">
288
+ {submission.review_note}
289
+ </p>
290
+ )
291
+
292
+ // Nothing to do on a finished line. The header already carries the tick and
293
+ // the Done badge, so a sentence saying the same thing a third time is noise.
294
+ if (requirement.status === 'complete') {
295
+ return rejection
296
+ }
297
+
298
+ if (requirement.status === 'pending') {
299
+ return (
300
+ <>
301
+ {rejection}
302
+ <p className="text-muted-foreground text-sm">{t('onboarding.awaiting_review')}</p>
303
+ </>
304
+ )
305
+ }
306
+
307
+ return (
308
+ <>
309
+ {rejection}
310
+
311
+ {requirement.kind === 'accept_terms' && (
312
+ <div className="flex flex-col gap-3">
313
+ {/* The terms themselves. Accepting something a seller cannot
314
+ read is not consent — a link away is not enough. */}
315
+ {requirement.terms_html && (
316
+ <ScrollArea className="h-72 rounded-md border">
317
+ <div
318
+ className="prose prose-sm dark:prose-invert max-w-none p-4"
319
+ // Sanitized server-side by Spree::RichTextSanitizer.
320
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized on write
321
+ dangerouslySetInnerHTML={{ __html: requirement.terms_html }}
322
+ />
323
+ </ScrollArea>
324
+ )}
325
+ <div className="flex flex-wrap items-center gap-2">
326
+ {requirement.action_url && (
327
+ <Button variant="outline" asChild>
328
+ <a href={requirement.action_url} target="_blank" rel="noopener noreferrer">
329
+ {t('onboarding.read_terms')}
330
+ <ExternalLinkIcon className="size-4" />
331
+ </a>
332
+ </Button>
333
+ )}
334
+ <Button disabled={acceptTerms.isPending} onClick={() => acceptTerms.mutate()}>
335
+ {t('onboarding.accept_terms')}
336
+ </Button>
337
+ </div>
338
+ </div>
339
+ )}
340
+
341
+ {/* Every kind that takes a submission gets the same form: a note, a
342
+ file picker when the kind asks for one, and one button. Keyed off
343
+ what the server says the kind accepts rather than off its name, so a
344
+ kind a gem adds is usable without this file knowing about it. */}
345
+ {requirement.accepts_submissions && requirement.kind !== 'accept_terms' && (
346
+ <div className="flex flex-col gap-3">
347
+ {requirement.requires_file && (
348
+ <FileUploadField
349
+ value={file}
350
+ onChange={setFile}
351
+ accept={requirement.accepted_content_types?.join(',')}
352
+ label={t('onboarding.document_label')}
353
+ help={t('onboarding.document_help')}
354
+ />
355
+ )}
356
+
357
+ <Textarea
358
+ rows={2}
359
+ value={note}
360
+ placeholder={t('onboarding.note_placeholder')}
361
+ onChange={(event) => setNote(event.target.value)}
362
+ />
363
+
364
+ <div className="flex justify-start">
365
+ <Button
366
+ // A document requirement with nothing attached is an empty
367
+ // submission the operator can only reject.
368
+ disabled={submit.isPending || (requirement.requires_file && !file.signedId)}
369
+ onClick={() => submit.mutate()}
370
+ >
371
+ {requirement.requires_file
372
+ ? t('onboarding.submit_document')
373
+ : t('onboarding.confirm')}
374
+ </Button>
375
+ </div>
376
+ </div>
377
+ )}
378
+
379
+ {/* The fields the marketplace asks this seller to fill in, rendered
380
+ here rather than behind a link: the checklist is where they were
381
+ asked, so it is where they answer. */}
382
+ {requirement.custom_fields && requirement.custom_fields.length > 0 && (
383
+ <div className="flex flex-col gap-3">
384
+ {requirement.custom_fields.map((field) => (
385
+ <Field key={field.id}>
386
+ <FieldLabel htmlFor={`cf-${field.id}`}>{field.label}</FieldLabel>
387
+ <Input
388
+ id={`cf-${field.id}`}
389
+ value={fields[field.id] ?? (field.value == null ? '' : String(field.value))}
390
+ onChange={(event) =>
391
+ setFields((current) => ({ ...current, [field.id]: event.target.value }))
392
+ }
393
+ />
394
+ </Field>
395
+ ))}
396
+ <div className="flex justify-start">
397
+ <Button
398
+ disabled={saveFields.isPending || Object.keys(fields).length === 0}
399
+ onClick={() => saveFields.mutate()}
400
+ >
401
+ {t('common.save')}
402
+ </Button>
403
+ </div>
404
+ </div>
405
+ )}
406
+
407
+ {/* Addresses are filled in here rather than on the profile page: a
408
+ seller working through setup should not be sent away and lose their
409
+ place. Same card either way, so the two cannot diverge. */}
410
+ {requirement.kind === 'billing_address' && profile && (
411
+ <SellerAddressCard profile={profile} headless />
412
+ )}
413
+
414
+ {/* Returns go to a stock location rather than a loose address, so this
415
+ one writes a different record — the seller still just sees an
416
+ address form. */}
417
+ {requirement.kind === 'returns_address' && <SellerReturnsLocationCard headless />}
418
+
419
+ {/* The provider hosts the form, so this is a redirect rather than a
420
+ page: the seller gives their bank and identity details to whoever
421
+ is paying them, and the marketplace never handles either.
422
+
423
+ Three outcomes, three different things to say. Waiting on the
424
+ provider gets no button at all — offering one would invite the
425
+ seller round a flow that cannot move, and they would click it
426
+ repeatedly wondering why nothing changed. */}
427
+ {requirement.kind === 'payout_account' && (
428
+ <div className="flex flex-col items-start gap-2">
429
+ {requirement.blocker?.message && (
430
+ <p className="text-muted-foreground text-sm">{requirement.blocker.message}</p>
431
+ )}
432
+
433
+ {requirement.blocker?.state === 'pending' ? (
434
+ <p className="text-muted-foreground text-sm">
435
+ {t('onboarding.awaiting_payout_provider')}
436
+ </p>
437
+ ) : (
438
+ <Button
439
+ disabled={connectPayoutAccount.isPending}
440
+ onClick={() => connectPayoutAccount.mutate()}
441
+ >
442
+ {requirement.blocker?.state === 'rejected'
443
+ ? t('onboarding.payout_account_rejected')
444
+ : t('onboarding.connect_payout_account')}
445
+ <ExternalLinkIcon className="size-4" />
446
+ </Button>
447
+ )}
448
+ </div>
449
+ )}
450
+
451
+ {/* Kinds whose work happens on a page this panel already has. The
452
+ server cannot supply these: `action_url` is for somewhere it knows
453
+ about (a provider's hosted flow), and it has no idea how this panel
454
+ routes. Anything unmapped falls through to `action_url` below, so a
455
+ kind added by a gem still gets a way in without this file changing. */}
456
+ {panelRoute(requirement.kind) && (
457
+ <div className="flex justify-start">
458
+ <Button asChild>
459
+ <Link to={panelRoute(requirement.kind)!} params={{ sellerId }}>
460
+ {t('onboarding.go')}
461
+ <ChevronRightIcon className="size-4" />
462
+ </Link>
463
+ </Button>
464
+ </div>
465
+ )}
466
+
467
+ {/* Which documents are still owed. The generic link below says where to
468
+ go; without this the seller would arrive not knowing what to write. */}
469
+ {/* `accept_terms` renders its own link above, with different words. */}
470
+ {requirement.action_url &&
471
+ requirement.kind !== 'accept_terms' &&
472
+ requirement.kind !== 'payout_account' && (
473
+ <div className="flex justify-start">
474
+ <Button variant="outline" asChild>
475
+ <a href={requirement.action_url} target="_blank" rel="noopener noreferrer">
476
+ {t('onboarding.go')}
477
+ <ExternalLinkIcon className="size-4" />
478
+ </a>
479
+ </Button>
480
+ </div>
481
+ )}
482
+ </>
483
+ )
484
+ }
485
+
486
+ /**
487
+ * Where in this panel a seller goes to satisfy a kind.
488
+ *
489
+ * Deliberately a short map rather than a per-kind component: these lines say
490
+ * "go and fill that in", and the page that owns the form already exists. A
491
+ * kind that is not here renders its description and any `action_url`, which
492
+ * is what makes a provider's kind useful before this file knows it exists.
493
+ */
494
+ function panelRoute(kind: string): string | undefined {
495
+ switch (kind) {
496
+ case 'complete_profile':
497
+ return '/$sellerId/profile'
498
+ case 'policy':
499
+ return '/$sellerId/settings/policies'
500
+ case 'delivery_method':
501
+ return '/$sellerId/settings/delivery-methods'
502
+ case 'package_type':
503
+ return '/$sellerId/settings/package-types'
504
+ // `required_custom_fields` is not here: it renders its own fields inline,
505
+ // so a link away would offer a second, worse route to the same thing.
506
+ //
507
+ // `minimum_products` belongs here once the panel has a products page —
508
+ // until then it falls through and reads as a plain instruction rather
509
+ // than a link to nowhere.
510
+ default:
511
+ return undefined
512
+ }
513
+ }
514
+
515
+ function StatusIcon({ status }: { status: string }) {
516
+ if (status === 'complete') {
517
+ return <CheckCircle2Icon className="size-5 shrink-0 text-success" />
518
+ }
519
+ if (status === 'pending') {
520
+ return <ClockIcon className="size-5 shrink-0 text-warning" />
521
+ }
522
+ if (status === 'rejected') {
523
+ return <XCircleIcon className="size-5 shrink-0 text-destructive" />
524
+ }
525
+ return <CircleIcon className="size-5 shrink-0 text-muted-foreground" />
526
+ }
527
+
528
+ function StatusBadge({ status }: { status: string }) {
529
+ const { t } = useTranslation()
530
+
531
+ // Only the wording is local — the colour comes from the shared status map.
532
+ if (status === 'complete')
533
+ return <SharedStatusBadge status="complete" label={t('onboarding.status.complete')} />
534
+ if (status === 'pending')
535
+ return <SharedStatusBadge status="pending" label={t('onboarding.status.pending')} />
536
+ if (status === 'rejected')
537
+ return <SharedStatusBadge status="rejected" label={t('onboarding.status.rejected')} />
538
+ return null
539
+ }
@@ -0,0 +1,119 @@
1
+ import { formatStoreDateTime, PageHeader } from '@spree/dashboard-core'
2
+ import { DropdownMenuItem, ResourceLayout, StatusBadge } from '@spree/dashboard-ui'
3
+ import { XCircleIcon } from '@spree/dashboard-ui/icons'
4
+ import { useParams } from '@tanstack/react-router'
5
+ import { useState } from 'react'
6
+ import { useTranslation } from 'react-i18next'
7
+ import { CenteredMessage } from '../components/centered-message'
8
+ import { FulfillmentsCard } from '../components/orders/fulfillments-card'
9
+ import { OrderCancelDialog } from '../components/orders/order-cancel-dialog'
10
+ import { OrderCustomerCard, OrderSummaryCard } from '../components/orders/order-cards'
11
+ import { OrderEarningsCard } from '../components/orders/order-earnings-card'
12
+ import { InternalNoteCard, SpecialInstructionsCard } from '../components/orders/order-notes-cards'
13
+ import { OrderPaymentCard } from '../components/orders/order-payment-card'
14
+ import { ClaimsCard, ExchangesCard } from '../components/orders/post-sale-cards'
15
+ import { ReturnsCard } from '../components/orders/returns-card'
16
+ import { RetryableError } from '../components/retryable-error'
17
+ import { useOrder } from '../hooks/use-order'
18
+ import { useStoreTimezone } from '../hooks/use-store-timezone'
19
+
20
+ /**
21
+ * One order, as the seller needs it to pack, post, and put right.
22
+ *
23
+ * Laid out like the operator's order page — parcels and post-sale on the
24
+ * left, who and what on the right — minus the marketplace's own surfaces:
25
+ * the payment the customer made, the tax and discount breakdown, and the
26
+ * operator's bookkeeping all belong to whoever runs the marketplace.
27
+ */
28
+ export function OrderPage() {
29
+ const { t } = useTranslation()
30
+ const { orderId } = useParams({ from: '/_authenticated/$sellerId/orders/$orderId' })
31
+ const timezone = useStoreTimezone()
32
+ const [canceling, setCanceling] = useState(false)
33
+
34
+ const { data: order, isLoading, isError, refetch } = useOrder(orderId)
35
+
36
+ if (isLoading) return <CenteredMessage>{t('common.loading')}</CenteredMessage>
37
+ if (isError) return <RetryableError onRetry={() => refetch()} />
38
+ if (!order) return <CenteredMessage>{t('orders.not_found')}</CenteredMessage>
39
+
40
+ const cancelable = order.status !== 'canceled'
41
+ // Nothing can come back from an order that never went out, so the post-sale
42
+ // cards would only render empty with a disabled button.
43
+ const placed = !!order.completed_at
44
+
45
+ return (
46
+ <>
47
+ {canceling && <OrderCancelDialog orderId={orderId} onClose={() => setCanceling(false)} />}
48
+ <ResourceLayout
49
+ header={
50
+ // Cancel sits in the menu's destructive group rather than beside the
51
+ // title: it is easy to hit by mistake there, and this is where the
52
+ // operator dashboard puts the same action.
53
+ <PageHeader
54
+ title={order.number}
55
+ backTo="orders"
56
+ subtitle={
57
+ order.completed_at ? formatStoreDateTime(order.completed_at, timezone) : undefined
58
+ }
59
+ badges={
60
+ <>
61
+ {order.fulfillment_status && (
62
+ <StatusBadge
63
+ status={order.fulfillment_status}
64
+ label={t(`orders.fulfillment_statuses.${order.fulfillment_status}`, {
65
+ defaultValue: order.fulfillment_status,
66
+ })}
67
+ />
68
+ )}
69
+ {order.payment_status && (
70
+ <StatusBadge
71
+ status={order.payment_status}
72
+ label={t(`orders.payment_statuses.${order.payment_status}`, {
73
+ defaultValue: order.payment_status,
74
+ })}
75
+ />
76
+ )}
77
+ </>
78
+ }
79
+ resource={{ id: order.id, number: order.number }}
80
+ destructiveItems={
81
+ cancelable ? (
82
+ <DropdownMenuItem variant="destructive" onClick={() => setCanceling(true)}>
83
+ <XCircleIcon className="size-4" />
84
+ {t('admin.pages.orders.detail.actions.cancel')}
85
+ </DropdownMenuItem>
86
+ ) : undefined
87
+ }
88
+ />
89
+ }
90
+ main={
91
+ <>
92
+ <FulfillmentsCard order={order} />
93
+ {placed && (
94
+ <>
95
+ <ReturnsCard order={order} />
96
+ <ExchangesCard order={order} />
97
+ <ClaimsCard order={order} />
98
+ </>
99
+ )}
100
+ <OrderSummaryCard order={order} />
101
+ {placed && (
102
+ <>
103
+ <OrderPaymentCard order={order} />
104
+ <OrderEarningsCard order={order} />
105
+ </>
106
+ )}
107
+ </>
108
+ }
109
+ sidebar={
110
+ <>
111
+ <OrderCustomerCard order={order} />
112
+ <SpecialInstructionsCard order={order} />
113
+ <InternalNoteCard order={order} />
114
+ </>
115
+ }
116
+ />
117
+ </>
118
+ )
119
+ }
@@ -0,0 +1,33 @@
1
+ import { ExportButton, PageHeader, type ResourceSearch, ResourceTable } from '@spree/dashboard-core'
2
+ import { useRowClickBridge } from '@spree/dashboard-ui'
3
+ import type { Order } from '@spree/seller-sdk'
4
+ import { useNavigate, useParams } from '@tanstack/react-router'
5
+ import { useTranslation } from 'react-i18next'
6
+ import { sellerClient } from '../api-client'
7
+ import '../tables/orders'
8
+
9
+ /** What this seller has sold, newest first. */
10
+ export function OrdersPage({ search }: { search: ResourceSearch }) {
11
+ const { t } = useTranslation()
12
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
13
+ const navigate = useNavigate()
14
+
15
+ useRowClickBridge('data-order-id', (orderId: string) =>
16
+ navigate({ to: '/$sellerId/orders/$orderId', params: { sellerId, orderId } }),
17
+ )
18
+
19
+ return (
20
+ <div className="flex flex-col gap-4">
21
+ <PageHeader title={t('orders.title')} sticky={false} />
22
+
23
+ <ResourceTable<Order>
24
+ hideHeader
25
+ tableKey="seller-orders"
26
+ queryKey="seller-orders"
27
+ queryFn={(params) => sellerClient().orders.list(params)}
28
+ searchParams={search}
29
+ actions={(ctx) => <ExportButton type="orders" {...ctx} />}
30
+ />
31
+ </div>
32
+ )
33
+ }