@haus-tech/product-import-export-plugin 3.0.26-0 → 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 +51 -3
- package/package.json +1 -1
- package/src/api/product-export.controller.d.ts +1 -1
- package/src/api/product-export.controller.js +4 -8
- package/src/api/product-export.controller.js.map +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 +2 -0
- package/src/product-import-export.plugin.js.map +1 -1
- package/src/providers/import-providers/product-importer.d.ts +3 -1
- package/src/providers/import-providers/product-importer.js +41 -17
- package/src/providers/import-providers/product-importer.js.map +1 -1
- package/src/services/product-export.service.js +9 -8
- package/src/services/product-export.service.js.map +1 -1
- package/src/types.d.ts +6 -0
|
@@ -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
|
+
}
|