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