domma-cms 0.1.0 → 0.2.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 (87) hide show
  1. package/admin/css/admin.css +78 -1
  2. package/admin/js/api.js +32 -0
  3. package/admin/js/app.js +24 -7
  4. package/admin/js/config/sidebar-config.js +8 -0
  5. package/admin/js/templates/collection-editor.html +80 -0
  6. package/admin/js/templates/collection-entries.html +36 -0
  7. package/admin/js/templates/collections.html +12 -0
  8. package/admin/js/templates/documentation.html +136 -0
  9. package/admin/js/templates/navigation.html +26 -4
  10. package/admin/js/templates/page-editor.html +91 -85
  11. package/admin/js/templates/settings.html +433 -172
  12. package/admin/js/views/collection-editor.js +487 -0
  13. package/admin/js/views/collection-entries.js +484 -0
  14. package/admin/js/views/collections.js +153 -0
  15. package/admin/js/views/dashboard.js +14 -6
  16. package/admin/js/views/index.js +9 -3
  17. package/admin/js/views/login.js +3 -2
  18. package/admin/js/views/navigation.js +77 -11
  19. package/admin/js/views/page-editor.js +207 -25
  20. package/admin/js/views/pages.js +14 -6
  21. package/admin/js/views/settings.js +137 -2
  22. package/admin/js/views/users.js +10 -7
  23. package/bin/cli.js +37 -10
  24. package/config/auth.json +2 -1
  25. package/config/content.json +1 -0
  26. package/config/navigation.json +14 -4
  27. package/config/plugins.json +0 -18
  28. package/config/presets.json +4 -8
  29. package/config/site.json +44 -3
  30. package/package.json +6 -2
  31. package/plugins/domma-effects/admin/templates/domma-effects.html +92 -3
  32. package/plugins/domma-effects/plugin.js +125 -0
  33. package/plugins/domma-effects/public/inject-body.html +19 -0
  34. package/plugins/example-analytics/admin/views/analytics.js +2 -2
  35. package/plugins/example-analytics/plugin.json +8 -0
  36. package/plugins/example-analytics/stats.json +15 -1
  37. package/plugins/form-builder/admin/templates/form-editor.html +19 -6
  38. package/plugins/form-builder/admin/views/form-editor.js +634 -9
  39. package/plugins/form-builder/admin/views/form-submissions.js +4 -4
  40. package/plugins/form-builder/admin/views/forms-list.js +5 -5
  41. package/plugins/form-builder/data/forms/consent.json +104 -0
  42. package/plugins/form-builder/data/forms/contacts.json +66 -0
  43. package/plugins/form-builder/data/submissions/consent.json +13 -0
  44. package/plugins/form-builder/data/submissions/contacts.json +26 -0
  45. package/plugins/form-builder/plugin.js +62 -11
  46. package/plugins/form-builder/plugin.json +12 -16
  47. package/plugins/form-builder/public/form-logic-engine.js +568 -0
  48. package/plugins/form-builder/public/inject-body.html +88 -6
  49. package/plugins/form-builder/public/inject-head.html +16 -0
  50. package/plugins/form-builder/public/package.json +1 -0
  51. package/public/css/site.css +113 -0
  52. package/public/js/btt.js +90 -0
  53. package/public/js/cookie-consent.js +61 -0
  54. package/public/js/site.js +129 -34
  55. package/scripts/build.js +129 -0
  56. package/scripts/seed.js +517 -7
  57. package/server/routes/api/collections.js +301 -0
  58. package/server/routes/api/settings.js +66 -2
  59. package/server/server.js +19 -15
  60. package/server/services/collections.js +430 -0
  61. package/server/services/content.js +11 -2
  62. package/server/services/hooks.js +109 -0
  63. package/server/services/markdown.js +500 -149
  64. package/server/services/plugins.js +6 -1
  65. package/server/services/renderer.js +73 -7
  66. package/server/templates/page.html +38 -3
  67. package/plugins/back-to-top/admin/templates/back-to-top-settings.html +0 -55
  68. package/plugins/back-to-top/admin/views/back-to-top-settings.js +0 -44
  69. package/plugins/back-to-top/config.js +0 -10
  70. package/plugins/back-to-top/plugin.js +0 -24
  71. package/plugins/back-to-top/plugin.json +0 -36
  72. package/plugins/back-to-top/public/inject-body.html +0 -105
  73. package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +0 -113
  74. package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +0 -73
  75. package/plugins/cookie-consent/config.js +0 -30
  76. package/plugins/cookie-consent/plugin.js +0 -24
  77. package/plugins/cookie-consent/plugin.json +0 -36
  78. package/plugins/cookie-consent/public/inject-body.html +0 -69
  79. package/plugins/custom-css/admin/templates/custom-css.html +0 -17
  80. package/plugins/custom-css/admin/views/custom-css.js +0 -35
  81. package/plugins/custom-css/config.js +0 -1
  82. package/plugins/custom-css/data/custom.css +0 -0
  83. package/plugins/custom-css/plugin.js +0 -63
  84. package/plugins/custom-css/plugin.json +0 -32
  85. package/plugins/custom-css/public/inject-head.html +0 -1
  86. package/plugins/form-builder/data/forms/contact.json +0 -52
  87. package/plugins/form-builder/data/submissions/contact.json +0 -14
@@ -36,6 +36,15 @@ function getTypeLabel(type) {
36
36
  return FIELD_TYPES.find(t => t.value === type)?.label || type;
37
37
  }
38
38
 
39
+ function hasLogic(logic) {
40
+ if (!logic) return false;
41
+ if (logic.visibility?.conditions?.length) return true;
42
+ if (logic.requirement?.conditions?.length) return true;
43
+ if (logic.validation?.length) return true;
44
+ if (logic.cascade?.sourceField) return true;
45
+ return false;
46
+ }
47
+
39
48
  // ---------------------------------------------------------------------------
40
49
  // Read field values from DOM back into array
41
50
  // ---------------------------------------------------------------------------
@@ -43,6 +52,8 @@ function getTypeLabel(type) {
43
52
  function collectFieldFromDOM(idx) {
44
53
  const field = { ...fields[idx] };
45
54
 
55
+ if (field.type === 'spacer') return field;
56
+
46
57
  // Page-break cards only have label + description inputs
47
58
  if (field.type === 'page-break') {
48
59
  const labelEl = document.getElementById(`fb-pb-label-${idx}`);
@@ -91,9 +102,119 @@ function collectFieldFromDOM(idx) {
91
102
  const maxNum = document.getElementById(`fb-max-${idx}`)?.value;
92
103
  if (maxNum !== '' && maxNum !== undefined) field.max = parseFloat(maxNum);
93
104
 
105
+ // Collect conditional logic from the logic builder UI
106
+ const collectedLogic = collectLogicFromCard(idx);
107
+ if (collectedLogic) {
108
+ field.logic = collectedLogic;
109
+ } else {
110
+ delete field.logic;
111
+ }
112
+
94
113
  return field;
95
114
  }
96
115
 
116
+ function collectLogicFromCard(idx) {
117
+ const card = document.querySelector(`.fb-field-card[data-index="${idx}"]`);
118
+ if (!card) return undefined;
119
+ const logicSection = card.querySelector('.fb-field-logic');
120
+ if (!logicSection) return undefined;
121
+
122
+ const logic = {};
123
+ let hasAnyLogic = false;
124
+
125
+ // Visibility
126
+ const visSection = logicSection.querySelector('[data-logic-section="visibility"]');
127
+ if (visSection) {
128
+ const defaultSel = visSection.querySelector('.fb-logic-vis-default');
129
+ const transSel = visSection.querySelector('.fb-logic-vis-transition');
130
+ const visRules = Array.from(visSection.querySelectorAll('.fb-logic-cond-row')).map(row => {
131
+ const fieldSel = row.querySelector('.fb-logic-cond-field');
132
+ const opSel = row.querySelector('.fb-logic-cond-op');
133
+ const valInp = row.querySelector('.fb-logic-cond-val');
134
+ const thenSel = row.querySelector('.fb-logic-vis-then');
135
+ if (!fieldSel?.value) return null;
136
+ return {
137
+ when: { all: [{ field: fieldSel.value, operator: opSel.value, value: valInp.value }] },
138
+ then: thenSel.value
139
+ };
140
+ }).filter(Boolean);
141
+ const visDefault = defaultSel?.value || 'visible';
142
+ const transition = transSel?.value || 'none';
143
+ if (visDefault !== 'visible' || visRules.length > 0 || transition !== 'none') {
144
+ logic.visibility = { default: visDefault, conditions: visRules };
145
+ if (transition !== 'none') logic.visibility.transition = transition;
146
+ hasAnyLogic = true;
147
+ }
148
+ }
149
+
150
+ // Requirement
151
+ const reqSection = logicSection.querySelector('[data-logic-section="requirement"]');
152
+ if (reqSection) {
153
+ const defaultCb = reqSection.querySelector('.fb-logic-req-default');
154
+ const reqRules = Array.from(reqSection.querySelectorAll('.fb-logic-cond-row')).map(row => {
155
+ const fieldSel = row.querySelector('.fb-logic-cond-field');
156
+ const opSel = row.querySelector('.fb-logic-cond-op');
157
+ const valInp = row.querySelector('.fb-logic-cond-val');
158
+ const thenSel = row.querySelector('.fb-logic-req-then');
159
+ if (!fieldSel?.value) return null;
160
+ return {
161
+ when: { all: [{ field: fieldSel.value, operator: opSel.value, value: valInp.value }] },
162
+ then: thenSel.value === 'true'
163
+ };
164
+ }).filter(Boolean);
165
+ if (reqRules.length > 0) {
166
+ logic.requirement = { default: defaultCb?.checked === true, conditions: reqRules };
167
+ hasAnyLogic = true;
168
+ }
169
+ }
170
+
171
+ // Validation
172
+ const valSection = logicSection.querySelector('[data-logic-section="validation"]');
173
+ if (valSection) {
174
+ const valRules = Array.from(valSection.querySelectorAll('.fb-logic-val-rule')).map(row => {
175
+ const typeSel = row.querySelector('.fb-logic-val-type');
176
+ const patternInp = row.querySelector('.fb-logic-val-pattern');
177
+ const flagsInp = row.querySelector('.fb-logic-val-flags');
178
+ const msgInp = row.querySelector('.fb-logic-val-message');
179
+ if (!patternInp?.value.trim()) return null;
180
+ const type = typeSel?.value || 'regex';
181
+ const rule = { type, message: msgInp?.value.trim() || 'Invalid value.' };
182
+ if (type === 'regex') {
183
+ rule.pattern = patternInp.value.trim();
184
+ if (flagsInp?.value.trim()) rule.flags = flagsInp.value.trim();
185
+ } else {
186
+ rule.field = patternInp.value.trim();
187
+ }
188
+ return rule;
189
+ }).filter(Boolean);
190
+ if (valRules.length > 0) {
191
+ logic.validation = valRules;
192
+ hasAnyLogic = true;
193
+ }
194
+ }
195
+
196
+ // Cascade
197
+ const cascSection = logicSection.querySelector('[data-logic-section="cascade"]');
198
+ if (cascSection) {
199
+ const srcSel = cascSection.querySelector('.fb-logic-cascade-source');
200
+ const mapTa = cascSection.querySelector('.fb-logic-cascade-mapping');
201
+ const defTa = cascSection.querySelector('.fb-logic-cascade-defaults');
202
+ const sourceField = srcSel?.value?.trim();
203
+ if (sourceField) {
204
+ let mapping = {};
205
+ try { mapping = JSON.parse(mapTa?.value || '{}'); } catch { /* invalid json */ }
206
+ const defaultOptions = (defTa?.value || '').split('\n').filter(l => l.trim()).map(line => {
207
+ const [v, ...rest] = line.split(':');
208
+ return { value: v.trim(), label: rest.join(':').trim() || v.trim() };
209
+ });
210
+ logic.cascade = { sourceField, mapping, defaultOptions };
211
+ hasAnyLogic = true;
212
+ }
213
+ }
214
+
215
+ return hasAnyLogic ? logic : undefined;
216
+ }
217
+
97
218
  function syncAllFieldsFromDOM() {
98
219
  fields = fields.map((_, idx) => collectFieldFromDOM(idx));
99
220
  }
@@ -119,6 +240,8 @@ function renderFieldList($container) {
119
240
  fields.forEach((field, idx) => {
120
241
  const card = field.type === 'page-break'
121
242
  ? buildPageBreakCard(field, idx, $container)
243
+ : field.type === 'spacer'
244
+ ? buildSpacerCard(field, idx, $container)
122
245
  : buildFieldCard(field, idx, $container);
123
246
  listEl.appendChild(card);
124
247
  });
@@ -231,6 +354,79 @@ function buildPageBreakCard(field, idx, $container) {
231
354
  return card;
232
355
  }
233
356
 
357
+ // ---------------------------------------------------------------------------
358
+ // Spacer card
359
+ // ---------------------------------------------------------------------------
360
+
361
+ function buildSpacerCard(field, idx, $container) {
362
+ const card = document.createElement('div');
363
+ card.className = 'fb-field-card';
364
+ card.dataset.index = idx;
365
+ card.style.cssText = 'border:1px dashed var(--border-color,#444);border-radius:6px;margin-bottom:.5rem;background:transparent;';
366
+
367
+ const header = document.createElement('div');
368
+ header.style.cssText = 'display:flex;align-items:center;gap:.6rem;padding:.4rem .8rem;';
369
+
370
+ const line = document.createElement('div');
371
+ line.style.cssText = 'flex:1;height:1px;background:var(--border-color,#444);';
372
+
373
+ const label = document.createElement('span');
374
+ label.textContent = 'Spacer';
375
+ label.style.cssText = 'font-size:.7rem;color:var(--text-muted,#888);white-space:nowrap;padding:0 .4rem;font-style:italic;';
376
+
377
+ const line2 = document.createElement('div');
378
+ line2.style.cssText = 'flex:1;height:1px;background:var(--border-color,#444);';
379
+
380
+ const controls = document.createElement('div');
381
+ controls.style.cssText = 'display:flex;gap:.25rem;flex-shrink:0;';
382
+
383
+ if (idx > 0) {
384
+ const upBtn = document.createElement('button');
385
+ upBtn.className = 'btn btn-xs btn-ghost';
386
+ upBtn.title = 'Move up';
387
+ upBtn.textContent = '↑';
388
+ upBtn.addEventListener('click', (e) => {
389
+ e.stopPropagation();
390
+ syncAllFieldsFromDOM();
391
+ [fields[idx - 1], fields[idx]] = [fields[idx], fields[idx - 1]];
392
+ renderFieldList($container);
393
+ });
394
+ controls.appendChild(upBtn);
395
+ }
396
+ if (idx < fields.length - 1) {
397
+ const downBtn = document.createElement('button');
398
+ downBtn.className = 'btn btn-xs btn-ghost';
399
+ downBtn.title = 'Move down';
400
+ downBtn.textContent = '↓';
401
+ downBtn.addEventListener('click', (e) => {
402
+ e.stopPropagation();
403
+ syncAllFieldsFromDOM();
404
+ [fields[idx], fields[idx + 1]] = [fields[idx + 1], fields[idx]];
405
+ renderFieldList($container);
406
+ });
407
+ controls.appendChild(downBtn);
408
+ }
409
+
410
+ const removeBtn = document.createElement('button');
411
+ removeBtn.className = 'btn btn-xs btn-danger';
412
+ removeBtn.title = 'Remove spacer';
413
+ removeBtn.textContent = '✕';
414
+ removeBtn.addEventListener('click', async (e) => {
415
+ e.stopPropagation();
416
+ syncAllFieldsFromDOM();
417
+ fields.splice(idx, 1);
418
+ renderFieldList($container);
419
+ });
420
+ controls.appendChild(removeBtn);
421
+
422
+ header.appendChild(line);
423
+ header.appendChild(label);
424
+ header.appendChild(line2);
425
+ header.appendChild(controls);
426
+ card.appendChild(header);
427
+ return card;
428
+ }
429
+
234
430
  // ---------------------------------------------------------------------------
235
431
  // Regular field card
236
432
  // ---------------------------------------------------------------------------
@@ -315,6 +511,13 @@ function buildFieldCard(field, idx, $container) {
315
511
  req.style.cssText = 'font-size:.7rem;color:var(--danger,#ef4444);flex-shrink:0;';
316
512
  header.appendChild(req);
317
513
  }
514
+ if (hasLogic(field.logic)) {
515
+ const logicBadge = document.createElement('span');
516
+ logicBadge.textContent = '⚡';
517
+ logicBadge.title = 'Has conditional logic';
518
+ logicBadge.style.cssText = 'font-size:.75rem;color:var(--primary,#6366f1);flex-shrink:0;';
519
+ header.appendChild(logicBadge);
520
+ }
318
521
  header.appendChild(controls);
319
522
 
320
523
  // Click header to toggle body
@@ -390,9 +593,408 @@ function buildFieldBody(field, idx, labelSpan) {
390
593
  const extras = buildFieldExtras(field.type, field, idx);
391
594
  if (extras) body.appendChild(extras);
392
595
 
596
+ // Conditional logic builder
597
+ body.appendChild(buildFieldLogic(field, idx));
598
+
393
599
  return body;
394
600
  }
395
601
 
602
+ // ---------------------------------------------------------------------------
603
+ // Conditional Logic Builder UI
604
+ // ---------------------------------------------------------------------------
605
+
606
+ const CONDITION_OPERATORS = [
607
+ { value: 'equals', label: 'equals' },
608
+ { value: 'not_equals', label: 'does not equal' },
609
+ { value: 'contains', label: 'contains' },
610
+ { value: 'not_contains', label: 'does not contain' },
611
+ { value: 'starts_with', label: 'starts with' },
612
+ { value: 'ends_with', label: 'ends with' },
613
+ { value: 'greater_than', label: 'is greater than' },
614
+ { value: 'less_than', label: 'is less than' },
615
+ { value: 'is_empty', label: 'is empty' },
616
+ { value: 'is_not_empty', label: 'is not empty' },
617
+ { value: 'in', label: 'is one of (comma sep)' },
618
+ { value: 'not_in', label: 'is not one of (comma sep)' },
619
+ { value: 'matches_regex', label: 'matches regex' },
620
+ ];
621
+
622
+ const NO_VALUE_OPS = new Set(['is_empty', 'is_not_empty']);
623
+
624
+ function buildLogicSectionHeader(text) {
625
+ const p = document.createElement('p');
626
+ p.textContent = text;
627
+ p.style.cssText = 'font-size:.75rem;font-weight:700;color:var(--text-muted,#888);margin:.6rem 0 .3rem;text-transform:uppercase;letter-spacing:.04em;';
628
+ return p;
629
+ }
630
+
631
+ function buildConditionRow(condition, fieldOptions, thenOptions, thenClass, thenSelected) {
632
+ const row = document.createElement('div');
633
+ row.className = 'fb-logic-cond-row';
634
+ row.style.cssText = 'display:flex;gap:.35rem;align-items:center;margin-bottom:.35rem;flex-wrap:wrap;';
635
+
636
+ const whenLabel = document.createElement('span');
637
+ whenLabel.textContent = 'When';
638
+ whenLabel.style.cssText = 'font-size:.73rem;color:var(--text-muted,#888);flex-shrink:0;';
639
+
640
+ const fieldSel = document.createElement('select');
641
+ fieldSel.className = 'form-input fb-logic-cond-field';
642
+ fieldSel.style.cssText = 'flex:2;min-width:80px;font-size:.78rem;padding:.2rem .35rem;';
643
+ fieldOptions.forEach(opt => {
644
+ const o = document.createElement('option');
645
+ o.value = opt.value;
646
+ o.textContent = opt.label;
647
+ if (condition && opt.value === condition.field) o.selected = true;
648
+ fieldSel.appendChild(o);
649
+ });
650
+
651
+ const opSel = document.createElement('select');
652
+ opSel.className = 'form-input fb-logic-cond-op';
653
+ opSel.style.cssText = 'flex:2;min-width:80px;font-size:.78rem;padding:.2rem .35rem;';
654
+ CONDITION_OPERATORS.forEach(op => {
655
+ const o = document.createElement('option');
656
+ o.value = op.value;
657
+ o.textContent = op.label;
658
+ if (condition && op.value === condition.operator) o.selected = true;
659
+ opSel.appendChild(o);
660
+ });
661
+
662
+ const valInp = document.createElement('input');
663
+ valInp.type = 'text';
664
+ valInp.className = 'form-input fb-logic-cond-val';
665
+ valInp.placeholder = 'value';
666
+ valInp.style.cssText = 'flex:2;min-width:60px;font-size:.78rem;padding:.2rem .35rem;';
667
+ valInp.value = condition?.value || '';
668
+ if (condition && NO_VALUE_OPS.has(condition.operator)) valInp.style.display = 'none';
669
+ opSel.addEventListener('change', () => {
670
+ valInp.style.display = NO_VALUE_OPS.has(opSel.value) ? 'none' : '';
671
+ });
672
+
673
+ const arrow = document.createElement('span');
674
+ arrow.textContent = '→';
675
+ arrow.style.cssText = 'font-size:.73rem;color:var(--text-muted,#888);flex-shrink:0;';
676
+
677
+ const thenSel = document.createElement('select');
678
+ thenSel.className = `form-input ${thenClass}`;
679
+ thenSel.style.cssText = 'flex:2;min-width:80px;font-size:.78rem;padding:.2rem .35rem;';
680
+ thenOptions.forEach(opt => {
681
+ const o = document.createElement('option');
682
+ o.value = opt.value;
683
+ o.textContent = opt.label;
684
+ if (opt.value === thenSelected) o.selected = true;
685
+ thenSel.appendChild(o);
686
+ });
687
+
688
+ const removeBtn = document.createElement('button');
689
+ removeBtn.type = 'button';
690
+ removeBtn.className = 'btn btn-xs btn-danger';
691
+ removeBtn.textContent = '✕';
692
+ removeBtn.style.flexShrink = '0';
693
+ removeBtn.addEventListener('click', () => row.remove());
694
+
695
+ row.appendChild(whenLabel);
696
+ row.appendChild(fieldSel);
697
+ row.appendChild(opSel);
698
+ row.appendChild(valInp);
699
+ row.appendChild(arrow);
700
+ row.appendChild(thenSel);
701
+ row.appendChild(removeBtn);
702
+ return row;
703
+ }
704
+
705
+ function buildVisibilitySection(vis, idx, otherFields) {
706
+ const section = document.createElement('div');
707
+ section.dataset.logicSection = 'visibility';
708
+ section.appendChild(buildLogicSectionHeader('Visibility'));
709
+
710
+ const defaultRow = document.createElement('div');
711
+ defaultRow.style.cssText = 'display:flex;align-items:center;gap:.5rem;margin-bottom:.4rem;';
712
+ const defaultLabel = document.createElement('span');
713
+ defaultLabel.textContent = 'Default:';
714
+ defaultLabel.style.cssText = 'font-size:.8rem;flex-shrink:0;';
715
+ const defaultSel = document.createElement('select');
716
+ defaultSel.className = 'form-input fb-logic-vis-default';
717
+ defaultSel.style.cssText = 'font-size:.8rem;padding:.25rem .4rem;';
718
+ [{ value: 'visible', label: 'Visible' }, { value: 'hidden', label: 'Hidden' }].forEach(opt => {
719
+ const o = document.createElement('option');
720
+ o.value = opt.value;
721
+ o.textContent = opt.label;
722
+ if (opt.value === (vis.default || 'visible')) o.selected = true;
723
+ defaultSel.appendChild(o);
724
+ });
725
+ defaultRow.appendChild(defaultLabel);
726
+ defaultRow.appendChild(defaultSel);
727
+ section.appendChild(defaultRow);
728
+
729
+ // Transition type dropdown
730
+ const transRow = document.createElement('div');
731
+ transRow.style.cssText = 'display:flex;align-items:center;gap:.5rem;margin-bottom:.4rem;';
732
+ const transLabel = document.createElement('span');
733
+ transLabel.textContent = 'Transition:';
734
+ transLabel.style.cssText = 'font-size:.8rem;flex-shrink:0;';
735
+ const transSel = document.createElement('select');
736
+ transSel.className = 'form-input fb-logic-vis-transition';
737
+ transSel.style.cssText = 'font-size:.8rem;padding:.25rem .4rem;';
738
+ [
739
+ {value: 'none', label: 'None (instant)'},
740
+ {value: 'fade', label: 'Fade'},
741
+ {value: 'slide', label: 'Slide'},
742
+ {value: 'scale', label: 'Scale'},
743
+ ].forEach(opt => {
744
+ const o = document.createElement('option');
745
+ o.value = opt.value;
746
+ o.textContent = opt.label;
747
+ if (opt.value === (vis.transition || 'none')) o.selected = true;
748
+ transSel.appendChild(o);
749
+ });
750
+ transRow.appendChild(transLabel);
751
+ transRow.appendChild(transSel);
752
+ section.appendChild(transRow);
753
+
754
+ const rulesContainer = document.createElement('div');
755
+ rulesContainer.className = 'fb-logic-vis-rules';
756
+ const fieldOpts = otherFields.map(f => ({ value: f.name, label: f.label || f.name }));
757
+ const thenOpts = [{ value: 'visible', label: 'Show' }, { value: 'hidden', label: 'Hide' }];
758
+
759
+ (vis.conditions || []).forEach(rule => {
760
+ const cond = (rule.when?.all || rule.when?.any || [])[0];
761
+ const thenVal = rule.then === 'hidden' ? 'hidden' : 'visible';
762
+ if (fieldOpts.length > 0) rulesContainer.appendChild(buildConditionRow(cond, fieldOpts, thenOpts, 'fb-logic-vis-then', thenVal));
763
+ });
764
+ section.appendChild(rulesContainer);
765
+
766
+ if (fieldOpts.length > 0) {
767
+ const addBtn = document.createElement('button');
768
+ addBtn.type = 'button';
769
+ addBtn.className = 'btn btn-xs btn-ghost';
770
+ addBtn.style.cssText = 'font-size:.73rem;margin-top:.2rem;';
771
+ addBtn.textContent = '+ Add visibility rule';
772
+ addBtn.addEventListener('click', () => rulesContainer.appendChild(buildConditionRow(null, fieldOpts, thenOpts, 'fb-logic-vis-then', 'visible')));
773
+ section.appendChild(addBtn);
774
+ }
775
+ return section;
776
+ }
777
+
778
+ function buildRequirementSection(req, idx, otherFields) {
779
+ const section = document.createElement('div');
780
+ section.dataset.logicSection = 'requirement';
781
+ section.appendChild(buildLogicSectionHeader('Conditional Requirement'));
782
+
783
+ const cbLabel = document.createElement('label');
784
+ cbLabel.style.cssText = 'display:flex;align-items:center;gap:.4rem;font-size:.8rem;cursor:pointer;margin-bottom:.4rem;';
785
+ const cb = document.createElement('input');
786
+ cb.type = 'checkbox';
787
+ cb.className = 'fb-logic-req-default';
788
+ cb.checked = req.default === true;
789
+ cbLabel.appendChild(cb);
790
+ cbLabel.appendChild(document.createTextNode('Required by default'));
791
+ section.appendChild(cbLabel);
792
+
793
+ const rulesContainer = document.createElement('div');
794
+ rulesContainer.className = 'fb-logic-req-rules';
795
+ const fieldOpts = otherFields.map(f => ({ value: f.name, label: f.label || f.name }));
796
+ const thenOpts = [{ value: 'true', label: 'Make required' }, { value: 'false', label: 'Make optional' }];
797
+
798
+ (req.conditions || []).forEach(rule => {
799
+ const cond = (rule.when?.all || rule.when?.any || [])[0];
800
+ const thenVal = rule.then === true ? 'true' : 'false';
801
+ if (fieldOpts.length > 0) rulesContainer.appendChild(buildConditionRow(cond, fieldOpts, thenOpts, 'fb-logic-req-then', thenVal));
802
+ });
803
+ section.appendChild(rulesContainer);
804
+
805
+ if (fieldOpts.length > 0) {
806
+ const addBtn = document.createElement('button');
807
+ addBtn.type = 'button';
808
+ addBtn.className = 'btn btn-xs btn-ghost';
809
+ addBtn.style.cssText = 'font-size:.73rem;margin-top:.2rem;';
810
+ addBtn.textContent = '+ Add requirement rule';
811
+ addBtn.addEventListener('click', () => rulesContainer.appendChild(buildConditionRow(null, fieldOpts, thenOpts, 'fb-logic-req-then', 'true')));
812
+ section.appendChild(addBtn);
813
+ }
814
+ return section;
815
+ }
816
+
817
+ function buildValidationRuleRow(rule) {
818
+ const row = document.createElement('div');
819
+ row.className = 'fb-logic-val-rule';
820
+ row.style.cssText = 'display:flex;gap:.35rem;align-items:center;margin-bottom:.35rem;flex-wrap:wrap;';
821
+
822
+ const typeSel = document.createElement('select');
823
+ typeSel.className = 'form-input fb-logic-val-type';
824
+ typeSel.style.cssText = 'flex:0 0 auto;font-size:.78rem;padding:.2rem .35rem;';
825
+ [{ value: 'regex', label: 'Regex' }, { value: 'match', label: 'Match field' }].forEach(opt => {
826
+ const o = document.createElement('option');
827
+ o.value = opt.value;
828
+ o.textContent = opt.label;
829
+ if (opt.value === (rule?.type || 'regex')) o.selected = true;
830
+ typeSel.appendChild(o);
831
+ });
832
+
833
+ const patternInp = document.createElement('input');
834
+ patternInp.type = 'text';
835
+ patternInp.className = 'form-input fb-logic-val-pattern';
836
+ patternInp.placeholder = rule?.type === 'match' ? 'field name' : 'pattern';
837
+ patternInp.value = rule?.pattern || rule?.field || '';
838
+ patternInp.style.cssText = 'flex:3;font-size:.78rem;padding:.2rem .35rem;';
839
+
840
+ const flagsInp = document.createElement('input');
841
+ flagsInp.type = 'text';
842
+ flagsInp.className = 'form-input fb-logic-val-flags';
843
+ flagsInp.placeholder = 'flags';
844
+ flagsInp.value = rule?.flags || '';
845
+ flagsInp.style.cssText = 'flex:0 0 55px;font-size:.78rem;padding:.2rem .35rem;';
846
+ if (rule?.type === 'match') flagsInp.style.display = 'none';
847
+
848
+ typeSel.addEventListener('change', () => {
849
+ flagsInp.style.display = typeSel.value === 'match' ? 'none' : '';
850
+ patternInp.placeholder = typeSel.value === 'match' ? 'field name' : 'pattern';
851
+ });
852
+
853
+ const msgInp = document.createElement('input');
854
+ msgInp.type = 'text';
855
+ msgInp.className = 'form-input fb-logic-val-message';
856
+ msgInp.placeholder = 'Error message';
857
+ msgInp.value = rule?.message || '';
858
+ msgInp.style.cssText = 'flex:4;font-size:.78rem;padding:.2rem .35rem;';
859
+
860
+ const removeBtn = document.createElement('button');
861
+ removeBtn.type = 'button';
862
+ removeBtn.className = 'btn btn-xs btn-danger';
863
+ removeBtn.textContent = '✕';
864
+ removeBtn.style.flexShrink = '0';
865
+ removeBtn.addEventListener('click', () => row.remove());
866
+
867
+ row.appendChild(typeSel);
868
+ row.appendChild(patternInp);
869
+ row.appendChild(flagsInp);
870
+ row.appendChild(msgInp);
871
+ row.appendChild(removeBtn);
872
+ return row;
873
+ }
874
+
875
+ function buildValidationSection(validationRules, idx, otherFields) {
876
+ const section = document.createElement('div');
877
+ section.dataset.logicSection = 'validation';
878
+ section.appendChild(buildLogicSectionHeader('Custom Validation'));
879
+
880
+ const rulesContainer = document.createElement('div');
881
+ rulesContainer.className = 'fb-logic-val-rules';
882
+ (validationRules || []).forEach(rule => rulesContainer.appendChild(buildValidationRuleRow(rule)));
883
+ section.appendChild(rulesContainer);
884
+
885
+ const addBtn = document.createElement('button');
886
+ addBtn.type = 'button';
887
+ addBtn.className = 'btn btn-xs btn-ghost';
888
+ addBtn.style.cssText = 'font-size:.73rem;margin-top:.2rem;';
889
+ addBtn.textContent = '+ Add validation rule';
890
+ addBtn.addEventListener('click', () => rulesContainer.appendChild(buildValidationRuleRow(null)));
891
+ section.appendChild(addBtn);
892
+ return section;
893
+ }
894
+
895
+ function buildCascadeSection(cascade, idx, otherFields) {
896
+ const section = document.createElement('div');
897
+ section.dataset.logicSection = 'cascade';
898
+ section.appendChild(buildLogicSectionHeader('Cascade Options'));
899
+
900
+ const srcRow = document.createElement('div');
901
+ srcRow.style.cssText = 'display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;';
902
+ const srcLabel = document.createElement('span');
903
+ srcLabel.textContent = 'Source field:';
904
+ srcLabel.style.cssText = 'font-size:.8rem;flex-shrink:0;';
905
+ const srcSel = document.createElement('select');
906
+ srcSel.className = 'form-input fb-logic-cascade-source';
907
+ srcSel.style.cssText = 'flex:1;font-size:.8rem;padding:.25rem .4rem;';
908
+ const emptyOpt = document.createElement('option');
909
+ emptyOpt.value = '';
910
+ emptyOpt.textContent = '— none —';
911
+ srcSel.appendChild(emptyOpt);
912
+ otherFields.forEach(f => {
913
+ const o = document.createElement('option');
914
+ o.value = f.name;
915
+ o.textContent = f.label || f.name;
916
+ if (f.name === cascade.sourceField) o.selected = true;
917
+ srcSel.appendChild(o);
918
+ });
919
+ srcRow.appendChild(srcLabel);
920
+ srcRow.appendChild(srcSel);
921
+ section.appendChild(srcRow);
922
+
923
+ const mapLabel = document.createElement('p');
924
+ mapLabel.textContent = 'Mapping JSON — {"value":[{"value":"...","label":"..."}]}';
925
+ mapLabel.style.cssText = 'font-size:.73rem;color:var(--text-muted,#888);margin:.3rem 0 .2rem;';
926
+ const mapTa = document.createElement('textarea');
927
+ mapTa.className = 'form-input fb-logic-cascade-mapping';
928
+ mapTa.rows = 4;
929
+ mapTa.style.cssText = 'font-family:monospace;font-size:.78rem;';
930
+ mapTa.placeholder = '{"uk": [{"value": "london", "label": "London"}]}';
931
+ mapTa.value = cascade.mapping ? JSON.stringify(cascade.mapping, null, 2) : '';
932
+ section.appendChild(mapLabel);
933
+ section.appendChild(mapTa);
934
+
935
+ const defLabel = document.createElement('p');
936
+ defLabel.textContent = 'Default options (one per line: value:Label)';
937
+ defLabel.style.cssText = 'font-size:.73rem;color:var(--text-muted,#888);margin:.3rem 0 .2rem;';
938
+ const defTa = document.createElement('textarea');
939
+ defTa.className = 'form-input fb-logic-cascade-defaults';
940
+ defTa.rows = 3;
941
+ defTa.style.cssText = 'font-family:monospace;font-size:.78rem;';
942
+ defTa.placeholder = 'option1:Option 1\noption2:Option 2';
943
+ defTa.value = (cascade.defaultOptions || []).map(o => o.value === o.label ? o.value : `${o.value}:${o.label}`).join('\n');
944
+ section.appendChild(defLabel);
945
+ section.appendChild(defTa);
946
+ return section;
947
+ }
948
+
949
+ function buildFieldLogic(field, idx) {
950
+ const logic = field.logic || {};
951
+ const otherFields = fields.filter((f, i) => i !== idx && f.type !== 'page-break');
952
+
953
+ const section = document.createElement('div');
954
+ section.className = 'fb-field-logic';
955
+ section.style.cssText = 'margin-top:.75rem;border-top:1px solid var(--border-color,#333);padding-top:.5rem;';
956
+
957
+ const toggleHeader = document.createElement('div');
958
+ toggleHeader.style.cssText = 'display:flex;align-items:center;justify-content:space-between;cursor:pointer;padding:.15rem 0;';
959
+ const toggleLabel = document.createElement('span');
960
+ toggleLabel.style.cssText = 'font-size:.8rem;font-weight:600;color:var(--text-muted,#888);';
961
+ toggleLabel.textContent = '⚡ Conditional Logic';
962
+ const isOpen = hasLogic(logic);
963
+ const toggleBtn = document.createElement('button');
964
+ toggleBtn.type = 'button';
965
+ toggleBtn.className = 'btn btn-xs btn-ghost';
966
+ toggleBtn.textContent = isOpen ? '▾' : '▸';
967
+
968
+ const logicBody = document.createElement('div');
969
+ logicBody.className = 'fb-logic-body';
970
+ logicBody.style.cssText = 'padding:.25rem 0 .25rem;' + (isOpen ? '' : 'display:none;');
971
+
972
+ toggleHeader.addEventListener('click', () => {
973
+ const collapsed = logicBody.style.display === 'none';
974
+ logicBody.style.display = collapsed ? '' : 'none';
975
+ toggleBtn.textContent = collapsed ? '▾' : '▸';
976
+ });
977
+ toggleHeader.appendChild(toggleLabel);
978
+ toggleHeader.appendChild(toggleBtn);
979
+ section.appendChild(toggleHeader);
980
+
981
+ logicBody.appendChild(buildVisibilitySection(logic.visibility || {}, idx, otherFields));
982
+ logicBody.appendChild(buildRequirementSection(logic.requirement || {}, idx, otherFields));
983
+ logicBody.appendChild(buildValidationSection(logic.validation || [], idx, otherFields));
984
+
985
+ const currentType = document.getElementById(`fb-type-${idx}`)?.value || field.type;
986
+ if (OPTION_TYPES.has(currentType)) {
987
+ logicBody.appendChild(buildCascadeSection(logic.cascade || {}, idx, otherFields));
988
+ }
989
+
990
+ section.appendChild(logicBody);
991
+ return section;
992
+ }
993
+
994
+ // ---------------------------------------------------------------------------
995
+ // Field type-specific extras
996
+ // ---------------------------------------------------------------------------
997
+
396
998
  function buildFieldExtras(type, field, idx) {
397
999
  const wrap = document.createElement('div');
398
1000
  wrap.className = 'fb-field-extras';
@@ -575,7 +1177,7 @@ function buildWizardSteps(formFields) {
575
1177
  currentGroup = [];
576
1178
  stepTitle = f.label || `Step ${steps.length + 1}`;
577
1179
  stepDesc = f.description || '';
578
- } else {
1180
+ } else if (f.type !== 'spacer') {
579
1181
  currentGroup.push(f);
580
1182
  }
581
1183
  });
@@ -588,7 +1190,7 @@ function buildWizardSteps(formFields) {
588
1190
  function buildBlueprintFromFields(fieldList) {
589
1191
  const blueprint = {};
590
1192
  fieldList.forEach(field => {
591
- if (field.type === 'page-break') return;
1193
+ if (field.type === 'page-break' || field.type === 'spacer') return;
592
1194
  blueprint[field.name] = {
593
1195
  type: field.type,
594
1196
  label: field.label,
@@ -662,8 +1264,19 @@ export const formEditorView = {
662
1264
 
663
1265
  renderFieldList($container);
664
1266
 
1267
+ // --- Add dropdown toggle ---
1268
+ const addMenuEl = $container.find('#add-element-menu').get(0);
1269
+ $container.find('#add-element-btn').get(0).addEventListener('click', (e) => {
1270
+ e.stopPropagation();
1271
+ addMenuEl.style.display = addMenuEl.style.display === 'none' ? '' : 'none';
1272
+ });
1273
+ document.addEventListener('click', () => {
1274
+ if (addMenuEl) addMenuEl.style.display = 'none';
1275
+ });
1276
+
665
1277
  // --- Add field ---
666
1278
  $container.find('#add-field-btn').get(0).addEventListener('click', () => {
1279
+ if (addMenuEl) addMenuEl.style.display = 'none';
667
1280
  syncAllFieldsFromDOM();
668
1281
  const newIdx = fields.length;
669
1282
  fields.push({
@@ -683,8 +1296,17 @@ export const formEditorView = {
683
1296
  }
684
1297
  });
685
1298
 
1299
+ // --- Add spacer ---
1300
+ $container.find('#add-spacer-btn').get(0).addEventListener('click', () => {
1301
+ if (addMenuEl) addMenuEl.style.display = 'none';
1302
+ syncAllFieldsFromDOM();
1303
+ fields.push({type: 'spacer'});
1304
+ renderFieldList($container);
1305
+ });
1306
+
686
1307
  // --- Add page break ---
687
1308
  $container.find('#add-page-break-btn').get(0).addEventListener('click', () => {
1309
+ if (addMenuEl) addMenuEl.style.display = 'none';
688
1310
  syncAllFieldsFromDOM();
689
1311
  const stepNum = fields.filter(f => f.type === 'page-break').length + 2;
690
1312
  fields.push({
@@ -693,13 +1315,6 @@ export const formEditorView = {
693
1315
  description: ''
694
1316
  });
695
1317
  renderFieldList($container);
696
- // Auto-expand the newly added page break card
697
- const listEl = $container.find('#fields-list').get(0);
698
- const newCard = listEl?.lastElementChild;
699
- if (newCard) {
700
- const body = newCard.querySelector('.fb-field-body');
701
- if (body) body.style.display = '';
702
- }
703
1318
  });
704
1319
 
705
1320
  // --- Save ---
@@ -804,6 +1419,16 @@ export const formEditorView = {
804
1419
  onSubmit
805
1420
  });
806
1421
  }
1422
+ // Init conditional logic engine on preview form
1423
+ if (window.FormLogicEngine && data.fields.some(f => f.logic)) {
1424
+ requestAnimationFrame(() => {
1425
+ const runtime = new window.FormLogicEngine.FormLogicRuntime(
1426
+ { fields: data.fields },
1427
+ formEl
1428
+ );
1429
+ runtime.init();
1430
+ });
1431
+ }
807
1432
  } else {
808
1433
  const msg = document.createElement('p');
809
1434
  msg.textContent = `${data.fields.filter(f => f.type !== 'page-break').length} field(s): ${data.fields.filter(f => f.type !== 'page-break').map(f => f.label).join(', ')}`;