@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,100 @@
|
|
|
1
|
+
import { Button, Field, FieldError, FieldGroup, FieldLabel, Input } from '@spree/dashboard-ui'
|
|
2
|
+
import { Link } from '@tanstack/react-router'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { useForm } from 'react-hook-form'
|
|
5
|
+
import { useTranslation } from 'react-i18next'
|
|
6
|
+
import { sellerClient } from '../api-client'
|
|
7
|
+
|
|
8
|
+
interface ForgotValues {
|
|
9
|
+
email: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Asking for a reset link.
|
|
14
|
+
*
|
|
15
|
+
* Its own surface rather than the dashboard's: the link has to open the seller
|
|
16
|
+
* panel, and a seller sent to the staff dashboard would land on a form that
|
|
17
|
+
* resets them into an admin session they cannot use.
|
|
18
|
+
*/
|
|
19
|
+
export function ForgotPasswordPage() {
|
|
20
|
+
const { t } = useTranslation()
|
|
21
|
+
const [sent, setSent] = useState(false)
|
|
22
|
+
const [error, setError] = useState<string | null>(null)
|
|
23
|
+
const form = useForm<ForgotValues>({ defaultValues: { email: '' } })
|
|
24
|
+
|
|
25
|
+
async function onSubmit(values: ForgotValues) {
|
|
26
|
+
setError(null)
|
|
27
|
+
try {
|
|
28
|
+
await sellerClient().auth.requestPasswordReset({
|
|
29
|
+
email: values.email,
|
|
30
|
+
// Where this panel serves its reset page. The server honours it only
|
|
31
|
+
// if the origin is one the marketplace has vouched for, and resolves
|
|
32
|
+
// the panel origin itself otherwise.
|
|
33
|
+
redirect_url: `${window.location.origin}/reset-password`,
|
|
34
|
+
})
|
|
35
|
+
// Confirmed either way — the API never reveals whether the address
|
|
36
|
+
// matched, and neither may this page.
|
|
37
|
+
setSent(true)
|
|
38
|
+
} catch {
|
|
39
|
+
setError(t('forgot_password.failed'))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { errors, isSubmitting } = form.formState
|
|
44
|
+
|
|
45
|
+
if (sent) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex min-h-screen items-center justify-center p-6">
|
|
48
|
+
<div className="w-full max-w-sm">
|
|
49
|
+
<h1 className="font-medium text-xl">{t('forgot_password.sent_title')}</h1>
|
|
50
|
+
<p className="mt-1 mb-6 text-muted-foreground text-sm">
|
|
51
|
+
{t('forgot_password.sent_subtitle')}
|
|
52
|
+
</p>
|
|
53
|
+
<Link to="/login" className="text-sm underline underline-offset-4">
|
|
54
|
+
{t('forgot_password.back_to_login')}
|
|
55
|
+
</Link>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="flex min-h-screen items-center justify-center p-6">
|
|
63
|
+
<div className="w-full max-w-sm">
|
|
64
|
+
<h1 className="font-medium text-xl">{t('forgot_password.title')}</h1>
|
|
65
|
+
<p className="mt-1 mb-6 text-muted-foreground text-sm">{t('forgot_password.subtitle')}</p>
|
|
66
|
+
|
|
67
|
+
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
68
|
+
<FieldGroup>
|
|
69
|
+
{error && (
|
|
70
|
+
<p className="text-destructive text-sm" role="alert">
|
|
71
|
+
{error}
|
|
72
|
+
</p>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
<Field>
|
|
76
|
+
<FieldLabel htmlFor="email">{t('forgot_password.email')}</FieldLabel>
|
|
77
|
+
<Input
|
|
78
|
+
id="email"
|
|
79
|
+
type="email"
|
|
80
|
+
autoComplete="email"
|
|
81
|
+
autoFocus
|
|
82
|
+
aria-invalid={!!errors.email || undefined}
|
|
83
|
+
{...form.register('email', { required: true })}
|
|
84
|
+
/>
|
|
85
|
+
<FieldError errors={[errors.email]} />
|
|
86
|
+
</Field>
|
|
87
|
+
|
|
88
|
+
<Button type="submit" disabled={isSubmitting}>
|
|
89
|
+
{isSubmitting ? t('forgot_password.submitting') : t('forgot_password.submit')}
|
|
90
|
+
</Button>
|
|
91
|
+
|
|
92
|
+
<Link to="/login" className="text-sm underline underline-offset-4">
|
|
93
|
+
{t('forgot_password.back_to_login')}
|
|
94
|
+
</Link>
|
|
95
|
+
</FieldGroup>
|
|
96
|
+
</form>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { PageHeader, progressPercentage } from '@spree/dashboard-core'
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Card,
|
|
5
|
+
CardContent,
|
|
6
|
+
CardDescription,
|
|
7
|
+
CardHeader,
|
|
8
|
+
CardTitle,
|
|
9
|
+
Progress,
|
|
10
|
+
StatusBadge,
|
|
11
|
+
} from '@spree/dashboard-ui'
|
|
12
|
+
import { ChevronRightIcon, PackageIcon, UsersIcon } from '@spree/dashboard-ui/icons'
|
|
13
|
+
import { useQuery } from '@tanstack/react-query'
|
|
14
|
+
import { Link, useParams } from '@tanstack/react-router'
|
|
15
|
+
import type { ReactNode } from 'react'
|
|
16
|
+
import { useTranslation } from 'react-i18next'
|
|
17
|
+
import { sellerClient } from '../api-client'
|
|
18
|
+
import { CenteredMessage } from '../components/centered-message'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where a seller lands: how they stand with the marketplace, what is left to
|
|
22
|
+
* do, and the counts they would otherwise open a page to see.
|
|
23
|
+
*
|
|
24
|
+
* The operator's home is an analytics dashboard — a sales chart and top
|
|
25
|
+
* products. A seller has no analytics endpoint yet (orders are a later
|
|
26
|
+
* phase), and inventing widgets over data that does not exist would be worse
|
|
27
|
+
* than a smaller page that is true. This shows what the panel can actually
|
|
28
|
+
* answer today; the sales card lands with the orders surface.
|
|
29
|
+
*/
|
|
30
|
+
export function HomePage() {
|
|
31
|
+
const { t } = useTranslation()
|
|
32
|
+
const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
|
|
33
|
+
|
|
34
|
+
const { data: profile, isLoading } = useQuery({
|
|
35
|
+
queryKey: ['seller', sellerId, 'profile'],
|
|
36
|
+
queryFn: () => sellerClient().profile.get(),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const { data: onboarding } = useQuery({
|
|
40
|
+
queryKey: ['seller', sellerId, 'onboarding'],
|
|
41
|
+
queryFn: () => sellerClient().onboarding.get(),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const { data: team } = useQuery({
|
|
45
|
+
queryKey: ['seller', sellerId, 'team'],
|
|
46
|
+
queryFn: () => sellerClient().team.list(),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
if (isLoading) return <CenteredMessage>{t('common.loading')}</CenteredMessage>
|
|
50
|
+
if (!profile) return <CenteredMessage>{t('common.error')}</CenteredMessage>
|
|
51
|
+
|
|
52
|
+
const progress = onboarding?.progress
|
|
53
|
+
const setupDone = !progress || progress.total === 0 || progress.done === progress.total
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="flex flex-col gap-6">
|
|
57
|
+
<PageHeader title={t('home.title', { name: profile.name })} subtitle={t('home.subtitle')} />
|
|
58
|
+
|
|
59
|
+
{/* Setup leads while it is unfinished — until a seller is approved it is
|
|
60
|
+
the only thing on this page that matters. It drops away once done
|
|
61
|
+
rather than sitting at 100% forever. */}
|
|
62
|
+
{!setupDone && progress && (
|
|
63
|
+
<Card>
|
|
64
|
+
<CardHeader>
|
|
65
|
+
<CardTitle>{t('home.setup_title')}</CardTitle>
|
|
66
|
+
<CardDescription>
|
|
67
|
+
{t('home.setup_description', { done: progress.done, total: progress.total })}
|
|
68
|
+
</CardDescription>
|
|
69
|
+
</CardHeader>
|
|
70
|
+
<CardContent className="flex flex-col gap-4">
|
|
71
|
+
<Progress value={progressPercentage(progress)} />
|
|
72
|
+
<div>
|
|
73
|
+
<Button size="sm" asChild>
|
|
74
|
+
<Link to="/$sellerId/onboarding" params={{ sellerId }}>
|
|
75
|
+
{t('home.setup_cta')}
|
|
76
|
+
<ChevronRightIcon className="size-4" />
|
|
77
|
+
</Link>
|
|
78
|
+
</Button>
|
|
79
|
+
</div>
|
|
80
|
+
</CardContent>
|
|
81
|
+
</Card>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
<div className="grid gap-4 sm:grid-cols-3">
|
|
85
|
+
<StatCard label={t('home.status')}>
|
|
86
|
+
<StatusBadge status={profile.status} />
|
|
87
|
+
</StatCard>
|
|
88
|
+
<StatCard label={t('home.products')} icon={<PackageIcon className="size-4" />}>
|
|
89
|
+
{profile.products_count}
|
|
90
|
+
</StatCard>
|
|
91
|
+
<StatCard label={t('home.team')} icon={<UsersIcon className="size-4" />}>
|
|
92
|
+
{team?.data?.length ?? '—'}
|
|
93
|
+
</StatCard>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<Card>
|
|
97
|
+
<CardHeader>
|
|
98
|
+
<CardTitle>{t('home.selling_title')}</CardTitle>
|
|
99
|
+
</CardHeader>
|
|
100
|
+
<CardContent>
|
|
101
|
+
<p className="text-muted-foreground text-sm">
|
|
102
|
+
{profile.sellable ? t('home.selling_yes') : t('home.selling_no')}
|
|
103
|
+
</p>
|
|
104
|
+
</CardContent>
|
|
105
|
+
</Card>
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function StatCard({
|
|
111
|
+
label,
|
|
112
|
+
icon,
|
|
113
|
+
children,
|
|
114
|
+
}: {
|
|
115
|
+
label: string
|
|
116
|
+
icon?: ReactNode
|
|
117
|
+
children: ReactNode
|
|
118
|
+
}) {
|
|
119
|
+
return (
|
|
120
|
+
<Card>
|
|
121
|
+
<CardHeader className="pb-2">
|
|
122
|
+
<CardDescription className="flex items-center gap-2">
|
|
123
|
+
{icon}
|
|
124
|
+
{label}
|
|
125
|
+
</CardDescription>
|
|
126
|
+
</CardHeader>
|
|
127
|
+
<CardContent>
|
|
128
|
+
<div className="font-medium text-2xl">{children}</div>
|
|
129
|
+
</CardContent>
|
|
130
|
+
</Card>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useAuth } from '@spree/dashboard-core'
|
|
2
|
+
import { Button, Field, FieldError, FieldGroup, FieldLabel, Input } from '@spree/dashboard-ui'
|
|
3
|
+
import { Link } from '@tanstack/react-router'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import { useForm } from 'react-hook-form'
|
|
6
|
+
import { useTranslation } from 'react-i18next'
|
|
7
|
+
|
|
8
|
+
interface LoginValues {
|
|
9
|
+
email: string
|
|
10
|
+
password: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sign-in for sellers.
|
|
15
|
+
*
|
|
16
|
+
* Its own surface rather than the admin login: the two read different
|
|
17
|
+
* strategy registries against the same user class, so a marketplace can
|
|
18
|
+
* require SSO for its own staff while sellers still use a password.
|
|
19
|
+
*/
|
|
20
|
+
export function LoginPage() {
|
|
21
|
+
const { t } = useTranslation()
|
|
22
|
+
const { login } = useAuth()
|
|
23
|
+
const [error, setError] = useState<string | null>(null)
|
|
24
|
+
const form = useForm<LoginValues>({ defaultValues: { email: '', password: '' } })
|
|
25
|
+
|
|
26
|
+
async function onSubmit(values: LoginValues) {
|
|
27
|
+
setError(null)
|
|
28
|
+
try {
|
|
29
|
+
await login(values.email, values.password)
|
|
30
|
+
// Where to go next is the index route's decision — it already knows how
|
|
31
|
+
// to skip the picker for a seller who runs exactly one seller, and
|
|
32
|
+
// duplicating that here would leave two rules to keep in step.
|
|
33
|
+
} catch {
|
|
34
|
+
// The API refuses a staff member who runs no seller with the same
|
|
35
|
+
// status as a bad password, so the panel says the same thing: it must
|
|
36
|
+
// not become a way to discover which accounts exist.
|
|
37
|
+
setError(t('login.failed'))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { errors, isSubmitting } = form.formState
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="flex min-h-screen items-center justify-center p-6">
|
|
45
|
+
<div className="w-full max-w-sm">
|
|
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>
|
|
48
|
+
|
|
49
|
+
<form onSubmit={form.handleSubmit(onSubmit)}>
|
|
50
|
+
<FieldGroup>
|
|
51
|
+
{error && (
|
|
52
|
+
<p className="text-destructive text-sm" role="alert">
|
|
53
|
+
{error}
|
|
54
|
+
</p>
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
<Field>
|
|
58
|
+
<FieldLabel htmlFor="email">{t('login.email')}</FieldLabel>
|
|
59
|
+
<Input
|
|
60
|
+
id="email"
|
|
61
|
+
type="email"
|
|
62
|
+
autoComplete="email"
|
|
63
|
+
autoFocus
|
|
64
|
+
aria-invalid={!!errors.email || undefined}
|
|
65
|
+
{...form.register('email', { required: true })}
|
|
66
|
+
/>
|
|
67
|
+
<FieldError errors={[errors.email]} />
|
|
68
|
+
</Field>
|
|
69
|
+
|
|
70
|
+
<Field>
|
|
71
|
+
<FieldLabel htmlFor="password">{t('login.password')}</FieldLabel>
|
|
72
|
+
<Input
|
|
73
|
+
id="password"
|
|
74
|
+
type="password"
|
|
75
|
+
autoComplete="current-password"
|
|
76
|
+
aria-invalid={!!errors.password || undefined}
|
|
77
|
+
{...form.register('password', { required: true })}
|
|
78
|
+
/>
|
|
79
|
+
<FieldError errors={[errors.password]} />
|
|
80
|
+
</Field>
|
|
81
|
+
|
|
82
|
+
<Button type="submit" disabled={isSubmitting}>
|
|
83
|
+
{isSubmitting ? t('login.submitting') : t('login.submit')}
|
|
84
|
+
</Button>
|
|
85
|
+
|
|
86
|
+
<Link to="/forgot-password" className="text-sm underline underline-offset-4">
|
|
87
|
+
{t('login.forgot_password')}
|
|
88
|
+
</Link>
|
|
89
|
+
</FieldGroup>
|
|
90
|
+
</form>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|