@haus-tech/product-import-export-plugin 3.3.2-next.2 → 3.6.0-next.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.
@@ -1,7 +1,31 @@
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'
7
31
  import {
@@ -12,17 +36,26 @@ import {
12
36
  type PluginInitOptions,
13
37
  } from './utils'
14
38
 
15
- // Import page component
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
- 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)
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 && 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)
487
+ if (!selectedFile || !selectedMainLanguage || !updatingStrategy) {
488
+ toast.error('No file selected or main language not selected.')
489
+ return
490
+ }
460
491
 
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)
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
- } else {
483
- toast.error('No file selected or main language not selected.')
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
- <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
- ))}
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
- </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>
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 &amp; 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 &nbsp;•&nbsp; Max file size: 20MB
596
+ </p>
597
+ </div>
598
+ )}
550
599
  </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>
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
- <RadioGroupItem value="replace" id="replace" />
566
- <Label htmlFor="replace" className="font-normal">
567
- Overwrite
568
- </Label>
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
- <div className="pt-2 border-t">
579
- <Button type="button" onClick={uploadFile} disabled={!selectedMainLanguage}>
580
- Start import now
681
+ <DialogFooter>
682
+ <Button type="button" variant="outline" onClick={clearFileHandler} disabled={isUploading}>
683
+ Cancel
581
684
  </Button>
582
- </div>
583
- </div>
584
- )}
585
- </div>
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
  }
@@ -3,9 +3,9 @@ import {
3
3
  DashboardRouteDefinition,
4
4
  PageTitle,
5
5
  Page,
6
- PageLayout,
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 products',
19
+ title: 'Import/export',
20
20
  },
21
21
  path: '/product-importer',
22
22
  loader: () => ({
23
- breadcrumb: 'Import/export products',
23
+ breadcrumb: 'Import & export products',
24
24
  }),
25
25
  component: () => (
26
26
  <Page pageId="product-import">
27
- <PageTitle>Import/export products</PageTitle>
28
- <PageLayout>
29
- <PageBlock
30
- column="main"
31
- blockId="main-form-import"
32
- title="Import products"
33
- description="Import products from a CSV file"
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
- </PageBlock>
45
- <PageBlock
46
- column="main"
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
  }