@haus-tech/product-import-export-plugin 3.3.10-next.1 → 3.3.10-next.2

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,31 +1,7 @@
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'
1
+ import { Button, Switch, Label, RadioGroup, RadioGroupItem } from '@vendure/dashboard'
2
+ import { UploadIcon } from 'lucide-react'
28
3
  import { useState, useRef, useEffect } from 'react'
4
+ import { toast } from 'sonner'
29
5
  import type { LanguageCode, Channel } from '@vendure/core'
30
6
  import { size, startsWith, endsWith, uniq } from 'lodash-es'
31
7
  import {
@@ -36,26 +12,17 @@ import {
36
12
  type PluginInitOptions,
37
13
  } from './utils'
38
14
 
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
-
15
+ // Import page component
46
16
  export function ProductImportBlock() {
47
17
  const fileInputRef = useRef<HTMLInputElement>(null)
48
18
  const [selectedFile, setSelectedFile] = useState<File | null>(null)
49
19
  const [validateFile, setValidateFile] = useState<File | null>(null)
50
- const [isDragActive, setIsDragActive] = useState(false)
51
- const [optionsOpen, setOptionsOpen] = useState(false)
52
20
  const [updateProductSlug, setUpdateProductSlug] = useState(true)
53
21
  const [selectedMainLanguage, setSelectedMainLanguage] = useState<LanguageCode | undefined>()
54
22
  const [availableLanguages, setAvailableLanguages] = useState<LanguageCode[]>([])
55
23
  const [updatingStrategy, setUpdatingStrategy] = useState<UpdatingStrategy>('merge')
56
24
  const [config, setConfig] = useState<PluginInitOptions | null>(null)
57
25
  const [currentChannel, setCurrentChannel] = useState<Channel | null>(null)
58
- const [isUploading, setIsUploading] = useState(false)
59
26
 
60
27
  useEffect(() => {
61
28
  const fetchConfig = async () => {
@@ -108,6 +75,7 @@ export function ProductImportBlock() {
108
75
  endChar: string
109
76
  isJson?: boolean
110
77
  } => {
78
+ // Replace starting and ending double quotes if exists
111
79
  const trimmedText = text.trim().replace(/^"/, '').replace(/"$/, '')
112
80
 
113
81
  return {
@@ -150,6 +118,7 @@ export function ProductImportBlock() {
150
118
  }
151
119
  for (const lang of languageCodes) {
152
120
  if (lang) {
121
+ // Check if lang is defined
153
122
  const langSpecificColumn = `${baseColumn}:${lang}`
154
123
  const langColIndex = header.indexOf(langSpecificColumn)
155
124
  const colValue = row[langColIndex]
@@ -204,6 +173,7 @@ export function ProductImportBlock() {
204
173
 
205
174
  const header = splitLines(lines[0]).map((col) => col.trim())
206
175
 
176
+ // Fetch channel if not already loaded (matches Angular implementation)
207
177
  let channel = currentChannel
208
178
  if (!channel) {
209
179
  try {
@@ -217,6 +187,7 @@ export function ProductImportBlock() {
217
187
  credentials: 'include',
218
188
  })
219
189
  channel = await channelRes.json()
190
+ // Update state for next time
220
191
  setCurrentChannel(channel)
221
192
  } catch (error) {
222
193
  console.error('Failed to fetch channel:', error)
@@ -244,6 +215,7 @@ export function ProductImportBlock() {
244
215
  }
245
216
 
246
217
  if (!selectedMainLanguage && languageCodes.length === 0) {
218
+ // No language codes in CSV, return channel languages so user can select
247
219
  resolve({
248
220
  isValid: true,
249
221
  langCodes: availableLanguages.map((lang) => lang) as (string | undefined)[],
@@ -280,6 +252,7 @@ export function ProductImportBlock() {
280
252
 
281
253
  const nameColumn = validationLanguage ? `name:${validationLanguage}` : 'name'
282
254
 
255
+ // Get optionGroups and optionValues columns for the main language
283
256
  const optionGroupsColumn = validationLanguage
284
257
  ? `optionGroups:${validationLanguage}`
285
258
  : 'optionGroups'
@@ -290,13 +263,17 @@ export function ProductImportBlock() {
290
263
  const optionGroupsIndex = header.indexOf(optionGroupsColumn)
291
264
  const optionValuesIndex = header.indexOf(optionValuesColumn)
292
265
 
266
+ // Loop through each row to validate the option values
293
267
  let currentOptionGroupCount = 0
294
268
 
295
269
  for (let i = 1; i < lines.length; i++) {
296
270
  const row = splitLines(lines[i]).map((col) => col.trim())
271
+
272
+ // Check if it's a product row (it has a 'name' field)
297
273
  const isProductRow = row[header.indexOf(nameColumn)]
298
274
 
299
275
  if (isProductRow) {
276
+ // It's a new product row, get the option groups
300
277
  const optionGroups = (row[optionGroupsIndex]?.split('|') ?? []).filter(
301
278
  (group) => group !== '',
302
279
  )
@@ -305,6 +282,7 @@ export function ProductImportBlock() {
305
282
  )
306
283
  currentOptionGroupCount = optionGroups.length
307
284
 
285
+ // Validate that the number of option values matches the number of option groups
308
286
  if (optionValues.length !== currentOptionGroupCount) {
309
287
  toast.error(
310
288
  `Some product variants are missing product options. Please fill in all options: Row: ${i + 1}. Expected ${currentOptionGroupCount} option values but got ${optionValues.length}.`,
@@ -313,10 +291,12 @@ export function ProductImportBlock() {
313
291
  return
314
292
  }
315
293
  } else {
294
+ // It's a variant row, validate the option values
316
295
  const optionValues = (row[optionValuesIndex]?.split('|') ?? []).filter(
317
296
  (value) => value !== '',
318
297
  )
319
298
 
299
+ // Validate that the number of option values matches the product's option groups
320
300
  if (optionValues.length !== currentOptionGroupCount) {
321
301
  toast.error(
322
302
  `Some product variants are missing product options. Please fill in all options: Row: ${i + 1}. Expected ${currentOptionGroupCount} option values but got ${optionValues.length}.`,
@@ -327,6 +307,7 @@ export function ProductImportBlock() {
327
307
  }
328
308
  }
329
309
 
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.
330
311
  for (let i = 1; i < lines.length; i++) {
331
312
  const row = splitLines(lines[i]).map((col) => col.trim())
332
313
  for (const col of header) {
@@ -357,6 +338,7 @@ export function ProductImportBlock() {
357
338
  }
358
339
  }
359
340
 
341
+ // Check for missing translation values in translatable base columns
360
342
  const { errorRows: missingBaseTranslatableValuesRows } = checkTranslatabeColumns(
361
343
  lines,
362
344
  translatableBaseColumns,
@@ -373,6 +355,7 @@ export function ProductImportBlock() {
373
355
  return
374
356
  }
375
357
 
358
+ // Check if all translatable columns have values for the main language
376
359
  const { errorRows, errorColumns } = checkTranslatabeColumns(
377
360
  lines,
378
361
  translatableColumns,
@@ -404,15 +387,15 @@ export function ProductImportBlock() {
404
387
  const { isValid, langCodes, clearFile = true } = await validateCsvStructure(file)
405
388
  if (isValid) {
406
389
  setSelectedFile(file)
407
- setOptionsOpen(true)
408
390
  } else if (clearFile) {
409
391
  clearFileHandler()
410
392
  return
411
393
  } else {
394
+ // File is valid but needs language selection
412
395
  setSelectedFile(file)
413
- setOptionsOpen(true)
414
396
  }
415
397
 
398
+ // Set available languages - either from CSV or from channel
416
399
  if (langCodes && langCodes.length > 0) {
417
400
  const filteredLangCodes = langCodes.filter((code) => code) as LanguageCode[]
418
401
  setAvailableLanguages(filteredLangCodes)
@@ -423,6 +406,7 @@ export function ProductImportBlock() {
423
406
  currentChannel?.availableLanguageCodes &&
424
407
  currentChannel.availableLanguageCodes.length > 0
425
408
  ) {
409
+ // No languages in CSV headers, use channel languages as fallback
426
410
  setAvailableLanguages(currentChannel.availableLanguageCodes)
427
411
  if (!selectedMainLanguage) {
428
412
  setSelectedMainLanguage(
@@ -434,35 +418,19 @@ export function ProductImportBlock() {
434
418
  }
435
419
  }
436
420
 
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
-
454
421
  const onFileSelected = (event: React.ChangeEvent<HTMLInputElement>) => {
455
422
  const input = event.target
456
423
  if (input.files && input.files.length > 0) {
457
- handleFile(input.files[0])
458
- }
459
- }
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
+ }
460
430
 
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)
431
+ setValidateFile(file)
432
+ doValidation(file)
433
+ }
466
434
  }
467
435
 
468
436
  const clearFileHandler = () => {
@@ -473,7 +441,6 @@ export function ProductImportBlock() {
473
441
  setValidateFile(null)
474
442
  setSelectedMainLanguage(undefined)
475
443
  setUpdatingStrategy('merge')
476
- setOptionsOpen(false)
477
444
  }
478
445
 
479
446
  const onMainLanguageChange = (lang: LanguageCode) => {
@@ -484,214 +451,137 @@ export function ProductImportBlock() {
484
451
  }
485
452
 
486
453
  const uploadFile = async () => {
487
- if (!selectedFile || !selectedMainLanguage || !updatingStrategy) {
488
- toast.error('No file selected or main language not selected.')
489
- return
490
- }
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)
491
460
 
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 {
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)
514
480
  toast.error('An error occurred while uploading the file.')
515
481
  }
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)
482
+ } else {
483
+ toast.error('No file selected or main language not selected.')
521
484
  }
522
485
  }
523
486
 
524
487
  return (
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>
583
- </div>
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>
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
+ ))}
597
544
  </div>
598
- )}
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>
599
550
  </div>
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>
617
- </div>
618
- )}
619
551
 
620
- {config?.visibleOptions?.includes('updateProductSlug') && (
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>
563
+ </div>
621
564
  <div className="flex items-center space-x-2">
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>
565
+ <RadioGroupItem value="replace" id="replace" />
566
+ <Label htmlFor="replace" className="font-normal">
567
+ Overwrite
568
+ </Label>
628
569
  </div>
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.
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.
650
574
  </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>
575
+ )}
679
576
  </div>
680
577
 
681
- <DialogFooter>
682
- <Button type="button" variant="outline" onClick={clearFileHandler} disabled={isUploading}>
683
- Cancel
578
+ <div className="pt-2 border-t">
579
+ <Button type="button" onClick={uploadFile} disabled={!selectedMainLanguage}>
580
+ Start import now
684
581
  </Button>
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
- </>
582
+ </div>
583
+ </div>
584
+ )}
585
+ </div>
696
586
  )
697
587
  }
@@ -3,9 +3,9 @@ import {
3
3
  DashboardRouteDefinition,
4
4
  PageTitle,
5
5
  Page,
6
- Button,
6
+ PageLayout,
7
+ PageBlock,
7
8
  } 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,40 +16,41 @@ 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 products',
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
- <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"
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"
39
34
  >
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">
48
35
  <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
+ >
49
43
  <ProductExportBlock />
50
- </div>
51
- <ExportedList />
52
- </div>
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>
53
54
  </Page>
54
55
  ),
55
56
  }