@spree/dashboard-core 0.13.1 → 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/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +52 -27
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +383 -127
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +19 -14
- package/src/components/table-toolbar.tsx +1357 -440
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +241 -16
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +9 -6
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/table-registry.ts +50 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -517
- package/src/locales/de.json +1884 -517
- package/src/locales/en.json +1886 -519
- package/src/locales/fr.json +1884 -517
- package/src/locales/pl.json +1913 -517
- package/src/locales/zh-CN.json +1883 -517
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- package/src/components/top-bar.tsx +0 -209
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DndContext,
|
|
3
|
+
type DragEndEvent,
|
|
4
|
+
KeyboardSensor,
|
|
5
|
+
PointerSensor,
|
|
6
|
+
useSensor,
|
|
7
|
+
useSensors,
|
|
8
|
+
} from '@dnd-kit/core'
|
|
9
|
+
import {
|
|
10
|
+
rectSortingStrategy,
|
|
11
|
+
SortableContext,
|
|
12
|
+
sortableKeyboardCoordinates,
|
|
13
|
+
useSortable,
|
|
14
|
+
} from '@dnd-kit/sortable'
|
|
15
|
+
import { CSS } from '@dnd-kit/utilities'
|
|
16
|
+
import type { MediaType, ProductFormValues } from '@spree/dashboard-core'
|
|
17
|
+
import {
|
|
18
|
+
getApiClient,
|
|
19
|
+
MediaPickerSheet,
|
|
20
|
+
parseVideoUrl,
|
|
21
|
+
ResourceMultiAutocomplete,
|
|
22
|
+
TagCombobox,
|
|
23
|
+
useDirectUpload,
|
|
24
|
+
useOptionalStore,
|
|
25
|
+
} from '@spree/dashboard-core'
|
|
26
|
+
import {
|
|
27
|
+
Button,
|
|
28
|
+
Card,
|
|
29
|
+
CardAction,
|
|
30
|
+
CardContent,
|
|
31
|
+
CardHeader,
|
|
32
|
+
CardTitle,
|
|
33
|
+
ContextMenu,
|
|
34
|
+
ContextMenuContent,
|
|
35
|
+
ContextMenuItem,
|
|
36
|
+
ContextMenuSeparator,
|
|
37
|
+
ContextMenuTrigger,
|
|
38
|
+
DragHandle,
|
|
39
|
+
Field,
|
|
40
|
+
FieldError,
|
|
41
|
+
FieldLabel,
|
|
42
|
+
Input,
|
|
43
|
+
RichTextEditor,
|
|
44
|
+
Select,
|
|
45
|
+
SelectContent,
|
|
46
|
+
SelectItem,
|
|
47
|
+
SelectTrigger,
|
|
48
|
+
SelectValue,
|
|
49
|
+
StatusCard as SharedStatusCard,
|
|
50
|
+
Textarea,
|
|
51
|
+
toastManager,
|
|
52
|
+
useConfirm,
|
|
53
|
+
} from '@spree/dashboard-ui'
|
|
54
|
+
import {
|
|
55
|
+
DownloadIcon,
|
|
56
|
+
FilmIcon,
|
|
57
|
+
ImagePlusIcon,
|
|
58
|
+
LibraryIcon,
|
|
59
|
+
Loader2Icon,
|
|
60
|
+
PencilIcon,
|
|
61
|
+
PlayIcon,
|
|
62
|
+
TrashIcon,
|
|
63
|
+
} from '@spree/dashboard-ui/icons'
|
|
64
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
65
|
+
import { type Control, Controller, type UseFormReturn, useWatch } from 'react-hook-form'
|
|
66
|
+
import { useTranslation } from 'react-i18next'
|
|
67
|
+
import { AddVideoDialog } from './add-video-dialog'
|
|
68
|
+
import { InventorySection } from './inventory-section'
|
|
69
|
+
import { MediaEditSheet } from './media-edit-sheet'
|
|
70
|
+
import { ProductBulkPriceEditor } from './product-bulk-price-editor'
|
|
71
|
+
import type {
|
|
72
|
+
PanelMedia as Media,
|
|
73
|
+
PanelProduct as Product,
|
|
74
|
+
PanelVariant as Variant,
|
|
75
|
+
} from './product-types'
|
|
76
|
+
import {
|
|
77
|
+
categoryAutocompleteProps,
|
|
78
|
+
collectionAutocompleteProps,
|
|
79
|
+
useFormCategories as useCategories,
|
|
80
|
+
useFormCollections as useCollections,
|
|
81
|
+
useFormDeleteProductMedia as useDeleteProductMedia,
|
|
82
|
+
useFormDeliveryProfiles as useDeliveryProfiles,
|
|
83
|
+
useFormOptionTypesByIds as useOptionTypesByIds,
|
|
84
|
+
useFormProductType as useProductType,
|
|
85
|
+
useFormProductTypes as useProductTypes,
|
|
86
|
+
useFormTaxCategories as useTaxCategories,
|
|
87
|
+
} from './use-product-form-data'
|
|
88
|
+
import { VariantsSection } from './variants-section'
|
|
89
|
+
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
91
|
+
type FormCardProps = { form: UseFormReturn<ProductFormValues, any, any> }
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// General
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The description editor a panel supplies.
|
|
99
|
+
*
|
|
100
|
+
* The operator's dashboard passes its media-aware editor, so a merchant can
|
|
101
|
+
* embed a file from the library. A seller has no library to embed from, so
|
|
102
|
+
* they get the plain editor — which is the default, keeping the card usable
|
|
103
|
+
* without the injection.
|
|
104
|
+
*/
|
|
105
|
+
export type RichTextEditorComponent = React.ComponentType<{
|
|
106
|
+
id?: string
|
|
107
|
+
ariaLabel?: string
|
|
108
|
+
value?: string
|
|
109
|
+
onChange: (value: string) => void
|
|
110
|
+
placeholder?: string
|
|
111
|
+
}>
|
|
112
|
+
|
|
113
|
+
export function GeneralCard({
|
|
114
|
+
form,
|
|
115
|
+
descriptionEditor: DescriptionEditor = RichTextEditor,
|
|
116
|
+
}: FormCardProps & { descriptionEditor?: RichTextEditorComponent }) {
|
|
117
|
+
const { t } = useTranslation()
|
|
118
|
+
const { errors } = form.formState
|
|
119
|
+
return (
|
|
120
|
+
<Card>
|
|
121
|
+
<CardHeader>
|
|
122
|
+
<CardTitle>{t('admin.pages.products.section_basics')}</CardTitle>
|
|
123
|
+
</CardHeader>
|
|
124
|
+
<CardContent className="flex flex-col gap-4">
|
|
125
|
+
<Field>
|
|
126
|
+
<FieldLabel htmlFor="product-name">{t('admin.fields.name.label')}</FieldLabel>
|
|
127
|
+
<Input
|
|
128
|
+
id="product-name"
|
|
129
|
+
placeholder={t('admin.fields.product.name.placeholder')}
|
|
130
|
+
aria-invalid={!!errors.name || undefined}
|
|
131
|
+
{...form.register('name')}
|
|
132
|
+
/>
|
|
133
|
+
<FieldError errors={[errors.name]} />
|
|
134
|
+
</Field>
|
|
135
|
+
<Field>
|
|
136
|
+
<FieldLabel htmlFor="product-description">
|
|
137
|
+
{t('admin.fields.description.label')}
|
|
138
|
+
</FieldLabel>
|
|
139
|
+
<Controller
|
|
140
|
+
name="description"
|
|
141
|
+
control={form.control}
|
|
142
|
+
render={({ field }) => (
|
|
143
|
+
<DescriptionEditor
|
|
144
|
+
id="product-description"
|
|
145
|
+
ariaLabel={t('admin.fields.description.label')}
|
|
146
|
+
value={field.value}
|
|
147
|
+
onChange={field.onChange}
|
|
148
|
+
placeholder={t('admin.fields.product.description.placeholder')}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
/>
|
|
152
|
+
</Field>
|
|
153
|
+
</CardContent>
|
|
154
|
+
</Card>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Variants (just a passthrough so the page composition reads top-down)
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export function VariantsCard({ form, seedFromType }: FormCardProps & { seedFromType?: boolean }) {
|
|
163
|
+
return <VariantsSection form={form} seedFromType={seedFromType} />
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Prices — inline form-backed editor. Mirrors InventoryCard: a Card around a
|
|
168
|
+
// section that reads/writes form state directly. Save rides the parent
|
|
169
|
+
// product form's Save button; no modal, no snapshot, no separate
|
|
170
|
+
// save/discard. Currency switching is a view-only change because the form
|
|
171
|
+
// already holds every currency's prices for every variant.
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
export function PricesCard({
|
|
175
|
+
form,
|
|
176
|
+
productName,
|
|
177
|
+
currencies: currenciesProp,
|
|
178
|
+
defaultCurrency: defaultCurrencyProp,
|
|
179
|
+
}: FormCardProps & {
|
|
180
|
+
productName: string
|
|
181
|
+
/**
|
|
182
|
+
* A panel with no store context supplies these itself. The operator's has a
|
|
183
|
+
* StoreProvider; a seller's tenant is a seller and the store is derived
|
|
184
|
+
* server-side, so it passes the currencies its profile reports.
|
|
185
|
+
*/
|
|
186
|
+
currencies?: string[]
|
|
187
|
+
defaultCurrency?: string
|
|
188
|
+
}) {
|
|
189
|
+
const { t } = useTranslation()
|
|
190
|
+
const store = useOptionalStore()
|
|
191
|
+
const currencies = currenciesProp ?? store?.currencies ?? []
|
|
192
|
+
const defaultCurrency = defaultCurrencyProp ?? store?.defaultCurrency ?? 'USD'
|
|
193
|
+
// Not `useState(defaultCurrency)` alone: the store (or the seller's profile)
|
|
194
|
+
// arrives after the first render, so a state seeded from the fallback would
|
|
195
|
+
// stick and price everything in the wrong currency.
|
|
196
|
+
const [selectedCurrency, setCurrency] = useState<string | null>(null)
|
|
197
|
+
const currency = selectedCurrency ?? defaultCurrency
|
|
198
|
+
|
|
199
|
+
// Until the real default arrives there is no currency to price in, and an
|
|
200
|
+
// amount typed against the `USD` fallback would be saved under it — the
|
|
201
|
+
// header then flips to the true currency while the wrong row rides along.
|
|
202
|
+
const currencyResolved = Boolean(defaultCurrencyProp ?? store?.defaultCurrency)
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<Card>
|
|
206
|
+
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0">
|
|
207
|
+
<CardTitle>{t('admin.common.prices')}</CardTitle>
|
|
208
|
+
{currencies.length > 1 && (
|
|
209
|
+
<Select value={currency} onValueChange={setCurrency}>
|
|
210
|
+
<SelectTrigger size="sm" className="w-24">
|
|
211
|
+
<SelectValue />
|
|
212
|
+
</SelectTrigger>
|
|
213
|
+
<SelectContent>
|
|
214
|
+
{currencies.map((c) => (
|
|
215
|
+
<SelectItem key={c} value={c}>
|
|
216
|
+
{c}
|
|
217
|
+
</SelectItem>
|
|
218
|
+
))}
|
|
219
|
+
</SelectContent>
|
|
220
|
+
</Select>
|
|
221
|
+
)}
|
|
222
|
+
</CardHeader>
|
|
223
|
+
{/* `p-0` like every other card holding a table: the grid draws its own
|
|
224
|
+
cell borders to the card's edges, and padding here insets it into a
|
|
225
|
+
floating block with a gutter on all four sides. */}
|
|
226
|
+
<CardContent className="p-0">
|
|
227
|
+
{currencyResolved ? (
|
|
228
|
+
<ProductBulkPriceEditor form={form} currency={currency} productName={productName} />
|
|
229
|
+
) : (
|
|
230
|
+
<div className="m-3 h-24 animate-pulse rounded-md bg-muted" />
|
|
231
|
+
)}
|
|
232
|
+
</CardContent>
|
|
233
|
+
</Card>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Media — edit-only. On create, render a "save first" empty state so the
|
|
239
|
+
// merchant sees where uploads will go but can't trigger them.
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
interface PendingUpload {
|
|
243
|
+
id: string
|
|
244
|
+
file: File
|
|
245
|
+
preview: string
|
|
246
|
+
progress: 'uploading' | 'attaching' | 'done' | 'error'
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// What the gallery accepts. `accept` on the file input only constrains the OS
|
|
250
|
+
// picker — a drop bypasses it entirely, so handleDrop filters against the same
|
|
251
|
+
// list or an unsupported file uploads and then fails the whole product save.
|
|
252
|
+
const ACCEPTED_MEDIA = ['image/', 'video/mp4', 'video/webm', 'video/quicktime']
|
|
253
|
+
|
|
254
|
+
function isAcceptedMedia(file: File): boolean {
|
|
255
|
+
return ACCEPTED_MEDIA.some((type) =>
|
|
256
|
+
type.endsWith('/') ? file.type.startsWith(type) : file.type === type,
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Unified, form-backed media card. Single source of truth: form.media.
|
|
261
|
+
// Both new and edit pages use the same component; the only difference is
|
|
262
|
+
// whether form.media starts empty (new) or pre-hydrated from the persisted
|
|
263
|
+
// product (edit, via productToFormValues). Uploads, alt edits, reorders,
|
|
264
|
+
// and variant_ids assignments all live in form state and ride the same
|
|
265
|
+
// product POST/PATCH. The dedicated DELETE /media endpoint stays — we
|
|
266
|
+
// don't ship deletes inline (no implicit-omission semantics).
|
|
267
|
+
export function MediaCard({
|
|
268
|
+
productId,
|
|
269
|
+
variants,
|
|
270
|
+
form,
|
|
271
|
+
}: {
|
|
272
|
+
productId?: string
|
|
273
|
+
// The MediaEditSheet's "assign to variant" pill row needs the list of
|
|
274
|
+
// server-persisted variants — only those have an id that can ride the
|
|
275
|
+
// PATCH's media[].variant_ids. Form-state variants without a server id
|
|
276
|
+
// can't be assigned until the merchant saves.
|
|
277
|
+
variants?: Variant[]
|
|
278
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
279
|
+
form: UseFormReturn<ProductFormValues, any, any>
|
|
280
|
+
}) {
|
|
281
|
+
const { t } = useTranslation()
|
|
282
|
+
const directUpload = useDirectUpload()
|
|
283
|
+
const deleteMedia = useDeleteProductMedia(productId ?? '')
|
|
284
|
+
const confirm = useConfirm()
|
|
285
|
+
const [pending, setPending] = useState<PendingUpload[]>([])
|
|
286
|
+
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
|
287
|
+
const [addingVideo, setAddingVideo] = useState(false)
|
|
288
|
+
const [pickingFromLibrary, setPickingFromLibrary] = useState(false)
|
|
289
|
+
const mediaLibrary = getApiClient().mediaLibrary
|
|
290
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
291
|
+
|
|
292
|
+
const items = useWatch({ control: form.control, name: 'media' }) ?? []
|
|
293
|
+
|
|
294
|
+
// dnd-kit needs a stable id per row. Persisted items have one; new uploads
|
|
295
|
+
// get their uploadId (assigned at completion). signed_id is fine as a
|
|
296
|
+
// fallback for items hydrated server-side without an uploadId.
|
|
297
|
+
const sortableIds = useMemo(
|
|
298
|
+
() => items.map((m, i) => m.id ?? m.uploadId ?? m.signed_id ?? `idx-${i}`),
|
|
299
|
+
[items],
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
const sensors = useSensors(
|
|
303
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
304
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
const handleDragEnd = useCallback(
|
|
308
|
+
(event: DragEndEvent) => {
|
|
309
|
+
const { active, over } = event
|
|
310
|
+
if (!over || active.id === over.id) return
|
|
311
|
+
const current = form.getValues('media') ?? []
|
|
312
|
+
const fromIndex = sortableIds.indexOf(String(active.id))
|
|
313
|
+
const toIndex = sortableIds.indexOf(String(over.id))
|
|
314
|
+
if (fromIndex === -1 || toIndex === -1) return
|
|
315
|
+
const next = [...current]
|
|
316
|
+
const [moved] = next.splice(fromIndex, 1)
|
|
317
|
+
next.splice(toIndex, 0, moved)
|
|
318
|
+
form.setValue(
|
|
319
|
+
'media',
|
|
320
|
+
next.map((m, i) => ({ ...m, position: i + 1 })),
|
|
321
|
+
{ shouldDirty: true },
|
|
322
|
+
)
|
|
323
|
+
},
|
|
324
|
+
[form, sortableIds],
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
const handleFiles = useCallback(
|
|
328
|
+
async (files: FileList | File[]) => {
|
|
329
|
+
const fileArray = Array.from(files)
|
|
330
|
+
for (const file of fileArray) {
|
|
331
|
+
const uploadId = crypto.randomUUID()
|
|
332
|
+
const preview = URL.createObjectURL(file)
|
|
333
|
+
setPending((prev) => [...prev, { id: uploadId, file, preview, progress: 'uploading' }])
|
|
334
|
+
try {
|
|
335
|
+
const result = await directUpload.mutateAsync(file)
|
|
336
|
+
const current = form.getValues('media') ?? []
|
|
337
|
+
form.setValue(
|
|
338
|
+
'media',
|
|
339
|
+
[
|
|
340
|
+
...current,
|
|
341
|
+
{
|
|
342
|
+
signed_id: result.signedId,
|
|
343
|
+
alt: file.name,
|
|
344
|
+
position: current.length + 1,
|
|
345
|
+
media_type: file.type.startsWith('video/')
|
|
346
|
+
? ('video' as const)
|
|
347
|
+
: ('image' as const),
|
|
348
|
+
previewUrl: preview,
|
|
349
|
+
// Lets an uploaded video play in the editor before it is saved.
|
|
350
|
+
videoUrl: file.type.startsWith('video/') ? preview : null,
|
|
351
|
+
uploadId,
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
{ shouldDirty: true },
|
|
355
|
+
)
|
|
356
|
+
setPending((prev) => prev.filter((p) => p.id !== uploadId))
|
|
357
|
+
} catch (err) {
|
|
358
|
+
console.error(`Upload failed for ${file.name}:`, err)
|
|
359
|
+
setPending((prev) =>
|
|
360
|
+
prev.map((p) => (p.id === uploadId ? { ...p, progress: 'error' as const } : p)),
|
|
361
|
+
)
|
|
362
|
+
const message = err instanceof Error ? err.message : t('admin.errors.unexpected')
|
|
363
|
+
toastManager.add({
|
|
364
|
+
type: 'error',
|
|
365
|
+
title: t('admin.products.media.upload_failed', { name: file.name, message }),
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
[directUpload, form, t],
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
const handleDrop = useCallback(
|
|
374
|
+
(e: React.DragEvent) => {
|
|
375
|
+
e.preventDefault()
|
|
376
|
+
const accepted = Array.from(e.dataTransfer.files).filter(isAcceptedMedia)
|
|
377
|
+
const rejected = e.dataTransfer.files.length - accepted.length
|
|
378
|
+
|
|
379
|
+
if (rejected > 0)
|
|
380
|
+
toastManager.add({
|
|
381
|
+
type: 'error',
|
|
382
|
+
title: t('admin.products.media.unsupported_file', { count: rejected }),
|
|
383
|
+
})
|
|
384
|
+
if (accepted.length > 0) handleFiles(accepted)
|
|
385
|
+
},
|
|
386
|
+
[handleFiles, t],
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
390
|
+
e.preventDefault()
|
|
391
|
+
}, [])
|
|
392
|
+
|
|
393
|
+
// An external video has no file to upload — it goes straight into form state
|
|
394
|
+
// with its link, and the provider's own still stands in as the preview.
|
|
395
|
+
const handleAddVideo = useCallback(
|
|
396
|
+
(url: string) => {
|
|
397
|
+
const current = form.getValues('media') ?? []
|
|
398
|
+
form.setValue(
|
|
399
|
+
'media',
|
|
400
|
+
[
|
|
401
|
+
...current,
|
|
402
|
+
{
|
|
403
|
+
media_type: 'external_video' as const,
|
|
404
|
+
external_video_url: url,
|
|
405
|
+
position: current.length + 1,
|
|
406
|
+
previewUrl: parseVideoUrl(url)?.thumbnailUrl ?? undefined,
|
|
407
|
+
uploadId: crypto.randomUUID(),
|
|
408
|
+
},
|
|
409
|
+
],
|
|
410
|
+
{ shouldDirty: true },
|
|
411
|
+
)
|
|
412
|
+
},
|
|
413
|
+
[form],
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
// Files picked from the library become ordinary form entries carrying the
|
|
417
|
+
// source's id. The product save places them, sharing the file rather than
|
|
418
|
+
// uploading a second copy.
|
|
419
|
+
const handleAddFromLibrary = useCallback(
|
|
420
|
+
(picked: Media[]) => {
|
|
421
|
+
const current = form.getValues('media') ?? []
|
|
422
|
+
form.setValue(
|
|
423
|
+
'media',
|
|
424
|
+
[
|
|
425
|
+
...current,
|
|
426
|
+
...picked.map((media, index) => ({
|
|
427
|
+
source_media_id: media.id,
|
|
428
|
+
alt: media.alt ?? undefined,
|
|
429
|
+
media_type: (media.media_type ?? 'image') as MediaType,
|
|
430
|
+
position: current.length + index + 1,
|
|
431
|
+
previewUrl: media.small_url ?? media.original_url ?? undefined,
|
|
432
|
+
videoUrl: media.video_url ?? null,
|
|
433
|
+
posterUrl: media.poster_url ?? null,
|
|
434
|
+
uploadId: crypto.randomUUID(),
|
|
435
|
+
})),
|
|
436
|
+
],
|
|
437
|
+
{ shouldDirty: true },
|
|
438
|
+
)
|
|
439
|
+
},
|
|
440
|
+
[form],
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
const handleDelete = useCallback(
|
|
444
|
+
async (index: number) => {
|
|
445
|
+
const current = form.getValues('media') ?? []
|
|
446
|
+
const entry = current[index]
|
|
447
|
+
if (!entry) return
|
|
448
|
+
const confirmed = await confirm({
|
|
449
|
+
message: t('admin.products.media.delete_confirm'),
|
|
450
|
+
variant: 'destructive',
|
|
451
|
+
confirmLabel: t('admin.actions.delete'),
|
|
452
|
+
})
|
|
453
|
+
if (!confirmed) return
|
|
454
|
+
|
|
455
|
+
// Persisted entries call the dedicated DELETE endpoint first so we
|
|
456
|
+
// don't drop the form-state entry until the server actually removed it.
|
|
457
|
+
// Pre-save entries (signed_id only, no id) just disappear from form.
|
|
458
|
+
if (entry.id && productId) {
|
|
459
|
+
try {
|
|
460
|
+
await deleteMedia.mutateAsync(entry.id)
|
|
461
|
+
} catch {
|
|
462
|
+
toastManager.add({ type: 'error', title: t('admin.errors.failed_to_delete') })
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Release the Blob URL we created at upload time so the browser can
|
|
468
|
+
// reclaim its backing memory. Server-served previewUrls aren't blob:
|
|
469
|
+
// URLs and don't need revocation.
|
|
470
|
+
if (entry.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(entry.previewUrl)
|
|
471
|
+
|
|
472
|
+
const next = current.filter((_, i) => i !== index).map((m, i) => ({ ...m, position: i + 1 }))
|
|
473
|
+
form.setValue('media', next, { shouldDirty: true })
|
|
474
|
+
|
|
475
|
+
// Close or shift the edit sheet so it can't reference a stale index.
|
|
476
|
+
// If the deleted row was being edited, close. If the deleted row was
|
|
477
|
+
// BEFORE the open one, shift down by one.
|
|
478
|
+
setEditingIndex((current) => {
|
|
479
|
+
if (current == null) return current
|
|
480
|
+
if (current === index) return null
|
|
481
|
+
if (current > index) return current - 1
|
|
482
|
+
return current
|
|
483
|
+
})
|
|
484
|
+
},
|
|
485
|
+
[form, productId, deleteMedia, confirm, t],
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
// Revoke any remaining blob: previewUrls when the card unmounts (the
|
|
489
|
+
// merchant navigated away mid-edit without saving). form.getValues is a
|
|
490
|
+
// stable RHF method — listing it in deps wouldn't change effect timing
|
|
491
|
+
// but it keeps the linter happy.
|
|
492
|
+
useEffect(() => {
|
|
493
|
+
return () => {
|
|
494
|
+
const current = form.getValues('media') ?? []
|
|
495
|
+
for (const m of current) {
|
|
496
|
+
if (m.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(m.previewUrl)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}, [form])
|
|
500
|
+
|
|
501
|
+
const editingEntry = editingIndex !== null ? items[editingIndex] : null
|
|
502
|
+
|
|
503
|
+
return (
|
|
504
|
+
<>
|
|
505
|
+
<Card className="scroll-mt-[calc(var(--spacing-header-height)+1.5rem)]">
|
|
506
|
+
<CardHeader>
|
|
507
|
+
<CardTitle>{t('admin.pages.products.section_media')}</CardTitle>
|
|
508
|
+
</CardHeader>
|
|
509
|
+
<CardContent className="flex flex-col gap-4">
|
|
510
|
+
{(items.length > 0 || pending.length > 0) && (
|
|
511
|
+
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
|
512
|
+
<SortableContext items={sortableIds} strategy={rectSortingStrategy}>
|
|
513
|
+
<div className="grid grid-cols-4 gap-3">
|
|
514
|
+
{items.map((media, index) => (
|
|
515
|
+
<SortableMediaThumbnail
|
|
516
|
+
key={sortableIds[index]}
|
|
517
|
+
sortableId={sortableIds[index]}
|
|
518
|
+
previewUrl={media.previewUrl ?? null}
|
|
519
|
+
downloadUrl={media.downloadUrl}
|
|
520
|
+
alt={media.alt ?? ''}
|
|
521
|
+
mediaType={media.media_type ?? 'image'}
|
|
522
|
+
onEdit={() => setEditingIndex(index)}
|
|
523
|
+
onDelete={() => handleDelete(index)}
|
|
524
|
+
/>
|
|
525
|
+
))}
|
|
526
|
+
{pending.map((upload) => (
|
|
527
|
+
<div
|
|
528
|
+
key={upload.id}
|
|
529
|
+
className="relative aspect-square overflow-hidden rounded-lg border border-border bg-muted"
|
|
530
|
+
>
|
|
531
|
+
{upload.file.type.startsWith('video/') ? (
|
|
532
|
+
<video
|
|
533
|
+
src={upload.preview}
|
|
534
|
+
muted
|
|
535
|
+
playsInline
|
|
536
|
+
preload="metadata"
|
|
537
|
+
className="size-full object-cover opacity-60"
|
|
538
|
+
/>
|
|
539
|
+
) : (
|
|
540
|
+
<img
|
|
541
|
+
src={upload.preview}
|
|
542
|
+
alt=""
|
|
543
|
+
className="size-full object-cover opacity-60"
|
|
544
|
+
/>
|
|
545
|
+
)}
|
|
546
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
547
|
+
{upload.progress === 'error' ? (
|
|
548
|
+
<span className="text-xs text-destructive font-medium">
|
|
549
|
+
{t('admin.products.media.upload_status_failed')}
|
|
550
|
+
</span>
|
|
551
|
+
) : (
|
|
552
|
+
<Loader2Icon className="size-5 animate-spin text-muted-foreground" />
|
|
553
|
+
)}
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
))}
|
|
557
|
+
</div>
|
|
558
|
+
</SortableContext>
|
|
559
|
+
</DndContext>
|
|
560
|
+
)}
|
|
561
|
+
|
|
562
|
+
<button
|
|
563
|
+
type="button"
|
|
564
|
+
onDrop={handleDrop}
|
|
565
|
+
onDragOver={handleDragOver}
|
|
566
|
+
className="flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border p-6 text-center transition-colors hover:border-foreground/30 cursor-pointer"
|
|
567
|
+
onClick={() => fileInputRef.current?.click()}
|
|
568
|
+
>
|
|
569
|
+
<ImagePlusIcon className="size-8 text-muted-foreground" />
|
|
570
|
+
<p className="text-sm text-muted-foreground">{t('admin.products.media.drop_hint')}</p>
|
|
571
|
+
<p className="text-xs text-muted-foreground">
|
|
572
|
+
{t('admin.products.media.file_types_hint')}
|
|
573
|
+
</p>
|
|
574
|
+
</button>
|
|
575
|
+
<input
|
|
576
|
+
ref={fileInputRef}
|
|
577
|
+
type="file"
|
|
578
|
+
accept={ACCEPTED_MEDIA.map((type) => (type.endsWith('/') ? `${type}*` : type)).join(
|
|
579
|
+
',',
|
|
580
|
+
)}
|
|
581
|
+
multiple
|
|
582
|
+
className="hidden"
|
|
583
|
+
onChange={(e) => e.target.files && handleFiles(e.target.files)}
|
|
584
|
+
/>
|
|
585
|
+
|
|
586
|
+
<div className="flex flex-wrap gap-2 self-start">
|
|
587
|
+
{/* Only a panel with a library to browse offers this. */}
|
|
588
|
+
{mediaLibrary && (
|
|
589
|
+
<Button type="button" variant="outline" onClick={() => setPickingFromLibrary(true)}>
|
|
590
|
+
<LibraryIcon />
|
|
591
|
+
{t('admin.products.media.add_from_library')}
|
|
592
|
+
</Button>
|
|
593
|
+
)}
|
|
594
|
+
|
|
595
|
+
<Button type="button" variant="outline" onClick={() => setAddingVideo(true)}>
|
|
596
|
+
<FilmIcon />
|
|
597
|
+
{t('admin.products.media.add_video')}
|
|
598
|
+
</Button>
|
|
599
|
+
</div>
|
|
600
|
+
</CardContent>
|
|
601
|
+
</Card>
|
|
602
|
+
<AddVideoDialog open={addingVideo} onOpenChange={setAddingVideo} onAdd={handleAddVideo} />
|
|
603
|
+
{/* Not merely closed — the sheet reads the store context on mount, and
|
|
604
|
+
a panel without one has no library to pick from anyway. */}
|
|
605
|
+
{mediaLibrary && (
|
|
606
|
+
<MediaPickerSheet<Media>
|
|
607
|
+
open={pickingFromLibrary}
|
|
608
|
+
onOpenChange={setPickingFromLibrary}
|
|
609
|
+
queryKey="product-media-library"
|
|
610
|
+
search={async (query) =>
|
|
611
|
+
(await mediaLibrary?.list({
|
|
612
|
+
limit: 48,
|
|
613
|
+
...(query ? { filename_cont: query } : {}),
|
|
614
|
+
})) ?? { data: [] }
|
|
615
|
+
}
|
|
616
|
+
onConfirm={handleAddFromLibrary}
|
|
617
|
+
/>
|
|
618
|
+
)}
|
|
619
|
+
{editingEntry && editingIndex !== null && (
|
|
620
|
+
<MediaEditSheet
|
|
621
|
+
form={form}
|
|
622
|
+
mediaIndex={editingIndex}
|
|
623
|
+
variants={variants ?? []}
|
|
624
|
+
open
|
|
625
|
+
onOpenChange={(open) => {
|
|
626
|
+
if (!open) setEditingIndex(null)
|
|
627
|
+
}}
|
|
628
|
+
/>
|
|
629
|
+
)}
|
|
630
|
+
</>
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function SortableMediaThumbnail({
|
|
635
|
+
sortableId,
|
|
636
|
+
previewUrl,
|
|
637
|
+
alt,
|
|
638
|
+
mediaType,
|
|
639
|
+
downloadUrl,
|
|
640
|
+
onEdit,
|
|
641
|
+
onDelete,
|
|
642
|
+
}: {
|
|
643
|
+
sortableId: string
|
|
644
|
+
previewUrl: string | null
|
|
645
|
+
alt: string
|
|
646
|
+
mediaType: MediaType
|
|
647
|
+
/** Absent until the row is saved — a pre-save upload has no server URL. */
|
|
648
|
+
downloadUrl?: string | null
|
|
649
|
+
onEdit: () => void
|
|
650
|
+
onDelete: () => void
|
|
651
|
+
}) {
|
|
652
|
+
const { t } = useTranslation()
|
|
653
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
654
|
+
id: sortableId,
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
const style = {
|
|
658
|
+
transform: CSS.Transform.toString(transform),
|
|
659
|
+
transition,
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return (
|
|
663
|
+
<ContextMenu>
|
|
664
|
+
<ContextMenuTrigger
|
|
665
|
+
render={
|
|
666
|
+
// Clicking opens the edit sheet; dragging is the corner grip's job.
|
|
667
|
+
// The whole tile being the drag surface left no way to click it.
|
|
668
|
+
// biome-ignore lint/a11y/useSemanticElements: the grip inside rules out a <button>
|
|
669
|
+
<div
|
|
670
|
+
ref={setNodeRef}
|
|
671
|
+
style={style}
|
|
672
|
+
data-slot="media-thumbnail"
|
|
673
|
+
role="button"
|
|
674
|
+
tabIndex={0}
|
|
675
|
+
onClick={onEdit}
|
|
676
|
+
onKeyDown={(e) => {
|
|
677
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
678
|
+
e.preventDefault()
|
|
679
|
+
onEdit()
|
|
680
|
+
}
|
|
681
|
+
}}
|
|
682
|
+
className={`group relative aspect-square cursor-pointer bg-muted overflow-hidden rounded-md border border-border transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 ${
|
|
683
|
+
isDragging ? 'opacity-40 ring-2 ring-primary/40' : ''
|
|
684
|
+
}`}
|
|
685
|
+
/>
|
|
686
|
+
}
|
|
687
|
+
>
|
|
688
|
+
{previewUrl && mediaType === 'video' ? (
|
|
689
|
+
// An uploaded video has no still until a poster is set, so its own
|
|
690
|
+
// first frame stands in — an <img> would render a broken icon.
|
|
691
|
+
<video
|
|
692
|
+
src={previewUrl}
|
|
693
|
+
muted
|
|
694
|
+
playsInline
|
|
695
|
+
preload="metadata"
|
|
696
|
+
className="pointer-events-none size-full object-cover"
|
|
697
|
+
/>
|
|
698
|
+
) : previewUrl ? (
|
|
699
|
+
<img
|
|
700
|
+
src={previewUrl}
|
|
701
|
+
alt={alt}
|
|
702
|
+
draggable={false}
|
|
703
|
+
className="pointer-events-none size-full object-cover"
|
|
704
|
+
/>
|
|
705
|
+
) : (
|
|
706
|
+
<div className="flex size-full items-center justify-center text-muted-foreground">
|
|
707
|
+
{mediaType === 'image' ? (
|
|
708
|
+
<ImagePlusIcon className="size-6" />
|
|
709
|
+
) : (
|
|
710
|
+
<FilmIcon className="size-6" />
|
|
711
|
+
)}
|
|
712
|
+
</div>
|
|
713
|
+
)}
|
|
714
|
+
|
|
715
|
+
{mediaType !== 'image' && (
|
|
716
|
+
<span className="pointer-events-none absolute left-1.5 top-1.5 z-10 flex size-6 items-center justify-center rounded-full bg-black/60 text-white">
|
|
717
|
+
<PlayIcon className="size-3 fill-current" />
|
|
718
|
+
</span>
|
|
719
|
+
)}
|
|
720
|
+
|
|
721
|
+
<DragHandle
|
|
722
|
+
attributes={attributes}
|
|
723
|
+
listeners={listeners}
|
|
724
|
+
// Stop the click reaching the tile behind it, or picking the grip up
|
|
725
|
+
// would also open the sheet. touch-none lets a touch drag start
|
|
726
|
+
// without the browser treating it as a scroll.
|
|
727
|
+
onClick={(e) => e.stopPropagation()}
|
|
728
|
+
className="absolute right-1.5 top-1.5 z-10 h-auto w-auto touch-none rounded-md bg-background/80 p-1 opacity-0 shadow-sm backdrop-blur transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
|
729
|
+
/>
|
|
730
|
+
</ContextMenuTrigger>
|
|
731
|
+
|
|
732
|
+
{/* Right-click is the way in here; clicking a tile drags it, and the
|
|
733
|
+
edit sheet is reachable from this menu. */}
|
|
734
|
+
<ContextMenuContent>
|
|
735
|
+
<ContextMenuItem onClick={onEdit}>
|
|
736
|
+
<PencilIcon />
|
|
737
|
+
{t('admin.actions.edit')}
|
|
738
|
+
</ContextMenuItem>
|
|
739
|
+
{downloadUrl && (
|
|
740
|
+
// The URL carries a Content-Disposition attachment header, so
|
|
741
|
+
// navigating to it downloads rather than opening a page.
|
|
742
|
+
<ContextMenuItem
|
|
743
|
+
onClick={() => {
|
|
744
|
+
window.location.href = downloadUrl
|
|
745
|
+
}}
|
|
746
|
+
>
|
|
747
|
+
<DownloadIcon />
|
|
748
|
+
{t('admin.media_library.download')}
|
|
749
|
+
</ContextMenuItem>
|
|
750
|
+
)}
|
|
751
|
+
<ContextMenuSeparator />
|
|
752
|
+
<ContextMenuItem variant="destructive" onClick={onDelete}>
|
|
753
|
+
<TrashIcon />
|
|
754
|
+
{t('admin.actions.delete')}
|
|
755
|
+
</ContextMenuItem>
|
|
756
|
+
</ContextMenuContent>
|
|
757
|
+
</ContextMenu>
|
|
758
|
+
)
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ---------------------------------------------------------------------------
|
|
762
|
+
// Inventory
|
|
763
|
+
// ---------------------------------------------------------------------------
|
|
764
|
+
|
|
765
|
+
export function InventoryCard({
|
|
766
|
+
form,
|
|
767
|
+
stockLocationHref,
|
|
768
|
+
actions,
|
|
769
|
+
}: FormCardProps & { stockLocationHref?: (id: string) => string; actions?: React.ReactNode }) {
|
|
770
|
+
const { t } = useTranslation()
|
|
771
|
+
return (
|
|
772
|
+
<Card>
|
|
773
|
+
<CardHeader>
|
|
774
|
+
<CardTitle>{t('admin.pages.products.section_inventory')}</CardTitle>
|
|
775
|
+
{actions && <CardAction>{actions}</CardAction>}
|
|
776
|
+
</CardHeader>
|
|
777
|
+
<CardContent className="p-0">
|
|
778
|
+
<InventorySection form={form} stockLocationHref={stockLocationHref} />
|
|
779
|
+
</CardContent>
|
|
780
|
+
</Card>
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// ---------------------------------------------------------------------------
|
|
785
|
+
// Custom Fields — edit-only placeholder ("save first")
|
|
786
|
+
// ---------------------------------------------------------------------------
|
|
787
|
+
|
|
788
|
+
// ---------------------------------------------------------------------------
|
|
789
|
+
// SEO
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
|
|
792
|
+
export function SEOCard({ form, product }: FormCardProps & { product?: Product }) {
|
|
793
|
+
const { t } = useTranslation()
|
|
794
|
+
const slug = form.watch('slug')
|
|
795
|
+
const metaTitle = form.watch('meta_title')
|
|
796
|
+
const metaDescription = form.watch('meta_description')
|
|
797
|
+
const name = form.watch('name')
|
|
798
|
+
const { errors } = form.formState
|
|
799
|
+
|
|
800
|
+
const previewTitle = metaTitle || product?.name || name || ''
|
|
801
|
+
const previewSlug =
|
|
802
|
+
slug || product?.slug || t('admin.pages.products.new.preview_slug_placeholder')
|
|
803
|
+
|
|
804
|
+
return (
|
|
805
|
+
<Card>
|
|
806
|
+
<CardHeader>
|
|
807
|
+
<CardTitle>{t('admin.pages.products.section_seo')}</CardTitle>
|
|
808
|
+
</CardHeader>
|
|
809
|
+
<CardContent className="flex flex-col gap-4">
|
|
810
|
+
<div className="rounded-lg border border-border p-4 space-y-1">
|
|
811
|
+
<p className="text-sm font-medium text-blue-700 truncate">{previewTitle}</p>
|
|
812
|
+
<p className="text-xs text-green-700 truncate">example.com/products/{previewSlug}</p>
|
|
813
|
+
{metaDescription && (
|
|
814
|
+
<p className="text-xs text-muted-foreground line-clamp-2">{metaDescription}</p>
|
|
815
|
+
)}
|
|
816
|
+
</div>
|
|
817
|
+
|
|
818
|
+
<Field>
|
|
819
|
+
<FieldLabel htmlFor="product-slug">{t('admin.fields.slug.label')}</FieldLabel>
|
|
820
|
+
<Input
|
|
821
|
+
id="product-slug"
|
|
822
|
+
placeholder={t('admin.products.seo.slug_placeholder')}
|
|
823
|
+
aria-invalid={!!errors.slug || undefined}
|
|
824
|
+
{...form.register('slug')}
|
|
825
|
+
/>
|
|
826
|
+
<FieldError errors={[errors.slug]} />
|
|
827
|
+
</Field>
|
|
828
|
+
<Field>
|
|
829
|
+
<FieldLabel htmlFor="product-meta-title">{t('admin.fields.meta_title.label')}</FieldLabel>
|
|
830
|
+
<Input
|
|
831
|
+
id="product-meta-title"
|
|
832
|
+
placeholder={t('admin.products.seo.meta_title_placeholder')}
|
|
833
|
+
aria-invalid={!!errors.meta_title || undefined}
|
|
834
|
+
{...form.register('meta_title')}
|
|
835
|
+
/>
|
|
836
|
+
<FieldError errors={[errors.meta_title]} />
|
|
837
|
+
</Field>
|
|
838
|
+
<Field>
|
|
839
|
+
<FieldLabel htmlFor="product-meta-description">
|
|
840
|
+
{t('admin.fields.meta_description.label')}
|
|
841
|
+
</FieldLabel>
|
|
842
|
+
<Textarea
|
|
843
|
+
id="product-meta-description"
|
|
844
|
+
rows={3}
|
|
845
|
+
placeholder={t('admin.products.seo.meta_description_placeholder')}
|
|
846
|
+
aria-invalid={!!errors.meta_description || undefined}
|
|
847
|
+
{...form.register('meta_description')}
|
|
848
|
+
/>
|
|
849
|
+
<FieldError errors={[errors.meta_description]} />
|
|
850
|
+
</Field>
|
|
851
|
+
</CardContent>
|
|
852
|
+
</Card>
|
|
853
|
+
)
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// ---------------------------------------------------------------------------
|
|
857
|
+
// Status
|
|
858
|
+
// ---------------------------------------------------------------------------
|
|
859
|
+
|
|
860
|
+
const ASSIGNABLE_STATUSES = ['draft', 'active', 'archived']
|
|
861
|
+
|
|
862
|
+
export function StatusCard({
|
|
863
|
+
form,
|
|
864
|
+
reviewActions,
|
|
865
|
+
}: FormCardProps & {
|
|
866
|
+
/**
|
|
867
|
+
* Approve / Reject, supplied by the panel that can run them. Rendered only
|
|
868
|
+
* while the product is actually in review, so an ordinary product's card is
|
|
869
|
+
* unchanged.
|
|
870
|
+
*/
|
|
871
|
+
reviewActions?: React.ReactNode
|
|
872
|
+
}) {
|
|
873
|
+
const { t } = useTranslation()
|
|
874
|
+
const status = useWatch({ control: form.control, name: 'status' })
|
|
875
|
+
|
|
876
|
+
// A product in a review status sits on one nobody assigns — it got there by
|
|
877
|
+
// a seller submitting or an operator deciding. Listing it anyway is what
|
|
878
|
+
// lets the picker render the product's actual state instead of a raw value
|
|
879
|
+
// with no matching option (docs/plans/6.0-seller-product-submission.md).
|
|
880
|
+
//
|
|
881
|
+
// Only `proposed` locks the control. That is a decision still to be made,
|
|
882
|
+
// and it is made with Approve or Reject. `rejected` is a decision already
|
|
883
|
+
// made, so the operator can move the product on from here like any other —
|
|
884
|
+
// overriding their own refusal is not a review action, and leaving the
|
|
885
|
+
// picker disabled there was a dead end with no way out.
|
|
886
|
+
const inReview = Boolean(status) && !ASSIGNABLE_STATUSES.includes(status as string)
|
|
887
|
+
const awaitingDecision = status === 'proposed'
|
|
888
|
+
const values = inReview ? [status as string, ...ASSIGNABLE_STATUSES] : ASSIGNABLE_STATUSES
|
|
889
|
+
|
|
890
|
+
return (
|
|
891
|
+
<SharedStatusCard<ProductFormValues>
|
|
892
|
+
control={form.control as Control<ProductFormValues>}
|
|
893
|
+
name="status"
|
|
894
|
+
title={t('admin.fields.status.label')}
|
|
895
|
+
label={t('admin.fields.status.label')}
|
|
896
|
+
disabled={awaitingDecision}
|
|
897
|
+
description={awaitingDecision ? t('admin.pages.products.status_under_review') : undefined}
|
|
898
|
+
actions={inReview ? reviewActions : undefined}
|
|
899
|
+
options={values.map((value) => ({
|
|
900
|
+
value,
|
|
901
|
+
label: t(`admin.pages.products.status_options.${value}`),
|
|
902
|
+
}))}
|
|
903
|
+
/>
|
|
904
|
+
)
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// ---------------------------------------------------------------------------
|
|
908
|
+
// Categorization
|
|
909
|
+
// ---------------------------------------------------------------------------
|
|
910
|
+
|
|
911
|
+
export function CategorizationCard({ form }: FormCardProps) {
|
|
912
|
+
const { t } = useTranslation()
|
|
913
|
+
// Each picker shows only when the panel's client registers its source: a
|
|
914
|
+
// seller's registers types and delivery profiles but not the filing
|
|
915
|
+
// vocabulary (categories, collections, tags), which is the operator's.
|
|
916
|
+
const client = getApiClient()
|
|
917
|
+
const { data: categoriesData } = useCategories()
|
|
918
|
+
const { data: collectionsData } = useCollections()
|
|
919
|
+
const { data: productTypesData } = useProductTypes()
|
|
920
|
+
const productTypes = productTypesData?.data ?? []
|
|
921
|
+
const { data: deliveryProfilesData } = useDeliveryProfiles()
|
|
922
|
+
const deliveryProfiles = useMemo(() => deliveryProfilesData?.data ?? [], [deliveryProfilesData])
|
|
923
|
+
// A product with no profile of its own ships on the store default, so the
|
|
924
|
+
// select shows that rather than an empty trigger the merchant has to guess at.
|
|
925
|
+
const defaultDeliveryProfileId = deliveryProfiles.find((profile) => profile.default)?.id
|
|
926
|
+
const selectedProductTypeId = form.watch('product_type_id') as string | null | undefined
|
|
927
|
+
const { data: selectedProductType } = useProductType(selectedProductTypeId ?? undefined)
|
|
928
|
+
// Resolved by id: the type may reference option types beyond the first page
|
|
929
|
+
// of the global list, which would otherwise drop out of the hint.
|
|
930
|
+
const { data: typeOptionTypesData } = useOptionTypesByIds(selectedProductType?.option_type_ids)
|
|
931
|
+
const optionTypes = useMemo(() => typeOptionTypesData?.data ?? [], [typeOptionTypesData])
|
|
932
|
+
|
|
933
|
+
// The server seeds the type's categories onto the product when it is saved.
|
|
934
|
+
// Prefilling them here means the merchant sees what they are about to get and
|
|
935
|
+
// can still edit the list first. Additive, like the server: nothing they
|
|
936
|
+
// already picked is removed.
|
|
937
|
+
// Named rather than counted: on a product that already has variants the
|
|
938
|
+
// builder won't surface these, so the name is the only signal the merchant
|
|
939
|
+
// gets about what the type contributes.
|
|
940
|
+
const typeOptionTypeNames = useMemo(() => {
|
|
941
|
+
// Served already named where the panel has no option-type list to resolve
|
|
942
|
+
// ids against — a seller's API sends labels for exactly this reason.
|
|
943
|
+
const labels = selectedProductType?.option_type_labels
|
|
944
|
+
if (labels?.length) return labels
|
|
945
|
+
|
|
946
|
+
const ids = selectedProductType?.option_type_ids ?? []
|
|
947
|
+
return ids
|
|
948
|
+
.map((id) => optionTypes.find((optionType) => optionType.id === id))
|
|
949
|
+
.filter((optionType) => optionType !== undefined)
|
|
950
|
+
.map((optionType) => optionType.label ?? optionType.name ?? '')
|
|
951
|
+
.filter(Boolean)
|
|
952
|
+
}, [selectedProductType?.option_type_labels, selectedProductType?.option_type_ids, optionTypes])
|
|
953
|
+
|
|
954
|
+
useEffect(() => {
|
|
955
|
+
// Only where the panel files products at all: a seller's client registers
|
|
956
|
+
// no categories, so seeding them would dirty the form with ids their API
|
|
957
|
+
// drops on save — an unsaved-changes prompt over a field they cannot see.
|
|
958
|
+
if (!client.categories) return
|
|
959
|
+
|
|
960
|
+
const categoryIds = selectedProductType?.category_ids
|
|
961
|
+
if (!categoryIds?.length) return
|
|
962
|
+
|
|
963
|
+
const current = (form.getValues('category_ids') as string[] | undefined) ?? []
|
|
964
|
+
const missing = categoryIds.filter((id) => !current.includes(id))
|
|
965
|
+
if (missing.length === 0) return
|
|
966
|
+
|
|
967
|
+
form.setValue('category_ids', [...current, ...missing], { shouldDirty: true })
|
|
968
|
+
}, [selectedProductType, form, client.categories])
|
|
969
|
+
// Automatic collections rebuild their members from rules, so a hand-picked
|
|
970
|
+
// membership would be dropped on the next regeneration — offer manual only.
|
|
971
|
+
// Memoized: `initialItems` feeds a useEffect + useMemo inside
|
|
972
|
+
// ResourceMultiAutocomplete, so a fresh array each render would re-run both
|
|
973
|
+
// on every keystroke in the product form.
|
|
974
|
+
const manualCollections = useMemo(
|
|
975
|
+
() => collectionsData?.data.filter((collection) => !collection.automatic),
|
|
976
|
+
[collectionsData],
|
|
977
|
+
)
|
|
978
|
+
return (
|
|
979
|
+
<Card>
|
|
980
|
+
<CardHeader>
|
|
981
|
+
<CardTitle>{t('admin.pages.products.section_categorization')}</CardTitle>
|
|
982
|
+
</CardHeader>
|
|
983
|
+
<CardContent className="flex flex-col gap-4">
|
|
984
|
+
{client.productTypes && (
|
|
985
|
+
<Field>
|
|
986
|
+
<FieldLabel>{t('admin.fields.product.product_type_id.label')}</FieldLabel>
|
|
987
|
+
<Controller
|
|
988
|
+
name="product_type_id"
|
|
989
|
+
control={form.control}
|
|
990
|
+
render={({ field }) => (
|
|
991
|
+
<Select value={field.value ?? ''} onValueChange={(v) => field.onChange(v || null)}>
|
|
992
|
+
<SelectTrigger className="w-full">
|
|
993
|
+
<SelectValue placeholder={t('admin.products.product_type_placeholder')}>
|
|
994
|
+
{(v) =>
|
|
995
|
+
productTypes.find((pt) => pt.id === v)?.name ??
|
|
996
|
+
t('admin.products.product_type_placeholder')
|
|
997
|
+
}
|
|
998
|
+
</SelectValue>
|
|
999
|
+
</SelectTrigger>
|
|
1000
|
+
<SelectContent>
|
|
1001
|
+
{productTypes.map((productType) => (
|
|
1002
|
+
<SelectItem key={productType.id} value={productType.id}>
|
|
1003
|
+
{productType.name}
|
|
1004
|
+
</SelectItem>
|
|
1005
|
+
))}
|
|
1006
|
+
</SelectContent>
|
|
1007
|
+
</Select>
|
|
1008
|
+
)}
|
|
1009
|
+
/>
|
|
1010
|
+
<span className="text-muted-foreground text-xs">
|
|
1011
|
+
{t('admin.fields.product.product_type_id.help')}
|
|
1012
|
+
</span>
|
|
1013
|
+
{typeOptionTypeNames.length > 0 && (
|
|
1014
|
+
<span className="text-muted-foreground text-xs">
|
|
1015
|
+
{t('admin.products.product_type_adds_options', {
|
|
1016
|
+
options: typeOptionTypeNames.join(', '),
|
|
1017
|
+
count: typeOptionTypeNames.length,
|
|
1018
|
+
})}
|
|
1019
|
+
</span>
|
|
1020
|
+
)}
|
|
1021
|
+
</Field>
|
|
1022
|
+
)}
|
|
1023
|
+
|
|
1024
|
+
{/* A panel whose client registers no delivery profiles cannot write
|
|
1025
|
+
one, so it does not offer the field rather than showing an edit
|
|
1026
|
+
that goes nowhere. Gated on the registration, not on the rows
|
|
1027
|
+
having arrived: keyed on the list, the field would vanish and
|
|
1028
|
+
reappear as the fetch settles. */}
|
|
1029
|
+
{client.deliveryProfiles && (
|
|
1030
|
+
<Field>
|
|
1031
|
+
<FieldLabel>{t('admin.fields.product.delivery_profile_id.label')}</FieldLabel>
|
|
1032
|
+
<Controller
|
|
1033
|
+
name="delivery_profile_id"
|
|
1034
|
+
control={form.control}
|
|
1035
|
+
render={({ field }) => (
|
|
1036
|
+
<Select
|
|
1037
|
+
value={field.value ?? defaultDeliveryProfileId ?? ''}
|
|
1038
|
+
onValueChange={(v) => field.onChange(v || null)}
|
|
1039
|
+
>
|
|
1040
|
+
<SelectTrigger className="w-full">
|
|
1041
|
+
<SelectValue>
|
|
1042
|
+
{(v) => deliveryProfiles.find((profile) => profile.id === v)?.name ?? ''}
|
|
1043
|
+
</SelectValue>
|
|
1044
|
+
</SelectTrigger>
|
|
1045
|
+
<SelectContent>
|
|
1046
|
+
{deliveryProfiles.map((profile) => (
|
|
1047
|
+
<SelectItem key={profile.id} value={profile.id}>
|
|
1048
|
+
{profile.name}
|
|
1049
|
+
</SelectItem>
|
|
1050
|
+
))}
|
|
1051
|
+
</SelectContent>
|
|
1052
|
+
</Select>
|
|
1053
|
+
)}
|
|
1054
|
+
/>
|
|
1055
|
+
<span className="text-muted-foreground text-xs">
|
|
1056
|
+
{t('admin.fields.product.delivery_profile_id.help')}
|
|
1057
|
+
</span>
|
|
1058
|
+
</Field>
|
|
1059
|
+
)}
|
|
1060
|
+
|
|
1061
|
+
{client.categories && (
|
|
1062
|
+
<Field>
|
|
1063
|
+
<FieldLabel>{t('admin.fields.product.category_ids.label')}</FieldLabel>
|
|
1064
|
+
<Controller
|
|
1065
|
+
name="category_ids"
|
|
1066
|
+
control={form.control}
|
|
1067
|
+
render={({ field }) => (
|
|
1068
|
+
<ResourceMultiAutocomplete
|
|
1069
|
+
{...categoryAutocompleteProps('product-edit-category-picker')}
|
|
1070
|
+
initialItems={categoriesData?.data}
|
|
1071
|
+
value={field.value ?? []}
|
|
1072
|
+
onChange={field.onChange}
|
|
1073
|
+
/>
|
|
1074
|
+
)}
|
|
1075
|
+
/>
|
|
1076
|
+
</Field>
|
|
1077
|
+
)}
|
|
1078
|
+
|
|
1079
|
+
{client.collections && (
|
|
1080
|
+
<Field>
|
|
1081
|
+
<FieldLabel>{t('admin.fields.product.collection_ids.label')}</FieldLabel>
|
|
1082
|
+
<Controller
|
|
1083
|
+
name="collection_ids"
|
|
1084
|
+
control={form.control}
|
|
1085
|
+
render={({ field }) => (
|
|
1086
|
+
<ResourceMultiAutocomplete
|
|
1087
|
+
{...collectionAutocompleteProps('product-edit-collection-picker')}
|
|
1088
|
+
initialItems={manualCollections}
|
|
1089
|
+
value={field.value ?? []}
|
|
1090
|
+
onChange={field.onChange}
|
|
1091
|
+
/>
|
|
1092
|
+
)}
|
|
1093
|
+
/>
|
|
1094
|
+
<span className="text-muted-foreground text-xs">
|
|
1095
|
+
{t('admin.fields.product.collection_ids.help')}
|
|
1096
|
+
</span>
|
|
1097
|
+
</Field>
|
|
1098
|
+
)}
|
|
1099
|
+
|
|
1100
|
+
{/* Tags are the store's own vocabulary, so only a panel that manages
|
|
1101
|
+
it offers the field (docs/plans/6.0-multi-vendor-marketplace.md). */}
|
|
1102
|
+
{client.tags && (
|
|
1103
|
+
<Field>
|
|
1104
|
+
<FieldLabel>{t('admin.fields.product.tags.label')}</FieldLabel>
|
|
1105
|
+
<Controller
|
|
1106
|
+
name="tags"
|
|
1107
|
+
control={form.control}
|
|
1108
|
+
render={({ field }) => (
|
|
1109
|
+
<TagCombobox
|
|
1110
|
+
taggableType="Spree::Product"
|
|
1111
|
+
value={field.value ?? []}
|
|
1112
|
+
onChange={field.onChange}
|
|
1113
|
+
/>
|
|
1114
|
+
)}
|
|
1115
|
+
/>
|
|
1116
|
+
</Field>
|
|
1117
|
+
)}
|
|
1118
|
+
</CardContent>
|
|
1119
|
+
</Card>
|
|
1120
|
+
)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// ---------------------------------------------------------------------------
|
|
1124
|
+
// Tax
|
|
1125
|
+
// ---------------------------------------------------------------------------
|
|
1126
|
+
|
|
1127
|
+
export function TaxCard({ form }: FormCardProps) {
|
|
1128
|
+
const { t } = useTranslation()
|
|
1129
|
+
const { data: taxCategoriesResponse } = useTaxCategories()
|
|
1130
|
+
const taxCategories = taxCategoriesResponse?.data ?? []
|
|
1131
|
+
|
|
1132
|
+
return (
|
|
1133
|
+
<Card>
|
|
1134
|
+
<CardHeader>
|
|
1135
|
+
<CardTitle>{t('admin.fields.tax.label')}</CardTitle>
|
|
1136
|
+
</CardHeader>
|
|
1137
|
+
<CardContent>
|
|
1138
|
+
<Field>
|
|
1139
|
+
<FieldLabel>{t('admin.fields.tax_category_id.label')}</FieldLabel>
|
|
1140
|
+
<Controller
|
|
1141
|
+
name="tax_category_id"
|
|
1142
|
+
control={form.control}
|
|
1143
|
+
render={({ field }) => (
|
|
1144
|
+
<Select value={field.value ?? ''} onValueChange={(v) => field.onChange(v || null)}>
|
|
1145
|
+
<SelectTrigger className="w-full">
|
|
1146
|
+
<SelectValue placeholder={t('admin.products.tax_category_placeholder')}>
|
|
1147
|
+
{(v) =>
|
|
1148
|
+
taxCategories.find((c) => c.id === v)?.name ??
|
|
1149
|
+
t('admin.products.tax_category_placeholder')
|
|
1150
|
+
}
|
|
1151
|
+
</SelectValue>
|
|
1152
|
+
</SelectTrigger>
|
|
1153
|
+
<SelectContent>
|
|
1154
|
+
{taxCategories.map((cat) => (
|
|
1155
|
+
<SelectItem key={cat.id} value={cat.id}>
|
|
1156
|
+
{cat.name}
|
|
1157
|
+
</SelectItem>
|
|
1158
|
+
))}
|
|
1159
|
+
</SelectContent>
|
|
1160
|
+
</Select>
|
|
1161
|
+
)}
|
|
1162
|
+
/>
|
|
1163
|
+
</Field>
|
|
1164
|
+
</CardContent>
|
|
1165
|
+
</Card>
|
|
1166
|
+
)
|
|
1167
|
+
}
|