@spree/dashboard-core 0.13.1 → 1.0.0-beta.2

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 (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -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
+ }