configuration-management 0.1.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.
Files changed (35) hide show
  1. package/README.md +199 -0
  2. package/actions/configurations/commerce/index.js +55 -0
  3. package/actions/configurations/export-config/index.js +259 -0
  4. package/actions/configurations/ext.config.yaml +151 -0
  5. package/actions/configurations/import-config/index.js +544 -0
  6. package/actions/configurations/registration/index.js +37 -0
  7. package/actions/configurations/sync-store-mappings-from-commerce/index.js +199 -0
  8. package/actions/configurations/system-config-list/index.js +127 -0
  9. package/actions/configurations/system-config-save/index.js +160 -0
  10. package/actions/configurations/system-config-schema/index.js +327 -0
  11. package/actions/utils.js +73 -0
  12. package/package.json +74 -0
  13. package/scripts/setup-app-config.js +114 -0
  14. package/src/abdb-config.js +241 -0
  15. package/src/abdb-helper.js +476 -0
  16. package/src/index.js +20 -0
  17. package/src/oauth1a.js +135 -0
  18. package/src/system-config-crypto.js +113 -0
  19. package/src/system-config-shared.js +89 -0
  20. package/web/src/components/App.js +47 -0
  21. package/web/src/components/AppSectionNav.js +49 -0
  22. package/web/src/components/ExtensionRegistration.js +33 -0
  23. package/web/src/components/MainPage.js +46 -0
  24. package/web/src/components/SystemConfig.js +1464 -0
  25. package/web/src/components/SystemConfigSchemaEditor.js +459 -0
  26. package/web/src/hooks/useConfirm.js +355 -0
  27. package/web/src/hooks/useSystemConfig.js +238 -0
  28. package/web/src/hooks/useSystemConfigSchema.js +102 -0
  29. package/web/src/index.js +41 -0
  30. package/web/src/schema/systemConfigSchema.js +82 -0
  31. package/web/src/settings.js +57 -0
  32. package/web/src/styles/index.css +326 -0
  33. package/web/src/theme.js +104 -0
  34. package/web/src/utils/storeMappingsFromCommerceRest.js +73 -0
  35. package/web/src/utils.js +52 -0
@@ -0,0 +1,1464 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ */
7
+
8
+ import { useState, useMemo, useEffect, useRef } from 'react'
9
+ import { Link } from 'react-router-dom'
10
+ import {
11
+ View,
12
+ Flex,
13
+ Heading,
14
+ Text,
15
+ Button,
16
+ ActionButton,
17
+ TooltipTrigger,
18
+ Tooltip,
19
+ TextField,
20
+ TextArea,
21
+ NumberField,
22
+ Switch,
23
+ Checkbox,
24
+ Picker,
25
+ Item,
26
+ Section,
27
+ ProgressCircle,
28
+ ProgressBar,
29
+ Divider,
30
+ Well
31
+ } from '@adobe/react-spectrum'
32
+ import Settings from '@spectrum-icons/workflow/Settings'
33
+ import Globe from '@spectrum-icons/workflow/Globe'
34
+ import Refresh from '@spectrum-icons/workflow/Refresh'
35
+ import Edit from '@spectrum-icons/workflow/Edit'
36
+ import CloudUpload from '@spectrum-icons/workflow/UploadToCloud'
37
+ import LockClosed from '@spectrum-icons/workflow/LockClosed'
38
+ import Back from '@spectrum-icons/workflow/Back'
39
+ import ChevronDown from '@spectrum-icons/workflow/ChevronDown'
40
+ import ChevronRight from '@spectrum-icons/workflow/ChevronRight'
41
+ import { useSystemConfig } from '../hooks/useSystemConfig'
42
+ import { useSystemConfigSchema } from '../hooks/useSystemConfigSchema'
43
+ import { useConfirm } from '../hooks/useConfirm'
44
+ import { isFieldVisibleAtScope, coerceDefault } from '../schema/systemConfigSchema'
45
+ import SystemConfigSchemaEditor from './SystemConfigSchemaEditor'
46
+ import { callAction } from '../utils'
47
+ import { getActionKey } from '../settings'
48
+ import { PALETTE, RADIUS, SHADOW } from '../theme'
49
+
50
+ // ----------------------------------------------------------------------------
51
+ // Design tokens
52
+ // ----------------------------------------------------------------------------
53
+ // Height (px) reserved by the AppSectionNav strip at the top of every page.
54
+ // Sticky elements inside SystemConfig stack vertically below the section nav
55
+ // + the hero card. The hero height is measured at runtime (subtitle wrap
56
+ // changes its height) and exposed via the `--sc-hero-h` CSS variable so the
57
+ // save bar / sidebar always sit flush against the bottom of the hero.
58
+ const APP_NAV_OFFSET = 64
59
+ const HERO_HEIGHT = 160 // initial estimate; overridden via CSS var once measured
60
+ const SAVE_BAR_HEIGHT = 64 // save bar (action row + padding)
61
+ const HERO_VAR = `var(--sc-hero-h, ${HERO_HEIGHT}px)`
62
+
63
+
64
+ // ----------------------------------------------------------------------------
65
+ // Scope tree (Magento-style hierarchical Picker)
66
+ // ----------------------------------------------------------------------------
67
+ function buildScopeTreeForPicker (scopeTree) {
68
+ const def = { key: 'default::0', label: 'Default Config', scope: 'default', scopeId: '0' }
69
+ const websites = []
70
+ const all = [def]
71
+ const groupsById = new Map((scopeTree.storeGroups || []).map((g) => [String(g.id), g]))
72
+
73
+ for (const w of scopeTree.websites) {
74
+ const websiteOption = {
75
+ key: `websites::${w.id}`,
76
+ label: w.name || w.code || `Website ${w.id}`,
77
+ scope: 'websites',
78
+ scopeId: String(w.id)
79
+ }
80
+ all.push(websiteOption)
81
+
82
+ const storesForWebsite = (scopeTree.stores || []).filter(
83
+ (s) => String(s.website_id) === String(w.id)
84
+ )
85
+ storesForWebsite.sort((a, b) => {
86
+ const ga = groupsById.get(String(a.store_group_id))?.name || ''
87
+ const gb = groupsById.get(String(b.store_group_id))?.name || ''
88
+ if (ga !== gb) return ga.localeCompare(gb)
89
+ return (a.name || '').localeCompare(b.name || '')
90
+ })
91
+
92
+ const items = storesForWebsite.map((s) => {
93
+ const groupName = groupsById.get(String(s.store_group_id))?.name || ''
94
+ const label = groupName ? `${groupName} / ${s.name}` : s.name
95
+ const option = { key: `stores::${s.id}`, label, scope: 'stores', scopeId: String(s.id) }
96
+ all.push(option)
97
+ return option
98
+ })
99
+
100
+ websites.push({
101
+ websiteId: String(w.id),
102
+ websiteName: websiteOption.label,
103
+ websiteOption,
104
+ items
105
+ })
106
+ }
107
+
108
+ return { all, default: def, websites }
109
+ }
110
+
111
+ // ----------------------------------------------------------------------------
112
+ // Small UI atoms
113
+ // ----------------------------------------------------------------------------
114
+ function Pill ({ children, tone = 'neutral' }) {
115
+ const tones = {
116
+ neutral: { bg: PALETTE.neutralSoft, fg: PALETTE.neutralText },
117
+ accent: { bg: PALETTE.accentSoft, fg: PALETTE.accent },
118
+ warning: { bg: PALETTE.warningSoft, fg: PALETTE.warning },
119
+ success: { bg: PALETTE.successSoft, fg: PALETTE.success },
120
+ danger: { bg: PALETTE.dangerSoft, fg: PALETTE.danger }
121
+ }
122
+ const t = tones[tone] || tones.neutral
123
+ return (
124
+ <span
125
+ style={{
126
+ display: 'inline-flex',
127
+ alignItems: 'center',
128
+ gap: 4,
129
+ padding: '2px 8px',
130
+ borderRadius: RADIUS.pill,
131
+ background: t.bg,
132
+ color: t.fg,
133
+ fontSize: 11,
134
+ fontWeight: 600,
135
+ lineHeight: '16px',
136
+ letterSpacing: 0.2,
137
+ whiteSpace: 'nowrap'
138
+ }}
139
+ >
140
+ {children}
141
+ </span>
142
+ )
143
+ }
144
+
145
+ function Card ({ children, padded = true, style = {} }) {
146
+ return (
147
+ <div
148
+ style={{
149
+ background: PALETTE.surface,
150
+ border: `1px solid ${PALETTE.border}`,
151
+ borderRadius: RADIUS.lg,
152
+ boxShadow: SHADOW.xs,
153
+ ...(padded ? { padding: 20 } : {}),
154
+ ...style
155
+ }}
156
+ >
157
+ {children}
158
+ </div>
159
+ )
160
+ }
161
+
162
+ function SectionDivider ({ label }) {
163
+ return (
164
+ <div style={{
165
+ fontSize: 11,
166
+ fontWeight: 700,
167
+ letterSpacing: 0.8,
168
+ textTransform: 'uppercase',
169
+ color: PALETTE.textMuted,
170
+ padding: '14px 12px 6px'
171
+ }}>{label}</div>
172
+ )
173
+ }
174
+
175
+ // ----------------------------------------------------------------------------
176
+ // Field renderer
177
+ // ----------------------------------------------------------------------------
178
+ function FieldControl ({ field, value, disabled, sensitivePlaceholder, onChange }) {
179
+ const isMasked = value === sensitivePlaceholder
180
+
181
+ switch (field.type) {
182
+ case 'textarea':
183
+ return (
184
+ <View width="size-4600">
185
+ <TextArea
186
+ aria-label={field.label}
187
+ value={value ?? ''}
188
+ isDisabled={disabled}
189
+ onChange={onChange}
190
+ width="100%"
191
+ UNSAFE_className="sm-textarea"
192
+ />
193
+ </View>
194
+ )
195
+ case 'password':
196
+ return (
197
+ <TextField
198
+ aria-label={field.label}
199
+ type="password"
200
+ value={isMasked ? '' : (value ?? '')}
201
+ isDisabled={disabled}
202
+ onChange={onChange}
203
+ placeholder={isMasked ? '••••• (encrypted, leave blank to keep)' : ''}
204
+ width="size-4600"
205
+ />
206
+ )
207
+ case 'number':
208
+ return (
209
+ <NumberField
210
+ aria-label={field.label}
211
+ value={typeof value === 'number' ? value : Number(value) || 0}
212
+ isDisabled={disabled}
213
+ onChange={onChange}
214
+ width="size-3000"
215
+ />
216
+ )
217
+ case 'boolean':
218
+ return (
219
+ <Switch isSelected={!!value} isDisabled={disabled} onChange={onChange}>
220
+ {value ? 'Yes' : 'No'}
221
+ </Switch>
222
+ )
223
+ case 'select':
224
+ return (
225
+ <Picker
226
+ aria-label={field.label}
227
+ selectedKey={value ?? field.default}
228
+ isDisabled={disabled}
229
+ onSelectionChange={onChange}
230
+ width="size-3600"
231
+ >
232
+ {(field.options || []).map((opt) => (
233
+ <Item key={opt.value}>{opt.label}</Item>
234
+ ))}
235
+ </Picker>
236
+ )
237
+ case 'text':
238
+ default:
239
+ return (
240
+ <TextField
241
+ aria-label={field.label}
242
+ value={value ?? ''}
243
+ isDisabled={disabled}
244
+ onChange={onChange}
245
+ width="size-4600"
246
+ />
247
+ )
248
+ }
249
+ }
250
+
251
+ function FieldRow ({
252
+ field,
253
+ path,
254
+ scope,
255
+ displayValue,
256
+ origin,
257
+ inherited,
258
+ onFieldChange,
259
+ onUseDefaultChange,
260
+ sensitivePlaceholder
261
+ }) {
262
+ const allowed = isFieldVisibleAtScope(field, scope.scope)
263
+ const showUseDefault = scope.scope !== 'default' && allowed
264
+ const editorDisabled = !allowed || (showUseDefault && inherited)
265
+ const isTextarea = field.type === 'textarea'
266
+
267
+ const originLabel = origin
268
+ ? origin.scope === 'default' ? 'inherited from Default Config' : `set at ${origin.scope}:${origin.scopeId}`
269
+ : 'unset'
270
+
271
+ return (
272
+ <div
273
+ style={{
274
+ display: 'grid',
275
+ gridTemplateColumns: '220px 1fr auto',
276
+ gap: 16,
277
+ alignItems: isTextarea ? 'start' : 'center',
278
+ padding: '14px 0',
279
+ borderBottom: `1px solid ${PALETTE.border}`
280
+ }}
281
+ >
282
+ <div style={{ paddingTop: isTextarea ? 6 : 0 }}>
283
+ <div style={{
284
+ fontSize: 13,
285
+ fontWeight: 600,
286
+ color: PALETTE.text,
287
+ display: 'flex',
288
+ alignItems: 'center',
289
+ gap: 6
290
+ }}>
291
+ {field.label}
292
+ {field.sensitive && (
293
+ <TooltipTrigger>
294
+ <span style={{ display: 'inline-flex', color: PALETTE.textMuted }}>
295
+ <LockClosed size="XS" />
296
+ </span>
297
+ <Tooltip>Encrypted at rest</Tooltip>
298
+ </TooltipTrigger>
299
+ )}
300
+ </div>
301
+ <div style={{ marginTop: 4, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
302
+ {!allowed && <Pill tone="warning">Not configurable here</Pill>}
303
+ {allowed && scope.scope !== 'default' && (
304
+ <Pill tone={inherited ? 'neutral' : 'accent'}>
305
+ {inherited ? originLabel : 'overridden'}
306
+ </Pill>
307
+ )}
308
+ </div>
309
+ </div>
310
+
311
+ <div>
312
+ <FieldControl
313
+ field={field}
314
+ value={displayValue}
315
+ disabled={editorDisabled}
316
+ sensitivePlaceholder={sensitivePlaceholder}
317
+ onChange={(v) => onFieldChange(path, v)}
318
+ />
319
+ </div>
320
+
321
+ <div>
322
+ {showUseDefault && (
323
+ <Checkbox
324
+ isSelected={inherited}
325
+ onChange={(checked) => onUseDefaultChange(path, checked)}
326
+ >
327
+ Use Default
328
+ </Checkbox>
329
+ )}
330
+ </div>
331
+ </div>
332
+ )
333
+ }
334
+
335
+ // ----------------------------------------------------------------------------
336
+ // Group card (collapsible)
337
+ // ----------------------------------------------------------------------------
338
+ function GroupCard ({
339
+ group,
340
+ sectionId,
341
+ scope,
342
+ collapsed,
343
+ onToggle,
344
+ getDisplayValue,
345
+ getOrigin,
346
+ isInheritedAtScope,
347
+ setFieldValue,
348
+ setUseDefault,
349
+ sensitivePlaceholder
350
+ }) {
351
+ return (
352
+ <Card padded={false} style={{ marginBottom: 16 }}>
353
+ <button
354
+ type="button"
355
+ onClick={onToggle}
356
+ aria-expanded={!collapsed}
357
+ style={{
358
+ display: 'flex',
359
+ alignItems: 'center',
360
+ justifyContent: 'space-between',
361
+ width: '100%',
362
+ padding: '14px 20px',
363
+ background: 'transparent',
364
+ border: 0,
365
+ borderBottom: collapsed ? 0 : `1px solid ${PALETTE.border}`,
366
+ cursor: 'pointer',
367
+ userSelect: 'none',
368
+ font: 'inherit',
369
+ color: 'inherit',
370
+ textAlign: 'left'
371
+ }}
372
+ >
373
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
374
+ <span style={{ color: PALETTE.textMuted, display: 'inline-flex' }}>
375
+ {collapsed ? <ChevronRight size="S" /> : <ChevronDown size="S" />}
376
+ </span>
377
+ <span style={{ fontWeight: 700, fontSize: 15, color: PALETTE.text }}>{group.label}</span>
378
+ <Pill tone="neutral">{(group.fields || []).length} fields</Pill>
379
+ </div>
380
+ </button>
381
+ {!collapsed && (
382
+ <div style={{ padding: '4px 20px 16px' }}>
383
+ {(group.fields || []).map((field) => {
384
+ const path = `${sectionId}/${group.id}/${field.id}`
385
+ const inherited = isInheritedAtScope(path)
386
+ const displayValue = getDisplayValue(path, coerceDefault(field))
387
+ return (
388
+ <FieldRow
389
+ key={path}
390
+ field={field}
391
+ path={path}
392
+ scope={scope}
393
+ displayValue={displayValue}
394
+ origin={getOrigin(path)}
395
+ inherited={inherited}
396
+ onFieldChange={setFieldValue}
397
+ onUseDefaultChange={setUseDefault}
398
+ sensitivePlaceholder={sensitivePlaceholder}
399
+ />
400
+ )
401
+ })}
402
+ </div>
403
+ )}
404
+ </Card>
405
+ )
406
+ }
407
+
408
+ // ----------------------------------------------------------------------------
409
+ // Sidebar (sections)
410
+ // ----------------------------------------------------------------------------
411
+ function Sidebar ({ sections, activeSectionId, onSelect }) {
412
+ return (
413
+ <aside
414
+ role="tablist"
415
+ aria-label="Sections"
416
+ style={{
417
+ width: 260,
418
+ flexShrink: 0,
419
+ // Pill-track styling that matches the top AppSectionNav: muted grey
420
+ // track with inset shadow, full-rounded radius, holding individual
421
+ // rounded pill buttons.
422
+ background: PALETTE.surfaceMuted,
423
+ border: `1px solid ${PALETTE.border}`,
424
+ borderRadius: RADIUS.xxl,
425
+ boxShadow: SHADOW.inset,
426
+ padding: 6,
427
+ position: 'sticky',
428
+ // Sit below the hero + save bar (which are also sticky) so the
429
+ // sidebar never overlaps either of them. Uses the runtime-measured
430
+ // hero height so the offset stays correct on viewport resize.
431
+ top: `calc(${APP_NAV_OFFSET}px + ${HERO_VAR} + ${SAVE_BAR_HEIGHT + 16}px)`,
432
+ alignSelf: 'flex-start',
433
+ maxHeight: `calc(100vh - ${APP_NAV_OFFSET}px - ${HERO_VAR} - ${SAVE_BAR_HEIGHT + 32}px)`,
434
+ overflowY: 'auto',
435
+ zIndex: 5,
436
+ display: 'flex',
437
+ flexDirection: 'column',
438
+ gap: 4
439
+ }}
440
+ >
441
+ <div style={{
442
+ padding: '6px 14px 4px',
443
+ fontSize: 10,
444
+ fontWeight: 700,
445
+ letterSpacing: 0.8,
446
+ textTransform: 'uppercase',
447
+ color: PALETTE.textMuted
448
+ }}>
449
+ Sections
450
+ </div>
451
+ {sections.map((section) => {
452
+ const active = section.id === activeSectionId
453
+ const fieldCount = (section.groups || []).reduce((n, g) => n + (g.fields || []).length, 0)
454
+ return (
455
+ <button
456
+ key={section.id}
457
+ type="button"
458
+ role="tab"
459
+ aria-selected={active}
460
+ onClick={() => onSelect(section.id)}
461
+ style={{
462
+ display: 'flex',
463
+ alignItems: 'center',
464
+ gap: 10,
465
+ width: '100%',
466
+ padding: '10px 14px',
467
+ border: 0,
468
+ borderRadius: RADIUS.pill,
469
+ background: active ? PALETTE.surface : 'transparent',
470
+ cursor: active ? 'default' : 'pointer',
471
+ font: 'inherit',
472
+ color: active ? PALETTE.accent : PALETTE.neutralText,
473
+ fontWeight: active ? 700 : 600,
474
+ fontSize: 13,
475
+ textAlign: 'left',
476
+ boxShadow: active ? SHADOW.pill : 'none',
477
+ transition: 'background 140ms ease, color 140ms ease, box-shadow 140ms ease'
478
+ }}
479
+ onMouseOver={(e) => { if (!active) { e.currentTarget.style.background = PALETTE.surface; e.currentTarget.style.color = PALETTE.text } }}
480
+ onMouseOut={(e) => { if (!active) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = PALETTE.neutralText } }}
481
+ >
482
+ <span style={{ display: 'inline-flex', opacity: active ? 1 : 0.7 }}>
483
+ <Settings size="XS" />
484
+ </span>
485
+ <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
486
+ {section.label}
487
+ </span>
488
+ <Pill tone={active ? 'accent' : 'neutral'}>{fieldCount}</Pill>
489
+ </button>
490
+ )
491
+ })}
492
+ </aside>
493
+ )
494
+ }
495
+
496
+ // ----------------------------------------------------------------------------
497
+ // Values view
498
+ // ----------------------------------------------------------------------------
499
+ function ValuesView ({ schema, onEditSchema, toolsOpen, setToolsOpen, configCtx }) {
500
+ const {
501
+ scope,
502
+ scopeTree,
503
+ getDisplayValue,
504
+ getOrigin,
505
+ isInheritedAtScope,
506
+ setFieldValue,
507
+ setUseDefault,
508
+ dirtyCount,
509
+ loading,
510
+ saving,
511
+ error,
512
+ savedAt,
513
+ save,
514
+ reset,
515
+ refresh,
516
+ SENSITIVE_PLACEHOLDER
517
+ } = configCtx
518
+
519
+ const sections = schema?.sections || []
520
+ const [activeSectionId, setActiveSectionId] = useState(sections[0]?.id)
521
+ const activeSection = useMemo(() => {
522
+ if (sections.length === 0) return null
523
+ return sections.find((s) => s.id === activeSectionId) || sections[0]
524
+ }, [sections, activeSectionId])
525
+
526
+ const scopeTreeForPicker = useMemo(() => buildScopeTreeForPicker(scopeTree), [scopeTree])
527
+ const scopeKey = `${scope.scope}::${scope.scopeId}`
528
+ const activeScopeLabel = scopeTreeForPicker.all.find((o) => o.key === scopeKey)?.label || 'Default Config'
529
+
530
+ const [collapsedGroups, setCollapsedGroups] = useState({})
531
+ useEffect(() => { setCollapsedGroups({}) }, [activeSection?.id])
532
+ const toggleGroup = (gid) => setCollapsedGroups((prev) => ({ ...prev, [gid]: !prev[gid] }))
533
+ const setAllGroups = (collapsed) => {
534
+ const next = {}
535
+ for (const g of activeSection?.groups || []) next[g.id] = collapsed
536
+ setCollapsedGroups(next)
537
+ }
538
+
539
+ if (sections.length === 0) {
540
+ return (
541
+ <Card>
542
+ <div style={{ textAlign: 'center', padding: '40px 20px' }}>
543
+ <div style={{
544
+ display: 'inline-flex',
545
+ padding: 16,
546
+ background: PALETTE.accentSoft,
547
+ borderRadius: '50%',
548
+ marginBottom: 12,
549
+ color: PALETTE.accent
550
+ }}>
551
+ <Settings size="L" />
552
+ </div>
553
+ <Heading level={3} marginTop={0}>No configuration schema yet</Heading>
554
+ <Text UNSAFE_style={{ color: PALETTE.textMuted, maxWidth: 460, display: 'inline-block' }}>
555
+ Open the Schema Designer to define sections, groups, and fields for your sync integrations.
556
+ </Text>
557
+ <Flex justifyContent="center" gap="size-150" marginTop="size-200">
558
+ <Button variant="cta" onPress={onEditSchema}>Open Schema Designer</Button>
559
+ </Flex>
560
+ </div>
561
+ </Card>
562
+ )
563
+ }
564
+
565
+ return (
566
+ <>
567
+ {error && (
568
+ <Well marginBottom="size-200" UNSAFE_style={{ borderColor: PALETTE.danger }}>
569
+ <Text UNSAFE_style={{ color: PALETTE.danger }}>{error}</Text>
570
+ </Well>
571
+ )}
572
+
573
+ {/* Save bar — sticks to the top of the page (just under the hero card)
574
+ so the primary CTA is always in view as the user scrolls long forms. */}
575
+ <div
576
+ style={{
577
+ position: 'sticky',
578
+ // Hero card sticks at APP_NAV_OFFSET; this save bar sits flush
579
+ // against the hero's bottom edge (measured at runtime via
580
+ // --sc-hero-h so the gap is always zero regardless of subtitle
581
+ // wrap).
582
+ top: `calc(${APP_NAV_OFFSET}px + ${HERO_VAR})`,
583
+ marginBottom: 16,
584
+ padding: '12px 20px',
585
+ background: PALETTE.surface,
586
+ border: `1px solid ${PALETTE.border}`,
587
+ borderRadius: RADIUS.xl,
588
+ boxShadow: SHADOW.floating,
589
+ zIndex: 10
590
+ }}
591
+ >
592
+ <Flex gap="size-150" alignItems="center" justifyContent="space-between">
593
+ <div style={{ fontSize: 12, color: PALETTE.textMuted }}>
594
+ {dirtyCount > 0
595
+ ? <span style={{ color: PALETTE.warning, fontWeight: 600 }}>{dirtyCount} unsaved change{dirtyCount === 1 ? '' : 's'}</span>
596
+ : savedAt && !saving
597
+ ? <span style={{ color: PALETTE.success, fontWeight: 600 }}>✓ Saved {new Date(savedAt).toLocaleTimeString()}</span>
598
+ : 'All changes saved'}
599
+ </div>
600
+ <Flex gap="size-100" alignItems="center">
601
+ <Button variant="secondary" onPress={refresh} isDisabled={saving || loading}>
602
+ Reload
603
+ </Button>
604
+ <Button variant="secondary" onPress={reset} isDisabled={saving || dirtyCount === 0}>
605
+ Reset
606
+ </Button>
607
+ <Button variant="cta" onPress={save} isDisabled={saving || loading || dirtyCount === 0}>
608
+ {saving ? 'Saving…' : `Save Config${dirtyCount ? ` (${dirtyCount})` : ''}`}
609
+ </Button>
610
+ </Flex>
611
+ </Flex>
612
+ </div>
613
+
614
+ <div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
615
+ <Sidebar
616
+ sections={sections}
617
+ activeSectionId={activeSection?.id}
618
+ onSelect={setActiveSectionId}
619
+ />
620
+
621
+ <div style={{ flex: 1, minWidth: 0 }}>
622
+ <div style={{
623
+ display: 'flex',
624
+ justifyContent: 'space-between',
625
+ alignItems: 'center',
626
+ marginBottom: 16
627
+ }}>
628
+ <div>
629
+ <div style={{ fontSize: 12, color: PALETTE.textMuted, fontWeight: 600, marginBottom: 4 }}>
630
+ {activeScopeLabel}
631
+ </div>
632
+ <Heading level={2} marginTop={0} marginBottom={0}>{activeSection?.label}</Heading>
633
+ </div>
634
+ {(activeSection?.groups || []).length > 1 && (
635
+ <Flex gap="size-50">
636
+ <ActionButton onPress={() => setAllGroups(false)} isQuiet>Expand all</ActionButton>
637
+ <ActionButton onPress={() => setAllGroups(true)} isQuiet>Collapse all</ActionButton>
638
+ </Flex>
639
+ )}
640
+ </div>
641
+
642
+ {loading
643
+ ? (
644
+ <Card>
645
+ <Flex justifyContent="center" marginY="size-400">
646
+ <ProgressCircle aria-label="Loading values" isIndeterminate />
647
+ </Flex>
648
+ </Card>
649
+ )
650
+ : (
651
+ (activeSection?.groups || []).map((group) => (
652
+ <GroupCard
653
+ key={group.id}
654
+ group={group}
655
+ sectionId={activeSection.id}
656
+ scope={scope}
657
+ collapsed={!!collapsedGroups[group.id]}
658
+ onToggle={() => toggleGroup(group.id)}
659
+ getDisplayValue={getDisplayValue}
660
+ getOrigin={getOrigin}
661
+ isInheritedAtScope={isInheritedAtScope}
662
+ setFieldValue={setFieldValue}
663
+ setUseDefault={setUseDefault}
664
+ sensitivePlaceholder={SENSITIVE_PLACEHOLDER}
665
+ />
666
+ ))
667
+ )}
668
+
669
+ <div style={{ height: 80 }} />
670
+ </div>
671
+ </div>
672
+ </>
673
+ )
674
+ }
675
+
676
+ // ----------------------------------------------------------------------------
677
+ // Custom scope picker — replaces Spectrum's Picker so we can render a clean
678
+ // hierarchical menu without the duplicate website-name section header that
679
+ // the Spectrum Section title forced.
680
+ // ----------------------------------------------------------------------------
681
+ function ScopePicker ({ scopeTreeForPicker, selectedKey, onChange, disabled }) {
682
+ const [open, setOpen] = useState(false)
683
+ const wrapperRef = useRef(null)
684
+
685
+ useEffect(() => {
686
+ if (!open) return
687
+ const onDoc = (e) => {
688
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target)) setOpen(false)
689
+ }
690
+ const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
691
+ document.addEventListener('mousedown', onDoc)
692
+ document.addEventListener('keydown', onKey)
693
+ return () => {
694
+ document.removeEventListener('mousedown', onDoc)
695
+ document.removeEventListener('keydown', onKey)
696
+ }
697
+ }, [open])
698
+
699
+ const selected = scopeTreeForPicker.all.find((o) => o.key === selectedKey)
700
+ const selectedLabel = selected?.label || 'Default Config'
701
+
702
+ const select = (key) => { onChange(key); setOpen(false) }
703
+
704
+ const renderItem = ({ key, label, indent = 0, isWebsite = false }) => {
705
+ const active = key === selectedKey
706
+ return (
707
+ <button
708
+ key={key}
709
+ type="button"
710
+ onClick={() => select(key)}
711
+ style={{
712
+ display: 'flex',
713
+ alignItems: 'center',
714
+ justifyContent: 'space-between',
715
+ width: '100%',
716
+ padding: `8px 12px 8px ${12 + indent * 18}px`,
717
+ background: active ? PALETTE.accentSoft : 'transparent',
718
+ color: active ? PALETTE.accent : PALETTE.text,
719
+ fontSize: 13,
720
+ fontWeight: active ? 700 : (isWebsite ? 600 : 500),
721
+ border: 0,
722
+ textAlign: 'left',
723
+ cursor: 'pointer',
724
+ font: 'inherit'
725
+ }}
726
+ onMouseOver={(e) => { if (!active) e.currentTarget.style.background = PALETTE.surfaceMuted }}
727
+ onMouseOut={(e) => { if (!active) e.currentTarget.style.background = 'transparent' }}
728
+ >
729
+ <span style={{ display: 'flex', alignItems: 'center', gap: 6, fontFamily: 'inherit' }}>
730
+ {indent > 0 && <span style={{ color: PALETTE.textMuted }}>↳</span>}
731
+ <span>{label}</span>
732
+ </span>
733
+ {active && <span style={{ color: PALETTE.accent, fontSize: 14 }}>✓</span>}
734
+ </button>
735
+ )
736
+ }
737
+
738
+ return (
739
+ <div ref={wrapperRef} style={{ position: 'relative' }}>
740
+ <button
741
+ type="button"
742
+ onClick={() => !disabled && setOpen((o) => !o)}
743
+ disabled={disabled}
744
+ aria-haspopup="listbox"
745
+ aria-expanded={open}
746
+ style={{
747
+ display: 'inline-flex',
748
+ alignItems: 'center',
749
+ gap: 8,
750
+ background: PALETTE.surface,
751
+ border: `1px solid ${PALETTE.border}`,
752
+ borderRadius: RADIUS.md,
753
+ padding: '6px 10px',
754
+ minWidth: 220,
755
+ fontFamily: 'inherit',
756
+ fontSize: 13,
757
+ fontWeight: 600,
758
+ color: PALETTE.text,
759
+ cursor: disabled ? 'not-allowed' : 'pointer',
760
+ opacity: disabled ? 0.6 : 1
761
+ }}
762
+ >
763
+ <Globe size="XS" />
764
+ <span style={{ flex: 1, textAlign: 'left', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
765
+ {selectedLabel}
766
+ </span>
767
+ <span style={{ color: PALETTE.textMuted, fontSize: 11 }}>▾</span>
768
+ </button>
769
+
770
+ {open && (
771
+ <div
772
+ role="listbox"
773
+ style={{
774
+ position: 'absolute',
775
+ top: '100%',
776
+ right: 0,
777
+ marginTop: 4,
778
+ minWidth: 280,
779
+ maxHeight: 420,
780
+ overflowY: 'auto',
781
+ background: PALETTE.surface,
782
+ border: `1px solid ${PALETTE.border}`,
783
+ borderRadius: RADIUS.lg,
784
+ boxShadow: SHADOW.dropdown,
785
+ zIndex: 100,
786
+ padding: 4
787
+ }}
788
+ >
789
+ {renderItem({ key: scopeTreeForPicker.default.key, label: scopeTreeForPicker.default.label, indent: 0 })}
790
+ {scopeTreeForPicker.websites.map((w) => (
791
+ <div key={w.websiteId} style={{ marginTop: 6, paddingTop: 6, borderTop: `1px solid ${PALETTE.border}` }}>
792
+ <div style={{
793
+ padding: '6px 12px 4px',
794
+ fontSize: 10,
795
+ fontWeight: 700,
796
+ letterSpacing: 0.8,
797
+ textTransform: 'uppercase',
798
+ color: PALETTE.textMuted
799
+ }}>
800
+ Website
801
+ </div>
802
+ {renderItem({ key: w.websiteOption.key, label: w.websiteOption.label, indent: 0, isWebsite: true })}
803
+ {w.items.map((s) => renderItem({ key: s.key, label: s.label, indent: 1 }))}
804
+ </div>
805
+ ))}
806
+ </div>
807
+ )}
808
+ </div>
809
+ )
810
+ }
811
+
812
+ // ----------------------------------------------------------------------------
813
+ // Top header (sticky)
814
+ // ----------------------------------------------------------------------------
815
+ function PageHeader ({
816
+ heroRef,
817
+ mode,
818
+ setMode,
819
+ scopeTree,
820
+ scopeTreeForPicker,
821
+ scopeKey,
822
+ onScopeChange,
823
+ onReloadStores,
824
+ onOpenTools,
825
+ toolsOpen
826
+ }) {
827
+ const isSchemaMode = mode === 'schema'
828
+ return (
829
+ <div
830
+ ref={heroRef}
831
+ style={{
832
+ // Hero card. Identical chrome to DataIngestion's hero — same border,
833
+ // radius, padding, shadow, font. Sticky so the title + scope picker
834
+ // stay reachable while scrolling long pages of fields.
835
+ position: 'sticky',
836
+ top: APP_NAV_OFFSET,
837
+ zIndex: 20,
838
+ background: PALETTE.surface,
839
+ border: `1px solid ${PALETTE.border}`,
840
+ borderRadius: RADIUS.xl,
841
+ padding: '20px 24px',
842
+ boxShadow: SHADOW.xs,
843
+ display: 'flex',
844
+ gap: 24,
845
+ alignItems: 'flex-start',
846
+ justifyContent: 'space-between',
847
+ flexWrap: 'wrap',
848
+ fontFamily: "adobe-clean, 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
849
+ }}
850
+ >
851
+ {/* Left: icon tile + eyebrow + title + subtitle */}
852
+ <div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', minWidth: 0 }}>
853
+ <div style={{
854
+ display: 'inline-flex',
855
+ padding: 10,
856
+ background: PALETTE.accentSoft,
857
+ color: PALETTE.accent,
858
+ borderRadius: RADIUS.lg,
859
+ flexShrink: 0
860
+ }}>
861
+ <Settings size="S" />
862
+ </div>
863
+ <div style={{ minWidth: 0 }}>
864
+ <div style={{
865
+ fontSize: 11, fontWeight: 700, letterSpacing: 0.6,
866
+ textTransform: 'uppercase', color: PALETTE.textMuted, marginBottom: 6
867
+ }}>
868
+ Configurations / App Builder
869
+ </div>
870
+ <div style={{ fontSize: 24, fontWeight: 700, color: PALETTE.text, lineHeight: 1.2 }}>
871
+ {isSchemaMode ? 'Schema Designer' : 'System Configuration'}
872
+ </div>
873
+ <div style={{ fontSize: 13, color: PALETTE.textMuted, marginTop: 6, maxWidth: 540 }}>
874
+ {isSchemaMode
875
+ ? 'Define sections, groups, and fields. Renaming an id strands existing values; removing one prompts to delete its stored values.'
876
+ : 'Manage configuration values across Default Config, websites, and store views — stored in App Builder DB.'}
877
+ </div>
878
+ </div>
879
+ </div>
880
+
881
+ {/* Right: scope picker + action buttons + Back */}
882
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
883
+ {mode === 'values' && (
884
+ <>
885
+ <ScopePicker
886
+ scopeTreeForPicker={scopeTreeForPicker}
887
+ selectedKey={scopeKey}
888
+ onChange={onScopeChange}
889
+ disabled={scopeTree.loading}
890
+ />
891
+ <TooltipTrigger>
892
+ <ActionButton onPress={onReloadStores} isDisabled={scopeTree.loading} aria-label="Reload stores">
893
+ <Refresh />
894
+ </ActionButton>
895
+ <Tooltip>Reload websites & stores from Commerce</Tooltip>
896
+ </TooltipTrigger>
897
+ <TooltipTrigger>
898
+ <ActionButton onPress={onOpenTools} aria-label="Open tools" isQuiet={!toolsOpen}>
899
+ <CloudUpload />
900
+ </ActionButton>
901
+ <Tooltip>Legacy migration tools</Tooltip>
902
+ </TooltipTrigger>
903
+ <TooltipTrigger>
904
+ <ActionButton onPress={() => setMode('schema')} aria-label="Edit schema">
905
+ <Edit />
906
+ </ActionButton>
907
+ <Tooltip>Edit schema</Tooltip>
908
+ </TooltipTrigger>
909
+ </>
910
+ )}
911
+ </div>
912
+ </div>
913
+ )
914
+ }
915
+
916
+ // ----------------------------------------------------------------------------
917
+ // Tools drawer (Export/Import + Commerce sync)
918
+ // ----------------------------------------------------------------------------
919
+ function ToolsPanel ({
920
+ onClose,
921
+ // Export / Import
922
+ onExport, exporting,
923
+ onImport, importing,
924
+ ioMsg,
925
+ ioProgress, // { phase, done, total, label }
926
+ importSourceKey, setImportSourceKey,
927
+ // Commerce sync
928
+ onSyncStoreMappings, syncingStoreMappings, syncMsg
929
+ }) {
930
+ return (
931
+ <Card style={{ marginBottom: 16 }}>
932
+ <Flex justifyContent="space-between" alignItems="center" marginBottom="size-150">
933
+ <Flex gap="size-100" alignItems="center">
934
+ <CloudUpload size="S" />
935
+ <Heading level={4} margin={0}>Export / Import</Heading>
936
+ </Flex>
937
+ <ActionButton isQuiet onPress={onClose} aria-label="Close tools">✕</ActionButton>
938
+ </Flex>
939
+ <Text UNSAFE_style={{ color: PALETTE.textMuted, fontSize: 13, display: 'block', marginBottom: 12 }}>
940
+ Download the entire configuration bundle as JSON for backup or to copy
941
+ between workspaces.
942
+ </Text>
943
+ <Flex gap="size-150" alignItems="center" wrap>
944
+ <Button variant="secondary" onPress={onExport} isDisabled={exporting || importing}>
945
+ {exporting ? 'Exporting…' : 'Export Configuration'}
946
+ </Button>
947
+ <Button variant="secondary" onPress={onImport} isDisabled={importing || exporting}>
948
+ {importing ? 'Importing…' : 'Import Configuration'}
949
+ </Button>
950
+ </Flex>
951
+ <View marginTop="size-150" UNSAFE_style={{ maxWidth: 520 }}>
952
+ <TextField
953
+ label="Source encryption key (only for legacy v1 dumps)"
954
+ type="password"
955
+ value={importSourceKey}
956
+ onChange={setImportSourceKey}
957
+ isDisabled={importing}
958
+ width="100%"
959
+ />
960
+ </View>
961
+
962
+ {ioProgress && ioProgress.phase === 'running' && (
963
+ <View marginTop="size-200">
964
+ {ioProgress.total > 0
965
+ ? (
966
+ <ProgressBar
967
+ label={ioProgress.label || 'Working…'}
968
+ value={ioProgress.done}
969
+ maxValue={ioProgress.total}
970
+ valueLabel={`${ioProgress.done} / ${ioProgress.total}`}
971
+ width="100%"
972
+ />
973
+ )
974
+ : (
975
+ <ProgressBar
976
+ label={ioProgress.label || 'Working…'}
977
+ isIndeterminate
978
+ width="100%"
979
+ />
980
+ )}
981
+ </View>
982
+ )}
983
+ {ioMsg && (
984
+ <View
985
+ marginTop="size-150"
986
+ padding="size-150"
987
+ UNSAFE_style={{
988
+ background: PALETTE.surface,
989
+ border: `1px solid ${PALETTE.border}`,
990
+ borderRadius: RADIUS.md
991
+ }}
992
+ >
993
+ <Text UNSAFE_style={{ whiteSpace: 'pre-line', fontSize: 13, fontFamily: 'ui-monospace, Menlo, monospace' }}>
994
+ {ioMsg}
995
+ </Text>
996
+ </View>
997
+ )}
998
+
999
+ <Divider size="S" marginY="size-250" />
1000
+
1001
+ <Flex justifyContent="space-between" alignItems="center" marginBottom="size-100">
1002
+ <Heading level={4} margin={0}>Sync Store Mappings</Heading>
1003
+ </Flex>
1004
+ <Text UNSAFE_style={{ color: PALETTE.textMuted, fontSize: 13, display: 'block', marginBottom: 12 }}>
1005
+ Rebuild <code>general/settings/store_mappings</code> from Commerce.
1006
+ </Text>
1007
+ <Flex gap="size-150" alignItems="center" wrap>
1008
+ <Button
1009
+ variant="secondary"
1010
+ onPress={onSyncStoreMappings}
1011
+ isDisabled={syncingStoreMappings || exporting || importing}
1012
+ >
1013
+ {syncingStoreMappings ? 'Syncing…' : 'Sync Store Mappings'}
1014
+ </Button>
1015
+ </Flex>
1016
+ {syncMsg && (
1017
+ <View
1018
+ marginTop="size-150"
1019
+ padding="size-150"
1020
+ UNSAFE_style={{
1021
+ background: PALETTE.surface,
1022
+ border: `1px solid ${PALETTE.border}`,
1023
+ borderRadius: RADIUS.md
1024
+ }}
1025
+ >
1026
+ <Text UNSAFE_style={{ whiteSpace: 'pre-line', fontSize: 13, fontFamily: 'ui-monospace, Menlo, monospace' }}>
1027
+ {syncMsg}
1028
+ </Text>
1029
+ </View>
1030
+ )}
1031
+ </Card>
1032
+ )
1033
+ }
1034
+
1035
+ // ----------------------------------------------------------------------------
1036
+ // Root
1037
+ // ----------------------------------------------------------------------------
1038
+ export default function SystemConfig (props) {
1039
+ const {
1040
+ schema,
1041
+ saveSchema,
1042
+ refresh: refreshSchema,
1043
+ loading: schemaLoading,
1044
+ saving: schemaSaving,
1045
+ error: schemaError
1046
+ } = useSystemConfigSchema(props)
1047
+ const [mode, setMode] = useState('values')
1048
+ const [toolsOpen, setToolsOpen] = useState(false)
1049
+ // Export / Import state
1050
+ const [exporting, setExporting] = useState(false)
1051
+ const [importing, setImporting] = useState(false)
1052
+ const [ioMsg, setIoMsg] = useState(null)
1053
+ // { phase: 'idle'|'running'|'done'|'error', done, total, label }
1054
+ const [ioProgress, setIoProgress] = useState({ phase: 'idle', done: 0, total: 0, label: '' })
1055
+ // Optional SOURCE env's SYSTEM_CONFIG_CRYPT_KEY for cross-env imports.
1056
+ const [importSourceKey, setImportSourceKey] = useState('')
1057
+ // Store-mapping sync state
1058
+ const [syncingStoreMappings, setSyncingStoreMappings] = useState(false)
1059
+ const [syncMsg, setSyncMsg] = useState(null)
1060
+ const { confirm, dialog: confirmDialog } = useConfirm()
1061
+
1062
+ // Measure the hero card so the sticky save bar / sidebar always sit
1063
+ // flush against its bottom — even when the subtitle wraps to a different
1064
+ // line count on narrow viewports. Exposed via a CSS variable that the
1065
+ // save bar and sidebars (in this file and in SystemConfigSchemaEditor)
1066
+ // consume via calc().
1067
+ const heroRef = useRef(null)
1068
+ useEffect(() => {
1069
+ if (!heroRef.current) return undefined
1070
+ const update = () => {
1071
+ const h = heroRef.current ? heroRef.current.offsetHeight : HERO_HEIGHT
1072
+ document.documentElement.style.setProperty('--sc-hero-h', `${h}px`)
1073
+ }
1074
+ update()
1075
+ const ro = new ResizeObserver(update)
1076
+ ro.observe(heroRef.current)
1077
+ return () => { ro.disconnect() }
1078
+ }, [mode])
1079
+
1080
+ // Single source of truth — both the sticky header (scope picker) and the
1081
+ // ValuesView render against the same hook instance. Previously each rendered
1082
+ // their own copy of useSystemConfig and changes never propagated.
1083
+ const configCtx = useSystemConfig(
1084
+ props,
1085
+ mode === 'values' ? schema : { sections: [] }
1086
+ )
1087
+ const { scope, setScope, scopeTree, refreshScopeTree } = configCtx
1088
+ const scopeTreeForPicker = useMemo(() => buildScopeTreeForPicker(scopeTree), [scopeTree])
1089
+ const scopeKey = `${scope.scope}::${scope.scopeId}`
1090
+ const onScopeChange = (key) => {
1091
+ const opt = scopeTreeForPicker.all.find((o) => o.key === key)
1092
+ if (!opt) return
1093
+ setScope({ scope: opt.scope, scopeId: opt.scopeId })
1094
+ }
1095
+
1096
+ const onSchemaSave = async (next) => {
1097
+ let result = await saveSchema(next)
1098
+ if (result?.needsConfirmation) {
1099
+ const removed = result.removedPaths || []
1100
+ const ok = await confirm({
1101
+ title: 'Removing schema entries will delete stored values',
1102
+ body:
1103
+ 'The following field path(s) are being removed from the schema. ' +
1104
+ 'Their values will be permanently deleted from system_config_data ' +
1105
+ 'across every scope:\n\n • ' + removed.join('\n • ') + '\n\n' +
1106
+ 'Continue?',
1107
+ confirmLabel: 'Delete & save',
1108
+ cancelLabel: 'Cancel',
1109
+ variant: 'destructive'
1110
+ })
1111
+ if (!ok) return
1112
+ result = await saveSchema(next, { confirmCascade: true })
1113
+ }
1114
+ if (!result?.ok) return
1115
+ if ((result.deletedCount || 0) > 0) {
1116
+ // Refresh values too so UI reflects the deletions.
1117
+ try { await configCtx.refresh() } catch (_) {}
1118
+ }
1119
+ setMode('values')
1120
+ }
1121
+
1122
+ // ── Export configuration → JSON file download ───────────────────────────
1123
+ const onExport = async () => {
1124
+ setExporting(true)
1125
+ setIoMsg(null)
1126
+ setIoProgress({ phase: 'running', done: 0, total: 0, label: 'Collecting schema + values from ABDB…' })
1127
+ try {
1128
+ const response = await callAction(
1129
+ props,
1130
+ getActionKey('exportConfig'),
1131
+ '',
1132
+ {}
1133
+ )
1134
+ const dump = response?.dump || response?.body?.dump
1135
+ if (!dump) throw new Error('Export response missing `dump`')
1136
+
1137
+ setIoProgress(p => ({ ...p, label: 'Building file…' }))
1138
+ const blob = new Blob([JSON.stringify(dump, null, 2)], { type: 'application/json' })
1139
+ const filename = `system-config-export-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
1140
+ const url = URL.createObjectURL(blob)
1141
+ const a = document.createElement('a')
1142
+ a.href = url
1143
+ a.download = filename
1144
+ document.body.appendChild(a)
1145
+ a.click()
1146
+ document.body.removeChild(a)
1147
+ URL.revokeObjectURL(url)
1148
+
1149
+ const c = dump.counts || {}
1150
+ setIoProgress({ phase: 'done', done: c.values || 0, total: c.values || 0, label: 'Export complete' })
1151
+ setIoMsg(`✓ Exported ${c.sections ?? '?'} section(s) and ${c.values ?? '?'} value(s) → ${filename}`)
1152
+ } catch (e) {
1153
+ console.error('Export failed', e)
1154
+ setIoProgress({ phase: 'error', done: 0, total: 0, label: 'Export failed' })
1155
+ setIoMsg(`Export failed: ${e.message || e}`)
1156
+ } finally {
1157
+ setExporting(false)
1158
+ }
1159
+ }
1160
+
1161
+ // ── Import configuration ← JSON file picker ─────────────────────────────
1162
+ // Imports a previously-exported dump in client-side chunks so we can drive a
1163
+ // determinate ProgressBar (instead of a single opaque 5-minute call). On the
1164
+ // backend, import-config translates website_id/store_id by matching codes
1165
+ // against the target env's current `general/settings/store_mappings`, so be
1166
+ // sure to Sync store_mappings before importing into a fresh env.
1167
+ const IMPORT_CHUNK_SIZE = 25
1168
+ const onImport = async () => {
1169
+ // Open file picker
1170
+ const input = document.createElement('input')
1171
+ input.type = 'file'
1172
+ input.accept = '.json,application/json'
1173
+ input.style.display = 'none'
1174
+ document.body.appendChild(input)
1175
+
1176
+ const file = await new Promise((resolve) => {
1177
+ input.onchange = () => { resolve(input.files && input.files[0]) }
1178
+ input.click()
1179
+ })
1180
+ document.body.removeChild(input)
1181
+ if (!file) return
1182
+
1183
+ let dump
1184
+ try {
1185
+ const text = await file.text()
1186
+ dump = JSON.parse(text)
1187
+ } catch (e) {
1188
+ setIoMsg(`Could not parse "${file.name}": ${e.message}`)
1189
+ return
1190
+ }
1191
+
1192
+ // Modern multi-choice confirm — pick overwrite vs insert-only.
1193
+ const choice = await confirm({
1194
+ title: `Import "${file.name}"?`,
1195
+ variant: 'information',
1196
+ body: (
1197
+ <span>
1198
+ Schema + values from this dump will be applied to the current
1199
+ workspace. website_id / store_id are remapped on the fly by matching
1200
+ <code> website_code </code> and store <code>code</code> against the
1201
+ target environment&apos;s Commerce instance.
1202
+ </span>
1203
+ ),
1204
+ choices: [
1205
+ {
1206
+ label: 'Overwrite existing values',
1207
+ value: 'overwrite',
1208
+ variant: 'destructive',
1209
+ description: 'Recommended for restoring a backup. Existing rows are replaced.'
1210
+ },
1211
+ {
1212
+ label: 'Insert-only',
1213
+ value: 'insert',
1214
+ variant: 'information',
1215
+ description: 'Skip rows that already exist; only add new ones.'
1216
+ }
1217
+ ],
1218
+ cancelLabel: 'Cancel'
1219
+ })
1220
+ if (!choice) return
1221
+ const overwrite = choice === 'overwrite'
1222
+
1223
+ const allValues = Array.isArray(dump.values) ? dump.values : []
1224
+ const schemaPayload = dump.schema
1225
+ const total = allValues.length
1226
+
1227
+ setImporting(true)
1228
+ setIoMsg(null)
1229
+ setIoProgress({
1230
+ phase: 'running',
1231
+ done: 0,
1232
+ total,
1233
+ label: schemaPayload ? 'Importing schema…' : 'Importing values…'
1234
+ })
1235
+
1236
+ const aggregate = {
1237
+ schemaImported: false,
1238
+ schemaSkipped: false,
1239
+ valuesInserted: 0,
1240
+ valuesUpserted: 0,
1241
+ valuesSkipped: 0,
1242
+ unmappedSkipped: 0,
1243
+ unmapped: [],
1244
+ invalid: [],
1245
+ idMap: null,
1246
+ sensitiveReencrypted: 0,
1247
+ sensitiveDecryptFailed: 0
1248
+ }
1249
+ const sensitiveCount = allValues.filter(
1250
+ v => typeof v?.value === 'string' && v.value.startsWith('enc:v1:')
1251
+ ).length
1252
+
1253
+ try {
1254
+ // 1) Schema (only on the first call). We still send the source
1255
+ // store_mappings each chunk so the backend can build the id map.
1256
+ if (schemaPayload) {
1257
+ const r = await callAction(
1258
+ props,
1259
+ getActionKey('importConfig'),
1260
+ '',
1261
+ { schema: schemaPayload, overwrite, valuesOnly: false, schemaOnly: true }
1262
+ )
1263
+ const s = r?.summary || r?.body?.summary
1264
+ if (s) {
1265
+ aggregate.schemaImported = !!s.schemaImported
1266
+ aggregate.schemaSkipped = !!s.schemaSkipped
1267
+ }
1268
+ }
1269
+
1270
+ // 2) Values in chunks. The backend resolves scope_id remap by reading
1271
+ // the target env's Commerce live and matching each row's
1272
+ // `scope_code` (stamped by export-config v2+) — no source mapping
1273
+ // needs to be carried in the dump.
1274
+ // sensitivePaths (added by export-config v2): tells the backend which
1275
+ // paths must be encrypted with the local key. Without this list the
1276
+ // backend has to derive it from the schema in ABDB, which may not be
1277
+ // present yet on a fresh import.
1278
+ const sensitivePaths = Array.isArray(dump.sensitivePaths) ? dump.sensitivePaths : undefined
1279
+
1280
+ setIoProgress(p => ({ ...p, label: 'Importing values…' }))
1281
+ for (let i = 0; i < total; i += IMPORT_CHUNK_SIZE) {
1282
+ const chunk = allValues.slice(i, i + IMPORT_CHUNK_SIZE)
1283
+ const r = await callAction(
1284
+ props,
1285
+ getActionKey('importConfig'),
1286
+ '',
1287
+ {
1288
+ values: chunk,
1289
+ overwrite,
1290
+ valuesOnly: true,
1291
+ // Re-encrypt sensitive ciphertext against the target env's key.
1292
+ sourceCryptKey: importSourceKey ? importSourceKey.trim() : undefined,
1293
+ // sensitivePaths on every chunk so the backend knows what to
1294
+ // encrypt even before the schema row lands.
1295
+ dump: sensitivePaths ? { sensitivePaths } : undefined
1296
+ }
1297
+ )
1298
+ const s = r?.summary || r?.body?.summary
1299
+ if (s) {
1300
+ aggregate.valuesInserted += s.valuesInserted || 0
1301
+ aggregate.valuesUpserted += s.valuesUpserted || 0
1302
+ aggregate.valuesSkipped += s.valuesSkipped || 0
1303
+ aggregate.unmappedSkipped += s.unmappedSkipped || 0
1304
+ aggregate.sensitiveReencrypted += s.sensitiveReencrypted || 0
1305
+ aggregate.sensitiveDecryptFailed += s.sensitiveDecryptFailed || 0
1306
+ if (Array.isArray(s.unmapped)) aggregate.unmapped.push(...s.unmapped)
1307
+ if (Array.isArray(s.invalid)) aggregate.invalid.push(...s.invalid)
1308
+ if (s.idMap) {
1309
+ if (!aggregate.idMap) {
1310
+ aggregate.idMap = { ...s.idMap }
1311
+ } else {
1312
+ aggregate.idMap.matchedByCode = (aggregate.idMap.matchedByCode || 0) + (s.idMap.matchedByCode || 0)
1313
+ aggregate.idMap.matchedById = (aggregate.idMap.matchedById || 0) + (s.idMap.matchedById || 0)
1314
+ }
1315
+ }
1316
+ }
1317
+ setIoProgress({
1318
+ phase: 'running',
1319
+ done: Math.min(i + chunk.length, total),
1320
+ total,
1321
+ label: `Importing values… (${Math.min(i + chunk.length, total)}/${total})`
1322
+ })
1323
+ }
1324
+
1325
+ const lines = [
1326
+ `✓ Import complete (${overwrite ? 'overwrite' : 'insert-only'})`,
1327
+ ` Schema: ${aggregate.schemaImported ? 'imported' : aggregate.schemaSkipped ? 'skipped (exists)' : 'no schema in dump'}`,
1328
+ ` Values: inserted=${aggregate.valuesInserted} upserted=${aggregate.valuesUpserted} skipped=${aggregate.valuesSkipped}`,
1329
+ aggregate.unmappedSkipped
1330
+ ? ` ⚠ Unmapped rows skipped (no matching website_code/store_code in target): ${aggregate.unmappedSkipped}`
1331
+ : '',
1332
+ sensitiveCount
1333
+ ? ` Sensitive: ${sensitiveCount} ciphertext row(s) in dump → re-encrypted=${aggregate.sensitiveReencrypted}, decrypt-failed=${aggregate.sensitiveDecryptFailed}${
1334
+ importSourceKey ? '' : ' (no source key provided — values may show blank if this env\'s key differs)'
1335
+ }`
1336
+ : '',
1337
+ aggregate.invalid.length ? ` ⚠ Invalid rows: ${aggregate.invalid.length}` : '',
1338
+ aggregate.idMap
1339
+ ? [
1340
+ ` id remap → target(${aggregate.idMap.targetSource || 'none'}, websites=${aggregate.idMap.targetWebsiteCount || 0}, stores=${aggregate.idMap.targetStoreCount || 0}) matched(by-code=${aggregate.idMap.matchedByCode || 0}, by-id=${aggregate.idMap.matchedById || 0})`,
1341
+ !aggregate.idMap.hasTarget ? ' ⚠ Target env Commerce returned no stores — check COMMERCE_BASE_URL / OAuth1 secrets in this workspace.' : ''
1342
+ ].filter(Boolean).join('\n')
1343
+ : ''
1344
+ ].filter(Boolean)
1345
+ setIoMsg(lines.join('\n'))
1346
+ setIoProgress({ phase: 'done', done: total, total, label: 'Import complete' })
1347
+ await refreshSchema()
1348
+ try { await configCtx.refresh() } catch (_) {}
1349
+ } catch (e) {
1350
+ console.error('Import failed', e)
1351
+ setIoProgress(p => ({ ...p, phase: 'error', label: 'Import failed' }))
1352
+ setIoMsg(`Import failed: ${e.message || e}`)
1353
+ } finally {
1354
+ setImporting(false)
1355
+ }
1356
+ }
1357
+
1358
+ // ── Sync Store Mappings from Commerce REST ──────────────────────────────
1359
+ const onSyncStoreMappings = async () => {
1360
+ setSyncingStoreMappings(true)
1361
+ setSyncMsg('Fetching websites + store views from Commerce…')
1362
+ try {
1363
+ const response = await callAction(
1364
+ props,
1365
+ getActionKey('syncStoreMappings'),
1366
+ '',
1367
+ {}
1368
+ )
1369
+ const ok = response?.ok ?? response?.body?.ok
1370
+ const count = response?.count ?? response?.body?.count
1371
+ const mapping = response?.mapping ?? response?.body?.mapping
1372
+ if (!ok) throw new Error('Sync response missing `ok`')
1373
+ const sample = mapping
1374
+ ? Object.entries(mapping).slice(0, 5).map(([id, m]) =>
1375
+ ` ${id}: ${m.code} → website ${m.website_code}(${m.website_id}), lang=${m.language_code}`
1376
+ ).join('\n')
1377
+ : ''
1378
+ setSyncMsg(
1379
+ `✓ Synced ${count} store(s) → general/settings/store_mappings\n` +
1380
+ (sample ? sample + (count > 5 ? `\n … (${count - 5} more)` : '') : '')
1381
+ )
1382
+ try { await configCtx.refresh() } catch (_) {}
1383
+ } catch (e) {
1384
+ console.error('Store-mapping sync failed', e)
1385
+ setSyncMsg(`Sync failed: ${e.message || e}`)
1386
+ } finally {
1387
+ setSyncingStoreMappings(false)
1388
+ }
1389
+ }
1390
+
1391
+ return (
1392
+ <View
1393
+ UNSAFE_style={{
1394
+ background: PALETTE.bg,
1395
+ minHeight: '100vh',
1396
+ color: PALETTE.text
1397
+ }}
1398
+ >
1399
+ {confirmDialog}
1400
+ <View padding="size-400" maxWidth="1400px" marginX="auto">
1401
+ <PageHeader
1402
+ heroRef={heroRef}
1403
+ mode={mode}
1404
+ setMode={setMode}
1405
+ scopeTree={scopeTree}
1406
+ scopeTreeForPicker={scopeTreeForPicker}
1407
+ scopeKey={scopeKey}
1408
+ onScopeChange={onScopeChange}
1409
+ onReloadStores={refreshScopeTree}
1410
+ onOpenTools={() => setToolsOpen((o) => !o)}
1411
+ toolsOpen={toolsOpen}
1412
+ />
1413
+
1414
+ <div style={{ paddingTop: 24 }}>
1415
+ {toolsOpen && mode === 'values' && (
1416
+ <ToolsPanel
1417
+ onClose={() => setToolsOpen(false)}
1418
+ onExport={onExport}
1419
+ exporting={exporting}
1420
+ onImport={onImport}
1421
+ importing={importing}
1422
+ ioMsg={ioMsg}
1423
+ ioProgress={ioProgress}
1424
+ importSourceKey={importSourceKey}
1425
+ setImportSourceKey={setImportSourceKey}
1426
+ onSyncStoreMappings={onSyncStoreMappings}
1427
+ syncingStoreMappings={syncingStoreMappings}
1428
+ syncMsg={syncMsg}
1429
+ />
1430
+ )}
1431
+
1432
+ {schemaLoading
1433
+ ? (
1434
+ <Card>
1435
+ <Flex justifyContent="center" marginY="size-400">
1436
+ <ProgressCircle aria-label="Loading schema" isIndeterminate />
1437
+ </Flex>
1438
+ </Card>
1439
+ )
1440
+ : mode === 'schema'
1441
+ ? (
1442
+ <SystemConfigSchemaEditor
1443
+ schema={schema}
1444
+ onSave={onSchemaSave}
1445
+ onCancel={() => setMode('values')}
1446
+ saving={schemaSaving}
1447
+ error={schemaError}
1448
+ palette={PALETTE}
1449
+ />
1450
+ )
1451
+ : (
1452
+ <ValuesView
1453
+ schema={schema}
1454
+ onEditSchema={() => setMode('schema')}
1455
+ toolsOpen={toolsOpen}
1456
+ setToolsOpen={setToolsOpen}
1457
+ configCtx={configCtx}
1458
+ />
1459
+ )}
1460
+ </div>
1461
+ </View>
1462
+ </View>
1463
+ )
1464
+ }