@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,154 @@
1
+ import { formatStoreDateTime, PageHeader } from '@spree/dashboard-core'
2
+ import {
3
+ Card,
4
+ CardHeader,
5
+ CardTitle,
6
+ Pagination,
7
+ ResourceLayout,
8
+ Skeleton,
9
+ StatusBadge,
10
+ Table,
11
+ TableBody,
12
+ TableCell,
13
+ TableHead,
14
+ TableHeader,
15
+ TableRow,
16
+ } from '@spree/dashboard-ui'
17
+ import { HandCoinsIcon } from '@spree/dashboard-ui/icons'
18
+ import { Link, useParams } from '@tanstack/react-router'
19
+ import { useState } from 'react'
20
+ import { useTranslation } from 'react-i18next'
21
+ import { CenteredMessage } from '../components/centered-message'
22
+ import { ReadRow } from '../components/read-row'
23
+ import { RetryableError } from '../components/retryable-error'
24
+ import { usePayout, useTransfers } from '../hooks/use-ledger'
25
+ import { useStoreTimezone } from '../hooks/use-store-timezone'
26
+
27
+ /**
28
+ * One settlement, and the earnings it covers.
29
+ *
30
+ * The itemisation is the point: a seller reconciling a deposit against their
31
+ * own books needs to see which sales it paid for, not just a total.
32
+ */
33
+ export function PayoutPage() {
34
+ const { t } = useTranslation()
35
+ const { sellerId, payoutId } = useParams({ from: '/_authenticated/$sellerId/payouts/$payoutId' })
36
+
37
+ const timezone = useStoreTimezone()
38
+ const [page, setPage] = useState(1)
39
+
40
+ const { data: payout, isLoading, isError, refetch } = usePayout(payoutId)
41
+ const {
42
+ data: transfers,
43
+ isPending: transfersPending,
44
+ isError: transfersFailed,
45
+ } = useTransfers({ payout_id_eq: payoutId }, page)
46
+
47
+ if (isLoading) return <CenteredMessage>{t('common.loading')}</CenteredMessage>
48
+ if (isError) return <RetryableError onRetry={() => refetch()} />
49
+ if (!payout) return <CenteredMessage>{t('payouts.not_found')}</CenteredMessage>
50
+
51
+ const rows = transfers?.data ?? []
52
+
53
+ return (
54
+ <ResourceLayout
55
+ header={
56
+ <PageHeader
57
+ title={payout.display_amount}
58
+ subtitle={formatStoreDateTime(payout.created_at, timezone)}
59
+ backTo="payouts"
60
+ badges={
61
+ <StatusBadge
62
+ status={payout.status}
63
+ label={t(`earnings.statuses.${payout.status}`, { defaultValue: payout.status })}
64
+ />
65
+ }
66
+ resource={{ id: payout.id }}
67
+ />
68
+ }
69
+ main={
70
+ <Card>
71
+ <CardHeader>
72
+ <CardTitle>
73
+ <HandCoinsIcon className="size-4" />
74
+ {/* The settlement's own count, not the rows this page happened
75
+ to load — the list is paged. */}
76
+ {t('payouts.detail.covers', { count: payout.transfers_count })}
77
+ </CardTitle>
78
+ </CardHeader>
79
+
80
+ {/* The empty line is only true once the earnings actually loaded —
81
+ a pending or failed query says nothing about what this
82
+ settlement covers. */}
83
+ {transfersPending ? (
84
+ <div className="px-6 pb-6">
85
+ <Skeleton className="h-16 w-full" />
86
+ </div>
87
+ ) : transfersFailed ? (
88
+ <p className="px-6 pb-6 text-sm text-destructive">{t('payouts.detail.covers_error')}</p>
89
+ ) : rows.length === 0 ? (
90
+ <p className="px-6 pb-6 text-sm text-muted-foreground">
91
+ {t('payouts.detail.covers_empty')}
92
+ </p>
93
+ ) : (
94
+ <Table>
95
+ <TableHeader>
96
+ <TableRow>
97
+ <TableHead>{t('earnings.columns.date')}</TableHead>
98
+ <TableHead>{t('earnings.columns.order')}</TableHead>
99
+ <TableHead>{t('earnings.columns.kind')}</TableHead>
100
+ <TableHead className="text-right">{t('earnings.columns.amount')}</TableHead>
101
+ </TableRow>
102
+ </TableHeader>
103
+ <TableBody>
104
+ {rows.map((transfer) => (
105
+ <TableRow key={transfer.id}>
106
+ <TableCell>{formatStoreDateTime(transfer.created_at, timezone)}</TableCell>
107
+ <TableCell>
108
+ {transfer.order_id ? (
109
+ <Link
110
+ to="/$sellerId/orders/$orderId"
111
+ params={{ sellerId, orderId: transfer.order_id }}
112
+ className="no-underline"
113
+ >
114
+ {transfer.order_number ?? transfer.order_id}
115
+ </Link>
116
+ ) : (
117
+ '—'
118
+ )}
119
+ </TableCell>
120
+ <TableCell className="text-muted-foreground">
121
+ {t(`earnings.kinds.${transfer.kind}`, { defaultValue: transfer.kind })}
122
+ </TableCell>
123
+ <TableCell className="text-right tabular-nums">
124
+ {transfer.display_amount}
125
+ </TableCell>
126
+ </TableRow>
127
+ ))}
128
+ </TableBody>
129
+ </Table>
130
+ )}
131
+ {transfers?.meta && transfers.meta.pages > 1 && (
132
+ <Pagination meta={transfers.meta} onPageChange={setPage} />
133
+ )}
134
+ </Card>
135
+ }
136
+ sidebar={
137
+ <Card>
138
+ <CardHeader>
139
+ <CardTitle>{t('payouts.detail.settlement')}</CardTitle>
140
+ </CardHeader>
141
+ <div className="flex flex-col gap-3 px-6 pb-6">
142
+ <ReadRow label={t('payouts.columns.amount')}>{payout.display_amount}</ReadRow>
143
+ <ReadRow label={t('payouts.columns.reference')}>{payout.reference}</ReadRow>
144
+ <ReadRow label={t('payouts.detail.period')}>
145
+ {payout.period_start && payout.period_end
146
+ ? `${formatStoreDateTime(payout.period_start, timezone)} – ${formatStoreDateTime(payout.period_end, timezone)}`
147
+ : null}
148
+ </ReadRow>
149
+ </div>
150
+ </Card>
151
+ }
152
+ />
153
+ )
154
+ }
@@ -0,0 +1,32 @@
1
+ import { PageHeader, type ResourceSearch, ResourceTable } from '@spree/dashboard-core'
2
+ import { useRowClickBridge } from '@spree/dashboard-ui'
3
+ import type { Payout } from '@spree/seller-sdk'
4
+ import { useNavigate, useParams } from '@tanstack/react-router'
5
+ import { useTranslation } from 'react-i18next'
6
+ import { sellerClient } from '../api-client'
7
+ import '../tables/payouts'
8
+
9
+ /** What the marketplace has sent this seller, and what is on its way. */
10
+ export function PayoutsPage({ search }: { search: ResourceSearch }) {
11
+ const { t } = useTranslation()
12
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
13
+ const navigate = useNavigate()
14
+
15
+ useRowClickBridge('data-payout-id', (payoutId: string) =>
16
+ navigate({ to: '/$sellerId/payouts/$payoutId', params: { sellerId, payoutId } }),
17
+ )
18
+
19
+ return (
20
+ <div className="flex flex-col gap-4">
21
+ <PageHeader title={t('payouts.title')} subtitle={t('payouts.subtitle')} />
22
+
23
+ <ResourceTable<Payout>
24
+ hideHeader
25
+ tableKey="seller-payouts"
26
+ queryKey="seller-payouts-list"
27
+ queryFn={(params) => sellerClient().payouts.list(params)}
28
+ searchParams={search}
29
+ />
30
+ </div>
31
+ )
32
+ }
@@ -0,0 +1,292 @@
1
+ import {
2
+ PageHeader,
3
+ type ResourceSearch,
4
+ ResourceTable,
5
+ useResourceKey,
6
+ useResourceKeyBuilder,
7
+ } from '@spree/dashboard-core'
8
+ import {
9
+ Button,
10
+ Card,
11
+ CardContent,
12
+ CardHeader,
13
+ CardTitle,
14
+ Field,
15
+ FieldError,
16
+ FieldLabel,
17
+ Input,
18
+ RichTextEditor,
19
+ RowActions,
20
+ Sheet,
21
+ SheetContent,
22
+ SheetDescription,
23
+ SheetFooter,
24
+ SheetHeader,
25
+ SheetTitle,
26
+ toastManager,
27
+ useConfirm,
28
+ useRowClickBridge,
29
+ } from '@spree/dashboard-ui'
30
+ import { PlusIcon } from '@spree/dashboard-ui/icons'
31
+ import type { Policy } from '@spree/seller-sdk'
32
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
33
+ import { useParams } from '@tanstack/react-router'
34
+ import { useEffect, useState } from 'react'
35
+ import { Controller, useForm } from 'react-hook-form'
36
+ import { useTranslation } from 'react-i18next'
37
+ import { sellerClient } from '../api-client'
38
+ import '../tables/policies'
39
+
40
+ interface PolicyFormValues {
41
+ name: string
42
+ body: string
43
+ }
44
+
45
+ /**
46
+ * The seller's own legal documents.
47
+ *
48
+ * A seller starts with none — what they are expected to publish is the
49
+ * marketplace's decision, so the outstanding requirements are surfaced here
50
+ * with a one-click create that carries the required name through. That name
51
+ * is what the onboarding check matches on, which is why it is never typed by
52
+ * hand when it comes from a requirement.
53
+ */
54
+ export function PoliciesPage({ search }: { search: ResourceSearch }) {
55
+ const { t } = useTranslation()
56
+ const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
57
+ const confirm = useConfirm()
58
+ const queryClient = useQueryClient()
59
+
60
+ const [editingId, setEditingId] = useState<string | null>(null)
61
+ const [creatingName, setCreatingName] = useState<string | null>(null)
62
+
63
+ // What the marketplace asks for, so the page can name what is still missing
64
+ // rather than leaving the seller to guess from the onboarding screen.
65
+ const onboarding = useQuery({
66
+ queryKey: ['seller', sellerId, 'onboarding'],
67
+ queryFn: () => sellerClient().onboarding.get(),
68
+ })
69
+
70
+ // One requirement row per document, so an outstanding line is simply an
71
+ // incomplete policy requirement. Deduped by name in case a marketplace
72
+ // configured the same document twice — the seller owes it once.
73
+ const outstanding = Array.from(
74
+ new Map(
75
+ (onboarding.data?.requirements ?? [])
76
+ .filter((requirement) => requirement.status !== 'complete')
77
+ .map((requirement) => requirement.required_policy_name)
78
+ .filter((name): name is string => !!name)
79
+ .map((name) => [name, name]),
80
+ ).values(),
81
+ )
82
+
83
+ // Built through the same tenant-scoped helper `ResourceTable` uses, so the
84
+ // list this page renders and the keys it invalidates are the same keys.
85
+ const buildKey = useResourceKeyBuilder()
86
+
87
+ const invalidate = () => {
88
+ queryClient.invalidateQueries({ queryKey: buildKey('seller-policies') })
89
+ // A published policy can satisfy an onboarding requirement, so the
90
+ // checklist is no longer what it was.
91
+ queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'onboarding'] })
92
+ }
93
+
94
+ const deleteMutation = useMutation({
95
+ mutationFn: (id: string) => sellerClient().policies.delete(id),
96
+ onSuccess: () => {
97
+ toastManager.add({ type: 'success', title: t('policies.messages.deleted') })
98
+ invalidate()
99
+ },
100
+ onError: (err) =>
101
+ toastManager.add({
102
+ type: 'error',
103
+ title: err instanceof Error ? err.message : t('common.error'),
104
+ }),
105
+ })
106
+
107
+ useRowClickBridge('data-policy-id', setEditingId)
108
+
109
+ async function handleDelete(policy: Policy) {
110
+ const ok = await confirm({
111
+ title: t('policies.delete_confirm.title'),
112
+ message: t('policies.delete_confirm.message', { name: policy.name }),
113
+ variant: 'destructive',
114
+ confirmLabel: t('common.delete'),
115
+ })
116
+ if (!ok) return
117
+ await deleteMutation.mutateAsync(policy.id).catch(() => undefined)
118
+ }
119
+
120
+ return (
121
+ <div className="flex flex-col gap-6">
122
+ <PageHeader title={t('policies.title')} subtitle={t('policies.description')} sticky={false} />
123
+
124
+ {outstanding.length > 0 && (
125
+ <Card>
126
+ <CardHeader>
127
+ <CardTitle>{t('policies.required.title')}</CardTitle>
128
+ </CardHeader>
129
+ <CardContent className="flex flex-col gap-3">
130
+ <p className="text-muted-foreground text-sm">{t('policies.required.description')}</p>
131
+ <ul className="flex flex-col gap-2">
132
+ {outstanding.map((name) => (
133
+ <li
134
+ key={name}
135
+ className="flex items-center justify-between gap-4 rounded-lg border border-border-subtle px-3 py-2"
136
+ >
137
+ <span className="font-medium text-sm">{name}</span>
138
+ <Button size="sm" variant="outline" onClick={() => setCreatingName(name)}>
139
+ {t('policies.required.write')}
140
+ </Button>
141
+ </li>
142
+ ))}
143
+ </ul>
144
+ </CardContent>
145
+ </Card>
146
+ )}
147
+
148
+ <ResourceTable<Policy>
149
+ hideHeader
150
+ tableKey="seller-policies"
151
+ queryKey="seller-policies"
152
+ queryFn={(params) => sellerClient().policies.list(params)}
153
+ searchParams={search}
154
+ rowActions={(policy) => (
155
+ <RowActions
156
+ actions={[
157
+ { key: 'edit', onSelect: () => setEditingId(policy.id) },
158
+ {
159
+ key: 'delete',
160
+ destructive: true,
161
+ disabled: deleteMutation.isPending,
162
+ onSelect: () => handleDelete(policy),
163
+ },
164
+ ]}
165
+ />
166
+ )}
167
+ actions={
168
+ <Button size="sm" onClick={() => setCreatingName('')}>
169
+ <PlusIcon className="size-4" />
170
+ {t('policies.add')}
171
+ </Button>
172
+ }
173
+ />
174
+
175
+ {creatingName !== null && (
176
+ <PolicySheet
177
+ presetName={creatingName}
178
+ onSaved={invalidate}
179
+ onClose={() => setCreatingName(null)}
180
+ />
181
+ )}
182
+ {editingId && (
183
+ <PolicySheet policyId={editingId} onSaved={invalidate} onClose={() => setEditingId(null)} />
184
+ )}
185
+ </div>
186
+ )
187
+ }
188
+
189
+ function PolicySheet({
190
+ policyId,
191
+ presetName = '',
192
+ onSaved,
193
+ onClose,
194
+ }: {
195
+ policyId?: string
196
+ presetName?: string
197
+ onSaved: () => void
198
+ onClose: () => void
199
+ }) {
200
+ const { t } = useTranslation()
201
+
202
+ // Fetched rather than passed down: the row the table rendered carries the
203
+ // list payload, and editing needs the record itself.
204
+ const { data: policy } = useQuery({
205
+ // Seller-scoped like every other key here: an unscoped one would serve
206
+ // one seller's policy body to the next after a seller switch.
207
+ queryKey: useResourceKey('seller-policies', policyId ?? 'new'),
208
+ queryFn: () => sellerClient().policies.get(policyId as string),
209
+ enabled: !!policyId,
210
+ })
211
+
212
+ const form = useForm<PolicyFormValues>({
213
+ defaultValues: { name: presetName, body: '' },
214
+ })
215
+
216
+ useEffect(() => {
217
+ if (policy) form.reset({ name: policy.name, body: policy.body_html ?? '' })
218
+ }, [policy, form])
219
+
220
+ const save = useMutation({
221
+ mutationFn: (values: PolicyFormValues) =>
222
+ policyId
223
+ ? sellerClient().policies.update(policyId, values)
224
+ : sellerClient().policies.create(values),
225
+ onSuccess: () => {
226
+ toastManager.add({ type: 'success', title: t('policies.messages.saved') })
227
+ onSaved()
228
+ onClose()
229
+ },
230
+ // The sheet stays open on failure — closing it would discard what the
231
+ // seller wrote along with the error that explains why it did not save.
232
+ onError: (err) =>
233
+ toastManager.add({
234
+ type: 'error',
235
+ title: err instanceof Error ? err.message : t('common.error'),
236
+ }),
237
+ })
238
+
239
+ const { errors } = form.formState
240
+
241
+ return (
242
+ <Sheet open onOpenChange={(next) => !next && onClose()}>
243
+ <SheetContent className="data-[side=right]:max-w-[860px]">
244
+ <SheetHeader>
245
+ <SheetTitle>{policy?.name ?? t('policies.sheet.new_title')}</SheetTitle>
246
+ <SheetDescription>{t('policies.sheet.description')}</SheetDescription>
247
+ </SheetHeader>
248
+ <form
249
+ onSubmit={form.handleSubmit((values) => save.mutateAsync(values).catch(() => undefined))}
250
+ className="flex min-h-0 flex-1 flex-col"
251
+ >
252
+ <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
253
+ <Field>
254
+ <FieldLabel htmlFor="policy-name">{t('policies.fields.name')}</FieldLabel>
255
+ <Input
256
+ id="policy-name"
257
+ autoFocus
258
+ aria-invalid={!!errors.name || undefined}
259
+ {...form.register('name', { required: true })}
260
+ />
261
+ <FieldError errors={[errors.name]} />
262
+ </Field>
263
+
264
+ <Field>
265
+ <FieldLabel htmlFor="policy-body">{t('policies.fields.body')}</FieldLabel>
266
+ <Controller
267
+ control={form.control}
268
+ name="body"
269
+ render={({ field }) => (
270
+ <RichTextEditor
271
+ id="policy-body"
272
+ ariaLabel={t('policies.fields.body')}
273
+ value={field.value}
274
+ onChange={field.onChange}
275
+ />
276
+ )}
277
+ />
278
+ </Field>
279
+ </div>
280
+ <SheetFooter>
281
+ <Button type="button" variant="outline" onClick={onClose} disabled={save.isPending}>
282
+ {t('common.cancel')}
283
+ </Button>
284
+ <Button type="submit" disabled={save.isPending}>
285
+ {save.isPending ? t('common.saving') : t('common.save')}
286
+ </Button>
287
+ </SheetFooter>
288
+ </form>
289
+ </SheetContent>
290
+ </Sheet>
291
+ )
292
+ }