@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,647 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defaultPreferences,
|
|
3
|
+
PageHeader,
|
|
4
|
+
PreferencesForm,
|
|
5
|
+
type ResourceSearch,
|
|
6
|
+
ResourceTable,
|
|
7
|
+
typeDescription,
|
|
8
|
+
typeLabel,
|
|
9
|
+
useResourceKey,
|
|
10
|
+
useResourceKeyBuilder,
|
|
11
|
+
} from '@spree/dashboard-core'
|
|
12
|
+
import {
|
|
13
|
+
Button,
|
|
14
|
+
Card,
|
|
15
|
+
CardContent,
|
|
16
|
+
CardHeader,
|
|
17
|
+
CardTitle,
|
|
18
|
+
Field,
|
|
19
|
+
FieldError,
|
|
20
|
+
FieldLabel,
|
|
21
|
+
Input,
|
|
22
|
+
RowActions,
|
|
23
|
+
Select,
|
|
24
|
+
SelectContent,
|
|
25
|
+
SelectItem,
|
|
26
|
+
SelectTrigger,
|
|
27
|
+
SelectValue,
|
|
28
|
+
Sheet,
|
|
29
|
+
SheetContent,
|
|
30
|
+
SheetDescription,
|
|
31
|
+
SheetFooter,
|
|
32
|
+
SheetHeader,
|
|
33
|
+
SheetTitle,
|
|
34
|
+
Switch,
|
|
35
|
+
toastManager,
|
|
36
|
+
useConfirm,
|
|
37
|
+
useRowClickBridge,
|
|
38
|
+
} from '@spree/dashboard-ui'
|
|
39
|
+
import { PlusIcon, TrashIcon } from '@spree/dashboard-ui/icons'
|
|
40
|
+
import type { DeliveryMethod, DeliveryPreferenceField } from '@spree/seller-sdk'
|
|
41
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
42
|
+
import { useParams } from '@tanstack/react-router'
|
|
43
|
+
import { useEffect, useState } from 'react'
|
|
44
|
+
import { Controller, useForm } from 'react-hook-form'
|
|
45
|
+
import { useTranslation } from 'react-i18next'
|
|
46
|
+
import { sellerClient } from '../api-client'
|
|
47
|
+
import '../tables/delivery-methods'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How this seller ships.
|
|
51
|
+
*
|
|
52
|
+
* The list carries the marketplace's shared methods alongside the seller's
|
|
53
|
+
* own, because the question a seller has is "can my goods be shipped", not
|
|
54
|
+
* "what have I made" — and on a marketplace where the operator ships for
|
|
55
|
+
* everyone the honest answer is a list the seller did not create
|
|
56
|
+
* (docs/plans/6.0-multi-vendor-marketplace.md, Decision 13).
|
|
57
|
+
*
|
|
58
|
+
* A shared row is read-only: the API refuses every write against it, so the
|
|
59
|
+
* page offers no actions on one rather than letting a seller discover that
|
|
60
|
+
* from a 404.
|
|
61
|
+
*/
|
|
62
|
+
export function DeliveryMethodsPage({ search }: { search: ResourceSearch }) {
|
|
63
|
+
const { t } = useTranslation()
|
|
64
|
+
const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
|
|
65
|
+
const confirm = useConfirm()
|
|
66
|
+
const queryClient = useQueryClient()
|
|
67
|
+
|
|
68
|
+
const [editingId, setEditingId] = useState<string | null>(null)
|
|
69
|
+
const [creating, setCreating] = useState(false)
|
|
70
|
+
|
|
71
|
+
const buildKey = useResourceKeyBuilder()
|
|
72
|
+
|
|
73
|
+
const invalidate = () => {
|
|
74
|
+
queryClient.invalidateQueries({ queryKey: buildKey('seller-delivery-methods') })
|
|
75
|
+
// Having a way to ship can be an onboarding requirement, so the checklist
|
|
76
|
+
// is no longer what it was.
|
|
77
|
+
queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'onboarding'] })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const deleteMutation = useMutation({
|
|
81
|
+
mutationFn: (id: string) => sellerClient().deliveryMethods.delete(id),
|
|
82
|
+
onSuccess: () => {
|
|
83
|
+
toastManager.add({ type: 'success', title: t('delivery_methods.messages.deleted') })
|
|
84
|
+
invalidate()
|
|
85
|
+
},
|
|
86
|
+
onError: (err) =>
|
|
87
|
+
toastManager.add({
|
|
88
|
+
type: 'error',
|
|
89
|
+
title: err instanceof Error ? err.message : t('common.error'),
|
|
90
|
+
}),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// Only the seller's own rows open the editor — a shared marketplace method
|
|
94
|
+
// has nothing here to change.
|
|
95
|
+
useRowClickBridge('data-delivery-method-id', setEditingId)
|
|
96
|
+
|
|
97
|
+
async function handleDelete(method: DeliveryMethod) {
|
|
98
|
+
const ok = await confirm({
|
|
99
|
+
title: t('delivery_methods.delete_confirm.title'),
|
|
100
|
+
message: t('delivery_methods.delete_confirm.message', { name: method.name }),
|
|
101
|
+
variant: 'destructive',
|
|
102
|
+
confirmLabel: t('common.delete'),
|
|
103
|
+
})
|
|
104
|
+
if (!ok) return
|
|
105
|
+
await deleteMutation.mutateAsync(method.id).catch(() => undefined)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="flex flex-col gap-6">
|
|
110
|
+
<PageHeader
|
|
111
|
+
title={t('delivery_methods.title')}
|
|
112
|
+
subtitle={t('delivery_methods.description')}
|
|
113
|
+
sticky={false}
|
|
114
|
+
/>
|
|
115
|
+
|
|
116
|
+
<ResourceTable<DeliveryMethod>
|
|
117
|
+
hideHeader
|
|
118
|
+
tableKey="seller-delivery-methods"
|
|
119
|
+
queryKey="seller-delivery-methods"
|
|
120
|
+
queryFn={(params) => sellerClient().deliveryMethods.list(params)}
|
|
121
|
+
searchParams={search}
|
|
122
|
+
rowActions={(method) =>
|
|
123
|
+
method.editable ? (
|
|
124
|
+
<RowActions
|
|
125
|
+
actions={[
|
|
126
|
+
{ key: 'edit', onSelect: () => setEditingId(method.id) },
|
|
127
|
+
{
|
|
128
|
+
key: 'delete',
|
|
129
|
+
destructive: true,
|
|
130
|
+
disabled: deleteMutation.isPending,
|
|
131
|
+
onSelect: () => handleDelete(method),
|
|
132
|
+
},
|
|
133
|
+
]}
|
|
134
|
+
/>
|
|
135
|
+
) : null
|
|
136
|
+
}
|
|
137
|
+
actions={
|
|
138
|
+
<Button size="sm" onClick={() => setCreating(true)}>
|
|
139
|
+
<PlusIcon className="size-4" />
|
|
140
|
+
{t('delivery_methods.add')}
|
|
141
|
+
</Button>
|
|
142
|
+
}
|
|
143
|
+
/>
|
|
144
|
+
|
|
145
|
+
{creating && <DeliveryMethodSheet onSaved={invalidate} onClose={() => setCreating(false)} />}
|
|
146
|
+
{editingId && (
|
|
147
|
+
<DeliveryMethodSheet
|
|
148
|
+
methodId={editingId}
|
|
149
|
+
onSaved={invalidate}
|
|
150
|
+
onClose={() => setEditingId(null)}
|
|
151
|
+
/>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface RuleDraft {
|
|
158
|
+
id?: string
|
|
159
|
+
type: string
|
|
160
|
+
preferences: Record<string, unknown>
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface DeliveryMethodFormValues {
|
|
164
|
+
name: string
|
|
165
|
+
admin_name: string
|
|
166
|
+
storefront_visible: boolean
|
|
167
|
+
delivery_profile_id: string
|
|
168
|
+
delivery_zone_id: string
|
|
169
|
+
calculator_type: string
|
|
170
|
+
calculator_preferences: Record<string, unknown>
|
|
171
|
+
rules: RuleDraft[]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const DEFAULTS: DeliveryMethodFormValues = {
|
|
175
|
+
name: '',
|
|
176
|
+
admin_name: '',
|
|
177
|
+
storefront_visible: true,
|
|
178
|
+
delivery_profile_id: '',
|
|
179
|
+
delivery_zone_id: '',
|
|
180
|
+
calculator_type: '',
|
|
181
|
+
calculator_preferences: {},
|
|
182
|
+
rules: [],
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function DeliveryMethodSheet({
|
|
186
|
+
methodId,
|
|
187
|
+
onSaved,
|
|
188
|
+
onClose,
|
|
189
|
+
}: {
|
|
190
|
+
methodId?: string
|
|
191
|
+
onSaved: () => void
|
|
192
|
+
onClose: () => void
|
|
193
|
+
}) {
|
|
194
|
+
const { t } = useTranslation()
|
|
195
|
+
|
|
196
|
+
const { data: method } = useQuery({
|
|
197
|
+
queryKey: useResourceKey('seller-delivery-methods', methodId ?? 'new'),
|
|
198
|
+
queryFn: () => sellerClient().deliveryMethods.get(methodId as string),
|
|
199
|
+
enabled: !!methodId,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// The currencies this marketplace trades in. A calculator set to anything
|
|
203
|
+
// else saves fine and then quotes no rate at checkout, so the picker is
|
|
204
|
+
// narrowed to what the store actually supports.
|
|
205
|
+
const { data: sellerProfile } = useQuery({
|
|
206
|
+
queryKey: useResourceKey('seller-profile', 'currencies'),
|
|
207
|
+
queryFn: () => sellerClient().profile.get(),
|
|
208
|
+
})
|
|
209
|
+
const currencyOptions = sellerProfile?.supported_currencies ?? []
|
|
210
|
+
|
|
211
|
+
// The marketplace's vocabulary this form picks from.
|
|
212
|
+
const { data: allProfiles } = useQuery({
|
|
213
|
+
queryKey: useResourceKey('seller-delivery-profiles', 'all'),
|
|
214
|
+
queryFn: () => sellerClient().deliveryProfiles.list({ limit: 100 }),
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// A seller's method ships by hand, which a digital profile refuses. Offering
|
|
218
|
+
// one would only produce a save that fails on the fulfillment provider — a
|
|
219
|
+
// field this form does not have.
|
|
220
|
+
const profiles = allProfiles?.data.filter((profile) => !profile.digital) ?? []
|
|
221
|
+
|
|
222
|
+
const { data: calculators } = useQuery({
|
|
223
|
+
queryKey: useResourceKey('seller-delivery-calculators', 'all'),
|
|
224
|
+
queryFn: () => sellerClient().deliveryMethods.calculators(),
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const { data: ruleTypes } = useQuery({
|
|
228
|
+
queryKey: useResourceKey('seller-delivery-rule-types', 'all'),
|
|
229
|
+
queryFn: () => sellerClient().deliveryMethods.ruleTypes(),
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
const form = useForm<DeliveryMethodFormValues>({ defaultValues: DEFAULTS })
|
|
233
|
+
const profileId = form.watch('delivery_profile_id')
|
|
234
|
+
const calculatorType = form.watch('calculator_type')
|
|
235
|
+
const rules = form.watch('rules')
|
|
236
|
+
|
|
237
|
+
// A zone only means something under its profile, so the picker follows
|
|
238
|
+
// whichever profile is selected rather than listing every zone the
|
|
239
|
+
// marketplace has drawn.
|
|
240
|
+
const { data: zones } = useQuery({
|
|
241
|
+
queryKey: useResourceKey('seller-delivery-zones', profileId || 'none'),
|
|
242
|
+
queryFn: () =>
|
|
243
|
+
sellerClient().deliveryZones.list({ limit: 100, delivery_profile_id: profileId }),
|
|
244
|
+
enabled: !!profileId,
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
if (!method) return
|
|
249
|
+
form.reset({
|
|
250
|
+
name: method.name,
|
|
251
|
+
admin_name: method.admin_name ?? '',
|
|
252
|
+
storefront_visible: method.storefront_visible,
|
|
253
|
+
delivery_profile_id: method.delivery_profile_id ?? '',
|
|
254
|
+
delivery_zone_id: method.delivery_zone_id ?? '',
|
|
255
|
+
calculator_type: method.calculator_type ?? '',
|
|
256
|
+
calculator_preferences: method.calculator_preferences ?? {},
|
|
257
|
+
rules: (method.rules ?? []).map((rule) => ({
|
|
258
|
+
id: rule.id,
|
|
259
|
+
type: rule.type,
|
|
260
|
+
preferences: rule.preferences ?? {},
|
|
261
|
+
})),
|
|
262
|
+
})
|
|
263
|
+
}, [method, form])
|
|
264
|
+
|
|
265
|
+
// A new method opens on the marketplace's default profile, so a seller who
|
|
266
|
+
// ships ordinary parcels never has to make the choice.
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (methodId || form.getValues('delivery_profile_id')) return
|
|
269
|
+
const fallback = profiles.find((profile) => profile.default) ?? profiles[0]
|
|
270
|
+
if (fallback) form.setValue('delivery_profile_id', fallback.id)
|
|
271
|
+
}, [profiles, methodId, form])
|
|
272
|
+
|
|
273
|
+
// And on a flat rate, which is what almost every seller charges. Leaving
|
|
274
|
+
// the picker blank would show an empty Pricing box while the server
|
|
275
|
+
// quietly created the method free.
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
if (methodId || form.getValues('calculator_type')) return
|
|
278
|
+
const flatRate = calculators?.data.find((calculator) => calculator.type === 'flat_rate')
|
|
279
|
+
if (!flatRate) return
|
|
280
|
+
|
|
281
|
+
form.setValue('calculator_type', flatRate.type)
|
|
282
|
+
form.setValue(
|
|
283
|
+
'calculator_preferences',
|
|
284
|
+
defaultPreferences(renderableSchema(flatRate.preference_schema)),
|
|
285
|
+
)
|
|
286
|
+
}, [calculators, methodId, form])
|
|
287
|
+
|
|
288
|
+
const calculatorSchema = renderableSchema(
|
|
289
|
+
calculators?.data.find((calculator) => calculator.type === calculatorType)?.preference_schema,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
const save = useMutation({
|
|
293
|
+
mutationFn: (values: DeliveryMethodFormValues) => {
|
|
294
|
+
const params = {
|
|
295
|
+
name: values.name,
|
|
296
|
+
admin_name: values.admin_name || null,
|
|
297
|
+
storefront_visible: values.storefront_visible,
|
|
298
|
+
delivery_profile_id: values.delivery_profile_id || undefined,
|
|
299
|
+
delivery_zone_id: values.delivery_zone_id || null,
|
|
300
|
+
...(values.calculator_type ? { calculator_type: values.calculator_type } : {}),
|
|
301
|
+
calculator_preferences: values.calculator_preferences,
|
|
302
|
+
// The array replaces the whole set: a rule dropped here is deleted,
|
|
303
|
+
// which is why every surviving row is re-sent with its id.
|
|
304
|
+
rules: values.rules.map((rule) => ({
|
|
305
|
+
...(rule.id ? { id: rule.id } : {}),
|
|
306
|
+
type: rule.type,
|
|
307
|
+
preferences: rule.preferences,
|
|
308
|
+
})),
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return methodId
|
|
312
|
+
? sellerClient().deliveryMethods.update(methodId, params)
|
|
313
|
+
: sellerClient().deliveryMethods.create(params)
|
|
314
|
+
},
|
|
315
|
+
onSuccess: () => {
|
|
316
|
+
toastManager.add({ type: 'success', title: t('delivery_methods.messages.saved') })
|
|
317
|
+
onSaved()
|
|
318
|
+
onClose()
|
|
319
|
+
},
|
|
320
|
+
// The sheet stays open on failure — closing it would discard what the
|
|
321
|
+
// seller filled in along with the error explaining why it did not save.
|
|
322
|
+
onError: (err) =>
|
|
323
|
+
toastManager.add({
|
|
324
|
+
type: 'error',
|
|
325
|
+
title: err instanceof Error ? err.message : t('common.error'),
|
|
326
|
+
}),
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
const { errors } = form.formState
|
|
330
|
+
|
|
331
|
+
function addRule(type: string) {
|
|
332
|
+
const schema = renderableSchema(
|
|
333
|
+
ruleTypes?.data.find((rule) => rule.type === type)?.preference_schema,
|
|
334
|
+
)
|
|
335
|
+
form.setValue('rules', [...rules, { type, preferences: defaultPreferences(schema) }])
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function removeRule(index: number) {
|
|
339
|
+
form.setValue(
|
|
340
|
+
'rules',
|
|
341
|
+
rules.filter((_, position) => position !== index),
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const unusedRuleTypes = (ruleTypes?.data ?? []).filter(
|
|
346
|
+
(ruleType) => !rules.some((rule) => rule.type === ruleType.type),
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<Sheet open onOpenChange={(next) => !next && onClose()}>
|
|
351
|
+
<SheetContent className="data-[side=right]:max-w-[720px]">
|
|
352
|
+
<SheetHeader>
|
|
353
|
+
<SheetTitle>{method?.name ?? t('delivery_methods.sheet.new_title')}</SheetTitle>
|
|
354
|
+
<SheetDescription>{t('delivery_methods.sheet.description')}</SheetDescription>
|
|
355
|
+
</SheetHeader>
|
|
356
|
+
<form
|
|
357
|
+
onSubmit={form.handleSubmit((values) => save.mutateAsync(values).catch(() => undefined))}
|
|
358
|
+
className="flex min-h-0 flex-1 flex-col"
|
|
359
|
+
>
|
|
360
|
+
<div className="flex flex-1 flex-col gap-6 overflow-y-auto p-4">
|
|
361
|
+
<div className="flex flex-col gap-4">
|
|
362
|
+
<Field>
|
|
363
|
+
<FieldLabel htmlFor="delivery-method-name">
|
|
364
|
+
{t('delivery_methods.fields.name')}
|
|
365
|
+
</FieldLabel>
|
|
366
|
+
<Input
|
|
367
|
+
id="delivery-method-name"
|
|
368
|
+
autoFocus
|
|
369
|
+
aria-invalid={!!errors.name || undefined}
|
|
370
|
+
{...form.register('name', { required: true })}
|
|
371
|
+
/>
|
|
372
|
+
<FieldError errors={[errors.name]} />
|
|
373
|
+
</Field>
|
|
374
|
+
|
|
375
|
+
<Field>
|
|
376
|
+
<FieldLabel htmlFor="delivery-method-admin-name">
|
|
377
|
+
{t('delivery_methods.fields.admin_name')}
|
|
378
|
+
</FieldLabel>
|
|
379
|
+
<Input id="delivery-method-admin-name" {...form.register('admin_name')} />
|
|
380
|
+
</Field>
|
|
381
|
+
|
|
382
|
+
<Field orientation="horizontal">
|
|
383
|
+
<FieldLabel htmlFor="delivery-method-visible">
|
|
384
|
+
{t('delivery_methods.fields.storefront_visible')}
|
|
385
|
+
</FieldLabel>
|
|
386
|
+
<Controller
|
|
387
|
+
control={form.control}
|
|
388
|
+
name="storefront_visible"
|
|
389
|
+
render={({ field }) => (
|
|
390
|
+
<Switch
|
|
391
|
+
id="delivery-method-visible"
|
|
392
|
+
checked={field.value}
|
|
393
|
+
onCheckedChange={field.onChange}
|
|
394
|
+
/>
|
|
395
|
+
)}
|
|
396
|
+
/>
|
|
397
|
+
</Field>
|
|
398
|
+
</div>
|
|
399
|
+
|
|
400
|
+
<Card>
|
|
401
|
+
<CardHeader>
|
|
402
|
+
<CardTitle>{t('delivery_methods.sections.goods')}</CardTitle>
|
|
403
|
+
</CardHeader>
|
|
404
|
+
<CardContent className="flex flex-col gap-4">
|
|
405
|
+
<Field>
|
|
406
|
+
<FieldLabel htmlFor="delivery-method-profile">
|
|
407
|
+
{t('delivery_methods.fields.delivery_profile')}
|
|
408
|
+
</FieldLabel>
|
|
409
|
+
<Controller
|
|
410
|
+
control={form.control}
|
|
411
|
+
name="delivery_profile_id"
|
|
412
|
+
render={({ field }) => (
|
|
413
|
+
<Select
|
|
414
|
+
value={field.value}
|
|
415
|
+
onValueChange={(next) => {
|
|
416
|
+
field.onChange(next)
|
|
417
|
+
// The zone must sit under the method's profile, so
|
|
418
|
+
// one chosen under the old profile cannot survive
|
|
419
|
+
// the change.
|
|
420
|
+
form.setValue('delivery_zone_id', '')
|
|
421
|
+
}}
|
|
422
|
+
>
|
|
423
|
+
<SelectTrigger id="delivery-method-profile">
|
|
424
|
+
<SelectValue>
|
|
425
|
+
{(value) =>
|
|
426
|
+
profiles.find((profile) => profile.id === value)?.name ??
|
|
427
|
+
(value as string)
|
|
428
|
+
}
|
|
429
|
+
</SelectValue>
|
|
430
|
+
</SelectTrigger>
|
|
431
|
+
<SelectContent>
|
|
432
|
+
{profiles.map((profile) => (
|
|
433
|
+
<SelectItem key={profile.id} value={profile.id}>
|
|
434
|
+
{profile.name}
|
|
435
|
+
</SelectItem>
|
|
436
|
+
))}
|
|
437
|
+
</SelectContent>
|
|
438
|
+
</Select>
|
|
439
|
+
)}
|
|
440
|
+
/>
|
|
441
|
+
</Field>
|
|
442
|
+
|
|
443
|
+
<Field>
|
|
444
|
+
<FieldLabel htmlFor="delivery-method-zone">
|
|
445
|
+
{t('delivery_methods.fields.delivery_zone')}
|
|
446
|
+
</FieldLabel>
|
|
447
|
+
<Controller
|
|
448
|
+
control={form.control}
|
|
449
|
+
name="delivery_zone_id"
|
|
450
|
+
render={({ field }) => (
|
|
451
|
+
<Select value={field.value} onValueChange={field.onChange}>
|
|
452
|
+
<SelectTrigger id="delivery-method-zone">
|
|
453
|
+
<SelectValue placeholder={t('delivery_methods.zone.everywhere')}>
|
|
454
|
+
{(value) =>
|
|
455
|
+
zones?.data.find((zone) => zone.id === value)?.name ??
|
|
456
|
+
t('delivery_methods.zone.everywhere')
|
|
457
|
+
}
|
|
458
|
+
</SelectValue>
|
|
459
|
+
</SelectTrigger>
|
|
460
|
+
<SelectContent>
|
|
461
|
+
{zones?.data.map((zone) => (
|
|
462
|
+
<SelectItem key={zone.id} value={zone.id}>
|
|
463
|
+
{zone.name}
|
|
464
|
+
</SelectItem>
|
|
465
|
+
))}
|
|
466
|
+
</SelectContent>
|
|
467
|
+
</Select>
|
|
468
|
+
)}
|
|
469
|
+
/>
|
|
470
|
+
</Field>
|
|
471
|
+
</CardContent>
|
|
472
|
+
</Card>
|
|
473
|
+
|
|
474
|
+
<Card>
|
|
475
|
+
<CardHeader>
|
|
476
|
+
<CardTitle>{t('delivery_methods.sections.price')}</CardTitle>
|
|
477
|
+
</CardHeader>
|
|
478
|
+
<CardContent className="flex flex-col gap-4">
|
|
479
|
+
<Field>
|
|
480
|
+
<FieldLabel htmlFor="delivery-method-calculator">
|
|
481
|
+
{t('delivery_methods.fields.calculator')}
|
|
482
|
+
</FieldLabel>
|
|
483
|
+
<Controller
|
|
484
|
+
control={form.control}
|
|
485
|
+
name="calculator_type"
|
|
486
|
+
render={({ field }) => (
|
|
487
|
+
<Select
|
|
488
|
+
value={field.value}
|
|
489
|
+
onValueChange={(next) => {
|
|
490
|
+
field.onChange(next)
|
|
491
|
+
const schema = renderableSchema(
|
|
492
|
+
calculators?.data.find((calculator) => calculator.type === next)
|
|
493
|
+
?.preference_schema,
|
|
494
|
+
)
|
|
495
|
+
form.setValue('calculator_preferences', defaultPreferences(schema))
|
|
496
|
+
}}
|
|
497
|
+
>
|
|
498
|
+
<SelectTrigger id="delivery-method-calculator">
|
|
499
|
+
<SelectValue>
|
|
500
|
+
{(value) =>
|
|
501
|
+
typeLabel(
|
|
502
|
+
'calculator',
|
|
503
|
+
value as string,
|
|
504
|
+
calculators?.data.find((calculator) => calculator.type === value)
|
|
505
|
+
?.name,
|
|
506
|
+
)
|
|
507
|
+
}
|
|
508
|
+
</SelectValue>
|
|
509
|
+
</SelectTrigger>
|
|
510
|
+
<SelectContent>
|
|
511
|
+
{calculators?.data.map((calculator) => (
|
|
512
|
+
<SelectItem key={calculator.type} value={calculator.type}>
|
|
513
|
+
{typeLabel('calculator', calculator.type, calculator.name)}
|
|
514
|
+
</SelectItem>
|
|
515
|
+
))}
|
|
516
|
+
</SelectContent>
|
|
517
|
+
</Select>
|
|
518
|
+
)}
|
|
519
|
+
/>
|
|
520
|
+
</Field>
|
|
521
|
+
|
|
522
|
+
<Controller
|
|
523
|
+
control={form.control}
|
|
524
|
+
name="calculator_preferences"
|
|
525
|
+
render={({ field }) => (
|
|
526
|
+
<PreferencesForm
|
|
527
|
+
schema={calculatorSchema}
|
|
528
|
+
values={field.value}
|
|
529
|
+
onChange={field.onChange}
|
|
530
|
+
currencyOptions={currencyOptions}
|
|
531
|
+
/>
|
|
532
|
+
)}
|
|
533
|
+
/>
|
|
534
|
+
</CardContent>
|
|
535
|
+
</Card>
|
|
536
|
+
|
|
537
|
+
<Card>
|
|
538
|
+
<CardHeader>
|
|
539
|
+
<CardTitle>{t('delivery_methods.sections.conditions')}</CardTitle>
|
|
540
|
+
</CardHeader>
|
|
541
|
+
<CardContent className="flex flex-col gap-4">
|
|
542
|
+
<p className="text-muted-foreground text-sm">
|
|
543
|
+
{t('delivery_methods.conditions.description')}
|
|
544
|
+
</p>
|
|
545
|
+
|
|
546
|
+
{rules.map((rule, index) => {
|
|
547
|
+
const definition = ruleTypes?.data.find((type) => type.type === rule.type)
|
|
548
|
+
|
|
549
|
+
return (
|
|
550
|
+
<div
|
|
551
|
+
key={rule.id ?? `${rule.type}-${index}`}
|
|
552
|
+
className="flex flex-col gap-3 rounded-lg border border-border-subtle p-3"
|
|
553
|
+
>
|
|
554
|
+
<div className="flex items-center justify-between gap-4">
|
|
555
|
+
<div className="flex flex-col">
|
|
556
|
+
<span className="font-medium text-sm">
|
|
557
|
+
{typeLabel('delivery_method_rule', rule.type, definition?.name)}
|
|
558
|
+
</span>
|
|
559
|
+
{typeDescription(
|
|
560
|
+
'delivery_method_rule',
|
|
561
|
+
rule.type,
|
|
562
|
+
definition?.description,
|
|
563
|
+
) && (
|
|
564
|
+
<span className="text-muted-foreground text-xs">
|
|
565
|
+
{typeDescription(
|
|
566
|
+
'delivery_method_rule',
|
|
567
|
+
rule.type,
|
|
568
|
+
definition?.description,
|
|
569
|
+
)}
|
|
570
|
+
</span>
|
|
571
|
+
)}
|
|
572
|
+
</div>
|
|
573
|
+
<Button
|
|
574
|
+
type="button"
|
|
575
|
+
size="sm"
|
|
576
|
+
variant="ghost"
|
|
577
|
+
onClick={() => removeRule(index)}
|
|
578
|
+
aria-label={t('delivery_methods.conditions.remove')}
|
|
579
|
+
>
|
|
580
|
+
<TrashIcon className="size-4" />
|
|
581
|
+
</Button>
|
|
582
|
+
</div>
|
|
583
|
+
|
|
584
|
+
<PreferencesForm
|
|
585
|
+
schema={renderableSchema(definition?.preference_schema)}
|
|
586
|
+
currencyOptions={currencyOptions}
|
|
587
|
+
values={rule.preferences}
|
|
588
|
+
onChange={(next) =>
|
|
589
|
+
form.setValue(
|
|
590
|
+
'rules',
|
|
591
|
+
rules.map((row, position) =>
|
|
592
|
+
position === index ? { ...row, preferences: next } : row,
|
|
593
|
+
),
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
/>
|
|
597
|
+
</div>
|
|
598
|
+
)
|
|
599
|
+
})}
|
|
600
|
+
|
|
601
|
+
{unusedRuleTypes.length > 0 && (
|
|
602
|
+
<Select value="" onValueChange={addRule}>
|
|
603
|
+
<SelectTrigger>
|
|
604
|
+
<SelectValue placeholder={t('delivery_methods.conditions.add')}>
|
|
605
|
+
{() => t('delivery_methods.conditions.add')}
|
|
606
|
+
</SelectValue>
|
|
607
|
+
</SelectTrigger>
|
|
608
|
+
<SelectContent>
|
|
609
|
+
{unusedRuleTypes.map((ruleType) => (
|
|
610
|
+
<SelectItem key={ruleType.type} value={ruleType.type}>
|
|
611
|
+
{typeLabel('delivery_method_rule', ruleType.type, ruleType.name)}
|
|
612
|
+
</SelectItem>
|
|
613
|
+
))}
|
|
614
|
+
</SelectContent>
|
|
615
|
+
</Select>
|
|
616
|
+
)}
|
|
617
|
+
</CardContent>
|
|
618
|
+
</Card>
|
|
619
|
+
</div>
|
|
620
|
+
<SheetFooter>
|
|
621
|
+
<Button type="button" variant="outline" onClick={onClose} disabled={save.isPending}>
|
|
622
|
+
{t('common.cancel')}
|
|
623
|
+
</Button>
|
|
624
|
+
<Button type="submit" disabled={save.isPending}>
|
|
625
|
+
{save.isPending ? t('common.saving') : t('common.save')}
|
|
626
|
+
</Button>
|
|
627
|
+
</SheetFooter>
|
|
628
|
+
</form>
|
|
629
|
+
</SheetContent>
|
|
630
|
+
</Sheet>
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* The fields of a preference schema the shared form can render.
|
|
636
|
+
*
|
|
637
|
+
* Drops `hash`-typed preferences — today the flat-rate calculator's
|
|
638
|
+
* per-currency `amounts` map, which the operator edits through a dedicated
|
|
639
|
+
* multi-currency control and the generic form would show as the hash itself
|
|
640
|
+
* in a text box. A seller quotes in the store's currency, so `amount` and
|
|
641
|
+
* `currency` are the whole question.
|
|
642
|
+
*/
|
|
643
|
+
function renderableSchema(
|
|
644
|
+
schema: DeliveryPreferenceField[] | undefined,
|
|
645
|
+
): DeliveryPreferenceField[] {
|
|
646
|
+
return (schema ?? []).filter((field) => field.type !== 'hash')
|
|
647
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { PageHeader, type ResourceSearch, ResourceTable } from '@spree/dashboard-core'
|
|
2
|
+
import { ErrorState, Skeleton } from '@spree/dashboard-ui'
|
|
3
|
+
import type { Transfer } from '@spree/seller-sdk'
|
|
4
|
+
import { useTranslation } from 'react-i18next'
|
|
5
|
+
import { sellerClient } from '../api-client'
|
|
6
|
+
import { BalanceSummary } from '../components/finance/balance-summary'
|
|
7
|
+
import { useBalances } from '../hooks/use-ledger'
|
|
8
|
+
import '../tables/transfers'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What this seller has earned: where they stand overall, then the sale-by-sale
|
|
12
|
+
* rows behind it.
|
|
13
|
+
*
|
|
14
|
+
* Read-only. An earning is written when an order is fulfilled and corrected by
|
|
15
|
+
* a reversal when it is refunded, so there is nothing here to edit.
|
|
16
|
+
*/
|
|
17
|
+
export function EarningsPage({ search }: { search: ResourceSearch }) {
|
|
18
|
+
const { t } = useTranslation()
|
|
19
|
+
const { data, isLoading, isError, refetch } = useBalances()
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex flex-col gap-4">
|
|
23
|
+
<PageHeader title={t('earnings.title')} subtitle={t('earnings.subtitle')} sticky={false} />
|
|
24
|
+
|
|
25
|
+
{/* A failed balance request must not fall through to the empty state:
|
|
26
|
+
"you have earned nothing yet" is the opposite of "we could not ask". */}
|
|
27
|
+
{isLoading ? (
|
|
28
|
+
<Skeleton className="h-36 w-full" />
|
|
29
|
+
) : isError ? (
|
|
30
|
+
<ErrorState title={t('earnings.balance.load_error')} onRetry={() => void refetch()} />
|
|
31
|
+
) : (
|
|
32
|
+
<BalanceSummary balances={data?.data ?? []} />
|
|
33
|
+
)}
|
|
34
|
+
|
|
35
|
+
<ResourceTable<Transfer>
|
|
36
|
+
hideHeader
|
|
37
|
+
tableKey="seller-transfers"
|
|
38
|
+
queryKey="seller-transfers-list"
|
|
39
|
+
queryFn={(params) => sellerClient().transfers.list(params)}
|
|
40
|
+
searchParams={search}
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|