@haus-tech/product-import-export-plugin 3.0.27-4 → 3.0.28-0
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/CHANGELOG.md +4 -0
- package/package.json +1 -1
- package/src/dashboard/export.tsx +337 -0
- package/src/dashboard/import.tsx +634 -0
- package/src/dashboard/index.tsx +17 -0
- package/src/dashboard/locales/en.json +41 -0
- package/src/dashboard/locales/sv.json +41 -0
- package/src/dashboard/utils.ts +64 -0
- package/src/product-import-export.plugin.js +1 -0
- package/src/product-import-export.plugin.js.map +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## 3.0.27-4
|
|
2
|
+
|
|
3
|
+
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
|
|
4
|
+
|
|
1
5
|
## 3.0.27-3
|
|
2
6
|
|
|
3
7
|
This was a version bump only for product-import-export-plugin to align it with other projects, there were no code changes.
|
package/package.json
CHANGED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogContent,
|
|
5
|
+
DialogDescription,
|
|
6
|
+
DialogFooter,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
Input,
|
|
10
|
+
Label,
|
|
11
|
+
RadioGroup,
|
|
12
|
+
RadioGroupItem,
|
|
13
|
+
DataTableBulkActionItem,
|
|
14
|
+
} from '@vendure/dashboard'
|
|
15
|
+
import { DownloadIcon } from 'lucide-react'
|
|
16
|
+
import { useState, useEffect } from 'react'
|
|
17
|
+
import { toast } from 'sonner'
|
|
18
|
+
import {
|
|
19
|
+
getServerLocation,
|
|
20
|
+
getChannelHeader,
|
|
21
|
+
type PluginInitOptions,
|
|
22
|
+
type ExportFields,
|
|
23
|
+
type ProductFields,
|
|
24
|
+
type VariantFields,
|
|
25
|
+
} from './utils'
|
|
26
|
+
|
|
27
|
+
// Export Dialog Component
|
|
28
|
+
function ExportDialog({
|
|
29
|
+
open,
|
|
30
|
+
onOpenChange,
|
|
31
|
+
productIds,
|
|
32
|
+
}: {
|
|
33
|
+
open: boolean
|
|
34
|
+
onOpenChange: (open: boolean) => void
|
|
35
|
+
productIds: string[]
|
|
36
|
+
}) {
|
|
37
|
+
const [fileName, setFileName] = useState('')
|
|
38
|
+
const [customFields, setCustomFields] = useState<string[]>([])
|
|
39
|
+
const [selectedFields, setSelectedFields] = useState<string[]>([])
|
|
40
|
+
const [exportAssetsAs, setExportAssetsAs] = useState<'url' | 'json'>('url')
|
|
41
|
+
const [selectedExportFields, setSelectedExportFields] = useState<ExportFields>([])
|
|
42
|
+
const [availableExportFields] = useState<ExportFields>([
|
|
43
|
+
'productId',
|
|
44
|
+
'name',
|
|
45
|
+
'slug',
|
|
46
|
+
'description',
|
|
47
|
+
'assets',
|
|
48
|
+
'facets',
|
|
49
|
+
'optionGroups',
|
|
50
|
+
'sku',
|
|
51
|
+
'optionValues',
|
|
52
|
+
'price',
|
|
53
|
+
'taxCategory',
|
|
54
|
+
'stockOnHand',
|
|
55
|
+
'trackInventory',
|
|
56
|
+
'variantAssets',
|
|
57
|
+
'variantFacets',
|
|
58
|
+
'enabled',
|
|
59
|
+
])
|
|
60
|
+
const [config, setConfig] = useState<PluginInitOptions['exportOptions'] | null>(null)
|
|
61
|
+
const [toggleAllChecked, setToggleAllChecked] = useState(false)
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const fetchConfig = async () => {
|
|
65
|
+
try {
|
|
66
|
+
const serverPath = getServerLocation()
|
|
67
|
+
const [configRes, customFieldsRes] = await Promise.all([
|
|
68
|
+
fetch(`${serverPath}/product-import-export/config`, {
|
|
69
|
+
method: 'GET',
|
|
70
|
+
headers: {
|
|
71
|
+
'Content-Type': 'application/json',
|
|
72
|
+
...getChannelHeader(),
|
|
73
|
+
},
|
|
74
|
+
credentials: 'include',
|
|
75
|
+
}),
|
|
76
|
+
fetch(`${serverPath}/product-export/custom-fields`, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: {
|
|
79
|
+
'Content-Type': 'application/json',
|
|
80
|
+
...getChannelHeader(),
|
|
81
|
+
},
|
|
82
|
+
credentials: 'include',
|
|
83
|
+
body: JSON.stringify(productIds),
|
|
84
|
+
}),
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
const configData = await configRes.json()
|
|
88
|
+
const customFieldsData = await customFieldsRes.json()
|
|
89
|
+
setConfig(configData.exportOptions)
|
|
90
|
+
setCustomFields(customFieldsData.map((field: { name: string }) => field.name))
|
|
91
|
+
setSelectedFields([...customFieldsData.map((field: { name: string }) => field.name)])
|
|
92
|
+
setExportAssetsAs(
|
|
93
|
+
configData.exportOptions?.defaultExportAssetsAs ||
|
|
94
|
+
configData.exportOptions?.exportAssetsAsOptions?.[0] ||
|
|
95
|
+
'url',
|
|
96
|
+
)
|
|
97
|
+
if (
|
|
98
|
+
configData.exportOptions?.defaultExportFields ||
|
|
99
|
+
configData.exportOptions?.requiredExportFields
|
|
100
|
+
) {
|
|
101
|
+
setSelectedExportFields([
|
|
102
|
+
...(configData.exportOptions.defaultExportFields || []),
|
|
103
|
+
...(configData.exportOptions.requiredExportFields || []),
|
|
104
|
+
])
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error('Failed to fetch config:', error)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (open && productIds.length > 0) {
|
|
112
|
+
fetchConfig()
|
|
113
|
+
}
|
|
114
|
+
}, [open, productIds])
|
|
115
|
+
|
|
116
|
+
const toggleFieldSelection = (fieldName: string) => {
|
|
117
|
+
setSelectedFields((prev) =>
|
|
118
|
+
prev.includes(fieldName) ? prev.filter((f) => f !== fieldName) : [...prev, fieldName],
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const toggleExportFieldSelection = (fieldName: ProductFields | VariantFields) => {
|
|
123
|
+
setSelectedExportFields((prev) =>
|
|
124
|
+
prev.includes(fieldName) ? prev.filter((f) => f !== fieldName) : [...prev, fieldName],
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const toggleSelectAll = (checked: boolean) => {
|
|
129
|
+
setToggleAllChecked(checked)
|
|
130
|
+
if (checked) {
|
|
131
|
+
setSelectedExportFields([...availableExportFields])
|
|
132
|
+
setSelectedFields([...customFields])
|
|
133
|
+
} else {
|
|
134
|
+
setSelectedExportFields([...(config?.requiredExportFields || [])])
|
|
135
|
+
setSelectedFields([])
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const handleExport = async () => {
|
|
140
|
+
const trimmedFileName = fileName.trim()
|
|
141
|
+
const finalFileName = trimmedFileName || config?.defaultFileName || 'products_export.csv'
|
|
142
|
+
|
|
143
|
+
toast.info(`Exporting ${productIds.length} products to ${finalFileName}`)
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const serverPath = getServerLocation()
|
|
147
|
+
const res = await fetch(
|
|
148
|
+
`${serverPath}/product-export/export?fileName=${encodeURIComponent(finalFileName)}&customFields=${encodeURIComponent(selectedFields.join(','))}&exportAssetsAs=${exportAssetsAs}&selectedExportFields=${encodeURIComponent(selectedExportFields.join(','))}`,
|
|
149
|
+
{
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: {
|
|
152
|
+
'Content-Type': 'application/json',
|
|
153
|
+
...getChannelHeader(),
|
|
154
|
+
},
|
|
155
|
+
credentials: 'include',
|
|
156
|
+
body: JSON.stringify(productIds),
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
const json = await res.json()
|
|
162
|
+
throw new Error(json?.message || 'Export failed')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log('res', res)
|
|
166
|
+
const header = res.headers.get('Content-Disposition')
|
|
167
|
+
|
|
168
|
+
const parts = header!.split(';')
|
|
169
|
+
const filename = parts[1].split('=')[1]
|
|
170
|
+
const blob = await res.blob()
|
|
171
|
+
|
|
172
|
+
const blobUrl = window.URL.createObjectURL(blob)
|
|
173
|
+
const a = document.createElement('a')
|
|
174
|
+
document.body.appendChild(a)
|
|
175
|
+
a.setAttribute('hidden', 'true')
|
|
176
|
+
a.href = blobUrl
|
|
177
|
+
a.download = filename
|
|
178
|
+
a.setAttribute('target', '_blank')
|
|
179
|
+
a.click()
|
|
180
|
+
|
|
181
|
+
onOpenChange(false)
|
|
182
|
+
toast.success('Products exported successfully')
|
|
183
|
+
} catch (error: any) {
|
|
184
|
+
console.error(error)
|
|
185
|
+
toast.error(error.message || 'Failed to export products')
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
191
|
+
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
|
192
|
+
<DialogHeader>
|
|
193
|
+
<DialogTitle>Export products to CSV</DialogTitle>
|
|
194
|
+
<DialogDescription>Configure export options</DialogDescription>
|
|
195
|
+
</DialogHeader>
|
|
196
|
+
|
|
197
|
+
<div className="space-y-4">
|
|
198
|
+
<div>
|
|
199
|
+
<Label htmlFor="file-name">File name (optional)</Label>
|
|
200
|
+
<Input
|
|
201
|
+
id="file-name"
|
|
202
|
+
value={fileName}
|
|
203
|
+
onChange={(e) => setFileName(e.target.value)}
|
|
204
|
+
placeholder={config?.defaultFileName || 'products_export.csv'}
|
|
205
|
+
className="mt-1"
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<div className="flex items-center space-x-2">
|
|
210
|
+
<input
|
|
211
|
+
type="checkbox"
|
|
212
|
+
id="select-all"
|
|
213
|
+
checked={toggleAllChecked}
|
|
214
|
+
onChange={(e) => toggleSelectAll(e.target.checked)}
|
|
215
|
+
/>
|
|
216
|
+
<Label htmlFor="select-all" className="font-normal">
|
|
217
|
+
Select all
|
|
218
|
+
</Label>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<div>
|
|
222
|
+
<Label>Fields to export</Label>
|
|
223
|
+
<div className="grid grid-cols-2 gap-2 mt-2">
|
|
224
|
+
{availableExportFields.map((field) => (
|
|
225
|
+
<div key={field} className="flex items-center space-x-2">
|
|
226
|
+
<input
|
|
227
|
+
type="checkbox"
|
|
228
|
+
id={`field-${field}`}
|
|
229
|
+
checked={selectedExportFields.includes(field)}
|
|
230
|
+
onChange={() => toggleExportFieldSelection(field)}
|
|
231
|
+
disabled={config?.requiredExportFields?.includes(field)}
|
|
232
|
+
/>
|
|
233
|
+
<Label htmlFor={`field-${field}`} className="font-normal">
|
|
234
|
+
{field}
|
|
235
|
+
</Label>
|
|
236
|
+
</div>
|
|
237
|
+
))}
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
{customFields.length > 0 && (
|
|
242
|
+
<div>
|
|
243
|
+
<Label>Custom fields to include in the export</Label>
|
|
244
|
+
<div className="grid grid-cols-2 gap-2 mt-2">
|
|
245
|
+
{customFields.map((field) => (
|
|
246
|
+
<div key={field} className="flex items-center space-x-2">
|
|
247
|
+
<input
|
|
248
|
+
type="checkbox"
|
|
249
|
+
id={`custom-${field}`}
|
|
250
|
+
checked={selectedFields.includes(field)}
|
|
251
|
+
onChange={() => toggleFieldSelection(field)}
|
|
252
|
+
/>
|
|
253
|
+
<Label htmlFor={`custom-${field}`} className="font-normal">
|
|
254
|
+
{field}
|
|
255
|
+
</Label>
|
|
256
|
+
</div>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
|
|
262
|
+
<div>
|
|
263
|
+
<Label>Export assets as:</Label>
|
|
264
|
+
<RadioGroup
|
|
265
|
+
value={exportAssetsAs}
|
|
266
|
+
onValueChange={(val) => setExportAssetsAs(val as 'url' | 'json')}
|
|
267
|
+
>
|
|
268
|
+
{config?.exportAssetsAsOptions?.includes('url') && (
|
|
269
|
+
<div className="flex items-center space-x-2">
|
|
270
|
+
<RadioGroupItem value="url" id="assets-url" />
|
|
271
|
+
<Label htmlFor="assets-url" className="font-normal">
|
|
272
|
+
URL
|
|
273
|
+
</Label>
|
|
274
|
+
</div>
|
|
275
|
+
)}
|
|
276
|
+
{config?.exportAssetsAsOptions?.includes('json') && (
|
|
277
|
+
<div className="flex items-center space-x-2">
|
|
278
|
+
<RadioGroupItem value="json" id="assets-json" />
|
|
279
|
+
<Label htmlFor="assets-json" className="font-normal">
|
|
280
|
+
JSON
|
|
281
|
+
</Label>
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
</RadioGroup>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<DialogFooter>
|
|
289
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
290
|
+
Cancel
|
|
291
|
+
</Button>
|
|
292
|
+
<Button type="button" onClick={handleExport}>
|
|
293
|
+
Export
|
|
294
|
+
</Button>
|
|
295
|
+
</DialogFooter>
|
|
296
|
+
</DialogContent>
|
|
297
|
+
</Dialog>
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Export Bulk Action Component for Product List
|
|
302
|
+
export function ExportProductsBulkAction({
|
|
303
|
+
selection,
|
|
304
|
+
table,
|
|
305
|
+
}: {
|
|
306
|
+
selection: Array<{ id: string }>
|
|
307
|
+
table: any
|
|
308
|
+
}) {
|
|
309
|
+
const [dialogOpen, setDialogOpen] = useState(false)
|
|
310
|
+
const productIds = selection.map((product) => product.id)
|
|
311
|
+
|
|
312
|
+
const handleClose = () => {
|
|
313
|
+
setDialogOpen(false)
|
|
314
|
+
table.resetRowSelection()
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<>
|
|
319
|
+
<DataTableBulkActionItem
|
|
320
|
+
onClick={() => setDialogOpen(true)}
|
|
321
|
+
label="Export products to CSV"
|
|
322
|
+
icon={DownloadIcon}
|
|
323
|
+
/>
|
|
324
|
+
<ExportDialog
|
|
325
|
+
open={dialogOpen}
|
|
326
|
+
onOpenChange={(open) => {
|
|
327
|
+
if (open) {
|
|
328
|
+
setDialogOpen(true)
|
|
329
|
+
} else {
|
|
330
|
+
handleClose()
|
|
331
|
+
}
|
|
332
|
+
}}
|
|
333
|
+
productIds={productIds}
|
|
334
|
+
/>
|
|
335
|
+
</>
|
|
336
|
+
)
|
|
337
|
+
}
|
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
DashboardRouteDefinition,
|
|
4
|
+
Page,
|
|
5
|
+
PageTitle,
|
|
6
|
+
PageLayout,
|
|
7
|
+
PageBlock,
|
|
8
|
+
Switch,
|
|
9
|
+
Label,
|
|
10
|
+
RadioGroup,
|
|
11
|
+
RadioGroupItem,
|
|
12
|
+
} from '@vendure/dashboard'
|
|
13
|
+
import { UploadIcon } from 'lucide-react'
|
|
14
|
+
import { useState, useRef, useEffect } from 'react'
|
|
15
|
+
import { toast } from 'sonner'
|
|
16
|
+
import type { LanguageCode, Channel } from '@vendure/core'
|
|
17
|
+
import { size, startsWith, endsWith, uniq } from 'lodash'
|
|
18
|
+
import {
|
|
19
|
+
getServerLocation,
|
|
20
|
+
getChannelHeader,
|
|
21
|
+
type UpdatingStrategy,
|
|
22
|
+
type ValidateReturnType,
|
|
23
|
+
type PluginInitOptions,
|
|
24
|
+
} from './utils'
|
|
25
|
+
|
|
26
|
+
// Import page component
|
|
27
|
+
function ProductImportPage() {
|
|
28
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
29
|
+
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
|
30
|
+
const [validateFile, setValidateFile] = useState<File | null>(null)
|
|
31
|
+
const [updateProductSlug, setUpdateProductSlug] = useState(true)
|
|
32
|
+
const [selectedMainLanguage, setSelectedMainLanguage] = useState<LanguageCode | undefined>()
|
|
33
|
+
const [availableLanguages, setAvailableLanguages] = useState<LanguageCode[]>([])
|
|
34
|
+
const [updatingStrategy, setUpdatingStrategy] = useState<UpdatingStrategy>('merge')
|
|
35
|
+
const [config, setConfig] = useState<PluginInitOptions | null>(null)
|
|
36
|
+
const [currentChannel, setCurrentChannel] = useState<Channel | null>(null)
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const fetchConfig = async () => {
|
|
40
|
+
try {
|
|
41
|
+
const serverPath = getServerLocation()
|
|
42
|
+
const [configRes, channelRes] = await Promise.all([
|
|
43
|
+
fetch(`${serverPath}/product-import-export/config`, {
|
|
44
|
+
method: 'GET',
|
|
45
|
+
headers: {
|
|
46
|
+
'Content-Type': 'application/json',
|
|
47
|
+
...getChannelHeader(),
|
|
48
|
+
},
|
|
49
|
+
credentials: 'include',
|
|
50
|
+
}),
|
|
51
|
+
fetch(`${serverPath}/product-import-export/channel`, {
|
|
52
|
+
method: 'GET',
|
|
53
|
+
headers: {
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
...getChannelHeader(),
|
|
56
|
+
},
|
|
57
|
+
credentials: 'include',
|
|
58
|
+
}),
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
const configData = await configRes.json()
|
|
62
|
+
const channelData = await channelRes.json()
|
|
63
|
+
setConfig(configData.importOptions)
|
|
64
|
+
setCurrentChannel(channelData)
|
|
65
|
+
if (configData.importOptions?.defaultOptions?.updateProductSlug !== undefined) {
|
|
66
|
+
setUpdateProductSlug(configData.importOptions.defaultOptions.updateProductSlug)
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error('Failed to fetch config:', error)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fetchConfig()
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
const splitLines = (text: string): string[] => {
|
|
77
|
+
return text.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const checkifJson = (
|
|
81
|
+
text: string,
|
|
82
|
+
): {
|
|
83
|
+
startsWithJson: boolean
|
|
84
|
+
endsWithJson: boolean
|
|
85
|
+
startChar: string
|
|
86
|
+
endChar: string
|
|
87
|
+
isJson?: boolean
|
|
88
|
+
} => {
|
|
89
|
+
// Replace starting and ending double quotes if exists
|
|
90
|
+
const trimmedText = text.trim().replace(/^"/, '').replace(/"$/, '')
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
startsWithJson: startsWith(trimmedText, '{') || startsWith(trimmedText, '['),
|
|
94
|
+
endsWithJson: endsWith(trimmedText, '}') || endsWith(trimmedText, ']'),
|
|
95
|
+
startChar: trimmedText.charAt(0),
|
|
96
|
+
endChar: trimmedText.charAt(trimmedText.length - 1),
|
|
97
|
+
isJson:
|
|
98
|
+
startsWith(trimmedText, '{') ||
|
|
99
|
+
startsWith(trimmedText, '[') ||
|
|
100
|
+
endsWith(trimmedText, '}') ||
|
|
101
|
+
endsWith(trimmedText, ']'),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const checkTranslatabeColumns = (
|
|
106
|
+
lines: string[],
|
|
107
|
+
columns: string[],
|
|
108
|
+
header: string[],
|
|
109
|
+
languageCodes: (string | undefined)[],
|
|
110
|
+
mainLanguage?: LanguageCode,
|
|
111
|
+
): { errorRows: number[]; errorColumns: string[] } => {
|
|
112
|
+
const errorRows: number[] = []
|
|
113
|
+
const errorColumns: string[] = []
|
|
114
|
+
|
|
115
|
+
if (size(languageCodes) < 2 || !mainLanguage) {
|
|
116
|
+
return { errorRows, errorColumns }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (let i = 1; i < lines.length; i++) {
|
|
120
|
+
const row = splitLines(lines[i]).map((col) => col.trim())
|
|
121
|
+
for (const baseColumn of columns) {
|
|
122
|
+
const mainLanguageColumn = `${baseColumn}:${mainLanguage}`
|
|
123
|
+
const colIndex = header.indexOf(mainLanguageColumn)
|
|
124
|
+
const mainLanguageValue = row[colIndex]
|
|
125
|
+
const mainLanguageHasValue = mainLanguageValue !== undefined && mainLanguageValue !== ''
|
|
126
|
+
|
|
127
|
+
if (mainLanguageHasValue) {
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
for (const lang of languageCodes) {
|
|
131
|
+
if (lang) {
|
|
132
|
+
// Check if lang is defined
|
|
133
|
+
const langSpecificColumn = `${baseColumn}:${lang}`
|
|
134
|
+
const langColIndex = header.indexOf(langSpecificColumn)
|
|
135
|
+
const colValue = row[langColIndex]
|
|
136
|
+
const colHasValue = colValue !== undefined && colValue !== ''
|
|
137
|
+
|
|
138
|
+
if (!mainLanguageHasValue && colHasValue) {
|
|
139
|
+
errorRows.push(i + 1)
|
|
140
|
+
errorColumns.push(langSpecificColumn)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { errorRows, errorColumns }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const validateCsvStructure = async (file: File): Promise<ValidateReturnType> => {
|
|
151
|
+
const baseColumns = ['sku']
|
|
152
|
+
const optionalColumns = [
|
|
153
|
+
'assets',
|
|
154
|
+
'price',
|
|
155
|
+
'taxCategory',
|
|
156
|
+
'stockOnHand',
|
|
157
|
+
'trackInventory',
|
|
158
|
+
'variantAssets',
|
|
159
|
+
]
|
|
160
|
+
const translatableBaseColumns = ['name']
|
|
161
|
+
const translatableColumns = [
|
|
162
|
+
'slug',
|
|
163
|
+
'description',
|
|
164
|
+
'facets',
|
|
165
|
+
'optionGroups',
|
|
166
|
+
'optionValues',
|
|
167
|
+
'variantFacets',
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
return new Promise<ValidateReturnType>((resolve) => {
|
|
171
|
+
const reader = new FileReader()
|
|
172
|
+
reader.onload = async (e) => {
|
|
173
|
+
const text = e.target?.result as string
|
|
174
|
+
const lines = text.split('\n').filter((line) => line.trim() !== '')
|
|
175
|
+
|
|
176
|
+
if (lines.length === 0) {
|
|
177
|
+
toast.error('The file is empty.')
|
|
178
|
+
resolve({ isValid: false })
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const headerLength = splitLines(lines[0]).length
|
|
183
|
+
const inconsistentLines = lines.filter((line) => splitLines(line).length !== headerLength)
|
|
184
|
+
|
|
185
|
+
if (inconsistentLines.length > 0) {
|
|
186
|
+
toast.error(
|
|
187
|
+
'The columns in the file are inconsistent. Please ensure all rows have the same number of columns.',
|
|
188
|
+
)
|
|
189
|
+
resolve({ isValid: false })
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const header = splitLines(lines[0]).map((col) => col.trim())
|
|
194
|
+
|
|
195
|
+
// Fetch channel if not already loaded (matches Angular implementation)
|
|
196
|
+
let channel = currentChannel
|
|
197
|
+
if (!channel) {
|
|
198
|
+
try {
|
|
199
|
+
const serverPath = getServerLocation()
|
|
200
|
+
const channelRes = await fetch(`${serverPath}/product-import-export/channel`, {
|
|
201
|
+
method: 'GET',
|
|
202
|
+
headers: {
|
|
203
|
+
'Content-Type': 'application/json',
|
|
204
|
+
...getChannelHeader(),
|
|
205
|
+
},
|
|
206
|
+
credentials: 'include',
|
|
207
|
+
})
|
|
208
|
+
channel = await channelRes.json()
|
|
209
|
+
// Update state for next time
|
|
210
|
+
setCurrentChannel(channel)
|
|
211
|
+
} catch (error) {
|
|
212
|
+
console.error('Failed to fetch channel:', error)
|
|
213
|
+
toast.error('Failed to fetch channel information.')
|
|
214
|
+
resolve({ isValid: false })
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const availableLanguages = channel?.availableLanguageCodes || []
|
|
220
|
+
|
|
221
|
+
const languageCodes = header
|
|
222
|
+
.map((col) => col.match(/:(\w{2})$/)?.[1])
|
|
223
|
+
.filter((code, index, self) => code && self.indexOf(code) === index)
|
|
224
|
+
|
|
225
|
+
let validationLanguage = languageCodes.length === 0 ? undefined : selectedMainLanguage
|
|
226
|
+
|
|
227
|
+
if (!selectedMainLanguage && languageCodes.length < 2) {
|
|
228
|
+
validationLanguage = (languageCodes as LanguageCode[])?.[0]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!selectedMainLanguage && languageCodes.length > 1) {
|
|
232
|
+
resolve({ isValid: true, langCodes: languageCodes, clearFile: false })
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!selectedMainLanguage && languageCodes.length === 0) {
|
|
237
|
+
// No language codes in CSV, return channel languages so user can select
|
|
238
|
+
resolve({
|
|
239
|
+
isValid: true,
|
|
240
|
+
langCodes: availableLanguages.map((lang) => lang) as (string | undefined)[],
|
|
241
|
+
clearFile: false,
|
|
242
|
+
})
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const missingLanguages = (languageCodes as LanguageCode[]).filter(
|
|
247
|
+
(code) => !availableLanguages.includes(code),
|
|
248
|
+
)
|
|
249
|
+
if (missingLanguages.length > 0) {
|
|
250
|
+
toast.error(
|
|
251
|
+
`Your CSV file contains languages that are not available in this channel. Please add the languages to the channel or remove the languages that are not used and try again. Missing: ${missingLanguages.join(', ')}`,
|
|
252
|
+
)
|
|
253
|
+
resolve({ isValid: false })
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const missingBaseColumns = baseColumns.filter((col) => !header.includes(col))
|
|
258
|
+
const missingTranslatableBaseColumns = translatableBaseColumns.filter(
|
|
259
|
+
(col) => !header.includes(validationLanguage ? `${col}:${validationLanguage}` : col),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const combinedMissingBaseColumns = [
|
|
263
|
+
...missingBaseColumns,
|
|
264
|
+
...missingTranslatableBaseColumns,
|
|
265
|
+
]
|
|
266
|
+
if (combinedMissingBaseColumns.length > 0) {
|
|
267
|
+
toast.error(`Some columns are missing: ${combinedMissingBaseColumns.join(', ')}`)
|
|
268
|
+
resolve({ isValid: false })
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const nameColumn = validationLanguage ? `name:${validationLanguage}` : 'name'
|
|
273
|
+
|
|
274
|
+
// Get optionGroups and optionValues columns for the main language
|
|
275
|
+
const optionGroupsColumn = validationLanguage
|
|
276
|
+
? `optionGroups:${validationLanguage}`
|
|
277
|
+
: 'optionGroups'
|
|
278
|
+
const optionValuesColumn = validationLanguage
|
|
279
|
+
? `optionValues:${validationLanguage}`
|
|
280
|
+
: 'optionValues'
|
|
281
|
+
|
|
282
|
+
const optionGroupsIndex = header.indexOf(optionGroupsColumn)
|
|
283
|
+
const optionValuesIndex = header.indexOf(optionValuesColumn)
|
|
284
|
+
|
|
285
|
+
// Loop through each row to validate the option values
|
|
286
|
+
let currentOptionGroupCount = 0
|
|
287
|
+
|
|
288
|
+
for (let i = 1; i < lines.length; i++) {
|
|
289
|
+
const row = splitLines(lines[i]).map((col) => col.trim())
|
|
290
|
+
|
|
291
|
+
// Check if it's a product row (it has a 'name' field)
|
|
292
|
+
const isProductRow = row[header.indexOf(nameColumn)]
|
|
293
|
+
|
|
294
|
+
if (isProductRow) {
|
|
295
|
+
// It's a new product row, get the option groups
|
|
296
|
+
const optionGroups = (row[optionGroupsIndex]?.split('|') ?? []).filter(
|
|
297
|
+
(group) => group !== '',
|
|
298
|
+
)
|
|
299
|
+
const optionValues = (row[optionValuesIndex]?.split('|') ?? []).filter(
|
|
300
|
+
(value) => value !== '',
|
|
301
|
+
)
|
|
302
|
+
currentOptionGroupCount = optionGroups.length
|
|
303
|
+
|
|
304
|
+
// Validate that the number of option values matches the number of option groups
|
|
305
|
+
if (optionValues.length !== currentOptionGroupCount) {
|
|
306
|
+
toast.error(
|
|
307
|
+
`Some product variants are missing product options. Please fill in all options: Row: ${i + 1}. Expected ${currentOptionGroupCount} option values but got ${optionValues.length}.`,
|
|
308
|
+
)
|
|
309
|
+
resolve({ isValid: false })
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
// It's a variant row, validate the option values
|
|
314
|
+
const optionValues = (row[optionValuesIndex]?.split('|') ?? []).filter(
|
|
315
|
+
(value) => value !== '',
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
// Validate that the number of option values matches the product's option groups
|
|
319
|
+
if (optionValues.length !== currentOptionGroupCount) {
|
|
320
|
+
toast.error(
|
|
321
|
+
`Some product variants are missing product options. Please fill in all options: Row: ${i + 1}. Expected ${currentOptionGroupCount} option values but got ${optionValues.length}.`,
|
|
322
|
+
)
|
|
323
|
+
resolve({ isValid: false })
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Check if a row contains JSON, if so check if it is valid. This should be done for all columns, not just translatable ones.
|
|
330
|
+
for (let i = 1; i < lines.length; i++) {
|
|
331
|
+
const row = splitLines(lines[i]).map((col) => col.trim())
|
|
332
|
+
for (const col of header) {
|
|
333
|
+
const colIndex = header.indexOf(col)
|
|
334
|
+
if (colIndex !== -1 && row[colIndex]) {
|
|
335
|
+
try {
|
|
336
|
+
const { isJson } = checkifJson(row[colIndex])
|
|
337
|
+
if (isJson) {
|
|
338
|
+
JSON.parse(
|
|
339
|
+
row[colIndex]
|
|
340
|
+
.trim()
|
|
341
|
+
.replace(/^"/, '')
|
|
342
|
+
.replace(/"$/, '')
|
|
343
|
+
.replace(/'/g, '"')
|
|
344
|
+
.replace(/\s+/g, ' ')
|
|
345
|
+
.replace(/,\s*'/g, ", '"),
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
} catch (error) {
|
|
349
|
+
toast.error(
|
|
350
|
+
`Invalid JSON structure. Please upload a correct JSON file. Column: ${col}, Row: ${i + 1}`,
|
|
351
|
+
)
|
|
352
|
+
resolve({ isValid: false })
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Check for missing translation values in translatable base columns
|
|
360
|
+
const { errorRows: missingBaseTranslatableValuesRows } = checkTranslatabeColumns(
|
|
361
|
+
lines,
|
|
362
|
+
translatableBaseColumns,
|
|
363
|
+
header,
|
|
364
|
+
languageCodes,
|
|
365
|
+
selectedMainLanguage,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if (missingBaseTranslatableValuesRows.length > 0) {
|
|
369
|
+
toast.error(
|
|
370
|
+
`Main language is missing for name. Please add the main language and try again. Rows: ${uniq(missingBaseTranslatableValuesRows).join(', ')}`,
|
|
371
|
+
)
|
|
372
|
+
resolve({ isValid: false })
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Check if all translatable columns have values for the main language
|
|
377
|
+
const { errorRows, errorColumns } = checkTranslatabeColumns(
|
|
378
|
+
lines,
|
|
379
|
+
translatableColumns,
|
|
380
|
+
header,
|
|
381
|
+
languageCodes,
|
|
382
|
+
selectedMainLanguage,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
if (errorRows.length > 0) {
|
|
386
|
+
toast.warning(
|
|
387
|
+
`Some fields are missing translations for the main language that exist in other languages. These will not be imported. Rows: ${uniq(errorRows).join(', ')} Columns: ${uniq(errorColumns).join(', ')}`,
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
resolve({ isValid: true, langCodes: languageCodes })
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
reader.onerror = (error) => {
|
|
395
|
+
console.error('FileReader error:', error)
|
|
396
|
+
toast.error('An error occurred while reading the file.')
|
|
397
|
+
resolve({ isValid: false })
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
reader.readAsText(file)
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const doValidation = async (file: File) => {
|
|
405
|
+
const { isValid, langCodes, clearFile = true } = await validateCsvStructure(file)
|
|
406
|
+
if (isValid) {
|
|
407
|
+
setSelectedFile(file)
|
|
408
|
+
} else if (clearFile) {
|
|
409
|
+
clearFileHandler()
|
|
410
|
+
return
|
|
411
|
+
} else {
|
|
412
|
+
// File is valid but needs language selection
|
|
413
|
+
setSelectedFile(file)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Set available languages - either from CSV or from channel
|
|
417
|
+
if (langCodes && langCodes.length > 0) {
|
|
418
|
+
const filteredLangCodes = langCodes.filter((code) => code) as LanguageCode[]
|
|
419
|
+
setAvailableLanguages(filteredLangCodes)
|
|
420
|
+
if (!selectedMainLanguage) {
|
|
421
|
+
setSelectedMainLanguage(filteredLangCodes.length === 1 ? filteredLangCodes[0] : undefined)
|
|
422
|
+
}
|
|
423
|
+
} else if (
|
|
424
|
+
currentChannel?.availableLanguageCodes &&
|
|
425
|
+
currentChannel.availableLanguageCodes.length > 0
|
|
426
|
+
) {
|
|
427
|
+
// No languages in CSV headers, use channel languages as fallback
|
|
428
|
+
setAvailableLanguages(currentChannel.availableLanguageCodes)
|
|
429
|
+
if (!selectedMainLanguage) {
|
|
430
|
+
setSelectedMainLanguage(
|
|
431
|
+
currentChannel.availableLanguageCodes.length === 1
|
|
432
|
+
? currentChannel.availableLanguageCodes[0]
|
|
433
|
+
: undefined,
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const onFileSelected = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
440
|
+
const input = event.target
|
|
441
|
+
if (input.files && input.files.length > 0) {
|
|
442
|
+
const file = input.files[0]
|
|
443
|
+
if (file.type !== 'text/csv') {
|
|
444
|
+
toast.error('Invalid file type. Please upload a CSV file.')
|
|
445
|
+
clearFileHandler()
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
setValidateFile(file)
|
|
450
|
+
doValidation(file)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const clearFileHandler = () => {
|
|
455
|
+
if (fileInputRef.current) {
|
|
456
|
+
fileInputRef.current.value = ''
|
|
457
|
+
}
|
|
458
|
+
setSelectedFile(null)
|
|
459
|
+
setValidateFile(null)
|
|
460
|
+
setSelectedMainLanguage(undefined)
|
|
461
|
+
setUpdatingStrategy('merge')
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const onMainLanguageChange = (lang: LanguageCode) => {
|
|
465
|
+
setSelectedMainLanguage(lang)
|
|
466
|
+
if (validateFile) {
|
|
467
|
+
doValidation(validateFile)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const uploadFile = async () => {
|
|
472
|
+
if (selectedFile && selectedMainLanguage && updatingStrategy) {
|
|
473
|
+
const formData = new FormData()
|
|
474
|
+
formData.append('file', selectedFile)
|
|
475
|
+
formData.append('updateProductSlug', updateProductSlug.toString())
|
|
476
|
+
formData.append('mainLanguage', selectedMainLanguage)
|
|
477
|
+
formData.append('updatingStrategy', updatingStrategy)
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
const serverPath = getServerLocation()
|
|
481
|
+
const res = await fetch(`${serverPath}/product-import/upload`, {
|
|
482
|
+
method: 'POST',
|
|
483
|
+
body: formData,
|
|
484
|
+
headers: {
|
|
485
|
+
...getChannelHeader(),
|
|
486
|
+
},
|
|
487
|
+
credentials: 'include',
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
if (res.ok) {
|
|
491
|
+
clearFileHandler()
|
|
492
|
+
toast.success('The file was uploaded and has been added to the import queue.')
|
|
493
|
+
} else {
|
|
494
|
+
toast.error('An error occurred while uploading the file.')
|
|
495
|
+
}
|
|
496
|
+
} catch (error) {
|
|
497
|
+
toast.error('An error occurred while uploading the file.')
|
|
498
|
+
}
|
|
499
|
+
} else {
|
|
500
|
+
toast.error('No file selected or main language not selected.')
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return (
|
|
505
|
+
<Page pageId="product-import">
|
|
506
|
+
<PageTitle>Import Products</PageTitle>
|
|
507
|
+
<PageLayout>
|
|
508
|
+
<PageBlock column="main" blockId="main-form" title="Import products from CSV">
|
|
509
|
+
<div className="space-y-6">
|
|
510
|
+
<div>
|
|
511
|
+
<Label htmlFor="file-upload">Choose a CSV file to import products from</Label>
|
|
512
|
+
<div className="flex items-center gap-2 mt-2">
|
|
513
|
+
<input
|
|
514
|
+
ref={fileInputRef}
|
|
515
|
+
type="file"
|
|
516
|
+
id="file-upload"
|
|
517
|
+
accept=".csv"
|
|
518
|
+
onChange={onFileSelected}
|
|
519
|
+
className="hidden"
|
|
520
|
+
/>
|
|
521
|
+
{selectedFile ? (
|
|
522
|
+
<>
|
|
523
|
+
<Button
|
|
524
|
+
type="button"
|
|
525
|
+
variant="outline"
|
|
526
|
+
onClick={() => fileInputRef.current?.click()}
|
|
527
|
+
>
|
|
528
|
+
<UploadIcon className="mr-2 h-4 w-4" />
|
|
529
|
+
{selectedFile.name}
|
|
530
|
+
</Button>
|
|
531
|
+
<Button type="button" onClick={uploadFile} disabled={!selectedMainLanguage}>
|
|
532
|
+
Upload file
|
|
533
|
+
</Button>
|
|
534
|
+
</>
|
|
535
|
+
) : (
|
|
536
|
+
<Button
|
|
537
|
+
type="button"
|
|
538
|
+
variant="default"
|
|
539
|
+
onClick={() => fileInputRef.current?.click()}
|
|
540
|
+
>
|
|
541
|
+
<UploadIcon className="mr-2 h-4 w-4" />
|
|
542
|
+
Select file
|
|
543
|
+
</Button>
|
|
544
|
+
)}
|
|
545
|
+
</div>
|
|
546
|
+
</div>
|
|
547
|
+
|
|
548
|
+
{selectedFile && (
|
|
549
|
+
<div className="space-y-4 pt-4 border-t">
|
|
550
|
+
{config?.visibleOptions?.includes('updateProductSlug') && (
|
|
551
|
+
<div className="flex items-center space-x-2">
|
|
552
|
+
<Switch
|
|
553
|
+
id="update-slug"
|
|
554
|
+
checked={updateProductSlug}
|
|
555
|
+
onCheckedChange={setUpdateProductSlug}
|
|
556
|
+
/>
|
|
557
|
+
<Label htmlFor="update-slug">Update product slugs based on name</Label>
|
|
558
|
+
</div>
|
|
559
|
+
)}
|
|
560
|
+
|
|
561
|
+
<div>
|
|
562
|
+
<Label>Select main language:</Label>
|
|
563
|
+
<div className="flex flex-col gap-2 mt-2">
|
|
564
|
+
{availableLanguages.map((lang) => (
|
|
565
|
+
<div key={lang} className="flex items-center space-x-2">
|
|
566
|
+
<input
|
|
567
|
+
type="radio"
|
|
568
|
+
id={`lang-${lang}`}
|
|
569
|
+
name="selectedMainLanguage"
|
|
570
|
+
value={lang}
|
|
571
|
+
checked={selectedMainLanguage === lang}
|
|
572
|
+
onChange={() => onMainLanguageChange(lang)}
|
|
573
|
+
/>
|
|
574
|
+
<Label htmlFor={`lang-${lang}`} className="font-normal">
|
|
575
|
+
{lang}
|
|
576
|
+
</Label>
|
|
577
|
+
</div>
|
|
578
|
+
))}
|
|
579
|
+
</div>
|
|
580
|
+
<p className="text-sm text-muted-foreground mt-2 italic">
|
|
581
|
+
The main language is used as a fallback when translations are missing. It is
|
|
582
|
+
recommended to select the language that is used the most in your system.
|
|
583
|
+
</p>
|
|
584
|
+
</div>
|
|
585
|
+
|
|
586
|
+
<div>
|
|
587
|
+
<Label>Updating strategy for facets and assets:</Label>
|
|
588
|
+
<RadioGroup
|
|
589
|
+
value={updatingStrategy}
|
|
590
|
+
onValueChange={(val) => setUpdatingStrategy(val as UpdatingStrategy)}
|
|
591
|
+
>
|
|
592
|
+
<div className="flex items-center space-x-2">
|
|
593
|
+
<RadioGroupItem value="merge" id="merge" />
|
|
594
|
+
<Label htmlFor="merge" className="font-normal">
|
|
595
|
+
Merge
|
|
596
|
+
</Label>
|
|
597
|
+
</div>
|
|
598
|
+
<div className="flex items-center space-x-2">
|
|
599
|
+
<RadioGroupItem value="replace" id="replace" />
|
|
600
|
+
<Label htmlFor="replace" className="font-normal">
|
|
601
|
+
Overwrite
|
|
602
|
+
</Label>
|
|
603
|
+
</div>
|
|
604
|
+
</RadioGroup>
|
|
605
|
+
{updatingStrategy === 'replace' && (
|
|
606
|
+
<p className="text-sm text-red-600 mt-2">
|
|
607
|
+
Warning: This will remove existing facets and assets from products and
|
|
608
|
+
variants.
|
|
609
|
+
</p>
|
|
610
|
+
)}
|
|
611
|
+
</div>
|
|
612
|
+
</div>
|
|
613
|
+
)}
|
|
614
|
+
</div>
|
|
615
|
+
</PageBlock>
|
|
616
|
+
</PageLayout>
|
|
617
|
+
</Page>
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Import route definition
|
|
622
|
+
export const productImportRoute: DashboardRouteDefinition = {
|
|
623
|
+
navMenuItem: {
|
|
624
|
+
sectionId: 'catalog',
|
|
625
|
+
id: 'product-importer',
|
|
626
|
+
url: '/product-importer',
|
|
627
|
+
title: 'Import Products',
|
|
628
|
+
},
|
|
629
|
+
path: '/product-importer',
|
|
630
|
+
loader: () => ({
|
|
631
|
+
breadcrumb: 'Import Products',
|
|
632
|
+
}),
|
|
633
|
+
component: () => <ProductImportPage />,
|
|
634
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineDashboardExtension } from '@vendure/dashboard'
|
|
2
|
+
import { productImportRoute } from './import'
|
|
3
|
+
import { ExportProductsBulkAction } from './export'
|
|
4
|
+
|
|
5
|
+
defineDashboardExtension({
|
|
6
|
+
routes: [productImportRoute],
|
|
7
|
+
dataTables: [
|
|
8
|
+
{
|
|
9
|
+
pageId: 'product-list',
|
|
10
|
+
bulkActions: [
|
|
11
|
+
{
|
|
12
|
+
component: ExportProductsBulkAction,
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"product-import.import-products": "Import products",
|
|
3
|
+
"product-import.card.title": "Import products from CSV",
|
|
4
|
+
"product-import.card.description": "Choose a CSV file to import products from",
|
|
5
|
+
"product-import.card.button-select": "Select file",
|
|
6
|
+
"product-import.card.button-upload": "Upload file",
|
|
7
|
+
"product-import.card.update-product-slugs": "Update product slugs based on name",
|
|
8
|
+
"product-import.card.select-main-language": "Select main language:",
|
|
9
|
+
"product-import.card.updating-strategy": "Updating strategy for facets and assets:",
|
|
10
|
+
"product-import.card.overwrite": "Overwrite",
|
|
11
|
+
"product-import.card.overwrite-warning": "Warning: This will remove existing facets and assets from products and variants.",
|
|
12
|
+
"product-import.card.merge": "Merge",
|
|
13
|
+
"product-import.card.main-language-info": "The main language is used as a fallback when translations are missing. It is recommended to select the language that is used the most in your system. The main language will be used to create products, the other languages will be used to fill in translations.",
|
|
14
|
+
"product-import.notifications.invalid-file-type": "Invalid file type. Please upload a CSV file.",
|
|
15
|
+
"product-import.notifications.invalid-file-structure": "Invalid file structure. Please upload a correct CSV file.",
|
|
16
|
+
"product-import.notifications.upload-success": "The file was uploaded and has been added to the import queue.",
|
|
17
|
+
"product-import.notifications.upload-error": "An error occurred while uploading the file.",
|
|
18
|
+
"product-import.notifications.no-file-selected": "No file selected.",
|
|
19
|
+
"product-import.notifications.mismatched-values": "Some fields are missing language specifications. Please fill in all languages:",
|
|
20
|
+
"product-import.notifications.missing-base-columns": "Some columns are missing:",
|
|
21
|
+
"product-import.notifications.missing-column": "Column is missing:",
|
|
22
|
+
"product-import.notifications.no-lines-in-file": "The file is empty.",
|
|
23
|
+
"product-import.notifications.file-read-error": "An error occurred while reading the file.",
|
|
24
|
+
"product-import.notifications.invalid-json": "Invalid JSON structure. Please upload a correct JSON file.",
|
|
25
|
+
"product-import.notifications.missing-languages": "Your CSV file contains languages that are not available in the system. Please add the languages to the system or remove the languages that are not used and try again.",
|
|
26
|
+
"product-import.notifications.mismatched-option-values": "Some product variants are missing product options. Please fill in all options:",
|
|
27
|
+
"product-import.notifications.missing-main-language-name": "Main language is missing for name. Please add the main language and try again.",
|
|
28
|
+
"product-import.notifications.missing-translation-values": "Some fields are missing translations for the main language that exist in other languages. These will not be imported:",
|
|
29
|
+
"product-import.notifications.inconsistent-columns": "The columns in the file are inconsistent. Please ensure all rows have the same number of columns.",
|
|
30
|
+
"product-export.action": "Export products to CSV",
|
|
31
|
+
"product-export.dialog.title": "Export products to CSV",
|
|
32
|
+
"product-export.dialog.label": "File name (optional)",
|
|
33
|
+
"product-export.dialog.export": "Export",
|
|
34
|
+
"product-export.dialog.cancel": "Cancel",
|
|
35
|
+
"product-export.dialog.customFieldsLabel": "Custom fields to include in the export",
|
|
36
|
+
"product-export.dialog.exportAssetsAs": "Export assets as:",
|
|
37
|
+
"product-export.dialog.exportAssetsAsOptions.url": "URL",
|
|
38
|
+
"product-export.dialog.exportAssetsAsOptions.json": "JSON",
|
|
39
|
+
"product-export.dialog.availableExportFieldsLabel": "Fields to export",
|
|
40
|
+
"product-export.dialog.selectAll": "Select all"
|
|
41
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"product-import.import-products": "Importera produkter",
|
|
3
|
+
"product-import.card.title": "Importera produkter från CSV",
|
|
4
|
+
"product-import.card.description": "Välj en CSV-fil att importera produkter från",
|
|
5
|
+
"product-import.card.button-select": "Välj fil",
|
|
6
|
+
"product-import.card.button-upload": "Ladda upp fil",
|
|
7
|
+
"product-import.card.update-product-slugs": "Uppdatera produkt-sluggar utifrån namn",
|
|
8
|
+
"product-import.card.select-main-language": "Välj huvudspråk:",
|
|
9
|
+
"product-import.card.updating-strategy": "Uppdateringsstrategi för etiketter och filer:",
|
|
10
|
+
"product-import.card.overwrite": "Skriv över",
|
|
11
|
+
"product-import.card.overwrite-warning": "Varning: Detta kommer ta bort befintliga etiketter och filer från produkter och varianter.",
|
|
12
|
+
"product-import.card.merge": "Sammanfoga",
|
|
13
|
+
"product-import.card.main-language-info": "Huvudspråket används som fallback när översättningar saknas. Det rekommenderas att välja det språk som används mest i din kanal. Huvudspråket kommer användas för att skapa upp produkter, de övriga språken kommer användas för att fylla i översättningar.",
|
|
14
|
+
"product-import.notifications.invalid-file-type": "Felaktig filtyp. Vänligen ladda upp en CSV-fil.",
|
|
15
|
+
"product-import.notifications.invalid-file-structure": "Felaktig filstruktur. Vänligen ladda upp en korrekt CSV-fil.",
|
|
16
|
+
"product-import.notifications.upload-success": "Filen laddades upp och har lagts till i kön för import.",
|
|
17
|
+
"product-import.notifications.upload-error": "Ett fel uppstod vid uppladdning av filen.",
|
|
18
|
+
"product-import.notifications.no-file-selected": "Ingen fil vald.",
|
|
19
|
+
"product-import.notifications.mismatched-values": "Det saknas språkspecifikationer för vissa fält. Vänligen fyll i alla språk:",
|
|
20
|
+
"product-import.notifications.missing-base-columns": "Vissa kolumner saknas:",
|
|
21
|
+
"product-import.notifications.missing-column": "Kolumnen saknas:",
|
|
22
|
+
"product-import.notifications.no-lines-in-file": "Filen är tom.",
|
|
23
|
+
"product-import.notifications.file-read-error": "Ett fel uppstod vid läsning av filen.",
|
|
24
|
+
"product-import.notifications.invalid-json": "Fältet {col} på rad {row} innehåller felaktig JSON-struktur.",
|
|
25
|
+
"product-import.notifications.missing-languages": "Din CSV-fil innehåller språk som inte är tillgängliga i den här kanalen. Vänligen lägg till språken i kanalen eller ta bort de språk som inte används och försök igen.",
|
|
26
|
+
"product-import.notifications.mismatched-option-values": "Antalet produktalternativ för vissa produktvarianter är felaktigt:",
|
|
27
|
+
"product-import.notifications.missing-main-language-name": "Huvudspråket saknas för namn. Vänligen lägg till huvudspråket och försök igen.",
|
|
28
|
+
"product-import.notifications.missing-translation-values": "Det saknas översättningar för vissa fält för huvudspråket som existerar i andra språk. Dessa kommer inte importeras:",
|
|
29
|
+
"product-import.notifications.inconsistent-columns": "Kolumnerna i filen är inkonsekventa. Vänligen kontrollera att alla rader har samma antal kolumner.",
|
|
30
|
+
"product-export.action": "Exportera produkter till CSV",
|
|
31
|
+
"product-export.dialog.title": "Exportera produkter till CSV",
|
|
32
|
+
"product-export.dialog.label": "Filnamn (valfritt)",
|
|
33
|
+
"product-export.dialog.export": "Exportera",
|
|
34
|
+
"product-export.dialog.cancel": "Avbryt",
|
|
35
|
+
"product-export.dialog.customFieldsLabel": "Anpassade fält som ska inkluderas i exporten",
|
|
36
|
+
"product-export.dialog.exportAssetsAs": "Exportera filer som:",
|
|
37
|
+
"product-export.dialog.exportAssetsAsOptions.url": "URL",
|
|
38
|
+
"product-export.dialog.exportAssetsAsOptions.json": "JSON",
|
|
39
|
+
"product-export.dialog.availableExportFieldsLabel": "Fält att exportera",
|
|
40
|
+
"product-export.dialog.selectAll": "Välj alla"
|
|
41
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Shared utilities and types for product import/export plugin
|
|
2
|
+
|
|
3
|
+
export type UpdatingStrategy = 'replace' | 'merge'
|
|
4
|
+
|
|
5
|
+
export type ImportOptions = {
|
|
6
|
+
updateProductSlug?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ValidateReturnType = {
|
|
10
|
+
isValid: boolean
|
|
11
|
+
langCodes?: (string | undefined)[]
|
|
12
|
+
clearFile?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ProductFields =
|
|
16
|
+
| 'productId'
|
|
17
|
+
| 'name'
|
|
18
|
+
| 'slug'
|
|
19
|
+
| 'description'
|
|
20
|
+
| 'assets'
|
|
21
|
+
| 'facets'
|
|
22
|
+
| 'optionGroups'
|
|
23
|
+
|
|
24
|
+
export type VariantFields =
|
|
25
|
+
| 'sku'
|
|
26
|
+
| 'optionValues'
|
|
27
|
+
| 'price'
|
|
28
|
+
| 'taxCategory'
|
|
29
|
+
| 'stockOnHand'
|
|
30
|
+
| 'trackInventory'
|
|
31
|
+
| 'variantAssets'
|
|
32
|
+
| 'variantFacets'
|
|
33
|
+
| 'enabled'
|
|
34
|
+
|
|
35
|
+
export type ExportFields = Array<ProductFields | VariantFields>
|
|
36
|
+
|
|
37
|
+
export interface PluginInitOptions {
|
|
38
|
+
visibleOptions?: Array<keyof ImportOptions>
|
|
39
|
+
defaultOptions?: ImportOptions
|
|
40
|
+
exportOptions?: {
|
|
41
|
+
defaultFileName?: string
|
|
42
|
+
exportAssetsAsOptions?: Array<'url' | 'json'>
|
|
43
|
+
defaultExportAssetsAs?: 'url' | 'json'
|
|
44
|
+
defaultExportFields?: ExportFields
|
|
45
|
+
requiredExportFields?: ExportFields
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Helper function to get server location (matches Angular UI's getServerLocation)
|
|
50
|
+
export const getServerLocation = (): string => {
|
|
51
|
+
// In development with Vite, use the API server directly
|
|
52
|
+
if (window.location.port === '5173') {
|
|
53
|
+
return 'http://localhost:3000'
|
|
54
|
+
}
|
|
55
|
+
const { protocol, hostname, port } = window.location
|
|
56
|
+
const origin = port ? `${protocol}//${hostname}:${port}` : `${protocol}//${hostname}`
|
|
57
|
+
return origin
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Return vendure-token header if a non-default channel has been selected in the Dashboard
|
|
61
|
+
export const getChannelHeader = (): Record<string, string> => {
|
|
62
|
+
const token = localStorage.getItem('vendure-selected-channel-token')
|
|
63
|
+
return token ? { 'vendure-token': token } : {}
|
|
64
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"product-import-export.plugin.js","sourceRoot":"","sources":["../../../../packages/product-import-export-plugin/src/product-import-export.plugin.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,wCAAuE;AAEvE,2CAA4B;AAC5B,mCAA6B;AAE7B,2CAAkE;AAGlE,iBAAiB;AACjB,+EAAyE;AACzE,+EAAyE;AACzE,+DAA6E;AAC7E,eAAe;AACf,oFAA+E;AAC/E,8EAAyE;AACzE,gFAAmF;AACnF,cAAc;AACd,8EAAwE;AACxE,8FAAuF;AACvF,8EAAwE;
|
|
1
|
+
{"version":3,"file":"product-import-export.plugin.js","sourceRoot":"","sources":["../../../../packages/product-import-export-plugin/src/product-import-export.plugin.ts"],"names":[],"mappings":"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"}
|