@haus-tech/product-import-export-plugin 3.3.2-next.2 → 3.3.2-next.3
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 +6 -0
- package/README.mdx +56 -30
- package/package.json +5 -5
- package/src/dashboard/bulk-export.tsx +1 -0
- package/src/dashboard/export-configuration-panel.tsx +418 -0
- package/src/dashboard/export-dialog.tsx +22 -252
- package/src/dashboard/export.tsx +52 -17
- package/src/dashboard/exported-list.tsx +168 -94
- package/src/dashboard/import.tsx +258 -148
- package/src/dashboard/index.tsx +27 -28
- package/src/providers/import-providers/asset-importer.js +27 -8
- package/src/providers/import-providers/asset-importer.js.map +1 -1
- package/src/providers/import-providers/import-parser.d.ts +1 -0
- package/src/providers/import-providers/import-parser.js +18 -2
- package/src/providers/import-providers/import-parser.js.map +1 -1
- package/src/providers/import-providers/product-importer.d.ts +10 -0
- package/src/providers/import-providers/product-importer.js +114 -21
- package/src/providers/import-providers/product-importer.js.map +1 -1
- package/src/services/extended-fast-importer.service.js +20 -12
- package/src/services/extended-fast-importer.service.js.map +1 -1
- package/src/services/product-export.service.d.ts +1 -0
- package/src/services/product-export.service.js +11 -1
- package/src/services/product-export.service.js.map +1 -1
package/src/dashboard/import.tsx
CHANGED
|
@@ -1,7 +1,31 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
Card,
|
|
4
|
+
CardContent,
|
|
5
|
+
CardDescription,
|
|
6
|
+
CardHeader,
|
|
7
|
+
CardTitle,
|
|
8
|
+
Switch,
|
|
9
|
+
Label,
|
|
10
|
+
RadioGroup,
|
|
11
|
+
RadioGroupItem,
|
|
12
|
+
Dialog,
|
|
13
|
+
DialogContent,
|
|
14
|
+
DialogDescription,
|
|
15
|
+
DialogFooter,
|
|
16
|
+
DialogHeader,
|
|
17
|
+
DialogTitle,
|
|
18
|
+
toast,
|
|
19
|
+
} from '@vendure/dashboard'
|
|
20
|
+
import {
|
|
21
|
+
UploadIcon,
|
|
22
|
+
UploadCloudIcon,
|
|
23
|
+
FileTextIcon,
|
|
24
|
+
XIcon,
|
|
25
|
+
DownloadIcon,
|
|
26
|
+
ExternalLinkIcon,
|
|
27
|
+
} from 'lucide-react'
|
|
3
28
|
import { useState, useRef, useEffect } from 'react'
|
|
4
|
-
import { toast } from 'sonner'
|
|
5
29
|
import type { LanguageCode, Channel } from '@vendure/core'
|
|
6
30
|
import { size, startsWith, endsWith, uniq } from 'lodash-es'
|
|
7
31
|
import {
|
|
@@ -12,17 +36,26 @@ import {
|
|
|
12
36
|
type PluginInitOptions,
|
|
13
37
|
} from './utils'
|
|
14
38
|
|
|
15
|
-
|
|
39
|
+
const TEMPLATE_URL =
|
|
40
|
+
'https://github.com/WeAreHausTech/haus-tech-vendure-plugins-public/blob/main/packages/product-import-export-plugin/README.mdx#csv-format-for-import'
|
|
41
|
+
const GUIDE_URL =
|
|
42
|
+
'https://github.com/WeAreHausTech/haus-tech-vendure-plugins-public/blob/main/packages/product-import-export-plugin/README.mdx'
|
|
43
|
+
|
|
44
|
+
const MAX_SIZE_BYTES = 20 * 1024 * 1024
|
|
45
|
+
|
|
16
46
|
export function ProductImportBlock() {
|
|
17
47
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
18
48
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
|
19
49
|
const [validateFile, setValidateFile] = useState<File | null>(null)
|
|
50
|
+
const [isDragActive, setIsDragActive] = useState(false)
|
|
51
|
+
const [optionsOpen, setOptionsOpen] = useState(false)
|
|
20
52
|
const [updateProductSlug, setUpdateProductSlug] = useState(true)
|
|
21
53
|
const [selectedMainLanguage, setSelectedMainLanguage] = useState<LanguageCode | undefined>()
|
|
22
54
|
const [availableLanguages, setAvailableLanguages] = useState<LanguageCode[]>([])
|
|
23
55
|
const [updatingStrategy, setUpdatingStrategy] = useState<UpdatingStrategy>('merge')
|
|
24
56
|
const [config, setConfig] = useState<PluginInitOptions | null>(null)
|
|
25
57
|
const [currentChannel, setCurrentChannel] = useState<Channel | null>(null)
|
|
58
|
+
const [isUploading, setIsUploading] = useState(false)
|
|
26
59
|
|
|
27
60
|
useEffect(() => {
|
|
28
61
|
const fetchConfig = async () => {
|
|
@@ -75,7 +108,6 @@ export function ProductImportBlock() {
|
|
|
75
108
|
endChar: string
|
|
76
109
|
isJson?: boolean
|
|
77
110
|
} => {
|
|
78
|
-
// Replace starting and ending double quotes if exists
|
|
79
111
|
const trimmedText = text.trim().replace(/^"/, '').replace(/"$/, '')
|
|
80
112
|
|
|
81
113
|
return {
|
|
@@ -118,7 +150,6 @@ export function ProductImportBlock() {
|
|
|
118
150
|
}
|
|
119
151
|
for (const lang of languageCodes) {
|
|
120
152
|
if (lang) {
|
|
121
|
-
// Check if lang is defined
|
|
122
153
|
const langSpecificColumn = `${baseColumn}:${lang}`
|
|
123
154
|
const langColIndex = header.indexOf(langSpecificColumn)
|
|
124
155
|
const colValue = row[langColIndex]
|
|
@@ -173,7 +204,6 @@ export function ProductImportBlock() {
|
|
|
173
204
|
|
|
174
205
|
const header = splitLines(lines[0]).map((col) => col.trim())
|
|
175
206
|
|
|
176
|
-
// Fetch channel if not already loaded (matches Angular implementation)
|
|
177
207
|
let channel = currentChannel
|
|
178
208
|
if (!channel) {
|
|
179
209
|
try {
|
|
@@ -187,7 +217,6 @@ export function ProductImportBlock() {
|
|
|
187
217
|
credentials: 'include',
|
|
188
218
|
})
|
|
189
219
|
channel = await channelRes.json()
|
|
190
|
-
// Update state for next time
|
|
191
220
|
setCurrentChannel(channel)
|
|
192
221
|
} catch (error) {
|
|
193
222
|
console.error('Failed to fetch channel:', error)
|
|
@@ -215,7 +244,6 @@ export function ProductImportBlock() {
|
|
|
215
244
|
}
|
|
216
245
|
|
|
217
246
|
if (!selectedMainLanguage && languageCodes.length === 0) {
|
|
218
|
-
// No language codes in CSV, return channel languages so user can select
|
|
219
247
|
resolve({
|
|
220
248
|
isValid: true,
|
|
221
249
|
langCodes: availableLanguages.map((lang) => lang) as (string | undefined)[],
|
|
@@ -252,7 +280,6 @@ export function ProductImportBlock() {
|
|
|
252
280
|
|
|
253
281
|
const nameColumn = validationLanguage ? `name:${validationLanguage}` : 'name'
|
|
254
282
|
|
|
255
|
-
// Get optionGroups and optionValues columns for the main language
|
|
256
283
|
const optionGroupsColumn = validationLanguage
|
|
257
284
|
? `optionGroups:${validationLanguage}`
|
|
258
285
|
: 'optionGroups'
|
|
@@ -263,17 +290,13 @@ export function ProductImportBlock() {
|
|
|
263
290
|
const optionGroupsIndex = header.indexOf(optionGroupsColumn)
|
|
264
291
|
const optionValuesIndex = header.indexOf(optionValuesColumn)
|
|
265
292
|
|
|
266
|
-
// Loop through each row to validate the option values
|
|
267
293
|
let currentOptionGroupCount = 0
|
|
268
294
|
|
|
269
295
|
for (let i = 1; i < lines.length; i++) {
|
|
270
296
|
const row = splitLines(lines[i]).map((col) => col.trim())
|
|
271
|
-
|
|
272
|
-
// Check if it's a product row (it has a 'name' field)
|
|
273
297
|
const isProductRow = row[header.indexOf(nameColumn)]
|
|
274
298
|
|
|
275
299
|
if (isProductRow) {
|
|
276
|
-
// It's a new product row, get the option groups
|
|
277
300
|
const optionGroups = (row[optionGroupsIndex]?.split('|') ?? []).filter(
|
|
278
301
|
(group) => group !== '',
|
|
279
302
|
)
|
|
@@ -282,7 +305,6 @@ export function ProductImportBlock() {
|
|
|
282
305
|
)
|
|
283
306
|
currentOptionGroupCount = optionGroups.length
|
|
284
307
|
|
|
285
|
-
// Validate that the number of option values matches the number of option groups
|
|
286
308
|
if (optionValues.length !== currentOptionGroupCount) {
|
|
287
309
|
toast.error(
|
|
288
310
|
`Some product variants are missing product options. Please fill in all options: Row: ${i + 1}. Expected ${currentOptionGroupCount} option values but got ${optionValues.length}.`,
|
|
@@ -291,12 +313,10 @@ export function ProductImportBlock() {
|
|
|
291
313
|
return
|
|
292
314
|
}
|
|
293
315
|
} else {
|
|
294
|
-
// It's a variant row, validate the option values
|
|
295
316
|
const optionValues = (row[optionValuesIndex]?.split('|') ?? []).filter(
|
|
296
317
|
(value) => value !== '',
|
|
297
318
|
)
|
|
298
319
|
|
|
299
|
-
// Validate that the number of option values matches the product's option groups
|
|
300
320
|
if (optionValues.length !== currentOptionGroupCount) {
|
|
301
321
|
toast.error(
|
|
302
322
|
`Some product variants are missing product options. Please fill in all options: Row: ${i + 1}. Expected ${currentOptionGroupCount} option values but got ${optionValues.length}.`,
|
|
@@ -307,7 +327,6 @@ export function ProductImportBlock() {
|
|
|
307
327
|
}
|
|
308
328
|
}
|
|
309
329
|
|
|
310
|
-
// Check if a row contains JSON, if so check if it is valid. This should be done for all columns, not just translatable ones.
|
|
311
330
|
for (let i = 1; i < lines.length; i++) {
|
|
312
331
|
const row = splitLines(lines[i]).map((col) => col.trim())
|
|
313
332
|
for (const col of header) {
|
|
@@ -338,7 +357,6 @@ export function ProductImportBlock() {
|
|
|
338
357
|
}
|
|
339
358
|
}
|
|
340
359
|
|
|
341
|
-
// Check for missing translation values in translatable base columns
|
|
342
360
|
const { errorRows: missingBaseTranslatableValuesRows } = checkTranslatabeColumns(
|
|
343
361
|
lines,
|
|
344
362
|
translatableBaseColumns,
|
|
@@ -355,7 +373,6 @@ export function ProductImportBlock() {
|
|
|
355
373
|
return
|
|
356
374
|
}
|
|
357
375
|
|
|
358
|
-
// Check if all translatable columns have values for the main language
|
|
359
376
|
const { errorRows, errorColumns } = checkTranslatabeColumns(
|
|
360
377
|
lines,
|
|
361
378
|
translatableColumns,
|
|
@@ -387,15 +404,15 @@ export function ProductImportBlock() {
|
|
|
387
404
|
const { isValid, langCodes, clearFile = true } = await validateCsvStructure(file)
|
|
388
405
|
if (isValid) {
|
|
389
406
|
setSelectedFile(file)
|
|
407
|
+
setOptionsOpen(true)
|
|
390
408
|
} else if (clearFile) {
|
|
391
409
|
clearFileHandler()
|
|
392
410
|
return
|
|
393
411
|
} else {
|
|
394
|
-
// File is valid but needs language selection
|
|
395
412
|
setSelectedFile(file)
|
|
413
|
+
setOptionsOpen(true)
|
|
396
414
|
}
|
|
397
415
|
|
|
398
|
-
// Set available languages - either from CSV or from channel
|
|
399
416
|
if (langCodes && langCodes.length > 0) {
|
|
400
417
|
const filteredLangCodes = langCodes.filter((code) => code) as LanguageCode[]
|
|
401
418
|
setAvailableLanguages(filteredLangCodes)
|
|
@@ -406,7 +423,6 @@ export function ProductImportBlock() {
|
|
|
406
423
|
currentChannel?.availableLanguageCodes &&
|
|
407
424
|
currentChannel.availableLanguageCodes.length > 0
|
|
408
425
|
) {
|
|
409
|
-
// No languages in CSV headers, use channel languages as fallback
|
|
410
426
|
setAvailableLanguages(currentChannel.availableLanguageCodes)
|
|
411
427
|
if (!selectedMainLanguage) {
|
|
412
428
|
setSelectedMainLanguage(
|
|
@@ -418,21 +434,37 @@ export function ProductImportBlock() {
|
|
|
418
434
|
}
|
|
419
435
|
}
|
|
420
436
|
|
|
437
|
+
const handleFile = (file: File) => {
|
|
438
|
+
if (file.type !== 'text/csv' && !file.name.toLowerCase().endsWith('.csv')) {
|
|
439
|
+
toast.error('Invalid file type. Please upload a CSV file.')
|
|
440
|
+
clearFileHandler()
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (file.size > MAX_SIZE_BYTES) {
|
|
445
|
+
toast.error('File is larger than 20MB.')
|
|
446
|
+
clearFileHandler()
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
setValidateFile(file)
|
|
451
|
+
doValidation(file)
|
|
452
|
+
}
|
|
453
|
+
|
|
421
454
|
const onFileSelected = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
422
455
|
const input = event.target
|
|
423
456
|
if (input.files && input.files.length > 0) {
|
|
424
|
-
|
|
425
|
-
if (file.type !== 'text/csv') {
|
|
426
|
-
toast.error('Invalid file type. Please upload a CSV file.')
|
|
427
|
-
clearFileHandler()
|
|
428
|
-
return
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
setValidateFile(file)
|
|
432
|
-
doValidation(file)
|
|
457
|
+
handleFile(input.files[0])
|
|
433
458
|
}
|
|
434
459
|
}
|
|
435
460
|
|
|
461
|
+
const onDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
|
462
|
+
event.preventDefault()
|
|
463
|
+
setIsDragActive(false)
|
|
464
|
+
const file = event.dataTransfer.files?.[0]
|
|
465
|
+
if (file) handleFile(file)
|
|
466
|
+
}
|
|
467
|
+
|
|
436
468
|
const clearFileHandler = () => {
|
|
437
469
|
if (fileInputRef.current) {
|
|
438
470
|
fileInputRef.current.value = ''
|
|
@@ -441,6 +473,7 @@ export function ProductImportBlock() {
|
|
|
441
473
|
setValidateFile(null)
|
|
442
474
|
setSelectedMainLanguage(undefined)
|
|
443
475
|
setUpdatingStrategy('merge')
|
|
476
|
+
setOptionsOpen(false)
|
|
444
477
|
}
|
|
445
478
|
|
|
446
479
|
const onMainLanguageChange = (lang: LanguageCode) => {
|
|
@@ -451,137 +484,214 @@ export function ProductImportBlock() {
|
|
|
451
484
|
}
|
|
452
485
|
|
|
453
486
|
const uploadFile = async () => {
|
|
454
|
-
if (selectedFile
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
formData.append('mainLanguage', selectedMainLanguage)
|
|
459
|
-
formData.append('updatingStrategy', updatingStrategy)
|
|
487
|
+
if (!selectedFile || !selectedMainLanguage || !updatingStrategy) {
|
|
488
|
+
toast.error('No file selected or main language not selected.')
|
|
489
|
+
return
|
|
490
|
+
}
|
|
460
491
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
492
|
+
setIsUploading(true)
|
|
493
|
+
const formData = new FormData()
|
|
494
|
+
formData.append('file', selectedFile)
|
|
495
|
+
formData.append('updateProductSlug', updateProductSlug.toString())
|
|
496
|
+
formData.append('mainLanguage', selectedMainLanguage)
|
|
497
|
+
formData.append('updatingStrategy', updatingStrategy)
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
const serverPath = getServerLocation()
|
|
501
|
+
const res = await fetch(`${serverPath}/product-import/upload`, {
|
|
502
|
+
method: 'POST',
|
|
503
|
+
body: formData,
|
|
504
|
+
headers: {
|
|
505
|
+
...getChannelHeader(),
|
|
506
|
+
},
|
|
507
|
+
credentials: 'include',
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
if (res.ok) {
|
|
511
|
+
clearFileHandler()
|
|
512
|
+
toast.success('The file was uploaded and has been added to the import queue.')
|
|
513
|
+
} else {
|
|
480
514
|
toast.error('An error occurred while uploading the file.')
|
|
481
515
|
}
|
|
482
|
-
}
|
|
483
|
-
|
|
516
|
+
} catch (error: unknown) {
|
|
517
|
+
console.error('Upload error:', error)
|
|
518
|
+
toast.error('An error occurred while uploading the file.')
|
|
519
|
+
} finally {
|
|
520
|
+
setIsUploading(false)
|
|
484
521
|
}
|
|
485
522
|
}
|
|
486
523
|
|
|
487
524
|
return (
|
|
488
|
-
|
|
489
|
-
<
|
|
490
|
-
<
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
525
|
+
<>
|
|
526
|
+
<Card className="h-full">
|
|
527
|
+
<CardHeader className="flex flex-row items-start gap-3 space-y-0 items-center">
|
|
528
|
+
<div className="bg-primary/10 text-primary rounded-md p-2 flex h-9 w-9 items-center justify-center">
|
|
529
|
+
<UploadIcon className="h-5 w-5" />
|
|
530
|
+
</div>
|
|
531
|
+
<div className="space-y-1">
|
|
532
|
+
<CardTitle className="mb-0">Import products</CardTitle>
|
|
533
|
+
<CardDescription className="text-xs">Upload a CSV file to create or update products.</CardDescription>
|
|
534
|
+
</div>
|
|
535
|
+
</CardHeader>
|
|
536
|
+
<CardContent className="space-y-4">
|
|
537
|
+
<div
|
|
538
|
+
onDragOver={(e) => {
|
|
539
|
+
e.preventDefault()
|
|
540
|
+
setIsDragActive(true)
|
|
541
|
+
}}
|
|
542
|
+
onDragLeave={() => setIsDragActive(false)}
|
|
543
|
+
onDrop={onDrop}
|
|
544
|
+
onClick={() => fileInputRef.current?.click()}
|
|
545
|
+
role="button"
|
|
546
|
+
tabIndex={0}
|
|
547
|
+
onKeyDown={(e) => {
|
|
548
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
549
|
+
fileInputRef.current?.click()
|
|
550
|
+
}
|
|
551
|
+
}}
|
|
552
|
+
className={`border-2 border-dashed rounded-lg px-6 py-10 text-center cursor-pointer transition-colors ${isDragActive
|
|
553
|
+
? 'border-primary bg-primary/5'
|
|
554
|
+
: 'border-border hover:border-primary/40 hover:bg-muted/40'
|
|
555
|
+
}`}
|
|
556
|
+
>
|
|
557
|
+
<input
|
|
558
|
+
ref={fileInputRef}
|
|
559
|
+
type="file"
|
|
560
|
+
id="file-upload"
|
|
561
|
+
accept=".csv"
|
|
562
|
+
onChange={onFileSelected}
|
|
563
|
+
className="hidden"
|
|
564
|
+
/>
|
|
565
|
+
{selectedFile ? (
|
|
566
|
+
<div className="flex flex-col items-center gap-2">
|
|
567
|
+
<FileTextIcon className="h-8 w-8 text-primary" />
|
|
568
|
+
<p className="text-sm font-medium">{selectedFile.name}</p>
|
|
569
|
+
<p className="text-xs text-muted-foreground">
|
|
570
|
+
{(selectedFile.size / 1024).toFixed(1)} KB
|
|
571
|
+
</p>
|
|
572
|
+
<Button
|
|
573
|
+
type="button"
|
|
574
|
+
variant="ghost"
|
|
575
|
+
size="sm"
|
|
576
|
+
onClick={(e) => {
|
|
577
|
+
e.stopPropagation()
|
|
578
|
+
clearFileHandler()
|
|
579
|
+
}}
|
|
580
|
+
>
|
|
581
|
+
<XIcon className="mr-1 h-3 w-3" /> Remove
|
|
582
|
+
</Button>
|
|
544
583
|
</div>
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
584
|
+
) : (
|
|
585
|
+
<div className="flex flex-col items-center gap-3">
|
|
586
|
+
<UploadCloudIcon className="h-10 w-10 text-muted-foreground" />
|
|
587
|
+
<div className="space-y-1">
|
|
588
|
+
<p className="text-sm font-medium">Drag & drop your CSV file here</p>
|
|
589
|
+
<p className="text-xs text-muted-foreground">or</p>
|
|
590
|
+
</div>
|
|
591
|
+
<Button type="button" variant="outline" size="sm">
|
|
592
|
+
Choose file
|
|
593
|
+
</Button>
|
|
594
|
+
<p className="text-xs text-muted-foreground">
|
|
595
|
+
CSV UTF-8 only • Max file size: 20MB
|
|
596
|
+
</p>
|
|
597
|
+
</div>
|
|
598
|
+
)}
|
|
550
599
|
</div>
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
>
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
600
|
+
</CardContent>
|
|
601
|
+
</Card>
|
|
602
|
+
|
|
603
|
+
<Dialog open={optionsOpen} onOpenChange={(open) => (open ? setOptionsOpen(true) : clearFileHandler())}>
|
|
604
|
+
<DialogContent className="max-w-lg">
|
|
605
|
+
<DialogHeader>
|
|
606
|
+
<DialogTitle>Import options</DialogTitle>
|
|
607
|
+
<DialogDescription>
|
|
608
|
+
Configure how this CSV should be imported into your catalog.
|
|
609
|
+
</DialogDescription>
|
|
610
|
+
</DialogHeader>
|
|
611
|
+
|
|
612
|
+
<div className="space-y-4">
|
|
613
|
+
{selectedFile && (
|
|
614
|
+
<div className="bg-muted/40 rounded-md px-3 py-2 text-sm flex items-center gap-2">
|
|
615
|
+
<FileTextIcon className="h-4 w-4 text-primary" />
|
|
616
|
+
<span className="font-medium truncate">{selectedFile.name}</span>
|
|
563
617
|
</div>
|
|
618
|
+
)}
|
|
619
|
+
|
|
620
|
+
{config?.visibleOptions?.includes('updateProductSlug') && (
|
|
564
621
|
<div className="flex items-center space-x-2">
|
|
565
|
-
<
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
622
|
+
<Switch
|
|
623
|
+
id="update-slug"
|
|
624
|
+
checked={updateProductSlug}
|
|
625
|
+
onCheckedChange={setUpdateProductSlug}
|
|
626
|
+
/>
|
|
627
|
+
<Label htmlFor="update-slug">Update product slugs based on name</Label>
|
|
569
628
|
</div>
|
|
570
|
-
</RadioGroup>
|
|
571
|
-
{updatingStrategy === 'replace' && (
|
|
572
|
-
<p className="text-sm text-red-600 mt-2">
|
|
573
|
-
Warning: This will remove existing facets and assets from products and variants.
|
|
574
|
-
</p>
|
|
575
629
|
)}
|
|
630
|
+
|
|
631
|
+
<div>
|
|
632
|
+
<Label>Main language</Label>
|
|
633
|
+
<RadioGroup
|
|
634
|
+
value={selectedMainLanguage || ''}
|
|
635
|
+
onValueChange={(val) => onMainLanguageChange(val as LanguageCode)}
|
|
636
|
+
>
|
|
637
|
+
<div className="flex flex-wrap gap-3 mt-2">
|
|
638
|
+
{availableLanguages.map((lang) => (
|
|
639
|
+
<div key={lang} className="flex items-center space-x-2">
|
|
640
|
+
<RadioGroupItem value={lang} id={`lang-${lang}`} />
|
|
641
|
+
<Label htmlFor={`lang-${lang}`} className="font-normal uppercase">
|
|
642
|
+
{lang}
|
|
643
|
+
</Label>
|
|
644
|
+
</div>
|
|
645
|
+
))}
|
|
646
|
+
</div>
|
|
647
|
+
</RadioGroup>
|
|
648
|
+
<p className="text-xs text-muted-foreground mt-2">
|
|
649
|
+
Used as a fallback when a translation is missing.
|
|
650
|
+
</p>
|
|
651
|
+
</div>
|
|
652
|
+
|
|
653
|
+
<div>
|
|
654
|
+
<Label>Strategy for facets and assets</Label>
|
|
655
|
+
<RadioGroup
|
|
656
|
+
value={updatingStrategy}
|
|
657
|
+
onValueChange={(val) => setUpdatingStrategy(val as UpdatingStrategy)}
|
|
658
|
+
className="mt-2"
|
|
659
|
+
>
|
|
660
|
+
<div className="flex items-center space-x-2">
|
|
661
|
+
<RadioGroupItem value="merge" id="merge" />
|
|
662
|
+
<Label htmlFor="merge" className="font-normal">
|
|
663
|
+
Merge
|
|
664
|
+
</Label>
|
|
665
|
+
</div>
|
|
666
|
+
<div className="flex items-center space-x-2">
|
|
667
|
+
<RadioGroupItem value="replace" id="replace" />
|
|
668
|
+
<Label htmlFor="replace" className="font-normal">
|
|
669
|
+
Overwrite
|
|
670
|
+
</Label>
|
|
671
|
+
</div>
|
|
672
|
+
</RadioGroup>
|
|
673
|
+
{updatingStrategy === 'replace' && (
|
|
674
|
+
<p className="text-xs text-destructive mt-2">
|
|
675
|
+
Warning: this will remove existing facets and assets from products and variants.
|
|
676
|
+
</p>
|
|
677
|
+
)}
|
|
678
|
+
</div>
|
|
576
679
|
</div>
|
|
577
680
|
|
|
578
|
-
<
|
|
579
|
-
<Button type="button" onClick={
|
|
580
|
-
|
|
681
|
+
<DialogFooter>
|
|
682
|
+
<Button type="button" variant="outline" onClick={clearFileHandler} disabled={isUploading}>
|
|
683
|
+
Cancel
|
|
581
684
|
</Button>
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
685
|
+
<Button
|
|
686
|
+
type="button"
|
|
687
|
+
onClick={uploadFile}
|
|
688
|
+
disabled={!selectedMainLanguage || isUploading}
|
|
689
|
+
>
|
|
690
|
+
{isUploading ? 'Uploading…' : 'Start import'}
|
|
691
|
+
</Button>
|
|
692
|
+
</DialogFooter>
|
|
693
|
+
</DialogContent>
|
|
694
|
+
</Dialog>
|
|
695
|
+
</>
|
|
586
696
|
)
|
|
587
697
|
}
|
package/src/dashboard/index.tsx
CHANGED
|
@@ -3,9 +3,9 @@ import {
|
|
|
3
3
|
DashboardRouteDefinition,
|
|
4
4
|
PageTitle,
|
|
5
5
|
Page,
|
|
6
|
-
|
|
7
|
-
PageBlock,
|
|
6
|
+
Button,
|
|
8
7
|
} from '@vendure/dashboard'
|
|
8
|
+
import { HelpCircleIcon } from 'lucide-react'
|
|
9
9
|
import { ExportProductsBulkAction } from './bulk-export'
|
|
10
10
|
import { ProductImportBlock } from './import'
|
|
11
11
|
import { ProductExportBlock } from './export'
|
|
@@ -16,41 +16,40 @@ export const productImportRoute: DashboardRouteDefinition = {
|
|
|
16
16
|
sectionId: 'catalog',
|
|
17
17
|
id: 'product-importer',
|
|
18
18
|
url: '/product-importer',
|
|
19
|
-
title: 'Import/export
|
|
19
|
+
title: 'Import/export',
|
|
20
20
|
},
|
|
21
21
|
path: '/product-importer',
|
|
22
22
|
loader: () => ({
|
|
23
|
-
breadcrumb: 'Import
|
|
23
|
+
breadcrumb: 'Import & export products',
|
|
24
24
|
}),
|
|
25
25
|
component: () => (
|
|
26
26
|
<Page pageId="product-import">
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
<div className="flex items-start justify-between">
|
|
28
|
+
<div>
|
|
29
|
+
<PageTitle>Import & export products</PageTitle>
|
|
30
|
+
<p className="text-sm text-muted-foreground mt-1">
|
|
31
|
+
Import products from CSV or export your entire catalog as CSV.
|
|
32
|
+
</p>
|
|
33
|
+
</div>
|
|
34
|
+
<a
|
|
35
|
+
href="https://wearehaustech.github.io/docs/vendure-plugins/product-import-export-plugin"
|
|
36
|
+
target="_blank"
|
|
37
|
+
rel="noreferrer"
|
|
38
|
+
className="hidden sm:inline-flex"
|
|
34
39
|
>
|
|
40
|
+
<Button variant="outline" size="sm">
|
|
41
|
+
<HelpCircleIcon className="mr-2 h-4 w-4" />
|
|
42
|
+
Help & guide
|
|
43
|
+
</Button>
|
|
44
|
+
</a>
|
|
45
|
+
</div>
|
|
46
|
+
<div className="space-y-6 w-full">
|
|
47
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full">
|
|
35
48
|
<ProductImportBlock />
|
|
36
|
-
</PageBlock>
|
|
37
|
-
<PageBlock
|
|
38
|
-
column="main"
|
|
39
|
-
blockId="main-form-export"
|
|
40
|
-
title="Export products"
|
|
41
|
-
description="Exports all products to a CSV file. The export is added to the job queue and will be processed in the background. When the file is ready, you will receive an email and the exported file will be available to download from the list below."
|
|
42
|
-
>
|
|
43
49
|
<ProductExportBlock />
|
|
44
|
-
</
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
blockId="main-form-exported-list"
|
|
48
|
-
title="Exported files"
|
|
49
|
-
description="Files available for download"
|
|
50
|
-
>
|
|
51
|
-
<ExportedList />
|
|
52
|
-
</PageBlock>
|
|
53
|
-
</PageLayout>
|
|
50
|
+
</div>
|
|
51
|
+
<ExportedList />
|
|
52
|
+
</div>
|
|
54
53
|
</Page>
|
|
55
54
|
),
|
|
56
55
|
}
|