@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.
- package/LICENSE +21 -0
- package/README.md +31 -0
- package/dist/vite-plugin/index.js +51 -0
- package/package.json +64 -0
- package/src/api-client.ts +203 -0
- package/src/components/centered-message.tsx +8 -0
- package/src/components/finance/balance-summary.tsx +69 -0
- package/src/components/onboarding-nav-badge.tsx +38 -0
- package/src/components/orders/fulfillment-deliveries.tsx +116 -0
- package/src/components/orders/fulfillment-delivery-dialog.tsx +82 -0
- package/src/components/orders/fulfillment-edit-dialog.tsx +95 -0
- package/src/components/orders/fulfillment-fulfill-form.tsx +132 -0
- package/src/components/orders/fulfillment-label-upload-dialog.tsx +95 -0
- package/src/components/orders/fulfillment-split-dialog.tsx +142 -0
- package/src/components/orders/fulfillments-card.tsx +289 -0
- package/src/components/orders/line-label.ts +28 -0
- package/src/components/orders/order-cancel-dialog.tsx +74 -0
- package/src/components/orders/order-cards.tsx +297 -0
- package/src/components/orders/order-earnings-card.tsx +99 -0
- package/src/components/orders/order-notes-cards.tsx +83 -0
- package/src/components/orders/order-payment-card.tsx +94 -0
- package/src/components/orders/post-sale-cards.tsx +347 -0
- package/src/components/orders/post-sale-create-dialogs.tsx +201 -0
- package/src/components/orders/returns-card.tsx +258 -0
- package/src/components/orders/shipping-label-row.tsx +124 -0
- package/src/components/product-status-card.tsx +111 -0
- package/src/components/read-row.tsx +16 -0
- package/src/components/retryable-error.tsx +23 -0
- package/src/components/seller-address-card.tsx +123 -0
- package/src/components/seller-business-card.tsx +152 -0
- package/src/components/seller-returns-location-card.tsx +151 -0
- package/src/components/seller-switcher.tsx +124 -0
- package/src/components/seller-tax-identifiers-card.tsx +63 -0
- package/src/create-router.ts +32 -0
- package/src/hooks/use-fulfillments.ts +155 -0
- package/src/hooks/use-ledger.ts +46 -0
- package/src/hooks/use-order.ts +62 -0
- package/src/hooks/use-post-sale.ts +202 -0
- package/src/hooks/use-reasons.ts +34 -0
- package/src/hooks/use-stock-locations.ts +23 -0
- package/src/hooks/use-store-timezone.ts +26 -0
- package/src/i18n.ts +26 -0
- package/src/index.ts +27 -0
- package/src/lib/packing-slip.ts +109 -0
- package/src/locales/en.json +841 -0
- package/src/nav/default.ts +108 -0
- package/src/nav/settings.ts +114 -0
- package/src/pages/accept-invitation.tsx +127 -0
- package/src/pages/delivery-methods.tsx +647 -0
- package/src/pages/earnings.tsx +44 -0
- package/src/pages/forgot-password.tsx +100 -0
- package/src/pages/home.tsx +132 -0
- package/src/pages/login.tsx +94 -0
- package/src/pages/onboarding.tsx +539 -0
- package/src/pages/order.tsx +119 -0
- package/src/pages/orders.tsx +33 -0
- package/src/pages/payout.tsx +154 -0
- package/src/pages/payouts.tsx +32 -0
- package/src/pages/policies.tsx +292 -0
- package/src/pages/product.tsx +250 -0
- package/src/pages/products.tsx +228 -0
- package/src/pages/profile.tsx +389 -0
- package/src/pages/reset-password.tsx +105 -0
- package/src/pages/seller-picker.tsx +46 -0
- package/src/pages/team.tsx +468 -0
- package/src/panel-chrome.tsx +36 -0
- package/src/routeTree.gen.ts +590 -0
- package/src/routes/__root.tsx +15 -0
- package/src/routes/_authenticated/$sellerId/earnings/index.tsx +15 -0
- package/src/routes/_authenticated/$sellerId/index.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/onboarding.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/orders/$orderId.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/orders/index.tsx +15 -0
- package/src/routes/_authenticated/$sellerId/payouts/$payoutId.tsx +7 -0
- package/src/routes/_authenticated/$sellerId/payouts/index.tsx +15 -0
- package/src/routes/_authenticated/$sellerId/products/$productId.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/products/index.tsx +23 -0
- package/src/routes/_authenticated/$sellerId/products/new.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/profile.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/settings/delivery-methods.tsx +14 -0
- package/src/routes/_authenticated/$sellerId/settings/index.tsx +18 -0
- package/src/routes/_authenticated/$sellerId/settings/package-types.tsx +21 -0
- package/src/routes/_authenticated/$sellerId/settings/policies.tsx +14 -0
- package/src/routes/_authenticated/$sellerId/settings/stock-locations.tsx +22 -0
- package/src/routes/_authenticated/$sellerId/settings/team.tsx +6 -0
- package/src/routes/_authenticated/$sellerId/settings.tsx +18 -0
- package/src/routes/_authenticated/$sellerId.tsx +49 -0
- package/src/routes/_authenticated/index.tsx +48 -0
- package/src/routes/_authenticated.tsx +13 -0
- package/src/routes/accept-invitation.$invitationId.tsx +19 -0
- package/src/routes/forgot-password.tsx +15 -0
- package/src/routes/login.tsx +15 -0
- package/src/routes/reset-password.tsx +21 -0
- package/src/schemas/fulfillment.ts +31 -0
- package/src/seller-dashboard.tsx +50 -0
- package/src/styles.css +18 -0
- package/src/tables/delivery-methods.tsx +68 -0
- package/src/tables/orders.tsx +116 -0
- package/src/tables/payouts.tsx +87 -0
- package/src/tables/policies.tsx +37 -0
- package/src/tables/products.tsx +56 -0
- package/src/tables/transfers.tsx +118 -0
- 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
|
+
}
|