@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,16 @@
1
+ import type { ReactNode } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+
4
+ /** One labelled value in a read-only card, with a placeholder when unset. */
5
+ export function ReadRow({ label, children }: { label: string; children: ReactNode }) {
6
+ const { t } = useTranslation()
7
+
8
+ return (
9
+ <div className="flex items-start justify-between gap-3 text-sm">
10
+ <span className="text-muted-foreground">{label}</span>
11
+ <span className="text-right">
12
+ {children ?? <span className="text-muted-foreground">{t('profile.not_provided')}</span>}
13
+ </span>
14
+ </div>
15
+ )
16
+ }
@@ -0,0 +1,23 @@
1
+ import { Button } from '@spree/dashboard-ui'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { CenteredMessage } from './centered-message'
4
+
5
+ /**
6
+ * Shown when a page's own query failed, in place of its content.
7
+ *
8
+ * Distinct from a "not found" message on purpose: a failed request and a
9
+ * missing record send the seller looking for different causes, and only one
10
+ * of the two is worth retrying.
11
+ */
12
+ export function RetryableError({ onRetry }: { onRetry: () => void }) {
13
+ const { t } = useTranslation()
14
+
15
+ return (
16
+ <CenteredMessage>
17
+ {t('common.error')}{' '}
18
+ <Button variant="outline" onClick={onRetry}>
19
+ {t('common.retry')}
20
+ </Button>
21
+ </CenteredMessage>
22
+ )
23
+ }
@@ -0,0 +1,123 @@
1
+ import { AddressFormDialog } from '@spree/dashboard-core'
2
+ import {
3
+ AddressBlock,
4
+ Badge,
5
+ Button,
6
+ Card,
7
+ CardAction,
8
+ CardContent,
9
+ CardHeader,
10
+ CardTitle,
11
+ StatusBadge,
12
+ toastManager,
13
+ } from '@spree/dashboard-ui'
14
+ import { PencilIcon, PlusIcon } from '@spree/dashboard-ui/icons'
15
+ import type { Profile, SellerAddressParams } from '@spree/seller-sdk'
16
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
17
+ import { useParams } from '@tanstack/react-router'
18
+ import { useState } from 'react'
19
+ import { useTranslation } from 'react-i18next'
20
+ import { sellerClient } from '../api-client'
21
+
22
+ /**
23
+ * Where the seller is invoiced — read as a block, edited in the shared
24
+ * address dialog.
25
+ *
26
+ * Used both on the profile page and inline in the onboarding checklist, so a
27
+ * seller working through setup fills the address where they are told about
28
+ * it rather than being sent somewhere else and losing their place. Same
29
+ * component either way, so the two can never diverge.
30
+ *
31
+ * Returns are not here: they go to a stock location, which is a record of its
32
+ * own — see `SellerReturnsLocationCard`.
33
+ */
34
+ export function SellerAddressCard({
35
+ profile,
36
+ headless = false,
37
+ }: {
38
+ profile: Profile
39
+ /** Render just the body — the onboarding checklist supplies its own frame. */
40
+ headless?: boolean
41
+ }) {
42
+ const { t } = useTranslation()
43
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
44
+ const queryClient = useQueryClient()
45
+ const [editing, setEditing] = useState(false)
46
+
47
+ const address = profile.billing_address
48
+ const title = t('profile.billing_address')
49
+
50
+ const save = useMutation({
51
+ // Only the billing address is sent: the rest of the profile is untouched,
52
+ // and posting it all would rewrite fields the seller never opened.
53
+ mutationFn: (values: SellerAddressParams) =>
54
+ sellerClient().profile.update({ billing_address: values }),
55
+ onSuccess: (updated) => {
56
+ queryClient.setQueryData(['seller', sellerId, 'profile'], updated)
57
+ // The checklist reads addresses too, so it has to re-evaluate.
58
+ void queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'onboarding'] })
59
+ setEditing(false)
60
+ toastManager.add({ type: 'success', title: t('profile.saved') })
61
+ },
62
+ onError: (err) =>
63
+ toastManager.add({
64
+ type: 'error',
65
+ title: err instanceof Error ? err.message : t('common.error'),
66
+ }),
67
+ })
68
+
69
+ const body = address ? (
70
+ <AddressBlock address={address} />
71
+ ) : (
72
+ <p className="text-muted-foreground text-sm">{t('profile.address_not_provided')}</p>
73
+ )
74
+
75
+ const editButton = (
76
+ <Button variant={address ? 'outline' : 'default'} size="sm" onClick={() => setEditing(true)}>
77
+ {address ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />}
78
+ {address ? t('profile.edit') : t('profile.add_address')}
79
+ </Button>
80
+ )
81
+
82
+ const dialog = editing && (
83
+ <AddressFormDialog
84
+ title={title}
85
+ address={address}
86
+ open
87
+ business
88
+ onOpenChange={setEditing}
89
+ onSave={(values) => save.mutate(values)}
90
+ isPending={save.isPending}
91
+ />
92
+ )
93
+
94
+ if (headless) {
95
+ return (
96
+ <div className="flex flex-col items-start gap-3">
97
+ {body}
98
+ {editButton}
99
+ {dialog}
100
+ </div>
101
+ )
102
+ }
103
+
104
+ return (
105
+ <>
106
+ <Card>
107
+ <CardHeader>
108
+ <CardTitle>
109
+ {title}
110
+ {address ? (
111
+ <StatusBadge status="complete" label={t('profile.address_filled')} />
112
+ ) : (
113
+ <Badge variant="outline">{t('profile.address_missing')}</Badge>
114
+ )}
115
+ </CardTitle>
116
+ <CardAction>{editButton}</CardAction>
117
+ </CardHeader>
118
+ <CardContent>{body}</CardContent>
119
+ </Card>
120
+ {dialog}
121
+ </>
122
+ )
123
+ }
@@ -0,0 +1,152 @@
1
+ import {
2
+ Button,
3
+ Card,
4
+ CardAction,
5
+ CardContent,
6
+ CardHeader,
7
+ CardTitle,
8
+ Field,
9
+ FieldLabel,
10
+ Input,
11
+ Sheet,
12
+ SheetContent,
13
+ SheetFooter,
14
+ SheetHeader,
15
+ SheetTitle,
16
+ toastManager,
17
+ } from '@spree/dashboard-ui'
18
+ import { PencilIcon } from '@spree/dashboard-ui/icons'
19
+ import type { Profile } from '@spree/seller-sdk'
20
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
21
+ import { useParams } from '@tanstack/react-router'
22
+ import { useState } from 'react'
23
+ import { useTranslation } from 'react-i18next'
24
+ import { sellerClient } from '../api-client'
25
+
26
+ function errorMessage(error: unknown, fallback: string) {
27
+ return error instanceof Error && error.message ? error.message : fallback
28
+ }
29
+
30
+ /**
31
+ * The business behind the seller, as the marketplace's commission invoice must
32
+ * address it: the legal name and registration number.
33
+ *
34
+ * The tax registrations sit in their own card beside this one, on the same
35
+ * panel a company's are managed from — a business trading in several regimes
36
+ * holds a registration in each, so they are a collection rather than a field.
37
+ *
38
+ * Separate from the billing address, which is only where post goes.
39
+ */
40
+ export function SellerBusinessCard({ profile }: { profile: Profile }) {
41
+ const { t } = useTranslation()
42
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
43
+ const queryClient = useQueryClient()
44
+ const [editing, setEditing] = useState(false)
45
+
46
+ const [legalName, setLegalName] = useState(profile.legal_name ?? '')
47
+ const [registrationNumber, setRegistrationNumber] = useState(profile.registration_number ?? '')
48
+ // Shown in the sheet rather than as a toast: the toast stack deliberately
49
+ // sits below the sheet overlay, so a rejection raised here would be hidden
50
+ // behind the very form that caused it.
51
+ const [formError, setFormError] = useState<string | null>(null)
52
+
53
+ // Opening the sheet is the seller starting over, so it shows what is on file
54
+ // rather than whatever they last typed — the card never remounts, so an
55
+ // abandoned edit would otherwise outlive the sheet that made it.
56
+ function setSheetOpen(open: boolean) {
57
+ if (open) {
58
+ setLegalName(profile.legal_name ?? '')
59
+ setRegistrationNumber(profile.registration_number ?? '')
60
+ setFormError(null)
61
+ }
62
+ setEditing(open)
63
+ }
64
+
65
+ const save = useMutation({
66
+ mutationFn: () => {
67
+ setFormError(null)
68
+ return sellerClient().profile.update({
69
+ legal_name: legalName || null,
70
+ registration_number: registrationNumber || null,
71
+ })
72
+ },
73
+ onSuccess: (updated) => {
74
+ queryClient.setQueryData(['seller', sellerId, 'profile'], updated)
75
+ // A checklist kind may read these, so it has to re-evaluate.
76
+ void queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'onboarding'] })
77
+ setEditing(false)
78
+ toastManager.add({ type: 'success', title: t('profile.saved') })
79
+ },
80
+ onError: (err) => setFormError(errorMessage(err, t('common.error'))),
81
+ })
82
+
83
+ const rows = [
84
+ { label: t('profile.legal_name'), value: profile.legal_name },
85
+ { label: t('profile.registration_number'), value: profile.registration_number },
86
+ ]
87
+
88
+ return (
89
+ <>
90
+ <Card>
91
+ <CardHeader>
92
+ <CardTitle>{t('profile.business_details')}</CardTitle>
93
+ <CardAction>
94
+ <Button variant="outline" size="sm" onClick={() => setSheetOpen(true)}>
95
+ <PencilIcon className="size-4" />
96
+ {t('profile.edit')}
97
+ </Button>
98
+ </CardAction>
99
+ </CardHeader>
100
+ <CardContent className="flex flex-col gap-2 text-sm">
101
+ {rows.map((row) => (
102
+ <div key={row.label} className="flex justify-between gap-4">
103
+ <span className="text-muted-foreground">{row.label}</span>
104
+ <span className="text-right">{row.value || '—'}</span>
105
+ </div>
106
+ ))}
107
+ </CardContent>
108
+ </Card>
109
+
110
+ <Sheet open={editing} onOpenChange={setSheetOpen}>
111
+ <SheetContent>
112
+ <SheetHeader>
113
+ <SheetTitle>{t('profile.business_details')}</SheetTitle>
114
+ </SheetHeader>
115
+ <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
116
+ {formError && (
117
+ <p role="alert" className="text-destructive text-sm">
118
+ {formError}
119
+ </p>
120
+ )}
121
+ <Field>
122
+ <FieldLabel htmlFor="legal-name">{t('profile.legal_name')}</FieldLabel>
123
+ <Input
124
+ id="legal-name"
125
+ value={legalName}
126
+ onChange={(event) => setLegalName(event.target.value)}
127
+ />
128
+ </Field>
129
+ <Field>
130
+ <FieldLabel htmlFor="registration-number">
131
+ {t('profile.registration_number')}
132
+ </FieldLabel>
133
+ <Input
134
+ id="registration-number"
135
+ value={registrationNumber}
136
+ onChange={(event) => setRegistrationNumber(event.target.value)}
137
+ />
138
+ </Field>
139
+ </div>
140
+ <SheetFooter>
141
+ <Button variant="outline" size="sm" onClick={() => setSheetOpen(false)}>
142
+ {t('common.cancel')}
143
+ </Button>
144
+ <Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
145
+ {t('common.save')}
146
+ </Button>
147
+ </SheetFooter>
148
+ </SheetContent>
149
+ </Sheet>
150
+ </>
151
+ )
152
+ }
@@ -0,0 +1,151 @@
1
+ import {
2
+ AddressFormDialog,
3
+ editableAddressToStockLocationParams,
4
+ stockLocationToAddressBlock,
5
+ stockLocationToEditableAddress,
6
+ } from '@spree/dashboard-core'
7
+ import {
8
+ AddressBlock,
9
+ Badge,
10
+ Button,
11
+ Card,
12
+ CardAction,
13
+ CardContent,
14
+ CardHeader,
15
+ CardTitle,
16
+ StatusBadge,
17
+ toastManager,
18
+ } from '@spree/dashboard-ui'
19
+ import { PencilIcon, PlusIcon } from '@spree/dashboard-ui/icons'
20
+ import type { StockLocationParams } from '@spree/seller-sdk'
21
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
22
+ import { useParams } from '@tanstack/react-router'
23
+ import { useState } from 'react'
24
+ import { useTranslation } from 'react-i18next'
25
+ import { sellerClient } from '../api-client'
26
+
27
+ /**
28
+ * Where customers send returns — the address on the seller's default stock
29
+ * location.
30
+ *
31
+ * A location rather than a loose address because returned goods have to be
32
+ * restocked somewhere the catalog can see: stock movements anchor to a
33
+ * location, so an address on its own would leave the parcel arriving nowhere.
34
+ * The seller sees an address form all the same — the location is the record
35
+ * behind it, not a concept they have to learn to fill this in.
36
+ */
37
+ export function SellerReturnsLocationCard({ headless = false }: { headless?: boolean }) {
38
+ const { t } = useTranslation()
39
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
40
+ const queryClient = useQueryClient()
41
+ const [editing, setEditing] = useState(false)
42
+
43
+ // Two filtered requests rather than one page filtered in the browser, to
44
+ // match `Seller#returns_location` exactly: a location that accepts returns
45
+ // wins outright, and only then does default and name decide. A seller with
46
+ // more locations than one page holds would otherwise see one address while
47
+ // the server sent their returns to another, with nothing on screen saying
48
+ // so — and they would edit the wrong one.
49
+ const activeParams = { active_true: true, limit: 1 }
50
+ const { data: takesReturns, isError: returnsFailed } = useQuery({
51
+ queryKey: ['seller', sellerId, 'stock-locations', 'returns'],
52
+ queryFn: () =>
53
+ sellerClient().stockLocations.list({ ...activeParams, returns_enabled_true: true }),
54
+ })
55
+ const hasReturnsLocation = (takesReturns?.data.length ?? 0) > 0
56
+ const { data: anyActive, isError: anyFailed } = useQuery({
57
+ queryKey: ['seller', sellerId, 'stock-locations'],
58
+ queryFn: () => sellerClient().stockLocations.list(activeParams),
59
+ enabled: !!takesReturns && !hasReturnsLocation,
60
+ })
61
+
62
+ const location = takesReturns?.data[0] ?? anyActive?.data[0]
63
+ // A failed lookup must not read as "no address yet" — that invites the
64
+ // seller to re-enter one they already have.
65
+ const failed = returnsFailed || anyFailed
66
+ const hasAddress = Boolean(location?.address1)
67
+ const title = t('profile.returns_address')
68
+
69
+ const save = useMutation({
70
+ mutationFn: (params: StockLocationParams) => {
71
+ if (!location) throw new Error(t('common.error'))
72
+ return sellerClient().stockLocations.update(location.id, params)
73
+ },
74
+ onSuccess: () => {
75
+ void queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'stock-locations'] })
76
+ // The checklist asks whether this address is filled in, so it has to
77
+ // re-evaluate.
78
+ void queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'onboarding'] })
79
+ setEditing(false)
80
+ toastManager.add({ type: 'success', title: t('profile.saved') })
81
+ },
82
+ onError: (err) =>
83
+ toastManager.add({
84
+ type: 'error',
85
+ title: err instanceof Error ? err.message : t('common.error'),
86
+ }),
87
+ })
88
+
89
+ const body =
90
+ hasAddress && location ? (
91
+ <AddressBlock address={stockLocationToAddressBlock(location)} />
92
+ ) : (
93
+ <p className={failed ? 'text-destructive text-sm' : 'text-muted-foreground text-sm'}>
94
+ {t(failed ? 'common.error' : 'profile.address_not_provided')}
95
+ </p>
96
+ )
97
+
98
+ const editButton = (
99
+ <Button
100
+ variant={hasAddress ? 'outline' : 'default'}
101
+ size="sm"
102
+ disabled={!location}
103
+ onClick={() => setEditing(true)}
104
+ >
105
+ {hasAddress ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />}
106
+ {hasAddress ? t('profile.edit') : t('profile.add_address')}
107
+ </Button>
108
+ )
109
+
110
+ const dialog = editing && location && (
111
+ <AddressFormDialog
112
+ title={title}
113
+ address={stockLocationToEditableAddress(location)}
114
+ open
115
+ business
116
+ onOpenChange={setEditing}
117
+ onSave={(values) => save.mutate(editableAddressToStockLocationParams(values))}
118
+ isPending={save.isPending}
119
+ />
120
+ )
121
+
122
+ if (headless) {
123
+ return (
124
+ <div className="flex flex-col items-start gap-3">
125
+ {body}
126
+ {editButton}
127
+ {dialog}
128
+ </div>
129
+ )
130
+ }
131
+
132
+ return (
133
+ <>
134
+ <Card>
135
+ <CardHeader>
136
+ <CardTitle>
137
+ {title}
138
+ {hasAddress ? (
139
+ <StatusBadge status="complete" label={t('profile.address_filled')} />
140
+ ) : (
141
+ <Badge variant="outline">{t('profile.address_missing')}</Badge>
142
+ )}
143
+ </CardTitle>
144
+ <CardAction>{editButton}</CardAction>
145
+ </CardHeader>
146
+ <CardContent>{body}</CardContent>
147
+ </Card>
148
+ {dialog}
149
+ </>
150
+ )
151
+ }
@@ -0,0 +1,124 @@
1
+ import { getInitials } from '@spree/dashboard-core'
2
+ import {
3
+ Avatar,
4
+ AvatarFallback,
5
+ AvatarImage,
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuTrigger,
10
+ SidebarMenu,
11
+ SidebarMenuItem,
12
+ Skeleton,
13
+ useSidebar,
14
+ } from '@spree/dashboard-ui'
15
+ import { CheckIcon, ChevronsUpDownIcon } from '@spree/dashboard-ui/icons'
16
+ import { useQuery } from '@tanstack/react-query'
17
+ import { Link, useParams } from '@tanstack/react-router'
18
+ import { useTranslation } from 'react-i18next'
19
+ import { sellerClient } from '../api-client'
20
+
21
+ /**
22
+ * Sidebar seller header, mirroring the operator's store switcher.
23
+ *
24
+ * A plain, non-interactive block when the signed-in person runs one seller —
25
+ * which is most of them — and a dropdown listing every seller they hold a
26
+ * role on once there are two or more. Switching is a link to the other
27
+ * seller's route, so the URL stays the source of truth for who is being
28
+ * acted as, exactly as the store switcher navigates by store id.
29
+ *
30
+ * Replaces the fixed "Seller panel" label, which told a seller something
31
+ * they already knew and left their own name off the one piece of chrome
32
+ * that is always on screen.
33
+ */
34
+ export function SellerSwitcher() {
35
+ const { t } = useTranslation()
36
+ const { isMobile, state } = useSidebar()
37
+ const isCollapsed = state === 'collapsed'
38
+ const { sellerId } = useParams({ strict: false }) as { sellerId?: string }
39
+
40
+ // `/me` for the list, the profile for the current seller's own logo — the
41
+ // summary in `/me` carries no image, and the profile query is already
42
+ // loaded by the page behind this.
43
+ const { data: me, isLoading } = useQuery({
44
+ queryKey: ['seller', 'me'],
45
+ queryFn: () => sellerClient().me(),
46
+ })
47
+
48
+ const { data: profile } = useQuery({
49
+ queryKey: ['seller', sellerId, 'profile'],
50
+ queryFn: () => sellerClient().profile.get(),
51
+ enabled: Boolean(sellerId),
52
+ })
53
+
54
+ if (isLoading) return <Skeleton className="h-rail-header-height w-full rounded-xl" />
55
+
56
+ const sellers = me?.sellers ?? []
57
+ const current = sellers.find((seller) => seller.id === sellerId) ?? sellers[0]
58
+
59
+ // Capped at two characters — see the store switcher: one letter per word
60
+ // overflows the avatar as soon as a name runs past two words.
61
+ const initials = getInitials(current?.name, '?')
62
+
63
+ const header = (
64
+ <>
65
+ <Avatar>
66
+ {profile?.square_logo_url || profile?.logo_url ? (
67
+ <AvatarImage src={profile.square_logo_url ?? profile.logo_url ?? undefined} />
68
+ ) : null}
69
+ <AvatarFallback>{initials}</AvatarFallback>
70
+ </Avatar>
71
+ {!isCollapsed && (
72
+ <div className="grid flex-1 text-left text-sm leading-tight">
73
+ <span className="truncate font-medium text-foreground">{current?.name}</span>
74
+ </div>
75
+ )}
76
+ </>
77
+ )
78
+
79
+ if (sellers.length < 2) {
80
+ return (
81
+ <SidebarMenu>
82
+ <SidebarMenuItem className="flex h-rail-header-height items-center">
83
+ <div className="flex w-full items-center gap-2 p-1.5">{header}</div>
84
+ </SidebarMenuItem>
85
+ </SidebarMenu>
86
+ )
87
+ }
88
+
89
+ return (
90
+ <SidebarMenu>
91
+ <SidebarMenuItem className="flex h-rail-header-height items-center">
92
+ <DropdownMenu>
93
+ <DropdownMenuTrigger asChild className="flex w-full items-center">
94
+ <button
95
+ type="button"
96
+ aria-label={t('seller_switcher.label')}
97
+ className="gap-2 rounded-xl p-1.5 outline-hidden transition-colors duration-100 hover:bg-sidebar-accent data-[state=open]:bg-sidebar-accent"
98
+ >
99
+ {header}
100
+ {!isCollapsed && (
101
+ <ChevronsUpDownIcon className="ml-auto size-4 text-muted-foreground" />
102
+ )}
103
+ </button>
104
+ </DropdownMenuTrigger>
105
+ <DropdownMenuContent
106
+ className="min-w-48"
107
+ side={isMobile ? 'bottom' : 'right'}
108
+ align="start"
109
+ sideOffset={8}
110
+ >
111
+ {sellers.map((seller) => (
112
+ <DropdownMenuItem key={seller.id} asChild>
113
+ <Link to="/$sellerId" params={{ sellerId: seller.id }} className="no-underline">
114
+ <span className="flex-1 truncate">{seller.name}</span>
115
+ {seller.id === current?.id && <CheckIcon className="size-4" />}
116
+ </Link>
117
+ </DropdownMenuItem>
118
+ ))}
119
+ </DropdownMenuContent>
120
+ </DropdownMenu>
121
+ </SidebarMenuItem>
122
+ </SidebarMenu>
123
+ )
124
+ }
@@ -0,0 +1,63 @@
1
+ import { mapSpreeErrorsToForm } from '@spree/dashboard-core'
2
+ import { TaxIdentifiersCard } from '@spree/dashboard-ui'
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
4
+ import { useParams } from '@tanstack/react-router'
5
+ import { sellerClient } from '../api-client'
6
+
7
+ /**
8
+ * The seller's own tax registrations, rendered by the same panel a company's
9
+ * are managed from: a seller is a business like any other, and two surfaces
10
+ * for one job is how they come to behave differently.
11
+ */
12
+ export function SellerTaxIdentifiersCard() {
13
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
14
+ const queryClient = useQueryClient()
15
+ const queryKey = ['seller', sellerId, 'tax_identifiers']
16
+
17
+ const { data, isLoading } = useQuery({
18
+ queryKey,
19
+ queryFn: () => sellerClient().taxIdentifiers.list(),
20
+ })
21
+
22
+ // A registration can satisfy an onboarding requirement, so the checklist has
23
+ // to re-evaluate whenever one changes.
24
+ function refresh() {
25
+ void queryClient.invalidateQueries({ queryKey })
26
+ void queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'onboarding'] })
27
+ }
28
+
29
+ const create = useMutation({
30
+ mutationFn: (params: { kind: string; value: string }) =>
31
+ sellerClient().taxIdentifiers.create(params),
32
+ onSuccess: refresh,
33
+ })
34
+ const update = useMutation({
35
+ mutationFn: ({ id, params }: { id: string; params: { kind: string; value: string } }) =>
36
+ sellerClient().taxIdentifiers.update(id, params),
37
+ onSuccess: refresh,
38
+ })
39
+ const remove = useMutation({
40
+ mutationFn: (id: string) => sellerClient().taxIdentifiers.remove(id),
41
+ onSuccess: refresh,
42
+ })
43
+ const validate = useMutation({
44
+ mutationFn: (id: string) => sellerClient().taxIdentifiers.validate(id),
45
+ onSuccess: refresh,
46
+ })
47
+
48
+ return (
49
+ <TaxIdentifiersCard
50
+ identifiers={data?.data ?? []}
51
+ isLoading={isLoading}
52
+ canEdit
53
+ mutations={{
54
+ create: (params) => create.mutateAsync(params),
55
+ update: (id, params) => update.mutateAsync({ id, params }),
56
+ remove: (id) => remove.mutateAsync(id),
57
+ validate: (id) => validate.mutateAsync(id),
58
+ isValidating: validate.isPending,
59
+ mapErrors: mapSpreeErrorsToForm,
60
+ }}
61
+ />
62
+ )
63
+ }
@@ -0,0 +1,32 @@
1
+ import type { AnyRoute, RouterConstructorOptions, RouterHistory } from '@tanstack/react-router'
2
+ import { createRouter } from '@tanstack/react-router'
3
+
4
+ export interface SellerRouterOptions {
5
+ /** Mirrors Vite's `base` so sub-path mounts route correctly. */
6
+ basepath?: string
7
+ }
8
+
9
+ /**
10
+ * Builds the panel's router from a route tree.
11
+ *
12
+ * Hosts own their entry point and pass the tree in, exactly as they do for
13
+ * the operator's dashboard — so a marketplace can add its own seller-facing
14
+ * pages without forking this package.
15
+ */
16
+ export function createSellerRouter<TRouteTree extends AnyRoute>(
17
+ routeTree: TRouteTree,
18
+ { basepath }: SellerRouterOptions = {},
19
+ ) {
20
+ // Cast because the shell's root route requires no router context, which
21
+ // TypeScript cannot prove through the generic tree parameter. The return
22
+ // type stays parameterized by TRouteTree — that's what keeps links typed.
23
+ const options = { routeTree, basepath } as RouterConstructorOptions<
24
+ TRouteTree,
25
+ 'never',
26
+ false,
27
+ RouterHistory,
28
+ Record<string, unknown>
29
+ >
30
+
31
+ return createRouter(options)
32
+ }