@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,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
|
+
}
|