@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,82 @@
|
|
|
1
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
|
+
import {
|
|
3
|
+
type DeliveryFormValues,
|
|
4
|
+
deliveryFormSchema,
|
|
5
|
+
mapSpreeErrorsToForm,
|
|
6
|
+
} from '@spree/dashboard-core'
|
|
7
|
+
import { DeliveryFormDialog } from '@spree/dashboard-ui'
|
|
8
|
+
import type { Delivery } from '@spree/seller-sdk'
|
|
9
|
+
import { useForm } from 'react-hook-form'
|
|
10
|
+
import { useFulfillmentActions } from '../../hooks/use-fulfillments'
|
|
11
|
+
import { useTrackingCarriers } from '../../hooks/use-reasons'
|
|
12
|
+
|
|
13
|
+
/** What the seller records against one consignment of a parcel they ship themselves. */
|
|
14
|
+
export function FulfillmentDeliveryDialog({
|
|
15
|
+
orderId,
|
|
16
|
+
fulfillmentId,
|
|
17
|
+
delivery,
|
|
18
|
+
open,
|
|
19
|
+
onOpenChange,
|
|
20
|
+
}: {
|
|
21
|
+
orderId: string
|
|
22
|
+
fulfillmentId: string
|
|
23
|
+
delivery?: Delivery
|
|
24
|
+
open: boolean
|
|
25
|
+
onOpenChange: (open: boolean) => void
|
|
26
|
+
}) {
|
|
27
|
+
const { createDelivery, updateDelivery } = useFulfillmentActions(orderId)
|
|
28
|
+
const { data: carriersData } = useTrackingCarriers(open)
|
|
29
|
+
|
|
30
|
+
const carriers = carriersData?.data ?? []
|
|
31
|
+
const carrierKeys = carriers.map((carrier) => carrier.id)
|
|
32
|
+
const carrierLabel = (value: string) =>
|
|
33
|
+
carriers.find((carrier) => carrier.id === value)?.name ?? value
|
|
34
|
+
|
|
35
|
+
const form = useForm<DeliveryFormValues>({
|
|
36
|
+
resolver: zodResolver(deliveryFormSchema),
|
|
37
|
+
defaultValues: {
|
|
38
|
+
tracking_number: delivery?.tracking_number ?? '',
|
|
39
|
+
carrier: delivery?.carrier ?? '',
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
async function onSubmit(values: DeliveryFormValues) {
|
|
44
|
+
try {
|
|
45
|
+
const tracking_number = values.tracking_number.trim()
|
|
46
|
+
const carrier = values.carrier.trim()
|
|
47
|
+
|
|
48
|
+
if (delivery) {
|
|
49
|
+
// Carrier is sent even when empty: clearing it is how a merchant asks
|
|
50
|
+
// for it to be detected from the number again.
|
|
51
|
+
await updateDelivery.mutateAsync({
|
|
52
|
+
fulfillmentId,
|
|
53
|
+
deliveryId: delivery.id,
|
|
54
|
+
tracking_number,
|
|
55
|
+
carrier,
|
|
56
|
+
})
|
|
57
|
+
} else {
|
|
58
|
+
await createDelivery.mutateAsync({
|
|
59
|
+
fulfillmentId,
|
|
60
|
+
tracking_number,
|
|
61
|
+
carrier: carrier || undefined,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
onOpenChange(false)
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (!mapSpreeErrorsToForm(err, form.setError)) throw err
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<DeliveryFormDialog
|
|
72
|
+
form={form}
|
|
73
|
+
open={open}
|
|
74
|
+
onOpenChange={onOpenChange}
|
|
75
|
+
editing={!!delivery}
|
|
76
|
+
carrierKeys={carrierKeys}
|
|
77
|
+
carrierLabel={carrierLabel}
|
|
78
|
+
onSubmit={onSubmit}
|
|
79
|
+
pending={createDelivery.isPending || updateDelivery.isPending}
|
|
80
|
+
/>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { FulfillmentEditDialog as SharedFulfillmentEditDialog } from '@spree/dashboard-ui'
|
|
2
|
+
import { type Fulfillment, SpreeError } from '@spree/seller-sdk'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { useTranslation } from 'react-i18next'
|
|
5
|
+
import { useFulfillmentActions } from '../../hooks/use-fulfillments'
|
|
6
|
+
import { useOrder } from '../../hooks/use-order'
|
|
7
|
+
import { useStockLocations } from '../../hooks/use-stock-locations'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where the parcel ships from and which quoted service carries it.
|
|
11
|
+
*
|
|
12
|
+
* The dialog is shared with the operator's order page; this supplies the
|
|
13
|
+
* seller's view of it. The shelves come from the seller's own endpoint, so the
|
|
14
|
+
* marketplace's warehouses never appear — and the server refuses one anyway.
|
|
15
|
+
*/
|
|
16
|
+
export function FulfillmentEditDialog({
|
|
17
|
+
orderId,
|
|
18
|
+
fulfillment,
|
|
19
|
+
open,
|
|
20
|
+
onOpenChange,
|
|
21
|
+
}: {
|
|
22
|
+
orderId: string
|
|
23
|
+
fulfillment: Fulfillment
|
|
24
|
+
open: boolean
|
|
25
|
+
onOpenChange: (open: boolean) => void
|
|
26
|
+
}) {
|
|
27
|
+
const { t } = useTranslation()
|
|
28
|
+
const { update } = useFulfillmentActions(orderId)
|
|
29
|
+
const { data: locationsData } = useStockLocations(open)
|
|
30
|
+
const { refetch: refetchOrder } = useOrder(orderId)
|
|
31
|
+
const [errorMessage, setErrorMessage] = useState<string>()
|
|
32
|
+
|
|
33
|
+
const originOptions = (locationsData?.data ?? []).map((location) => ({
|
|
34
|
+
value: location.id,
|
|
35
|
+
label: location.name,
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
const rateOptions = (fulfillment.delivery_rates ?? []).map((rate) => ({
|
|
39
|
+
value: rate.id,
|
|
40
|
+
label: `${rate.name} — ${
|
|
41
|
+
Number.parseFloat(rate.cost) === 0 ? t('orders.fulfillments.free') : rate.display_cost
|
|
42
|
+
}`,
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
async function handleSubmit(
|
|
46
|
+
values: { stockLocationId: string; selectedDeliveryRateId: string },
|
|
47
|
+
changed: { origin: boolean; rate: boolean },
|
|
48
|
+
): Promise<'requote' | 'done'> {
|
|
49
|
+
setErrorMessage(undefined)
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
if (changed.origin) {
|
|
53
|
+
await update.mutateAsync({
|
|
54
|
+
fulfillmentId: fulfillment.id,
|
|
55
|
+
stock_location_id: values.stockLocationId,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Moving the parcel re-quotes it: the rates on screen were priced from
|
|
59
|
+
// the shelf it just left. Pull the fresh parcel back so the seller
|
|
60
|
+
// confirms a service that actually applies.
|
|
61
|
+
await refetchOrder()
|
|
62
|
+
return 'requote'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (changed.rate) {
|
|
66
|
+
await update.mutateAsync({
|
|
67
|
+
fulfillmentId: fulfillment.id,
|
|
68
|
+
selected_delivery_rate_id: values.selectedDeliveryRateId,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return 'done'
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// A refused save keeps the dialog open with the server's reason on it;
|
|
75
|
+
// anything else is a real fault and belongs to the caller's toast.
|
|
76
|
+
if (!(err instanceof SpreeError)) throw err
|
|
77
|
+
setErrorMessage(err.message)
|
|
78
|
+
return 'requote'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<SharedFulfillmentEditDialog
|
|
84
|
+
open={open}
|
|
85
|
+
onOpenChange={onOpenChange}
|
|
86
|
+
originOptions={originOptions}
|
|
87
|
+
rateOptions={rateOptions}
|
|
88
|
+
currentOriginId={fulfillment.stock_location_id ?? ''}
|
|
89
|
+
currentRateId={fulfillment.selected_delivery_rate_id ?? ''}
|
|
90
|
+
onSubmit={handleSubmit}
|
|
91
|
+
pending={update.isPending}
|
|
92
|
+
errorMessage={errorMessage}
|
|
93
|
+
/>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
|
+
import {
|
|
3
|
+
type FulfillmentItemRow,
|
|
4
|
+
fulfillmentItemRows,
|
|
5
|
+
mapSpreeErrorsToForm,
|
|
6
|
+
} from '@spree/dashboard-core'
|
|
7
|
+
import {
|
|
8
|
+
type FulfillableRowData,
|
|
9
|
+
FulfillItemsForm,
|
|
10
|
+
type FulfillItemsValues,
|
|
11
|
+
} from '@spree/dashboard-ui'
|
|
12
|
+
import type { Fulfillment, Order } from '@spree/seller-sdk'
|
|
13
|
+
import { useEffect, useRef } from 'react'
|
|
14
|
+
import { useForm } from 'react-hook-form'
|
|
15
|
+
import { useTranslation } from 'react-i18next'
|
|
16
|
+
import { useFulfillmentActions } from '../../hooks/use-fulfillments'
|
|
17
|
+
import { useTrackingCarriers } from '../../hooks/use-reasons'
|
|
18
|
+
import { type FulfillItemsFormValues, fulfillItemsFormSchema } from '../../schemas/fulfillment'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Rows whose fulfillment item no longer maps to a line item are dropped: the
|
|
22
|
+
* fulfill endpoint addresses items by line item, so there is nothing to send
|
|
23
|
+
* for them. They still ship — selecting everything else sends no `items` at
|
|
24
|
+
* all, which ships the whole parcel including them.
|
|
25
|
+
*/
|
|
26
|
+
function fulfillableRows(rows: FulfillmentItemRow[]): FulfillableRowData[] {
|
|
27
|
+
return rows.flatMap((row) => (row.lineItem ? [{ ...row, itemId: row.lineItem.id }] : []))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What the seller sends out of one parcel. */
|
|
31
|
+
export function FulfillmentFulfillForm({
|
|
32
|
+
order,
|
|
33
|
+
fulfillment,
|
|
34
|
+
onDone,
|
|
35
|
+
}: {
|
|
36
|
+
order: Order
|
|
37
|
+
fulfillment: Fulfillment
|
|
38
|
+
onDone: () => void
|
|
39
|
+
}) {
|
|
40
|
+
const { t } = useTranslation()
|
|
41
|
+
const { fulfill } = useFulfillmentActions(order.id)
|
|
42
|
+
const { data: carriersData } = useTrackingCarriers()
|
|
43
|
+
|
|
44
|
+
const carrierOptions = [
|
|
45
|
+
{ value: '', label: t('admin.orders.detail.fulfillments.carrier_auto') },
|
|
46
|
+
...(carriersData?.data ?? []).map((carrier) => ({ value: carrier.id, label: carrier.name })),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
const idPrefix = `fulfill-${fulfillment.id}`
|
|
50
|
+
const rows = fulfillableRows(fulfillmentItemRows(fulfillment, order.items ?? []))
|
|
51
|
+
// The parcel holds units, but none of them can be named by line item — the
|
|
52
|
+
// only case where shipping with nothing picked is right. An empty parcel
|
|
53
|
+
// has nothing to ship and stays unsubmittable.
|
|
54
|
+
const canShipWholeParcel = rows.length === 0 && (fulfillment.fulfillment_items?.length ?? 0) > 0
|
|
55
|
+
|
|
56
|
+
const form = useForm<FulfillItemsFormValues>({
|
|
57
|
+
resolver: zodResolver(fulfillItemsFormSchema),
|
|
58
|
+
defaultValues: {
|
|
59
|
+
items: rows.map((row) => ({ item_id: row.itemId, selected: true, quantity: row.quantity })),
|
|
60
|
+
tracking: fulfillment.tracking ?? '',
|
|
61
|
+
// Detected from the number, or picked in the tracking dialog once the
|
|
62
|
+
// parcel has one.
|
|
63
|
+
tracking_carrier: '',
|
|
64
|
+
notify_customer: true,
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// A refetch re-renders this form with fresh rows, so the values it mirrors
|
|
69
|
+
// have to follow. The effect keys on the parcel's composition rather than on
|
|
70
|
+
// the rows array, so a refetch returning the same units does not wipe what
|
|
71
|
+
// the seller typed.
|
|
72
|
+
const rowsRef = useRef(rows)
|
|
73
|
+
rowsRef.current = rows
|
|
74
|
+
const rowsKey = rows.map((row) => `${row.itemId}:${row.quantity}`).join('|')
|
|
75
|
+
const seededKey = useRef(rowsKey)
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (seededKey.current === rowsKey) return
|
|
79
|
+
seededKey.current = rowsKey
|
|
80
|
+
form.reset({
|
|
81
|
+
...form.getValues(),
|
|
82
|
+
items: rowsRef.current.map((row) => ({
|
|
83
|
+
item_id: row.itemId,
|
|
84
|
+
selected: true,
|
|
85
|
+
quantity: row.quantity,
|
|
86
|
+
})),
|
|
87
|
+
})
|
|
88
|
+
}, [rowsKey, form])
|
|
89
|
+
|
|
90
|
+
const totalAvailable = rows.reduce((sum, row) => sum + row.quantity, 0)
|
|
91
|
+
|
|
92
|
+
async function onSubmit(values: FulfillItemsValues) {
|
|
93
|
+
const items = values.items
|
|
94
|
+
.filter((item) => item.selected && item.quantity > 0)
|
|
95
|
+
.map((item) => ({ item_id: item.item_id, quantity: item.quantity }))
|
|
96
|
+
// An omitted `items` ships the whole parcel, which is only right when
|
|
97
|
+
// nothing was addressable. A partial pick that came out empty, or an
|
|
98
|
+
// empty parcel, is a no-op worth refusing.
|
|
99
|
+
if (items.length === 0 && !canShipWholeParcel) return
|
|
100
|
+
|
|
101
|
+
const everything = items.reduce((sum, item) => sum + item.quantity, 0) === totalAvailable
|
|
102
|
+
const tracking = values.tracking.trim()
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
// Omitting `items` ships the whole parcel, avoiding a pointless split
|
|
106
|
+
// when every unit was selected anyway.
|
|
107
|
+
await fulfill.mutateAsync({
|
|
108
|
+
fulfillmentId: fulfillment.id,
|
|
109
|
+
items: everything ? undefined : items,
|
|
110
|
+
tracking: tracking || undefined,
|
|
111
|
+
tracking_carrier: values.tracking_carrier || undefined,
|
|
112
|
+
notify_customer: values.notify_customer,
|
|
113
|
+
})
|
|
114
|
+
onDone()
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (!mapSpreeErrorsToForm(err, form.setError)) throw err
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<FulfillItemsForm
|
|
122
|
+
form={form}
|
|
123
|
+
rows={rows}
|
|
124
|
+
idPrefix={idPrefix}
|
|
125
|
+
carrierOptions={carrierOptions}
|
|
126
|
+
onSubmit={onSubmit}
|
|
127
|
+
onCancel={onDone}
|
|
128
|
+
canShipWholeParcel={canShipWholeParcel}
|
|
129
|
+
pending={fulfill.isPending}
|
|
130
|
+
/>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
|
+
import {
|
|
3
|
+
EMPTY_FILE_UPLOAD_VALUE,
|
|
4
|
+
FileUploadField,
|
|
5
|
+
type FileUploadValue,
|
|
6
|
+
LABEL_ACCEPT,
|
|
7
|
+
type LabelUploadFormValues,
|
|
8
|
+
labelUploadFormSchema,
|
|
9
|
+
mapSpreeErrorsToForm,
|
|
10
|
+
} from '@spree/dashboard-core'
|
|
11
|
+
import { LabelUploadDialog } from '@spree/dashboard-ui'
|
|
12
|
+
import { useState } from 'react'
|
|
13
|
+
import { useForm } from 'react-hook-form'
|
|
14
|
+
import { useTranslation } from 'react-i18next'
|
|
15
|
+
import { useFulfillmentActions } from '../../hooks/use-fulfillments'
|
|
16
|
+
import { useTrackingCarriers } from '../../hooks/use-reasons'
|
|
17
|
+
|
|
18
|
+
/** Records postage the seller bought elsewhere. Buying and refunding are not here: both run through the marketplace's carrier account, and the endpoint refuses them. */
|
|
19
|
+
export function FulfillmentLabelUploadDialog({
|
|
20
|
+
orderId,
|
|
21
|
+
fulfillmentId,
|
|
22
|
+
currency,
|
|
23
|
+
open,
|
|
24
|
+
onOpenChange,
|
|
25
|
+
}: {
|
|
26
|
+
orderId: string
|
|
27
|
+
fulfillmentId: string
|
|
28
|
+
currency: string
|
|
29
|
+
open: boolean
|
|
30
|
+
onOpenChange: (open: boolean) => void
|
|
31
|
+
}) {
|
|
32
|
+
const { t } = useTranslation()
|
|
33
|
+
const { uploadLabel } = useFulfillmentActions(orderId)
|
|
34
|
+
const { data: carriersData } = useTrackingCarriers(open)
|
|
35
|
+
|
|
36
|
+
const carriers = carriersData?.data ?? []
|
|
37
|
+
const carrierKeys = carriers.map((carrier) => carrier.id)
|
|
38
|
+
const carrierLabel = (value: string) =>
|
|
39
|
+
carriers.find((carrier) => carrier.id === value)?.name ?? value
|
|
40
|
+
|
|
41
|
+
const [file, setFile] = useState<FileUploadValue>(EMPTY_FILE_UPLOAD_VALUE)
|
|
42
|
+
// The signed id does not exist until the upload resolves, so saving during
|
|
43
|
+
// one would silently drop the file.
|
|
44
|
+
const [uploading, setUploading] = useState(false)
|
|
45
|
+
|
|
46
|
+
const form = useForm<LabelUploadFormValues>({
|
|
47
|
+
resolver: zodResolver(labelUploadFormSchema),
|
|
48
|
+
defaultValues: { tracking_number: '', carrier: '', cost: '' },
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
async function onSubmit(values: LabelUploadFormValues) {
|
|
52
|
+
// Guarded here as well as on the server: submitting without one would
|
|
53
|
+
// record a label with no file behind it.
|
|
54
|
+
if (!file.signedId) {
|
|
55
|
+
form.setError('root', { message: t('admin.orders.detail.fulfillments.upload_label_file') })
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await uploadLabel.mutateAsync({
|
|
61
|
+
fulfillmentId,
|
|
62
|
+
file: file.signedId,
|
|
63
|
+
tracking_number: values.tracking_number.trim(),
|
|
64
|
+
carrier: values.carrier.trim() || undefined,
|
|
65
|
+
cost: values.cost.trim() || undefined,
|
|
66
|
+
currency: values.cost.trim() ? currency : undefined,
|
|
67
|
+
})
|
|
68
|
+
onOpenChange(false)
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (!mapSpreeErrorsToForm(err, form.setError)) throw err
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<LabelUploadDialog
|
|
76
|
+
form={form}
|
|
77
|
+
open={open}
|
|
78
|
+
onOpenChange={onOpenChange}
|
|
79
|
+
fileField={
|
|
80
|
+
<FileUploadField
|
|
81
|
+
accept={LABEL_ACCEPT}
|
|
82
|
+
value={file}
|
|
83
|
+
onChange={setFile}
|
|
84
|
+
onUploadingChange={setUploading}
|
|
85
|
+
private
|
|
86
|
+
/>
|
|
87
|
+
}
|
|
88
|
+
carrierKeys={carrierKeys}
|
|
89
|
+
carrierLabel={carrierLabel}
|
|
90
|
+
currency={currency}
|
|
91
|
+
onSubmit={onSubmit}
|
|
92
|
+
pending={uploadLabel.isPending || uploading}
|
|
93
|
+
/>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogBody,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogDescription,
|
|
7
|
+
DialogFooter,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
Field,
|
|
11
|
+
FieldError,
|
|
12
|
+
FieldLabel,
|
|
13
|
+
Input,
|
|
14
|
+
Select,
|
|
15
|
+
SelectContent,
|
|
16
|
+
SelectItem,
|
|
17
|
+
SelectTrigger,
|
|
18
|
+
SelectValue,
|
|
19
|
+
useConfirm,
|
|
20
|
+
} from '@spree/dashboard-ui'
|
|
21
|
+
import type { Fulfillment } from '@spree/seller-sdk'
|
|
22
|
+
import { useState } from 'react'
|
|
23
|
+
import { useTranslation } from 'react-i18next'
|
|
24
|
+
import { useFulfillmentActions } from '../../hooks/use-fulfillments'
|
|
25
|
+
import { unitLabel } from './line-label'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Moves part of a parcel onto one of its own, for goods leaving separately.
|
|
29
|
+
*
|
|
30
|
+
* One variant at a time, which is the granularity the transfer works at —
|
|
31
|
+
* splitting several apart is several splits.
|
|
32
|
+
*/
|
|
33
|
+
export function FulfillmentSplitDialog({
|
|
34
|
+
orderId,
|
|
35
|
+
fulfillment,
|
|
36
|
+
open,
|
|
37
|
+
onOpenChange,
|
|
38
|
+
}: {
|
|
39
|
+
orderId: string
|
|
40
|
+
fulfillment: Fulfillment
|
|
41
|
+
open: boolean
|
|
42
|
+
onOpenChange: (open: boolean) => void
|
|
43
|
+
}) {
|
|
44
|
+
const { t } = useTranslation()
|
|
45
|
+
const confirm = useConfirm()
|
|
46
|
+
const { split } = useFulfillmentActions(orderId)
|
|
47
|
+
|
|
48
|
+
// The endpoint addresses what to move by variant, so a row without one is
|
|
49
|
+
// not splittable — offering it would 404 on a unit the seller just picked
|
|
50
|
+
// out of their own parcel.
|
|
51
|
+
const units = (fulfillment.fulfillment_items ?? []).flatMap((item) =>
|
|
52
|
+
item.variant_id
|
|
53
|
+
? [{ value: item.variant_id, label: unitLabel(item), quantity: item.quantity }]
|
|
54
|
+
: [],
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const [variantId, setVariantId] = useState(units[0]?.value ?? '')
|
|
58
|
+
const [quantity, setQuantity] = useState(1)
|
|
59
|
+
|
|
60
|
+
const selected = units.find((unit) => unit.value === variantId)
|
|
61
|
+
const totalUnits = units.reduce((total, unit) => total + unit.quantity, 0)
|
|
62
|
+
// Moving everything is not a split — it empties the parcel and leaves an
|
|
63
|
+
// orphan behind, which is never what was meant.
|
|
64
|
+
const wouldEmpty = quantity >= totalUnits
|
|
65
|
+
const invalid = !variantId || quantity < 1 || wouldEmpty || quantity > (selected?.quantity ?? 0)
|
|
66
|
+
|
|
67
|
+
async function handleSplit() {
|
|
68
|
+
const ok = await confirm({
|
|
69
|
+
title: t('orders.fulfillments.split_title'),
|
|
70
|
+
message: t('orders.fulfillments.confirm_split'),
|
|
71
|
+
confirmLabel: t('orders.fulfillments.split'),
|
|
72
|
+
})
|
|
73
|
+
if (!ok) return
|
|
74
|
+
|
|
75
|
+
await split
|
|
76
|
+
.mutateAsync({ fulfillmentId: fulfillment.id, variant_id: variantId, quantity })
|
|
77
|
+
.then(() => onOpenChange(false))
|
|
78
|
+
.catch(() => undefined)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
83
|
+
<DialogContent>
|
|
84
|
+
<DialogHeader>
|
|
85
|
+
<DialogTitle>{t('orders.fulfillments.split_title')}</DialogTitle>
|
|
86
|
+
<DialogDescription>{t('orders.fulfillments.split_description')}</DialogDescription>
|
|
87
|
+
</DialogHeader>
|
|
88
|
+
|
|
89
|
+
<DialogBody>
|
|
90
|
+
<div className="flex flex-col gap-4">
|
|
91
|
+
<Field>
|
|
92
|
+
<FieldLabel htmlFor="split-variant">{t('orders.fulfillments.split_item')}</FieldLabel>
|
|
93
|
+
<Select
|
|
94
|
+
items={units}
|
|
95
|
+
value={variantId}
|
|
96
|
+
onValueChange={(value) => setVariantId((value as string) ?? '')}
|
|
97
|
+
>
|
|
98
|
+
<SelectTrigger id="split-variant">
|
|
99
|
+
<SelectValue />
|
|
100
|
+
</SelectTrigger>
|
|
101
|
+
<SelectContent>
|
|
102
|
+
{units.map((unit) => (
|
|
103
|
+
<SelectItem key={unit.value} value={unit.value}>
|
|
104
|
+
{unit.label}
|
|
105
|
+
</SelectItem>
|
|
106
|
+
))}
|
|
107
|
+
</SelectContent>
|
|
108
|
+
</Select>
|
|
109
|
+
</Field>
|
|
110
|
+
|
|
111
|
+
<Field>
|
|
112
|
+
<FieldLabel htmlFor="split-quantity">
|
|
113
|
+
{t('orders.fulfillments.split_quantity')}
|
|
114
|
+
</FieldLabel>
|
|
115
|
+
<Input
|
|
116
|
+
id="split-quantity"
|
|
117
|
+
type="number"
|
|
118
|
+
min={1}
|
|
119
|
+
max={selected?.quantity ?? 1}
|
|
120
|
+
value={quantity}
|
|
121
|
+
// Clearing the field yields NaN, which fails every comparison
|
|
122
|
+
// below — so `invalid` would read false and the split would
|
|
123
|
+
// send a null quantity.
|
|
124
|
+
onChange={(event) => setQuantity(Number(event.target.value) || 0)}
|
|
125
|
+
/>
|
|
126
|
+
{wouldEmpty && <FieldError>{t('orders.fulfillments.split_would_empty')}</FieldError>}
|
|
127
|
+
</Field>
|
|
128
|
+
</div>
|
|
129
|
+
</DialogBody>
|
|
130
|
+
|
|
131
|
+
<DialogFooter>
|
|
132
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
133
|
+
{t('common.cancel')}
|
|
134
|
+
</Button>
|
|
135
|
+
<Button type="button" disabled={invalid || split.isPending} onClick={handleSplit}>
|
|
136
|
+
{split.isPending ? t('orders.fulfillments.splitting') : t('orders.fulfillments.split')}
|
|
137
|
+
</Button>
|
|
138
|
+
</DialogFooter>
|
|
139
|
+
</DialogContent>
|
|
140
|
+
</Dialog>
|
|
141
|
+
)
|
|
142
|
+
}
|