@haus-tech/product-import-export-plugin 3.3.2-next.1 → 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.
Files changed (29) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.mdx +70 -33
  3. package/package.json +5 -5
  4. package/src/dashboard/bulk-export.tsx +1 -0
  5. package/src/dashboard/export-configuration-panel.tsx +418 -0
  6. package/src/dashboard/export-dialog.tsx +22 -252
  7. package/src/dashboard/export.tsx +52 -17
  8. package/src/dashboard/exported-list.tsx +168 -94
  9. package/src/dashboard/import.tsx +258 -148
  10. package/src/dashboard/index.tsx +27 -28
  11. package/src/email-templates/product-export-complete/body.hbs +12 -0
  12. package/src/email.d.ts +15 -4
  13. package/src/email.js +108 -1
  14. package/src/email.js.map +1 -1
  15. package/src/product-import-export.plugin.js +5 -1
  16. package/src/product-import-export.plugin.js.map +1 -1
  17. package/src/providers/import-providers/asset-importer.js +27 -8
  18. package/src/providers/import-providers/asset-importer.js.map +1 -1
  19. package/src/providers/import-providers/import-parser.d.ts +1 -0
  20. package/src/providers/import-providers/import-parser.js +18 -2
  21. package/src/providers/import-providers/import-parser.js.map +1 -1
  22. package/src/providers/import-providers/product-importer.d.ts +10 -0
  23. package/src/providers/import-providers/product-importer.js +114 -21
  24. package/src/providers/import-providers/product-importer.js.map +1 -1
  25. package/src/services/extended-fast-importer.service.js +23 -13
  26. package/src/services/extended-fast-importer.service.js.map +1 -1
  27. package/src/services/product-export.service.d.ts +1 -0
  28. package/src/services/product-export.service.js +11 -1
  29. package/src/services/product-export.service.js.map +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## 3.3.2-next.2
2
+
3
+ ### 🚀 Features
4
+
5
+ - **product-import-export-plugin:** enhance email notification and import/export functionality ([c133dc5](https://github.com/WeAreHausTech/haus-tech-vendure-plugins-public/commit/c133dc5))
6
+
7
+ ## 3.3.2-next.1
8
+
9
+ ### 🚀 Features
10
+
11
+ - **product-import-export-plugin:** enforce import/export acceptance criteria and enhance documentation ([b1a93b6](https://github.com/WeAreHausTech/haus-tech-vendure-plugins-public/commit/b1a93b6))
12
+ - **product-import-export-plugin:** update pre-version command and clean up export artifacts ([1cde024](https://github.com/WeAreHausTech/haus-tech-vendure-plugins-public/commit/1cde024))
13
+
1
14
  ## 3.3.2-next.0
2
15
 
3
16
  ### 🚀 Features
package/README.mdx CHANGED
@@ -19,16 +19,16 @@ A Vendure plugin for importing and exporting product data via CSV. Supports bulk
19
19
 
20
20
  ## Features
21
21
 
22
- * **Import** – Bulk upload products from CSV with validation, custom fields, assets, facets, and variants
23
- * **Export** – Export products to CSV with selected fields; export all products or a selection
24
- * **Storage strategies** – Store exported files in S3 or locally on disk (with automatic fallback)
25
- * **UI** – Admin UI and Dashboard integration for import and export
22
+ - **Import** – Bulk upload products from CSV with validation, custom fields, assets, facets, and variants
23
+ - **Export** – Export products to CSV with selected fields; export all products or a selection
24
+ - **Storage strategies** – Store exported files in S3 or locally on disk (with automatic fallback)
25
+ - **UI** – Admin UI and Dashboard integration for import and export
26
26
 
27
27
  ![Dashboard UI](assets/Screenshot-dashboard-ui.png)
28
28
 
29
29
  ## Compatibility
30
30
 
31
- Vendure **^2.0.0** or **^3.0.0**
31
+ Vendure **^3.6.0**
32
32
 
33
33
  ## Getting started
34
34
 
@@ -65,13 +65,13 @@ export const config = {
65
65
 
66
66
  ### Import options
67
67
 
68
- | Option | Type | Default | Description |
69
- | ------------------------ | ----------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
70
- | `updateProductSlug` | `boolean` | `true` | Update product slug from the `name` field in the CSV |
71
- | `restoreSoftDeleted` | `boolean` | `true` | Restore soft-deleted products/variants found by id or SKU during import. If `false`, new products will be created instead. |
72
- | `storageStrategy` | `ImportJobStorageStrategy` | local disk | Strategy instance for queued import file storage and retrieval. |
73
- | `storageStrategyFactory` | `(injector: Injector) => ImportJobStorageStrategy` | `undefined` | Factory for DI-aware strategy creation (recommended for S3/remote storage). |
74
- | `importJobStorage` | `'local' \| 's3'` | `'local'` | Deprecated compatibility flag. Prefer `storageStrategy` / `storageStrategyFactory`. |
68
+ | Option | Type | Default | Description |
69
+ | ------------------------ | -------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------- |
70
+ | `updateProductSlug` | `boolean` | `true` | Update product slug from the `name` field in the CSV |
71
+ | `restoreSoftDeleted` | `boolean` | `true` | Restore soft-deleted products/variants found by id or SKU during import. If `false`, new products will be created instead. |
72
+ | `storageStrategy` | `ImportJobStorageStrategy` | local disk | Strategy instance for queued import file storage and retrieval. |
73
+ | `storageStrategyFactory` | `(injector: Injector) => ImportJobStorageStrategy` | `undefined` | Factory for DI-aware strategy creation (recommended for S3/remote storage). |
74
+ | `importJobStorage` | `'local' \| 's3'` | `'local'` | Deprecated compatibility flag. Prefer `storageStrategy` / `storageStrategyFactory`. |
75
75
 
76
76
  Available import storage strategies:
77
77
 
@@ -94,8 +94,8 @@ Available import storage strategies:
94
94
 
95
95
  The plugin supports configurable storage for exported files:
96
96
 
97
- * **S3** – Stores export files in an S3-compatible bucket
98
- * **Disk (default)** – Saves files locally on the server
97
+ - **S3** – Stores export files in an S3-compatible bucket
98
+ - **Disk (default)** – Saves files locally on the server
99
99
 
100
100
  ## CSV Format for Import
101
101
 
@@ -106,21 +106,21 @@ The plugin supports configurable storage for exported files:
106
106
 
107
107
  ### Optional columns
108
108
 
109
- | Column | Format | Example |
110
- | ----------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
111
- | `name`, `slug`, `description` | Text, or `name:en`, `name:sv` for translations | `My Product` |
112
- | `assets` | Pipe-separated URLs, or JSON `[{"url":"...","name":"..."}]` | `https://example.com/1.jpg\|https://example.com/2.jpg` |
113
- | `facets` | `facet:value` pairs, pipe-separated | `Brand:Acme\|Category:Electronics` |
114
- | `optionGroups` | Pipe-separated group names | `Size\|Color` |
115
- | `optionValues` | Pipe-separated values (order matches option groups) | `Large\|Blue` |
116
- | `price` | Number (in smallest currency unit) | `9999` |
117
- | `taxCategory` | Tax category name | `standard` |
118
- | `stockOnHand` | Number | `100` |
119
- | `trackInventory` | `true` or `false` | `true` |
120
- | `variantAssets` | Same format as `assets` | `https://example.com/variant.jpg` |
121
- | `variantFacets` | Same format as `facets` | `Material:Cotton` |
122
- | `enabled` | `true` or `false` | `true` |
123
- | Custom fields | Column name = custom field name; value depends on field type | Add columns for any Product or ProductVariant custom fields defined in your Vendure config |
109
+ | Column | Format | Example |
110
+ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
111
+ | `name`, `slug`, `description` | Text, or `name:en`, `name:sv` for translations | `My Product` |
112
+ | `assets` | Pipe-separated URLs, or JSON `[{"url":"...","name":"..."}]` | `https://example.com/1.jpg\|https://example.com/2.jpg` |
113
+ | `facets` | `facet:value` pairs, pipe-separated | `Brand:Acme\|Category:Electronics` |
114
+ | `optionGroups` | Pipe-separated group names. Use `name:code` to share a group across products (see [Sharing option groups](#sharing-option-groups)) | `Size\|Color` or `size:shoe-size` |
115
+ | `optionValues` | Pipe-separated values (order matches option groups) | `Large\|Blue` |
116
+ | `price` | Number (in smallest currency unit) | `9999` |
117
+ | `taxCategory` | Tax category name | `standard` |
118
+ | `stockOnHand` | Number | `100` |
119
+ | `trackInventory` | `true` or `false` | `true` |
120
+ | `variantAssets` | Same format as `assets` | `https://example.com/variant.jpg` |
121
+ | `variantFacets` | Same format as `facets` | `Material:Cotton` |
122
+ | `enabled` | `true` or `false` | `true` |
123
+ | Custom fields | Column name = custom field name; value depends on field type | Add columns for any Product or ProductVariant custom fields defined in your Vendure config |
124
124
 
125
125
  #### Import behavior rules
126
126
 
@@ -128,6 +128,28 @@ The plugin supports configurable storage for exported files:
128
128
  - If a column exists in the header but the cell is empty, that field is explicitly cleared.
129
129
  - For products with more than one variant, `optionGroups` and `optionValues` are required.
130
130
 
131
+ #### Sharing option groups
132
+
133
+ By default, each product gets its own option groups. To **share an option group** across
134
+ multiple products (e.g. a common "Size" group for all shoes), use the `name:code` syntax in the
135
+ `optionGroups` column: `size:shoe-size`. All products that reference the same code will share
136
+ a single option group. Products using a plain name (e.g. just `size`) will each get their own
137
+ independent option group.
138
+
139
+ ```csv
140
+ ...optionGroups , optionValues , ...
141
+ # Each shoe product shares a single "shoe-size" option group:
142
+ "size:shoe-size" , Small , ...
143
+ "size:shoe-size" , Medium , ...
144
+ # This product gets its own independent "size" group:
145
+ "size" , 4GB , ...
146
+ ```
147
+
148
+ The first product to reference a given code defines the full set of option values for that
149
+ shared group; subsequent products may use any subset of those values. Codes are language-
150
+ agnostic — only the `name` portion before the colon is translated per language column
151
+ (`optionGroups:en`, `optionGroups:sv`, etc.).
152
+
131
153
  #### Multi-language support
132
154
 
133
155
  Use language-specific columns by suffixing the field name with the language code. The language codes must match those configured in your Vendure instance (e.g. `en`, `sv`).
@@ -186,16 +208,31 @@ The Admin UI offers basic import and export. Some features such as the export-al
186
208
 
187
209
  ## Optional: Email notification on export complete
188
210
 
189
- If you use `@vendure/email-plugin`, you can add an email notification when an export finishes by importing it in the handler from the `/email` subpath. Create a template in your email templates directory: `product-export-complete/body.hbs`
211
+ If you use `@vendure/email-plugin`, you can add an email notification when an export finishes from the `/email` subpath.
212
+
213
+ The plugin now provides a default fallback template for `product-export-complete/body.hbs`.
214
+ If you define your own template in your app's template directory, your template takes precedence.
215
+ Note: templates for `@vendure/email-plugin` should be valid MJML.
190
216
 
191
217
  ```typescript
192
- import { DefaultEmailPlugin } from '@vendure/email-plugin'
193
- import { productExportedHandler } from '@haus-tech/product-import-export-plugin/email'
218
+ import path from 'path'
219
+ import {
220
+ DefaultEmailPlugin,
221
+ FileBasedTemplateLoader,
222
+ defaultEmailHandlers,
223
+ } from '@vendure/email-plugin'
224
+ import {
225
+ withProductExportedHandler,
226
+ withProductExportedTemplateFallback,
227
+ } from '@haus-tech/product-import-export-plugin/email'
194
228
 
195
229
  export const config = {
196
230
  plugins: [
197
231
  DefaultEmailPlugin.init({
198
- handlers: [productExportedHandler],
232
+ handlers: withProductExportedHandler(defaultEmailHandlers),
233
+ templateLoader: withProductExportedTemplateFallback(
234
+ new FileBasedTemplateLoader(path.join(__dirname, '../static/email/templates')),
235
+ ),
199
236
  }),
200
237
  ],
201
238
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haus-tech/product-import-export-plugin",
3
- "version": "3.3.2-next.1",
3
+ "version": "3.3.2-next.3",
4
4
  "description": "A Vendure plugin for importing and exporting products using a CSV file",
5
5
  "author": "Haus Tech",
6
6
  "repository": {
@@ -53,13 +53,13 @@
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/fs-extra": "^11.0.4",
56
- "csv-parse": "^5.6.0",
56
+ "csv-parse": "^6.0.0",
57
57
  "csv-stringify": "^6.6.0"
58
58
  },
59
59
  "peerDependencies": {
60
- "@vendure/core": "^2.0.0 || ^3.0.0",
61
- "@vendure/email-plugin": "^2.0.0 || ^3.0.0",
62
- "csv-parse": "^5.5.6",
60
+ "@vendure/core": "^3.6.0",
61
+ "@vendure/email-plugin": "^3.6.0",
62
+ "csv-parse": "^6.0.0",
63
63
  "csv-stringify": "^6.5.1",
64
64
  "slug": "^9.1.0"
65
65
  },
@@ -22,6 +22,7 @@ export function ExportProductsBulkAction({
22
22
  return (
23
23
  <>
24
24
  <DataTableBulkActionItem
25
+ closeOnClick={false}
25
26
  onClick={() => setDialogOpen(true)}
26
27
  label="Export products to CSV"
27
28
  icon={DownloadIcon}
@@ -0,0 +1,418 @@
1
+ import {
2
+ Accordion,
3
+ AccordionContent,
4
+ AccordionItem,
5
+ AccordionTrigger,
6
+ Checkbox,
7
+ Input,
8
+ Label,
9
+ Popover,
10
+ PopoverContent,
11
+ PopoverTrigger,
12
+ RadioGroup,
13
+ RadioGroupItem,
14
+ toast,
15
+ } from '@vendure/dashboard'
16
+ import { ChevronRight, InfoIcon } from 'lucide-react'
17
+ import {
18
+ useCallback,
19
+ useEffect,
20
+ useImperativeHandle,
21
+ useMemo,
22
+ forwardRef,
23
+ useState,
24
+ } from 'react'
25
+ import {
26
+ getServerLocation,
27
+ getChannelHeader,
28
+ type PluginInitOptions,
29
+ type ExportFields,
30
+ type ProductFields,
31
+ type VariantFields,
32
+ } from './utils'
33
+
34
+ const MANDATORY_OPTION_FIELDS: Array<ProductFields | VariantFields> = [
35
+ 'optionGroups',
36
+ 'optionValues',
37
+ ]
38
+
39
+ const AVAILABLE_EXPORT_FIELDS: ExportFields = [
40
+ 'name',
41
+ 'slug',
42
+ 'description',
43
+ 'assets',
44
+ 'facets',
45
+ 'optionGroups',
46
+ 'sku',
47
+ 'optionValues',
48
+ 'price',
49
+ 'taxCategory',
50
+ 'stockOnHand',
51
+ 'trackInventory',
52
+ 'variantAssets',
53
+ 'variantFacets',
54
+ 'enabled',
55
+ ]
56
+
57
+ export type ExportConfigurationPanelProps = {
58
+ /** Selected product IDs for scoped export; omit when `isExportAll` (treated as empty). */
59
+ productIds?: string[]
60
+ isExportAll: boolean
61
+ /** Fetch config + fields only while true (e.g. dialog `open`) */
62
+ active: boolean
63
+ /** Prefix for input/checkbox ids so modal vs page never collide */
64
+ idPrefix?: string
65
+ /** Expand “Select fields to export” on first paint (e.g. modal); inline page keeps it collapsed */
66
+ fieldsAccordionDefaultOpen?: boolean
67
+ onExportSuccess?: () => void
68
+ }
69
+
70
+ export type ExportConfigurationPanelHandle = {
71
+ submitExport: () => Promise<void>
72
+ }
73
+
74
+ export const ExportConfigurationPanel = forwardRef<
75
+ ExportConfigurationPanelHandle,
76
+ ExportConfigurationPanelProps
77
+ >(function ExportConfigurationPanel(
78
+ {
79
+ productIds,
80
+ isExportAll,
81
+ active,
82
+ idPrefix = 'export-config',
83
+ fieldsAccordionDefaultOpen = false,
84
+ onExportSuccess,
85
+ },
86
+ ref,
87
+ ) {
88
+ const resolvedProductIds = productIds ?? []
89
+
90
+ const [config, setConfig] = useState<PluginInitOptions['exportOptions'] | null>(null)
91
+
92
+ const [fileName, setFileName] = useState('')
93
+ const [customFieldNames, setCustomFieldNames] = useState<string[]>([])
94
+ const [selectedCustomFields, setSelectedCustomFields] = useState<string[]>([])
95
+ const [exportAssetsAs, setExportAssetsAs] = useState<'url' | 'json'>('url')
96
+ const [selectedExportFields, setSelectedExportFields] = useState<ExportFields>([])
97
+ const [toggleAllChecked, setToggleAllChecked] = useState(false)
98
+
99
+ /** Stable primitive so effects / callbacks don’t churn on fresh array references with the same IDs. */
100
+ const productIdsFetchKey = resolvedProductIds.join(',')
101
+
102
+ useEffect(() => {
103
+ if (!active) return
104
+
105
+ const fetchInitial = async () => {
106
+ try {
107
+ const serverPath = getServerLocation()
108
+ const [configRes, customFieldsRes] = await Promise.all([
109
+ fetch(`${serverPath}/product-import-export/config`, {
110
+ method: 'GET',
111
+ headers: {
112
+ 'Content-Type': 'application/json',
113
+ ...getChannelHeader(),
114
+ },
115
+ credentials: 'include',
116
+ }),
117
+ fetch(`${serverPath}/product-export/custom-fields`, {
118
+ method: 'POST',
119
+ headers: {
120
+ 'Content-Type': 'application/json',
121
+ ...getChannelHeader(),
122
+ },
123
+ credentials: 'include',
124
+ body: JSON.stringify(
125
+ resolvedProductIds.length > 0 ? resolvedProductIds : [],
126
+ ),
127
+ }),
128
+ ])
129
+
130
+ const configData = await configRes.json()
131
+ const customFieldsData = await customFieldsRes.json()
132
+
133
+ setConfig(configData.exportOptions)
134
+
135
+ const names = customFieldsData.map((field: { name: string }) => field.name)
136
+ setCustomFieldNames(names)
137
+ setSelectedCustomFields([...names])
138
+
139
+ const defaultAssets =
140
+ configData.exportOptions?.defaultExportAssetsAs ||
141
+ configData.exportOptions?.exportAssetsAsOptions?.[0] ||
142
+ 'url'
143
+ setExportAssetsAs(defaultAssets)
144
+
145
+ const initialExportFields = Array.from(
146
+ new Set([
147
+ ...(configData.exportOptions?.defaultExportFields || []),
148
+ ...(configData.exportOptions?.requiredExportFields || []),
149
+ ...MANDATORY_OPTION_FIELDS,
150
+ ]),
151
+ ) as ExportFields
152
+
153
+ setSelectedExportFields(initialExportFields)
154
+ } catch (error) {
155
+ console.error('Failed to fetch export config:', error)
156
+ }
157
+ }
158
+
159
+ fetchInitial()
160
+ }, [active, productIdsFetchKey])
161
+
162
+ const derivedToggleAll = useMemo(() => {
163
+ const allStandard = AVAILABLE_EXPORT_FIELDS.every((f) => selectedExportFields.includes(f))
164
+ const allCustom =
165
+ customFieldNames.length === 0 ||
166
+ customFieldNames.every((f) => selectedCustomFields.includes(f))
167
+ return allStandard && allCustom
168
+ }, [selectedExportFields, selectedCustomFields, customFieldNames])
169
+
170
+ useEffect(() => {
171
+ setToggleAllChecked(derivedToggleAll)
172
+ }, [derivedToggleAll])
173
+
174
+ const toggleCustomField = (fieldName: string) => {
175
+ setSelectedCustomFields((prev) =>
176
+ prev.includes(fieldName) ? prev.filter((f) => f !== fieldName) : [...prev, fieldName],
177
+ )
178
+ }
179
+
180
+ const toggleExportField = (fieldName: ProductFields | VariantFields) => {
181
+ if (MANDATORY_OPTION_FIELDS.includes(fieldName)) return
182
+ setSelectedExportFields((prev) =>
183
+ prev.includes(fieldName)
184
+ ? prev.filter((f) => f !== fieldName)
185
+ : [...prev, fieldName],
186
+ )
187
+ }
188
+
189
+ const toggleSelectAll = (checked: boolean) => {
190
+ setToggleAllChecked(checked)
191
+ if (checked) {
192
+ setSelectedExportFields([...AVAILABLE_EXPORT_FIELDS])
193
+ setSelectedCustomFields([...customFieldNames])
194
+ } else {
195
+ setSelectedExportFields([
196
+ ...(config?.requiredExportFields || []),
197
+ ...MANDATORY_OPTION_FIELDS,
198
+ ])
199
+ setSelectedCustomFields([])
200
+ }
201
+ }
202
+
203
+ const bothAssetModes =
204
+ config?.exportAssetsAsOptions?.includes('url') &&
205
+ config?.exportAssetsAsOptions?.includes('json')
206
+
207
+ const submitExport = useCallback(async () => {
208
+ try {
209
+ const serverPath = getServerLocation()
210
+ const trimmed = fileName.trim()
211
+ const finalFileName = trimmed || config?.defaultFileName || 'products_export.csv'
212
+ const endpoint = isExportAll ? 'export-all' : 'export'
213
+ const body = isExportAll ? undefined : JSON.stringify(resolvedProductIds)
214
+
215
+ const res = await fetch(
216
+ `${serverPath}/product-export/${endpoint}?fileName=${encodeURIComponent(
217
+ finalFileName,
218
+ )}&customFields=${encodeURIComponent(
219
+ selectedCustomFields.join(','),
220
+ )}&exportAssetsAs=${exportAssetsAs}&selectedExportFields=${encodeURIComponent(
221
+ selectedExportFields.join(','),
222
+ )}`,
223
+ {
224
+ method: 'POST',
225
+ headers: {
226
+ 'Content-Type': 'application/json',
227
+ ...getChannelHeader(),
228
+ },
229
+ credentials: 'include',
230
+ ...(body && { body }),
231
+ },
232
+ )
233
+
234
+ if (!res.ok) {
235
+ const json = await res.json().catch(() => ({}))
236
+ throw new Error(json?.message || 'Export failed')
237
+ }
238
+
239
+ toast.success(
240
+ isExportAll
241
+ ? 'Export queued. You will receive an email when the file is ready.'
242
+ : `Export job queued successfully. ${resolvedProductIds.length} products will be exported.`,
243
+ )
244
+ onExportSuccess?.()
245
+ } catch (error: unknown) {
246
+ console.error(error)
247
+ toast.error((error as Error)?.message || 'Failed to queue export')
248
+ }
249
+ }, [
250
+ fileName,
251
+ config?.defaultFileName,
252
+ isExportAll,
253
+ productIdsFetchKey,
254
+ selectedCustomFields,
255
+ exportAssetsAs,
256
+ selectedExportFields,
257
+ onExportSuccess,
258
+ ])
259
+
260
+ useImperativeHandle(ref, () => ({ submitExport }), [submitExport])
261
+
262
+ if (!active) {
263
+ return null
264
+ }
265
+
266
+ return (
267
+ <div className="space-y-4">
268
+ <div>
269
+ <Label htmlFor={`${idPrefix}-file-name`}>File name (optional)</Label>
270
+ <p className="text-xs text-muted-foreground mt-1">
271
+ A timestamp is added automatically for uniqueness.
272
+ </p>
273
+ <Input
274
+ id={`${idPrefix}-file-name`}
275
+ value={fileName}
276
+ onChange={(e) => setFileName(e.target.value)}
277
+ placeholder={config?.defaultFileName || 'products_export.csv'}
278
+ className="mt-2"
279
+ />
280
+ </div>
281
+
282
+ <Accordion
283
+ className="border rounded-lg px-3"
284
+ defaultValue={
285
+ fieldsAccordionDefaultOpen ? ['select-fields'] : ([] as string[])
286
+ }
287
+ >
288
+ <AccordionItem value="select-fields" className="border-0">
289
+ <AccordionTrigger className="group/trigger flex w-full items-center gap-3 py-3 text-sm hover:no-underline [&>svg:last-child]:size-4 [&>svg:last-child]:shrink-0 [&>svg:last-child]:self-center">
290
+ <ChevronRight className="text-primary size-4 shrink-0 self-center transition-transform duration-200 group-data-[state=open]/trigger:rotate-90" />
291
+ <span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1 self-center text-left leading-snug">
292
+ <span className="font-medium text-foreground">Configure fields to export</span>
293
+ </span>
294
+ </AccordionTrigger>
295
+ <AccordionContent className="space-y-4 pb-4 pt-4 border-t border-border">
296
+ <div className="flex items-center space-x-2 pt-2">
297
+ <Checkbox
298
+ id={`${idPrefix}-select-all`}
299
+ checked={toggleAllChecked}
300
+ onCheckedChange={(checked) => toggleSelectAll(checked === true)}
301
+ />
302
+ <Label htmlFor={`${idPrefix}-select-all`} className="font-normal">
303
+ Select all fields
304
+ </Label>
305
+ </div>
306
+
307
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
308
+ {AVAILABLE_EXPORT_FIELDS.map((field) => (
309
+ <div key={field} className="flex items-center space-x-2">
310
+ <Checkbox
311
+ id={`${idPrefix}-field-${field}`}
312
+ checked={selectedExportFields.includes(field)}
313
+ onCheckedChange={() => toggleExportField(field)}
314
+ disabled={
315
+ config?.requiredExportFields?.includes(field) ||
316
+ MANDATORY_OPTION_FIELDS.includes(field)
317
+ }
318
+ />
319
+ <Label htmlFor={`${idPrefix}-field-${field}`} className="font-normal">
320
+ {field}
321
+ </Label>
322
+ </div>
323
+ ))}
324
+ </div>
325
+
326
+ {customFieldNames.length > 0 && (
327
+ <div className="space-y-2 pt-4 border-t">
328
+ <Label className="text-muted-foreground pb-2">Custom fields</Label>
329
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
330
+ {customFieldNames.map((field) => (
331
+ <div key={field} className="flex items-center space-x-2">
332
+ <Checkbox
333
+ id={`${idPrefix}-custom-${field}`}
334
+ checked={selectedCustomFields.includes(field)}
335
+ onCheckedChange={() => toggleCustomField(field)}
336
+ />
337
+ <Label htmlFor={`${idPrefix}-custom-${field}`} className="font-normal">
338
+ {field}
339
+ </Label>
340
+ </div>
341
+ ))}
342
+ </div>
343
+ </div>
344
+ )}
345
+ </AccordionContent>
346
+ </AccordionItem>
347
+ </Accordion>
348
+
349
+ {bothAssetModes ? (
350
+ <Accordion className="border rounded-lg px-3" defaultValue={[]}>
351
+ <AccordionItem value="advanced-settings" className="border-0">
352
+ <AccordionTrigger className="group/trigger flex w-full items-center gap-3 py-3 text-sm hover:no-underline [&>svg:last-child]:size-4 [&>svg:last-child]:shrink-0 [&>svg:last-child]:self-center">
353
+ <ChevronRight className="text-primary size-4 shrink-0 self-center transition-transform duration-200 group-data-[state=open]/trigger:rotate-90" />
354
+ <span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1 self-center text-left leading-snug">
355
+ <span className="font-medium text-foreground">Advanced settings</span>
356
+ </span>
357
+ </AccordionTrigger>
358
+ <AccordionContent className="space-y-4 pb-4 pt-4 border-t border-border">
359
+ <div className="flex flex-row flex-wrap items-center gap-x-6 gap-y-2 pt-2">
360
+ <div className="flex shrink-0 items-center gap-1">
361
+ <Label className="font-medium">Export assets as</Label>
362
+ <Popover>
363
+ <PopoverTrigger
364
+ type="button"
365
+ className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
366
+ aria-label="What does export assets as mean?"
367
+ >
368
+ <InfoIcon className="h-4 w-4" />
369
+ </PopoverTrigger>
370
+ <PopoverContent className="w-80 text-sm" align="start" side="top">
371
+ <p className="font-medium text-foreground">Asset columns in the CSV</p>
372
+ <p className="mt-2 text-muted-foreground">
373
+ Controls how product and variant asset fields are serialized in the export.
374
+ </p>
375
+ <ul className="mt-3 list-disc space-y-2 pl-4 text-muted-foreground">
376
+ <li>
377
+ <span className="font-medium text-foreground">URL only</span> — each cell
378
+ lists asset file URLs separated by{' '}
379
+ <span className="font-mono text-xs">|</span>.
380
+ </li>
381
+ <li>
382
+ <span className="font-medium text-foreground">Full JSON</span> — each cell
383
+ contains JSON with <span className="font-mono text-xs">id</span>,{' '}
384
+ <span className="font-mono text-xs">name</span>, and{' '}
385
+ <span className="font-mono text-xs">url</span> per asset.
386
+ </li>
387
+ </ul>
388
+ </PopoverContent>
389
+ </Popover>
390
+ </div>
391
+ <RadioGroup
392
+ value={exportAssetsAs}
393
+ onValueChange={(val) => setExportAssetsAs(val as 'url' | 'json')}
394
+ className="flex flex-row flex-wrap gap-6"
395
+ >
396
+ <div className="flex items-center space-x-2">
397
+ <RadioGroupItem value="url" id={`${idPrefix}-assets-url`} />
398
+ <Label htmlFor={`${idPrefix}-assets-url`} className="font-normal">
399
+ URL only
400
+ </Label>
401
+ </div>
402
+ <div className="flex items-center space-x-2">
403
+ <RadioGroupItem value="json" id={`${idPrefix}-assets-json`} />
404
+ <Label htmlFor={`${idPrefix}-assets-json`} className="font-normal">
405
+ Full JSON
406
+ </Label>
407
+ </div>
408
+ </RadioGroup>
409
+ </div>
410
+ </AccordionContent>
411
+ </AccordionItem>
412
+ </Accordion>
413
+ ) : null}
414
+ </div>
415
+ )
416
+ })
417
+
418
+ ExportConfigurationPanel.displayName = 'ExportConfigurationPanel'