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