@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,250 @@
|
|
|
1
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2
|
+
import {
|
|
3
|
+
CategorizationCard,
|
|
4
|
+
GeneralCard,
|
|
5
|
+
InventoryCard,
|
|
6
|
+
MediaCard,
|
|
7
|
+
mapSpreeErrorsToForm,
|
|
8
|
+
newProductFormDefaults,
|
|
9
|
+
PageHeader,
|
|
10
|
+
type PanelProduct,
|
|
11
|
+
PricesCard,
|
|
12
|
+
type ProductFormValues,
|
|
13
|
+
productFormSchema,
|
|
14
|
+
productToFormValues,
|
|
15
|
+
VariantsCard,
|
|
16
|
+
variantToWirePayload,
|
|
17
|
+
} from '@spree/dashboard-core'
|
|
18
|
+
import { FormActions, ResourceLayout, StatusBadge, toastManager } from '@spree/dashboard-ui'
|
|
19
|
+
import type { ProductParams } from '@spree/seller-sdk'
|
|
20
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
21
|
+
import { useNavigate, useParams } from '@tanstack/react-router'
|
|
22
|
+
import { useEffect } from 'react'
|
|
23
|
+
import { FormProvider, useForm } from 'react-hook-form'
|
|
24
|
+
import { useTranslation } from 'react-i18next'
|
|
25
|
+
import { sellerClient } from '../api-client'
|
|
26
|
+
import { CenteredMessage } from '../components/centered-message'
|
|
27
|
+
import { ProductStatusCard } from '../components/product-status-card'
|
|
28
|
+
import { RetryableError } from '../components/retryable-error'
|
|
29
|
+
|
|
30
|
+
/** Everything the form edits, in one request. */
|
|
31
|
+
const PRODUCT_EXPAND = [
|
|
32
|
+
'variants',
|
|
33
|
+
'variants.prices',
|
|
34
|
+
'variants.stock_levels',
|
|
35
|
+
'variants.stock_levels.stock_location',
|
|
36
|
+
'default_variant',
|
|
37
|
+
'default_variant.prices',
|
|
38
|
+
'default_variant.stock_levels',
|
|
39
|
+
'default_variant.stock_levels.stock_location',
|
|
40
|
+
'media',
|
|
41
|
+
'submission',
|
|
42
|
+
].join(',')
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One product, created or edited.
|
|
46
|
+
*
|
|
47
|
+
* The same cards the operator's dashboard renders, in the same order, from
|
|
48
|
+
* `@spree/dashboard-core` — the two forms are one form, and the differences
|
|
49
|
+
* are only what a seller may not do. Publishing to channels and tax category
|
|
50
|
+
* are absent because they are marketplace configuration, the Categorization
|
|
51
|
+
* card offers only the product type and delivery profile (filing a listing
|
|
52
|
+
* under categories, collections and tags is the operator's at review), and
|
|
53
|
+
* status is not a field here at all: a seller submits for review and the
|
|
54
|
+
* marketplace decides (docs/plans/6.0-seller-product-submission.md).
|
|
55
|
+
*/
|
|
56
|
+
export function ProductPage({ mode }: { mode: 'new' | 'edit' }) {
|
|
57
|
+
const { t } = useTranslation()
|
|
58
|
+
const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
|
|
59
|
+
const params = useParams({ strict: false }) as { productId?: string }
|
|
60
|
+
const productId = mode === 'edit' ? params.productId : undefined
|
|
61
|
+
const navigate = useNavigate()
|
|
62
|
+
const queryClient = useQueryClient()
|
|
63
|
+
|
|
64
|
+
const productKey = ['seller', sellerId, 'product', productId]
|
|
65
|
+
const {
|
|
66
|
+
data: product,
|
|
67
|
+
isLoading,
|
|
68
|
+
isError,
|
|
69
|
+
refetch,
|
|
70
|
+
} = useQuery({
|
|
71
|
+
queryKey: productKey,
|
|
72
|
+
queryFn: () => sellerClient().products.get(productId as string, PRODUCT_EXPAND),
|
|
73
|
+
enabled: !!productId,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// The seller panel has no StoreProvider — its tenant is a seller and the
|
|
77
|
+
// store is derived server-side — so the currencies the price cards need
|
|
78
|
+
// come from the profile, which reports the store's.
|
|
79
|
+
const { data: profile } = useQuery({
|
|
80
|
+
queryKey: ['seller', sellerId, 'profile'],
|
|
81
|
+
queryFn: () => sellerClient().profile.get(),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const form = useForm<ProductFormValues>({
|
|
85
|
+
// Cast for the same reason the operator's form does: `z.coerce.number()`
|
|
86
|
+
// infers `unknown` input, which the resolver's generic will not accept.
|
|
87
|
+
resolver: zodResolver(productFormSchema) as any,
|
|
88
|
+
// The operator's defaults, which seed one placeholder variant — without
|
|
89
|
+
// it the Prices and Inventory cards render no row, so a seller could not
|
|
90
|
+
// price or stock a new listing at all.
|
|
91
|
+
defaultValues: newProductFormDefaults(),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Reset once the record arrives, so the inputs show what was last saved
|
|
95
|
+
// rather than the blank defaults the form mounted with.
|
|
96
|
+
//
|
|
97
|
+
// Never over unsaved work: submitting for review invalidates this query,
|
|
98
|
+
// and an unguarded reset would throw away whatever the seller had typed
|
|
99
|
+
// but not saved. After a save RHF has already cleared isDirty, so the
|
|
100
|
+
// round-trip still re-hydrates.
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!product) return
|
|
103
|
+
if (form.formState.isDirty) return
|
|
104
|
+
|
|
105
|
+
form.reset(productToFormValues(product as PanelProduct, product.media))
|
|
106
|
+
}, [product, form])
|
|
107
|
+
|
|
108
|
+
const save = useMutation({
|
|
109
|
+
mutationFn: (values: ProductFormValues) => {
|
|
110
|
+
const payload: ProductParams = {
|
|
111
|
+
name: values.name,
|
|
112
|
+
description: values.description,
|
|
113
|
+
slug: values.slug || undefined,
|
|
114
|
+
meta_title: values.meta_title ?? undefined,
|
|
115
|
+
meta_description: values.meta_description ?? undefined,
|
|
116
|
+
// Null is a real value here — it detaches the type. The profile has
|
|
117
|
+
// no such state: a product always ships under one, so an empty pick
|
|
118
|
+
// means "leave it".
|
|
119
|
+
product_type_id: values.product_type_id ?? null,
|
|
120
|
+
delivery_profile_id: values.delivery_profile_id ?? undefined,
|
|
121
|
+
// Both lists are the whole intent: anything the seller removed is
|
|
122
|
+
// absent here, which is what tells the API to drop it.
|
|
123
|
+
// The video address and the focal point ride along because the
|
|
124
|
+
// cards offer both — an allow-list that omitted them let a seller
|
|
125
|
+
// set a crop or paste a video URL and watch it vanish on save.
|
|
126
|
+
media: (values.media ?? []).map((item) => ({
|
|
127
|
+
id: item.id,
|
|
128
|
+
signed_id: item.signed_id ?? undefined,
|
|
129
|
+
poster_signed_id: item.poster_signed_id ?? undefined,
|
|
130
|
+
alt: item.alt ?? undefined,
|
|
131
|
+
position: item.position,
|
|
132
|
+
media_type: item.media_type,
|
|
133
|
+
external_video_url: item.external_video_url ?? undefined,
|
|
134
|
+
focal_point_x: item.focal_point_x ?? undefined,
|
|
135
|
+
focal_point_y: item.focal_point_y ?? undefined,
|
|
136
|
+
variant_ids: item.variant_ids,
|
|
137
|
+
})),
|
|
138
|
+
variants: (values.variants ?? []).map((variant, index) =>
|
|
139
|
+
variantToWirePayload(variant, index),
|
|
140
|
+
) as ProductParams['variants'],
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return productId
|
|
144
|
+
? sellerClient().products.update(productId, payload)
|
|
145
|
+
: sellerClient().products.create(payload)
|
|
146
|
+
},
|
|
147
|
+
onSuccess: (saved) => {
|
|
148
|
+
void queryClient.invalidateQueries({ queryKey: ['seller-products'] })
|
|
149
|
+
// Invalidated, never seeded: the write's response carries no expands, so
|
|
150
|
+
// planting it would blank the variants and media the form is showing —
|
|
151
|
+
// the reset effect would read a product whose collections are undefined.
|
|
152
|
+
void queryClient.invalidateQueries({ queryKey: ['seller', sellerId, 'product', saved.id] })
|
|
153
|
+
toastManager.add({ type: 'success', title: t('products.saved') })
|
|
154
|
+
if (!productId) {
|
|
155
|
+
// Replace history rather than pushing — otherwise back lands on the
|
|
156
|
+
// (now-stale) new product form.
|
|
157
|
+
navigate({
|
|
158
|
+
to: '/$sellerId/products/$productId',
|
|
159
|
+
params: { sellerId, productId: saved.id },
|
|
160
|
+
replace: true,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
async function onSubmit(values: ProductFormValues) {
|
|
167
|
+
try {
|
|
168
|
+
await save.mutateAsync(values)
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (!mapSpreeErrorsToForm(err, form.setError)) {
|
|
171
|
+
toastManager.add({
|
|
172
|
+
type: 'error',
|
|
173
|
+
title: err instanceof Error ? err.message : t('common.error'),
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (mode === 'edit' && isLoading) return <CenteredMessage>{t('common.loading')}</CenteredMessage>
|
|
180
|
+
if (mode === 'edit' && isError) return <RetryableError onRetry={() => refetch()} />
|
|
181
|
+
if (mode === 'edit' && !product)
|
|
182
|
+
return <CenteredMessage>{t('products.not_found')}</CenteredMessage>
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<FormProvider {...form}>
|
|
186
|
+
<form
|
|
187
|
+
onSubmit={form.handleSubmit(onSubmit, (errors) => {
|
|
188
|
+
// A schema rejection otherwise does nothing visible: the fields at
|
|
189
|
+
// fault may be in a card the seller cannot see.
|
|
190
|
+
toastManager.add({
|
|
191
|
+
type: 'error',
|
|
192
|
+
title: t('common.error'),
|
|
193
|
+
description: Object.keys(errors).join(', '),
|
|
194
|
+
})
|
|
195
|
+
})}
|
|
196
|
+
>
|
|
197
|
+
{form.formState.errors.root?.message && (
|
|
198
|
+
<p className="text-destructive text-sm" role="alert">
|
|
199
|
+
{form.formState.errors.root.message}
|
|
200
|
+
</p>
|
|
201
|
+
)}
|
|
202
|
+
<ResourceLayout
|
|
203
|
+
header={
|
|
204
|
+
<PageHeader
|
|
205
|
+
title={product?.name ?? t('products.new_title')}
|
|
206
|
+
backTo="products"
|
|
207
|
+
badges={
|
|
208
|
+
product?.status ? (
|
|
209
|
+
<StatusBadge
|
|
210
|
+
status={product.status}
|
|
211
|
+
label={t(`products.statuses.${product.status}`)}
|
|
212
|
+
/>
|
|
213
|
+
) : undefined
|
|
214
|
+
}
|
|
215
|
+
actions={<FormActions form={form} saveLabel={t('common.save')} />}
|
|
216
|
+
/>
|
|
217
|
+
}
|
|
218
|
+
main={
|
|
219
|
+
<>
|
|
220
|
+
<GeneralCard form={form} />
|
|
221
|
+
<VariantsCard form={form} />
|
|
222
|
+
<MediaCard productId={productId} variants={product?.variants ?? []} form={form} />
|
|
223
|
+
<PricesCard
|
|
224
|
+
form={form}
|
|
225
|
+
productName={product?.name ?? ''}
|
|
226
|
+
currencies={profile?.supported_currencies}
|
|
227
|
+
defaultCurrency={profile?.default_currency}
|
|
228
|
+
/>
|
|
229
|
+
<InventoryCard form={form} />
|
|
230
|
+
</>
|
|
231
|
+
}
|
|
232
|
+
sidebar={
|
|
233
|
+
<>
|
|
234
|
+
{productId && product && (
|
|
235
|
+
<ProductStatusCard
|
|
236
|
+
product={product}
|
|
237
|
+
onDone={() => {
|
|
238
|
+
void queryClient.invalidateQueries({ queryKey: productKey })
|
|
239
|
+
void queryClient.invalidateQueries({ queryKey: ['seller-products'] })
|
|
240
|
+
}}
|
|
241
|
+
/>
|
|
242
|
+
)}
|
|
243
|
+
<CategorizationCard form={form} />
|
|
244
|
+
</>
|
|
245
|
+
}
|
|
246
|
+
/>
|
|
247
|
+
</form>
|
|
248
|
+
</FormProvider>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BulkAction,
|
|
3
|
+
BulkActionFormProps,
|
|
4
|
+
BulkActionOutcome,
|
|
5
|
+
ResourceSearch,
|
|
6
|
+
} from '@spree/dashboard-core'
|
|
7
|
+
import {
|
|
8
|
+
ExportButton,
|
|
9
|
+
ImportButton,
|
|
10
|
+
ImportWizardDialog,
|
|
11
|
+
PageHeader,
|
|
12
|
+
ResourceTable,
|
|
13
|
+
Subject,
|
|
14
|
+
} from '@spree/dashboard-core'
|
|
15
|
+
import {
|
|
16
|
+
BulkDialog,
|
|
17
|
+
Button,
|
|
18
|
+
Field,
|
|
19
|
+
FieldLabel,
|
|
20
|
+
Select,
|
|
21
|
+
SelectContent,
|
|
22
|
+
SelectItem,
|
|
23
|
+
SelectTrigger,
|
|
24
|
+
SelectValue,
|
|
25
|
+
toastManager,
|
|
26
|
+
useRowClickBridge,
|
|
27
|
+
} from '@spree/dashboard-ui'
|
|
28
|
+
import { ArchiveIcon, PlusIcon, SendIcon, Trash2Icon } from '@spree/dashboard-ui/icons'
|
|
29
|
+
import type { BulkProductResult, Product } from '@spree/seller-sdk'
|
|
30
|
+
import { useNavigate, useParams } from '@tanstack/react-router'
|
|
31
|
+
import { useMemo, useState } from 'react'
|
|
32
|
+
import { useTranslation } from 'react-i18next'
|
|
33
|
+
import { sellerClient } from '../api-client'
|
|
34
|
+
import '../tables/products'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The statuses a seller may move a selection to.
|
|
38
|
+
*
|
|
39
|
+
* `active` is absent by design: a listing goes on sale when the marketplace
|
|
40
|
+
* approves it, so the only moves offered here are the two a seller makes
|
|
41
|
+
* alone (docs/plans/6.0-seller-product-submission.md).
|
|
42
|
+
*/
|
|
43
|
+
const BULK_STATUSES = ['draft', 'archived'] as const
|
|
44
|
+
|
|
45
|
+
type BulkStatus = (typeof BULK_STATUSES)[number]
|
|
46
|
+
type StatusFormValues = { status: BulkStatus }
|
|
47
|
+
|
|
48
|
+
/** This seller's own catalog. */
|
|
49
|
+
export function ProductsPage({ search }: { search: ResourceSearch & { import?: string } }) {
|
|
50
|
+
const { t } = useTranslation()
|
|
51
|
+
const { sellerId } = useParams({ from: '/_authenticated/$sellerId' })
|
|
52
|
+
const navigate = useNavigate()
|
|
53
|
+
|
|
54
|
+
const open = (productId: string) =>
|
|
55
|
+
navigate({ to: '/$sellerId/products/$productId', params: { sellerId, productId } })
|
|
56
|
+
|
|
57
|
+
useRowClickBridge('data-product-id', open)
|
|
58
|
+
|
|
59
|
+
const bulkActions = useMemo<BulkAction<unknown>[]>(() => {
|
|
60
|
+
// These endpoints move what they can and leave the rest, so the selection
|
|
61
|
+
// size is not the outcome. The success toast is told what the server
|
|
62
|
+
// actually did, and anything it left alone gets a second toast saying so —
|
|
63
|
+
// otherwise "15 submitted" sits next to "12 were left alone".
|
|
64
|
+
const report = (result: BulkProductResult, skippedMessage: string): BulkActionOutcome => {
|
|
65
|
+
if (result.skipped_count) {
|
|
66
|
+
toastManager.add({
|
|
67
|
+
type: 'info',
|
|
68
|
+
title: t('products.bulk.skipped', {
|
|
69
|
+
count: result.skipped_count,
|
|
70
|
+
message: skippedMessage,
|
|
71
|
+
}),
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { count: result.product_count }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const submitAction: BulkAction<unknown> = {
|
|
79
|
+
key: 'submit-for-review',
|
|
80
|
+
label: t('products.submit_for_review'),
|
|
81
|
+
icon: <SendIcon className="size-4" />,
|
|
82
|
+
subject: Subject.Product,
|
|
83
|
+
confirm: {
|
|
84
|
+
title: t('products.bulk.submit_confirm_title'),
|
|
85
|
+
message: t('products.bulk.submit_confirm_description'),
|
|
86
|
+
confirmLabel: t('products.submit_for_review'),
|
|
87
|
+
},
|
|
88
|
+
run: async ({ ids }) =>
|
|
89
|
+
report(
|
|
90
|
+
await sellerClient().products.bulkSubmit({ ids }),
|
|
91
|
+
t('products.bulk.skipped_submit'),
|
|
92
|
+
),
|
|
93
|
+
successMessage: t('products.bulk.submitted'),
|
|
94
|
+
errorMessage: t('products.bulk.submit_failed'),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const statusAction: BulkAction<StatusFormValues> = {
|
|
98
|
+
key: 'set-status',
|
|
99
|
+
label: t('products.bulk.set_status_action'),
|
|
100
|
+
icon: <ArchiveIcon className="size-4" />,
|
|
101
|
+
subject: Subject.Product,
|
|
102
|
+
form: (props) => <StatusPickerDialog {...props} />,
|
|
103
|
+
run: async ({ ids, formValues }) =>
|
|
104
|
+
report(
|
|
105
|
+
await sellerClient().products.bulkStatusUpdate({ ids, status: formValues!.status }),
|
|
106
|
+
t('products.bulk.skipped_refused'),
|
|
107
|
+
),
|
|
108
|
+
successMessage: t('products.bulk.status_updated'),
|
|
109
|
+
errorMessage: t('products.bulk.status_update_failed'),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const deleteAction: BulkAction<unknown> = {
|
|
113
|
+
key: 'delete',
|
|
114
|
+
label: t('common.delete'),
|
|
115
|
+
icon: <Trash2Icon className="size-4" />,
|
|
116
|
+
subject: Subject.Product,
|
|
117
|
+
action: 'destroy',
|
|
118
|
+
confirm: {
|
|
119
|
+
title: t('products.bulk.delete_confirm_title'),
|
|
120
|
+
message: t('products.bulk.delete_confirm_description'),
|
|
121
|
+
confirmLabel: t('common.delete'),
|
|
122
|
+
variant: 'destructive',
|
|
123
|
+
},
|
|
124
|
+
run: async ({ ids }) =>
|
|
125
|
+
report(
|
|
126
|
+
await sellerClient().products.bulkDestroy({ ids }),
|
|
127
|
+
t('products.bulk.skipped_refused'),
|
|
128
|
+
),
|
|
129
|
+
successMessage: t('products.bulk.deleted'),
|
|
130
|
+
errorMessage: t('products.bulk.delete_failed'),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return [submitAction, statusAction, deleteAction] as BulkAction<unknown>[]
|
|
134
|
+
}, [t])
|
|
135
|
+
|
|
136
|
+
// The wizard is driven by an `?import=` param rather than component state,
|
|
137
|
+
// so a refresh (or the link in the import-done email) reopens it.
|
|
138
|
+
const openImportWizard = (id: string) =>
|
|
139
|
+
navigate({ search: (prev: Record<string, unknown>) => ({ ...prev, import: id }) as never })
|
|
140
|
+
|
|
141
|
+
const closeImportWizard = () =>
|
|
142
|
+
navigate({
|
|
143
|
+
search: (prev: Record<string, unknown>) => {
|
|
144
|
+
const { import: _i, ...rest } = prev
|
|
145
|
+
return rest as never
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div className="flex flex-col gap-4">
|
|
151
|
+
<PageHeader title={t('products.title')} sticky={false} />
|
|
152
|
+
|
|
153
|
+
<ResourceTable<Product>
|
|
154
|
+
hideHeader
|
|
155
|
+
tableKey="seller-products"
|
|
156
|
+
queryKey="seller-products"
|
|
157
|
+
queryFn={(params) => sellerClient().products.list(params)}
|
|
158
|
+
searchParams={search}
|
|
159
|
+
bulkActions={bulkActions}
|
|
160
|
+
actions={(ctx) => (
|
|
161
|
+
<>
|
|
162
|
+
<ImportButton
|
|
163
|
+
type="products"
|
|
164
|
+
subject={Subject.Product}
|
|
165
|
+
onCreated={(imp) => openImportWizard(imp.id)}
|
|
166
|
+
resultsPath={`/${sellerId}/products`}
|
|
167
|
+
/>
|
|
168
|
+
<ExportButton type="products" {...ctx} />
|
|
169
|
+
<Button
|
|
170
|
+
onClick={() => navigate({ to: '/$sellerId/products/new', params: { sellerId } })}
|
|
171
|
+
>
|
|
172
|
+
<PlusIcon className="size-4" />
|
|
173
|
+
{t('products.add')}
|
|
174
|
+
</Button>
|
|
175
|
+
</>
|
|
176
|
+
)}
|
|
177
|
+
/>
|
|
178
|
+
|
|
179
|
+
<ImportWizardDialog
|
|
180
|
+
importId={search.import ?? null}
|
|
181
|
+
onClose={closeImportWizard}
|
|
182
|
+
// Imported products land as drafts awaiting review, so "view records"
|
|
183
|
+
// returns to this very list — where the seller submits them.
|
|
184
|
+
onViewRecords={() => navigate({ to: '/$sellerId/products', params: { sellerId } })}
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function StatusPickerDialog({ onSubmit, onCancel }: BulkActionFormProps<StatusFormValues>) {
|
|
191
|
+
const { t } = useTranslation()
|
|
192
|
+
const [status, setStatus] = useState<BulkStatus>('draft')
|
|
193
|
+
|
|
194
|
+
const statusItems = BULK_STATUSES.map((value) => ({
|
|
195
|
+
value,
|
|
196
|
+
label: t(`products.statuses.${value}`),
|
|
197
|
+
}))
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<BulkDialog
|
|
201
|
+
title={t('products.bulk.status_dialog_title')}
|
|
202
|
+
description={t('products.bulk.status_dialog_description')}
|
|
203
|
+
submitLabel={t('common.apply')}
|
|
204
|
+
onCancel={onCancel}
|
|
205
|
+
onSubmit={() => onSubmit({ status })}
|
|
206
|
+
>
|
|
207
|
+
<Field>
|
|
208
|
+
<FieldLabel>{t('products.fields.status')}</FieldLabel>
|
|
209
|
+
<Select
|
|
210
|
+
items={statusItems}
|
|
211
|
+
value={status}
|
|
212
|
+
onValueChange={(value) => setStatus(value as BulkStatus)}
|
|
213
|
+
>
|
|
214
|
+
<SelectTrigger>
|
|
215
|
+
<SelectValue />
|
|
216
|
+
</SelectTrigger>
|
|
217
|
+
<SelectContent>
|
|
218
|
+
{statusItems.map((item) => (
|
|
219
|
+
<SelectItem key={item.value} value={item.value}>
|
|
220
|
+
{item.label}
|
|
221
|
+
</SelectItem>
|
|
222
|
+
))}
|
|
223
|
+
</SelectContent>
|
|
224
|
+
</Select>
|
|
225
|
+
</Field>
|
|
226
|
+
</BulkDialog>
|
|
227
|
+
)
|
|
228
|
+
}
|