@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,15 @@
|
|
|
1
|
+
import { type ResourceSearch, resourceSearchSchema } from '@spree/dashboard-core'
|
|
2
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
3
|
+
import { PayoutsPage } from '../../../../pages/payouts'
|
|
4
|
+
|
|
5
|
+
/** Settlements to this seller — what has been sent, and what is owed. */
|
|
6
|
+
export const Route = createFileRoute('/_authenticated/$sellerId/payouts/')({
|
|
7
|
+
validateSearch: resourceSearchSchema,
|
|
8
|
+
component: PayoutsRoute,
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
function PayoutsRoute() {
|
|
12
|
+
const search = Route.useSearch() as ResourceSearch
|
|
13
|
+
|
|
14
|
+
return <PayoutsPage search={search} />
|
|
15
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ResourceSearch, resourceSearchSchema } from '@spree/dashboard-core'
|
|
2
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
import { ProductsPage } from '../../../../pages/products'
|
|
5
|
+
|
|
6
|
+
// `import` carries the prefixed id of the import whose wizard is open over the
|
|
7
|
+
// table — deep-linkable and refresh-safe, which is what lets the import-done
|
|
8
|
+
// email link straight back into it.
|
|
9
|
+
const productsSearchSchema = resourceSearchSchema.extend({
|
|
10
|
+
import: z.string().optional(),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export const Route = createFileRoute('/_authenticated/$sellerId/products/')({
|
|
14
|
+
validateSearch: productsSearchSchema,
|
|
15
|
+
component: ProductsRoute,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function ProductsRoute() {
|
|
19
|
+
// Cast: the inferred type unions with the parent layout's shape.
|
|
20
|
+
const search = Route.useSearch() as ResourceSearch & { import?: string }
|
|
21
|
+
|
|
22
|
+
return <ProductsPage search={search} />
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ResourceSearch, resourceSearchSchema } from '@spree/dashboard-core'
|
|
2
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
3
|
+
import { DeliveryMethodsPage } from '../../../../pages/delivery-methods'
|
|
4
|
+
|
|
5
|
+
export const Route = createFileRoute('/_authenticated/$sellerId/settings/delivery-methods')({
|
|
6
|
+
validateSearch: resourceSearchSchema,
|
|
7
|
+
component: DeliveryMethodsRoute,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
function DeliveryMethodsRoute() {
|
|
11
|
+
const search = Route.useSearch() as ResourceSearch
|
|
12
|
+
|
|
13
|
+
return <DeliveryMethodsPage search={search} />
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { SettingsIndexPage } from '@spree/dashboard-core'
|
|
2
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
3
|
+
|
|
4
|
+
export const Route = createFileRoute('/_authenticated/$sellerId/settings/')({
|
|
5
|
+
component: SettingsIndexRoute,
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The same landing page the operator's dashboard renders: a card grid of every
|
|
10
|
+
* settings area this member can reach. On a narrow viewport the settings rail
|
|
11
|
+
* is hidden, so this grid is the only way into the area — it must not redirect
|
|
12
|
+
* to a specific page.
|
|
13
|
+
*/
|
|
14
|
+
function SettingsIndexRoute() {
|
|
15
|
+
const { sellerId } = Route.useParams()
|
|
16
|
+
|
|
17
|
+
return <SettingsIndexPage tenantId={sellerId} />
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { PackageTypesPage, packageTypesSearchSchema } from '@spree/dashboard-core'
|
|
2
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
3
|
+
import type { z } from 'zod/v4'
|
|
4
|
+
|
|
5
|
+
export const Route = createFileRoute('/_authenticated/$sellerId/settings/package-types')({
|
|
6
|
+
validateSearch: packageTypesSearchSchema,
|
|
7
|
+
component: PackageTypesRoute,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The same page the operator's dashboard renders, against this seller's own
|
|
12
|
+
* packaging — the Seller API scopes it, so nothing here has to.
|
|
13
|
+
*
|
|
14
|
+
* No owner column: the list is this seller's rows plus the marketplace's
|
|
15
|
+
* shared packaging, and the read-only rows already say which is which.
|
|
16
|
+
*/
|
|
17
|
+
function PackageTypesRoute() {
|
|
18
|
+
const search = Route.useSearch() as z.infer<typeof packageTypesSearchSchema>
|
|
19
|
+
|
|
20
|
+
return <PackageTypesPage search={search} />
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ResourceSearch, resourceSearchSchema } from '@spree/dashboard-core'
|
|
2
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
3
|
+
import { PoliciesPage } from '../../../../pages/policies'
|
|
4
|
+
|
|
5
|
+
export const Route = createFileRoute('/_authenticated/$sellerId/settings/policies')({
|
|
6
|
+
validateSearch: resourceSearchSchema,
|
|
7
|
+
component: PoliciesRoute,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
function PoliciesRoute() {
|
|
11
|
+
const search = Route.useSearch() as ResourceSearch
|
|
12
|
+
|
|
13
|
+
return <PoliciesPage search={search} />
|
|
14
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { StockLocationsPage, stockLocationsSearchSchema } from '@spree/dashboard-core'
|
|
2
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
3
|
+
import type { z } from 'zod/v4'
|
|
4
|
+
|
|
5
|
+
export const Route = createFileRoute('/_authenticated/$sellerId/settings/stock-locations')({
|
|
6
|
+
validateSearch: stockLocationsSearchSchema,
|
|
7
|
+
component: StockLocationsRoute,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The same page the operator's dashboard renders, against this seller's own
|
|
12
|
+
* locations — the Seller API scopes them, so nothing here has to.
|
|
13
|
+
*
|
|
14
|
+
* No stock-levels panel: editing on-hand counts reads the Admin API and links
|
|
15
|
+
* to the operator's product pages. A seller's stock lives on their products,
|
|
16
|
+
* which is where the panel will edit it.
|
|
17
|
+
*/
|
|
18
|
+
function StockLocationsRoute() {
|
|
19
|
+
const search = Route.useSearch() as z.infer<typeof stockLocationsSearchSchema>
|
|
20
|
+
|
|
21
|
+
return <StockLocationsPage search={search} />
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
|
2
|
+
|
|
3
|
+
export const Route = createFileRoute('/_authenticated/$sellerId/settings')({
|
|
4
|
+
component: SettingsLayout,
|
|
5
|
+
})
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Content padding for the settings area. The rail itself lives in the chrome,
|
|
9
|
+
* one level up, so it can span full height beside the top bar — this layout
|
|
10
|
+
* owns only the right-hand column, matching the dashboard's settings layout.
|
|
11
|
+
*/
|
|
12
|
+
function SettingsLayout() {
|
|
13
|
+
return (
|
|
14
|
+
<div className="container mx-auto flex flex-1 flex-col gap-4 p-4 lg:p-6">
|
|
15
|
+
<Outlet />
|
|
16
|
+
</div>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { usePermissions } from '@spree/dashboard-core'
|
|
2
|
+
import { createFileRoute, Outlet, useParams } from '@tanstack/react-router'
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import { rememberSeller, setActiveSeller } from '../../api-client'
|
|
5
|
+
import { PanelChrome } from '../../panel-chrome'
|
|
6
|
+
|
|
7
|
+
export const Route = createFileRoute('/_authenticated/$sellerId')({
|
|
8
|
+
component: SellerLayout,
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Everything below this route acts as one seller.
|
|
13
|
+
*
|
|
14
|
+
* The id in the URL is the single source of truth — the client header follows
|
|
15
|
+
* it, so a seller can keep two sellers open in two tabs without one stealing
|
|
16
|
+
* the other's session, and a bookmarked page opens the seller it belongs to.
|
|
17
|
+
*/
|
|
18
|
+
function SellerLayout() {
|
|
19
|
+
const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
|
|
20
|
+
const { refresh: refreshPermissions } = usePermissions()
|
|
21
|
+
|
|
22
|
+
// Synchronously, not in an effect: children render (and fetch) before
|
|
23
|
+
// effects run, and a request sent under the previous seller's header would
|
|
24
|
+
// load the wrong seller's data into this one's screen.
|
|
25
|
+
setActiveSeller(sellerId)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
rememberSeller(sellerId)
|
|
29
|
+
}, [sellerId])
|
|
30
|
+
|
|
31
|
+
// Capability is per seller, so the permission provider's own fetch — which
|
|
32
|
+
// runs the moment authentication succeeds, before any seller is chosen —
|
|
33
|
+
// comes back with nothing and every `<Can>` and permission-gated nav entry
|
|
34
|
+
// reads as denied. That is why the sidebar was empty until a refresh.
|
|
35
|
+
//
|
|
36
|
+
// Unlike the operator's dashboard, this does NOT skip its first run: there
|
|
37
|
+
// the initial fetch is already correct for the initial store, here it never
|
|
38
|
+
// is. Re-runs on `sellerId` so switching sellers reloads capability too.
|
|
39
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: sellerId drives the seller-switch re-run
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
void refreshPermissions()
|
|
42
|
+
}, [sellerId, refreshPermissions])
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<PanelChrome>
|
|
46
|
+
<Outlet />
|
|
47
|
+
</PanelChrome>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import { useTranslation } from 'react-i18next'
|
|
5
|
+
import { rememberedSeller, sellerClient } from '../../api-client'
|
|
6
|
+
import { CenteredMessage } from '../../components/centered-message'
|
|
7
|
+
import { SellerPicker } from '../../pages/seller-picker'
|
|
8
|
+
|
|
9
|
+
export const Route = createFileRoute('/_authenticated/')({
|
|
10
|
+
component: SellerEntry,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where a signed-in seller lands.
|
|
15
|
+
*
|
|
16
|
+
* Unlike the operator's dashboard, which always redirects into a store, a
|
|
17
|
+
* seller may genuinely run several sellers — so the picker is a real page.
|
|
18
|
+
* It is skipped in the two cases where the answer is already known: a
|
|
19
|
+
* remembered choice from last session, or exactly one seller.
|
|
20
|
+
*/
|
|
21
|
+
function SellerEntry() {
|
|
22
|
+
const { t } = useTranslation()
|
|
23
|
+
const navigate = useNavigate()
|
|
24
|
+
|
|
25
|
+
const { data, isLoading, error } = useQuery({
|
|
26
|
+
queryKey: ['seller', 'me'],
|
|
27
|
+
queryFn: () => sellerClient().me(),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const sellers = data?.sellers ?? []
|
|
31
|
+
const remembered = rememberedSeller()
|
|
32
|
+
// A remembered id is only honoured if the seller still runs that seller —
|
|
33
|
+
// access can be revoked between sessions, and following a stale id would
|
|
34
|
+
// land them on a page that 403s.
|
|
35
|
+
const target =
|
|
36
|
+
(remembered && sellers.some((seller) => seller.id === remembered) ? remembered : null) ??
|
|
37
|
+
(sellers.length === 1 ? sellers[0].id : null)
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (target) navigate({ to: '/$sellerId', params: { sellerId: target }, replace: true })
|
|
41
|
+
}, [navigate, target])
|
|
42
|
+
|
|
43
|
+
if (isLoading) return <CenteredMessage>{t('common.loading')}</CenteredMessage>
|
|
44
|
+
if (error) return <CenteredMessage>{t('common.error')}</CenteredMessage>
|
|
45
|
+
if (target) return null
|
|
46
|
+
|
|
47
|
+
return <SellerPicker />
|
|
48
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useAuth } from '@spree/dashboard-core'
|
|
2
|
+
import { createFileRoute, Navigate, Outlet } from '@tanstack/react-router'
|
|
3
|
+
|
|
4
|
+
export const Route = createFileRoute('/_authenticated')({
|
|
5
|
+
component: AuthenticatedLayout,
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
function AuthenticatedLayout() {
|
|
9
|
+
const { isInitializing, isAuthenticated } = useAuth()
|
|
10
|
+
if (isInitializing) return null
|
|
11
|
+
if (!isAuthenticated) return <Navigate to="/login" replace />
|
|
12
|
+
return <Outlet />
|
|
13
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { z } from 'zod/v4'
|
|
3
|
+
import { AcceptInvitationPage } from '../pages/accept-invitation'
|
|
4
|
+
|
|
5
|
+
const acceptSearchSchema = z.object({
|
|
6
|
+
token: z.string().min(1).optional(),
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
export const Route = createFileRoute('/accept-invitation/$invitationId')({
|
|
10
|
+
validateSearch: acceptSearchSchema,
|
|
11
|
+
component: AcceptInvitation,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
function AcceptInvitation() {
|
|
15
|
+
const { invitationId } = Route.useParams()
|
|
16
|
+
const { token } = Route.useSearch()
|
|
17
|
+
|
|
18
|
+
return <AcceptInvitationPage invitationId={invitationId} token={token} />
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useAuth } from '@spree/dashboard-core'
|
|
2
|
+
import { createFileRoute, Navigate } from '@tanstack/react-router'
|
|
3
|
+
import { ForgotPasswordPage } from '../pages/forgot-password'
|
|
4
|
+
|
|
5
|
+
export const Route = createFileRoute('/forgot-password')({
|
|
6
|
+
component: ForgotPassword,
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
function ForgotPassword() {
|
|
10
|
+
const { isInitializing, isAuthenticated } = useAuth()
|
|
11
|
+
if (isInitializing) return null
|
|
12
|
+
// Someone already signed in has no use for a reset link.
|
|
13
|
+
if (isAuthenticated) return <Navigate to="/" replace />
|
|
14
|
+
return <ForgotPasswordPage />
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useAuth } from '@spree/dashboard-core'
|
|
2
|
+
import { createFileRoute, Navigate } from '@tanstack/react-router'
|
|
3
|
+
import { LoginPage } from '../pages/login'
|
|
4
|
+
|
|
5
|
+
export const Route = createFileRoute('/login')({
|
|
6
|
+
component: Login,
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
function Login() {
|
|
10
|
+
const { isInitializing, isAuthenticated } = useAuth()
|
|
11
|
+
if (isInitializing) return null
|
|
12
|
+
// A live session landing on /login goes where it was headed instead.
|
|
13
|
+
if (isAuthenticated) return <Navigate to="/" replace />
|
|
14
|
+
return <LoginPage />
|
|
15
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { z } from 'zod/v4'
|
|
3
|
+
import { ResetPasswordPage } from '../pages/reset-password'
|
|
4
|
+
|
|
5
|
+
const resetSearchSchema = z.object({
|
|
6
|
+
token: z.string().min(1).optional(),
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
export const Route = createFileRoute('/reset-password')({
|
|
10
|
+
validateSearch: resetSearchSchema,
|
|
11
|
+
component: ResetPassword,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
function ResetPassword() {
|
|
15
|
+
const { token } = Route.useSearch()
|
|
16
|
+
|
|
17
|
+
// No redirect for a live session: someone following a reset link means to
|
|
18
|
+
// change their password, and a stale session in the same browser must not
|
|
19
|
+
// silently swallow the link.
|
|
20
|
+
return <ResetPasswordPage token={token} />
|
|
21
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from 'zod/v4'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the seller chose to send out of one parcel: per-line quantities plus
|
|
5
|
+
* the tracking number and whether the customer hears about it.
|
|
6
|
+
*
|
|
7
|
+
* Every line the parcel holds gets a row, so an unselected row means "leave
|
|
8
|
+
* this one behind" rather than being absent — the payload drops the
|
|
9
|
+
* unselected rows on submit.
|
|
10
|
+
*
|
|
11
|
+
* The upper bound is per row (how many units that parcel holds), so it lives
|
|
12
|
+
* on the field rather than in the schema; the schema only enforces that a
|
|
13
|
+
* quantity is a whole number that is not negative. Sending nothing at all is
|
|
14
|
+
* prevented by disabling the submit, since it is a no-op rather than a
|
|
15
|
+
* validation failure.
|
|
16
|
+
*/
|
|
17
|
+
export const fulfillItemsFormSchema = z.object({
|
|
18
|
+
items: z.array(
|
|
19
|
+
z.object({
|
|
20
|
+
item_id: z.string(),
|
|
21
|
+
selected: z.boolean(),
|
|
22
|
+
quantity: z.number().int().min(0),
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
tracking: z.string(),
|
|
26
|
+
// '' = detect from the number; slugs come from trackingCarriers.list().
|
|
27
|
+
tracking_carrier: z.string(),
|
|
28
|
+
notify_customer: z.boolean(),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export type FulfillItemsFormValues = z.infer<typeof fulfillItemsFormSchema>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { AuthProvider, PermissionProvider, queryClient } from '@spree/dashboard-core'
|
|
2
|
+
import { ConfirmProvider, ThemeProvider, TooltipProvider } from '@spree/dashboard-ui'
|
|
3
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
4
|
+
import type { AnyRouter } from '@tanstack/react-router'
|
|
5
|
+
import { RouterProvider } from '@tanstack/react-router'
|
|
6
|
+
import { StrictMode } from 'react'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The seller panel shell.
|
|
10
|
+
*
|
|
11
|
+
* The same providers as the marketplace operator's dashboard — that is what
|
|
12
|
+
* `@spree/dashboard-core` and `@spree/dashboard-ui` are for — pointed at the
|
|
13
|
+
* Seller API. The host registers that client before rendering:
|
|
14
|
+
*
|
|
15
|
+
* // src/main.tsx
|
|
16
|
+
* import { createRoot } from 'react-dom/client'
|
|
17
|
+
* import { SellerDashboard, createSellerRouter } from '@spree/seller-dashboard'
|
|
18
|
+
* import './api-client' // registers the Seller API client
|
|
19
|
+
* import './styles.css'
|
|
20
|
+
* import { routeTree } from './routeTree.gen'
|
|
21
|
+
*
|
|
22
|
+
* const router = createSellerRouter(routeTree)
|
|
23
|
+
*
|
|
24
|
+
* declare module '@tanstack/react-router' {
|
|
25
|
+
* interface Register {
|
|
26
|
+
* router: typeof router
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* createRoot(document.getElementById('root')!).render(<SellerDashboard router={router} />)
|
|
31
|
+
*/
|
|
32
|
+
export function SellerDashboard({ router }: { router: AnyRouter }) {
|
|
33
|
+
return (
|
|
34
|
+
<StrictMode>
|
|
35
|
+
<QueryClientProvider client={queryClient}>
|
|
36
|
+
<ThemeProvider>
|
|
37
|
+
<TooltipProvider>
|
|
38
|
+
<ConfirmProvider>
|
|
39
|
+
<AuthProvider>
|
|
40
|
+
<PermissionProvider>
|
|
41
|
+
<RouterProvider router={router} />
|
|
42
|
+
</PermissionProvider>
|
|
43
|
+
</AuthProvider>
|
|
44
|
+
</ConfirmProvider>
|
|
45
|
+
</TooltipProvider>
|
|
46
|
+
</ThemeProvider>
|
|
47
|
+
</QueryClientProvider>
|
|
48
|
+
</StrictMode>
|
|
49
|
+
)
|
|
50
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* Seller panel CSS entry.
|
|
2
|
+
*
|
|
3
|
+
* Pulls in the design system stylesheet from `@spree/dashboard-ui` — the same
|
|
4
|
+
* components and the same look as the marketplace operator's dashboard, with
|
|
5
|
+
* a different API behind them.
|
|
6
|
+
*
|
|
7
|
+
* `@source` directives for this package and `@spree/dashboard-core` are
|
|
8
|
+
* appended at build time by `spreeSellerDashboardPlugin` from
|
|
9
|
+
* `@spree/seller-dashboard/vite`. Resolution goes through Node module
|
|
10
|
+
* resolution, so paths stay correct under any package layout (workspace
|
|
11
|
+
* symlinks, npm tarballs, pnpm's `.pnpm/` hoisting) — never hardcode them
|
|
12
|
+
* relative to this file.
|
|
13
|
+
*
|
|
14
|
+
* The Vite plugin bundles `@tailwindcss/vite` internally, so host apps must
|
|
15
|
+
* NOT register `tailwindcss()` separately.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
@import "@spree/dashboard-ui/styles.css";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { defineTable } from '@spree/dashboard-core'
|
|
2
|
+
import { Badge, ResourceNameCell } from '@spree/dashboard-ui'
|
|
3
|
+
import { TruckIcon } from '@spree/dashboard-ui/icons'
|
|
4
|
+
import type { DeliveryMethod } from '@spree/seller-sdk'
|
|
5
|
+
import i18n from 'i18next'
|
|
6
|
+
|
|
7
|
+
defineTable<DeliveryMethod>('seller-delivery-methods', {
|
|
8
|
+
title: i18n.t('delivery_methods.title'),
|
|
9
|
+
searchParam: 'name_cont',
|
|
10
|
+
searchPlaceholder: i18n.t('delivery_methods.search_placeholder'),
|
|
11
|
+
defaultSort: { field: 'name', direction: 'asc' },
|
|
12
|
+
emptyIcon: <TruckIcon className="size-8 text-muted-foreground" />,
|
|
13
|
+
emptyMessage: i18n.t('delivery_methods.empty.title'),
|
|
14
|
+
columns: [
|
|
15
|
+
{
|
|
16
|
+
key: 'name',
|
|
17
|
+
label: i18n.t('delivery_methods.columns.name'),
|
|
18
|
+
sortable: true,
|
|
19
|
+
default: true,
|
|
20
|
+
// A marketplace method the operator merely shares renders as plain
|
|
21
|
+
// text: the API refuses every write against it, so making its name
|
|
22
|
+
// look clickable would open an editor whose Save could only 404.
|
|
23
|
+
render: (method) =>
|
|
24
|
+
method.editable ? (
|
|
25
|
+
<ResourceNameCell
|
|
26
|
+
id={method.id}
|
|
27
|
+
dataAttr="data-delivery-method-id"
|
|
28
|
+
name={method.name}
|
|
29
|
+
secondary={method.admin_name ?? undefined}
|
|
30
|
+
/>
|
|
31
|
+
) : (
|
|
32
|
+
<div className="flex flex-col">
|
|
33
|
+
<span className="font-medium">{method.name}</span>
|
|
34
|
+
{method.admin_name && (
|
|
35
|
+
<span className="text-muted-foreground text-xs">{method.admin_name}</span>
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
),
|
|
39
|
+
},
|
|
40
|
+
// Whose method it is. A marketplace method the operator shares is listed
|
|
41
|
+
// so a seller can see what already ships their goods, and cannot be
|
|
42
|
+
// changed here.
|
|
43
|
+
{
|
|
44
|
+
key: 'editable',
|
|
45
|
+
label: i18n.t('delivery_methods.columns.owner'),
|
|
46
|
+
default: true,
|
|
47
|
+
render: (method) => (
|
|
48
|
+
<Badge variant={method.editable ? 'default' : 'secondary'}>
|
|
49
|
+
{method.editable
|
|
50
|
+
? i18n.t('delivery_methods.owner.mine')
|
|
51
|
+
: i18n.t('delivery_methods.owner.marketplace')}
|
|
52
|
+
</Badge>
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
key: 'storefront_visible',
|
|
57
|
+
label: i18n.t('delivery_methods.columns.visibility'),
|
|
58
|
+
default: true,
|
|
59
|
+
render: (method) => (
|
|
60
|
+
<Badge variant={method.storefront_visible ? 'default' : 'outline'}>
|
|
61
|
+
{method.storefront_visible
|
|
62
|
+
? i18n.t('delivery_methods.visibility.storefront')
|
|
63
|
+
: i18n.t('delivery_methods.visibility.hidden')}
|
|
64
|
+
</Badge>
|
|
65
|
+
),
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { defineTable, formatStoreDateTime } from '@spree/dashboard-core'
|
|
2
|
+
import { ResourceNameCell, StatusBadge } from '@spree/dashboard-ui'
|
|
3
|
+
import { PackageIcon } from '@spree/dashboard-ui/icons'
|
|
4
|
+
import type { Order } from '@spree/seller-sdk'
|
|
5
|
+
import i18n from 'i18next'
|
|
6
|
+
import { useStoreTimezone } from '../hooks/use-store-timezone'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An order date in the marketplace's timezone.
|
|
10
|
+
*
|
|
11
|
+
* Its own component because a table definition is module-level and has no
|
|
12
|
+
* access to hooks, while `render` is called inside the table's JSX.
|
|
13
|
+
*/
|
|
14
|
+
function OrderDate({ iso }: { iso: string }) {
|
|
15
|
+
const timezone = useStoreTimezone()
|
|
16
|
+
|
|
17
|
+
return <>{formatStoreDateTime(iso, timezone)}</>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Mirrors the operator dashboard's order status vocabularies. */
|
|
21
|
+
const FULFILLMENT_STATUSES = [
|
|
22
|
+
'unfulfilled',
|
|
23
|
+
'backorder',
|
|
24
|
+
'partial',
|
|
25
|
+
'fulfilled',
|
|
26
|
+
'delivered',
|
|
27
|
+
'canceled',
|
|
28
|
+
] as const
|
|
29
|
+
const PAYMENT_STATUSES = [
|
|
30
|
+
'none',
|
|
31
|
+
'authorized',
|
|
32
|
+
'partially_paid',
|
|
33
|
+
'paid',
|
|
34
|
+
'partially_refunded',
|
|
35
|
+
'refunded',
|
|
36
|
+
'overcharged',
|
|
37
|
+
'voided',
|
|
38
|
+
] as const
|
|
39
|
+
|
|
40
|
+
defineTable<Order>('seller-orders', {
|
|
41
|
+
title: i18n.t('orders.title'),
|
|
42
|
+
searchParam: 'number_cont',
|
|
43
|
+
searchPlaceholder: i18n.t('orders.search_placeholder'),
|
|
44
|
+
defaultSort: { field: 'completed_at', direction: 'desc' },
|
|
45
|
+
emptyIcon: <PackageIcon className="size-8 text-muted-foreground" />,
|
|
46
|
+
emptyMessage: i18n.t('orders.empty'),
|
|
47
|
+
columns: [
|
|
48
|
+
{
|
|
49
|
+
key: 'number',
|
|
50
|
+
label: i18n.t('orders.columns.number'),
|
|
51
|
+
sortable: true,
|
|
52
|
+
default: true,
|
|
53
|
+
render: (order) => (
|
|
54
|
+
<ResourceNameCell id={order.id} dataAttr="data-order-id" name={order.number} />
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
key: 'completed_at',
|
|
59
|
+
label: i18n.t('orders.columns.placed'),
|
|
60
|
+
sortable: true,
|
|
61
|
+
filterable: true,
|
|
62
|
+
filterType: 'date',
|
|
63
|
+
quickFilter: true,
|
|
64
|
+
default: true,
|
|
65
|
+
render: (order) => (order.completed_at ? <OrderDate iso={order.completed_at} /> : '—'),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: 'fulfillment_status',
|
|
69
|
+
label: i18n.t('orders.columns.fulfillment'),
|
|
70
|
+
filterable: true,
|
|
71
|
+
filterType: 'enum',
|
|
72
|
+
filterOptions: FULFILLMENT_STATUSES.map((status) => ({
|
|
73
|
+
value: status,
|
|
74
|
+
label: i18n.t(`orders.fulfillment_statuses.${status}`),
|
|
75
|
+
})),
|
|
76
|
+
quickFilter: true,
|
|
77
|
+
default: true,
|
|
78
|
+
render: (order) =>
|
|
79
|
+
order.fulfillment_status ? (
|
|
80
|
+
<StatusBadge
|
|
81
|
+
status={order.fulfillment_status}
|
|
82
|
+
label={i18n.t(`orders.fulfillment_statuses.${order.fulfillment_status}`)}
|
|
83
|
+
/>
|
|
84
|
+
) : (
|
|
85
|
+
'—'
|
|
86
|
+
),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
key: 'payment_status',
|
|
90
|
+
label: i18n.t('orders.columns.payment'),
|
|
91
|
+
filterable: true,
|
|
92
|
+
filterType: 'enum',
|
|
93
|
+
filterOptions: PAYMENT_STATUSES.map((status) => ({
|
|
94
|
+
value: status,
|
|
95
|
+
label: i18n.t(`orders.payment_statuses.${status}`),
|
|
96
|
+
})),
|
|
97
|
+
quickFilter: true,
|
|
98
|
+
default: true,
|
|
99
|
+
render: (order) =>
|
|
100
|
+
order.payment_status ? (
|
|
101
|
+
<StatusBadge
|
|
102
|
+
status={order.payment_status}
|
|
103
|
+
label={i18n.t(`orders.payment_statuses.${order.payment_status}`)}
|
|
104
|
+
/>
|
|
105
|
+
) : (
|
|
106
|
+
'—'
|
|
107
|
+
),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
key: 'total',
|
|
111
|
+
label: i18n.t('orders.columns.total'),
|
|
112
|
+
default: true,
|
|
113
|
+
render: (order) => order.display_total as string,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
})
|