@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vendo Connect Inc., Vendo Sp. z o.o.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @spree/seller-dashboard
|
|
2
|
+
|
|
3
|
+
The React seller panel for [Spree Commerce](https://spreecommerce.org) marketplaces — the surface a seller signs into to manage their products, orders and fulfillments. Built on the [Seller API](https://spreecommerce.org/docs/api-reference/seller-api/introduction) via `@spree/seller-sdk`.
|
|
4
|
+
|
|
5
|
+
> **Beta.** Published under the `beta` tag while Spree 6.0 is in beta, and
|
|
6
|
+
> promoted to `latest` at 6.0 GA. Install it explicitly:
|
|
7
|
+
>
|
|
8
|
+
> ```bash
|
|
9
|
+
> npm install @spree/seller-dashboard@beta
|
|
10
|
+
> ```
|
|
11
|
+
|
|
12
|
+
It shares its primitives with `@spree/dashboard` — components from `@spree/dashboard-ui`, framework pieces from `@spree/dashboard-core` — so the two panels stay visually and behaviourally consistent.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
Host it from a thin app that renders `<SellerDashboard />`. Scaffold one with:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx spree add seller-dashboard
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Rails serves the built bundle at `/sellers` when `SPREE_SELLER_PANEL_DIST_PATH` points at it — the Docker image built from the Spree starter does this for you.
|
|
23
|
+
|
|
24
|
+
## Documentation
|
|
25
|
+
|
|
26
|
+
- [Seller API reference](https://spreecommerce.org/docs/api-reference/seller-api/introduction) — the API this panel is built on
|
|
27
|
+
- [Spree developer documentation](https://spreecommerce.org/docs)
|
|
28
|
+
|
|
29
|
+
## License
|
|
30
|
+
|
|
31
|
+
MIT
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
spreeDashboardPlugin as spreeDashboardCorePlugin
|
|
5
|
+
} from "@spree/dashboard-core/vite";
|
|
6
|
+
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
|
7
|
+
import { index, layout, physical, rootRoute, route } from "@tanstack/virtual-file-routes";
|
|
8
|
+
function spreeSellerDashboardPlugin(options = {}) {
|
|
9
|
+
const hostRoot = options.root ?? process.cwd();
|
|
10
|
+
return [
|
|
11
|
+
// This shell is always scanned, whatever the host passes. Core defaults to
|
|
12
|
+
// the operator's dashboard, which a seller's host does not install — and a
|
|
13
|
+
// host naming its own shell is adding one, not replacing the panel whose
|
|
14
|
+
// components it renders. Overwriting instead of merging drops the panel's
|
|
15
|
+
// classes from the production CSS, and only in a built bundle.
|
|
16
|
+
...spreeDashboardCorePlugin({
|
|
17
|
+
...options,
|
|
18
|
+
shellPackages: ["@spree/seller-dashboard", ...options.shellPackages ?? []]
|
|
19
|
+
}),
|
|
20
|
+
sellerRouterPlugin(hostRoot, options)
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
function sellerRouterPlugin(hostRoot, options) {
|
|
24
|
+
const fromHost = createRequire(path.join(hostRoot, "package.json"));
|
|
25
|
+
const shellEntry = fromHost.resolve("@spree/seller-dashboard");
|
|
26
|
+
const shellRoutesDir = path.join(path.dirname(shellEntry), "routes");
|
|
27
|
+
const virtualRouteConfig = rootRoute("__root.tsx", [
|
|
28
|
+
route("/login", "login.tsx"),
|
|
29
|
+
route("/forgot-password", "forgot-password.tsx"),
|
|
30
|
+
route("/reset-password", "reset-password.tsx"),
|
|
31
|
+
route("/accept-invitation/$invitationId", "accept-invitation.$invitationId.tsx"),
|
|
32
|
+
layout("_authenticated.tsx", [
|
|
33
|
+
index("_authenticated/index.tsx"),
|
|
34
|
+
route("/$sellerId", "_authenticated/$sellerId.tsx", [
|
|
35
|
+
physical("", "_authenticated/$sellerId")
|
|
36
|
+
])
|
|
37
|
+
])
|
|
38
|
+
]);
|
|
39
|
+
return TanStackRouterVite({
|
|
40
|
+
target: "react",
|
|
41
|
+
virtualRouteConfig,
|
|
42
|
+
routesDirectory: shellRoutesDir,
|
|
43
|
+
generatedRouteTree: path.resolve(
|
|
44
|
+
hostRoot,
|
|
45
|
+
options.generatedRouteTree ?? "src/routeTree.gen.ts"
|
|
46
|
+
)
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export {
|
|
50
|
+
spreeSellerDashboardPlugin
|
|
51
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spree/seller-dashboard",
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Spree marketplace seller panel — the seller-facing counterpart to the admin dashboard.",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./styles.css": "./src/styles.css",
|
|
10
|
+
"./vite": {
|
|
11
|
+
"types": "./src/vite/index.ts",
|
|
12
|
+
"default": "./dist/vite-plugin/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"dist/vite-plugin",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@hookform/resolvers": "^5.2.2",
|
|
24
|
+
"@dnd-kit/core": "^6.3.1",
|
|
25
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
26
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
27
|
+
"@tanstack/react-query": "^5.90.2",
|
|
28
|
+
"@tanstack/react-router": "^1.133.32",
|
|
29
|
+
"@tanstack/router-plugin": "^1.167.35",
|
|
30
|
+
"@tanstack/virtual-file-routes": "^1.162.0",
|
|
31
|
+
"i18next": "^26.2.0",
|
|
32
|
+
"lucide-react": "^0.545.0",
|
|
33
|
+
"react-hook-form": "^7.65.0",
|
|
34
|
+
"react-i18next": "^17.0.8",
|
|
35
|
+
"zod": "^4.4.3",
|
|
36
|
+
"@spree/dashboard-ui": "1.0.0-beta.1",
|
|
37
|
+
"@spree/seller-sdk": "1.0.0-beta.1",
|
|
38
|
+
"@spree/dashboard-core": "1.0.0-beta.1"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"react": "^19.0.0",
|
|
42
|
+
"react-dom": "^19.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@biomejs/biome": "2.4.13",
|
|
46
|
+
"@types/node": "^25.6.0",
|
|
47
|
+
"@types/react": "^19.2.2",
|
|
48
|
+
"@types/react-dom": "^19.2.1",
|
|
49
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
50
|
+
"react": "^19.2.0",
|
|
51
|
+
"react-dom": "^19.2.0",
|
|
52
|
+
"tsup": "^8.5.0",
|
|
53
|
+
"typescript": "~6.0.3",
|
|
54
|
+
"vite": "^8.0.0"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "pnpm generate:routes && tsup",
|
|
58
|
+
"typecheck": "tsc -b",
|
|
59
|
+
"lint": "biome check .",
|
|
60
|
+
"lint:fix": "biome check --fix .",
|
|
61
|
+
"format": "biome format --write .",
|
|
62
|
+
"generate:routes": "node scripts/generate-routes.mjs"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { setApiClient } from '@spree/dashboard-core'
|
|
2
|
+
import type { SellerApiClient, SellerExportCreateParams } from '@spree/seller-sdk'
|
|
3
|
+
import { createSellerClient } from '@spree/seller-sdk'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which seller the signed-in user is acting as.
|
|
7
|
+
*
|
|
8
|
+
* Kept out of React state because the client needs it on every request,
|
|
9
|
+
* including ones fired before any component renders. The URL is the real
|
|
10
|
+
* source of truth — the `$sellerId` route layout writes it here as it renders
|
|
11
|
+
* — and this copy is only what the request headers read.
|
|
12
|
+
*/
|
|
13
|
+
const SELLER_STORAGE_KEY = 'spree.seller.id'
|
|
14
|
+
|
|
15
|
+
let client: SellerApiClient | null = null
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The seller the client is currently sending, mirrored here because the SDK
|
|
19
|
+
* keeps it in a closure with no reader.
|
|
20
|
+
*
|
|
21
|
+
* Not the same thing as the stored choice: a browser that blocks storage
|
|
22
|
+
* (private mode, sandboxed iframe) still runs the panel perfectly well, and
|
|
23
|
+
* `rememberSeller` sets the client either way. Reading `localStorage` to
|
|
24
|
+
* answer "who are we acting as" would come back empty in exactly those
|
|
25
|
+
* sessions and drop the header off every file download.
|
|
26
|
+
*/
|
|
27
|
+
let activeSellerId: string | null = null
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Builds the Seller API client and registers it with the shared framework.
|
|
31
|
+
*
|
|
32
|
+
* The host calls this once at boot, before rendering. `@spree/dashboard-core`
|
|
33
|
+
* is the framework behind both panels, so it cannot own either client — the
|
|
34
|
+
* operator's dashboard registers an Admin API client the same way.
|
|
35
|
+
*
|
|
36
|
+
* Leave `baseUrl` unset for relative URLs, so the Vite dev proxy (or a
|
|
37
|
+
* static-host rewrite in production) keeps the panel same-origin with the API
|
|
38
|
+
* — which is what lets the seller refresh cookie ride under `SameSite=Lax`
|
|
39
|
+
* without HTTPS. Set it only for a genuinely cross-origin deployment.
|
|
40
|
+
*/
|
|
41
|
+
export function createSellerApiClient({
|
|
42
|
+
baseUrl = '',
|
|
43
|
+
}: {
|
|
44
|
+
baseUrl?: string
|
|
45
|
+
} = {}): SellerApiClient {
|
|
46
|
+
client = createSellerClient({ baseUrl })
|
|
47
|
+
|
|
48
|
+
const remembered = rememberedSeller()
|
|
49
|
+
if (remembered) setActiveSeller(remembered)
|
|
50
|
+
|
|
51
|
+
setApiClient({
|
|
52
|
+
auth: client.auth,
|
|
53
|
+
setToken: (token: string) => sellerClient().setToken(token),
|
|
54
|
+
onUnauthorized: (handler) => sellerClient().onUnauthorized(handler),
|
|
55
|
+
// A stale seller header would ride into the next seller's first requests
|
|
56
|
+
// and 403 them against a seller they may hold no role on.
|
|
57
|
+
clearTenant: forgetSeller,
|
|
58
|
+
fetchPermissions: async () => {
|
|
59
|
+
const response = await sellerClient().me()
|
|
60
|
+
|
|
61
|
+
// Both shapes, exactly as the operator's dashboard receives them. The
|
|
62
|
+
// framework's `<Can>` reads CanCanCan rules; the key list is what the
|
|
63
|
+
// API's own gate enforces. Dropping the rules here (an earlier version
|
|
64
|
+
// did) left every `<Can>` on the panel answering false — silently.
|
|
65
|
+
return { rules: response.permissions ?? [], keys: response.permission_keys ?? [] }
|
|
66
|
+
},
|
|
67
|
+
// The shared address form reads countries through the registered client,
|
|
68
|
+
// so it works in a panel that has no admin credential.
|
|
69
|
+
listCountries: () => sellerClient().countries.list(),
|
|
70
|
+
createDirectUpload: (params) => sellerClient().directUploads.create(params),
|
|
71
|
+
// A file download is a bare fetch, so it does not go through the client
|
|
72
|
+
// and picks up none of its headers. Without the seller header the Seller
|
|
73
|
+
// API refuses the request before the action runs, so an export would
|
|
74
|
+
// generate fine and then fail to download.
|
|
75
|
+
downloadHeaders: (): Record<string, string> =>
|
|
76
|
+
activeSellerId ? { 'X-Spree-Seller-Id': activeSellerId } : {},
|
|
77
|
+
// Backs the shared export dialog, the same one the operator's dashboard
|
|
78
|
+
// renders. The Seller API narrows what may be exported to records that
|
|
79
|
+
// can be scoped to one seller, and refuses anything else.
|
|
80
|
+
exports: {
|
|
81
|
+
create: (params) => sellerClient().exports.create(params as SellerExportCreateParams),
|
|
82
|
+
get: (id) => sellerClient().exports.get(id),
|
|
83
|
+
},
|
|
84
|
+
// Backs the shared CSV import wizard. Products only: customers belong to
|
|
85
|
+
// the marketplace and translations of catalog copy are its merchandising,
|
|
86
|
+
// so the Seller API refuses both and the wizard offers neither.
|
|
87
|
+
imports: {
|
|
88
|
+
list: (params) => sellerClient().imports.list(params),
|
|
89
|
+
get: (id) => sellerClient().imports.get(id),
|
|
90
|
+
create: (params) =>
|
|
91
|
+
sellerClient().imports.create({
|
|
92
|
+
...params,
|
|
93
|
+
// Narrowed by the API too — this only keeps the SDK's own type
|
|
94
|
+
// honest about what this branch accepts.
|
|
95
|
+
type: params.type as 'products',
|
|
96
|
+
}),
|
|
97
|
+
completeMapping: (id, params) => sellerClient().imports.completeMapping(id, params),
|
|
98
|
+
retryFailedRows: (id) => sellerClient().imports.retryFailedRows(id),
|
|
99
|
+
delete: (id) => sellerClient().imports.delete(id),
|
|
100
|
+
rows: { list: (importId, params) => sellerClient().imports.rows.list(importId, params) },
|
|
101
|
+
types: ['products'],
|
|
102
|
+
// This panel's product list is keyed `seller-products`, so core's
|
|
103
|
+
// default `products` invalidation would miss it and the list would sit
|
|
104
|
+
// on its pre-import cache.
|
|
105
|
+
invalidateKeys: ['seller-products'],
|
|
106
|
+
templateUrl: (type) => `/api/v3/seller/imports/template?type=${encodeURIComponent(type)}`,
|
|
107
|
+
exampleUrl: (type) => `/api/v3/seller/imports/example?type=${encodeURIComponent(type)}`,
|
|
108
|
+
downloadUrl: (id) => `/api/v3/seller/imports/${id}/download`,
|
|
109
|
+
},
|
|
110
|
+
// Backs the shared stock-locations page. No `delete`: a location holds
|
|
111
|
+
// stock levels and is named on historical fulfillments, so the Seller API
|
|
112
|
+
// does not offer it and the page hides the action accordingly.
|
|
113
|
+
stockLocations: {
|
|
114
|
+
list: (params) => sellerClient().stockLocations.list(params),
|
|
115
|
+
get: (id) => sellerClient().stockLocations.get(id),
|
|
116
|
+
create: (params) => sellerClient().stockLocations.create(params),
|
|
117
|
+
update: (id, params) => sellerClient().stockLocations.update(id, params),
|
|
118
|
+
},
|
|
119
|
+
// The marketplace's vocabulary the product form picks from: types and
|
|
120
|
+
// delivery profiles only. Categories, collections and tags are filing,
|
|
121
|
+
// which is the operator's at review, so they are not registered and the
|
|
122
|
+
// shared Categorization card offers just these two
|
|
123
|
+
// (docs/plans/6.0-multi-vendor-marketplace.md, Decision 13).
|
|
124
|
+
productTypes: {
|
|
125
|
+
list: (params) => sellerClient().productTypes.list(params),
|
|
126
|
+
get: (id) => sellerClient().productTypes.get(id),
|
|
127
|
+
},
|
|
128
|
+
deliveryProfiles: { list: (params) => sellerClient().deliveryProfiles.list(params) },
|
|
129
|
+
// What this seller packs into: their own boxes and cartons, plus the
|
|
130
|
+
// marketplace's shared packaging, which the API lists read-only. Backs
|
|
131
|
+
// both the packaging settings page and the variant editor's carton
|
|
132
|
+
// picker (docs/plans/6.0-seller-package-types.md).
|
|
133
|
+
packageTypes: {
|
|
134
|
+
list: (params) => sellerClient().packageTypes.list(params),
|
|
135
|
+
get: (id) => sellerClient().packageTypes.get(id),
|
|
136
|
+
create: (params) => sellerClient().packageTypes.create(params),
|
|
137
|
+
update: (id, params) => sellerClient().packageTypes.update(id, params),
|
|
138
|
+
delete: (id) => sellerClient().packageTypes.delete(id),
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
return client
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The registered client.
|
|
147
|
+
*
|
|
148
|
+
* Throws rather than returning null: a panel rendering without one is
|
|
149
|
+
* misconfigured, and every call would otherwise fail further away with a less
|
|
150
|
+
* useful message.
|
|
151
|
+
*/
|
|
152
|
+
export function sellerClient(): SellerApiClient {
|
|
153
|
+
if (!client) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
'@spree/seller-dashboard: no API client created. Call createSellerApiClient() at boot, ' +
|
|
156
|
+
'before rendering the panel.',
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return client
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Points subsequent requests at a seller, without persisting the choice. */
|
|
164
|
+
export function setActiveSeller(sellerId: string): void {
|
|
165
|
+
activeSellerId = sellerId
|
|
166
|
+
sellerClient().setSeller(sellerId)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Persists the choice so a reload does not drop the seller back to the picker.
|
|
171
|
+
*
|
|
172
|
+
* Every access is guarded: a browser that blocks storage (sandboxed iframe,
|
|
173
|
+
* blocked third-party storage, some private modes) throws on `localStorage`,
|
|
174
|
+
* and these run at boot and during logout — the two places a throw would
|
|
175
|
+
* leave the panel unusable rather than merely forgetful. Losing the memory is
|
|
176
|
+
* a small cost; failing to boot or to sign out is not.
|
|
177
|
+
*/
|
|
178
|
+
export function rememberSeller(sellerId: string): void {
|
|
179
|
+
try {
|
|
180
|
+
localStorage.setItem(SELLER_STORAGE_KEY, sellerId)
|
|
181
|
+
} catch {
|
|
182
|
+
// Storage unavailable — the choice lives for this page only.
|
|
183
|
+
}
|
|
184
|
+
setActiveSeller(sellerId)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function forgetSeller(): void {
|
|
188
|
+
try {
|
|
189
|
+
localStorage.removeItem(SELLER_STORAGE_KEY)
|
|
190
|
+
} catch {
|
|
191
|
+
// Nothing to forget; clearing the header below is what matters for logout.
|
|
192
|
+
}
|
|
193
|
+
activeSellerId = null
|
|
194
|
+
client?.setSeller('')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function rememberedSeller(): string | null {
|
|
198
|
+
try {
|
|
199
|
+
return localStorage.getItem(SELLER_STORAGE_KEY)
|
|
200
|
+
} catch {
|
|
201
|
+
return null
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Full-height placeholder for the panel's loading and error states. */
|
|
2
|
+
export function CenteredMessage({ children }: { children: React.ReactNode }) {
|
|
3
|
+
return (
|
|
4
|
+
<div className="flex min-h-screen items-center justify-center text-muted-foreground text-sm">
|
|
5
|
+
{children}
|
|
6
|
+
</div>
|
|
7
|
+
)
|
|
8
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@spree/dashboard-ui'
|
|
2
|
+
import type { Balance } from '@spree/seller-sdk'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the marketplace owes this seller, one card per currency.
|
|
7
|
+
*
|
|
8
|
+
* Nothing is ever converted between currencies, so two currencies are two
|
|
9
|
+
* positions rather than one total. `pending` is money the payout provider has
|
|
10
|
+
* not confirmed yet — shown beside the balance so a seller who has shipped
|
|
11
|
+
* does not read their own order as unpaid.
|
|
12
|
+
*
|
|
13
|
+
* A position has two sides when the seller banks in a currency other than the
|
|
14
|
+
* one they sold in: the balance is what their account will actually be paid,
|
|
15
|
+
* and the sale's own figure is shown beneath it. Both are recorded figures.
|
|
16
|
+
*/
|
|
17
|
+
export function BalanceSummary({ balances }: { balances: Balance[] }) {
|
|
18
|
+
const { t } = useTranslation()
|
|
19
|
+
|
|
20
|
+
if (balances.length === 0) {
|
|
21
|
+
return (
|
|
22
|
+
<Card>
|
|
23
|
+
<CardHeader>
|
|
24
|
+
<CardTitle>{t('earnings.balance.title')}</CardTitle>
|
|
25
|
+
<CardDescription>{t('earnings.balance.empty')}</CardDescription>
|
|
26
|
+
</CardHeader>
|
|
27
|
+
</Card>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
33
|
+
{balances.map((balance) => (
|
|
34
|
+
<Card key={`${balance.currency}-${balance.settlement_currency}`}>
|
|
35
|
+
<CardHeader>
|
|
36
|
+
<CardTitle>
|
|
37
|
+
{t('earnings.balance.owed', { currency: balance.settlement_currency })}
|
|
38
|
+
</CardTitle>
|
|
39
|
+
{balance.converted && (
|
|
40
|
+
<CardDescription>
|
|
41
|
+
{t('earnings.balance.converted', { currency: balance.currency })}
|
|
42
|
+
</CardDescription>
|
|
43
|
+
)}
|
|
44
|
+
</CardHeader>
|
|
45
|
+
<CardContent className="flex flex-col gap-3">
|
|
46
|
+
<span className="text-2xl font-semibold tabular-nums">{balance.display_balance}</span>
|
|
47
|
+
<dl className="flex flex-col gap-1 text-sm">
|
|
48
|
+
<Figure label={t('earnings.balance.earned')} value={balance.display_earned} />
|
|
49
|
+
{balance.converted && (
|
|
50
|
+
<Figure label={t('earnings.balance.payable')} value={balance.display_payable} />
|
|
51
|
+
)}
|
|
52
|
+
<Figure label={t('earnings.balance.paid')} value={balance.display_paid} />
|
|
53
|
+
<Figure label={t('earnings.balance.pending')} value={balance.display_pending} />
|
|
54
|
+
</dl>
|
|
55
|
+
</CardContent>
|
|
56
|
+
</Card>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function Figure({ label, value }: { label: string; value: string }) {
|
|
63
|
+
return (
|
|
64
|
+
<div className="flex items-center justify-between gap-3">
|
|
65
|
+
<dt className="text-muted-foreground">{label}</dt>
|
|
66
|
+
<dd className="tabular-nums">{value}</dd>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Badge } from '@spree/dashboard-ui'
|
|
2
|
+
import { useQuery } from '@tanstack/react-query'
|
|
3
|
+
import { useParams } from '@tanstack/react-router'
|
|
4
|
+
import { sellerClient } from '../api-client'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Progress counter beside the onboarding nav entry, mirroring the operator's
|
|
8
|
+
* Getting Started badge — a seller can see how far along they are without
|
|
9
|
+
* opening the page.
|
|
10
|
+
*
|
|
11
|
+
* Shares the onboarding query key with the page itself, so the two read one
|
|
12
|
+
* cache entry: the badge updates the moment a task completes, and visiting
|
|
13
|
+
* the page costs no extra request.
|
|
14
|
+
*
|
|
15
|
+
* Hidden once everything is done, like its operator twin — a badge that
|
|
16
|
+
* always reads `4/4` is noise.
|
|
17
|
+
*/
|
|
18
|
+
export function OnboardingNavBadge() {
|
|
19
|
+
// `strict: false`: the badge renders inside the sidebar, which the seller
|
|
20
|
+
// picker also mounts — there is no `$sellerId` in scope there.
|
|
21
|
+
const { sellerId } = useParams({ strict: false }) as { sellerId?: string }
|
|
22
|
+
|
|
23
|
+
const { data } = useQuery({
|
|
24
|
+
queryKey: ['seller', sellerId, 'onboarding'],
|
|
25
|
+
queryFn: () => sellerClient().onboarding.get(),
|
|
26
|
+
enabled: Boolean(sellerId),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const progress = data?.progress
|
|
30
|
+
if (!progress || progress.total === 0 || progress.done === progress.total) return null
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Badge variant="outline" className="mr-1 flex items-center gap-0 rounded-lg">
|
|
34
|
+
<span>{progress.done}</span>
|
|
35
|
+
<span className="opacity-50">/{progress.total}</span>
|
|
36
|
+
</Badge>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
CardContent,
|
|
4
|
+
DropdownMenu,
|
|
5
|
+
DropdownMenuContent,
|
|
6
|
+
DropdownMenuItem,
|
|
7
|
+
DropdownMenuSeparator,
|
|
8
|
+
DropdownMenuTrigger,
|
|
9
|
+
StatusBadge,
|
|
10
|
+
useConfirm,
|
|
11
|
+
} from '@spree/dashboard-ui'
|
|
12
|
+
import { EllipsisVerticalIcon, PencilIcon, TrashIcon } from '@spree/dashboard-ui/icons'
|
|
13
|
+
import type { Delivery } from '@spree/seller-sdk'
|
|
14
|
+
import { useTranslation } from 'react-i18next'
|
|
15
|
+
import { useFulfillmentActions } from '../../hooks/use-fulfillments'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The tracked consignments of one parcel.
|
|
19
|
+
*
|
|
20
|
+
* A parcel usually travels as one, but three boxes or a pallet under a freight
|
|
21
|
+
* PRO number are the same shape — each with its own tracking number and its
|
|
22
|
+
* own carrier status. Confirming arrival is not here: that a parcel landed is
|
|
23
|
+
* the buyer's word, not the sender's, so it stays with the operator.
|
|
24
|
+
*/
|
|
25
|
+
export function FulfillmentDeliveries({
|
|
26
|
+
orderId,
|
|
27
|
+
fulfillmentId,
|
|
28
|
+
deliveries,
|
|
29
|
+
onEdit,
|
|
30
|
+
}: {
|
|
31
|
+
orderId: string
|
|
32
|
+
fulfillmentId: string
|
|
33
|
+
deliveries: Delivery[]
|
|
34
|
+
onEdit: (delivery: Delivery) => void
|
|
35
|
+
}) {
|
|
36
|
+
const { t } = useTranslation()
|
|
37
|
+
const confirm = useConfirm()
|
|
38
|
+
const { deleteDelivery } = useFulfillmentActions(orderId)
|
|
39
|
+
|
|
40
|
+
if (deliveries.length === 0) return null
|
|
41
|
+
|
|
42
|
+
async function handleDelete(delivery: Delivery) {
|
|
43
|
+
const ok = await confirm({
|
|
44
|
+
message: t('orders.fulfillments.confirm_delete_delivery'),
|
|
45
|
+
variant: 'destructive',
|
|
46
|
+
confirmLabel: t('common.delete'),
|
|
47
|
+
})
|
|
48
|
+
if (!ok) return
|
|
49
|
+
deleteDelivery.mutate({ fulfillmentId, deliveryId: delivery.id })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<CardContent className="flex flex-col gap-2 border-border-subtle border-b py-3 text-sm">
|
|
54
|
+
{deliveries.map((delivery) => (
|
|
55
|
+
<div key={delivery.id} className="flex flex-wrap items-center justify-between gap-2">
|
|
56
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
57
|
+
<span className="text-muted-foreground">
|
|
58
|
+
{delivery.carrier_name ?? t('orders.tracking_label')}:
|
|
59
|
+
</span>
|
|
60
|
+
{delivery.tracking_url ? (
|
|
61
|
+
<a
|
|
62
|
+
href={delivery.tracking_url}
|
|
63
|
+
target="_blank"
|
|
64
|
+
rel="noopener noreferrer"
|
|
65
|
+
className="truncate underline"
|
|
66
|
+
>
|
|
67
|
+
{delivery.tracking_number}
|
|
68
|
+
</a>
|
|
69
|
+
) : (
|
|
70
|
+
<span className="truncate">{delivery.tracking_number}</span>
|
|
71
|
+
)}
|
|
72
|
+
<StatusBadge
|
|
73
|
+
status={delivery.status}
|
|
74
|
+
label={t(`orders.fulfillments.carrier_status_${delivery.status}`, {
|
|
75
|
+
defaultValue: delivery.status,
|
|
76
|
+
})}
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<DropdownMenu>
|
|
81
|
+
<DropdownMenuTrigger asChild>
|
|
82
|
+
<Button variant="ghost" size="icon-xs">
|
|
83
|
+
<EllipsisVerticalIcon className="size-4" />
|
|
84
|
+
<span className="sr-only">{t('admin.actions.actions_menu')}</span>
|
|
85
|
+
</Button>
|
|
86
|
+
</DropdownMenuTrigger>
|
|
87
|
+
<DropdownMenuContent align="end">
|
|
88
|
+
<DropdownMenuItem onClick={() => onEdit(delivery)}>
|
|
89
|
+
<PencilIcon className="size-4" />
|
|
90
|
+
{t('common.edit')}
|
|
91
|
+
</DropdownMenuItem>
|
|
92
|
+
|
|
93
|
+
{/* Two consignments are not removable: one a label minted, since
|
|
94
|
+
the label records what was bought, and one that arrived,
|
|
95
|
+
since its arrival is what the returns window counts from. The
|
|
96
|
+
server refuses both; this keeps the menu honest. */}
|
|
97
|
+
{!delivery.shipping_label_id && delivery.status !== 'delivered' && (
|
|
98
|
+
<>
|
|
99
|
+
<DropdownMenuSeparator />
|
|
100
|
+
<DropdownMenuItem
|
|
101
|
+
variant="destructive"
|
|
102
|
+
disabled={deleteDelivery.isPending}
|
|
103
|
+
onClick={() => handleDelete(delivery)}
|
|
104
|
+
>
|
|
105
|
+
<TrashIcon className="size-4" />
|
|
106
|
+
{t('common.delete')}
|
|
107
|
+
</DropdownMenuItem>
|
|
108
|
+
</>
|
|
109
|
+
)}
|
|
110
|
+
</DropdownMenuContent>
|
|
111
|
+
</DropdownMenu>
|
|
112
|
+
</div>
|
|
113
|
+
))}
|
|
114
|
+
</CardContent>
|
|
115
|
+
)
|
|
116
|
+
}
|