@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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -0
  3. package/dist/vite-plugin/index.js +51 -0
  4. package/package.json +64 -0
  5. package/src/api-client.ts +203 -0
  6. package/src/components/centered-message.tsx +8 -0
  7. package/src/components/finance/balance-summary.tsx +69 -0
  8. package/src/components/onboarding-nav-badge.tsx +38 -0
  9. package/src/components/orders/fulfillment-deliveries.tsx +116 -0
  10. package/src/components/orders/fulfillment-delivery-dialog.tsx +82 -0
  11. package/src/components/orders/fulfillment-edit-dialog.tsx +95 -0
  12. package/src/components/orders/fulfillment-fulfill-form.tsx +132 -0
  13. package/src/components/orders/fulfillment-label-upload-dialog.tsx +95 -0
  14. package/src/components/orders/fulfillment-split-dialog.tsx +142 -0
  15. package/src/components/orders/fulfillments-card.tsx +289 -0
  16. package/src/components/orders/line-label.ts +28 -0
  17. package/src/components/orders/order-cancel-dialog.tsx +74 -0
  18. package/src/components/orders/order-cards.tsx +297 -0
  19. package/src/components/orders/order-earnings-card.tsx +99 -0
  20. package/src/components/orders/order-notes-cards.tsx +83 -0
  21. package/src/components/orders/order-payment-card.tsx +94 -0
  22. package/src/components/orders/post-sale-cards.tsx +347 -0
  23. package/src/components/orders/post-sale-create-dialogs.tsx +201 -0
  24. package/src/components/orders/returns-card.tsx +258 -0
  25. package/src/components/orders/shipping-label-row.tsx +124 -0
  26. package/src/components/product-status-card.tsx +111 -0
  27. package/src/components/read-row.tsx +16 -0
  28. package/src/components/retryable-error.tsx +23 -0
  29. package/src/components/seller-address-card.tsx +123 -0
  30. package/src/components/seller-business-card.tsx +152 -0
  31. package/src/components/seller-returns-location-card.tsx +151 -0
  32. package/src/components/seller-switcher.tsx +124 -0
  33. package/src/components/seller-tax-identifiers-card.tsx +63 -0
  34. package/src/create-router.ts +32 -0
  35. package/src/hooks/use-fulfillments.ts +155 -0
  36. package/src/hooks/use-ledger.ts +46 -0
  37. package/src/hooks/use-order.ts +62 -0
  38. package/src/hooks/use-post-sale.ts +202 -0
  39. package/src/hooks/use-reasons.ts +34 -0
  40. package/src/hooks/use-stock-locations.ts +23 -0
  41. package/src/hooks/use-store-timezone.ts +26 -0
  42. package/src/i18n.ts +26 -0
  43. package/src/index.ts +27 -0
  44. package/src/lib/packing-slip.ts +109 -0
  45. package/src/locales/en.json +841 -0
  46. package/src/nav/default.ts +108 -0
  47. package/src/nav/settings.ts +114 -0
  48. package/src/pages/accept-invitation.tsx +127 -0
  49. package/src/pages/delivery-methods.tsx +647 -0
  50. package/src/pages/earnings.tsx +44 -0
  51. package/src/pages/forgot-password.tsx +100 -0
  52. package/src/pages/home.tsx +132 -0
  53. package/src/pages/login.tsx +94 -0
  54. package/src/pages/onboarding.tsx +539 -0
  55. package/src/pages/order.tsx +119 -0
  56. package/src/pages/orders.tsx +33 -0
  57. package/src/pages/payout.tsx +154 -0
  58. package/src/pages/payouts.tsx +32 -0
  59. package/src/pages/policies.tsx +292 -0
  60. package/src/pages/product.tsx +250 -0
  61. package/src/pages/products.tsx +228 -0
  62. package/src/pages/profile.tsx +389 -0
  63. package/src/pages/reset-password.tsx +105 -0
  64. package/src/pages/seller-picker.tsx +46 -0
  65. package/src/pages/team.tsx +468 -0
  66. package/src/panel-chrome.tsx +36 -0
  67. package/src/routeTree.gen.ts +590 -0
  68. package/src/routes/__root.tsx +15 -0
  69. package/src/routes/_authenticated/$sellerId/earnings/index.tsx +15 -0
  70. package/src/routes/_authenticated/$sellerId/index.tsx +6 -0
  71. package/src/routes/_authenticated/$sellerId/onboarding.tsx +6 -0
  72. package/src/routes/_authenticated/$sellerId/orders/$orderId.tsx +6 -0
  73. package/src/routes/_authenticated/$sellerId/orders/index.tsx +15 -0
  74. package/src/routes/_authenticated/$sellerId/payouts/$payoutId.tsx +7 -0
  75. package/src/routes/_authenticated/$sellerId/payouts/index.tsx +15 -0
  76. package/src/routes/_authenticated/$sellerId/products/$productId.tsx +6 -0
  77. package/src/routes/_authenticated/$sellerId/products/index.tsx +23 -0
  78. package/src/routes/_authenticated/$sellerId/products/new.tsx +6 -0
  79. package/src/routes/_authenticated/$sellerId/profile.tsx +6 -0
  80. package/src/routes/_authenticated/$sellerId/settings/delivery-methods.tsx +14 -0
  81. package/src/routes/_authenticated/$sellerId/settings/index.tsx +18 -0
  82. package/src/routes/_authenticated/$sellerId/settings/package-types.tsx +21 -0
  83. package/src/routes/_authenticated/$sellerId/settings/policies.tsx +14 -0
  84. package/src/routes/_authenticated/$sellerId/settings/stock-locations.tsx +22 -0
  85. package/src/routes/_authenticated/$sellerId/settings/team.tsx +6 -0
  86. package/src/routes/_authenticated/$sellerId/settings.tsx +18 -0
  87. package/src/routes/_authenticated/$sellerId.tsx +49 -0
  88. package/src/routes/_authenticated/index.tsx +48 -0
  89. package/src/routes/_authenticated.tsx +13 -0
  90. package/src/routes/accept-invitation.$invitationId.tsx +19 -0
  91. package/src/routes/forgot-password.tsx +15 -0
  92. package/src/routes/login.tsx +15 -0
  93. package/src/routes/reset-password.tsx +21 -0
  94. package/src/schemas/fulfillment.ts +31 -0
  95. package/src/seller-dashboard.tsx +50 -0
  96. package/src/styles.css +18 -0
  97. package/src/tables/delivery-methods.tsx +68 -0
  98. package/src/tables/orders.tsx +116 -0
  99. package/src/tables/payouts.tsx +87 -0
  100. package/src/tables/policies.tsx +37 -0
  101. package/src/tables/products.tsx +56 -0
  102. package/src/tables/transfers.tsx +118 -0
  103. 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
+ }