@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,259 @@
1
+ import type { ProductFormValues, StockLevelFormValues } from '@spree/dashboard-core'
2
+ import { useStockLocations } from '@spree/dashboard-core'
3
+ import {
4
+ DataGrid,
5
+ editableRowIndex,
6
+ NumberCell,
7
+ ReadOnlyCell,
8
+ SwitchCell,
9
+ } from '@spree/dashboard-ui'
10
+ import { ExternalLinkIcon } from '@spree/dashboard-ui/icons'
11
+ import type { ColumnDef } from '@tanstack/react-table'
12
+ import { useCallback, useMemo } from 'react'
13
+ import { type UseFormReturn, useWatch } from 'react-hook-form'
14
+ import { useTranslation } from 'react-i18next'
15
+ import { useFormOptionTypes as useOptionTypes } from './use-product-form-data'
16
+ import { composeOptionsText } from './variants-matrix'
17
+
18
+ interface InventoryRow {
19
+ /** Composite ID `${variantIndex}.${stockLocationId}` (or `${variantIndex}.header` for headers). */
20
+ id: string
21
+ kind: 'header' | 'item'
22
+ variantIndex: number
23
+ /** Header rows: variant label. */
24
+ headerLabel?: string
25
+ headerSku?: string | null
26
+ /** Item rows: stock location identity. */
27
+ stockLocationId?: string
28
+ stockLocationName?: string
29
+ /** Current values (snapshotted from form state for display). Cells are
30
+ * bound through the find-or-create handlers below, not through Controller,
31
+ * so we read values directly instead. */
32
+ countOnHand?: number
33
+ backorderable?: boolean
34
+ }
35
+
36
+ const LOW_STOCK_THRESHOLD = 5
37
+
38
+ interface InventorySectionProps {
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ form: UseFormReturn<ProductFormValues, any, any>
41
+ /**
42
+ * Where a stock location is managed, if this panel has such a page. Built
43
+ * by the caller because each panel's router owns its own paths and the
44
+ * shared component cannot name a route that may not exist. Without one the
45
+ * location renders as plain text.
46
+ */
47
+ stockLocationHref?: (stockLocationId: string) => string
48
+ }
49
+
50
+ export function InventorySection({ form, stockLocationHref }: InventorySectionProps) {
51
+ const { t } = useTranslation()
52
+ // Subscribe to the array so the rows recompute when the user adds/removes
53
+ // a variant or edits a stock item. Cell edits route through findOrCreate
54
+ // below, which calls form.setValue and re-triggers this watch.
55
+ const variants = useWatch({ control: form.control, name: 'variants' }) ?? []
56
+ const { data: stockLocationsData, isLoading: stockLocationsLoading } = useStockLocations()
57
+ const stockLocations = stockLocationsData?.data ?? []
58
+ const { data: optionTypesData } = useOptionTypes()
59
+ const optionTypes = useMemo(() => optionTypesData?.data ?? [], [optionTypesData])
60
+
61
+ // Derive "this product has real variants" from live form state, not the
62
+ // API hydration — so adding options to a fresh product immediately groups
63
+ // the inventory rows by variant header, instead of waiting until after save.
64
+ const hasMultipleVariants = variants.length > 1
65
+
66
+ const rows = useMemo<InventoryRow[]>(() => {
67
+ if (stockLocations.length === 0) return []
68
+ const out: InventoryRow[] = []
69
+ variants.forEach((variant, variantIndex) => {
70
+ const variantLabel =
71
+ composeOptionsText(variant.options, optionTypes) ||
72
+ variant.sku ||
73
+ t('admin.products.variants.default_variant')
74
+ if (hasMultipleVariants) {
75
+ out.push({
76
+ id: `${variantIndex}.header`,
77
+ kind: 'header',
78
+ variantIndex,
79
+ headerLabel: variantLabel,
80
+ headerSku: variant.sku ?? null,
81
+ })
82
+ }
83
+ // Render one row per (variant × location), regardless of whether a
84
+ // stock_level exists in form state. Mirrors the pricing editor's
85
+ // (variant × currency) projection. Missing entries display 0/false and
86
+ // are created on first edit via findOrCreateStockLevel below.
87
+ stockLocations.forEach((loc) => {
88
+ const existing = (variant.stock_levels ?? []).find((si) => si.stock_location_id === loc.id)
89
+ out.push({
90
+ id: `${variantIndex}.${loc.id}`,
91
+ kind: 'item',
92
+ variantIndex,
93
+ stockLocationId: loc.id,
94
+ stockLocationName: loc.name,
95
+ countOnHand: existing?.count_on_hand ?? 0,
96
+ backorderable: existing?.backorderable ?? false,
97
+ })
98
+ })
99
+ })
100
+ return out
101
+ }, [variants, stockLocations, hasMultipleVariants, optionTypes, t])
102
+
103
+ // Find-or-create the stock_level entry for (variantIndex, stockLocationId)
104
+ // and patch the given field. Uses form.setValue with the full updated
105
+ // stock_levels array so RHF treats this as one atomic edit per cell.
106
+ const patchStockLevel = useCallback(
107
+ (
108
+ variantIndex: number,
109
+ stockLocationId: string,
110
+ stockLocationName: string,
111
+ field: 'count_on_hand' | 'backorderable',
112
+ next: number | boolean,
113
+ ) => {
114
+ const current = form.getValues(`variants.${variantIndex}.stock_levels`) ?? []
115
+ const existingIdx = current.findIndex((si) => si.stock_location_id === stockLocationId)
116
+ const nextItems: StockLevelFormValues[] = [...current]
117
+ if (existingIdx === -1) {
118
+ nextItems.push({
119
+ stock_location_id: stockLocationId,
120
+ stock_location_name: stockLocationName,
121
+ count_on_hand: field === 'count_on_hand' ? (next as number) : 0,
122
+ backorderable: field === 'backorderable' ? (next as boolean) : false,
123
+ })
124
+ } else {
125
+ const existing = nextItems[existingIdx]
126
+ nextItems[existingIdx] = {
127
+ ...existing,
128
+ ...(field === 'count_on_hand'
129
+ ? { count_on_hand: next as number }
130
+ : { backorderable: next as boolean }),
131
+ }
132
+ }
133
+ form.setValue(`variants.${variantIndex}.stock_levels`, nextItems, { shouldDirty: true })
134
+ },
135
+ [form],
136
+ )
137
+
138
+ const columns = useMemo<ColumnDef<InventoryRow>[]>(
139
+ () => [
140
+ {
141
+ id: 'location',
142
+ header: t('admin.products.inventory.columns.location'),
143
+ cell: ({ row }) => {
144
+ const r = row.original
145
+ if (r.kind !== 'item') return null
146
+ return (
147
+ <ReadOnlyCell>
148
+ {r.stockLocationId && stockLocationHref ? (
149
+ <a
150
+ href={stockLocationHref(r.stockLocationId)}
151
+ className="inline-flex items-center gap-1 hover:underline"
152
+ >
153
+ {r.stockLocationName}
154
+ <ExternalLinkIcon className="size-3 text-muted-foreground" />
155
+ </a>
156
+ ) : (
157
+ <span>{r.stockLocationName}</span>
158
+ )}
159
+ </ReadOnlyCell>
160
+ )
161
+ },
162
+ },
163
+ {
164
+ id: 'count_on_hand',
165
+ header: () => (
166
+ <span className="block text-right">{t('admin.products.inventory.columns.on_hand')}</span>
167
+ ),
168
+ cell: ({ row, table }) => {
169
+ const r = row.original
170
+ if (r.kind !== 'item' || !r.stockLocationId || !r.stockLocationName) return null
171
+ const coords = { row: editableRowIndex(table.getRowModel().rows, row.id), col: 1 }
172
+ const value = r.countOnHand ?? 0
173
+ const intent = value < LOW_STOCK_THRESHOLD ? ('warning' as const) : ('default' as const)
174
+ return (
175
+ <NumberCell
176
+ coords={coords}
177
+ value={value}
178
+ onChange={(next) =>
179
+ patchStockLevel(
180
+ r.variantIndex,
181
+ r.stockLocationId as string,
182
+ r.stockLocationName as string,
183
+ 'count_on_hand',
184
+ next,
185
+ )
186
+ }
187
+ ariaLabel={t('admin.products.inventory.on_hand_aria', {
188
+ location: r.stockLocationName,
189
+ })}
190
+ intent={intent}
191
+ />
192
+ )
193
+ },
194
+ },
195
+ {
196
+ id: 'backorderable',
197
+ header: t('admin.products.inventory.columns.backorder'),
198
+ cell: ({ row, table }) => {
199
+ const r = row.original
200
+ if (r.kind !== 'item' || !r.stockLocationId || !r.stockLocationName) return null
201
+ const coords = { row: editableRowIndex(table.getRowModel().rows, row.id), col: 2 }
202
+ return (
203
+ <SwitchCell
204
+ coords={coords}
205
+ value={r.backorderable ?? false}
206
+ onChange={(next) =>
207
+ patchStockLevel(
208
+ r.variantIndex,
209
+ r.stockLocationId as string,
210
+ r.stockLocationName as string,
211
+ 'backorderable',
212
+ next,
213
+ )
214
+ }
215
+ ariaLabel={t('admin.products.inventory.backorder_aria', {
216
+ location: r.stockLocationName,
217
+ })}
218
+ />
219
+ )
220
+ },
221
+ },
222
+ ],
223
+ [patchStockLevel, stockLocationHref, t],
224
+ )
225
+
226
+ if (rows.length === 0) {
227
+ // Showing "no stock locations" while the query is still in flight is
228
+ // misleading — the locations may exist but haven't loaded yet.
229
+ if (stockLocationsLoading) {
230
+ return <p className="text-sm text-muted-foreground">{t('admin.common.loading')}</p>
231
+ }
232
+ return (
233
+ <p className="text-sm text-muted-foreground">
234
+ {stockLocations.length === 0
235
+ ? t('admin.products.inventory.empty_no_locations')
236
+ : t('admin.products.inventory.empty_no_variants')}
237
+ </p>
238
+ )
239
+ }
240
+
241
+ return (
242
+ <DataGrid<InventoryRow>
243
+ rows={rows}
244
+ columns={columns}
245
+ getRowId={(row) => row.id}
246
+ renderSectionHeader={(row) =>
247
+ row.kind === 'header' ? (
248
+ <div className="min-w-0">
249
+ <div className="truncate font-medium">{row.headerLabel}</div>
250
+ {row.headerSku && (
251
+ <div className="truncate text-xs text-muted-foreground">SKU {row.headerSku}</div>
252
+ )}
253
+ </div>
254
+ ) : null
255
+ }
256
+ aria-label={t('admin.a11y.stock_at_locations')}
257
+ />
258
+ )
259
+ }
@@ -0,0 +1,336 @@
1
+ import type { ProductFormValues } from '@spree/dashboard-core'
2
+ import { ImageUploadField, parseVideoUrl, useTranslation } from '@spree/dashboard-core'
3
+ import {
4
+ Button,
5
+ Field,
6
+ FieldError,
7
+ FieldLabel,
8
+ Input,
9
+ MediaPreview,
10
+ Sheet,
11
+ SheetContent,
12
+ SheetFooter,
13
+ SheetHeader,
14
+ SheetTitle,
15
+ } from '@spree/dashboard-ui'
16
+ import { CheckIcon } from '@spree/dashboard-ui/icons'
17
+ import i18n from 'i18next'
18
+ import { useCallback, useEffect, useRef } from 'react'
19
+ import type { UseFormReturn } from 'react-hook-form'
20
+ import type { PanelVariant as Variant } from './product-types'
21
+
22
+ // The fields this sheet edits. Snapshot, cancel-restore and the type all derive
23
+ // from this one list, so a new editable field can't reach the form and then be
24
+ // missed by the restore — which would silently keep an edit the merchant cancelled.
25
+ const EDITED_FIELDS = [
26
+ 'alt',
27
+ 'variant_ids',
28
+ 'external_video_url',
29
+ 'focal_point_x',
30
+ 'focal_point_y',
31
+ 'poster_signed_id',
32
+ 'posterUrl',
33
+ ] as const
34
+
35
+ interface Props {
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ form: UseFormReturn<ProductFormValues, any, any>
38
+ mediaIndex: number
39
+ variants: Variant[]
40
+ open: boolean
41
+ onOpenChange: (open: boolean) => void
42
+ }
43
+
44
+ // Form-backed media editor. The sheet edits `form.media[index]` directly —
45
+ // changes ride the parent product PATCH/POST, no per-asset API calls.
46
+ // Cancel restores a snapshot taken on open; Done closes (parent form Save
47
+ // commits). Same model as the per-variant Sheet.
48
+ export function MediaEditSheet({ form, mediaIndex, variants, open, onOpenChange }: Props) {
49
+ const { t } = useTranslation()
50
+
51
+ // Snapshot on open so Cancel can restore. Re-snapshot if mediaIndex changes
52
+ // while the sheet remains open (user switches between rows).
53
+ //
54
+ // Capture the row's stable identity (server `id`, or pre-save `signed_id`,
55
+ // or in-flight `uploadId`) alongside the values. Reordering the media grid
56
+ // while the sheet is open shifts what lives at `mediaIndex`, so cancel /
57
+ // save resolve the target row by id rather than trusting the index. For a
58
+ // freshly-uploaded row that has no id yet we fall back to the current
59
+ // index — uploads don't surface a drag handle so the race is impossible.
60
+ type MediaRow = NonNullable<ProductFormValues['media']>[number]
61
+ type Snapshot = Pick<MediaRow, (typeof EDITED_FIELDS)[number]> & { key: string | null }
62
+ const snapshotRef = useRef<Snapshot | null>(null)
63
+ const rowKey = useCallback(
64
+ (m: MediaRow): string | null => m.id ?? m.signed_id ?? m.uploadId ?? null,
65
+ [],
66
+ )
67
+
68
+ useEffect(() => {
69
+ if (!open) {
70
+ snapshotRef.current = null
71
+ return
72
+ }
73
+ const current = form.getValues('media') ?? []
74
+ const row = current[mediaIndex]
75
+ if (!row) return
76
+ snapshotRef.current = {
77
+ // Copy each value as-is. Normalizing to null would turn an absent
78
+ // `poster_signed_id` — typed string | undefined — into a value the
79
+ // schema rejects, so cancelling would break the next save.
80
+ ...(Object.fromEntries(EDITED_FIELDS.map((field) => [field, row[field]])) as Pick<
81
+ MediaRow,
82
+ (typeof EDITED_FIELDS)[number]
83
+ >),
84
+ // Copy the array — the snapshot must not alias the row's own value.
85
+ variant_ids: [...(row.variant_ids ?? [])],
86
+ key: rowKey(row),
87
+ }
88
+ }, [open, mediaIndex, form, rowKey])
89
+
90
+ const entry = form.watch(`media.${mediaIndex}`)
91
+ if (!entry) return null
92
+
93
+ // For an image this is the picture itself; for a hosted video it is the file
94
+ // the merchant just picked (a blob URL) or the URL the API serves it at.
95
+ // The sheet is far wider than a grid cell, so it wants the large rendition.
96
+ // A pre-save row has no server URL yet, but its blob URL is the original.
97
+ const previewUrl = entry.fullPreviewUrl ?? entry.previewUrl ?? null
98
+ const alt = entry.alt ?? ''
99
+ const selectedVariantIds = new Set(entry.variant_ids ?? [])
100
+
101
+ const setAlt = (value: string) => {
102
+ form.setValue(`media.${mediaIndex}.alt`, value, { shouldDirty: true })
103
+ }
104
+
105
+ const mediaType = entry.media_type ?? 'image'
106
+ const isImage = mediaType === 'image'
107
+ const isHostedVideo = mediaType === 'video'
108
+ const isExternalVideo = mediaType === 'external_video'
109
+ // Mirrors Spree::Media#playable_video? — both kinds want a poster.
110
+ const isVideo = isHostedVideo || isExternalVideo
111
+ const externalVideoUrl = entry.external_video_url ?? ''
112
+ // The video file itself — a blob URL before save, the served file after.
113
+ const videoUrl = entry.videoUrl ?? null
114
+ const parsedVideo = parseVideoUrl(externalVideoUrl)
115
+ const videoUrlInvalid = externalVideoUrl.length > 0 && !parsedVideo
116
+
117
+ const focalPoint =
118
+ entry.focal_point_x != null && entry.focal_point_y != null
119
+ ? { x: entry.focal_point_x, y: entry.focal_point_y }
120
+ : null
121
+
122
+ const setExternalVideoUrl = (value: string) => {
123
+ form.setValue(`media.${mediaIndex}.external_video_url`, value, { shouldDirty: true })
124
+ }
125
+
126
+ // Click the image where it should stay in frame when a storefront crops it.
127
+ const handleFocalPointClick = (event: React.MouseEvent<HTMLElement>) => {
128
+ if (!isImage) return
129
+
130
+ const bounds = event.currentTarget.getBoundingClientRect()
131
+ if (bounds.width === 0 || bounds.height === 0) return
132
+
133
+ const round = (value: number) => Math.round(Math.min(1, Math.max(0, value)) * 1000) / 1000
134
+
135
+ form.setValue(
136
+ `media.${mediaIndex}.focal_point_x`,
137
+ round((event.clientX - bounds.left) / bounds.width),
138
+ { shouldDirty: true },
139
+ )
140
+ form.setValue(
141
+ `media.${mediaIndex}.focal_point_y`,
142
+ round((event.clientY - bounds.top) / bounds.height),
143
+ { shouldDirty: true },
144
+ )
145
+ }
146
+
147
+ // The poster is a still the merchant supplies for a video. YouTube provides
148
+ // one, so this is how the other cases — Vimeo, uploaded files — get a tile
149
+ // that isn't blank.
150
+ const posterValue = {
151
+ signedId: entry.poster_signed_id ?? null,
152
+ previewUrl: entry.posterUrl ?? null,
153
+ cleared: entry.poster_signed_id === '',
154
+ }
155
+
156
+ const setPoster = (value: {
157
+ signedId: string | null
158
+ previewUrl: string | null
159
+ cleared: boolean
160
+ }) => {
161
+ // An empty string is how the API is told to drop the poster; `undefined`
162
+ // would fall out of the request body and leave the old one in place.
163
+ const next = value.cleared ? '' : (value.signedId ?? undefined)
164
+ form.setValue(`media.${mediaIndex}.poster_signed_id`, next, { shouldDirty: true })
165
+ form.setValue(`media.${mediaIndex}.posterUrl`, value.cleared ? null : value.previewUrl, {
166
+ shouldDirty: true,
167
+ })
168
+ }
169
+
170
+ const clearFocalPoint = () => {
171
+ form.setValue(`media.${mediaIndex}.focal_point_x`, null, { shouldDirty: true })
172
+ form.setValue(`media.${mediaIndex}.focal_point_y`, null, { shouldDirty: true })
173
+ }
174
+
175
+ const toggleVariant = (variantId: string) => {
176
+ const next = new Set(selectedVariantIds)
177
+ if (next.has(variantId)) next.delete(variantId)
178
+ else next.add(variantId)
179
+ form.setValue(`media.${mediaIndex}.variant_ids`, Array.from(next), { shouldDirty: true })
180
+ }
181
+
182
+ const handleCancel = () => {
183
+ const snap = snapshotRef.current
184
+ if (snap) {
185
+ // Resolve the row by stable key — the media array may have reordered
186
+ // while the sheet was open. Skip the restore if the row vanished
187
+ // (e.g. delete from the grid) so we don't write the snapshot onto a
188
+ // different image's alt/variant_ids.
189
+ let targetIndex = mediaIndex
190
+ if (snap.key) {
191
+ const all = form.getValues('media') ?? []
192
+ const found = all.findIndex((m) => rowKey(m) === snap.key)
193
+ if (found === -1) return onOpenChange(false)
194
+ targetIndex = found
195
+ }
196
+ // Restore only the fields the sheet writes to — a sibling card might have
197
+ // appended a new upload while the sheet was open, so overwriting the whole
198
+ // `media` array would drop it. The parent form's isDirty bit may stay true
199
+ // after a cancel; it clears on the next Save round-trip.
200
+ for (const field of EDITED_FIELDS) {
201
+ form.setValue(`media.${targetIndex}.${field}`, snap[field], {
202
+ shouldDirty: false,
203
+ shouldTouch: false,
204
+ })
205
+ }
206
+ }
207
+ onOpenChange(false)
208
+ }
209
+
210
+ return (
211
+ <Sheet open={open} onOpenChange={(o) => (o ? onOpenChange(o) : handleCancel())}>
212
+ <SheetContent side="right" showCloseButton={false} className="flex flex-col">
213
+ <SheetHeader>
214
+ <SheetTitle>{t('admin.products.media.edit_title')}</SheetTitle>
215
+ </SheetHeader>
216
+
217
+ <div className="flex-1 overflow-y-auto p-4 flex flex-col gap-5">
218
+ <MediaPreview
219
+ mediaType={entry.media_type}
220
+ previewUrl={previewUrl}
221
+ videoUrl={videoUrl}
222
+ embedUrl={parsedVideo?.embedUrl}
223
+ alt={alt}
224
+ focalPoint={focalPoint}
225
+ onFocalPointClick={handleFocalPointClick}
226
+ />
227
+
228
+ {isImage && previewUrl && (
229
+ <div className="flex items-center justify-between gap-2 -mt-3">
230
+ <p className="text-xs text-muted-foreground">
231
+ {t('admin.products.media.focal_point_help')}
232
+ </p>
233
+ {focalPoint && (
234
+ <Button type="button" variant="ghost" size="sm" onClick={clearFocalPoint}>
235
+ {t('admin.products.media.focal_point_reset')}
236
+ </Button>
237
+ )}
238
+ </div>
239
+ )}
240
+
241
+ {isExternalVideo && (
242
+ <Field>
243
+ <FieldLabel htmlFor="media-video-url">
244
+ {t('admin.fields.media.external_video_url.label')}
245
+ </FieldLabel>
246
+ <Input
247
+ id="media-video-url"
248
+ value={externalVideoUrl}
249
+ aria-invalid={videoUrlInvalid || undefined}
250
+ onChange={(e) => setExternalVideoUrl(e.target.value)}
251
+ placeholder={t('admin.fields.media.external_video_url.placeholder')}
252
+ />
253
+ {videoUrlInvalid && (
254
+ <FieldError>{t('admin.products.media.video_url_invalid')}</FieldError>
255
+ )}
256
+ </Field>
257
+ )}
258
+
259
+ <Field>
260
+ <FieldLabel htmlFor="media-alt">{t('admin.fields.media.alt.label')}</FieldLabel>
261
+ <Input
262
+ id="media-alt"
263
+ value={alt}
264
+ onChange={(e) => setAlt(e.target.value)}
265
+ placeholder={t('admin.fields.media.alt.placeholder')}
266
+ />
267
+ </Field>
268
+
269
+ {isVideo && (
270
+ <Field>
271
+ <FieldLabel>{t('admin.fields.media.poster.label')}</FieldLabel>
272
+ <ImageUploadField
273
+ value={posterValue}
274
+ onChange={setPoster}
275
+ help={t('admin.fields.media.poster.help')}
276
+ />
277
+ </Field>
278
+ )}
279
+
280
+ {variants.length > 0 && (
281
+ <Field>
282
+ <FieldLabel>{t('admin.products.media.assigned_variants_label')}</FieldLabel>
283
+ <p className="text-xs text-muted-foreground">
284
+ {t('admin.products.media.assigned_variants_help')}
285
+ </p>
286
+ <div className="flex flex-wrap gap-2 pt-2">
287
+ {variants.map((v) => {
288
+ const id = v.id
289
+ if (!id) return null
290
+ const selected = selectedVariantIds.has(id)
291
+ const label = variantLabel(v)
292
+ return (
293
+ <button
294
+ key={id}
295
+ type="button"
296
+ onClick={() => toggleVariant(id)}
297
+ aria-pressed={selected}
298
+ className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
299
+ selected
300
+ ? 'border-primary bg-primary text-primary-foreground'
301
+ : 'border-border bg-background text-muted-foreground hover:border-muted-foreground hover:text-foreground'
302
+ }`}
303
+ >
304
+ {selected && <CheckIcon className="size-3" />}
305
+ {label}
306
+ </button>
307
+ )
308
+ })}
309
+ </div>
310
+ </Field>
311
+ )}
312
+ </div>
313
+
314
+ <SheetFooter>
315
+ <Button type="button" variant="outline" onClick={handleCancel}>
316
+ {t('admin.actions.cancel')}
317
+ </Button>
318
+ <Button type="button" onClick={() => onOpenChange(false)}>
319
+ {t('admin.actions.done')}
320
+ </Button>
321
+ </SheetFooter>
322
+ </SheetContent>
323
+ </Sheet>
324
+ )
325
+ }
326
+
327
+ function variantLabel(v: Variant): string {
328
+ const named = v as { options_text?: string; name?: string; sku?: string | null }
329
+ return (
330
+ named.options_text ||
331
+ named.name ||
332
+ named.sku ||
333
+ v.id ||
334
+ i18n.t('admin.products.variants.variant_label')
335
+ )
336
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { normalizeCustomsDescription, normalizeHsCode } from './normalize-customs'
3
+
4
+ describe('normalizeHsCode', () => {
5
+ it('keeps a plain code unchanged', () => {
6
+ expect(normalizeHsCode('640411')).toBe('640411')
7
+ })
8
+
9
+ it('strips separators a broker spreadsheet would carry', () => {
10
+ expect(normalizeHsCode('6404.11')).toBe('640411')
11
+ expect(normalizeHsCode('6404 11')).toBe('640411')
12
+ expect(normalizeHsCode('6404-11-00')).toBe('64041100')
13
+ })
14
+
15
+ it('collapses an empty or symbol-only value to null', () => {
16
+ expect(normalizeHsCode('')).toBeNull()
17
+ expect(normalizeHsCode(' ')).toBeNull()
18
+ expect(normalizeHsCode('..')).toBeNull()
19
+ expect(normalizeHsCode(null)).toBeNull()
20
+ expect(normalizeHsCode(undefined)).toBeNull()
21
+ })
22
+
23
+ it('does not pad or truncate — the server owns length validation', () => {
24
+ expect(normalizeHsCode('123')).toBe('123')
25
+ })
26
+ })
27
+
28
+ describe('normalizeCustomsDescription', () => {
29
+ it('trims surrounding whitespace', () => {
30
+ expect(normalizeCustomsDescription(' Leather footwear ')).toBe('Leather footwear')
31
+ })
32
+
33
+ it('collapses a blank value to null so clearing the field clears it', () => {
34
+ expect(normalizeCustomsDescription('')).toBeNull()
35
+ expect(normalizeCustomsDescription(' ')).toBeNull()
36
+ expect(normalizeCustomsDescription(null)).toBeNull()
37
+ expect(normalizeCustomsDescription(undefined)).toBeNull()
38
+ })
39
+ })
@@ -0,0 +1,27 @@
1
+ // Customs field normalization, shared by the variant edit sheet and the bulk
2
+ // variants editor so both commit the same value for the same keystrokes.
3
+ //
4
+ // These mirror `Spree::Variant`'s `normalizes` declarations. Doing it in the
5
+ // browser too means a merchant pasting a dotted tariff code from a customs
6
+ // broker's spreadsheet sees it accepted, rather than bouncing off the
7
+ // server's 6–13 digit format validation.
8
+
9
+ /**
10
+ * Strips everything but digits from a Harmonized System code, so `6404.11`
11
+ * and `6404 11` both become `640411`.
12
+ *
13
+ * @returns the digits, or null when nothing is left (the field is optional).
14
+ */
15
+ export function normalizeHsCode(value: unknown): string | null {
16
+ const digits = String(value ?? '').replace(/[^0-9]/g, '')
17
+ return digits === '' ? null : digits
18
+ }
19
+
20
+ /**
21
+ * Trims a customs description, collapsing a blank field to null so clearing
22
+ * it actually clears it rather than storing an empty string.
23
+ */
24
+ export function normalizeCustomsDescription(value: unknown): string | null {
25
+ const trimmed = String(value ?? '').trim()
26
+ return trimmed === '' ? null : trimmed
27
+ }