@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,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
|
+
}
|