@spree/seller-dashboard 6.0.2 → 6.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +23 -23
  2. package/src/api-client.ts +2 -0
  3. package/src/components/account-dialog.tsx +1 -1
  4. package/src/components/centered-message.tsx +1 -1
  5. package/src/components/finance/balance-summary.tsx +1 -1
  6. package/src/components/orders/fulfillment-deliveries.tsx +1 -1
  7. package/src/components/orders/fulfillments-card.tsx +30 -20
  8. package/src/components/orders/order-cards.tsx +115 -102
  9. package/src/components/orders/order-payment-card.tsx +2 -3
  10. package/src/components/orders/post-sale-cards.tsx +79 -47
  11. package/src/components/orders/post-sale-create-dialogs.tsx +15 -0
  12. package/src/components/orders/returns-card.tsx +44 -36
  13. package/src/components/orders/shipping-label-row.tsx +1 -1
  14. package/src/components/orders/use-product-link.tsx +10 -0
  15. package/src/components/product-status-card.tsx +2 -2
  16. package/src/components/seller-address-card.tsx +2 -2
  17. package/src/components/seller-returns-location-card.tsx +2 -7
  18. package/src/components/seller-switcher.tsx +1 -1
  19. package/src/hooks/use-fulfillments.ts +33 -22
  20. package/src/hooks/use-profile.ts +38 -0
  21. package/src/locales/ar.json +37 -32
  22. package/src/locales/de.json +37 -32
  23. package/src/locales/en.json +37 -32
  24. package/src/locales/fr.json +37 -32
  25. package/src/locales/ja.json +37 -32
  26. package/src/locales/pl.json +37 -32
  27. package/src/locales/vi.json +37 -32
  28. package/src/locales/zh-CN.json +37 -32
  29. package/src/nav/default.ts +0 -10
  30. package/src/nav/settings.ts +53 -0
  31. package/src/pages/delivery-methods.tsx +47 -49
  32. package/src/pages/forgot-password.tsx +5 -5
  33. package/src/pages/general-settings.tsx +233 -0
  34. package/src/pages/home.tsx +1 -1
  35. package/src/pages/login.tsx +3 -3
  36. package/src/pages/onboarding.tsx +26 -23
  37. package/src/pages/order.tsx +2 -1
  38. package/src/pages/orders.tsx +8 -15
  39. package/src/pages/payout.tsx +7 -4
  40. package/src/pages/payouts.tsx +23 -2
  41. package/src/pages/policies.tsx +12 -8
  42. package/src/pages/product.tsx +18 -4
  43. package/src/pages/products.tsx +0 -4
  44. package/src/pages/profile-settings.tsx +221 -0
  45. package/src/pages/reset-password.tsx +3 -3
  46. package/src/pages/seller-picker.tsx +1 -1
  47. package/src/pages/taxes.tsx +40 -0
  48. package/src/pages/team.tsx +1 -1
  49. package/src/routeTree.gen.ts +63 -0
  50. package/src/routes/_authenticated/$sellerId/profile.tsx +5 -3
  51. package/src/routes/_authenticated/$sellerId/settings/general.tsx +6 -0
  52. package/src/routes/_authenticated/$sellerId/settings/profile.tsx +6 -0
  53. package/src/routes/_authenticated/$sellerId/settings/taxes.tsx +6 -0
  54. package/src/routes/_authenticated/$sellerId/settings.tsx +3 -7
  55. package/src/schemas/account.ts +1 -1
  56. package/src/tables/products.tsx +13 -6
  57. package/src/components/read-row.tsx +0 -16
  58. package/src/components/seller-business-card.tsx +0 -152
  59. package/src/pages/profile.tsx +0 -395
@@ -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
+ }
@@ -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-sm">
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>
@@ -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-sm">{t('login.subtitle')}</p>
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-sm" role="alert">
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-sm underline underline-offset-4">
86
+ <Link to="/forgot-password" className="text-base underline underline-offset-4">
87
87
  {t('login.forgot_password')}
88
88
  </Link>
89
89
  </FieldGroup>
@@ -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-sm">{t('onboarding.under_review')}</p>
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-sm">
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 py-0">
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-sm">{requirement.description}</p>
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 needs it, and it is already loaded by the
193
- // profile page and the sidebar switcher — this reads the same cache entry.
194
- // The returns address does not: it lives on a stock location, which its own
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-sm">{t('onboarding.awaiting_review')}</p>
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
- {/* Addresses are filled in here rather than on the profile page: a
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-sm">
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
- {panelRoute(requirement.kind) && (
462
+ {route && (
460
463
  <div className="flex justify-start">
461
464
  <Button asChild>
462
- <Link to={panelRoute(requirement.kind)!} params={{ sellerId }}>
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':
@@ -78,7 +78,8 @@ export function OrderPage() {
78
78
  )}
79
79
  </>
80
80
  }
81
- resource={{ id: order.id, number: order.number }}
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)}>
@@ -1,14 +1,12 @@
1
- import { ExportButton, PageHeader, type ResourceSearch, ResourceTable } from '@spree/dashboard-core'
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
- <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>
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
  }
@@ -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={{ id: payout.id }}
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-sm text-destructive">{t('payouts.detail.covers_error')}</p>
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-sm text-muted-foreground">
101
+ <p className="px-6 pb-6 text-base text-muted-foreground">
99
102
  {t('payouts.detail.covers_empty')}
100
103
  </p>
101
104
  ) : (
@@ -1,9 +1,16 @@
1
- import { PageHeader, type ResourceSearch, ResourceTable } from '@spree/dashboard-core'
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 title={t('payouts.title')} subtitle={t('payouts.subtitle')} />
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
@@ -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 title={t('policies.title')} subtitle={t('policies.description')} sticky={false} />
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-sm">{t('policies.required.description')}</p>
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 && (
@@ -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
- // Both lists are the whole intent: anything the seller removed is
145
- // absent here, which is what tells the API to drop it.
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-sm" role="alert">
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 ?? ''}