@spree/dashboard-core 0.13.0 → 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 +97 -36
- 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 +425 -131
- 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 +63 -22
- package/src/components/table-toolbar.tsx +1373 -442
- 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 +261 -14
- 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 +21 -0
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/storefront.ts +19 -0
- package/src/lib/table-registry.ts +56 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -516
- package/src/locales/de.json +1883 -515
- package/src/locales/en.json +1886 -516
- package/src/locales/fr.json +1884 -516
- package/src/locales/pl.json +1913 -516
- package/src/locales/zh-CN.json +1883 -516
- 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 -210
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closestCenter,
|
|
3
|
+
DndContext,
|
|
4
|
+
type DragEndEvent,
|
|
5
|
+
KeyboardSensor,
|
|
6
|
+
PointerSensor,
|
|
7
|
+
useSensor,
|
|
8
|
+
useSensors,
|
|
9
|
+
} from '@dnd-kit/core'
|
|
10
|
+
import {
|
|
11
|
+
SortableContext,
|
|
12
|
+
sortableKeyboardCoordinates,
|
|
13
|
+
useSortable,
|
|
14
|
+
verticalListSortingStrategy,
|
|
15
|
+
} from '@dnd-kit/sortable'
|
|
16
|
+
import { CSS } from '@dnd-kit/utilities'
|
|
17
|
+
import type { ProductFormValues, VariantFormValues } from '@spree/dashboard-core'
|
|
18
|
+
import {
|
|
19
|
+
Button,
|
|
20
|
+
Card,
|
|
21
|
+
CardContent,
|
|
22
|
+
CardHeader,
|
|
23
|
+
CardTitle,
|
|
24
|
+
cn,
|
|
25
|
+
DragHandle,
|
|
26
|
+
Input,
|
|
27
|
+
Table,
|
|
28
|
+
TableBody,
|
|
29
|
+
TableCell,
|
|
30
|
+
TableHead,
|
|
31
|
+
TableHeader,
|
|
32
|
+
TableRow,
|
|
33
|
+
} from '@spree/dashboard-ui'
|
|
34
|
+
import { PencilIcon, XIcon } from '@spree/dashboard-ui/icons'
|
|
35
|
+
import { type CSSProperties, useEffect, useMemo, useState } from 'react'
|
|
36
|
+
import { type UseFormReturn, useFieldArray, useWatch } from 'react-hook-form'
|
|
37
|
+
import { useTranslation } from 'react-i18next'
|
|
38
|
+
import type { PanelOptionType as OptionType } from '../api-client'
|
|
39
|
+
import { BulkVariantsDialog } from './bulk-variants-dialog'
|
|
40
|
+
import {
|
|
41
|
+
useFormOptionTypes as useOptionTypes,
|
|
42
|
+
useFormOptionTypesByIds as useOptionTypesByIds,
|
|
43
|
+
useFormProductType as useProductType,
|
|
44
|
+
} from './use-product-form-data'
|
|
45
|
+
import { VariantEditSheet } from './variant-edit-sheet'
|
|
46
|
+
import {
|
|
47
|
+
generateVariantCombinations,
|
|
48
|
+
optionsKey,
|
|
49
|
+
reconcileVariants,
|
|
50
|
+
type SelectedOptionType,
|
|
51
|
+
variantDisplayLabel,
|
|
52
|
+
} from './variants-matrix'
|
|
53
|
+
import { VariantsOptionsBuilder } from './variants-options-builder'
|
|
54
|
+
|
|
55
|
+
interface Props {
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
|
+
form: UseFormReturn<ProductFormValues, any, any>
|
|
58
|
+
/**
|
|
59
|
+
* Offer the product type's option types as empty rows when the product has
|
|
60
|
+
* none of its own. New products only — a saved product's option set is
|
|
61
|
+
* whatever it already has.
|
|
62
|
+
*/
|
|
63
|
+
seedFromType?: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Derive the initial selected option types from current variant rows so the
|
|
67
|
+
// builder picks up where the merchant left off. Names come from variant
|
|
68
|
+
// options; labels + ids are looked up against the global option-type registry.
|
|
69
|
+
function deriveSelectedFromVariants(
|
|
70
|
+
variants: VariantFormValues[],
|
|
71
|
+
allOptionTypes: OptionType[],
|
|
72
|
+
): SelectedOptionType[] {
|
|
73
|
+
const namesInOrder: string[] = []
|
|
74
|
+
const valuesByName = new Map<string, Set<string>>()
|
|
75
|
+
|
|
76
|
+
variants.forEach((v) => {
|
|
77
|
+
v.options.forEach((o) => {
|
|
78
|
+
if (!valuesByName.has(o.name)) {
|
|
79
|
+
valuesByName.set(o.name, new Set())
|
|
80
|
+
namesInOrder.push(o.name)
|
|
81
|
+
}
|
|
82
|
+
valuesByName.get(o.name)?.add(o.value)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return namesInOrder
|
|
87
|
+
.map<SelectedOptionType | null>((name, idx) => {
|
|
88
|
+
const ot = allOptionTypes.find((x) => x.name === name)
|
|
89
|
+
if (!ot) return null
|
|
90
|
+
return {
|
|
91
|
+
id: ot.id,
|
|
92
|
+
name: ot.name,
|
|
93
|
+
label: ot.label,
|
|
94
|
+
position: ot.position ?? idx,
|
|
95
|
+
values: Array.from(valuesByName.get(name) ?? []).map((value) => ({
|
|
96
|
+
name: value,
|
|
97
|
+
label: ot.option_values?.find((ov) => ov.name === value)?.label,
|
|
98
|
+
})),
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
.filter((x): x is SelectedOptionType => x !== null)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Empty rows for a product type's option types — a starting point, not state.
|
|
105
|
+
function seedFromProductType(
|
|
106
|
+
optionTypeIds: string[],
|
|
107
|
+
allOptionTypes: OptionType[],
|
|
108
|
+
): SelectedOptionType[] {
|
|
109
|
+
return optionTypeIds
|
|
110
|
+
.map<SelectedOptionType | null>((id, idx) => {
|
|
111
|
+
const optionType = allOptionTypes.find((candidate) => candidate.id === id)
|
|
112
|
+
if (!optionType) return null
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
id: optionType.id,
|
|
116
|
+
name: optionType.name,
|
|
117
|
+
label: optionType.label,
|
|
118
|
+
position: optionType.position ?? idx,
|
|
119
|
+
values: [],
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
.filter((entry): entry is SelectedOptionType => entry !== null)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function VariantsSection({ form, seedFromType = false }: Props) {
|
|
126
|
+
const { t } = useTranslation()
|
|
127
|
+
const { data: optionTypesData } = useOptionTypes()
|
|
128
|
+
const allOptionTypes = useMemo(() => optionTypesData?.data ?? [], [optionTypesData])
|
|
129
|
+
const productTypeId = form.watch('product_type_id') as string | null | undefined
|
|
130
|
+
const { data: productType } = useProductType(productTypeId ?? undefined)
|
|
131
|
+
// Resolved by id rather than read out of `allOptionTypes`, whose first page
|
|
132
|
+
// may not contain every option type the product type references.
|
|
133
|
+
const { data: typeOptionTypesData } = useOptionTypesByIds(productType?.option_type_ids)
|
|
134
|
+
const typeOptionTypes = useMemo(() => typeOptionTypesData?.data ?? [], [typeOptionTypesData])
|
|
135
|
+
|
|
136
|
+
const variantsArray = useFieldArray<ProductFormValues, 'variants', '_key'>({
|
|
137
|
+
control: form.control,
|
|
138
|
+
name: 'variants',
|
|
139
|
+
keyName: '_key',
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
|
143
|
+
const [sheetOpen, setSheetOpen] = useState(false)
|
|
144
|
+
const [bulkOpen, setBulkOpen] = useState(false)
|
|
145
|
+
const [orphanedKeys, setOrphanedKeys] = useState<string[]>([])
|
|
146
|
+
|
|
147
|
+
// `selected` is fully derived from the current variants array + the global
|
|
148
|
+
// option-type registry. Changes flow: builder → handleOptionsChange writes
|
|
149
|
+
// RHF variants → next render derives `selected` from the new variants. No
|
|
150
|
+
// local state to drift out of sync.
|
|
151
|
+
const watchedVariants = useWatch({ control: form.control, name: 'variants' })
|
|
152
|
+
const derived = useMemo(
|
|
153
|
+
() => deriveSelectedFromVariants(watchedVariants ?? [], allOptionTypes),
|
|
154
|
+
[watchedVariants, allOptionTypes],
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
// A product type's option types are *offered* as empty rows, once, when the
|
|
158
|
+
// merchant picks the type on a new product. They live in local state rather
|
|
159
|
+
// than being derived every render: an empty row writes no variants, so a
|
|
160
|
+
// derived version would regenerate itself the moment the merchant removed it.
|
|
161
|
+
// Once any row has values the variants array takes over as the source.
|
|
162
|
+
const [offeredOptionTypes, setOfferedOptionTypes] = useState<SelectedOptionType[]>([])
|
|
163
|
+
const [offeredForTypeId, setOfferedForTypeId] = useState<string | null>(null)
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
// Clearing the type withdraws the offer; anything the merchant built is in
|
|
167
|
+
// `derived` and unaffected.
|
|
168
|
+
if (!productTypeId) {
|
|
169
|
+
if (offeredForTypeId !== null) {
|
|
170
|
+
setOfferedForTypeId(null)
|
|
171
|
+
setOfferedOptionTypes([])
|
|
172
|
+
}
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!seedFromType || productTypeId === offeredForTypeId) return
|
|
177
|
+
// Only on a product the merchant hasn't started building options for.
|
|
178
|
+
if (derived.length > 0) return
|
|
179
|
+
// Both queries must have landed. Marking the type seeded off an empty
|
|
180
|
+
// registry would offer nothing and never retry once the data arrives.
|
|
181
|
+
if (!productType) return
|
|
182
|
+
// Nothing to offer until the type's own option types have resolved.
|
|
183
|
+
const configuredIds = productType.option_type_ids ?? []
|
|
184
|
+
if (configuredIds.length > 0 && typeOptionTypes.length === 0) return
|
|
185
|
+
|
|
186
|
+
setOfferedForTypeId(productTypeId)
|
|
187
|
+
setOfferedOptionTypes(seedFromProductType(configuredIds, typeOptionTypes))
|
|
188
|
+
}, [seedFromType, productTypeId, offeredForTypeId, derived.length, productType, typeOptionTypes])
|
|
189
|
+
|
|
190
|
+
const selected = derived.length > 0 ? derived : offeredOptionTypes
|
|
191
|
+
|
|
192
|
+
const fields = variantsArray.fields
|
|
193
|
+
const hasOptionTypes = selected.length > 0
|
|
194
|
+
const isSimpleProduct = !hasOptionTypes && fields.length <= 1
|
|
195
|
+
// Options are being defined but none have values yet, so the only variant is
|
|
196
|
+
// the option-less placeholder. Showing it as "Default variant" next to a
|
|
197
|
+
// half-filled option row reads like a real row the merchant should edit.
|
|
198
|
+
const awaitingOptionValues =
|
|
199
|
+
hasOptionTypes && selected.every((optionType) => optionType.values.length === 0)
|
|
200
|
+
|
|
201
|
+
const handleOptionsChange = (next: SelectedOptionType[]) => {
|
|
202
|
+
// Keep the offered rows in step with the builder — including a removal,
|
|
203
|
+
// which must not be undone by the seeding effect above.
|
|
204
|
+
setOfferedOptionTypes(next)
|
|
205
|
+
|
|
206
|
+
const combinations = generateVariantCombinations(next)
|
|
207
|
+
const existing = form.getValues('variants') ?? []
|
|
208
|
+
if (combinations.length === 0) {
|
|
209
|
+
// No options selected — drop all option-bearing variants down to a
|
|
210
|
+
// single default-variant row so the product remains purchasable. Keep
|
|
211
|
+
// a persisted id when possible so the existing default is reused, and
|
|
212
|
+
// carry stock_levels forward. The inventory grid fills any gaps for
|
|
213
|
+
// locations the default doesn't already cover (find-or-create on edit).
|
|
214
|
+
const carry = existing[0]
|
|
215
|
+
const defaultRow: VariantFormValues = {
|
|
216
|
+
...(carry?.id ? { id: carry.id } : {}),
|
|
217
|
+
sku: carry?.sku ?? null,
|
|
218
|
+
barcode: carry?.barcode ?? null,
|
|
219
|
+
position: 0,
|
|
220
|
+
options: [],
|
|
221
|
+
weight: carry?.weight ?? null,
|
|
222
|
+
height: carry?.height ?? null,
|
|
223
|
+
width: carry?.width ?? null,
|
|
224
|
+
depth: carry?.depth ?? null,
|
|
225
|
+
weight_unit: carry?.weight_unit ?? null,
|
|
226
|
+
dimensions_unit: carry?.dimensions_unit ?? null,
|
|
227
|
+
track_inventory: carry?.track_inventory ?? true,
|
|
228
|
+
tax_category_id: carry?.tax_category_id ?? null,
|
|
229
|
+
// Carried like the rest: removing the last option type must not
|
|
230
|
+
// silently wipe the customs classification or the purchasing rules a
|
|
231
|
+
// merchant entered on the row this collapses into.
|
|
232
|
+
hs_code: carry?.hs_code ?? null,
|
|
233
|
+
country_of_origin: carry?.country_of_origin ?? null,
|
|
234
|
+
customs_description: carry?.customs_description ?? null,
|
|
235
|
+
minimum_order_quantity: carry?.minimum_order_quantity ?? null,
|
|
236
|
+
order_multiple: carry?.order_multiple ?? null,
|
|
237
|
+
purchase_unit: carry?.purchase_unit ?? null,
|
|
238
|
+
units_per_carton: carry?.units_per_carton ?? null,
|
|
239
|
+
cartons_per_pallet: carry?.cartons_per_pallet ?? null,
|
|
240
|
+
carton_weight: carry?.carton_weight ?? null,
|
|
241
|
+
carton_package_type_id: carry?.carton_package_type_id ?? null,
|
|
242
|
+
prices: carry?.prices ?? [],
|
|
243
|
+
stock_levels: carry?.stock_levels ?? [],
|
|
244
|
+
}
|
|
245
|
+
form.setValue('variants', [defaultRow], { shouldDirty: true })
|
|
246
|
+
setOrphanedKeys([])
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
const { next: nextVariants, orphanedKeys: nextOrphaned } = reconcileVariants(
|
|
250
|
+
existing,
|
|
251
|
+
combinations,
|
|
252
|
+
)
|
|
253
|
+
form.setValue('variants', nextVariants, { shouldDirty: true })
|
|
254
|
+
setOrphanedKeys(nextOrphaned)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const handleConfirmDropOrphans = () => {
|
|
258
|
+
const existing = form.getValues('variants') ?? []
|
|
259
|
+
const orphanedSet = new Set(orphanedKeys)
|
|
260
|
+
const filtered = existing.filter((v) => !orphanedSet.has(optionsKey(v.options)))
|
|
261
|
+
const reindexed = filtered.map((v, i) => ({ ...v, position: i }))
|
|
262
|
+
form.setValue('variants', reindexed, { shouldDirty: true })
|
|
263
|
+
setOrphanedKeys([])
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const handleKeepOrphans = () => {
|
|
267
|
+
// Re-merge orphaned variants back in. They were never actually removed
|
|
268
|
+
// from form state — orphanedKeys is just a list of options-keys that
|
|
269
|
+
// DON'T match a generated combination. Clearing the banner is the only
|
|
270
|
+
// action needed.
|
|
271
|
+
setOrphanedKeys([])
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const handleRowRemove = (index: number) => {
|
|
275
|
+
// If the removed row was in the orphan list, drop its key from
|
|
276
|
+
// `orphanedKeys` too — otherwise the banner's count ("N variants no
|
|
277
|
+
// longer match…") outlives the row and reports a stale number. The
|
|
278
|
+
// visible list filters missing rows out via `orphanedEntries`, but the
|
|
279
|
+
// count reads `orphanedKeys.length` directly so the two get out of sync.
|
|
280
|
+
const removed = form.getValues(`variants.${index}`)
|
|
281
|
+
if (removed) {
|
|
282
|
+
const removedKey = optionsKey(removed.options ?? [])
|
|
283
|
+
setOrphanedKeys((prev) => prev.filter((k) => k !== removedKey))
|
|
284
|
+
}
|
|
285
|
+
variantsArray.remove(index)
|
|
286
|
+
// Reindex remaining rows so position reflects array order.
|
|
287
|
+
const remaining = form.getValues('variants') ?? []
|
|
288
|
+
remaining.forEach((_, i) => {
|
|
289
|
+
form.setValue(`variants.${i}.position`, i)
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const handleEditRow = (index: number) => {
|
|
294
|
+
setEditingIndex(index)
|
|
295
|
+
setSheetOpen(true)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const sensors = useSensors(
|
|
299
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
300
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
const handleDragEnd = (event: DragEndEvent) => {
|
|
304
|
+
const { active, over } = event
|
|
305
|
+
if (!over || active.id === over.id) return
|
|
306
|
+
const fromIndex = fields.findIndex((f) => f._key === active.id)
|
|
307
|
+
const toIndex = fields.findIndex((f) => f._key === over.id)
|
|
308
|
+
if (fromIndex === -1 || toIndex === -1) return
|
|
309
|
+
variantsArray.move(fromIndex, toIndex)
|
|
310
|
+
// Rewrite position to match the new order — the API uses position to
|
|
311
|
+
// order the variants array and acts_as_list keys off it server-side.
|
|
312
|
+
const reordered = form.getValues('variants') ?? []
|
|
313
|
+
reordered.forEach((_, i) => {
|
|
314
|
+
form.setValue(`variants.${i}.position`, i)
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Pair each orphan key with its display label so the list can key by the
|
|
319
|
+
// options-key (uniquely identifies an orphan row in form state regardless
|
|
320
|
+
// of save status).
|
|
321
|
+
const orphanedEntries = useMemo(() => {
|
|
322
|
+
const existing = form.getValues('variants') ?? []
|
|
323
|
+
const byKey = new Map<string, VariantFormValues>()
|
|
324
|
+
existing.forEach((v) => {
|
|
325
|
+
byKey.set(optionsKey(v.options), v)
|
|
326
|
+
})
|
|
327
|
+
return orphanedKeys
|
|
328
|
+
.map((key) => {
|
|
329
|
+
const variant = byKey.get(key)
|
|
330
|
+
if (!variant) return null
|
|
331
|
+
return {
|
|
332
|
+
key,
|
|
333
|
+
label: variantDisplayLabel(
|
|
334
|
+
variant,
|
|
335
|
+
t('admin.products.variants.default_variant'),
|
|
336
|
+
allOptionTypes,
|
|
337
|
+
),
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
.filter((entry): entry is { key: string; label: string } => entry !== null)
|
|
341
|
+
}, [orphanedKeys, form, t, allOptionTypes])
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<Card>
|
|
345
|
+
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0">
|
|
346
|
+
<CardTitle>{t('admin.products.variants.title')}</CardTitle>
|
|
347
|
+
{fields.length > 0 && (
|
|
348
|
+
<Button type="button" variant="outline" size="sm" onClick={() => setBulkOpen(true)}>
|
|
349
|
+
{t('admin.products.variants.bulk_edit.trigger')}
|
|
350
|
+
</Button>
|
|
351
|
+
)}
|
|
352
|
+
</CardHeader>
|
|
353
|
+
<CardContent className="flex flex-col gap-6">
|
|
354
|
+
<VariantsOptionsBuilder
|
|
355
|
+
selected={selected}
|
|
356
|
+
onChange={handleOptionsChange}
|
|
357
|
+
extraOptionTypes={typeOptionTypes}
|
|
358
|
+
/>
|
|
359
|
+
|
|
360
|
+
{orphanedKeys.length > 0 && (
|
|
361
|
+
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm dark:border-amber-700 dark:bg-amber-950/40">
|
|
362
|
+
<p className="font-medium text-amber-900 dark:text-amber-100">
|
|
363
|
+
{t('admin.products.variants.orphan_warning', { count: orphanedKeys.length })}
|
|
364
|
+
</p>
|
|
365
|
+
{orphanedEntries.length > 0 && (
|
|
366
|
+
<ul className="mt-1 list-disc pl-5 text-amber-900 dark:text-amber-200">
|
|
367
|
+
{orphanedEntries.map((entry) => (
|
|
368
|
+
<li key={entry.key}>{entry.label}</li>
|
|
369
|
+
))}
|
|
370
|
+
</ul>
|
|
371
|
+
)}
|
|
372
|
+
<div className="mt-2 flex items-center justify-end gap-2">
|
|
373
|
+
<Button type="button" variant="ghost" size="sm" onClick={handleKeepOrphans}>
|
|
374
|
+
{t('admin.products.variants.keep')}
|
|
375
|
+
</Button>
|
|
376
|
+
<Button
|
|
377
|
+
type="button"
|
|
378
|
+
variant="destructive"
|
|
379
|
+
size="sm"
|
|
380
|
+
onClick={handleConfirmDropOrphans}
|
|
381
|
+
>
|
|
382
|
+
{t('admin.actions.remove')}
|
|
383
|
+
</Button>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
387
|
+
|
|
388
|
+
{fields.length > 0 && !awaitingOptionValues && (
|
|
389
|
+
<div className="overflow-hidden rounded-lg border border-border-subtle">
|
|
390
|
+
<DndContext
|
|
391
|
+
sensors={sensors}
|
|
392
|
+
collisionDetection={closestCenter}
|
|
393
|
+
onDragEnd={handleDragEnd}
|
|
394
|
+
>
|
|
395
|
+
<SortableContext
|
|
396
|
+
items={fields.map((f) => f._key)}
|
|
397
|
+
strategy={verticalListSortingStrategy}
|
|
398
|
+
>
|
|
399
|
+
<Table>
|
|
400
|
+
<TableHeader>
|
|
401
|
+
<TableRow>
|
|
402
|
+
{!isSimpleProduct && (
|
|
403
|
+
<TableHead className="w-8" aria-label={t('admin.a11y.reorder')} />
|
|
404
|
+
)}
|
|
405
|
+
<TableHead>
|
|
406
|
+
{isSimpleProduct
|
|
407
|
+
? t('admin.products.variants.default_variant')
|
|
408
|
+
: t('admin.products.variants.variant_label')}
|
|
409
|
+
</TableHead>
|
|
410
|
+
<TableHead>{t('admin.fields.variant.sku.label')}</TableHead>
|
|
411
|
+
<TableHead className="w-24" aria-label={t('admin.actions.actions_menu')} />
|
|
412
|
+
</TableRow>
|
|
413
|
+
</TableHeader>
|
|
414
|
+
<TableBody>
|
|
415
|
+
{fields.map((field, index) => (
|
|
416
|
+
<SortableVariantRow
|
|
417
|
+
key={field._key}
|
|
418
|
+
sortableId={field._key}
|
|
419
|
+
form={form}
|
|
420
|
+
index={index}
|
|
421
|
+
isSimpleProduct={isSimpleProduct}
|
|
422
|
+
optionTypes={allOptionTypes}
|
|
423
|
+
onEdit={() => handleEditRow(index)}
|
|
424
|
+
onRemove={() => handleRowRemove(index)}
|
|
425
|
+
/>
|
|
426
|
+
))}
|
|
427
|
+
</TableBody>
|
|
428
|
+
</Table>
|
|
429
|
+
</SortableContext>
|
|
430
|
+
</DndContext>
|
|
431
|
+
</div>
|
|
432
|
+
)}
|
|
433
|
+
</CardContent>
|
|
434
|
+
|
|
435
|
+
<BulkVariantsDialog form={form} open={bulkOpen} onOpenChange={setBulkOpen} />
|
|
436
|
+
|
|
437
|
+
{editingIndex !== null && (
|
|
438
|
+
<VariantEditSheet
|
|
439
|
+
form={form}
|
|
440
|
+
variantIndex={editingIndex}
|
|
441
|
+
open={sheetOpen}
|
|
442
|
+
onOpenChange={(o) => {
|
|
443
|
+
setSheetOpen(o)
|
|
444
|
+
if (!o) setEditingIndex(null)
|
|
445
|
+
}}
|
|
446
|
+
/>
|
|
447
|
+
)}
|
|
448
|
+
</Card>
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
interface SortableVariantRowProps {
|
|
453
|
+
sortableId: string
|
|
454
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
455
|
+
form: UseFormReturn<ProductFormValues, any, any>
|
|
456
|
+
index: number
|
|
457
|
+
isSimpleProduct: boolean
|
|
458
|
+
optionTypes: OptionType[]
|
|
459
|
+
onEdit: () => void
|
|
460
|
+
onRemove: () => void
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function SortableVariantRow({
|
|
464
|
+
sortableId,
|
|
465
|
+
form,
|
|
466
|
+
index,
|
|
467
|
+
isSimpleProduct,
|
|
468
|
+
optionTypes,
|
|
469
|
+
onEdit,
|
|
470
|
+
onRemove,
|
|
471
|
+
}: SortableVariantRowProps) {
|
|
472
|
+
const { t } = useTranslation()
|
|
473
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
474
|
+
id: sortableId,
|
|
475
|
+
})
|
|
476
|
+
const style: CSSProperties = {
|
|
477
|
+
transform: CSS.Transform.toString(transform),
|
|
478
|
+
transition,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const variant = form.watch(`variants.${index}`)
|
|
482
|
+
if (!variant) return null
|
|
483
|
+
|
|
484
|
+
const label = variantDisplayLabel(
|
|
485
|
+
variant,
|
|
486
|
+
t('admin.products.variants.default_variant'),
|
|
487
|
+
optionTypes,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
return (
|
|
491
|
+
<TableRow
|
|
492
|
+
ref={setNodeRef}
|
|
493
|
+
style={style}
|
|
494
|
+
className={cn(isDragging && 'relative z-10 bg-card opacity-80 shadow-lg')}
|
|
495
|
+
>
|
|
496
|
+
{!isSimpleProduct && (
|
|
497
|
+
<TableCell className="w-8 touch-none p-0">
|
|
498
|
+
<DragHandle attributes={attributes} listeners={listeners} />
|
|
499
|
+
</TableCell>
|
|
500
|
+
)}
|
|
501
|
+
<TableCell className="font-medium">{label}</TableCell>
|
|
502
|
+
<TableCell>
|
|
503
|
+
<Input
|
|
504
|
+
aria-label={t('admin.fields.variant.sku.label')}
|
|
505
|
+
placeholder={t('admin.fields.variant.sku.placeholder')}
|
|
506
|
+
{...form.register(`variants.${index}.sku`)}
|
|
507
|
+
/>
|
|
508
|
+
</TableCell>
|
|
509
|
+
<TableCell className="text-right">
|
|
510
|
+
<div className="flex items-center justify-end gap-1">
|
|
511
|
+
<Button
|
|
512
|
+
type="button"
|
|
513
|
+
variant="ghost"
|
|
514
|
+
size="icon-sm"
|
|
515
|
+
aria-label={t('admin.products.variants.edit_row_aria', { name: label })}
|
|
516
|
+
onClick={onEdit}
|
|
517
|
+
>
|
|
518
|
+
<PencilIcon />
|
|
519
|
+
</Button>
|
|
520
|
+
{!isSimpleProduct && (
|
|
521
|
+
<Button
|
|
522
|
+
type="button"
|
|
523
|
+
variant="ghost"
|
|
524
|
+
size="icon-sm"
|
|
525
|
+
aria-label={t('admin.products.variants.remove_row_aria', { name: label })}
|
|
526
|
+
onClick={onRemove}
|
|
527
|
+
>
|
|
528
|
+
<XIcon />
|
|
529
|
+
</Button>
|
|
530
|
+
)}
|
|
531
|
+
</div>
|
|
532
|
+
</TableCell>
|
|
533
|
+
</TableRow>
|
|
534
|
+
)
|
|
535
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isSupportedVideoUrl, parseVideoUrl } from './video-url'
|
|
3
|
+
|
|
4
|
+
describe('parseVideoUrl', () => {
|
|
5
|
+
it.each([
|
|
6
|
+
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
7
|
+
'https://m.youtube.com/watch?v=dQw4w9WgXcQ&t=42',
|
|
8
|
+
'https://youtu.be/dQw4w9WgXcQ',
|
|
9
|
+
'https://www.youtube.com/embed/dQw4w9WgXcQ',
|
|
10
|
+
'https://www.youtube.com/shorts/dQw4w9WgXcQ',
|
|
11
|
+
])('reads the YouTube link %s', (url) => {
|
|
12
|
+
expect(parseVideoUrl(url)).toEqual({
|
|
13
|
+
provider: 'youtube',
|
|
14
|
+
videoId: 'dQw4w9WgXcQ',
|
|
15
|
+
embedUrl: 'https://www.youtube.com/embed/dQw4w9WgXcQ',
|
|
16
|
+
thumbnailUrl: 'https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg',
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it.each([
|
|
21
|
+
'https://vimeo.com/123456789',
|
|
22
|
+
'https://player.vimeo.com/video/123456789',
|
|
23
|
+
'https://vimeo.com/channels/staffpicks/123456789',
|
|
24
|
+
])('reads the Vimeo link %s', (url) => {
|
|
25
|
+
expect(parseVideoUrl(url)).toEqual({
|
|
26
|
+
provider: 'vimeo',
|
|
27
|
+
videoId: '123456789',
|
|
28
|
+
embedUrl: 'https://player.vimeo.com/video/123456789',
|
|
29
|
+
thumbnailUrl: null,
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// Mirrors the Ruby spec of the same name — the two parsers must agree, or
|
|
34
|
+
// the dialog accepts a link the server rejects.
|
|
35
|
+
it('accepts a trailing slash, as an address-bar copy carries', () => {
|
|
36
|
+
expect(parseVideoUrl('https://youtu.be/dQw4w9WgXcQ/')?.videoId).toBe('dQw4w9WgXcQ')
|
|
37
|
+
expect(parseVideoUrl('https://vimeo.com/123456789/')?.videoId).toBe('123456789')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('ignores surrounding whitespace', () => {
|
|
41
|
+
expect(parseVideoUrl(' https://vimeo.com/123456789 ')?.videoId).toBe('123456789')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it.each([
|
|
45
|
+
// Not videos: a channel, a handle, an arbitrary nested path, or a short
|
|
46
|
+
// link with anything after the id.
|
|
47
|
+
'https://www.youtube.com/c/dQw4w9WgXcQ',
|
|
48
|
+
'https://www.youtube.com/not-a-video/ABCDEFGHIJK',
|
|
49
|
+
'https://youtu.be/dQw4w9WgXcQ/extra',
|
|
50
|
+
'https://example.com/video.mp4',
|
|
51
|
+
'https://www.youtube.com/watch?v=short',
|
|
52
|
+
'https://vimeo.com/not-a-number',
|
|
53
|
+
'javascript:alert(1)',
|
|
54
|
+
'not a url',
|
|
55
|
+
'',
|
|
56
|
+
null,
|
|
57
|
+
undefined,
|
|
58
|
+
])('returns null for %s', (url) => {
|
|
59
|
+
expect(parseVideoUrl(url)).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('isSupportedVideoUrl', () => {
|
|
64
|
+
it('accepts a link Spree can embed', () => {
|
|
65
|
+
expect(isSupportedVideoUrl('https://vimeo.com/123456789')).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('rejects anything else', () => {
|
|
69
|
+
expect(isSupportedVideoUrl('https://example.com/clip.mp4')).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Mirror of Spree::ExternalVideo on the server. The server is the authority —
|
|
2
|
+
// this exists so the merchant learns a link is unusable while typing it, not
|
|
3
|
+
// after a failed save.
|
|
4
|
+
|
|
5
|
+
const YOUTUBE_HOSTS = [
|
|
6
|
+
'youtube.com',
|
|
7
|
+
'www.youtube.com',
|
|
8
|
+
'm.youtube.com',
|
|
9
|
+
'youtu.be',
|
|
10
|
+
'www.youtu.be',
|
|
11
|
+
]
|
|
12
|
+
const VIMEO_HOSTS = ['vimeo.com', 'www.vimeo.com', 'player.vimeo.com']
|
|
13
|
+
|
|
14
|
+
// Path shapes that actually carry a video id. Anything else on the domain —
|
|
15
|
+
// /c/name, /@handle, /playlist — is not a video. Mirrors Spree::ExternalVideo.
|
|
16
|
+
const YOUTUBE_ID_PATHS = ['embed', 'shorts', 'live', 'v']
|
|
17
|
+
|
|
18
|
+
const YOUTUBE_ID = /^[\w-]{11}$/
|
|
19
|
+
const VIMEO_ID = /^\d+$/
|
|
20
|
+
|
|
21
|
+
export interface ParsedVideoUrl {
|
|
22
|
+
provider: 'youtube' | 'vimeo'
|
|
23
|
+
videoId: string
|
|
24
|
+
embedUrl: string
|
|
25
|
+
thumbnailUrl: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseVideoUrl(input: string | null | undefined): ParsedVideoUrl | null {
|
|
29
|
+
if (!input?.trim()) return null
|
|
30
|
+
|
|
31
|
+
let url: URL
|
|
32
|
+
try {
|
|
33
|
+
url = new URL(input.trim())
|
|
34
|
+
} catch {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
|
39
|
+
|
|
40
|
+
const host = url.hostname.toLowerCase()
|
|
41
|
+
const segments = url.pathname.split('/').filter(Boolean)
|
|
42
|
+
|
|
43
|
+
if (YOUTUBE_HOSTS.includes(host)) {
|
|
44
|
+
const videoId = host.includes('youtu.be')
|
|
45
|
+
? // A short link is the id and nothing else.
|
|
46
|
+
segments.length === 1
|
|
47
|
+
? segments[0]
|
|
48
|
+
: ''
|
|
49
|
+
: url.pathname.replace(/\/$/, '') === '/watch'
|
|
50
|
+
? (url.searchParams.get('v') ?? '')
|
|
51
|
+
: segments.length === 2 && YOUTUBE_ID_PATHS.includes(segments[0])
|
|
52
|
+
? segments[1]
|
|
53
|
+
: ''
|
|
54
|
+
|
|
55
|
+
if (!YOUTUBE_ID.test(videoId)) return null
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
provider: 'youtube',
|
|
59
|
+
videoId,
|
|
60
|
+
embedUrl: `https://www.youtube.com/embed/${videoId}`,
|
|
61
|
+
thumbnailUrl: `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (VIMEO_HOSTS.includes(host)) {
|
|
66
|
+
const videoId = segments.at(-1) ?? ''
|
|
67
|
+
if (!VIMEO_ID.test(videoId)) return null
|
|
68
|
+
|
|
69
|
+
// Vimeo thumbnails need an API call, so there is no provider still here.
|
|
70
|
+
return {
|
|
71
|
+
provider: 'vimeo',
|
|
72
|
+
videoId,
|
|
73
|
+
embedUrl: `https://player.vimeo.com/video/${videoId}`,
|
|
74
|
+
thumbnailUrl: null,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isSupportedVideoUrl(url: string | null | undefined): boolean {
|
|
82
|
+
return parseVideoUrl(url) !== null
|
|
83
|
+
}
|