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