@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,389 @@
|
|
|
1
|
+
import { PageHeader } 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={<BrandCard profile={profile} onEdit={() => setEditing(true)} />}
|
|
88
|
+
sidebar={
|
|
89
|
+
<>
|
|
90
|
+
<StandingCard profile={profile} />
|
|
91
|
+
<AtAGlanceCard profile={profile} teamCount={team?.data?.length} />
|
|
92
|
+
<ContactCard profile={profile} onEdit={() => setEditing(true)} />
|
|
93
|
+
<SellerBusinessCard profile={profile} />
|
|
94
|
+
<SellerTaxIdentifiersCard />
|
|
95
|
+
<SellerAddressCard profile={profile} />
|
|
96
|
+
<SellerReturnsLocationCard />
|
|
97
|
+
<SettlementCard profile={profile} />
|
|
98
|
+
</>
|
|
99
|
+
}
|
|
100
|
+
/>
|
|
101
|
+
|
|
102
|
+
<EditProfileSheet profile={profile} open={editing} onOpenChange={setEditing} />
|
|
103
|
+
</>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The storefront face of the seller: cover, logo, handle and description.
|
|
109
|
+
*
|
|
110
|
+
* Copied from the operator's card, including why the empty states look as
|
|
111
|
+
* they do — a seller with no cover gets a slim band rather than a tall empty
|
|
112
|
+
* block, and the logo tile straddles it with its own z-index so the band's
|
|
113
|
+
* edge does not cut across it.
|
|
114
|
+
*/
|
|
115
|
+
function BrandCard({ profile, onEdit }: { profile: Profile; onEdit: () => void }) {
|
|
116
|
+
const { t } = useTranslation()
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<Card className="overflow-hidden">
|
|
120
|
+
<div className="relative">
|
|
121
|
+
{profile.cover_photo_url ? (
|
|
122
|
+
<img
|
|
123
|
+
src={profile.cover_photo_url}
|
|
124
|
+
alt=""
|
|
125
|
+
className="h-40 w-full bg-accent object-cover"
|
|
126
|
+
/>
|
|
127
|
+
) : (
|
|
128
|
+
<div className="h-20 w-full border-border border-b bg-muted" />
|
|
129
|
+
)}
|
|
130
|
+
<Button size="sm" variant="outline" className="absolute top-3 right-3" onClick={onEdit}>
|
|
131
|
+
<PencilIcon className="size-4" />
|
|
132
|
+
{t('profile.edit')}
|
|
133
|
+
</Button>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<CardContent className="flex flex-col gap-4 px-5 pb-5">
|
|
137
|
+
<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">
|
|
138
|
+
{/* The square logo is the one cropped for a tile like this; the main
|
|
139
|
+
logo is the fallback, since a seller may have set only one. */}
|
|
140
|
+
{profile.square_logo_url || profile.logo_url ? (
|
|
141
|
+
<img
|
|
142
|
+
src={profile.square_logo_url ?? profile.logo_url ?? undefined}
|
|
143
|
+
alt=""
|
|
144
|
+
className="size-full object-cover"
|
|
145
|
+
/>
|
|
146
|
+
) : (
|
|
147
|
+
<StoreIcon className="size-7 text-muted-foreground" />
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{/* The name is already the page title; the handle is what it lacks. */}
|
|
152
|
+
<p className="text-muted-foreground text-sm">/{profile.slug}</p>
|
|
153
|
+
|
|
154
|
+
{profile.about_html ? (
|
|
155
|
+
// Server-sanitized on every write path (Spree::RichTextSanitizer),
|
|
156
|
+
// which is what makes rendering it here safe.
|
|
157
|
+
<div
|
|
158
|
+
className="prose prose-sm dark:prose-invert max-w-none"
|
|
159
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized server-side
|
|
160
|
+
dangerouslySetInnerHTML={{ __html: profile.about_html }}
|
|
161
|
+
/>
|
|
162
|
+
) : (
|
|
163
|
+
<p className="text-muted-foreground text-sm">{t('profile.no_about')}</p>
|
|
164
|
+
)}
|
|
165
|
+
</CardContent>
|
|
166
|
+
</Card>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Where the seller stands with the marketplace, and what that means. */
|
|
171
|
+
function StandingCard({ profile }: { profile: Profile }) {
|
|
172
|
+
const { t } = useTranslation()
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<Card>
|
|
176
|
+
<CardHeader>
|
|
177
|
+
<CardTitle>{t('profile.section_standing')}</CardTitle>
|
|
178
|
+
<CardAction>
|
|
179
|
+
<StatusBadge status={profile.status} />
|
|
180
|
+
</CardAction>
|
|
181
|
+
</CardHeader>
|
|
182
|
+
<CardContent className="flex flex-col gap-2">
|
|
183
|
+
<p className="text-muted-foreground text-sm">
|
|
184
|
+
{t(`profile.status_help.${profile.status}`, {
|
|
185
|
+
defaultValue: t('profile.status_help.pending'),
|
|
186
|
+
})}
|
|
187
|
+
</p>
|
|
188
|
+
{profile.on_holiday && (
|
|
189
|
+
<p className="text-muted-foreground text-sm">{t('profile.on_holiday_notice')}</p>
|
|
190
|
+
)}
|
|
191
|
+
</CardContent>
|
|
192
|
+
</Card>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function AtAGlanceCard({ profile, teamCount }: { profile: Profile; teamCount?: number }) {
|
|
197
|
+
const { t } = useTranslation()
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<Card>
|
|
201
|
+
<CardHeader>
|
|
202
|
+
<CardTitle>{t('profile.at_a_glance')}</CardTitle>
|
|
203
|
+
</CardHeader>
|
|
204
|
+
<CardContent className="flex flex-col gap-3">
|
|
205
|
+
<div className="flex items-center justify-between text-sm">
|
|
206
|
+
<span className="flex items-center gap-2 text-muted-foreground">
|
|
207
|
+
<PackageIcon className="size-4" />
|
|
208
|
+
{t('profile.products_count')}
|
|
209
|
+
</span>
|
|
210
|
+
<span>{profile.products_count}</span>
|
|
211
|
+
</div>
|
|
212
|
+
<div className="flex items-center justify-between text-sm">
|
|
213
|
+
<span className="flex items-center gap-2 text-muted-foreground">
|
|
214
|
+
<UsersIcon className="size-4" />
|
|
215
|
+
{t('profile.team_count')}
|
|
216
|
+
</span>
|
|
217
|
+
<span>{teamCount ?? '—'}</span>
|
|
218
|
+
</div>
|
|
219
|
+
<ReadRow label={t('profile.joined')}>
|
|
220
|
+
<RelativeTime iso={profile.created_at} />
|
|
221
|
+
</ReadRow>
|
|
222
|
+
</CardContent>
|
|
223
|
+
</Card>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function ContactCard({ profile, onEdit }: { profile: Profile; onEdit: () => void }) {
|
|
228
|
+
const { t } = useTranslation()
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<Card>
|
|
232
|
+
<CardHeader>
|
|
233
|
+
<CardTitle>{t('profile.section_contact')}</CardTitle>
|
|
234
|
+
<CardAction>
|
|
235
|
+
<Button variant="outline" size="sm" onClick={onEdit}>
|
|
236
|
+
<PencilIcon className="size-4" />
|
|
237
|
+
{t('profile.edit')}
|
|
238
|
+
</Button>
|
|
239
|
+
</CardAction>
|
|
240
|
+
</CardHeader>
|
|
241
|
+
<CardContent className="flex flex-col gap-3">
|
|
242
|
+
<ReadRow label={t('profile.contact_email')}>
|
|
243
|
+
{profile.contact_email ? (
|
|
244
|
+
<a href={`mailto:${profile.contact_email}`}>{profile.contact_email}</a>
|
|
245
|
+
) : null}
|
|
246
|
+
</ReadRow>
|
|
247
|
+
<ReadRow label={t('profile.billing_email')}>
|
|
248
|
+
{profile.billing_email ? (
|
|
249
|
+
<a href={`mailto:${profile.billing_email}`}>{profile.billing_email}</a>
|
|
250
|
+
) : null}
|
|
251
|
+
</ReadRow>
|
|
252
|
+
</CardContent>
|
|
253
|
+
</Card>
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* How and when this seller gets paid. Read-only on purpose: these are the
|
|
259
|
+
* marketplace's terms, and the seller API refuses to write them — showing an
|
|
260
|
+
* editor here would offer a control the server would reject.
|
|
261
|
+
*/
|
|
262
|
+
function SettlementCard({ profile }: { profile: Profile }) {
|
|
263
|
+
const { t } = useTranslation()
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<Card>
|
|
267
|
+
<CardHeader>
|
|
268
|
+
<CardTitle>{t('profile.section_settlement')}</CardTitle>
|
|
269
|
+
</CardHeader>
|
|
270
|
+
<CardContent className="flex flex-col gap-3">
|
|
271
|
+
<p className="text-muted-foreground text-xs">{t('profile.operator_managed')}</p>
|
|
272
|
+
<ReadRow label={t('profile.tax_remittance')}>{profile.tax_remittance}</ReadRow>
|
|
273
|
+
<ReadRow label={t('profile.payout_schedule')}>{profile.payouts_schedule_interval}</ReadRow>
|
|
274
|
+
<ReadRow label={t('profile.minimum_payout')}>{profile.minimum_payout_amount}</ReadRow>
|
|
275
|
+
</CardContent>
|
|
276
|
+
</Card>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function EditProfileSheet({
|
|
281
|
+
profile,
|
|
282
|
+
open,
|
|
283
|
+
onOpenChange,
|
|
284
|
+
}: {
|
|
285
|
+
profile: Profile
|
|
286
|
+
open: boolean
|
|
287
|
+
onOpenChange: (open: boolean) => void
|
|
288
|
+
}) {
|
|
289
|
+
const { t } = useTranslation()
|
|
290
|
+
const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
|
|
291
|
+
const queryClient = useQueryClient()
|
|
292
|
+
|
|
293
|
+
const form = useForm<ProfileValues>({
|
|
294
|
+
defaultValues: { name: '', contact_email: '', billing_email: '', about: '' },
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// The sheet stays mounted across opens, so re-seed from the latest record
|
|
298
|
+
// each time it opens rather than only on first mount.
|
|
299
|
+
useEffect(() => {
|
|
300
|
+
if (!open) return
|
|
301
|
+
|
|
302
|
+
form.reset({
|
|
303
|
+
name: profile.name,
|
|
304
|
+
contact_email: profile.contact_email ?? '',
|
|
305
|
+
billing_email: profile.billing_email ?? '',
|
|
306
|
+
about: profile.about_html ?? '',
|
|
307
|
+
})
|
|
308
|
+
}, [open, profile, form])
|
|
309
|
+
|
|
310
|
+
const save = useMutation({
|
|
311
|
+
mutationFn: (values: ProfileValues) =>
|
|
312
|
+
sellerClient().profile.update({
|
|
313
|
+
name: values.name,
|
|
314
|
+
contact_email: values.contact_email || null,
|
|
315
|
+
billing_email: values.billing_email || null,
|
|
316
|
+
about: values.about || null,
|
|
317
|
+
}),
|
|
318
|
+
onSuccess: (updated) => {
|
|
319
|
+
queryClient.setQueryData(['seller', sellerId, 'profile'], updated)
|
|
320
|
+
onOpenChange(false)
|
|
321
|
+
toastManager.add({ type: 'success', title: t('profile.saved') })
|
|
322
|
+
},
|
|
323
|
+
onError: (err) =>
|
|
324
|
+
toastManager.add({
|
|
325
|
+
type: 'error',
|
|
326
|
+
title: err instanceof Error ? err.message : t('common.error'),
|
|
327
|
+
}),
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
const { errors } = form.formState
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
334
|
+
<SheetContent>
|
|
335
|
+
<SheetHeader>
|
|
336
|
+
<SheetTitle>{t('profile.edit_title')}</SheetTitle>
|
|
337
|
+
</SheetHeader>
|
|
338
|
+
|
|
339
|
+
{/* `stopPropagation` after handleSubmit: this form is portalled and
|
|
340
|
+
a parent form would otherwise see the bubbling submit. */}
|
|
341
|
+
<form
|
|
342
|
+
onSubmit={(event) => {
|
|
343
|
+
form.handleSubmit((values) => save.mutate(values))(event)
|
|
344
|
+
event.stopPropagation()
|
|
345
|
+
}}
|
|
346
|
+
className="flex min-h-0 flex-1 flex-col"
|
|
347
|
+
>
|
|
348
|
+
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
|
349
|
+
<FieldGroup>
|
|
350
|
+
<Field>
|
|
351
|
+
<FieldLabel htmlFor="name">{t('profile.name')}</FieldLabel>
|
|
352
|
+
<Input
|
|
353
|
+
id="name"
|
|
354
|
+
aria-invalid={!!errors.name || undefined}
|
|
355
|
+
{...form.register('name', { required: true })}
|
|
356
|
+
/>
|
|
357
|
+
<FieldError errors={[errors.name]} />
|
|
358
|
+
</Field>
|
|
359
|
+
|
|
360
|
+
<Field>
|
|
361
|
+
<FieldLabel htmlFor="about">{t('profile.about')}</FieldLabel>
|
|
362
|
+
<Textarea id="about" rows={6} {...form.register('about')} />
|
|
363
|
+
</Field>
|
|
364
|
+
|
|
365
|
+
<Field>
|
|
366
|
+
<FieldLabel htmlFor="contact_email">{t('profile.contact_email')}</FieldLabel>
|
|
367
|
+
<Input id="contact_email" type="email" {...form.register('contact_email')} />
|
|
368
|
+
</Field>
|
|
369
|
+
|
|
370
|
+
<Field>
|
|
371
|
+
<FieldLabel htmlFor="billing_email">{t('profile.billing_email')}</FieldLabel>
|
|
372
|
+
<Input id="billing_email" type="email" {...form.register('billing_email')} />
|
|
373
|
+
</Field>
|
|
374
|
+
</FieldGroup>
|
|
375
|
+
</div>
|
|
376
|
+
|
|
377
|
+
<SheetFooter>
|
|
378
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
379
|
+
{t('common.cancel')}
|
|
380
|
+
</Button>
|
|
381
|
+
<Button type="submit" disabled={save.isPending}>
|
|
382
|
+
{save.isPending ? t('profile.saving') : t('profile.save')}
|
|
383
|
+
</Button>
|
|
384
|
+
</SheetFooter>
|
|
385
|
+
</form>
|
|
386
|
+
</SheetContent>
|
|
387
|
+
</Sheet>
|
|
388
|
+
)
|
|
389
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useAuth } from '@spree/dashboard-core'
|
|
2
|
+
import { Button, Field, FieldError, FieldGroup, FieldLabel, Input } from '@spree/dashboard-ui'
|
|
3
|
+
import { Link, useNavigate } from '@tanstack/react-router'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import { useForm } from 'react-hook-form'
|
|
6
|
+
import { useTranslation } from 'react-i18next'
|
|
7
|
+
import { CenteredMessage } from '../components/centered-message'
|
|
8
|
+
|
|
9
|
+
interface ResetValues {
|
|
10
|
+
password: string
|
|
11
|
+
password_confirmation: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Choosing a new password from the emailed link.
|
|
16
|
+
*
|
|
17
|
+
* The token in the URL is the whole credential — nobody is signed in yet.
|
|
18
|
+
* Spending it returns a seller session, so they land in the panel rather than
|
|
19
|
+
* being bounced to a login form.
|
|
20
|
+
*/
|
|
21
|
+
export function ResetPasswordPage({ token }: { token?: string }) {
|
|
22
|
+
const { t } = useTranslation()
|
|
23
|
+
const { resetPassword } = useAuth()
|
|
24
|
+
const navigate = useNavigate()
|
|
25
|
+
const [error, setError] = useState<string | null>(null)
|
|
26
|
+
const form = useForm<ResetValues>({
|
|
27
|
+
defaultValues: { password: '', password_confirmation: '' },
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
if (!token) return <CenteredMessage>{t('reset_password.invalid_link')}</CenteredMessage>
|
|
31
|
+
|
|
32
|
+
async function onSubmit(values: ResetValues) {
|
|
33
|
+
setError(null)
|
|
34
|
+
try {
|
|
35
|
+
await resetPassword(token ?? '', values)
|
|
36
|
+
// The index route decides where to go from there — it already knows how
|
|
37
|
+
// to skip the picker for someone who runs exactly one seller.
|
|
38
|
+
navigate({ to: '/', replace: true })
|
|
39
|
+
} catch {
|
|
40
|
+
// A rejected token and a refused password are reported the same way the
|
|
41
|
+
// API reports them: it will not say whether the address exists.
|
|
42
|
+
setError(t('reset_password.failed'))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { errors, isSubmitting } = form.formState
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="flex min-h-screen items-center justify-center p-6">
|
|
50
|
+
<div className="w-full max-w-sm">
|
|
51
|
+
<h1 className="font-medium text-xl">{t('reset_password.title')}</h1>
|
|
52
|
+
<p className="mt-1 mb-6 text-muted-foreground text-sm">{t('reset_password.subtitle')}</p>
|
|
53
|
+
|
|
54
|
+
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
55
|
+
<FieldGroup>
|
|
56
|
+
{error && (
|
|
57
|
+
<p className="text-destructive text-sm" role="alert">
|
|
58
|
+
{error}
|
|
59
|
+
</p>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
<Field>
|
|
63
|
+
<FieldLabel htmlFor="password">{t('reset_password.password')}</FieldLabel>
|
|
64
|
+
<Input
|
|
65
|
+
id="password"
|
|
66
|
+
type="password"
|
|
67
|
+
autoComplete="new-password"
|
|
68
|
+
autoFocus
|
|
69
|
+
aria-invalid={!!errors.password || undefined}
|
|
70
|
+
{...form.register('password', { required: true, minLength: 8 })}
|
|
71
|
+
/>
|
|
72
|
+
<FieldError errors={[errors.password]} />
|
|
73
|
+
</Field>
|
|
74
|
+
|
|
75
|
+
<Field>
|
|
76
|
+
<FieldLabel htmlFor="password_confirmation">
|
|
77
|
+
{t('reset_password.password_confirmation')}
|
|
78
|
+
</FieldLabel>
|
|
79
|
+
<Input
|
|
80
|
+
id="password_confirmation"
|
|
81
|
+
type="password"
|
|
82
|
+
autoComplete="new-password"
|
|
83
|
+
aria-invalid={!!errors.password_confirmation || undefined}
|
|
84
|
+
{...form.register('password_confirmation', {
|
|
85
|
+
required: true,
|
|
86
|
+
validate: (value, values) =>
|
|
87
|
+
value === values.password || t('reset_password.mismatch'),
|
|
88
|
+
})}
|
|
89
|
+
/>
|
|
90
|
+
<FieldError errors={[errors.password_confirmation]} />
|
|
91
|
+
</Field>
|
|
92
|
+
|
|
93
|
+
<Button type="submit" disabled={isSubmitting}>
|
|
94
|
+
{isSubmitting ? t('reset_password.submitting') : t('reset_password.submit')}
|
|
95
|
+
</Button>
|
|
96
|
+
|
|
97
|
+
<Link to="/forgot-password" className="text-sm underline underline-offset-4">
|
|
98
|
+
{t('reset_password.request_new_link')}
|
|
99
|
+
</Link>
|
|
100
|
+
</FieldGroup>
|
|
101
|
+
</form>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from '@spree/dashboard-ui'
|
|
2
|
+
import { useQuery } from '@tanstack/react-query'
|
|
3
|
+
import { Link } from '@tanstack/react-router'
|
|
4
|
+
import { useTranslation } from 'react-i18next'
|
|
5
|
+
import { sellerClient } from '../api-client'
|
|
6
|
+
import { CenteredMessage } from '../components/centered-message'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Which seller to act as.
|
|
10
|
+
*
|
|
11
|
+
* Only shown to a seller who runs more than one — the panel acts as exactly
|
|
12
|
+
* one at a time, because every request carries a single `X-Spree-Seller-Id`.
|
|
13
|
+
*/
|
|
14
|
+
export function SellerPicker() {
|
|
15
|
+
const { t } = useTranslation()
|
|
16
|
+
const { data, isLoading, error } = useQuery({
|
|
17
|
+
queryKey: ['seller', 'me'],
|
|
18
|
+
queryFn: () => sellerClient().me(),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
if (isLoading) return <CenteredMessage>{t('common.loading')}</CenteredMessage>
|
|
22
|
+
if (error) return <CenteredMessage>{t('common.error')}</CenteredMessage>
|
|
23
|
+
|
|
24
|
+
const sellers = data?.sellers ?? []
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex min-h-screen items-center justify-center p-6">
|
|
28
|
+
<Card className="w-full max-w-md">
|
|
29
|
+
<CardHeader>
|
|
30
|
+
<CardTitle>{t('seller_picker.title')}</CardTitle>
|
|
31
|
+
</CardHeader>
|
|
32
|
+
<CardContent className="flex flex-col gap-2">
|
|
33
|
+
<p className="mb-2 text-muted-foreground text-sm">{t('seller_picker.subtitle')}</p>
|
|
34
|
+
{sellers.map((seller) => (
|
|
35
|
+
<Button key={seller.id} variant="outline" className="justify-between" asChild>
|
|
36
|
+
<Link to="/$sellerId" params={{ sellerId: seller.id }}>
|
|
37
|
+
<span>{seller.name}</span>
|
|
38
|
+
<Badge variant="outline">{seller.status}</Badge>
|
|
39
|
+
</Link>
|
|
40
|
+
</Button>
|
|
41
|
+
))}
|
|
42
|
+
</CardContent>
|
|
43
|
+
</Card>
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|