@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,201 @@
1
+ import { currencyParts } from '@spree/dashboard-core'
2
+ import {
3
+ Field,
4
+ FieldLabel,
5
+ ReasonField,
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ CreateClaimDialog as SharedCreateClaimDialog,
12
+ CreateReturnDialog as SharedCreateReturnDialog,
13
+ } from '@spree/dashboard-ui'
14
+ import type { Order } from '@spree/seller-sdk'
15
+ import i18n from 'i18next'
16
+ import { useState } from 'react'
17
+ import { useTranslation } from 'react-i18next'
18
+ import { useClaimActions, useReturnActions } from '../../hooks/use-post-sale'
19
+ import { useReasons } from '../../hooks/use-reasons'
20
+ import { useStockLocations } from '../../hooks/use-stock-locations'
21
+ import { unitLabel } from './line-label'
22
+
23
+ /** Units that actually went out, which is all that can come back. */
24
+ export function fulfilledUnits(order: Order) {
25
+ return (order.fulfillments ?? []).flatMap((fulfillment) =>
26
+ (fulfillment.fulfillment_items ?? []).map((item) => ({
27
+ id: item.id,
28
+ label: unitLabel(item),
29
+ quantity: item.quantity,
30
+ })),
31
+ )
32
+ }
33
+
34
+ /**
35
+ * Takes goods back on one of this seller's orders.
36
+ *
37
+ * The shelf list is the seller's own, so a marketplace warehouse never
38
+ * appears — and the server refuses one anyway. Shelves that accept returns
39
+ * lead, the rest stay selectable, since a one-off return has to be routable
40
+ * somewhere even when none is flagged.
41
+ */
42
+ export function CreateReturnDialog({
43
+ order,
44
+ open,
45
+ onOpenChange,
46
+ }: {
47
+ order: Order
48
+ open: boolean
49
+ onOpenChange: (open: boolean) => void
50
+ }) {
51
+ const { t } = useTranslation()
52
+ const { create } = useReturnActions(order.id)
53
+ const [reasonId, setReasonId] = useState('')
54
+ const [stockLocationId, setStockLocationId] = useState('')
55
+ const { data: locationsData } = useStockLocations(open)
56
+
57
+ const locationOptions = (locationsData?.data ?? [])
58
+ .filter((location) => location.active)
59
+ .sort((a, b) => Number(b.returns_enabled) - Number(a.returns_enabled))
60
+ .map((location) => ({
61
+ value: location.id,
62
+ label: location.returns_enabled
63
+ ? location.name
64
+ : t('admin.pages.orders.detail.returns.location_no_returns', { name: location.name }),
65
+ }))
66
+
67
+ if (!open) return null
68
+
69
+ return (
70
+ <SharedCreateReturnDialog
71
+ units={fulfilledUnits(order)}
72
+ reasonField={<ReturnReasonField value={reasonId} onChange={setReasonId} />}
73
+ extraFields={
74
+ <Field>
75
+ <FieldLabel htmlFor="return-location">
76
+ {t('admin.pages.orders.detail.returns.location')}
77
+ </FieldLabel>
78
+ <Select
79
+ items={locationOptions}
80
+ value={stockLocationId}
81
+ onValueChange={(value) => setStockLocationId((value as string) ?? '')}
82
+ >
83
+ <SelectTrigger id="return-location">
84
+ <SelectValue placeholder={t('admin.pages.orders.detail.returns.location_default')} />
85
+ </SelectTrigger>
86
+ <SelectContent>
87
+ {locationOptions.map((option) => (
88
+ <SelectItem key={option.value} value={option.value}>
89
+ {option.label}
90
+ </SelectItem>
91
+ ))}
92
+ </SelectContent>
93
+ </Select>
94
+ </Field>
95
+ }
96
+ onClose={() => onOpenChange(false)}
97
+ pending={create.isPending}
98
+ onSubmit={({ items, memo }) => {
99
+ create
100
+ .mutateAsync({
101
+ items,
102
+ memo,
103
+ reason_id: reasonId || undefined,
104
+ stock_location_id: stockLocationId || undefined,
105
+ })
106
+ .then(() => onOpenChange(false))
107
+ .catch(() => undefined)
108
+ }}
109
+ />
110
+ )
111
+ }
112
+
113
+ /**
114
+ * Reports a problem with a delivery on one of this seller's orders.
115
+ *
116
+ * The lines come from the order rather than its parcels: a claim is about
117
+ * what was bought, and goods that never arrived are exactly what one is for.
118
+ */
119
+ export function CreateClaimDialog({
120
+ order,
121
+ open,
122
+ onOpenChange,
123
+ }: {
124
+ order: Order
125
+ open: boolean
126
+ onOpenChange: (open: boolean) => void
127
+ }) {
128
+ const { create } = useClaimActions(order.id)
129
+ const [reasonId, setReasonId] = useState('')
130
+ const { symbol: currencySymbol } = currencyParts(order.currency, i18n.language)
131
+
132
+ if (!open) return null
133
+
134
+ return (
135
+ <SharedCreateClaimDialog
136
+ lines={(order.items ?? []).map((item) => ({
137
+ id: item.id,
138
+ label: unitLabel(item),
139
+ quantity: item.quantity,
140
+ price: item.price,
141
+ }))}
142
+ currencySymbol={currencySymbol}
143
+ reasonField={<ClaimReasonField value={reasonId} onChange={setReasonId} />}
144
+ onClose={() => onOpenChange(false)}
145
+ pending={create.isPending}
146
+ onSubmit={({ items, memo }) => {
147
+ create
148
+ .mutateAsync({ items, memo, reason_id: reasonId || undefined })
149
+ .then(() => onOpenChange(false))
150
+ .catch(() => undefined)
151
+ }}
152
+ />
153
+ )
154
+ }
155
+
156
+ /**
157
+ * The seller picks from the marketplace's vocabulary — the operator decides
158
+ * what the reasons are, so this reads the store's list rather than one of the
159
+ * seller's own.
160
+ */
161
+ function ReturnReasonField({
162
+ value,
163
+ onChange,
164
+ }: {
165
+ value: string
166
+ onChange: (value: string) => void
167
+ }) {
168
+ const { t } = useTranslation()
169
+ const { data } = useReasons('return-reasons')
170
+
171
+ return (
172
+ <ReasonField
173
+ id="return"
174
+ reasons={(data?.data ?? []).filter((reason) => reason.active)}
175
+ value={value}
176
+ onChange={onChange}
177
+ label={t('orders.post_sale.reason')}
178
+ />
179
+ )
180
+ }
181
+
182
+ function ClaimReasonField({
183
+ value,
184
+ onChange,
185
+ }: {
186
+ value: string
187
+ onChange: (value: string) => void
188
+ }) {
189
+ const { t } = useTranslation()
190
+ const { data } = useReasons('claim-reasons')
191
+
192
+ return (
193
+ <ReasonField
194
+ id="claim"
195
+ reasons={(data?.data ?? []).filter((reason) => reason.active)}
196
+ value={value}
197
+ onChange={onChange}
198
+ label={t('orders.post_sale.reason')}
199
+ />
200
+ )
201
+ }
@@ -0,0 +1,258 @@
1
+ import { currencyParts } from '@spree/dashboard-core'
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Card,
6
+ CardAction,
7
+ CardContent,
8
+ CardHeader,
9
+ CardTitle,
10
+ DropdownMenu,
11
+ DropdownMenuContent,
12
+ DropdownMenuItem,
13
+ DropdownMenuSeparator,
14
+ DropdownMenuTrigger,
15
+ ReturnReceiveDialog,
16
+ ReturnRefundDialog,
17
+ StatusBadge,
18
+ useConfirm,
19
+ } from '@spree/dashboard-ui'
20
+ import { EllipsisVerticalIcon, PlusIcon, RotateCcwIcon } from '@spree/dashboard-ui/icons'
21
+ import type { Order, Return, ReturnLineItem } from '@spree/seller-sdk'
22
+ import i18n from 'i18next'
23
+ import { useState } from 'react'
24
+ import { useTranslation } from 'react-i18next'
25
+ import { useOrderReturns, useReturnActions } from '../../hooks/use-post-sale'
26
+ import { lineLabel } from './line-label'
27
+ import { CreateReturnDialog, fulfilledUnits } from './post-sale-create-dialogs'
28
+
29
+ // Refunded and canceled returns are finished; offering a menu on them would
30
+ // open onto nothing.
31
+ const ACTIONABLE = ['requested', 'approved', 'received']
32
+
33
+ /**
34
+ * Goods coming back on this order.
35
+ *
36
+ * Each status change is its own action rather than an editable field, because
37
+ * each carries different arguments — receiving records what actually arrived,
38
+ * refunding decides where the money goes.
39
+ */
40
+ export function ReturnsCard({ order }: { order: Order }) {
41
+ const { t } = useTranslation()
42
+ const confirm = useConfirm()
43
+ const { data } = useOrderReturns(order.id)
44
+ const { approve, cancel } = useReturnActions(order.id)
45
+
46
+ const [createOpen, setCreateOpen] = useState(false)
47
+ const [receiving, setReceiving] = useState<Return | null>(null)
48
+ const [refunding, setRefunding] = useState<Return | null>(null)
49
+
50
+ const returns = data?.data ?? []
51
+ const canCreate = fulfilledUnits(order).length > 0
52
+
53
+ async function handleCancel(returnRecord: Return) {
54
+ const ok = await confirm({
55
+ title: t('orders.post_sale.returns.cancel_title'),
56
+ message: t('orders.post_sale.returns.cancel_message'),
57
+ variant: 'destructive',
58
+ confirmLabel: t('orders.post_sale.cancel'),
59
+ })
60
+ if (!ok) return
61
+ cancel.mutate(returnRecord.id)
62
+ }
63
+
64
+ return (
65
+ <Card variant="container">
66
+ <CardHeader className="items-center">
67
+ <CardTitle>
68
+ <RotateCcwIcon className="size-4" />
69
+ {t('orders.post_sale.returns.title')}
70
+ </CardTitle>
71
+ {canCreate && (
72
+ <CardAction>
73
+ <Button variant="outline" size="sm" onClick={() => setCreateOpen(true)}>
74
+ <PlusIcon className="size-4" />
75
+ {t('orders.post_sale.returns.create')}
76
+ </Button>
77
+ </CardAction>
78
+ )}
79
+ </CardHeader>
80
+
81
+ <CardContent className="flex flex-col gap-4">
82
+ {returns.length === 0 ? (
83
+ <p className="text-muted-foreground text-sm">{t('orders.post_sale.returns.empty')}</p>
84
+ ) : (
85
+ returns.map((returnRecord) => (
86
+ <Card key={returnRecord.id} variant="nested">
87
+ <CardHeader>
88
+ <CardTitle className="text-sm font-medium">{returnRecord.number}</CardTitle>
89
+ <CardAction className="flex items-center gap-2">
90
+ <StatusBadge
91
+ status={returnRecord.status}
92
+ label={t(`orders.post_sale.statuses.${returnRecord.status}`, {
93
+ defaultValue: returnRecord.status,
94
+ })}
95
+ />
96
+ {ACTIONABLE.includes(returnRecord.status) && (
97
+ <DropdownMenu>
98
+ <DropdownMenuTrigger asChild>
99
+ <Button variant="ghost" size="icon-xs">
100
+ <EllipsisVerticalIcon className="size-4" />
101
+ <span className="sr-only">{t('admin.actions.actions_menu')}</span>
102
+ </Button>
103
+ </DropdownMenuTrigger>
104
+ <DropdownMenuContent align="end">
105
+ {returnRecord.status === 'requested' && (
106
+ <DropdownMenuItem onClick={() => approve.mutate(returnRecord.id)}>
107
+ {t('orders.post_sale.approve')}
108
+ </DropdownMenuItem>
109
+ )}
110
+ {returnRecord.status === 'approved' && (
111
+ <DropdownMenuItem onClick={() => setReceiving(returnRecord)}>
112
+ {t('orders.post_sale.returns.receive')}
113
+ </DropdownMenuItem>
114
+ )}
115
+ {returnRecord.status === 'received' && (
116
+ <DropdownMenuItem onClick={() => setRefunding(returnRecord)}>
117
+ {t('orders.post_sale.returns.refund')}
118
+ </DropdownMenuItem>
119
+ )}
120
+ <DropdownMenuSeparator />
121
+ <DropdownMenuItem
122
+ variant="destructive"
123
+ onClick={() => handleCancel(returnRecord)}
124
+ >
125
+ {t('orders.post_sale.cancel')}
126
+ </DropdownMenuItem>
127
+ </DropdownMenuContent>
128
+ </DropdownMenu>
129
+ )}
130
+ </CardAction>
131
+ </CardHeader>
132
+
133
+ <CardContent className="flex flex-col gap-1.5">
134
+ {returnRecord.return_line_items?.map((line) => (
135
+ <ReturnLineRow key={line.id} line={line} />
136
+ ))}
137
+ <p className="text-muted-foreground text-sm">
138
+ {t('orders.post_sale.returns.refund_total', {
139
+ amount: returnRecord.display_refund_total,
140
+ })}
141
+ </p>
142
+ </CardContent>
143
+ </Card>
144
+ ))
145
+ )}
146
+ </CardContent>
147
+
148
+ <CreateReturnDialog order={order} open={createOpen} onOpenChange={setCreateOpen} />
149
+ {receiving && (
150
+ <ReceiveDialog
151
+ orderId={order.id}
152
+ returnRecord={receiving}
153
+ onOpenChange={() => setReceiving(null)}
154
+ />
155
+ )}
156
+ {refunding && (
157
+ <RefundDialog
158
+ orderId={order.id}
159
+ order={order}
160
+ returnRecord={refunding}
161
+ onOpenChange={() => setRefunding(null)}
162
+ />
163
+ )}
164
+ </Card>
165
+ )
166
+ }
167
+
168
+ function ReturnLineRow({ line }: { line: ReturnLineItem }) {
169
+ const { t } = useTranslation()
170
+ const label = lineLabel(line.name, line.variant, line.variant_id)
171
+
172
+ return (
173
+ <div className="flex items-center justify-between gap-3 text-sm">
174
+ <span className="truncate">{label}</span>
175
+ <span className="flex shrink-0 items-center gap-2 text-muted-foreground">
176
+ {line.received_quantity > 0 && line.received_quantity < line.quantity && (
177
+ <Badge variant="outline">
178
+ {t('orders.post_sale.returns.partially_received', {
179
+ received: line.received_quantity,
180
+ quantity: line.quantity,
181
+ })}
182
+ </Badge>
183
+ )}
184
+ {!line.resellable && (
185
+ <Badge variant="outline">{t('orders.post_sale.returns.not_resellable')}</Badge>
186
+ )}
187
+ × {line.quantity}
188
+ </span>
189
+ </div>
190
+ )
191
+ }
192
+
193
+ /** What actually turned up, and whether it can go back on the shelf. */
194
+
195
+ /** What the seller records as having arrived back. */
196
+ function ReceiveDialog({
197
+ orderId,
198
+ returnRecord,
199
+ onOpenChange,
200
+ }: {
201
+ orderId: string
202
+ returnRecord: Return
203
+ onOpenChange: (open: boolean) => void
204
+ }) {
205
+ const { receive } = useReturnActions(orderId)
206
+
207
+ return (
208
+ <ReturnReceiveDialog
209
+ lines={(returnRecord.return_line_items ?? []).map((line) => ({
210
+ id: line.id,
211
+ label: lineLabel(line.name, line.variant, line.variant_id),
212
+ quantity: line.quantity,
213
+ }))}
214
+ onClose={() => onOpenChange(false)}
215
+ pending={receive.isPending}
216
+ onSubmit={(items) => {
217
+ receive
218
+ .mutateAsync({ returnId: returnRecord.id, items })
219
+ .then(() => onOpenChange(false))
220
+ .catch(() => undefined)
221
+ }}
222
+ />
223
+ )
224
+ }
225
+
226
+ /**
227
+ * The seller took the money for their own child order, so giving it back is
228
+ * theirs. The currency is the order's — a seller has none of their own.
229
+ */
230
+ function RefundDialog({
231
+ orderId,
232
+ order,
233
+ returnRecord,
234
+ onOpenChange,
235
+ }: {
236
+ orderId: string
237
+ order: Order
238
+ returnRecord: Return
239
+ onOpenChange: (open: boolean) => void
240
+ }) {
241
+ const { refund } = useReturnActions(orderId)
242
+ const { symbol: currencySymbol } = currencyParts(order.currency, i18n.language)
243
+
244
+ return (
245
+ <ReturnRefundDialog
246
+ refundableTotal={returnRecord.refundable_total}
247
+ currencySymbol={currencySymbol}
248
+ onClose={() => onOpenChange(false)}
249
+ pending={refund.isPending}
250
+ onSubmit={({ refundMethod, amount }) => {
251
+ refund
252
+ .mutateAsync({ returnId: returnRecord.id, refundMethod, amount })
253
+ .then(() => onOpenChange(false))
254
+ .catch(() => undefined)
255
+ }}
256
+ />
257
+ )
258
+ }
@@ -0,0 +1,124 @@
1
+ import { downloadFromApi, getApiClient, useAuth } from '@spree/dashboard-core'
2
+ import {
3
+ Badge,
4
+ Button,
5
+ CardContent,
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuTrigger,
10
+ toastManager,
11
+ useConfirm,
12
+ } from '@spree/dashboard-ui'
13
+ import {
14
+ EllipsisVerticalIcon,
15
+ PrinterIcon,
16
+ ReceiptTextIcon,
17
+ TrashIcon,
18
+ } from '@spree/dashboard-ui/icons'
19
+ import type { ShippingLabel } from '@spree/seller-sdk'
20
+ import { useTranslation } from 'react-i18next'
21
+ import { useFulfillmentActions } from '../../hooks/use-fulfillments'
22
+
23
+ /**
24
+ * The label bound to a parcel: what it cost and how to print it.
25
+ *
26
+ * Refunding is absent by design — postage bought through the marketplace's
27
+ * carrier account is the operator's to reverse, and the endpoint refuses it.
28
+ * Only a label the seller uploaded can be removed, since that is the only one
29
+ * no carrier holds a record of.
30
+ */
31
+ export function ShippingLabelRow({
32
+ orderId,
33
+ fulfillmentId,
34
+ label,
35
+ }: {
36
+ orderId: string
37
+ fulfillmentId: string
38
+ label: ShippingLabel
39
+ }) {
40
+ const { t } = useTranslation()
41
+ const { token } = useAuth()
42
+ const confirm = useConfirm()
43
+ const { deleteLabel } = useFulfillmentActions(orderId)
44
+
45
+ const deletable = label.source === 'uploaded'
46
+
47
+ async function print() {
48
+ if (!label.download_url) return
49
+
50
+ // A rejected download inside a click handler is otherwise unhandled, so
51
+ // the file silently never arrives.
52
+ try {
53
+ await downloadFromApi(
54
+ token,
55
+ label.download_url,
56
+ `${label.tracking_number ?? 'label'}.${label.format ?? 'pdf'}`,
57
+ getApiClient().downloadHeaders?.() ?? {},
58
+ )
59
+ } catch {
60
+ // Deliberately not the thrown message: `downloadFromApi` reports the
61
+ // transport in English ("Download failed: 404"), which would land
62
+ // untranslated in a localized panel and tells the merchant nothing they
63
+ // can act on.
64
+ toastManager.add({ type: 'error', title: t('admin.errors.label_download_failed') })
65
+ }
66
+ }
67
+
68
+ async function handleDelete() {
69
+ const ok = await confirm({
70
+ message: t('orders.fulfillments.confirm_delete_label'),
71
+ variant: 'destructive',
72
+ confirmLabel: t('common.delete'),
73
+ })
74
+ if (!ok) return
75
+ deleteLabel.mutate({ fulfillmentId, labelId: label.id })
76
+ }
77
+
78
+ return (
79
+ <CardContent className="flex flex-wrap items-center justify-between gap-2 border-border-subtle border-b py-3 text-sm">
80
+ <div className="flex flex-wrap items-center gap-2">
81
+ <ReceiptTextIcon className="size-4 text-muted-foreground" />
82
+ <span className="text-muted-foreground">{t('orders.fulfillments.label_title')}</span>
83
+ {label.source === 'uploaded' && (
84
+ <Badge variant="secondary">{t('orders.fulfillments.label_uploaded')}</Badge>
85
+ )}
86
+ {label.display_cost && (
87
+ <span className="text-muted-foreground">
88
+ {t('orders.fulfillments.label_cost', { amount: label.display_cost })}
89
+ </span>
90
+ )}
91
+ </div>
92
+
93
+ <div className="flex items-center gap-2">
94
+ {label.download_url && (
95
+ <Button type="button" variant="outline" size="sm" onClick={print}>
96
+ <PrinterIcon data-icon="inline-start" />
97
+ {t('orders.fulfillments.print_label')}
98
+ </Button>
99
+ )}
100
+
101
+ {deletable && (
102
+ <DropdownMenu>
103
+ <DropdownMenuTrigger asChild>
104
+ <Button variant="ghost" size="icon-xs">
105
+ <EllipsisVerticalIcon className="size-4" />
106
+ <span className="sr-only">{t('admin.actions.actions_menu')}</span>
107
+ </Button>
108
+ </DropdownMenuTrigger>
109
+ <DropdownMenuContent align="end">
110
+ <DropdownMenuItem
111
+ variant="destructive"
112
+ disabled={deleteLabel.isPending}
113
+ onClick={handleDelete}
114
+ >
115
+ <TrashIcon className="size-4" />
116
+ {t('orders.fulfillments.delete_label')}
117
+ </DropdownMenuItem>
118
+ </DropdownMenuContent>
119
+ </DropdownMenu>
120
+ )}
121
+ </div>
122
+ </CardContent>
123
+ )
124
+ }
@@ -0,0 +1,111 @@
1
+ import {
2
+ Button,
3
+ Card,
4
+ CardContent,
5
+ CardHeader,
6
+ CardTitle,
7
+ StatusBadge,
8
+ toastManager,
9
+ useConfirm,
10
+ } from '@spree/dashboard-ui'
11
+ import type { Product } from '@spree/seller-sdk'
12
+ import { useMutation } from '@tanstack/react-query'
13
+ import { useTranslation } from 'react-i18next'
14
+ import { sellerClient } from '../api-client'
15
+
16
+ /**
17
+ * Where a product stands with the marketplace, and the one move available
18
+ * from here.
19
+ *
20
+ * Not a status dropdown: a seller cannot choose to be on sale. They submit
21
+ * and the operator decides, so each status offers only what it can actually
22
+ * do next (docs/plans/6.0-seller-product-submission.md).
23
+ */
24
+ export function ProductStatusCard({ product, onDone }: { product: Product; onDone: () => void }) {
25
+ const { t } = useTranslation()
26
+ const confirm = useConfirm()
27
+
28
+ const move = useMutation({
29
+ mutationFn: (action: 'submit' | 'draft' | 'archive') =>
30
+ sellerClient().products[action](product.id),
31
+ onSuccess: () => {
32
+ toastManager.add({ type: 'success', title: t('products.status_updated') })
33
+ onDone()
34
+ },
35
+ onError: (err) =>
36
+ toastManager.add({
37
+ type: 'error',
38
+ title: err instanceof Error ? err.message : t('common.error'),
39
+ }),
40
+ })
41
+
42
+ const status = product.status
43
+ const canSubmit = status === 'draft' || status === 'rejected'
44
+ const canTakeDown = status === 'active' || status === 'proposed'
45
+
46
+ return (
47
+ <Card>
48
+ <CardHeader>
49
+ <CardTitle>{t('products.fields.status')}</CardTitle>
50
+ </CardHeader>
51
+ <CardContent className="flex flex-col items-start gap-3">
52
+ <StatusBadge status={status} label={t(`products.statuses.${status}`)} />
53
+
54
+ <p className="text-muted-foreground text-sm">{t(`products.status_help.${status}`)}</p>
55
+
56
+ {/* Why the marketplace turned it down, so the seller knows what to
57
+ fix. It lives on the submission rather than the product: a note
58
+ the seller could overwrite by saving their own product was no
59
+ record of anything. */}
60
+ {status === 'rejected' && product.submission?.review_note && (
61
+ <p className="text-sm">
62
+ {t('products.rejection_reason', { reason: product.submission.review_note })}
63
+ </p>
64
+ )}
65
+
66
+ <div className="flex flex-wrap gap-2">
67
+ {canSubmit && (
68
+ <Button disabled={move.isPending} onClick={() => move.mutate('submit')}>
69
+ {t('products.submit_for_review')}
70
+ </Button>
71
+ )}
72
+
73
+ {canTakeDown && (
74
+ <Button
75
+ variant="outline"
76
+ disabled={move.isPending}
77
+ onClick={async () => {
78
+ const confirmed = await confirm({
79
+ title: t('products.take_down_confirm_title'),
80
+ message: t('products.take_down_confirm_description'),
81
+ confirmLabel: t('products.take_down'),
82
+ })
83
+ if (confirmed) move.mutate('draft')
84
+ }}
85
+ >
86
+ {t('products.take_down')}
87
+ </Button>
88
+ )}
89
+
90
+ {status !== 'archived' && (
91
+ <Button
92
+ variant="outline"
93
+ disabled={move.isPending}
94
+ onClick={async () => {
95
+ const confirmed = await confirm({
96
+ title: t('products.archive_confirm_title'),
97
+ message: t('products.archive_confirm_description'),
98
+ confirmLabel: t('products.archive'),
99
+ variant: 'destructive',
100
+ })
101
+ if (confirmed) move.mutate('archive')
102
+ }}
103
+ >
104
+ {t('products.archive')}
105
+ </Button>
106
+ )}
107
+ </div>
108
+ </CardContent>
109
+ </Card>
110
+ )
111
+ }