@haus-tech/product-import-export-plugin 3.6.3 → 3.6.5

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