@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,16 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
|
|
4
|
+
/** One labelled value in a read-only card, with a placeholder when unset. */
|
|
5
|
+
export function ReadRow({ label, children }: { label: string; children: ReactNode }) {
|
|
6
|
+
const { t } = useTranslation()
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<div className="flex items-start justify-between gap-3 text-sm">
|
|
10
|
+
<span className="text-muted-foreground">{label}</span>
|
|
11
|
+
<span className="text-right">
|
|
12
|
+
{children ?? <span className="text-muted-foreground">{t('profile.not_provided')}</span>}
|
|
13
|
+
</span>
|
|
14
|
+
</div>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Button } from '@spree/dashboard-ui'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { CenteredMessage } from './centered-message'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shown when a page's own query failed, in place of its content.
|
|
7
|
+
*
|
|
8
|
+
* Distinct from a "not found" message on purpose: a failed request and a
|
|
9
|
+
* missing record send the seller looking for different causes, and only one
|
|
10
|
+
* of the two is worth retrying.
|
|
11
|
+
*/
|
|
12
|
+
export function RetryableError({ onRetry }: { onRetry: () => void }) {
|
|
13
|
+
const { t } = useTranslation()
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<CenteredMessage>
|
|
17
|
+
{t('common.error')}{' '}
|
|
18
|
+
<Button variant="outline" onClick={onRetry}>
|
|
19
|
+
{t('common.retry')}
|
|
20
|
+
</Button>
|
|
21
|
+
</CenteredMessage>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { AddressFormDialog } from '@spree/dashboard-core'
|
|
2
|
+
import {
|
|
3
|
+
AddressBlock,
|
|
4
|
+
Badge,
|
|
5
|
+
Button,
|
|
6
|
+
Card,
|
|
7
|
+
CardAction,
|
|
8
|
+
CardContent,
|
|
9
|
+
CardHeader,
|
|
10
|
+
CardTitle,
|
|
11
|
+
StatusBadge,
|
|
12
|
+
toastManager,
|
|
13
|
+
} from '@spree/dashboard-ui'
|
|
14
|
+
import { PencilIcon, PlusIcon } from '@spree/dashboard-ui/icons'
|
|
15
|
+
import type { Profile, SellerAddressParams } from '@spree/seller-sdk'
|
|
16
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
17
|
+
import { useParams } from '@tanstack/react-router'
|
|
18
|
+
import { useState } from 'react'
|
|
19
|
+
import { useTranslation } from 'react-i18next'
|
|
20
|
+
import { sellerClient } from '../api-client'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Where the seller is invoiced — read as a block, edited in the shared
|
|
24
|
+
* address dialog.
|
|
25
|
+
*
|
|
26
|
+
* Used both on the profile page and inline in the onboarding checklist, so a
|
|
27
|
+
* seller working through setup fills the address where they are told about
|
|
28
|
+
* it rather than being sent somewhere else and losing their place. Same
|
|
29
|
+
* component either way, so the two can never diverge.
|
|
30
|
+
*
|
|
31
|
+
* Returns are not here: they go to a stock location, which is a record of its
|
|
32
|
+
* own — see `SellerReturnsLocationCard`.
|
|
33
|
+
*/
|
|
34
|
+
export function SellerAddressCard({
|
|
35
|
+
profile,
|
|
36
|
+
headless = false,
|
|
37
|
+
}: {
|
|
38
|
+
profile: Profile
|
|
39
|
+
/** Render just the body — the onboarding checklist supplies its own frame. */
|
|
40
|
+
headless?: boolean
|
|
41
|
+
}) {
|
|
42
|
+
const { t } = useTranslation()
|
|
43
|
+
const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
|
|
44
|
+
const queryClient = useQueryClient()
|
|
45
|
+
const [editing, setEditing] = useState(false)
|
|
46
|
+
|
|
47
|
+
const address = profile.billing_address
|
|
48
|
+
const title = t('profile.billing_address')
|
|
49
|
+
|
|
50
|
+
const save = useMutation({
|
|
51
|
+
// Only the billing address is sent: the rest of the profile is untouched,
|
|
52
|
+
// and posting it all would rewrite fields the seller never opened.
|
|
53
|
+
mutationFn: (values: SellerAddressParams) =>
|
|
54
|
+
sellerClient().profile.update({ billing_address: values }),
|
|
55
|
+
onSuccess: (updated) => {
|
|
56
|
+
queryClient.setQueryData(['seller', sellerId, 'profile'], updated)
|
|
57
|
+
// The checklist reads addresses too, so it has to re-evaluate.
|
|
58
|
+
void queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'onboarding'] })
|
|
59
|
+
setEditing(false)
|
|
60
|
+
toastManager.add({ type: 'success', title: t('profile.saved') })
|
|
61
|
+
},
|
|
62
|
+
onError: (err) =>
|
|
63
|
+
toastManager.add({
|
|
64
|
+
type: 'error',
|
|
65
|
+
title: err instanceof Error ? err.message : t('common.error'),
|
|
66
|
+
}),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const body = address ? (
|
|
70
|
+
<AddressBlock address={address} />
|
|
71
|
+
) : (
|
|
72
|
+
<p className="text-muted-foreground text-sm">{t('profile.address_not_provided')}</p>
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const editButton = (
|
|
76
|
+
<Button variant={address ? 'outline' : 'default'} size="sm" onClick={() => setEditing(true)}>
|
|
77
|
+
{address ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />}
|
|
78
|
+
{address ? t('profile.edit') : t('profile.add_address')}
|
|
79
|
+
</Button>
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const dialog = editing && (
|
|
83
|
+
<AddressFormDialog
|
|
84
|
+
title={title}
|
|
85
|
+
address={address}
|
|
86
|
+
open
|
|
87
|
+
business
|
|
88
|
+
onOpenChange={setEditing}
|
|
89
|
+
onSave={(values) => save.mutate(values)}
|
|
90
|
+
isPending={save.isPending}
|
|
91
|
+
/>
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if (headless) {
|
|
95
|
+
return (
|
|
96
|
+
<div className="flex flex-col items-start gap-3">
|
|
97
|
+
{body}
|
|
98
|
+
{editButton}
|
|
99
|
+
{dialog}
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<>
|
|
106
|
+
<Card>
|
|
107
|
+
<CardHeader>
|
|
108
|
+
<CardTitle>
|
|
109
|
+
{title}
|
|
110
|
+
{address ? (
|
|
111
|
+
<StatusBadge status="complete" label={t('profile.address_filled')} />
|
|
112
|
+
) : (
|
|
113
|
+
<Badge variant="outline">{t('profile.address_missing')}</Badge>
|
|
114
|
+
)}
|
|
115
|
+
</CardTitle>
|
|
116
|
+
<CardAction>{editButton}</CardAction>
|
|
117
|
+
</CardHeader>
|
|
118
|
+
<CardContent>{body}</CardContent>
|
|
119
|
+
</Card>
|
|
120
|
+
{dialog}
|
|
121
|
+
</>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AddressFormDialog,
|
|
3
|
+
editableAddressToStockLocationParams,
|
|
4
|
+
stockLocationToAddressBlock,
|
|
5
|
+
stockLocationToEditableAddress,
|
|
6
|
+
} from '@spree/dashboard-core'
|
|
7
|
+
import {
|
|
8
|
+
AddressBlock,
|
|
9
|
+
Badge,
|
|
10
|
+
Button,
|
|
11
|
+
Card,
|
|
12
|
+
CardAction,
|
|
13
|
+
CardContent,
|
|
14
|
+
CardHeader,
|
|
15
|
+
CardTitle,
|
|
16
|
+
StatusBadge,
|
|
17
|
+
toastManager,
|
|
18
|
+
} from '@spree/dashboard-ui'
|
|
19
|
+
import { PencilIcon, PlusIcon } from '@spree/dashboard-ui/icons'
|
|
20
|
+
import type { StockLocationParams } from '@spree/seller-sdk'
|
|
21
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
22
|
+
import { useParams } from '@tanstack/react-router'
|
|
23
|
+
import { useState } from 'react'
|
|
24
|
+
import { useTranslation } from 'react-i18next'
|
|
25
|
+
import { sellerClient } from '../api-client'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where customers send returns — the address on the seller's default stock
|
|
29
|
+
* location.
|
|
30
|
+
*
|
|
31
|
+
* A location rather than a loose address because returned goods have to be
|
|
32
|
+
* restocked somewhere the catalog can see: stock movements anchor to a
|
|
33
|
+
* location, so an address on its own would leave the parcel arriving nowhere.
|
|
34
|
+
* The seller sees an address form all the same — the location is the record
|
|
35
|
+
* behind it, not a concept they have to learn to fill this in.
|
|
36
|
+
*/
|
|
37
|
+
export function SellerReturnsLocationCard({ headless = false }: { headless?: boolean }) {
|
|
38
|
+
const { t } = useTranslation()
|
|
39
|
+
const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
|
|
40
|
+
const queryClient = useQueryClient()
|
|
41
|
+
const [editing, setEditing] = useState(false)
|
|
42
|
+
|
|
43
|
+
// Two filtered requests rather than one page filtered in the browser, to
|
|
44
|
+
// match `Seller#returns_location` exactly: a location that accepts returns
|
|
45
|
+
// wins outright, and only then does default and name decide. A seller with
|
|
46
|
+
// more locations than one page holds would otherwise see one address while
|
|
47
|
+
// the server sent their returns to another, with nothing on screen saying
|
|
48
|
+
// so — and they would edit the wrong one.
|
|
49
|
+
const activeParams = { active_true: true, limit: 1 }
|
|
50
|
+
const { data: takesReturns, isError: returnsFailed } = useQuery({
|
|
51
|
+
queryKey: ['seller', sellerId, 'stock-locations', 'returns'],
|
|
52
|
+
queryFn: () =>
|
|
53
|
+
sellerClient().stockLocations.list({ ...activeParams, returns_enabled_true: true }),
|
|
54
|
+
})
|
|
55
|
+
const hasReturnsLocation = (takesReturns?.data.length ?? 0) > 0
|
|
56
|
+
const { data: anyActive, isError: anyFailed } = useQuery({
|
|
57
|
+
queryKey: ['seller', sellerId, 'stock-locations'],
|
|
58
|
+
queryFn: () => sellerClient().stockLocations.list(activeParams),
|
|
59
|
+
enabled: !!takesReturns && !hasReturnsLocation,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const location = takesReturns?.data[0] ?? anyActive?.data[0]
|
|
63
|
+
// A failed lookup must not read as "no address yet" — that invites the
|
|
64
|
+
// seller to re-enter one they already have.
|
|
65
|
+
const failed = returnsFailed || anyFailed
|
|
66
|
+
const hasAddress = Boolean(location?.address1)
|
|
67
|
+
const title = t('profile.returns_address')
|
|
68
|
+
|
|
69
|
+
const save = useMutation({
|
|
70
|
+
mutationFn: (params: StockLocationParams) => {
|
|
71
|
+
if (!location) throw new Error(t('common.error'))
|
|
72
|
+
return sellerClient().stockLocations.update(location.id, params)
|
|
73
|
+
},
|
|
74
|
+
onSuccess: () => {
|
|
75
|
+
void queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'stock-locations'] })
|
|
76
|
+
// The checklist asks whether this address is filled in, so it has to
|
|
77
|
+
// re-evaluate.
|
|
78
|
+
void queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'onboarding'] })
|
|
79
|
+
setEditing(false)
|
|
80
|
+
toastManager.add({ type: 'success', title: t('profile.saved') })
|
|
81
|
+
},
|
|
82
|
+
onError: (err) =>
|
|
83
|
+
toastManager.add({
|
|
84
|
+
type: 'error',
|
|
85
|
+
title: err instanceof Error ? err.message : t('common.error'),
|
|
86
|
+
}),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const body =
|
|
90
|
+
hasAddress && location ? (
|
|
91
|
+
<AddressBlock address={stockLocationToAddressBlock(location)} />
|
|
92
|
+
) : (
|
|
93
|
+
<p className={failed ? 'text-destructive text-sm' : 'text-muted-foreground text-sm'}>
|
|
94
|
+
{t(failed ? 'common.error' : 'profile.address_not_provided')}
|
|
95
|
+
</p>
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const editButton = (
|
|
99
|
+
<Button
|
|
100
|
+
variant={hasAddress ? 'outline' : 'default'}
|
|
101
|
+
size="sm"
|
|
102
|
+
disabled={!location}
|
|
103
|
+
onClick={() => setEditing(true)}
|
|
104
|
+
>
|
|
105
|
+
{hasAddress ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />}
|
|
106
|
+
{hasAddress ? t('profile.edit') : t('profile.add_address')}
|
|
107
|
+
</Button>
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const dialog = editing && location && (
|
|
111
|
+
<AddressFormDialog
|
|
112
|
+
title={title}
|
|
113
|
+
address={stockLocationToEditableAddress(location)}
|
|
114
|
+
open
|
|
115
|
+
business
|
|
116
|
+
onOpenChange={setEditing}
|
|
117
|
+
onSave={(values) => save.mutate(editableAddressToStockLocationParams(values))}
|
|
118
|
+
isPending={save.isPending}
|
|
119
|
+
/>
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if (headless) {
|
|
123
|
+
return (
|
|
124
|
+
<div className="flex flex-col items-start gap-3">
|
|
125
|
+
{body}
|
|
126
|
+
{editButton}
|
|
127
|
+
{dialog}
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<>
|
|
134
|
+
<Card>
|
|
135
|
+
<CardHeader>
|
|
136
|
+
<CardTitle>
|
|
137
|
+
{title}
|
|
138
|
+
{hasAddress ? (
|
|
139
|
+
<StatusBadge status="complete" label={t('profile.address_filled')} />
|
|
140
|
+
) : (
|
|
141
|
+
<Badge variant="outline">{t('profile.address_missing')}</Badge>
|
|
142
|
+
)}
|
|
143
|
+
</CardTitle>
|
|
144
|
+
<CardAction>{editButton}</CardAction>
|
|
145
|
+
</CardHeader>
|
|
146
|
+
<CardContent>{body}</CardContent>
|
|
147
|
+
</Card>
|
|
148
|
+
{dialog}
|
|
149
|
+
</>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { getInitials } from '@spree/dashboard-core'
|
|
2
|
+
import {
|
|
3
|
+
Avatar,
|
|
4
|
+
AvatarFallback,
|
|
5
|
+
AvatarImage,
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
SidebarMenu,
|
|
11
|
+
SidebarMenuItem,
|
|
12
|
+
Skeleton,
|
|
13
|
+
useSidebar,
|
|
14
|
+
} from '@spree/dashboard-ui'
|
|
15
|
+
import { CheckIcon, ChevronsUpDownIcon } from '@spree/dashboard-ui/icons'
|
|
16
|
+
import { useQuery } from '@tanstack/react-query'
|
|
17
|
+
import { Link, useParams } from '@tanstack/react-router'
|
|
18
|
+
import { useTranslation } from 'react-i18next'
|
|
19
|
+
import { sellerClient } from '../api-client'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sidebar seller header, mirroring the operator's store switcher.
|
|
23
|
+
*
|
|
24
|
+
* A plain, non-interactive block when the signed-in person runs one seller —
|
|
25
|
+
* which is most of them — and a dropdown listing every seller they hold a
|
|
26
|
+
* role on once there are two or more. Switching is a link to the other
|
|
27
|
+
* seller's route, so the URL stays the source of truth for who is being
|
|
28
|
+
* acted as, exactly as the store switcher navigates by store id.
|
|
29
|
+
*
|
|
30
|
+
* Replaces the fixed "Seller panel" label, which told a seller something
|
|
31
|
+
* they already knew and left their own name off the one piece of chrome
|
|
32
|
+
* that is always on screen.
|
|
33
|
+
*/
|
|
34
|
+
export function SellerSwitcher() {
|
|
35
|
+
const { t } = useTranslation()
|
|
36
|
+
const { isMobile, state } = useSidebar()
|
|
37
|
+
const isCollapsed = state === 'collapsed'
|
|
38
|
+
const { sellerId } = useParams({ strict: false }) as { sellerId?: string }
|
|
39
|
+
|
|
40
|
+
// `/me` for the list, the profile for the current seller's own logo — the
|
|
41
|
+
// summary in `/me` carries no image, and the profile query is already
|
|
42
|
+
// loaded by the page behind this.
|
|
43
|
+
const { data: me, isLoading } = useQuery({
|
|
44
|
+
queryKey: ['seller', 'me'],
|
|
45
|
+
queryFn: () => sellerClient().me(),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const { data: profile } = useQuery({
|
|
49
|
+
queryKey: ['seller', sellerId, 'profile'],
|
|
50
|
+
queryFn: () => sellerClient().profile.get(),
|
|
51
|
+
enabled: Boolean(sellerId),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
if (isLoading) return <Skeleton className="h-rail-header-height w-full rounded-xl" />
|
|
55
|
+
|
|
56
|
+
const sellers = me?.sellers ?? []
|
|
57
|
+
const current = sellers.find((seller) => seller.id === sellerId) ?? sellers[0]
|
|
58
|
+
|
|
59
|
+
// Capped at two characters — see the store switcher: one letter per word
|
|
60
|
+
// overflows the avatar as soon as a name runs past two words.
|
|
61
|
+
const initials = getInitials(current?.name, '?')
|
|
62
|
+
|
|
63
|
+
const header = (
|
|
64
|
+
<>
|
|
65
|
+
<Avatar>
|
|
66
|
+
{profile?.square_logo_url || profile?.logo_url ? (
|
|
67
|
+
<AvatarImage src={profile.square_logo_url ?? profile.logo_url ?? undefined} />
|
|
68
|
+
) : null}
|
|
69
|
+
<AvatarFallback>{initials}</AvatarFallback>
|
|
70
|
+
</Avatar>
|
|
71
|
+
{!isCollapsed && (
|
|
72
|
+
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
73
|
+
<span className="truncate font-medium text-foreground">{current?.name}</span>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
</>
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if (sellers.length < 2) {
|
|
80
|
+
return (
|
|
81
|
+
<SidebarMenu>
|
|
82
|
+
<SidebarMenuItem className="flex h-rail-header-height items-center">
|
|
83
|
+
<div className="flex w-full items-center gap-2 p-1.5">{header}</div>
|
|
84
|
+
</SidebarMenuItem>
|
|
85
|
+
</SidebarMenu>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<SidebarMenu>
|
|
91
|
+
<SidebarMenuItem className="flex h-rail-header-height items-center">
|
|
92
|
+
<DropdownMenu>
|
|
93
|
+
<DropdownMenuTrigger asChild className="flex w-full items-center">
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
aria-label={t('seller_switcher.label')}
|
|
97
|
+
className="gap-2 rounded-xl p-1.5 outline-hidden transition-colors duration-100 hover:bg-sidebar-accent data-[state=open]:bg-sidebar-accent"
|
|
98
|
+
>
|
|
99
|
+
{header}
|
|
100
|
+
{!isCollapsed && (
|
|
101
|
+
<ChevronsUpDownIcon className="ml-auto size-4 text-muted-foreground" />
|
|
102
|
+
)}
|
|
103
|
+
</button>
|
|
104
|
+
</DropdownMenuTrigger>
|
|
105
|
+
<DropdownMenuContent
|
|
106
|
+
className="min-w-48"
|
|
107
|
+
side={isMobile ? 'bottom' : 'right'}
|
|
108
|
+
align="start"
|
|
109
|
+
sideOffset={8}
|
|
110
|
+
>
|
|
111
|
+
{sellers.map((seller) => (
|
|
112
|
+
<DropdownMenuItem key={seller.id} asChild>
|
|
113
|
+
<Link to="/$sellerId" params={{ sellerId: seller.id }} className="no-underline">
|
|
114
|
+
<span className="flex-1 truncate">{seller.name}</span>
|
|
115
|
+
{seller.id === current?.id && <CheckIcon className="size-4" />}
|
|
116
|
+
</Link>
|
|
117
|
+
</DropdownMenuItem>
|
|
118
|
+
))}
|
|
119
|
+
</DropdownMenuContent>
|
|
120
|
+
</DropdownMenu>
|
|
121
|
+
</SidebarMenuItem>
|
|
122
|
+
</SidebarMenu>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mapSpreeErrorsToForm } from '@spree/dashboard-core'
|
|
2
|
+
import { TaxIdentifiersCard } from '@spree/dashboard-ui'
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
4
|
+
import { useParams } from '@tanstack/react-router'
|
|
5
|
+
import { sellerClient } from '../api-client'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The seller's own tax registrations, rendered by the same panel a company's
|
|
9
|
+
* are managed from: a seller is a business like any other, and two surfaces
|
|
10
|
+
* for one job is how they come to behave differently.
|
|
11
|
+
*/
|
|
12
|
+
export function SellerTaxIdentifiersCard() {
|
|
13
|
+
const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
|
|
14
|
+
const queryClient = useQueryClient()
|
|
15
|
+
const queryKey = ['seller', sellerId, 'tax_identifiers']
|
|
16
|
+
|
|
17
|
+
const { data, isLoading } = useQuery({
|
|
18
|
+
queryKey,
|
|
19
|
+
queryFn: () => sellerClient().taxIdentifiers.list(),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// A registration can satisfy an onboarding requirement, so the checklist has
|
|
23
|
+
// to re-evaluate whenever one changes.
|
|
24
|
+
function refresh() {
|
|
25
|
+
void queryClient.invalidateQueries({ queryKey })
|
|
26
|
+
void queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'onboarding'] })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const create = useMutation({
|
|
30
|
+
mutationFn: (params: { kind: string; value: string }) =>
|
|
31
|
+
sellerClient().taxIdentifiers.create(params),
|
|
32
|
+
onSuccess: refresh,
|
|
33
|
+
})
|
|
34
|
+
const update = useMutation({
|
|
35
|
+
mutationFn: ({ id, params }: { id: string; params: { kind: string; value: string } }) =>
|
|
36
|
+
sellerClient().taxIdentifiers.update(id, params),
|
|
37
|
+
onSuccess: refresh,
|
|
38
|
+
})
|
|
39
|
+
const remove = useMutation({
|
|
40
|
+
mutationFn: (id: string) => sellerClient().taxIdentifiers.remove(id),
|
|
41
|
+
onSuccess: refresh,
|
|
42
|
+
})
|
|
43
|
+
const validate = useMutation({
|
|
44
|
+
mutationFn: (id: string) => sellerClient().taxIdentifiers.validate(id),
|
|
45
|
+
onSuccess: refresh,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<TaxIdentifiersCard
|
|
50
|
+
identifiers={data?.data ?? []}
|
|
51
|
+
isLoading={isLoading}
|
|
52
|
+
canEdit
|
|
53
|
+
mutations={{
|
|
54
|
+
create: (params) => create.mutateAsync(params),
|
|
55
|
+
update: (id, params) => update.mutateAsync({ id, params }),
|
|
56
|
+
remove: (id) => remove.mutateAsync(id),
|
|
57
|
+
validate: (id) => validate.mutateAsync(id),
|
|
58
|
+
isValidating: validate.isPending,
|
|
59
|
+
mapErrors: mapSpreeErrorsToForm,
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AnyRoute, RouterConstructorOptions, RouterHistory } from '@tanstack/react-router'
|
|
2
|
+
import { createRouter } from '@tanstack/react-router'
|
|
3
|
+
|
|
4
|
+
export interface SellerRouterOptions {
|
|
5
|
+
/** Mirrors Vite's `base` so sub-path mounts route correctly. */
|
|
6
|
+
basepath?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Builds the panel's router from a route tree.
|
|
11
|
+
*
|
|
12
|
+
* Hosts own their entry point and pass the tree in, exactly as they do for
|
|
13
|
+
* the operator's dashboard — so a marketplace can add its own seller-facing
|
|
14
|
+
* pages without forking this package.
|
|
15
|
+
*/
|
|
16
|
+
export function createSellerRouter<TRouteTree extends AnyRoute>(
|
|
17
|
+
routeTree: TRouteTree,
|
|
18
|
+
{ basepath }: SellerRouterOptions = {},
|
|
19
|
+
) {
|
|
20
|
+
// Cast because the shell's root route requires no router context, which
|
|
21
|
+
// TypeScript cannot prove through the generic tree parameter. The return
|
|
22
|
+
// type stays parameterized by TRouteTree — that's what keeps links typed.
|
|
23
|
+
const options = { routeTree, basepath } as RouterConstructorOptions<
|
|
24
|
+
TRouteTree,
|
|
25
|
+
'never',
|
|
26
|
+
false,
|
|
27
|
+
RouterHistory,
|
|
28
|
+
Record<string, unknown>
|
|
29
|
+
>
|
|
30
|
+
|
|
31
|
+
return createRouter(options)
|
|
32
|
+
}
|