@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.
- package/package.json +23 -23
- package/src/api-client.ts +2 -0
- package/src/components/account-dialog.tsx +1 -1
- package/src/components/centered-message.tsx +1 -1
- package/src/components/finance/balance-summary.tsx +1 -1
- package/src/components/orders/fulfillment-deliveries.tsx +1 -1
- package/src/components/orders/fulfillments-card.tsx +30 -20
- package/src/components/orders/order-cards.tsx +115 -102
- package/src/components/orders/order-payment-card.tsx +2 -3
- package/src/components/orders/post-sale-cards.tsx +79 -47
- package/src/components/orders/post-sale-create-dialogs.tsx +15 -0
- package/src/components/orders/returns-card.tsx +44 -36
- package/src/components/orders/shipping-label-row.tsx +1 -1
- package/src/components/orders/use-product-link.tsx +10 -0
- package/src/components/product-status-card.tsx +2 -2
- package/src/components/seller-address-card.tsx +2 -2
- package/src/components/seller-returns-location-card.tsx +2 -7
- package/src/components/seller-switcher.tsx +1 -1
- package/src/hooks/use-fulfillments.ts +33 -22
- package/src/hooks/use-profile.ts +38 -0
- package/src/locales/ar.json +35 -31
- package/src/locales/de.json +35 -31
- package/src/locales/en.json +35 -31
- package/src/locales/fr.json +35 -31
- package/src/locales/ja.json +35 -31
- package/src/locales/pl.json +35 -31
- package/src/locales/vi.json +35 -31
- package/src/locales/zh-CN.json +35 -31
- package/src/nav/default.ts +0 -10
- package/src/nav/settings.ts +53 -0
- package/src/pages/delivery-methods.tsx +47 -49
- package/src/pages/forgot-password.tsx +5 -5
- package/src/pages/general-settings.tsx +233 -0
- package/src/pages/home.tsx +1 -1
- package/src/pages/login.tsx +3 -3
- package/src/pages/onboarding.tsx +26 -23
- package/src/pages/order.tsx +2 -1
- package/src/pages/orders.tsx +8 -15
- package/src/pages/payout.tsx +7 -4
- package/src/pages/payouts.tsx +23 -2
- package/src/pages/policies.tsx +12 -8
- package/src/pages/product.tsx +18 -4
- package/src/pages/products.tsx +0 -4
- package/src/pages/profile-settings.tsx +221 -0
- package/src/pages/reset-password.tsx +3 -3
- package/src/pages/seller-picker.tsx +1 -1
- package/src/pages/taxes.tsx +40 -0
- package/src/pages/team.tsx +1 -1
- package/src/routeTree.gen.ts +63 -0
- package/src/routes/_authenticated/$sellerId/profile.tsx +5 -3
- package/src/routes/_authenticated/$sellerId/settings/general.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/settings/profile.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/settings/taxes.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/settings.tsx +3 -7
- package/src/schemas/account.ts +1 -1
- package/src/tables/products.tsx +6 -5
- package/src/components/read-row.tsx +0 -16
- package/src/components/seller-business-card.tsx +0 -152
- package/src/pages/profile.tsx +0 -395
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
|
+
import {
|
|
3
|
+
blankToNull,
|
|
4
|
+
mapSpreeErrorsToForm,
|
|
5
|
+
PageHeader,
|
|
6
|
+
SettingsFieldGroup,
|
|
7
|
+
SettingsRow,
|
|
8
|
+
SettingsSection,
|
|
9
|
+
StoreDatePicker,
|
|
10
|
+
} from '@spree/dashboard-core'
|
|
11
|
+
import {
|
|
12
|
+
CardContent,
|
|
13
|
+
FormActions,
|
|
14
|
+
Input,
|
|
15
|
+
ResourceLayout,
|
|
16
|
+
useFormSubmitShortcut,
|
|
17
|
+
} from '@spree/dashboard-ui'
|
|
18
|
+
import { Building2Icon, CalendarOffIcon, MailIcon, MapPinIcon } from '@spree/dashboard-ui/icons'
|
|
19
|
+
import type { Profile } from '@spree/seller-sdk'
|
|
20
|
+
import { Controller, useForm } from 'react-hook-form'
|
|
21
|
+
import { useTranslation } from 'react-i18next'
|
|
22
|
+
import { z } from 'zod/v4'
|
|
23
|
+
import { CenteredMessage } from '../components/centered-message'
|
|
24
|
+
import { SellerAddressCard } from '../components/seller-address-card'
|
|
25
|
+
import { SellerReturnsLocationCard } from '../components/seller-returns-location-card'
|
|
26
|
+
import { useProfile, useUpdateProfile } from '../hooks/use-profile'
|
|
27
|
+
|
|
28
|
+
const generalSettingsSchema = z.object({
|
|
29
|
+
contact_email: z.email().or(z.literal('')),
|
|
30
|
+
billing_email: z.email().or(z.literal('')),
|
|
31
|
+
legal_name: z.string().trim(),
|
|
32
|
+
registration_number: z.string().trim(),
|
|
33
|
+
holiday_mode_until: z.string(),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
type GeneralSettingsValues = z.infer<typeof generalSettingsSchema>
|
|
37
|
+
|
|
38
|
+
function formValues(profile: Profile): GeneralSettingsValues {
|
|
39
|
+
return {
|
|
40
|
+
contact_email: profile.contact_email ?? '',
|
|
41
|
+
billing_email: profile.billing_email ?? '',
|
|
42
|
+
legal_name: profile.legal_name ?? '',
|
|
43
|
+
registration_number: profile.registration_number ?? '',
|
|
44
|
+
holiday_mode_until: profile.holiday_mode_until ?? '',
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The seller's own configuration that buyers never see: how the marketplace
|
|
50
|
+
* reaches them, the business behind them, where post and returns go, and
|
|
51
|
+
* whether they are taking orders.
|
|
52
|
+
*/
|
|
53
|
+
export function GeneralSettingsPage() {
|
|
54
|
+
const { t } = useTranslation()
|
|
55
|
+
const { data: profile, isLoading, error } = useProfile()
|
|
56
|
+
|
|
57
|
+
if (isLoading) return <CenteredMessage>{t('common.loading')}</CenteredMessage>
|
|
58
|
+
if (error || !profile) return <CenteredMessage>{t('common.error')}</CenteredMessage>
|
|
59
|
+
|
|
60
|
+
return <GeneralSettingsForm profile={profile} />
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function GeneralSettingsForm({ profile }: { profile: Profile }) {
|
|
64
|
+
const { t } = useTranslation()
|
|
65
|
+
const update = useUpdateProfile()
|
|
66
|
+
|
|
67
|
+
const form = useForm<GeneralSettingsValues>({
|
|
68
|
+
resolver: zodResolver(generalSettingsSchema),
|
|
69
|
+
defaultValues: formValues(profile),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
async function onSubmit(values: GeneralSettingsValues) {
|
|
73
|
+
try {
|
|
74
|
+
const updated = await update.mutateAsync({
|
|
75
|
+
contact_email: blankToNull(values.contact_email),
|
|
76
|
+
billing_email: blankToNull(values.billing_email),
|
|
77
|
+
legal_name: blankToNull(values.legal_name),
|
|
78
|
+
registration_number: blankToNull(values.registration_number),
|
|
79
|
+
holiday_mode_until: blankToNull(values.holiday_mode_until),
|
|
80
|
+
})
|
|
81
|
+
form.reset(formValues(updated))
|
|
82
|
+
} catch (err) {
|
|
83
|
+
// Anything that is not a field error has already been toasted.
|
|
84
|
+
mapSpreeErrorsToForm(err, form.setError)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
useFormSubmitShortcut(form, onSubmit)
|
|
89
|
+
|
|
90
|
+
const { errors } = form.formState
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
94
|
+
<ResourceLayout
|
|
95
|
+
header={
|
|
96
|
+
<PageHeader
|
|
97
|
+
title={t('settings_general.title')}
|
|
98
|
+
description={t('settings_general.description')}
|
|
99
|
+
sticky={false}
|
|
100
|
+
actions={<FormActions form={form} />}
|
|
101
|
+
/>
|
|
102
|
+
}
|
|
103
|
+
main={
|
|
104
|
+
<>
|
|
105
|
+
{errors.root?.message && (
|
|
106
|
+
<p className="text-base text-destructive" role="alert">
|
|
107
|
+
{errors.root.message}
|
|
108
|
+
</p>
|
|
109
|
+
)}
|
|
110
|
+
<SettingsSection title={t('settings_general.sections.contact')} icon={<MailIcon />}>
|
|
111
|
+
<CardContent>
|
|
112
|
+
<SettingsFieldGroup>
|
|
113
|
+
<SettingsRow
|
|
114
|
+
label={t('admin.fields.contact_email.label')}
|
|
115
|
+
htmlFor="seller-contact-email"
|
|
116
|
+
help={t('admin.fields.seller.contact_email.help')}
|
|
117
|
+
error={errors.contact_email}
|
|
118
|
+
>
|
|
119
|
+
<Input
|
|
120
|
+
id="seller-contact-email"
|
|
121
|
+
type="email"
|
|
122
|
+
className="@md/field-group:w-80"
|
|
123
|
+
aria-invalid={!!errors.contact_email || undefined}
|
|
124
|
+
{...form.register('contact_email')}
|
|
125
|
+
/>
|
|
126
|
+
</SettingsRow>
|
|
127
|
+
<SettingsRow
|
|
128
|
+
label={t('admin.fields.seller.billing_email.label')}
|
|
129
|
+
htmlFor="seller-billing-email"
|
|
130
|
+
help={t('admin.fields.seller.billing_email.help')}
|
|
131
|
+
error={errors.billing_email}
|
|
132
|
+
>
|
|
133
|
+
<Input
|
|
134
|
+
id="seller-billing-email"
|
|
135
|
+
type="email"
|
|
136
|
+
className="@md/field-group:w-80"
|
|
137
|
+
aria-invalid={!!errors.billing_email || undefined}
|
|
138
|
+
{...form.register('billing_email')}
|
|
139
|
+
/>
|
|
140
|
+
</SettingsRow>
|
|
141
|
+
</SettingsFieldGroup>
|
|
142
|
+
</CardContent>
|
|
143
|
+
</SettingsSection>
|
|
144
|
+
|
|
145
|
+
<SettingsSection
|
|
146
|
+
title={t('settings_general.sections.business')}
|
|
147
|
+
icon={<Building2Icon />}
|
|
148
|
+
>
|
|
149
|
+
<CardContent>
|
|
150
|
+
<SettingsFieldGroup>
|
|
151
|
+
<SettingsRow
|
|
152
|
+
label={t('profile.legal_name')}
|
|
153
|
+
htmlFor="seller-legal-name"
|
|
154
|
+
error={errors.legal_name}
|
|
155
|
+
>
|
|
156
|
+
<Input
|
|
157
|
+
id="seller-legal-name"
|
|
158
|
+
className="@md/field-group:w-80"
|
|
159
|
+
{...form.register('legal_name')}
|
|
160
|
+
/>
|
|
161
|
+
</SettingsRow>
|
|
162
|
+
<SettingsRow
|
|
163
|
+
label={t('profile.registration_number')}
|
|
164
|
+
htmlFor="seller-registration-number"
|
|
165
|
+
error={errors.registration_number}
|
|
166
|
+
>
|
|
167
|
+
<Input
|
|
168
|
+
id="seller-registration-number"
|
|
169
|
+
className="@md/field-group:w-80"
|
|
170
|
+
{...form.register('registration_number')}
|
|
171
|
+
/>
|
|
172
|
+
</SettingsRow>
|
|
173
|
+
</SettingsFieldGroup>
|
|
174
|
+
</CardContent>
|
|
175
|
+
</SettingsSection>
|
|
176
|
+
|
|
177
|
+
{/* Each address saves on its own from a dialog. The dialog's form
|
|
178
|
+
is portalled but still a React child of this one, so its submit
|
|
179
|
+
would bubble here and save the page too. */}
|
|
180
|
+
<section onSubmit={(event) => event.stopPropagation()}>
|
|
181
|
+
<SettingsSection
|
|
182
|
+
title={t('settings_general.sections.addresses')}
|
|
183
|
+
icon={<MapPinIcon />}
|
|
184
|
+
>
|
|
185
|
+
<CardContent>
|
|
186
|
+
<SettingsFieldGroup>
|
|
187
|
+
<SettingsRow label={t('profile.billing_address')}>
|
|
188
|
+
<SellerAddressCard profile={profile} headless />
|
|
189
|
+
</SettingsRow>
|
|
190
|
+
<SettingsRow label={t('profile.returns_address')}>
|
|
191
|
+
<SellerReturnsLocationCard headless />
|
|
192
|
+
</SettingsRow>
|
|
193
|
+
</SettingsFieldGroup>
|
|
194
|
+
</CardContent>
|
|
195
|
+
</SettingsSection>
|
|
196
|
+
</section>
|
|
197
|
+
|
|
198
|
+
<SettingsSection
|
|
199
|
+
title={t('settings_general.sections.holiday')}
|
|
200
|
+
icon={<CalendarOffIcon />}
|
|
201
|
+
>
|
|
202
|
+
<CardContent>
|
|
203
|
+
<SettingsFieldGroup>
|
|
204
|
+
<Controller
|
|
205
|
+
name="holiday_mode_until"
|
|
206
|
+
control={form.control}
|
|
207
|
+
render={({ field, fieldState }) => (
|
|
208
|
+
<SettingsRow
|
|
209
|
+
label={t('admin.fields.seller.holiday_mode_until.label')}
|
|
210
|
+
htmlFor="seller-holiday-mode-until"
|
|
211
|
+
help={t('admin.fields.seller.holiday_mode_until.help')}
|
|
212
|
+
error={fieldState.error}
|
|
213
|
+
>
|
|
214
|
+
<StoreDatePicker
|
|
215
|
+
className="w-full @md/field-group:w-80"
|
|
216
|
+
id="seller-holiday-mode-until"
|
|
217
|
+
value={field.value || null}
|
|
218
|
+
onChange={(next) => field.onChange(next ?? '')}
|
|
219
|
+
placeholder={t('admin.fields.seller.holiday_mode_until.placeholder')}
|
|
220
|
+
includeTime
|
|
221
|
+
/>
|
|
222
|
+
</SettingsRow>
|
|
223
|
+
)}
|
|
224
|
+
/>
|
|
225
|
+
</SettingsFieldGroup>
|
|
226
|
+
</CardContent>
|
|
227
|
+
</SettingsSection>
|
|
228
|
+
</>
|
|
229
|
+
}
|
|
230
|
+
/>
|
|
231
|
+
</form>
|
|
232
|
+
)
|
|
233
|
+
}
|
package/src/pages/home.tsx
CHANGED
|
@@ -98,7 +98,7 @@ export function HomePage() {
|
|
|
98
98
|
<CardTitle>{t('home.selling_title')}</CardTitle>
|
|
99
99
|
</CardHeader>
|
|
100
100
|
<CardContent>
|
|
101
|
-
<p className="text-muted-foreground text-
|
|
101
|
+
<p className="text-muted-foreground text-base">
|
|
102
102
|
{profile.sellable ? t('home.selling_yes') : t('home.selling_no')}
|
|
103
103
|
</p>
|
|
104
104
|
</CardContent>
|
package/src/pages/login.tsx
CHANGED
|
@@ -44,12 +44,12 @@ export function LoginPage() {
|
|
|
44
44
|
<div className="flex min-h-screen items-center justify-center p-6">
|
|
45
45
|
<div className="w-full max-w-sm">
|
|
46
46
|
<h1 className="font-medium text-xl">{t('login.title')}</h1>
|
|
47
|
-
<p className="mt-1 mb-6 text-muted-foreground text-
|
|
47
|
+
<p className="mt-1 mb-6 text-muted-foreground text-base">{t('login.subtitle')}</p>
|
|
48
48
|
|
|
49
49
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
50
50
|
<FieldGroup>
|
|
51
51
|
{error && (
|
|
52
|
-
<p className="text-destructive text-
|
|
52
|
+
<p className="text-destructive text-base" role="alert">
|
|
53
53
|
{error}
|
|
54
54
|
</p>
|
|
55
55
|
)}
|
|
@@ -83,7 +83,7 @@ export function LoginPage() {
|
|
|
83
83
|
{isSubmitting ? t('login.submitting') : t('login.submit')}
|
|
84
84
|
</Button>
|
|
85
85
|
|
|
86
|
-
<Link to="/forgot-password" className="text-
|
|
86
|
+
<Link to="/forgot-password" className="text-base underline underline-offset-4">
|
|
87
87
|
{t('login.forgot_password')}
|
|
88
88
|
</Link>
|
|
89
89
|
</FieldGroup>
|
package/src/pages/onboarding.tsx
CHANGED
|
@@ -43,6 +43,7 @@ import { sellerClient } from '../api-client'
|
|
|
43
43
|
import { CenteredMessage } from '../components/centered-message'
|
|
44
44
|
import { SellerAddressCard } from '../components/seller-address-card'
|
|
45
45
|
import { SellerReturnsLocationCard } from '../components/seller-returns-location-card'
|
|
46
|
+
import { useProfile } from '../hooks/use-profile'
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
49
|
* What the marketplace asks of this seller before admitting them.
|
|
@@ -105,10 +106,10 @@ export function OnboardingPage() {
|
|
|
105
106
|
<Progress value={progressPercentage(progress)} />
|
|
106
107
|
|
|
107
108
|
{submitted ? (
|
|
108
|
-
<p className="text-muted-foreground text-
|
|
109
|
+
<p className="text-muted-foreground text-base">{t('onboarding.under_review')}</p>
|
|
109
110
|
) : (
|
|
110
111
|
<div className="flex items-center justify-between gap-4">
|
|
111
|
-
<p className="text-muted-foreground text-
|
|
112
|
+
<p className="text-muted-foreground text-base">
|
|
112
113
|
{blocking.length === 0
|
|
113
114
|
? t('onboarding.ready')
|
|
114
115
|
: t('onboarding.remaining', { count: blocking.length })}
|
|
@@ -132,7 +133,7 @@ export function OnboardingPage() {
|
|
|
132
133
|
// `multiple` because these are independent — a seller does them in any
|
|
133
134
|
// order — so opening one must not close the one they were reading.
|
|
134
135
|
// Opens on the first outstanding line, which answers "what next".
|
|
135
|
-
<Card className="overflow-hidden
|
|
136
|
+
<Card className="overflow-hidden">
|
|
136
137
|
<Accordion multiple defaultValue={openByDefault}>
|
|
137
138
|
{requirements.map((requirement) => (
|
|
138
139
|
<AccordionItem key={requirement.id} value={requirement.id}>
|
|
@@ -156,7 +157,7 @@ export function OnboardingPage() {
|
|
|
156
157
|
</AccordionTrigger>
|
|
157
158
|
<AccordionContent className="flex flex-col gap-3 ps-12">
|
|
158
159
|
{requirement.description && (
|
|
159
|
-
<p className="text-muted-foreground text-
|
|
160
|
+
<p className="text-muted-foreground text-base">{requirement.description}</p>
|
|
160
161
|
)}
|
|
161
162
|
<RequirementAction requirement={requirement} />
|
|
162
163
|
</AccordionContent>
|
|
@@ -189,15 +190,9 @@ function RequirementAction({ requirement }: { requirement: RequirementStatus })
|
|
|
189
190
|
const [file, setFile] = useState<FileUploadValue>(EMPTY_FILE_UPLOAD_VALUE)
|
|
190
191
|
const [fields, setFields] = useState<Record<string, string>>({})
|
|
191
192
|
|
|
192
|
-
// Only the billing address
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
// card fetches.
|
|
196
|
-
const { data: profile } = useQuery({
|
|
197
|
-
queryKey: ['seller', sellerId, 'profile'],
|
|
198
|
-
queryFn: () => sellerClient().profile.get(),
|
|
199
|
-
enabled: requirement.kind === 'billing_address',
|
|
200
|
-
})
|
|
193
|
+
// Only the billing address edits it. The returns address does not: it
|
|
194
|
+
// lives on a stock location, which its own card fetches.
|
|
195
|
+
const { data: profile } = useProfile({ enabled: requirement.kind === 'billing_address' })
|
|
201
196
|
|
|
202
197
|
const invalidate = () =>
|
|
203
198
|
queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'onboarding'] })
|
|
@@ -301,11 +296,17 @@ function RequirementAction({ requirement }: { requirement: RequirementStatus })
|
|
|
301
296
|
return (
|
|
302
297
|
<>
|
|
303
298
|
{rejection}
|
|
304
|
-
<p className="text-muted-foreground text-
|
|
299
|
+
<p className="text-muted-foreground text-base">{t('onboarding.awaiting_review')}</p>
|
|
305
300
|
</>
|
|
306
301
|
)
|
|
307
302
|
}
|
|
308
303
|
|
|
304
|
+
// The contact email is edited on General, everything else on Profile; the
|
|
305
|
+
// server says when the email is the only thing a profile still lacks.
|
|
306
|
+
const route =
|
|
307
|
+
requirement.blocker?.state === 'contact_email_missing'
|
|
308
|
+
? '/$sellerId/settings/general'
|
|
309
|
+
: panelRoute(requirement.kind)
|
|
309
310
|
return (
|
|
310
311
|
<>
|
|
311
312
|
{rejection}
|
|
@@ -407,7 +408,13 @@ function RequirementAction({ requirement }: { requirement: RequirementStatus })
|
|
|
407
408
|
</div>
|
|
408
409
|
)}
|
|
409
410
|
|
|
410
|
-
{/*
|
|
411
|
+
{/* Why the line is not done yet, when the server can say: what a
|
|
412
|
+
profile still lacks, or where a payout provider has got to. */}
|
|
413
|
+
{requirement.blocker?.message && (
|
|
414
|
+
<p className="text-muted-foreground text-base">{requirement.blocker.message}</p>
|
|
415
|
+
)}
|
|
416
|
+
|
|
417
|
+
{/* Addresses are filled in here rather than on the settings page: a
|
|
411
418
|
seller working through setup should not be sent away and lose their
|
|
412
419
|
place. Same card either way, so the two cannot diverge. */}
|
|
413
420
|
{requirement.kind === 'billing_address' && profile && (
|
|
@@ -429,12 +436,8 @@ function RequirementAction({ requirement }: { requirement: RequirementStatus })
|
|
|
429
436
|
repeatedly wondering why nothing changed. */}
|
|
430
437
|
{requirement.kind === 'payout_account' && (
|
|
431
438
|
<div className="flex flex-col items-start gap-2">
|
|
432
|
-
{requirement.blocker?.message && (
|
|
433
|
-
<p className="text-muted-foreground text-sm">{requirement.blocker.message}</p>
|
|
434
|
-
)}
|
|
435
|
-
|
|
436
439
|
{requirement.blocker?.state === 'pending' ? (
|
|
437
|
-
<p className="text-muted-foreground text-
|
|
440
|
+
<p className="text-muted-foreground text-base">
|
|
438
441
|
{t('onboarding.awaiting_payout_provider')}
|
|
439
442
|
</p>
|
|
440
443
|
) : (
|
|
@@ -456,10 +459,10 @@ function RequirementAction({ requirement }: { requirement: RequirementStatus })
|
|
|
456
459
|
about (a provider's hosted flow), and it has no idea how this panel
|
|
457
460
|
routes. Anything unmapped falls through to `action_url` below, so a
|
|
458
461
|
kind added by a gem still gets a way in without this file changing. */}
|
|
459
|
-
{
|
|
462
|
+
{route && (
|
|
460
463
|
<div className="flex justify-start">
|
|
461
464
|
<Button asChild>
|
|
462
|
-
<Link to={
|
|
465
|
+
<Link to={route} params={{ sellerId }}>
|
|
463
466
|
{t('onboarding.go')}
|
|
464
467
|
<ChevronRightIcon className="size-4" />
|
|
465
468
|
</Link>
|
|
@@ -497,7 +500,7 @@ function RequirementAction({ requirement }: { requirement: RequirementStatus })
|
|
|
497
500
|
function panelRoute(kind: string): string | undefined {
|
|
498
501
|
switch (kind) {
|
|
499
502
|
case 'complete_profile':
|
|
500
|
-
return '/$sellerId/profile'
|
|
503
|
+
return '/$sellerId/settings/profile'
|
|
501
504
|
case 'policy':
|
|
502
505
|
return '/$sellerId/settings/policies'
|
|
503
506
|
case 'delivery_method':
|
package/src/pages/order.tsx
CHANGED
|
@@ -78,7 +78,8 @@ export function OrderPage() {
|
|
|
78
78
|
)}
|
|
79
79
|
</>
|
|
80
80
|
}
|
|
81
|
-
resource={
|
|
81
|
+
resource={order}
|
|
82
|
+
endpoint={`/api/v3/seller/orders/${order.id}`}
|
|
82
83
|
destructiveItems={
|
|
83
84
|
cancelable ? (
|
|
84
85
|
<DropdownMenuItem variant="destructive" onClick={() => setCanceling(true)}>
|
package/src/pages/orders.tsx
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import { ExportButton,
|
|
1
|
+
import { ExportButton, type ResourceSearch, ResourceTable } from '@spree/dashboard-core'
|
|
2
2
|
import { useRowClickBridge } from '@spree/dashboard-ui'
|
|
3
3
|
import type { Order } from '@spree/seller-sdk'
|
|
4
4
|
import { useNavigate, useParams } from '@tanstack/react-router'
|
|
5
|
-
import { useTranslation } from 'react-i18next'
|
|
6
5
|
import { sellerClient } from '../api-client'
|
|
7
6
|
import '../tables/orders'
|
|
8
7
|
|
|
9
8
|
/** What this seller has sold, newest first. */
|
|
10
9
|
export function OrdersPage({ search }: { search: ResourceSearch }) {
|
|
11
|
-
const { t } = useTranslation()
|
|
12
10
|
const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
|
|
13
11
|
const navigate = useNavigate()
|
|
14
12
|
|
|
@@ -17,17 +15,12 @@ export function OrdersPage({ search }: { search: ResourceSearch }) {
|
|
|
17
15
|
)
|
|
18
16
|
|
|
19
17
|
return (
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
queryFn={(params) => sellerClient().orders.list(params)}
|
|
28
|
-
searchParams={search}
|
|
29
|
-
actions={(ctx) => <ExportButton type="orders" {...ctx} />}
|
|
30
|
-
/>
|
|
31
|
-
</div>
|
|
18
|
+
<ResourceTable<Order>
|
|
19
|
+
tableKey="seller-orders"
|
|
20
|
+
queryKey="seller-orders"
|
|
21
|
+
queryFn={(params) => sellerClient().orders.list(params)}
|
|
22
|
+
searchParams={search}
|
|
23
|
+
actions={(ctx) => <ExportButton type="orders" {...ctx} />}
|
|
24
|
+
/>
|
|
32
25
|
)
|
|
33
26
|
}
|
package/src/pages/payout.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
formatStoreDateTime,
|
|
4
4
|
Money,
|
|
5
5
|
PageHeader,
|
|
6
|
+
ReadRow,
|
|
6
7
|
Slot,
|
|
7
8
|
useMoneyLocale,
|
|
8
9
|
} from '@spree/dashboard-core'
|
|
@@ -26,7 +27,6 @@ import { Link, useParams } from '@tanstack/react-router'
|
|
|
26
27
|
import { useState } from 'react'
|
|
27
28
|
import { useTranslation } from 'react-i18next'
|
|
28
29
|
import { CenteredMessage } from '../components/centered-message'
|
|
29
|
-
import { ReadRow } from '../components/read-row'
|
|
30
30
|
import { RetryableError } from '../components/retryable-error'
|
|
31
31
|
import { usePayout, useTransfers } from '../hooks/use-ledger'
|
|
32
32
|
import { useStoreTimezone } from '../hooks/use-store-timezone'
|
|
@@ -71,7 +71,8 @@ export function PayoutPage() {
|
|
|
71
71
|
label={t(`earnings.statuses.${payout.status}`, { defaultValue: payout.status })}
|
|
72
72
|
/>
|
|
73
73
|
}
|
|
74
|
-
resource={
|
|
74
|
+
resource={payout}
|
|
75
|
+
endpoint={`/api/v3/seller/payouts/${payout.id}`}
|
|
75
76
|
/>
|
|
76
77
|
}
|
|
77
78
|
main={
|
|
@@ -93,9 +94,11 @@ export function PayoutPage() {
|
|
|
93
94
|
<Skeleton className="h-16 w-full" />
|
|
94
95
|
</div>
|
|
95
96
|
) : transfersFailed ? (
|
|
96
|
-
<p className="px-6 pb-6 text-
|
|
97
|
+
<p className="px-6 pb-6 text-base text-destructive">
|
|
98
|
+
{t('payouts.detail.covers_error')}
|
|
99
|
+
</p>
|
|
97
100
|
) : rows.length === 0 ? (
|
|
98
|
-
<p className="px-6 pb-6 text-
|
|
101
|
+
<p className="px-6 pb-6 text-base text-muted-foreground">
|
|
99
102
|
{t('payouts.detail.covers_empty')}
|
|
100
103
|
</p>
|
|
101
104
|
) : (
|
package/src/pages/payouts.tsx
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
formatAmount,
|
|
3
|
+
PageHeader,
|
|
4
|
+
type ResourceSearch,
|
|
5
|
+
ResourceTable,
|
|
6
|
+
useMoneyLocale,
|
|
7
|
+
} from '@spree/dashboard-core'
|
|
2
8
|
import { useRowClickBridge } from '@spree/dashboard-ui'
|
|
3
9
|
import type { Payout } from '@spree/seller-sdk'
|
|
4
10
|
import { useNavigate, useParams } from '@tanstack/react-router'
|
|
5
11
|
import { useTranslation } from 'react-i18next'
|
|
6
12
|
import { sellerClient } from '../api-client'
|
|
13
|
+
import { useProfile } from '../hooks/use-profile'
|
|
7
14
|
import '../tables/payouts'
|
|
8
15
|
|
|
9
16
|
/** What the marketplace has sent this seller, and what is on its way. */
|
|
@@ -11,6 +18,8 @@ export function PayoutsPage({ search }: { search: ResourceSearch }) {
|
|
|
11
18
|
const { t } = useTranslation()
|
|
12
19
|
const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
|
|
13
20
|
const navigate = useNavigate()
|
|
21
|
+
const { data: profile } = useProfile()
|
|
22
|
+
const moneyLocale = useMoneyLocale()
|
|
14
23
|
|
|
15
24
|
useRowClickBridge('data-payout-id', (payoutId: string) =>
|
|
16
25
|
navigate({ to: '/$sellerId/payouts/$payoutId', params: { sellerId, payoutId } }),
|
|
@@ -18,7 +27,19 @@ export function PayoutsPage({ search }: { search: ResourceSearch }) {
|
|
|
18
27
|
|
|
19
28
|
return (
|
|
20
29
|
<div className="flex flex-col gap-4">
|
|
21
|
-
<PageHeader
|
|
30
|
+
<PageHeader
|
|
31
|
+
title={t('payouts.title')}
|
|
32
|
+
subtitle={t('payouts.subtitle')}
|
|
33
|
+
// The marketplace's terms, read-only: the seller API refuses to write them.
|
|
34
|
+
description={
|
|
35
|
+
profile &&
|
|
36
|
+
[
|
|
37
|
+
`${t('profile.payout_schedule')}: ${profile.payouts_schedule_interval ? t(`payouts.schedule_intervals.${profile.payouts_schedule_interval}`) : '—'}`,
|
|
38
|
+
`${t('profile.minimum_payout')}: ${formatAmount(profile.minimum_payout_amount, profile.default_currency, moneyLocale)}`,
|
|
39
|
+
t('profile.operator_managed'),
|
|
40
|
+
].join(' · ')
|
|
41
|
+
}
|
|
42
|
+
/>
|
|
22
43
|
|
|
23
44
|
<ResourceTable<Payout>
|
|
24
45
|
hideHeader
|
package/src/pages/policies.tsx
CHANGED
|
@@ -124,7 +124,17 @@ export function PoliciesPage({ search }: { search: ResourceSearch }) {
|
|
|
124
124
|
|
|
125
125
|
return (
|
|
126
126
|
<div className="flex flex-col gap-6">
|
|
127
|
-
<PageHeader
|
|
127
|
+
<PageHeader
|
|
128
|
+
title={t('policies.title')}
|
|
129
|
+
description={t('policies.description')}
|
|
130
|
+
sticky={false}
|
|
131
|
+
actions={
|
|
132
|
+
<Button size="sm" onClick={() => setCreatingName('')}>
|
|
133
|
+
<PlusIcon className="size-4" />
|
|
134
|
+
{t('policies.add')}
|
|
135
|
+
</Button>
|
|
136
|
+
}
|
|
137
|
+
/>
|
|
128
138
|
|
|
129
139
|
{outstanding.length > 0 && (
|
|
130
140
|
<Card>
|
|
@@ -132,7 +142,7 @@ export function PoliciesPage({ search }: { search: ResourceSearch }) {
|
|
|
132
142
|
<CardTitle>{t('policies.required.title')}</CardTitle>
|
|
133
143
|
</CardHeader>
|
|
134
144
|
<CardContent className="flex flex-col gap-3">
|
|
135
|
-
<p className="text-muted-foreground text-
|
|
145
|
+
<p className="text-muted-foreground text-base">{t('policies.required.description')}</p>
|
|
136
146
|
<ItemGroup className="gap-2">
|
|
137
147
|
{outstanding.map((name) => (
|
|
138
148
|
<Item
|
|
@@ -176,12 +186,6 @@ export function PoliciesPage({ search }: { search: ResourceSearch }) {
|
|
|
176
186
|
]}
|
|
177
187
|
/>
|
|
178
188
|
)}
|
|
179
|
-
actions={
|
|
180
|
-
<Button size="sm" onClick={() => setCreatingName('')}>
|
|
181
|
-
<PlusIcon className="size-4" />
|
|
182
|
-
{t('policies.add')}
|
|
183
|
-
</Button>
|
|
184
|
-
}
|
|
185
189
|
/>
|
|
186
190
|
|
|
187
191
|
{creatingName !== null && (
|
package/src/pages/product.tsx
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
productToFormValues,
|
|
19
19
|
Slot,
|
|
20
20
|
useAssignableVariants,
|
|
21
|
+
useCommitRemovedProductMedia,
|
|
21
22
|
VariantsCard,
|
|
22
23
|
variantToWirePayload,
|
|
23
24
|
withExtensionRowValues,
|
|
@@ -68,6 +69,7 @@ export function ProductPage({ mode }: { mode: 'new' | 'edit' }) {
|
|
|
68
69
|
const productId = mode === 'edit' ? params.productId : undefined
|
|
69
70
|
const navigate = useNavigate()
|
|
70
71
|
const queryClient = useQueryClient()
|
|
72
|
+
const commitRemovedMedia = useCommitRemovedProductMedia(productId ?? '')
|
|
71
73
|
|
|
72
74
|
const productKey = ['seller', sellerId, 'product', productId]
|
|
73
75
|
const {
|
|
@@ -141,8 +143,9 @@ export function ProductPage({ mode }: { mode: 'new' | 'edit' }) {
|
|
|
141
143
|
// means "leave it".
|
|
142
144
|
product_type_id: values.product_type_id ?? null,
|
|
143
145
|
delivery_profile_id: values.delivery_profile_id ?? undefined,
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
+
// Variants are the whole intent: one the seller removed is absent
|
|
147
|
+
// here, which tells the API to drop it. Media is not — a file left out
|
|
148
|
+
// stays, and removed files go through the media DELETE after the save.
|
|
146
149
|
// The video address and the focal point ride along because the
|
|
147
150
|
// cards offer both — an allow-list that omitted them let a seller
|
|
148
151
|
// set a crop or paste a video URL and watch it vanish on save.
|
|
@@ -195,6 +198,15 @@ export function ProductPage({ mode }: { mode: 'new' | 'edit' }) {
|
|
|
195
198
|
values: withExtensionRowValues('seller.product', values, form.getValues()),
|
|
196
199
|
extensionValues: extensionSubmitValues('seller.product', form),
|
|
197
200
|
})
|
|
201
|
+
// Files the seller removed go only once the product itself saved.
|
|
202
|
+
if (productId && values.removed_media_ids?.length) {
|
|
203
|
+
const failed = await commitRemovedMedia(values.removed_media_ids)
|
|
204
|
+
if (failed > 0) toastManager.add({ type: 'error', title: t('common.error') })
|
|
205
|
+
// Committed: the next save must not try them again.
|
|
206
|
+
form.setValue('removed_media_ids', [], { shouldDirty: false })
|
|
207
|
+
// The product refetched on save may still list them; read it again.
|
|
208
|
+
await queryClient.invalidateQueries({ queryKey: productKey })
|
|
209
|
+
}
|
|
198
210
|
} catch (err) {
|
|
199
211
|
if (!mapSpreeErrorsToForm(err, form.setError)) {
|
|
200
212
|
toastManager.add({
|
|
@@ -224,7 +236,7 @@ export function ProductPage({ mode }: { mode: 'new' | 'edit' }) {
|
|
|
224
236
|
})}
|
|
225
237
|
>
|
|
226
238
|
{form.formState.errors.root?.message && (
|
|
227
|
-
<p className="text-destructive text-
|
|
239
|
+
<p className="text-destructive text-base" role="alert">
|
|
228
240
|
{form.formState.errors.root.message}
|
|
229
241
|
</p>
|
|
230
242
|
)}
|
|
@@ -233,6 +245,8 @@ export function ProductPage({ mode }: { mode: 'new' | 'edit' }) {
|
|
|
233
245
|
<PageHeader
|
|
234
246
|
title={product?.name ?? t('products.new_title')}
|
|
235
247
|
backTo="products"
|
|
248
|
+
resource={product}
|
|
249
|
+
endpoint={product ? `/api/v3/seller/products/${product.id}` : undefined}
|
|
236
250
|
badges={
|
|
237
251
|
product?.status ? (
|
|
238
252
|
<StatusBadge
|
|
@@ -247,13 +261,13 @@ export function ProductPage({ mode }: { mode: 'new' | 'edit' }) {
|
|
|
247
261
|
main={
|
|
248
262
|
<>
|
|
249
263
|
<GeneralCard form={form} />
|
|
250
|
-
<VariantsCard form={form} formKey="seller.product" />
|
|
251
264
|
<MediaCard
|
|
252
265
|
productId={productId}
|
|
253
266
|
variants={assignableVariants}
|
|
254
267
|
form={form}
|
|
255
268
|
formKey="seller.product"
|
|
256
269
|
/>
|
|
270
|
+
<VariantsCard form={form} formKey="seller.product" />
|
|
257
271
|
<PricesCard
|
|
258
272
|
form={form}
|
|
259
273
|
productName={product?.name ?? ''}
|