@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
@@ -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,6 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { ProductPage } from '../../../../pages/product'
3
+
4
+ export const Route = createFileRoute('/_authenticated/$sellerId/products/$productId')({
5
+ component: () => <ProductPage mode="edit" />,
6
+ })
@@ -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,6 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { ProductPage } from '../../../../pages/product'
3
+
4
+ export const Route = createFileRoute('/_authenticated/$sellerId/products/new')({
5
+ component: () => <ProductPage mode="new" />,
6
+ })
@@ -0,0 +1,6 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { ProfilePage } from '../../../pages/profile'
3
+
4
+ export const Route = createFileRoute('/_authenticated/$sellerId/profile')({
5
+ component: ProfilePage,
6
+ })
@@ -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,6 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { TeamPage } from '../../../../pages/team'
3
+
4
+ export const Route = createFileRoute('/_authenticated/$sellerId/settings/team')({
5
+ component: TeamPage,
6
+ })
@@ -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
+ })