@spree/seller-dashboard 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -0
  3. package/dist/vite-plugin/index.js +51 -0
  4. package/package.json +64 -0
  5. package/src/api-client.ts +203 -0
  6. package/src/components/centered-message.tsx +8 -0
  7. package/src/components/finance/balance-summary.tsx +69 -0
  8. package/src/components/onboarding-nav-badge.tsx +38 -0
  9. package/src/components/orders/fulfillment-deliveries.tsx +116 -0
  10. package/src/components/orders/fulfillment-delivery-dialog.tsx +82 -0
  11. package/src/components/orders/fulfillment-edit-dialog.tsx +95 -0
  12. package/src/components/orders/fulfillment-fulfill-form.tsx +132 -0
  13. package/src/components/orders/fulfillment-label-upload-dialog.tsx +95 -0
  14. package/src/components/orders/fulfillment-split-dialog.tsx +142 -0
  15. package/src/components/orders/fulfillments-card.tsx +289 -0
  16. package/src/components/orders/line-label.ts +28 -0
  17. package/src/components/orders/order-cancel-dialog.tsx +74 -0
  18. package/src/components/orders/order-cards.tsx +297 -0
  19. package/src/components/orders/order-earnings-card.tsx +99 -0
  20. package/src/components/orders/order-notes-cards.tsx +83 -0
  21. package/src/components/orders/order-payment-card.tsx +94 -0
  22. package/src/components/orders/post-sale-cards.tsx +347 -0
  23. package/src/components/orders/post-sale-create-dialogs.tsx +201 -0
  24. package/src/components/orders/returns-card.tsx +258 -0
  25. package/src/components/orders/shipping-label-row.tsx +124 -0
  26. package/src/components/product-status-card.tsx +111 -0
  27. package/src/components/read-row.tsx +16 -0
  28. package/src/components/retryable-error.tsx +23 -0
  29. package/src/components/seller-address-card.tsx +123 -0
  30. package/src/components/seller-business-card.tsx +152 -0
  31. package/src/components/seller-returns-location-card.tsx +151 -0
  32. package/src/components/seller-switcher.tsx +124 -0
  33. package/src/components/seller-tax-identifiers-card.tsx +63 -0
  34. package/src/create-router.ts +32 -0
  35. package/src/hooks/use-fulfillments.ts +155 -0
  36. package/src/hooks/use-ledger.ts +46 -0
  37. package/src/hooks/use-order.ts +62 -0
  38. package/src/hooks/use-post-sale.ts +202 -0
  39. package/src/hooks/use-reasons.ts +34 -0
  40. package/src/hooks/use-stock-locations.ts +23 -0
  41. package/src/hooks/use-store-timezone.ts +26 -0
  42. package/src/i18n.ts +26 -0
  43. package/src/index.ts +27 -0
  44. package/src/lib/packing-slip.ts +109 -0
  45. package/src/locales/en.json +841 -0
  46. package/src/nav/default.ts +108 -0
  47. package/src/nav/settings.ts +114 -0
  48. package/src/pages/accept-invitation.tsx +127 -0
  49. package/src/pages/delivery-methods.tsx +647 -0
  50. package/src/pages/earnings.tsx +44 -0
  51. package/src/pages/forgot-password.tsx +100 -0
  52. package/src/pages/home.tsx +132 -0
  53. package/src/pages/login.tsx +94 -0
  54. package/src/pages/onboarding.tsx +539 -0
  55. package/src/pages/order.tsx +119 -0
  56. package/src/pages/orders.tsx +33 -0
  57. package/src/pages/payout.tsx +154 -0
  58. package/src/pages/payouts.tsx +32 -0
  59. package/src/pages/policies.tsx +292 -0
  60. package/src/pages/product.tsx +250 -0
  61. package/src/pages/products.tsx +228 -0
  62. package/src/pages/profile.tsx +389 -0
  63. package/src/pages/reset-password.tsx +105 -0
  64. package/src/pages/seller-picker.tsx +46 -0
  65. package/src/pages/team.tsx +468 -0
  66. package/src/panel-chrome.tsx +36 -0
  67. package/src/routeTree.gen.ts +590 -0
  68. package/src/routes/__root.tsx +15 -0
  69. package/src/routes/_authenticated/$sellerId/earnings/index.tsx +15 -0
  70. package/src/routes/_authenticated/$sellerId/index.tsx +6 -0
  71. package/src/routes/_authenticated/$sellerId/onboarding.tsx +6 -0
  72. package/src/routes/_authenticated/$sellerId/orders/$orderId.tsx +6 -0
  73. package/src/routes/_authenticated/$sellerId/orders/index.tsx +15 -0
  74. package/src/routes/_authenticated/$sellerId/payouts/$payoutId.tsx +7 -0
  75. package/src/routes/_authenticated/$sellerId/payouts/index.tsx +15 -0
  76. package/src/routes/_authenticated/$sellerId/products/$productId.tsx +6 -0
  77. package/src/routes/_authenticated/$sellerId/products/index.tsx +23 -0
  78. package/src/routes/_authenticated/$sellerId/products/new.tsx +6 -0
  79. package/src/routes/_authenticated/$sellerId/profile.tsx +6 -0
  80. package/src/routes/_authenticated/$sellerId/settings/delivery-methods.tsx +14 -0
  81. package/src/routes/_authenticated/$sellerId/settings/index.tsx +18 -0
  82. package/src/routes/_authenticated/$sellerId/settings/package-types.tsx +21 -0
  83. package/src/routes/_authenticated/$sellerId/settings/policies.tsx +14 -0
  84. package/src/routes/_authenticated/$sellerId/settings/stock-locations.tsx +22 -0
  85. package/src/routes/_authenticated/$sellerId/settings/team.tsx +6 -0
  86. package/src/routes/_authenticated/$sellerId/settings.tsx +18 -0
  87. package/src/routes/_authenticated/$sellerId.tsx +49 -0
  88. package/src/routes/_authenticated/index.tsx +48 -0
  89. package/src/routes/_authenticated.tsx +13 -0
  90. package/src/routes/accept-invitation.$invitationId.tsx +19 -0
  91. package/src/routes/forgot-password.tsx +15 -0
  92. package/src/routes/login.tsx +15 -0
  93. package/src/routes/reset-password.tsx +21 -0
  94. package/src/schemas/fulfillment.ts +31 -0
  95. package/src/seller-dashboard.tsx +50 -0
  96. package/src/styles.css +18 -0
  97. package/src/tables/delivery-methods.tsx +68 -0
  98. package/src/tables/orders.tsx +116 -0
  99. package/src/tables/payouts.tsx +87 -0
  100. package/src/tables/policies.tsx +37 -0
  101. package/src/tables/products.tsx +56 -0
  102. package/src/tables/transfers.tsx +118 -0
  103. package/src/vite/index.ts +87 -0
@@ -0,0 +1,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
+ }