@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,289 @@
1
+ import { fulfillmentItemRows } from '@spree/dashboard-core'
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Card,
6
+ CardAction,
7
+ CardContent,
8
+ CardFooter,
9
+ CardHeader,
10
+ CardTitle,
11
+ DropdownMenu,
12
+ DropdownMenuContent,
13
+ DropdownMenuItem,
14
+ DropdownMenuSeparator,
15
+ DropdownMenuTrigger,
16
+ FulfillmentItemList,
17
+ FulfillmentPanel,
18
+ StatusBadge,
19
+ useConfirm,
20
+ } from '@spree/dashboard-ui'
21
+ import {
22
+ EllipsisVerticalIcon,
23
+ PencilIcon,
24
+ PlusIcon,
25
+ PrinterIcon,
26
+ SplitIcon,
27
+ TagIcon,
28
+ TruckIcon,
29
+ XCircleIcon,
30
+ } from '@spree/dashboard-ui/icons'
31
+ import type { Delivery, Fulfillment, Order } from '@spree/seller-sdk'
32
+ import { useState } from 'react'
33
+ import { useTranslation } from 'react-i18next'
34
+ import { useFulfillmentActions } from '../../hooks/use-fulfillments'
35
+ import { printPackingSlip } from '../../lib/packing-slip'
36
+ import { FulfillmentDeliveries } from './fulfillment-deliveries'
37
+ import { FulfillmentDeliveryDialog } from './fulfillment-delivery-dialog'
38
+ import { FulfillmentEditDialog } from './fulfillment-edit-dialog'
39
+ import { FulfillmentFulfillForm } from './fulfillment-fulfill-form'
40
+ import { FulfillmentLabelUploadDialog } from './fulfillment-label-upload-dialog'
41
+ import { FulfillmentSplitDialog } from './fulfillment-split-dialog'
42
+ import { ShippingLabelRow } from './shipping-label-row'
43
+
44
+ // A parcel that has not gone out is the only one still to ship or cancel.
45
+ // Reading these off "not unfulfilled" once badged a canceled parcel as a
46
+ // success.
47
+ const CAN_SHIP = ['unfulfilled']
48
+ const CAN_CANCEL = ['unfulfilled']
49
+
50
+ /** Every parcel owed on this order, and what the seller can do with each. */
51
+ export function FulfillmentsCard({ order }: { order: Order }) {
52
+ const { t } = useTranslation()
53
+ const fulfillments = order.fulfillments ?? []
54
+
55
+ return (
56
+ <Card variant="container">
57
+ <CardHeader>
58
+ <CardTitle>
59
+ <TruckIcon className="size-4" />
60
+ {t('orders.fulfillments.title')}
61
+ {fulfillments.length > 0 && <Badge variant="outline">{fulfillments.length}</Badge>}
62
+ </CardTitle>
63
+ <CardAction>
64
+ {order.fulfillment_status && (
65
+ <StatusBadge
66
+ status={order.fulfillment_status}
67
+ label={t(`orders.fulfillment_statuses.${order.fulfillment_status}`, {
68
+ defaultValue: order.fulfillment_status,
69
+ })}
70
+ />
71
+ )}
72
+ </CardAction>
73
+ </CardHeader>
74
+
75
+ {fulfillments.length === 0 ? (
76
+ <CardContent>
77
+ <p className="py-8 text-center text-muted-foreground">{t('orders.fulfillments.empty')}</p>
78
+ </CardContent>
79
+ ) : (
80
+ <CardContent className="flex flex-col gap-4">
81
+ {fulfillments.map((fulfillment) => (
82
+ <FulfillmentRow key={fulfillment.id} order={order} fulfillment={fulfillment} />
83
+ ))}
84
+ </CardContent>
85
+ )}
86
+ </Card>
87
+ )
88
+ }
89
+
90
+ function FulfillmentRow({ order, fulfillment }: { order: Order; fulfillment: Fulfillment }) {
91
+ const orderId = order.id
92
+ const { t } = useTranslation()
93
+ const confirm = useConfirm()
94
+ const { cancel } = useFulfillmentActions(orderId)
95
+
96
+ const [fulfilling, setFulfilling] = useState(false)
97
+ const [deliveryOpen, setDeliveryOpen] = useState(false)
98
+ const [editingDelivery, setEditingDelivery] = useState<Delivery | undefined>()
99
+ const [labelOpen, setLabelOpen] = useState(false)
100
+ const [editOpen, setEditOpen] = useState(false)
101
+ const [splitOpen, setSplitOpen] = useState(false)
102
+
103
+ const status = fulfillment.status ?? ''
104
+ const shippable = CAN_SHIP.includes(status)
105
+ const cancelable = CAN_CANCEL.includes(status)
106
+ const splittable = shippable && (fulfillment.fulfillment_items?.length ?? 0) > 0
107
+ const deliveries = fulfillment.deliveries ?? []
108
+ // At most one label binds a parcel: the workflows refuse a second while one
109
+ // is active, and a refunded one stays as history.
110
+ const activeLabel = (fulfillment.labels ?? []).find((label) => label.status !== 'refunded')
111
+ // A seller buys postage elsewhere and uploads it, so the slot is offered
112
+ // only while the parcel has no label and has not gone out.
113
+ const canUploadLabel = shippable && !activeLabel
114
+
115
+ async function handleCancel() {
116
+ const ok = await confirm({
117
+ title: t('orders.fulfillments.cancel_title'),
118
+ message: t('orders.fulfillments.cancel_message'),
119
+ variant: 'destructive',
120
+ confirmLabel: t('orders.fulfillments.cancel'),
121
+ })
122
+ if (!ok) return
123
+ cancel.mutate(fulfillment.id)
124
+ }
125
+
126
+ return (
127
+ <FulfillmentPanel
128
+ status={status}
129
+ statusLabel={t(`orders.fulfillment_statuses.${status}`, { defaultValue: status })}
130
+ location={fulfillment.stock_location_name}
131
+ actions={
132
+ <DropdownMenu>
133
+ <DropdownMenuTrigger asChild>
134
+ <Button variant="ghost" size="icon-xs">
135
+ <EllipsisVerticalIcon className="size-4" />
136
+ <span className="sr-only">{t('admin.actions.actions_menu')}</span>
137
+ </Button>
138
+ </DropdownMenuTrigger>
139
+ <DropdownMenuContent align="end">
140
+ {shippable && (
141
+ <DropdownMenuItem onClick={() => setEditOpen(true)}>
142
+ <PencilIcon className="size-4" />
143
+ {t('admin.actions.edit')}
144
+ </DropdownMenuItem>
145
+ )}
146
+ {splittable && (
147
+ <DropdownMenuItem onClick={() => setSplitOpen(true)}>
148
+ <SplitIcon className="size-4" />
149
+ {t('orders.fulfillments.split')}
150
+ </DropdownMenuItem>
151
+ )}
152
+ <DropdownMenuItem onClick={() => printPackingSlip(order, fulfillment, t)}>
153
+ <PrinterIcon className="size-4" />
154
+ {t('orders.fulfillments.print_packing_slip')}
155
+ </DropdownMenuItem>
156
+ <DropdownMenuItem
157
+ onClick={() => {
158
+ setEditingDelivery(undefined)
159
+ setDeliveryOpen(true)
160
+ }}
161
+ >
162
+ <TruckIcon className="size-4" />
163
+ {t('orders.fulfillments.add_delivery')}
164
+ </DropdownMenuItem>
165
+ {canUploadLabel && (
166
+ <DropdownMenuItem onClick={() => setLabelOpen(true)}>
167
+ <TagIcon className="size-4" />
168
+ {t('orders.fulfillments.upload_label')}
169
+ </DropdownMenuItem>
170
+ )}
171
+ {cancelable && (
172
+ <>
173
+ <DropdownMenuSeparator />
174
+ <DropdownMenuItem
175
+ variant="destructive"
176
+ disabled={cancel.isPending}
177
+ onClick={handleCancel}
178
+ >
179
+ <XCircleIcon className="size-4" />
180
+ {t('orders.fulfillments.cancel')}
181
+ </DropdownMenuItem>
182
+ </>
183
+ )}
184
+ </DropdownMenuContent>
185
+ </DropdownMenu>
186
+ }
187
+ >
188
+ {fulfillment.delivery_method_name && (
189
+ <CardContent className="flex items-center justify-between border-border-subtle border-b py-3 text-sm">
190
+ <span className="text-muted-foreground">{fulfillment.delivery_method_name}</span>
191
+ </CardContent>
192
+ )}
193
+
194
+ {activeLabel && !fulfilling && (
195
+ <ShippingLabelRow orderId={orderId} fulfillmentId={fulfillment.id} label={activeLabel} />
196
+ )}
197
+
198
+ <FulfillmentDeliveries
199
+ orderId={orderId}
200
+ fulfillmentId={fulfillment.id}
201
+ deliveries={deliveries}
202
+ onEdit={(delivery) => {
203
+ setEditingDelivery(delivery)
204
+ setDeliveryOpen(true)
205
+ }}
206
+ />
207
+
208
+ {fulfilling ? (
209
+ <FulfillmentFulfillForm
210
+ order={order}
211
+ fulfillment={fulfillment}
212
+ onDone={() => setFulfilling(false)}
213
+ />
214
+ ) : (
215
+ <>
216
+ <FulfillmentItemList rows={fulfillmentItemRows(fulfillment, order.items ?? [])} />
217
+
218
+ {shippable && (
219
+ <CardFooter className="justify-end gap-2 py-3">
220
+ <Button type="button" size="sm" onClick={() => setFulfilling(true)}>
221
+ <TruckIcon data-icon="inline-start" />
222
+ {t('orders.fulfill')}
223
+ </Button>
224
+ </CardFooter>
225
+ )}
226
+
227
+ {!shippable && deliveries.length === 0 && (
228
+ <CardFooter className="justify-end gap-2 py-3">
229
+ <Button
230
+ type="button"
231
+ size="sm"
232
+ variant="outline"
233
+ onClick={() => {
234
+ setEditingDelivery(undefined)
235
+ setDeliveryOpen(true)
236
+ }}
237
+ >
238
+ <PlusIcon data-icon="inline-start" />
239
+ {t('orders.fulfillments.add_tracking')}
240
+ </Button>
241
+ </CardFooter>
242
+ )}
243
+ </>
244
+ )}
245
+
246
+ {/* Mounted only while open, so each form seeds from the parcel as it is
247
+ now. Left mounted, their defaults froze at first render — and after
248
+ shipping, an edit would open empty and save that emptiness over a
249
+ real number. */}
250
+ {deliveryOpen && (
251
+ <FulfillmentDeliveryDialog
252
+ orderId={orderId}
253
+ fulfillmentId={fulfillment.id}
254
+ delivery={editingDelivery}
255
+ open
256
+ onOpenChange={(next) => {
257
+ setDeliveryOpen(next)
258
+ if (!next) setEditingDelivery(undefined)
259
+ }}
260
+ />
261
+ )}
262
+ {editOpen && (
263
+ <FulfillmentEditDialog
264
+ orderId={orderId}
265
+ fulfillment={fulfillment}
266
+ open
267
+ onOpenChange={setEditOpen}
268
+ />
269
+ )}
270
+ {labelOpen && (
271
+ <FulfillmentLabelUploadDialog
272
+ orderId={orderId}
273
+ fulfillmentId={fulfillment.id}
274
+ currency={order.currency}
275
+ open
276
+ onOpenChange={setLabelOpen}
277
+ />
278
+ )}
279
+ {splittable && (
280
+ <FulfillmentSplitDialog
281
+ orderId={orderId}
282
+ fulfillment={fulfillment}
283
+ open={splitOpen}
284
+ onOpenChange={setSplitOpen}
285
+ />
286
+ )}
287
+ </FulfillmentPanel>
288
+ )
289
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * How one unit of an order reads wherever it is listed.
3
+ *
4
+ * A variant carries a SKU and its option values but never a name — the name
5
+ * lives on the product — so the label is built from the line's own name with
6
+ * the options after it, falling back to the SKU and then the raw id.
7
+ *
8
+ * One helper because the same unit appears on the parcel, the fulfil form,
9
+ * the split dialog and all three post-sale cards: built separately they drift,
10
+ * and the same goods were reading as "Shirt — Blue" in one card and
11
+ * "Shirt · Blue" in the next.
12
+ */
13
+ export function lineLabel(
14
+ name: string | null | undefined,
15
+ variant: { sku?: string | null; options_text?: string | null } | null | undefined,
16
+ fallback: string | null | undefined,
17
+ ): string {
18
+ return [name, variant?.options_text].filter(Boolean).join(' · ') || variant?.sku || fallback || ''
19
+ }
20
+
21
+ /** The same label for a row that carries its own name and options directly. */
22
+ export function unitLabel(item: {
23
+ name?: string | null
24
+ options_text?: string | null
25
+ id: string
26
+ }): string {
27
+ return lineLabel(item.name, { options_text: item.options_text }, item.id)
28
+ }
@@ -0,0 +1,74 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import {
3
+ CANCEL_ORDER_DEFAULTS,
4
+ type CancelOrderFormValues,
5
+ cancelOrderFormSchema,
6
+ mapSpreeErrorsToForm,
7
+ } from '@spree/dashboard-core'
8
+ import { ReasonField, OrderCancelDialog as SharedOrderCancelDialog } from '@spree/dashboard-ui'
9
+ import { Controller, useForm } from 'react-hook-form'
10
+ import { useTranslation } from 'react-i18next'
11
+ import { useCancelOrder } from '../../hooks/use-order'
12
+ import { useReasons } from '../../hooks/use-reasons'
13
+
14
+ /**
15
+ * Withdrawing from an order this seller cannot fulfil.
16
+ *
17
+ * The refund is offered because a seller is merchant of record for their own
18
+ * child order — the party who owes the buyer their money back is the party
19
+ * who took it. On a split checkout the workflow settles through this order's
20
+ * own payment shares, so a seller can never reach a sibling's money.
21
+ */
22
+ export function OrderCancelDialog({ orderId, onClose }: { orderId: string; onClose: () => void }) {
23
+ const { t } = useTranslation()
24
+ const cancel = useCancelOrder(orderId)
25
+ const { data: reasonsData } = useReasons('order-cancellation-reasons')
26
+
27
+ const form = useForm<CancelOrderFormValues>({
28
+ resolver: zodResolver(cancelOrderFormSchema),
29
+ defaultValues: CANCEL_ORDER_DEFAULTS,
30
+ })
31
+
32
+ async function onSubmit(values: CancelOrderFormValues) {
33
+ try {
34
+ await cancel.mutateAsync({
35
+ cancel_reason_id: values.cancel_reason_id || undefined,
36
+ cancel_note: values.cancel_note.trim() || undefined,
37
+ refund_payments: values.refund_payments,
38
+ notify_customer: values.notify_customer,
39
+ })
40
+ onClose()
41
+ } catch (err) {
42
+ if (!mapSpreeErrorsToForm(err, form.setError)) throw err
43
+ }
44
+ }
45
+
46
+ return (
47
+ <SharedOrderCancelDialog
48
+ form={form}
49
+ showRefund
50
+ reasonField={
51
+ /* The reasons are the marketplace's — the operator decides what they
52
+ are. A marketplace that has not curated them can still be pulled
53
+ out of, so the empty choice stays rather than forcing one. */
54
+ <Controller
55
+ control={form.control}
56
+ name="cancel_reason_id"
57
+ render={({ field }) => (
58
+ <ReasonField
59
+ id="order-cancellation"
60
+ reasons={(reasonsData?.data ?? []).filter((reason) => reason.active)}
61
+ value={field.value}
62
+ onChange={field.onChange}
63
+ label={t('admin.orders.detail.cancel.reason_label')}
64
+ emptyOptionLabel={t('admin.orders.detail.cancel.no_reason')}
65
+ />
66
+ )}
67
+ />
68
+ }
69
+ onSubmit={onSubmit}
70
+ onClose={onClose}
71
+ pending={cancel.isPending}
72
+ />
73
+ )
74
+ }
@@ -0,0 +1,297 @@
1
+ import { AddressFormDialog, formatStoreDateTime } from '@spree/dashboard-core'
2
+ import {
3
+ AddressBlock,
4
+ Button,
5
+ Card,
6
+ CardAction,
7
+ CardHeader,
8
+ CardTitle,
9
+ cn,
10
+ DropdownMenu,
11
+ DropdownMenuContent,
12
+ DropdownMenuItem,
13
+ DropdownMenuTrigger,
14
+ Separator,
15
+ } from '@spree/dashboard-ui'
16
+ import { EllipsisVerticalIcon, PencilIcon } from '@spree/dashboard-ui/icons'
17
+ import type { Order, OrderAddressParams } from '@spree/seller-sdk'
18
+ import { type ReactNode, useState } from 'react'
19
+ import { useTranslation } from 'react-i18next'
20
+ import { sellerClient } from '../../api-client'
21
+ import { useOrderMutation } from '../../hooks/use-order'
22
+ import { useStoreTimezone } from '../../hooks/use-store-timezone'
23
+
24
+ function SummaryRow({
25
+ label,
26
+ value,
27
+ bold,
28
+ danger,
29
+ highlight,
30
+ }: {
31
+ label: string
32
+ value: ReactNode
33
+ bold?: boolean
34
+ danger?: boolean
35
+ highlight?: boolean
36
+ }) {
37
+ return (
38
+ <div
39
+ className={cn('flex items-center justify-between px-5 py-2.5', highlight && 'bg-accent/50')}
40
+ >
41
+ <span className="text-sm">{label}</span>
42
+ <span
43
+ className={cn('text-sm tabular-nums', bold && 'font-bold', danger && 'text-destructive')}
44
+ >
45
+ {value}
46
+ </span>
47
+ </div>
48
+ )
49
+ }
50
+
51
+ // The marketplace's zone, not the browser's: a seller trading away from the
52
+ // marketplace would otherwise see an order fall on the wrong day.
53
+ function formatDate(iso: string | null | undefined, timezone: string) {
54
+ if (!iso) return '—'
55
+
56
+ return formatStoreDateTime(iso, timezone)
57
+ }
58
+
59
+ /**
60
+ * What the sale came to, and what has been paid against it.
61
+ *
62
+ * The same rows the operator sees on their own order page, so a seller
63
+ * reconciling a payout is reading the same arithmetic — a row appears only
64
+ * when it carries a figure, which is why a simple order shows three lines and
65
+ * a discounted, taxed, shipped one shows eight.
66
+ *
67
+ * The items themselves are listed on the parcels above rather than repeated
68
+ * here: what a seller needs from a line is which parcel it travels in.
69
+ */
70
+ export function OrderSummaryCard({ order }: { order: Order }) {
71
+ const { t } = useTranslation()
72
+ const timezone = useStoreTimezone()
73
+
74
+ const amount = (value: string | null | undefined) => Number.parseFloat(value ?? '0')
75
+ const outstanding = amount(order.amount_due)
76
+ const placed = Boolean(order.completed_at)
77
+
78
+ return (
79
+ <Card>
80
+ <CardHeader>
81
+ <CardTitle>{t('orders.summary.title')}</CardTitle>
82
+ </CardHeader>
83
+
84
+ <div className="py-1">
85
+ <SummaryRow
86
+ label={t('orders.summary.created_at')}
87
+ value={formatDate(order.created_at, timezone)}
88
+ />
89
+ {order.completed_at && (
90
+ <SummaryRow
91
+ label={t('orders.summary.completed_at')}
92
+ value={formatDate(order.completed_at, timezone)}
93
+ />
94
+ )}
95
+
96
+ {order.canceled_at && (
97
+ <>
98
+ <Separator />
99
+ <SummaryRow
100
+ label={t('orders.summary.canceled_at')}
101
+ value={formatDate(order.canceled_at, timezone)}
102
+ />
103
+ {order.cancel_reason_name && (
104
+ <SummaryRow
105
+ label={t('orders.summary.cancel_reason')}
106
+ value={order.cancel_reason_name}
107
+ />
108
+ )}
109
+ {order.cancel_note && (
110
+ <SummaryRow label={t('orders.summary.cancel_note')} value={order.cancel_note} />
111
+ )}
112
+ </>
113
+ )}
114
+
115
+ <Separator />
116
+
117
+ <SummaryRow label={t('orders.summary.currency')} value={order.currency} />
118
+
119
+ <Separator />
120
+
121
+ <SummaryRow label={t('orders.summary.subtotal')} value={order.display_item_total} />
122
+
123
+ {amount(order.delivery_total) > 0 && (
124
+ <SummaryRow label={t('orders.summary.shipping')} value={order.display_delivery_total} />
125
+ )}
126
+
127
+ {amount(order.discount_total) !== 0 && (
128
+ <SummaryRow label={t('orders.summary.promotions')} value={order.display_discount_total} />
129
+ )}
130
+
131
+ {amount(order.adjustment_total) !== 0 && (
132
+ <SummaryRow
133
+ label={t('orders.summary.adjustments')}
134
+ value={order.display_adjustment_total}
135
+ />
136
+ )}
137
+
138
+ {amount(order.included_tax_total) > 0 && (
139
+ <SummaryRow
140
+ label={t('orders.summary.tax_included')}
141
+ value={order.display_included_tax_total}
142
+ />
143
+ )}
144
+
145
+ {/* Shown on a placed order even at zero, so "no tax was charged" reads
146
+ as an answer rather than a missing row. */}
147
+ {(amount(order.additional_tax_total) > 0 ||
148
+ (placed && amount(order.included_tax_total) === 0)) && (
149
+ <SummaryRow
150
+ label={t('orders.summary.tax_additional')}
151
+ value={order.display_additional_tax_total}
152
+ />
153
+ )}
154
+
155
+ <Separator />
156
+
157
+ <SummaryRow label={t('orders.summary.total')} value={order.display_total} bold />
158
+
159
+ {/* What the marketplace charged the seller on this sale. Below the
160
+ order total because it is not part of what the buyer paid — it is
161
+ billed to the seller separately.
162
+
163
+ Always qualified as a marketplace fee, never a bare "fee":
164
+ Spree::Fee is a buyer-facing charge (handling, gift wrap, COD)
165
+ that does roll into the order total, and a bare "total fee" beside
166
+ an order would reasonably be taken for that.
167
+
168
+ The tax is its own row because the seller reclaims it as input
169
+ tax, and that has to be attributable to the sale that produced
170
+ it.
171
+
172
+ Shown on every placed order, including one whose fee came to
173
+ zero: a zero-rated or exempt rate is an answer the seller needs,
174
+ and every order here is a seller's by definition. */}
175
+ {placed && (
176
+ <>
177
+ <Separator />
178
+
179
+ <SummaryRow
180
+ label={t('orders.summary.commission_fee')}
181
+ value={order.display_commission_amount_total}
182
+ />
183
+
184
+ {amount(order.commission_tax_total) > 0 && (
185
+ <SummaryRow
186
+ label={t('orders.summary.commission_tax')}
187
+ value={order.display_commission_tax_total}
188
+ />
189
+ )}
190
+
191
+ <SummaryRow
192
+ label={t('orders.summary.commission_total')}
193
+ value={order.display_commission_total}
194
+ bold
195
+ />
196
+ </>
197
+ )}
198
+
199
+ <Separator />
200
+
201
+ <SummaryRow
202
+ label={t('orders.summary.payment_total')}
203
+ value={order.display_payment_total}
204
+ highlight
205
+ />
206
+ <SummaryRow
207
+ label={t('orders.summary.outstanding_balance')}
208
+ value={order.display_amount_due}
209
+ highlight
210
+ danger={outstanding > 0}
211
+ />
212
+ </div>
213
+ </Card>
214
+ )
215
+ }
216
+
217
+ /**
218
+ * Where the parcel goes and who the invoice is for, and correcting either.
219
+ *
220
+ * The seller is merchant of record for their own child order, so a delivery
221
+ * address the buyer got wrong is theirs to fix and the invoice address is
222
+ * what they bill against.
223
+ *
224
+ * No email address: a seller reaching the customer about a delivery has the
225
+ * phone on the shipping address, and an email is the one contact detail that
226
+ * would let a marketplace's customer be taken off it.
227
+ */
228
+ export function OrderCustomerCard({ order }: { order: Order }) {
229
+ const { t } = useTranslation()
230
+ const [editing, setEditing] = useState<'shipping_address' | 'billing_address' | null>(null)
231
+
232
+ const save = useOrderMutation(order.id, (params: { type: string; address: OrderAddressParams }) =>
233
+ sellerClient().orders.address(order.id, { [params.type]: params.address }),
234
+ )
235
+
236
+ const editable = !order.canceled_at
237
+
238
+ return (
239
+ <>
240
+ <Card>
241
+ <CardHeader>
242
+ <CardTitle>{t('orders.customer.title')}</CardTitle>
243
+ {editable && (
244
+ <CardAction>
245
+ <DropdownMenu>
246
+ <DropdownMenuTrigger
247
+ render={
248
+ <Button variant="ghost" size="icon-xs" aria-label={t('common.actions')}>
249
+ <EllipsisVerticalIcon className="size-4" />
250
+ </Button>
251
+ }
252
+ />
253
+ <DropdownMenuContent align="end">
254
+ <DropdownMenuItem onClick={() => setEditing('shipping_address')}>
255
+ <PencilIcon className="size-4" />
256
+ {t('orders.address_edit.shipping_title')}
257
+ </DropdownMenuItem>
258
+ <DropdownMenuItem onClick={() => setEditing('billing_address')}>
259
+ <PencilIcon className="size-4" />
260
+ {t('orders.address_edit.billing_title')}
261
+ </DropdownMenuItem>
262
+ </DropdownMenuContent>
263
+ </DropdownMenu>
264
+ </CardAction>
265
+ )}
266
+ </CardHeader>
267
+ <div className="flex flex-col gap-4 px-6 pb-6">
268
+ <AddressBlock title={t('orders.shipping_address')} address={order.shipping_address} />
269
+ <AddressBlock title={t('orders.billing_address')} address={order.billing_address} />
270
+ </div>
271
+ </Card>
272
+
273
+ {/* Mounted only while open, so the form seeds from the address as it is
274
+ now rather than freezing at first render. */}
275
+ {editing && (
276
+ <AddressFormDialog
277
+ title={
278
+ editing === 'shipping_address'
279
+ ? t('orders.address_edit.shipping_title')
280
+ : t('orders.address_edit.billing_title')
281
+ }
282
+ address={editing === 'shipping_address' ? order.shipping_address : order.billing_address}
283
+ open
284
+ onOpenChange={(open) => !open && setEditing(null)}
285
+ onSave={(address) =>
286
+ save.mutate(
287
+ { type: editing, address },
288
+ // Kept open on failure so the entered address is not lost.
289
+ { onSuccess: () => setEditing(null) },
290
+ )
291
+ }
292
+ isPending={save.isPending}
293
+ />
294
+ )}
295
+ </>
296
+ )
297
+ }