@officexapp/catalogs-cli 0.3.0 → 0.4.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 (2) hide show
  1. package/dist/index.js +1244 -161
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -777,6 +777,13 @@ import { resolve as resolve4, dirname as dirname2, extname as extname3, join as
777
777
  import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync3, watch } from "fs";
778
778
  import { createServer } from "http";
779
779
  import ora5 from "ora";
780
+
781
+ // src/lib/dev-engine.ts
782
+ function buildEngineScript() {
783
+ return 'function resolveValue(rule, formState, context) {\n const source = rule.source ?? "field";\n switch (source) {\n case "field":\n return rule.field != null ? formState[rule.field] : void 0;\n case "url_param":\n return rule.param != null ? context.url_params[rule.param] : void 0;\n case "hint":\n return rule.param != null ? context.hints[rule.param] : void 0;\n case "tracer_prop":\n return rule.param != null ? context[rule.param] : void 0;\n case "score": {\n const scores = context.quiz_scores;\n if (!scores) return 0;\n const param = rule.param ?? rule.field ?? "total";\n switch (param) {\n case "total":\n return scores.total;\n case "max":\n return scores.max;\n case "percent":\n return scores.percent;\n case "correct_count":\n return scores.correct_count;\n case "question_count":\n return scores.question_count;\n default: {\n const answer = scores.answers.find((a) => a.component_id === param);\n return answer ? answer.points_earned : 0;\n }\n }\n }\n case "video": {\n const videoState = context.video_state;\n if (!videoState) return void 0;\n const compId = rule.field;\n if (!compId || !videoState[compId]) return void 0;\n const metric = rule.param ?? "watch_percent";\n return videoState[compId][metric];\n }\n default:\n return void 0;\n }\n}\nfunction applyOperator(operator, actual, expected) {\n switch (operator) {\n case "equals":\n return String(actual ?? "") === String(expected ?? "");\n case "not_equals":\n return String(actual ?? "") !== String(expected ?? "");\n case "contains":\n if (typeof actual === "string") return actual.includes(String(expected));\n if (Array.isArray(actual)) return actual.includes(expected);\n return false;\n case "not_contains":\n if (typeof actual === "string") return !actual.includes(String(expected));\n if (Array.isArray(actual)) return !actual.includes(expected);\n return true;\n case "greater_than":\n return Number(actual) > Number(expected);\n case "greater_than_or_equal":\n return Number(actual) >= Number(expected);\n case "less_than":\n return Number(actual) < Number(expected);\n case "less_than_or_equal":\n return Number(actual) <= Number(expected);\n case "is_empty":\n return actual == null || actual === "" || Array.isArray(actual) && actual.length === 0;\n case "is_not_empty":\n return !(actual == null || actual === "" || Array.isArray(actual) && actual.length === 0);\n case "matches_regex":\n try {\n const pattern = String(expected);\n if (pattern.length > 200 || /(\\+\\+|\\*\\*|\\{\\d{3,}\\})/.test(pattern)) return false;\n return new RegExp(pattern).test(String(actual ?? ""));\n } catch {\n return false;\n }\n case "in":\n if (Array.isArray(expected)) return expected.includes(actual);\n if (typeof expected === "string")\n return expected.split(",").map((s) => s.trim()).includes(String(actual));\n return false;\n default:\n return false;\n }\n}\nfunction isConditionGroup(rule) {\n return "match" in rule && "rules" in rule;\n}\nfunction evaluateCondition(rule, formState, context) {\n const actual = resolveValue(rule, formState, context);\n return applyOperator(rule.operator, actual, rule.value);\n}\nfunction evaluateConditionGroup(group, formState, context) {\n const evaluator = (item) => {\n if (isConditionGroup(item)) {\n return evaluateConditionGroup(item, formState, context);\n }\n return evaluateCondition(item, formState, context);\n };\n if (group.match === "all") {\n return group.rules.every(evaluator);\n }\n return group.rules.some(evaluator);\n}\nfunction getNextPage(routing, currentPageId, formState, context) {\n const outgoing = routing.edges.filter((e) => e.from === currentPageId);\n if (outgoing.length === 0) return null;\n const sorted = [...outgoing].sort((a, b) => {\n if (a.is_default && !b.is_default) return 1;\n if (!a.is_default && b.is_default) return -1;\n return (a.priority ?? Infinity) - (b.priority ?? Infinity);\n });\n for (const edge of sorted) {\n if (!edge.conditions) {\n return edge.to;\n }\n if (evaluateConditionGroup(edge.conditions, formState, context)) {\n return edge.to;\n }\n }\n const defaultEdge = sorted.find((e) => e.is_default);\n return defaultEdge?.to ?? null;\n}\nfunction getProgress(routing, currentPageId, pages) {\n const adjacency = /* @__PURE__ */ new Map();\n for (const edge of routing.edges) {\n if (!edge.to) continue;\n if (!adjacency.has(edge.from)) adjacency.set(edge.from, /* @__PURE__ */ new Set());\n adjacency.get(edge.from).add(edge.to);\n }\n const depthMap = /* @__PURE__ */ new Map();\n const queue = [[routing.entry, 0]];\n depthMap.set(routing.entry, 0);\n while (queue.length > 0) {\n const [node, depth] = queue.shift();\n const neighbours = adjacency.get(node);\n if (!neighbours) continue;\n for (const next of neighbours) {\n if (!depthMap.has(next)) {\n depthMap.set(next, depth + 1);\n queue.push([next, depth + 1]);\n }\n }\n }\n const currentDepth = depthMap.get(currentPageId) ?? 0;\n const maxDepth = Math.max(...depthMap.values(), 0);\n if (maxDepth === 0) return 0;\n return Math.round(currentDepth / maxDepth * 100);\n}\nconst INPUT_TYPES = /* @__PURE__ */ new Set([\n "short_text",\n "long_text",\n "rich_text",\n "email",\n "phone",\n "url",\n "address",\n "number",\n "currency",\n "date",\n "datetime",\n "time",\n "date_range",\n "dropdown",\n "multiselect",\n "multiple_choice",\n "checkboxes",\n "picture_choice",\n "switch",\n "checkbox",\n "choice_matrix",\n "ranking",\n "star_rating",\n "slider",\n "opinion_scale",\n "file_upload",\n "signature",\n "password",\n "location"\n]);\nconst BOOLEAN_TYPES = /* @__PURE__ */ new Set(["switch", "checkbox"]);\nfunction validatePage(page, formState, context, prefilledIds, overrides) {\n const errors = [];\n for (const comp of page.components) {\n if (!INPUT_TYPES.has(comp.type)) continue;\n const props = { ...comp.props, ...overrides?.[comp.id] };\n if (comp.hidden || props.hidden) continue;\n if (comp.visibility) {\n if (!evaluateConditionGroup(comp.visibility, formState, context)) continue;\n }\n if (comp.prefill_mode === "hidden" && formState[comp.id] != null && formState[comp.id] !== "") continue;\n const value = formState[comp.id];\n if (props.readonly || comp.prefill_mode === "readonly" && value != null && value !== "") continue;\n if (props.required) {\n if (BOOLEAN_TYPES.has(comp.type)) {\n if (!value) {\n errors.push({ componentId: comp.id, message: "This field is required" });\n continue;\n }\n } else {\n const isEmpty = value == null || value === "" || Array.isArray(value) && value.length === 0;\n if (isEmpty) {\n errors.push({ componentId: comp.id, message: "This field is required" });\n continue;\n }\n }\n }\n if (value != null && value !== "") {\n if (comp.type === "email") {\n if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value))) {\n errors.push({ componentId: comp.id, message: "Please enter a valid email address" });\n }\n } else if (comp.type === "url") {\n try {\n const urlStr = String(value);\n new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`);\n } catch {\n errors.push({ componentId: comp.id, message: "Please enter a valid URL" });\n }\n } else if (comp.type === "number") {\n const num = Number(value);\n if (isNaN(num)) {\n errors.push({ componentId: comp.id, message: "Please enter a valid number" });\n } else {\n if (props.min != null && num < props.min) {\n errors.push({ componentId: comp.id, message: `Must be at least ${props.min}` });\n }\n if (props.max != null && num > props.max) {\n errors.push({ componentId: comp.id, message: `Must be at most ${props.max}` });\n }\n }\n } else if (comp.type === "short_text" || comp.type === "long_text") {\n const str = String(value);\n if (props.min_length && str.length < props.min_length) {\n errors.push({ componentId: comp.id, message: `Must be at least ${props.min_length} characters` });\n }\n }\n }\n if ((comp.type === "checkboxes" || comp.type === "multiple_choice") && Array.isArray(value) && props.required) {\n const options = props.options || [];\n if (props.require_all) {\n for (const opt of options) {\n if (!value.includes(opt.value)) {\n errors.push({ componentId: comp.id, message: "All options must be selected" });\n break;\n }\n }\n }\n for (const opt of options) {\n if (!opt.inputs || opt.inputs.length === 0) continue;\n if (!props.require_all && !value.includes(opt.value)) continue;\n for (const input of opt.inputs) {\n const isRequired = input.required || input.props?.required;\n if (!isRequired) continue;\n const nestedId = `${comp.id}.${opt.value}.${input.id}`;\n const nestedValue = formState[nestedId];\n if (nestedValue == null || nestedValue === "" || Array.isArray(nestedValue) && nestedValue.length === 0) {\n errors.push({ componentId: nestedId, message: "This field is required" });\n }\n }\n }\n }\n }\n return errors;\n}\nfunction getVisibleFields(page, formState, context) {\n const fields = [];\n for (const comp of page.components) {\n if (!INPUT_TYPES.has(comp.type)) continue;\n const props = comp.props || {};\n if (comp.hidden || props.hidden) continue;\n if (comp.visibility) {\n if (!evaluateConditionGroup(comp.visibility, formState, context)) continue;\n }\n if (comp.prefill_mode === "hidden" && formState[comp.id] != null && formState[comp.id] !== "") continue;\n const field = {\n id: comp.id,\n type: comp.type,\n label: props.label,\n required: !!props.required,\n agent_hint: comp.agent_hint,\n placeholder: props.placeholder,\n default_value: props.default_value\n };\n if (props.options) {\n field.options = props.options.map((opt) => ({\n value: opt.value,\n label: opt.label,\n description: opt.description,\n agent_hint: opt.agent_hint\n }));\n }\n if (props.min != null) field.min = props.min;\n if (props.max != null) field.max = props.max;\n if (props.min_length != null) field.min_length = props.min_length;\n if (props.max_length != null) field.max_length = props.max_length;\n fields.push(field);\n }\n return fields;\n}\n'.replace(/<\/script/gi, "<\\/script");
784
+ }
785
+
786
+ // src/commands/catalog-dev.ts
780
787
  var DEFAULT_PORT = 3456;
781
788
  var MIME_TYPES = {
782
789
  ".html": "text/html",
@@ -810,6 +817,7 @@ function getMime(filepath) {
810
817
  function buildPreviewHtml(schema, port, validation, devConfig) {
811
818
  const schemaJson = JSON.stringify(schema).replace(/<\/script/gi, "<\\/script");
812
819
  const themeColor = schema.settings?.theme?.primary_color || "#6366f1";
820
+ const engineScript = buildEngineScript();
813
821
  return `<!DOCTYPE html>
814
822
  <html lang="en">
815
823
  <head>
@@ -922,28 +930,96 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
922
930
  background: #1a1a2e; color: #e0e0ff; font-size: 12px;
923
931
  padding: 4px 12px; display: flex; align-items: center; gap: 8px;
924
932
  font-family: monospace; border-bottom: 2px solid #6c63ff;
933
+ transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s ease;
934
+ }
935
+ .dev-banner.minimized {
936
+ transform: translateY(-100%); opacity: 0; pointer-events: none;
925
937
  }
938
+ .dev-banner .drag-handle {
939
+ cursor: grab; opacity: 0.4; font-size: 10px; user-select: none; letter-spacing: 1px;
940
+ padding: 0 2px; transition: opacity 0.15s;
941
+ }
942
+ .dev-banner .drag-handle:hover { opacity: 0.8; }
943
+ .dev-banner .drag-handle:active { cursor: grabbing; }
926
944
  .dev-banner .dot { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; animation: pulse 2s ease-in-out infinite; }
927
945
  @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
928
946
  .dev-banner .label { opacity: 0.7; }
929
947
  .dev-banner .slug { font-weight: bold; color: #a5b4fc; }
930
- .dev-banner .stub-tags { margin-left: auto; display: flex; gap: 6px; }
948
+ .dev-banner .stub-tags { margin-left: auto; display: flex; gap: 6px; align-items: center; }
931
949
  .dev-banner .stub-tag { background: rgba(255,255,255,0.1); border-radius: 3px; padding: 1px 6px; font-size: 11px; color: #fbbf24; }
950
+ .dev-banner .minimize-btn {
951
+ background: rgba(255,255,255,0.08); border: none; color: #a5b4fc; cursor: pointer;
952
+ font-size: 14px; width: 18px; height: 18px; border-radius: 4px;
953
+ display: flex; align-items: center; justify-content: center; padding: 0;
954
+ transition: background 0.15s;
955
+ }
956
+ .dev-banner .minimize-btn:hover { background: rgba(255,255,255,0.2); }
957
+ /* Minimized floating pill to restore banner */
958
+ .dev-banner-restore {
959
+ position: fixed; top: 8px; right: 8px; z-index: 99999;
960
+ background: #1a1a2e; color: #a5b4fc; border: 1px solid #6c63ff;
961
+ border-radius: 8px; padding: 4px 10px; font-size: 11px; font-family: monospace;
962
+ cursor: pointer; display: none; align-items: center; gap: 6px;
963
+ box-shadow: 0 2px 12px rgba(0,0,0,0.3); transition: all 0.15s ease;
964
+ }
965
+ .dev-banner-restore:hover { background: #2a2a4e; }
966
+ .dev-banner-restore .restore-dot { width: 6px; height: 6px; border-radius: 50%; background: #4ade80; animation: pulse 2s ease-in-out infinite; }
967
+ .dev-banner-restore.visible { display: flex; }
968
+ /* Validation tag in topbar */
969
+ .dev-banner .validation-tag { position: relative; }
970
+ .dev-banner .validation-tag .vt-btn {
971
+ background: rgba(239,68,68,0.2); border: none; color: #fca5a5; border-radius: 3px;
972
+ padding: 1px 6px; font-size: 11px; cursor: pointer; font-family: monospace;
973
+ transition: background 0.15s;
974
+ }
975
+ .dev-banner .validation-tag .vt-btn:hover { background: rgba(239,68,68,0.35); }
976
+ .dev-banner .validation-tag .vt-btn.clean {
977
+ background: rgba(74,222,128,0.15); color: #86efac;
978
+ }
979
+ .dev-banner .validation-tag .vt-btn.clean:hover { background: rgba(74,222,128,0.25); }
980
+ .dev-banner .vt-dropdown {
981
+ position: absolute; top: calc(100% + 8px); right: 0; min-width: 400px;
982
+ background: #1e1b2e; border: 1px solid #3b3660; border-radius: 10px;
983
+ box-shadow: 0 8px 32px rgba(0,0,0,0.4); display: none; overflow: hidden;
984
+ font-family: monospace; font-size: 12px;
985
+ }
986
+ .dev-banner .vt-dropdown.open { display: block; }
987
+ .dev-banner .vt-dropdown .vt-header {
988
+ padding: 8px 12px; background: #2a2550; border-bottom: 1px solid #3b3660;
989
+ font-weight: 600; color: #c4b5fd; display: flex; align-items: center; justify-content: space-between;
990
+ }
991
+ .dev-banner .vt-dropdown .vt-body { padding: 8px 12px; max-height: 240px; overflow-y: auto; }
992
+ .dev-banner .vt-dropdown .vt-error { color: #fca5a5; padding: 3px 0; }
993
+ .dev-banner .vt-dropdown .vt-warn { color: #fde68a; padding: 3px 0; }
932
994
 
933
995
  /* Pages mindmap overlay */
934
996
  .pages-overlay {
935
997
  position: fixed; inset: 0; z-index: 99990; background: rgba(10,10,20,0.92);
936
- backdrop-filter: blur(8px); display: none; align-items: center; justify-content: center;
937
- font-family: var(--font-display);
998
+ backdrop-filter: blur(8px); display: none;
999
+ font-family: var(--font-display); overflow: hidden; cursor: grab;
938
1000
  }
939
- .pages-overlay.open { display: flex; }
1001
+ .pages-overlay.open { display: block; }
1002
+ .pages-overlay.grabbing { cursor: grabbing; }
940
1003
  .pages-overlay .close-btn {
941
1004
  position: absolute; top: 16px; right: 16px; background: rgba(255,255,255,0.1);
942
1005
  border: none; color: white; width: 32px; height: 32px; border-radius: 8px;
943
1006
  cursor: pointer; font-size: 18px; display: flex; align-items: center; justify-content: center;
1007
+ z-index: 10;
944
1008
  }
945
1009
  .pages-overlay .close-btn:hover { background: rgba(255,255,255,0.2); }
946
- .mindmap-container { position: relative; padding: 40px; }
1010
+ .pages-overlay .zoom-controls {
1011
+ position: absolute; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 4px; z-index: 10;
1012
+ }
1013
+ .pages-overlay .zoom-btn {
1014
+ width: 32px; height: 32px; border-radius: 8px; border: none;
1015
+ background: rgba(255,255,255,0.1); color: white; font-size: 16px; cursor: pointer;
1016
+ display: flex; align-items: center; justify-content: center; font-family: monospace;
1017
+ }
1018
+ .pages-overlay .zoom-btn:hover { background: rgba(255,255,255,0.2); }
1019
+ .pages-overlay .zoom-level {
1020
+ text-align: center; color: rgba(255,255,255,0.4); font-size: 10px; font-family: monospace;
1021
+ }
1022
+ .mindmap-container { position: absolute; top: 0; left: 0; transform-origin: 0 0; padding: 40px; }
947
1023
  .mindmap-container svg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
948
1024
  .mindmap-nodes { position: relative; display: flex; flex-wrap: wrap; gap: 24px; justify-content: center; align-items: flex-start; max-width: 900px; }
949
1025
  .mindmap-node {
@@ -1005,24 +1081,7 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1005
1081
  box-shadow: 0 2px 12px rgba(0,0,0,0.04);
1006
1082
  }
1007
1083
 
1008
- /* Validation banner */
1009
- .validation-banner {
1010
- position: fixed; bottom: 0; left: 0; right: 0; z-index: 99999;
1011
- background: #7f1d1d; color: #fecaca; font-family: monospace; font-size: 12px;
1012
- padding: 10px 16px; max-height: 200px; overflow-y: auto;
1013
- border-top: 2px solid #ef4444;
1014
- }
1015
- .validation-banner .vb-header {
1016
- display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;
1017
- }
1018
- .validation-banner .vb-header strong { color: #fca5a5; }
1019
- .validation-banner .vb-dismiss {
1020
- background: rgba(255,255,255,0.1); border: none; color: #fca5a5; border-radius: 4px;
1021
- padding: 2px 8px; cursor: pointer; font-size: 11px;
1022
- }
1023
- .validation-banner .vb-dismiss:hover { background: rgba(255,255,255,0.2); }
1024
- .validation-banner .vb-error { color: #fca5a5; }
1025
- .validation-banner .vb-warn { color: #fde68a; }
1084
+ /* (validation banner moved into topbar dropdown) */
1026
1085
 
1027
1086
  /* Debug panel */
1028
1087
  .debug-panel {
@@ -1053,10 +1112,42 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1053
1112
  .debug-panel pre { margin: 0; white-space: pre-wrap; word-break: break-all; color: #c7d2fe; line-height: 1.5; }
1054
1113
  .dev-banner .stub-tag.debug-btn { cursor: pointer; transition: all 0.15s ease; }
1055
1114
  .dev-banner .stub-tag.debug-btn:hover { background: rgba(255,255,255,0.2); color: #a5b4fc; }
1115
+
1116
+ /* Field validation error */
1117
+ .cf-field-error .cf-input { border-color: #ef4444 !important; box-shadow: 0 0 0 3px rgba(239,68,68,0.1) !important; }
1118
+ .cf-field-error .cf-choice[data-selected="false"] { border-color: #fca5a5 !important; }
1119
+
1120
+ /* Action button styles */
1121
+ .cf-btn-secondary {
1122
+ font-family: var(--font-display); font-weight: 600; letter-spacing: -0.01em;
1123
+ border-radius: 14px; padding: 14px 28px; font-size: 16px; border: 2px solid;
1124
+ cursor: pointer; transition: all 0.25s var(--ease-out-expo); background: transparent;
1125
+ }
1126
+ .cf-btn-secondary:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); transform: translateY(-1px); }
1127
+ .cf-btn-ghost {
1128
+ font-family: var(--font-display); font-weight: 500; letter-spacing: -0.01em;
1129
+ border-radius: 14px; padding: 14px 28px; font-size: 16px; border: none;
1130
+ cursor: pointer; transition: all 0.2s var(--ease-out-expo); background: transparent;
1131
+ }
1132
+ .cf-btn-ghost:hover { background: rgba(0,0,0,0.04); }
1133
+
1134
+ /* Sticky bottom bar */
1135
+ .cf-sticky-bar {
1136
+ position: fixed; bottom: 0; left: 0; right: 0; z-index: 80;
1137
+ transition: transform 0.3s var(--ease-out-expo);
1138
+ }
1139
+ .cf-sticky-bar.hidden { transform: translateY(100%); }
1140
+
1141
+ /* Resume prompt */
1142
+ .cf-resume-backdrop {
1143
+ position: fixed; inset: 0; z-index: 99; background: rgba(0,0,0,0.2);
1144
+ backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center;
1145
+ }
1056
1146
  </style>
1057
1147
  </head>
1058
1148
  <body>
1059
- <div class="dev-banner">
1149
+ <div class="dev-banner" id="dev-banner">
1150
+ <span class="drag-handle" id="banner-drag" title="Drag to reposition">\u283F</span>
1060
1151
  <span class="dot"></span>
1061
1152
  <span class="label">LOCAL DEV</span>
1062
1153
  <span class="slug">${schema.slug || "catalog"}</span>
@@ -1065,10 +1156,22 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1065
1156
  <span class="stub-tag">Events: local</span>
1066
1157
  <span class="stub-tag clickable" id="pages-btn">Pages</span>
1067
1158
  <span class="stub-tag debug-btn" id="debug-btn">Debug</span>
1159
+ <span class="validation-tag" id="validation-tag"></span>
1160
+ <button class="minimize-btn" id="banner-minimize" title="Minimize toolbar">\u25B4</button>
1068
1161
  </span>
1069
1162
  </div>
1163
+ <div class="dev-banner-restore" id="banner-restore">
1164
+ <span class="restore-dot"></span>
1165
+ <span>DEV</span>
1166
+ </div>
1070
1167
  <div class="pages-overlay" id="pages-overlay">
1071
1168
  <button class="close-btn" id="pages-close">&times;</button>
1169
+ <div class="zoom-controls">
1170
+ <button class="zoom-btn" id="zoom-in">+</button>
1171
+ <div class="zoom-level" id="zoom-level">100%</div>
1172
+ <button class="zoom-btn" id="zoom-out">&minus;</button>
1173
+ <button class="zoom-btn" id="zoom-fit" style="font-size:11px;margin-top:4px;">Fit</button>
1174
+ </div>
1072
1175
  <div class="mindmap-container" id="mindmap-container"></div>
1073
1176
  </div>
1074
1177
  <div class="inspector-highlight" id="inspector-highlight" style="display:none"></div>
@@ -1079,7 +1182,6 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1079
1182
  <div class="dp-header"><span>Debug Panel</span><button class="dp-close" id="debug-close">&times;</button></div>
1080
1183
  <div class="dp-body" id="debug-body"></div>
1081
1184
  </div>
1082
- <div class="validation-banner" id="validation-banner" style="display:none"></div>
1083
1185
 
1084
1186
  <script id="__catalog_data" type="application/json">${schemaJson}</script>
1085
1187
  <script id="__validation_data" type="application/json">${JSON.stringify(validation || { errors: [], warnings: [] })}</script>
@@ -1093,61 +1195,16 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1093
1195
  const schema = JSON.parse(document.getElementById('__catalog_data').textContent);
1094
1196
  const themeColor = '${themeColor}';
1095
1197
 
1096
- // --- Visibility condition engine (ported from shared/engine/conditions.ts) ---
1198
+ // --- Shared Engine (auto-generated from shared/engine/{conditions,routing,validate}.ts) ---
1199
+ ${engineScript}
1200
+
1201
+ // --- Dev context for condition/routing evaluation ---
1097
1202
  const devContext = (() => {
1098
1203
  const params = {};
1099
1204
  new URLSearchParams(window.location.search).forEach((v, k) => { params[k] = v; });
1100
1205
  return { url_params: params, hints: {}, quiz_scores: null, video_state: null };
1101
1206
  })();
1102
1207
 
1103
- function resolveConditionValue(rule, formState, ctx) {
1104
- const source = rule.source || 'field';
1105
- if (source === 'field') return rule.field != null ? formState[rule.field] : undefined;
1106
- if (source === 'url_param') return rule.param != null ? ctx.url_params[rule.param] : undefined;
1107
- if (source === 'hint') return rule.param != null ? ctx.hints[rule.param] : undefined;
1108
- if (source === 'score') return 0;
1109
- if (source === 'video') return undefined;
1110
- return undefined;
1111
- }
1112
-
1113
- function applyConditionOperator(op, actual, expected) {
1114
- switch (op) {
1115
- case 'equals': return String(actual ?? '') === String(expected ?? '');
1116
- case 'not_equals': return String(actual ?? '') !== String(expected ?? '');
1117
- case 'contains':
1118
- if (typeof actual === 'string') return actual.includes(String(expected));
1119
- if (Array.isArray(actual)) return actual.includes(expected);
1120
- return false;
1121
- case 'not_contains':
1122
- if (typeof actual === 'string') return !actual.includes(String(expected));
1123
- if (Array.isArray(actual)) return !actual.includes(expected);
1124
- return true;
1125
- case 'greater_than': return Number(actual) > Number(expected);
1126
- case 'greater_than_or_equal': return Number(actual) >= Number(expected);
1127
- case 'less_than': return Number(actual) < Number(expected);
1128
- case 'less_than_or_equal': return Number(actual) <= Number(expected);
1129
- case 'is_empty': return actual == null || actual === '' || (Array.isArray(actual) && actual.length === 0);
1130
- case 'is_not_empty': return !(actual == null || actual === '' || (Array.isArray(actual) && actual.length === 0));
1131
- case 'matches_regex':
1132
- try { const p = String(expected); if (p.length > 200) return false; return new RegExp(p).test(String(actual ?? '')); } catch { return false; }
1133
- case 'in':
1134
- if (Array.isArray(expected)) return expected.includes(actual);
1135
- if (typeof expected === 'string') return expected.split(',').map(s => s.trim()).includes(String(actual));
1136
- return false;
1137
- default: return false;
1138
- }
1139
- }
1140
-
1141
- function evaluateConditionGroup(group, formState, ctx) {
1142
- if (!group || !group.rules) return true;
1143
- const evaluate = (item) => {
1144
- if (item.match && item.rules) return evaluateConditionGroup(item, formState, ctx);
1145
- const actual = resolveConditionValue(item, formState, ctx);
1146
- return applyConditionOperator(item.operator, actual, item.value);
1147
- };
1148
- return group.match === 'all' ? group.rules.every(evaluate) : group.rules.some(evaluate);
1149
- }
1150
-
1151
1208
  // --- Markdown-ish text rendering ---
1152
1209
  function inlineMarkdown(text) {
1153
1210
  return text
@@ -1190,26 +1247,31 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1190
1247
  return 'text-left';
1191
1248
  }
1192
1249
 
1193
- // --- Routing helpers ---
1194
- function getNextPageId(currentId, routing, formState) {
1195
- if (!routing || !routing.edges) return null;
1196
- const edges = routing.edges.filter(e => e.from === currentId);
1197
- // Sort by priority (lower = higher priority)
1198
- edges.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));
1199
- for (const edge of edges) {
1200
- if (!edge.conditions || edge.conditions.length === 0) return edge.to;
1201
- const match = edge.conditions.every(cond => {
1202
- const val = formState[cond.field];
1203
- if (cond.operator === 'equals') return val === cond.value;
1204
- if (cond.operator === 'not_equals') return val !== cond.value;
1205
- if (cond.operator === 'contains') return typeof val === 'string' && val.includes(cond.value);
1206
- return true;
1207
- });
1208
- if (match) return edge.to;
1250
+ // --- Variant resolution ---
1251
+ function resolveComponentVariants(props, hints) {
1252
+ const resolved = { ...props };
1253
+ const variantKeys = Object.keys(props).filter(k => k.endsWith('__variants'));
1254
+ for (const variantKey of variantKeys) {
1255
+ const baseProp = variantKey.replace('__variants', '');
1256
+ const variants = props[variantKey];
1257
+ if (!variants || typeof variants !== 'object') continue;
1258
+ let bestMatch = null;
1259
+ for (const [conditionStr, value] of Object.entries(variants)) {
1260
+ const conditions = conditionStr.split(',').map(c => c.trim());
1261
+ let allMatch = true, score = 0;
1262
+ for (const cond of conditions) {
1263
+ const [hintKey, hintValue] = cond.split('=');
1264
+ if (hints[hintKey] === hintValue) score++;
1265
+ else { allMatch = false; break; }
1266
+ }
1267
+ if (allMatch && score > 0 && (!bestMatch || score > bestMatch.score)) {
1268
+ bestMatch = { value, score };
1269
+ }
1270
+ }
1271
+ if (bestMatch) resolved[baseProp] = bestMatch.value;
1272
+ delete resolved[variantKey];
1209
1273
  }
1210
- // Fallback: default edge (no conditions)
1211
- const defaultEdge = edges.find(e => !e.conditions || e.conditions.length === 0);
1212
- return defaultEdge ? defaultEdge.to : null;
1274
+ return resolved;
1213
1275
  }
1214
1276
 
1215
1277
  // --- Component Renderers ---
@@ -1274,11 +1336,7 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1274
1336
  h('iframe', { src: embedUrl, className: 'absolute inset-0 w-full h-full', allow: 'autoplay; fullscreen; picture-in-picture', allowFullScreen: true })
1275
1337
  );
1276
1338
  }
1277
- return h('div', { className: 'w-full ' + compClass, style: compStyle },
1278
- h('div', { className: 'relative w-full overflow-hidden rounded-2xl shadow-lg' },
1279
- h('video', { src: props.hls_url || src, controls: true, playsInline: true, poster: props.poster, className: 'w-full' })
1280
- )
1281
- );
1339
+ return h(VideoPlayer, { comp, isCover, compClass, compStyle });
1282
1340
  }
1283
1341
 
1284
1342
  case 'html':
@@ -1451,6 +1509,194 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1451
1509
  case 'payment':
1452
1510
  return h(PaymentComponent, { comp, formState, isCover, compClass, compStyle });
1453
1511
 
1512
+ case 'date':
1513
+ case 'datetime':
1514
+ case 'time': {
1515
+ const htmlType = type === 'datetime' ? 'datetime-local' : type;
1516
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1517
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1518
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1519
+ ) : null,
1520
+ h('input', { type: htmlType, className: 'cf-input', value: formState[comp.id] ?? '', onChange: (e) => onFieldChange(comp.id, e.target.value) })
1521
+ );
1522
+ }
1523
+
1524
+ case 'date_range': {
1525
+ const rangeVal = formState[comp.id] || {};
1526
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1527
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1528
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1529
+ ) : null,
1530
+ h('div', { className: 'flex gap-3 items-center' },
1531
+ h('input', { type: 'date', className: 'cf-input flex-1', value: rangeVal.start || '', onChange: (e) => onFieldChange(comp.id, { ...rangeVal, start: e.target.value }) }),
1532
+ h('span', { className: 'text-gray-400 text-sm' }, 'to'),
1533
+ h('input', { type: 'date', className: 'cf-input flex-1', value: rangeVal.end || '', onChange: (e) => onFieldChange(comp.id, { ...rangeVal, end: e.target.value }) })
1534
+ )
1535
+ );
1536
+ }
1537
+
1538
+ case 'picture_choice': {
1539
+ const pcOptions = props.options || [];
1540
+ const pcMultiple = props.multiple;
1541
+ const pcSelected = formState[comp.id];
1542
+ const pcCols = props.columns || Math.min(pcOptions.length, 3);
1543
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1544
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1545
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1546
+ ) : null,
1547
+ h('div', { className: 'grid gap-3', style: { gridTemplateColumns: 'repeat(' + pcCols + ', 1fr)' } },
1548
+ ...(pcOptions).map((opt, j) => {
1549
+ const val = typeof opt === 'string' ? opt : opt.value;
1550
+ const lbl = typeof opt === 'string' ? opt : opt.label || opt.value;
1551
+ const img = typeof opt === 'object' ? opt.image : null;
1552
+ const isSel = pcMultiple ? Array.isArray(pcSelected) && pcSelected.includes(val) : pcSelected === val;
1553
+ return h('button', {
1554
+ key: j, className: 'rounded-xl border-2 overflow-hidden transition-all text-center p-2',
1555
+ style: isSel ? { borderColor: themeColor, boxShadow: '0 0 0 3px ' + themeColor + '26' } : { borderColor: '#e2e4e9' },
1556
+ onClick: () => {
1557
+ if (pcMultiple) {
1558
+ const arr = Array.isArray(pcSelected) ? [...pcSelected] : [];
1559
+ onFieldChange(comp.id, isSel ? arr.filter(v => v !== val) : [...arr, val]);
1560
+ } else { onFieldChange(comp.id, val); }
1561
+ },
1562
+ },
1563
+ img ? h('img', { src: img, alt: lbl, className: 'w-full h-24 object-cover rounded-lg mb-2' }) : null,
1564
+ h('span', { className: 'text-sm font-medium ' + (isCover ? 'text-white' : 'text-gray-700') }, lbl)
1565
+ );
1566
+ })
1567
+ )
1568
+ );
1569
+ }
1570
+
1571
+ case 'opinion_scale': {
1572
+ const osMin = props.min ?? 1, osMax = props.max ?? 10;
1573
+ const osValue = formState[comp.id];
1574
+ const osButtons = [];
1575
+ for (let i = osMin; i <= osMax; i++) osButtons.push(i);
1576
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1577
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1578
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1579
+ ) : null,
1580
+ h('div', { className: 'flex items-center gap-1' },
1581
+ props.min_label ? h('span', { className: 'text-xs text-gray-400 mr-2 shrink-0' }, props.min_label) : null,
1582
+ ...osButtons.map(n => h('button', {
1583
+ key: n, className: 'flex-1 py-2.5 rounded-lg text-sm font-semibold transition-all border',
1584
+ style: osValue === n ? { backgroundColor: themeColor, color: 'white', borderColor: themeColor } : { backgroundColor: 'transparent', color: isCover ? 'white' : '#374151', borderColor: '#e5e7eb' },
1585
+ onClick: () => onFieldChange(comp.id, n),
1586
+ }, String(n))),
1587
+ props.max_label ? h('span', { className: 'text-xs text-gray-400 ml-2 shrink-0' }, props.max_label) : null
1588
+ )
1589
+ );
1590
+ }
1591
+
1592
+ case 'address':
1593
+ return h(AddressInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1594
+
1595
+ case 'currency': {
1596
+ const currSymbol = props.currency_symbol || props.prefix || '$';
1597
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1598
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1599
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1600
+ ) : null,
1601
+ h('div', { className: 'relative' },
1602
+ h('span', { className: 'absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 font-medium pointer-events-none' }, currSymbol),
1603
+ h('input', { type: 'number', className: 'cf-input', style: { paddingLeft: '2.5rem' },
1604
+ placeholder: props.placeholder || '0.00', step: props.step || '0.01',
1605
+ value: formState[comp.id] ?? '', onChange: (e) => onFieldChange(comp.id, e.target.value) })
1606
+ )
1607
+ );
1608
+ }
1609
+
1610
+ case 'file_upload':
1611
+ return h(FileUploadInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1612
+
1613
+ case 'signature':
1614
+ return h(SignatureInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1615
+
1616
+ case 'table': {
1617
+ const tHeaders = props.headers || [];
1618
+ const tRows = props.rows || [];
1619
+ return h('div', { className: 'overflow-x-auto rounded-xl border border-gray-200 ' + compClass, style: compStyle },
1620
+ h('table', { className: 'w-full text-sm' },
1621
+ tHeaders.length > 0 ? h('thead', null,
1622
+ h('tr', null, ...tHeaders.map((hdr, i) => h('th', { key: i, className: 'px-4 py-3 text-left font-semibold text-white', style: { backgroundColor: themeColor } }, hdr)))
1623
+ ) : null,
1624
+ h('tbody', null,
1625
+ ...tRows.map((row, i) => h('tr', { key: i, className: i % 2 === 0 ? 'bg-white' : 'bg-gray-50' },
1626
+ ...(Array.isArray(row) ? row : Object.values(row)).map((cell, j) => h('td', { key: j, className: 'px-4 py-3 text-gray-700 border-t border-gray-100' }, String(cell ?? '')))
1627
+ ))
1628
+ )
1629
+ )
1630
+ );
1631
+ }
1632
+
1633
+ case 'social_links': {
1634
+ const socialLinks = props.links || [];
1635
+ return h('div', { className: 'flex items-center gap-3 ' + compClass, style: compStyle },
1636
+ ...socialLinks.map((link, i) => h('a', {
1637
+ key: i, href: link.url, target: '_blank', rel: 'noopener noreferrer',
1638
+ className: 'w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-bold transition-transform hover:scale-110',
1639
+ style: { backgroundColor: themeColor }, title: link.platform,
1640
+ }, (link.platform || '?')[0].toUpperCase()))
1641
+ );
1642
+ }
1643
+
1644
+ case 'accordion': {
1645
+ const accItems = props.items || [];
1646
+ return h('div', { className: 'space-y-3 ' + compClass, style: compStyle },
1647
+ ...accItems.map((item, i) => h(FaqItem, { key: i, question: item.title || item.question, answer: item.content || item.answer, isCover }))
1648
+ );
1649
+ }
1650
+
1651
+ case 'tabs':
1652
+ return h(TabsComponent, { comp, isCover, compClass, compStyle });
1653
+
1654
+ case 'countdown':
1655
+ return h(CountdownComponent, { comp, compClass, compStyle });
1656
+
1657
+ case 'comparison_table': {
1658
+ const ctPlans = props.plans || [];
1659
+ const ctFeatures = props.features || [];
1660
+ return h('div', { className: 'overflow-x-auto ' + compClass, style: compStyle },
1661
+ h('table', { className: 'w-full text-sm' },
1662
+ h('thead', null,
1663
+ h('tr', null,
1664
+ h('th', { className: 'px-4 py-3 text-left text-gray-500 font-medium' }, 'Feature'),
1665
+ ...ctPlans.map((plan, i) => h('th', { key: i, className: 'px-4 py-3 text-center font-bold', style: plan.highlighted ? { color: themeColor } : undefined }, plan.name || plan.label))
1666
+ )
1667
+ ),
1668
+ h('tbody', null,
1669
+ ...ctFeatures.map((feat, i) => h('tr', { key: i, className: i % 2 === 0 ? 'bg-white' : 'bg-gray-50' },
1670
+ h('td', { className: 'px-4 py-3 text-gray-700 font-medium border-t border-gray-100' }, feat.name || feat.label),
1671
+ ...ctPlans.map((plan, j) => {
1672
+ const fVal = plan.features?.[feat.id || feat.name] ?? feat.values?.[j];
1673
+ const fDisplay = fVal === true ? '\\u2713' : fVal === false ? '\\u2014' : String(fVal ?? '\\u2014');
1674
+ return h('td', { key: j, className: 'px-4 py-3 text-center border-t border-gray-100', style: fVal === true ? { color: themeColor } : undefined }, fDisplay);
1675
+ })
1676
+ ))
1677
+ )
1678
+ )
1679
+ );
1680
+ }
1681
+
1682
+ case 'progress_bar': {
1683
+ const pbValue = props.value ?? 0;
1684
+ const pbMax = props.max ?? 100;
1685
+ const pbPct = Math.min(100, Math.max(0, (pbValue / pbMax) * 100));
1686
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1687
+ props.label ? h('div', { className: 'flex justify-between text-sm' },
1688
+ h('span', { className: 'font-medium text-gray-700' }, props.label),
1689
+ h('span', { className: 'text-gray-400' }, Math.round(pbPct) + '%')
1690
+ ) : null,
1691
+ h('div', { className: 'w-full h-3 bg-gray-100 rounded-full overflow-hidden' },
1692
+ h('div', { className: 'h-full rounded-full progress-bar-fill', style: { width: pbPct + '%', backgroundColor: themeColor } })
1693
+ )
1694
+ );
1695
+ }
1696
+
1697
+ case 'modal':
1698
+ return h(ModalComponent, { comp, isCover, compClass, compStyle });
1699
+
1454
1700
  default:
1455
1701
  return h('div', {
1456
1702
  className: 'border-2 border-dashed border-gray-300 rounded-xl p-4 text-center text-gray-400 text-sm ' + compClass,
@@ -1610,6 +1856,226 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1610
1856
  );
1611
1857
  }
1612
1858
 
1859
+ // --- Additional input/display components ---
1860
+ function AddressInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1861
+ const props = comp.props || {};
1862
+ const addr = formState[comp.id] || {};
1863
+ const up = (field, val) => onFieldChange(comp.id, { ...addr, [field]: val });
1864
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1865
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1866
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null) : null,
1867
+ h('div', { className: 'space-y-2' },
1868
+ h('input', { type: 'text', className: 'cf-input', placeholder: 'Street address', value: addr.street || '', onChange: (e) => up('street', e.target.value) }),
1869
+ h('div', { className: 'grid grid-cols-2 gap-2' },
1870
+ h('input', { type: 'text', className: 'cf-input', placeholder: 'City', value: addr.city || '', onChange: (e) => up('city', e.target.value) }),
1871
+ h('input', { type: 'text', className: 'cf-input', placeholder: 'State', value: addr.state || '', onChange: (e) => up('state', e.target.value) })
1872
+ ),
1873
+ h('div', { className: 'grid grid-cols-2 gap-2' },
1874
+ h('input', { type: 'text', className: 'cf-input', placeholder: 'ZIP / Postal', value: addr.zip || '', onChange: (e) => up('zip', e.target.value) }),
1875
+ h('input', { type: 'text', className: 'cf-input', placeholder: 'Country', value: addr.country || '', onChange: (e) => up('country', e.target.value) })
1876
+ )
1877
+ )
1878
+ );
1879
+ }
1880
+
1881
+ function FileUploadInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1882
+ const props = comp.props || {};
1883
+ const fileName = formState[comp.id] || '';
1884
+ const fileRef = React.useRef(null);
1885
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1886
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1887
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null) : null,
1888
+ h('div', { className: 'border-2 border-dashed border-gray-200 rounded-xl p-6 text-center' },
1889
+ h('input', { type: 'file', ref: fileRef, className: 'hidden', accept: props.accept,
1890
+ onChange: (e) => { const f = e.target.files?.[0]; if (f) onFieldChange(comp.id, f.name); } }),
1891
+ fileName
1892
+ ? h('div', { className: 'flex items-center justify-center gap-2' },
1893
+ h('span', { className: 'text-sm text-gray-700' }, fileName),
1894
+ h('button', { className: 'text-xs text-red-500 hover:text-red-700', onClick: () => onFieldChange(comp.id, '') }, 'Remove'))
1895
+ : h('button', { className: 'text-sm font-medium', style: { color: themeColor }, onClick: () => fileRef.current?.click() }, props.button_text || 'Choose file'),
1896
+ h('p', { className: 'text-xs text-gray-400 mt-2' }, 'Files are not uploaded in dev mode')
1897
+ )
1898
+ );
1899
+ }
1900
+
1901
+ function SignatureInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1902
+ const props = comp.props || {};
1903
+ const signed = !!formState[comp.id];
1904
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1905
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1906
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null) : null,
1907
+ h('div', { className: 'border rounded-xl p-4' },
1908
+ signed
1909
+ ? h('div', { className: 'flex items-center justify-between' },
1910
+ h('span', { className: 'text-sm text-green-600 font-medium' }, '\\u2713 Signature captured'),
1911
+ h('button', { className: 'text-xs text-gray-400 hover:text-gray-600', onClick: () => onFieldChange(comp.id, '') }, 'Clear'))
1912
+ : h('button', {
1913
+ className: 'w-full py-8 text-center text-sm text-gray-400 border-2 border-dashed rounded-lg hover:bg-gray-50',
1914
+ onClick: () => onFieldChange(comp.id, 'signature_' + Date.now()),
1915
+ }, 'Click to sign (canvas stubbed in dev)')
1916
+ )
1917
+ );
1918
+ }
1919
+
1920
+ function TabsComponent({ comp, isCover, compClass, compStyle }) {
1921
+ const props = comp.props || {};
1922
+ const tabs = props.tabs || [];
1923
+ const [activeTab, setActiveTab] = React.useState(0);
1924
+ return h('div', { className: compClass, style: compStyle },
1925
+ h('div', { className: 'flex border-b border-gray-200 mb-4' },
1926
+ ...tabs.map((tab, i) => h('button', {
1927
+ key: i, className: 'px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px',
1928
+ style: i === activeTab ? { color: themeColor, borderColor: themeColor } : { color: '#9ca3af', borderColor: 'transparent' },
1929
+ onClick: () => setActiveTab(i),
1930
+ }, tab.label || tab.title || 'Tab ' + (i + 1)))
1931
+ ),
1932
+ tabs[activeTab] ? h('div', { className: 'text-sm text-gray-600', dangerouslySetInnerHTML: { __html: renderMarkdownish(tabs[activeTab].content || '') } }) : null
1933
+ );
1934
+ }
1935
+
1936
+ function CountdownComponent({ comp, compClass, compStyle }) {
1937
+ const props = comp.props || {};
1938
+ const [timeLeft, setTimeLeft] = React.useState({});
1939
+ React.useEffect(() => {
1940
+ const target = new Date(props.target_date).getTime();
1941
+ const update = () => {
1942
+ const diff = Math.max(0, target - Date.now());
1943
+ setTimeLeft({ days: Math.floor(diff / 86400000), hours: Math.floor((diff % 86400000) / 3600000), minutes: Math.floor((diff % 3600000) / 60000), seconds: Math.floor((diff % 60000) / 1000) });
1944
+ };
1945
+ update();
1946
+ const iv = setInterval(update, 1000);
1947
+ return () => clearInterval(iv);
1948
+ }, [props.target_date]);
1949
+ return h('div', { className: 'flex items-center justify-center gap-4 ' + compClass, style: compStyle },
1950
+ ...['days', 'hours', 'minutes', 'seconds'].map(unit =>
1951
+ h('div', { key: unit, className: 'text-center' },
1952
+ h('div', { className: 'text-3xl font-bold', style: { color: themeColor, fontFamily: 'var(--font-display)' } }, String(timeLeft[unit] ?? 0).padStart(2, '0')),
1953
+ h('div', { className: 'text-xs text-gray-400 uppercase tracking-wider mt-1' }, unit)
1954
+ )
1955
+ )
1956
+ );
1957
+ }
1958
+
1959
+ function ModalComponent({ comp, isCover, compClass, compStyle }) {
1960
+ const props = comp.props || {};
1961
+ const [open, setOpen] = React.useState(false);
1962
+ return h(React.Fragment, null,
1963
+ h('button', { className: 'cf-btn-primary text-white', style: { backgroundColor: themeColor, ...compStyle }, onClick: () => setOpen(true) },
1964
+ props.trigger_label || props.label || 'Open'),
1965
+ open ? h('div', { className: 'fixed inset-0 z-[99] flex items-center justify-center' },
1966
+ h('div', { className: 'absolute inset-0 bg-black/40 backdrop-blur-sm', onClick: () => setOpen(false) }),
1967
+ h('div', { className: 'relative bg-white rounded-2xl max-w-lg w-full mx-4 p-6 shadow-2xl max-h-[80vh] overflow-y-auto' },
1968
+ h('div', { className: 'flex justify-between items-center mb-4' },
1969
+ props.title ? h('h3', { className: 'text-lg font-bold text-gray-900' }, props.title) : null,
1970
+ h('button', { className: 'text-gray-400 hover:text-gray-600 text-xl', onClick: () => setOpen(false) }, '\\u2715')
1971
+ ),
1972
+ h('div', { className: 'text-sm text-gray-600', dangerouslySetInnerHTML: { __html: renderMarkdownish(props.content || props.body || '') } })
1973
+ )
1974
+ ) : null
1975
+ );
1976
+ }
1977
+
1978
+ function ActionButton({ action, themeColor, onAction }) {
1979
+ const st = action.style || 'primary';
1980
+ const hasSide = !!action.side_statement;
1981
+ const btnProps = st === 'primary'
1982
+ ? { className: 'cf-btn-primary text-white', style: { backgroundColor: themeColor } }
1983
+ : st === 'secondary'
1984
+ ? { className: 'cf-btn-secondary', style: { borderColor: themeColor, color: themeColor } }
1985
+ : st === 'danger'
1986
+ ? { className: 'cf-btn-primary text-white', style: { backgroundColor: '#ef4444' } }
1987
+ : { className: 'cf-btn-ghost', style: { color: themeColor + 'cc' } };
1988
+ const btn = h('button', {
1989
+ ...btnProps,
1990
+ className: btnProps.className + (hasSide ? ' flex-1' : ' w-full') + ' flex items-center justify-center',
1991
+ onClick: () => onAction(action),
1992
+ },
1993
+ action.icon ? h('span', { className: 'mr-2' }, action.icon) : null,
1994
+ action.label
1995
+ );
1996
+ return h('div', { className: 'w-full' },
1997
+ hasSide ? h('div', { className: 'flex items-center gap-4' }, btn, h('span', { className: 'text-sm font-medium text-gray-600 shrink-0' }, action.side_statement)) : btn,
1998
+ action.reassurance ? h('p', { className: 'text-xs text-gray-400 mt-1.5 text-center' }, action.reassurance) : null
1999
+ );
2000
+ }
2001
+
2002
+ function VideoPlayer({ comp, isCover, compClass, compStyle }) {
2003
+ const props = comp.props || {};
2004
+ const videoRef = React.useRef(null);
2005
+ React.useEffect(() => {
2006
+ const video = videoRef.current;
2007
+ if (!video) return;
2008
+ const handler = () => {
2009
+ const pct = video.duration ? Math.round((video.currentTime / video.duration) * 100) : 0;
2010
+ window.__videoWatchState = window.__videoWatchState || {};
2011
+ window.__videoWatchState[comp.id] = { watch_percent: pct, playing: !video.paused, duration: video.duration };
2012
+ };
2013
+ video.addEventListener('timeupdate', handler);
2014
+ return () => video.removeEventListener('timeupdate', handler);
2015
+ }, []);
2016
+ return h('div', { className: 'w-full ' + compClass, style: compStyle },
2017
+ h('div', { className: 'relative w-full overflow-hidden rounded-2xl shadow-lg' },
2018
+ h('video', { ref: videoRef, src: props.hls_url || props.src, controls: true, playsInline: true, poster: props.poster, className: 'w-full' })
2019
+ )
2020
+ );
2021
+ }
2022
+
2023
+ function StickyBottomBar({ config, page, formState, cartItems, themeColor, onNext, onAction, onBack, historyLen }) {
2024
+ const [visible, setVisible] = React.useState(!config.delay_ms);
2025
+ const [scrollDir, setScrollDir] = React.useState('down');
2026
+ React.useEffect(() => {
2027
+ if (!config.delay_ms) return;
2028
+ const timer = setTimeout(() => setVisible(true), config.delay_ms);
2029
+ return () => clearTimeout(timer);
2030
+ }, [config.delay_ms]);
2031
+ React.useEffect(() => {
2032
+ if (config.scroll_behavior !== 'show_on_up') return;
2033
+ let lastY = window.scrollY;
2034
+ const handler = () => { const dir = window.scrollY > lastY ? 'down' : 'up'; setScrollDir(dir); lastY = window.scrollY; };
2035
+ window.addEventListener('scroll', handler, { passive: true });
2036
+ return () => window.removeEventListener('scroll', handler);
2037
+ }, []);
2038
+ const show = visible && (config.scroll_behavior !== 'show_on_up' || scrollDir === 'up');
2039
+ const interpolate = (text) => text ? text.replace(/\\{\\{(\\w+)\\}\\}/g, (_, id) => formState[id] ?? '') : text;
2040
+ const bgStyles = {
2041
+ solid: { backgroundColor: 'white', borderTop: '1px solid #e5e7eb' },
2042
+ glass: { backgroundColor: 'rgba(255,255,255,0.85)', backdropFilter: 'blur(16px)', borderTop: '1px solid rgba(0,0,0,0.05)' },
2043
+ glass_dark: { backgroundColor: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(16px)', color: 'white' },
2044
+ gradient: { background: 'linear-gradient(135deg, ' + themeColor + ' 0%, ' + themeColor + 'dd 100%)', color: 'white' },
2045
+ };
2046
+ const handlePrimary = () => {
2047
+ const dispatch = config.primary_action?.dispatch;
2048
+ if (!dispatch || dispatch === 'next') { onNext(); return; }
2049
+ if (dispatch.startsWith('action:')) {
2050
+ const actionId = dispatch.slice(7);
2051
+ const action = page.actions?.find(a => a.id === actionId);
2052
+ if (action) onAction(action); else onNext();
2053
+ } else { onNext(); }
2054
+ };
2055
+ return h('div', {
2056
+ className: 'cf-sticky-bar' + (show ? '' : ' hidden'),
2057
+ style: bgStyles[config.style || 'solid'] || bgStyles.solid,
2058
+ },
2059
+ h('div', { className: 'max-w-2xl mx-auto px-6 py-4 flex items-center justify-between gap-4' },
2060
+ config.show_back && historyLen > 0
2061
+ ? h('button', { className: 'text-sm text-gray-500 hover:text-gray-700', onClick: onBack }, '\\u2190 Back') : null,
2062
+ h('div', { className: 'flex-1 text-center' },
2063
+ config.subtitle ? h('p', { className: 'text-xs opacity-60 mb-0.5' }, interpolate(config.subtitle)) : null
2064
+ ),
2065
+ h('div', { className: 'flex items-center gap-3' },
2066
+ config.cart_badge && cartItems.length > 0
2067
+ ? h('span', { className: 'bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center' }, cartItems.length) : null,
2068
+ h('button', {
2069
+ className: 'cf-btn-primary text-white text-sm',
2070
+ style: { backgroundColor: config.style === 'gradient' ? 'rgba(255,255,255,0.9)' : themeColor, color: config.style === 'gradient' ? themeColor : 'white' },
2071
+ disabled: config.disabled,
2072
+ onClick: handlePrimary,
2073
+ }, interpolate(config.primary_action?.label || page.submit_label || 'Continue'))
2074
+ )
2075
+ )
2076
+ );
2077
+ }
2078
+
1613
2079
  // --- Dev Config ---
1614
2080
  const devConfig = JSON.parse(document.getElementById('__dev_config').textContent);
1615
2081
 
@@ -1711,9 +2177,11 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1711
2177
 
1712
2178
  function CartButton({ itemCount, onClick }) {
1713
2179
  if (itemCount === 0) return null;
2180
+ const cartPos = (schema.settings?.cart?.position) || 'bottom-right';
2181
+ const posClass = { 'bottom-right': 'bottom-6 right-6', 'bottom-left': 'bottom-6 left-6', 'top-right': 'top-20 right-6', 'top-left': 'top-20 left-6' }[cartPos] || 'bottom-6 right-6';
1714
2182
  return h('button', {
1715
2183
  onClick,
1716
- className: 'fixed bottom-6 right-6 z-[90] flex items-center gap-2 rounded-full px-5 py-3.5 text-white font-semibold shadow-xl transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95',
2184
+ className: 'fixed z-[90] flex items-center gap-2 rounded-full px-5 py-3.5 text-white font-semibold shadow-xl transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95 ' + posClass,
1717
2185
  style: { backgroundColor: themeColor, fontFamily: 'var(--font-display)' },
1718
2186
  },
1719
2187
  h('svg', { className: 'w-5 h-5', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
@@ -1752,7 +2220,7 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1752
2220
  )
1753
2221
  ),
1754
2222
  h('div', null,
1755
- h('h2', { className: 'text-lg font-bold text-gray-900' }, 'Your Cart'),
2223
+ h('h2', { className: 'text-lg font-bold text-gray-900' }, schema.settings?.cart?.title || 'Your Cart'),
1756
2224
  h('p', { className: 'text-xs text-gray-400' }, items.length + ' ' + (items.length === 1 ? 'item' : 'items') + ' selected')
1757
2225
  )
1758
2226
  ),
@@ -1872,15 +2340,80 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1872
2340
  }
1873
2341
 
1874
2342
  // --- Main App ---
1875
- function CatalogPreview({ catalog }) {
2343
+ function CatalogPreview({ catalog: rawCatalog }) {
2344
+ // --- Variant resolution ---
2345
+ const catalog = React.useMemo(() => {
2346
+ const urlParams = Object.fromEntries(new URLSearchParams(window.location.search));
2347
+ const variantSlug = urlParams.variant;
2348
+ const catalogHints = rawCatalog.hints || {};
2349
+ let hints = { ...(catalogHints.defaults || {}) };
2350
+ if (variantSlug && catalogHints.variants) {
2351
+ const variant = catalogHints.variants.find(v => v.slug === variantSlug);
2352
+ if (variant?.hints) hints = { ...hints, ...variant.hints };
2353
+ }
2354
+ devContext.hints = hints;
2355
+ if (Object.keys(hints).length === 0) return rawCatalog;
2356
+ const resolvedPages = JSON.parse(JSON.stringify(rawCatalog.pages || {}));
2357
+ for (const page of Object.values(resolvedPages)) {
2358
+ for (const comp of page.components || []) {
2359
+ comp.props = resolveComponentVariants(comp.props || {}, hints);
2360
+ }
2361
+ if (page.actions) {
2362
+ for (const action of page.actions) Object.assign(action, resolveComponentVariants(action, hints));
2363
+ }
2364
+ }
2365
+ return { ...rawCatalog, pages: resolvedPages };
2366
+ }, [rawCatalog]);
2367
+
1876
2368
  const pages = catalog.pages || {};
1877
2369
  const pageKeys = Object.keys(pages);
1878
2370
  const routing = catalog.routing || {};
1879
- const [currentPageId, setCurrentPageId] = React.useState(routing.entry || pageKeys[0] || null);
1880
- const [formState, setFormState] = React.useState({});
2371
+ const entryPageId = routing.entry || pageKeys[0] || null;
2372
+ const saveKey = 'cf_resume_' + (catalog.slug || 'dev');
2373
+
2374
+ const [currentPageId, setCurrentPageId] = React.useState(entryPageId);
2375
+ // --- Prefill / default values ---
2376
+ const [formState, setFormState] = React.useState(() => {
2377
+ const state = {};
2378
+ for (const page of Object.values(pages)) {
2379
+ for (const comp of page.components || []) {
2380
+ if (comp.props?.default_value != null) state[comp.id] = comp.props.default_value;
2381
+ if ((comp.type === 'checkboxes' || comp.type === 'multiple_choice') && Array.isArray(comp.props?.options)) {
2382
+ for (const opt of comp.props.options) {
2383
+ if (!opt.inputs) continue;
2384
+ for (const input of opt.inputs) {
2385
+ const nd = input.props?.default_value ?? input.default_value;
2386
+ if (nd != null) state[comp.id + '.' + opt.value + '.' + input.id] = nd;
2387
+ }
2388
+ }
2389
+ }
2390
+ }
2391
+ }
2392
+ const mappings = catalog.settings?.url_params?.prefill_mappings;
2393
+ if (mappings) {
2394
+ const urlParams = Object.fromEntries(new URLSearchParams(window.location.search));
2395
+ for (const [param, compId] of Object.entries(mappings)) {
2396
+ if (urlParams[param]) state[compId] = urlParams[param];
2397
+ }
2398
+ }
2399
+ return state;
2400
+ });
1881
2401
  const [history, setHistory] = React.useState([]);
1882
2402
  const [cartItems, setCartItems] = React.useState([]);
1883
2403
  const [cartOpen, setCartOpen] = React.useState(false);
2404
+ const [showCheckout, setShowCheckout] = React.useState(false);
2405
+ const [validationErrors, setValidationErrors] = React.useState([]);
2406
+ const [savedSession, setSavedSession] = React.useState(null);
2407
+ const [showResumeModal, setShowResumeModal] = React.useState(false);
2408
+ const [submitted, setSubmitted] = React.useState(() => {
2409
+ const params = new URLSearchParams(window.location.search);
2410
+ return params.get('checkout') === 'success';
2411
+ });
2412
+ const formStateRef = React.useRef(formState);
2413
+ formStateRef.current = formState;
2414
+ const historyRef = React.useRef(history);
2415
+ historyRef.current = history;
2416
+ const autoAdvanceTimer = React.useRef(null);
1884
2417
 
1885
2418
  // --- Cart logic ---
1886
2419
  const addToCart = React.useCallback((pageId) => {
@@ -1896,6 +2429,11 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1896
2429
  price_display: offer.price_display,
1897
2430
  price_subtext: offer.price_subtext,
1898
2431
  image: offer.image,
2432
+ stripe_price_id: offer.stripe_price_id,
2433
+ amount_cents: offer.amount_cents,
2434
+ currency: offer.currency,
2435
+ payment_type: offer.payment_type,
2436
+ interval: offer.interval,
1899
2437
  }];
1900
2438
  });
1901
2439
  }, [pages]);
@@ -1927,11 +2465,18 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1927
2465
  }
1928
2466
  }, [formState, pages, cartItems, addToCart, removeFromCart]);
1929
2467
 
1930
- // Expose navigation for mindmap + emit page_view
2468
+ // Expose navigation for mindmap + emit page_view + fire CatalogKit events
1931
2469
  React.useEffect(() => {
1932
2470
  window.__devNavigateTo = (id) => { setCurrentPageId(id); setHistory([]); window.scrollTo({ top: 0, behavior: 'smooth' }); };
1933
2471
  window.__devSetCurrentPage && window.__devSetCurrentPage(currentPageId);
1934
2472
  devEvents.emit('page_view', { page_id: currentPageId, catalog_slug: catalog.slug });
2473
+ setValidationErrors([]);
2474
+ // CatalogKit pageenter event
2475
+ const listeners = window.__catalogKitListeners || {};
2476
+ for (const key of ['pageenter', 'pageenter:' + currentPageId]) {
2477
+ const set = listeners[key]; if (!set?.size) continue;
2478
+ for (const cb of set) { try { cb({ pageId: currentPageId }); } catch (e) { console.error('[CatalogKit]', key, e); } }
2479
+ }
1935
2480
  }, [currentPageId]);
1936
2481
 
1937
2482
  // Expose debug state
@@ -1941,6 +2486,98 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1941
2486
  window.dispatchEvent(new CustomEvent('devStateUpdate'));
1942
2487
  }, [currentPageId, formState, cartItems, routing]);
1943
2488
 
2489
+ // --- Browser history (pushState / popstate) ---
2490
+ React.useEffect(() => {
2491
+ window.history.replaceState({ pageId: entryPageId, history: [] }, '');
2492
+ const onPopState = (e) => {
2493
+ const pageId = e.state?.pageId;
2494
+ const prevHistory = e.state?.history || [];
2495
+ if (pageId && pages[pageId]) {
2496
+ setCurrentPageId(pageId);
2497
+ setHistory(prevHistory);
2498
+ setValidationErrors([]);
2499
+ window.scrollTo({ top: 0, behavior: 'smooth' });
2500
+ }
2501
+ };
2502
+ window.addEventListener('popstate', onPopState);
2503
+ return () => window.removeEventListener('popstate', onPopState);
2504
+ }, []);
2505
+
2506
+ // --- localStorage persistence ---
2507
+ React.useEffect(() => {
2508
+ if (!submitted) {
2509
+ try { localStorage.setItem(saveKey, JSON.stringify({ formState, currentPageId, history })); } catch {}
2510
+ }
2511
+ }, [formState, currentPageId, history, submitted]);
2512
+
2513
+ // --- Check for saved session on mount ---
2514
+ React.useEffect(() => {
2515
+ try {
2516
+ const raw = localStorage.getItem(saveKey);
2517
+ if (raw) {
2518
+ const data = JSON.parse(raw);
2519
+ if (data.currentPageId && data.currentPageId !== entryPageId && pages[data.currentPageId]) {
2520
+ setSavedSession(data);
2521
+ setShowResumeModal(true);
2522
+ }
2523
+ }
2524
+ } catch {}
2525
+ }, []);
2526
+
2527
+ // --- Auto-skip pages ---
2528
+ React.useEffect(() => {
2529
+ const page = pages[currentPageId];
2530
+ if (!page?.auto_skip) return;
2531
+ const inputTypes = new Set(['short_text','long_text','rich_text','email','phone','url','address','number','currency','date','datetime','time','date_range','dropdown','multiselect','multiple_choice','checkboxes','picture_choice','switch','checkbox','choice_matrix','ranking','star_rating','slider','opinion_scale','file_upload','signature','password','location']);
2532
+ const visibleInputs = (page.components || []).filter(c => {
2533
+ if (!inputTypes.has(c.type)) return false;
2534
+ if (c.hidden || c.props?.hidden) return false;
2535
+ if (c.visibility && !evaluateConditionGroup(c.visibility, formState, devContext)) return false;
2536
+ return true;
2537
+ });
2538
+ const allFilled = visibleInputs.every(c => {
2539
+ if (!c.props?.required) return true;
2540
+ const val = formState[c.id];
2541
+ return val != null && val !== '' && !(Array.isArray(val) && val.length === 0);
2542
+ });
2543
+ if (allFilled && visibleInputs.length > 0) {
2544
+ devEvents.emit('page_auto_skipped', { page_id: currentPageId });
2545
+ const nextId = getNextPage(routing, currentPageId, formState, devContext);
2546
+ if (nextId && pages[nextId]) {
2547
+ setCurrentPageId(nextId);
2548
+ window.history.replaceState({ pageId: nextId, history: historyRef.current }, '');
2549
+ }
2550
+ }
2551
+ }, [currentPageId]);
2552
+
2553
+ // --- CatalogKit API (window.CatalogKit) ---
2554
+ React.useEffect(() => {
2555
+ const listeners = {};
2556
+ const instance = {
2557
+ getField: (id) => formStateRef.current[id],
2558
+ getAllFields: () => ({ ...formStateRef.current }),
2559
+ getPageId: () => currentPageId,
2560
+ setField: (id, value) => setFormState(prev => ({ ...prev, [id]: value })),
2561
+ goNext: () => handleNextRef.current?.(),
2562
+ goBack: () => handleBackRef.current?.(),
2563
+ on: (event, cb) => { if (!listeners[event]) listeners[event] = new Set(); listeners[event].add(cb); },
2564
+ off: (event, cb) => { listeners[event]?.delete(cb); },
2565
+ openCart: () => setCartOpen(true),
2566
+ closeCart: () => setCartOpen(false),
2567
+ getCartItems: () => [...cartItems],
2568
+ setValidationError: (id, msg) => {
2569
+ setValidationErrors(prev => {
2570
+ const next = prev.filter(e => e.componentId !== id);
2571
+ if (msg) next.push({ componentId: id, message: msg });
2572
+ return next;
2573
+ });
2574
+ },
2575
+ };
2576
+ window.CatalogKit = { get: () => instance, getField: instance.getField, setField: instance.setField, getPageId: instance.getPageId, goNext: instance.goNext, goBack: instance.goBack, on: instance.on, off: instance.off };
2577
+ window.__catalogKitListeners = listeners;
2578
+ return () => { delete window.CatalogKit; delete window.__catalogKitListeners; };
2579
+ }, []);
2580
+
1944
2581
  const page = currentPageId ? pages[currentPageId] : null;
1945
2582
  const isCover = page?.layout === 'cover';
1946
2583
  const isLastPage = (() => {
@@ -1948,28 +2585,110 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1948
2585
  return !routing.edges.some(e => e.from === currentPageId);
1949
2586
  })();
1950
2587
 
2588
+ const navigateTo = React.useCallback((nextId) => {
2589
+ // Fire CatalogKit pageexit
2590
+ const ckListeners = window.__catalogKitListeners || {};
2591
+ for (const key of ['pageexit', 'pageexit:' + currentPageId]) {
2592
+ const set = ckListeners[key]; if (!set?.size) continue;
2593
+ for (const cb of set) { try { cb({ pageId: currentPageId }); } catch (e) { console.error('[CatalogKit]', key, e); } }
2594
+ }
2595
+ if (nextId && pages[nextId]) {
2596
+ const newHistory = [...history, currentPageId];
2597
+ setHistory(newHistory);
2598
+ setCurrentPageId(nextId);
2599
+ window.history.pushState({ pageId: nextId, history: newHistory }, '');
2600
+ window.scrollTo({ top: 0, behavior: 'smooth' });
2601
+ } else {
2602
+ // End of funnel \u2014 show checkout or completion
2603
+ if (catalog.settings?.checkout) {
2604
+ setShowCheckout(true);
2605
+ devEvents.emit('checkout_start', { item_count: cartItems.length });
2606
+ } else {
2607
+ setSubmitted(true);
2608
+ try { localStorage.removeItem(saveKey); } catch {}
2609
+ devEvents.emit('form_submit', { page_id: currentPageId, form_state: formState });
2610
+ }
2611
+ window.scrollTo({ top: 0, behavior: 'smooth' });
2612
+ }
2613
+ }, [currentPageId, pages, catalog, cartItems, formState, history]);
2614
+
1951
2615
  const onFieldChange = React.useCallback((id, value) => {
1952
2616
  setFormState(prev => ({ ...prev, [id]: value }));
1953
2617
  devEvents.emit('field_change', { field_id: id, value, page_id: currentPageId });
1954
- }, [currentPageId]);
2618
+ // Fire CatalogKit fieldchange
2619
+ const ckListeners = window.__catalogKitListeners || {};
2620
+ const fcSet = ckListeners['fieldchange']; if (fcSet?.size) for (const cb of fcSet) { try { cb({ fieldId: id, value, pageId: currentPageId }); } catch {} }
2621
+
2622
+ // Auto-advance: if page has auto_advance and this is a selection-type input
2623
+ const pg = pages[currentPageId];
2624
+ if (pg?.auto_advance && value != null && value !== '') {
2625
+ const selectionTypes = ['multiple_choice', 'picture_choice', 'dropdown', 'checkboxes', 'multiselect'];
2626
+ const comp = (pg.components || []).find(c => c.id === id);
2627
+ if (comp && selectionTypes.includes(comp.type)) {
2628
+ const newFormState = { ...formState, [id]: value };
2629
+ const inputTypes = [...selectionTypes, 'short_text', 'long_text', 'rich_text', 'email', 'phone', 'url',
2630
+ 'address', 'number', 'currency', 'date', 'datetime', 'time', 'date_range', 'switch', 'checkbox',
2631
+ 'choice_matrix', 'ranking', 'star_rating', 'slider', 'opinion_scale', 'file_upload', 'signature',
2632
+ 'password', 'location'];
2633
+ const visibleInputs = (pg.components || []).filter(c => {
2634
+ if (!inputTypes.includes(c.type)) return false;
2635
+ if (c.hidden || c.props?.hidden) return false;
2636
+ if (c.visibility && !evaluateConditionGroup(c.visibility, newFormState, devContext)) return false;
2637
+ return true;
2638
+ });
2639
+ const lastInput = visibleInputs[visibleInputs.length - 1];
2640
+ if (lastInput && lastInput.id === id) {
2641
+ if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current);
2642
+ autoAdvanceTimer.current = setTimeout(() => {
2643
+ const nextId = getNextPage(routing, currentPageId, newFormState, devContext);
2644
+ navigateTo(nextId);
2645
+ }, 400);
2646
+ }
2647
+ }
2648
+ }
2649
+ }, [currentPageId, pages, formState, routing, navigateTo]);
2650
+
2651
+ // --- Validation ---
2652
+ const runValidation = React.useCallback(() => {
2653
+ const page = pages[currentPageId];
2654
+ if (!page) return true;
2655
+ const errors = validatePage(page, formState, devContext);
2656
+ setValidationErrors(errors);
2657
+ if (errors.length > 0) {
2658
+ const el = document.querySelector('[data-component-id="' + errors[0].componentId + '"]');
2659
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
2660
+ return false;
2661
+ }
2662
+ return true;
2663
+ }, [currentPageId, pages, formState]);
1955
2664
 
1956
2665
  const handleNext = React.useCallback(() => {
1957
- // Check if page has an offer \u2014 treat "Next" as an accept action
2666
+ // Validate before advancing
2667
+ if (!runValidation()) return;
2668
+ // Check video watch requirements
1958
2669
  const currentPage = pages[currentPageId];
1959
- if (currentPage?.offer) {
1960
- const acceptValue = currentPage.offer.accept_value || 'accept';
1961
- // If there's no accept_field, the CTA button itself is the accept trigger
1962
- if (!currentPage.offer.accept_field) {
1963
- addToCart(currentPageId);
2670
+ if (currentPage) {
2671
+ for (const comp of currentPage.components || []) {
2672
+ if (comp.type === 'video' && comp.props?.require_watch_percent) {
2673
+ const vs = window.__videoWatchState?.[comp.id];
2674
+ if (!vs || vs.watch_percent < comp.props.require_watch_percent) {
2675
+ const el = document.querySelector('[data-component-id="' + comp.id + '"]');
2676
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
2677
+ alert('Please watch at least ' + comp.props.require_watch_percent + '% of the video before continuing.');
2678
+ return;
2679
+ }
2680
+ }
1964
2681
  }
1965
2682
  }
1966
- const nextId = getNextPageId(currentPageId, routing, formState);
1967
- if (nextId && pages[nextId]) {
1968
- setHistory(prev => [...prev, currentPageId]);
1969
- setCurrentPageId(nextId);
1970
- window.scrollTo({ top: 0, behavior: 'smooth' });
2683
+ // Check if page has an offer \u2014 treat "Next" as an accept action
2684
+ if (currentPage?.offer) {
2685
+ if (!currentPage.offer.accept_field) addToCart(currentPageId);
1971
2686
  }
1972
- }, [currentPageId, routing, formState, pages, addToCart]);
2687
+ const nextId = getNextPage(routing, currentPageId, formState, devContext);
2688
+ navigateTo(nextId);
2689
+ }, [currentPageId, routing, formState, pages, addToCart, navigateTo, runValidation]);
2690
+ const handleNextRef = React.useRef(handleNext);
2691
+ handleNextRef.current = handleNext;
1973
2692
 
1974
2693
  const handleBack = React.useCallback(() => {
1975
2694
  if (history.length > 0) {
@@ -1979,6 +2698,174 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1979
2698
  window.scrollTo({ top: 0, behavior: 'smooth' });
1980
2699
  }
1981
2700
  }, [history]);
2701
+ const handleBackRef = React.useRef(handleBack);
2702
+ handleBackRef.current = handleBack;
2703
+
2704
+ // --- Page Actions ---
2705
+ const handleAction = React.useCallback((action) => {
2706
+ devEvents.emit('action_click', { page_id: currentPageId, action_id: action.id });
2707
+ if (action.redirect_url) { window.open(action.redirect_url, '_blank'); return; }
2708
+ if (!runValidation()) return;
2709
+ const currentPage = pages[currentPageId];
2710
+ const currentOffer = currentPage?.offer;
2711
+ if (currentOffer) {
2712
+ const acceptValue = currentOffer.accept_value || 'accept';
2713
+ if (action.id === acceptValue) addToCart(currentPageId);
2714
+ }
2715
+ const actionKey = '__action_' + currentPageId;
2716
+ const newFormState = { ...formState, [actionKey]: action.id };
2717
+ setFormState(newFormState);
2718
+ const nextPageId = getNextPage(routing, currentPageId, newFormState, devContext);
2719
+ navigateTo(nextPageId);
2720
+ }, [currentPageId, routing, formState, pages, addToCart, navigateTo, runValidation]);
2721
+
2722
+ // --- Resume prompt ---
2723
+ if (showResumeModal) {
2724
+ return h('div', { className: 'cf-resume-backdrop' },
2725
+ h('div', { className: 'bg-white rounded-2xl max-w-sm w-full mx-4 p-8 shadow-2xl text-center' },
2726
+ h('h2', { className: 'text-xl font-bold text-gray-900 mb-2', style: { fontFamily: 'var(--font-display)' } }, 'Welcome back!'),
2727
+ h('p', { className: 'text-gray-500 mb-6 text-sm' }, 'Pick up where you left off?'),
2728
+ h('div', { className: 'space-y-3' },
2729
+ h('button', {
2730
+ className: 'cf-btn-primary w-full text-white', style: { backgroundColor: themeColor },
2731
+ onClick: () => {
2732
+ if (savedSession) {
2733
+ setFormState(savedSession.formState || {});
2734
+ setCurrentPageId(savedSession.currentPageId);
2735
+ setHistory(savedSession.history || []);
2736
+ window.history.replaceState({ pageId: savedSession.currentPageId, history: savedSession.history || [] }, '');
2737
+ }
2738
+ setShowResumeModal(false);
2739
+ },
2740
+ }, 'Resume'),
2741
+ h('button', {
2742
+ className: 'w-full px-4 py-2 text-sm text-gray-500 hover:text-gray-700 transition-colors',
2743
+ onClick: () => { setShowResumeModal(false); try { localStorage.removeItem(saveKey); } catch {} },
2744
+ }, 'Start Over')
2745
+ )
2746
+ )
2747
+ );
2748
+ }
2749
+
2750
+ // --- Completion screen ---
2751
+ if (submitted) {
2752
+ const completionSettings = catalog.settings?.completion;
2753
+ return h('div', { className: 'min-h-screen flex items-center justify-center', style: { background: 'linear-gradient(180deg, #f8f9fc 0%, #f0f2f7 100%)' } },
2754
+ h('div', { className: 'max-w-lg mx-auto text-center px-6 py-20 page-enter-active' },
2755
+ h('div', { className: 'w-20 h-20 mx-auto mb-6 rounded-full flex items-center justify-center', style: { backgroundColor: themeColor + '15' } },
2756
+ h('svg', { className: 'w-10 h-10', style: { color: themeColor }, fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2757
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z' })
2758
+ )
2759
+ ),
2760
+ h('h1', { className: 'text-3xl font-bold text-gray-900 mb-3', style: { fontFamily: 'var(--font-display)' } },
2761
+ completionSettings?.title || 'Thank You!'
2762
+ ),
2763
+ h('p', { className: 'text-gray-500 text-lg mb-8' },
2764
+ completionSettings?.message || 'Your submission has been received.'
2765
+ ),
2766
+ completionSettings?.redirect_url ? h('a', {
2767
+ href: completionSettings.redirect_url,
2768
+ className: 'inline-flex items-center gap-2 px-6 py-3 rounded-xl text-white font-semibold transition-all hover:scale-[1.02]',
2769
+ style: { backgroundColor: themeColor },
2770
+ }, completionSettings.redirect_label || 'Continue') : null,
2771
+ h('div', { className: 'mt-6' },
2772
+ h('button', {
2773
+ className: 'text-sm text-gray-400 hover:text-gray-600 transition-colors',
2774
+ onClick: () => { setSubmitted(false); setCurrentPageId(routing.entry || pageKeys[0]); setHistory([]); setFormState({}); setCartItems([]); try { localStorage.removeItem(saveKey); } catch {} },
2775
+ }, 'Start Over')
2776
+ )
2777
+ )
2778
+ );
2779
+ }
2780
+
2781
+ // --- Checkout screen ---
2782
+ if (showCheckout) {
2783
+ const checkoutSettings = catalog.settings?.checkout || {};
2784
+ const handleCheckoutBack = () => { setShowCheckout(false); };
2785
+ const handleCheckoutContinue = () => {
2786
+ setShowCheckout(false);
2787
+ setSubmitted(true);
2788
+ devEvents.emit('checkout_skip', { page_id: currentPageId });
2789
+ };
2790
+
2791
+ return h('div', { className: 'min-h-screen', style: { background: 'linear-gradient(180deg, #f8f9fc 0%, #f0f2f7 100%)', fontFamily: 'var(--font-display)' } },
2792
+ // Header
2793
+ h('div', { className: 'fixed top-[28px] left-0 right-0 z-50 bg-white/80 backdrop-blur-xl border-b border-gray-200/60' },
2794
+ h('div', { className: 'max-w-5xl mx-auto flex items-center justify-between px-6 py-3' },
2795
+ h('button', { onClick: handleCheckoutBack, className: 'flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 transition-colors' },
2796
+ h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2797
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M15.75 19.5L8.25 12l7.5-7.5' })
2798
+ ),
2799
+ 'Back'
2800
+ ),
2801
+ h('div', { className: 'flex items-center gap-2' },
2802
+ h('svg', { className: 'w-4 h-4 text-green-500', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2803
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z' })
2804
+ ),
2805
+ h('span', { className: 'text-xs font-medium text-gray-400' }, 'Secure Checkout')
2806
+ )
2807
+ )
2808
+ ),
2809
+ h('div', { className: 'max-w-5xl mx-auto px-6 pt-24 pb-12' },
2810
+ h('div', { className: 'text-center mb-10' },
2811
+ h('h1', { className: 'text-3xl sm:text-4xl font-bold text-gray-900', style: { letterSpacing: '-0.025em' } },
2812
+ checkoutSettings.title || 'Complete Your Order'
2813
+ )
2814
+ ),
2815
+ h('div', { className: 'grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 items-start' },
2816
+ // Order summary
2817
+ h('div', { className: 'lg:col-span-7' },
2818
+ h('div', { className: 'bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden' },
2819
+ h('div', { className: 'px-6 py-4 border-b border-gray-50' },
2820
+ h('h2', { className: 'text-sm font-bold text-gray-900 uppercase tracking-wide' }, 'Order Summary')
2821
+ ),
2822
+ h('div', { className: 'divide-y divide-gray-50' },
2823
+ cartItems.length === 0
2824
+ ? h('div', { className: 'flex items-center gap-5 px-6 py-5' },
2825
+ h('div', { className: 'flex-1 min-w-0' },
2826
+ h('h3', { className: 'text-base font-semibold text-gray-900' }, 'Complete Registration'),
2827
+ h('p', { className: 'text-sm text-gray-400 mt-0.5' }, 'No offers selected \u2014 continue for free')
2828
+ ),
2829
+ h('p', { className: 'text-base font-bold', style: { color: themeColor } }, '$0')
2830
+ )
2831
+ : cartItems.map(item => h('div', { key: item.offer_id, className: 'flex items-center gap-5 px-6 py-5' },
2832
+ item.image ? h('img', { src: item.image, alt: item.title, className: 'w-16 h-16 rounded-xl object-cover flex-shrink-0 border border-gray-100' }) : null,
2833
+ h('div', { className: 'flex-1 min-w-0' },
2834
+ h('h3', { className: 'text-base font-semibold text-gray-900' }, item.title),
2835
+ item.price_subtext ? h('p', { className: 'text-sm text-gray-400 mt-0.5' }, item.price_subtext) : null
2836
+ ),
2837
+ item.price_display ? h('p', { className: 'text-base font-bold', style: { color: themeColor } }, item.price_display) : null,
2838
+ h('button', { onClick: () => removeFromCart(item.offer_id), className: 'w-8 h-8 flex-shrink-0 flex items-center justify-center rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 transition-all' },
2839
+ h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2840
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M6 18L18 6M6 6l12 12' })
2841
+ )
2842
+ )
2843
+ ))
2844
+ )
2845
+ )
2846
+ ),
2847
+ // Payment action
2848
+ h('div', { className: 'lg:col-span-5' },
2849
+ h('div', { className: 'lg:sticky lg:top-20 space-y-5' },
2850
+ h('div', { className: 'bg-white rounded-2xl border border-gray-100 shadow-sm p-7 space-y-6' },
2851
+ h('div', { className: 'text-sm text-gray-500' },
2852
+ cartItems.length + ' ' + (cartItems.length === 1 ? 'item' : 'items')
2853
+ ),
2854
+ h(CartCheckoutButton, { items: cartItems, themeColor }),
2855
+ h('button', {
2856
+ onClick: handleCheckoutContinue,
2857
+ className: 'w-full text-center text-sm text-gray-400 hover:text-gray-600 font-medium transition-colors py-1',
2858
+ }, 'Continue without paying'),
2859
+ h('div', { className: 'flex items-center justify-center gap-3 pt-1' },
2860
+ h('span', { className: 'text-[10px] text-gray-400 font-medium' }, 'Powered by Stripe')
2861
+ )
2862
+ )
2863
+ )
2864
+ )
2865
+ )
2866
+ )
2867
+ );
2868
+ }
1982
2869
 
1983
2870
  if (!page) {
1984
2871
  return h('div', { className: 'min-h-screen flex items-center justify-center bg-gray-50' },
@@ -1988,14 +2875,16 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1988
2875
 
1989
2876
  const components = (page.components || []).filter(c => {
1990
2877
  if (c.hidden || c.props?.hidden) return false;
1991
- if (c.visibility) return evaluateConditionGroup(c.visibility, formState, devContext);
2878
+ if (c.visibility) { if (!evaluateConditionGroup(c.visibility, formState, devContext)) return false; }
2879
+ if (c.prefill_mode === 'hidden' && formState[c.id] != null && formState[c.id] !== '') return false;
1992
2880
  return true;
1993
2881
  });
1994
2882
  const bgImage = page.background_image || catalog.settings?.theme?.background_image;
1995
2883
 
1996
2884
  // Cart UI (shared between cover and standard)
2885
+ const cartSettings = catalog.settings?.cart || {};
1997
2886
  const cartUI = h(React.Fragment, null,
1998
- h(CartButton, { itemCount: cartItems.length, onClick: toggleCart }),
2887
+ !cartSettings.hide_button ? h(CartButton, { itemCount: cartItems.length, onClick: toggleCart }) : null,
1999
2888
  h(CartDrawer, { items: cartItems, isOpen: cartOpen, onToggle: toggleCart, onRemove: removeFromCart })
2000
2889
  );
2001
2890
 
@@ -2013,17 +2902,31 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2013
2902
  h('div', { className: 'cf-cover-overlay absolute inset-0' }),
2014
2903
  h('div', { className: 'cf-cover-content relative max-w-2xl mx-auto px-6 py-20 text-center text-white' },
2015
2904
  h('div', { className: 'page-enter-active space-y-7 flex flex-col items-stretch text-center' },
2016
- ...components.map((comp, i) => h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type },
2017
- h(RenderComponent, { comp, isCover: true, formState, onFieldChange })
2018
- )),
2019
- // CTA button
2020
- h('div', { className: 'mt-8' },
2021
- h('button', {
2022
- className: 'cf-btn-primary w-full py-4 text-lg',
2023
- style: { backgroundColor: themeColor },
2024
- onClick: handleNext,
2025
- }, page.submit_label || (isLastPage ? 'Submit' : 'Get Started'))
2026
- )
2905
+ ...components.map((comp, i) => {
2906
+ if (comp.prefill_mode === 'readonly' && formState[comp.id] != null && formState[comp.id] !== '') {
2907
+ return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: 'space-y-1' },
2908
+ comp.props?.label ? h('label', { className: 'block text-base font-medium text-white/80' }, comp.props.label) : null,
2909
+ h('div', { className: 'px-4 py-3 bg-white/10 rounded-xl text-white text-sm font-medium border border-white/20' }, String(formState[comp.id]))
2910
+ );
2911
+ }
2912
+ const fieldError = validationErrors.find(e => e.componentId === comp.id);
2913
+ return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: fieldError ? 'cf-field-error' : '' },
2914
+ h(RenderComponent, { comp, isCover: true, formState, onFieldChange }),
2915
+ fieldError ? h('p', { className: 'text-xs text-red-300 font-medium mt-1', role: 'alert' }, fieldError.message) : null
2916
+ );
2917
+ }),
2918
+ // Actions or CTA button
2919
+ page.actions?.length > 0
2920
+ ? h('div', { className: 'mt-8 space-y-3' },
2921
+ ...page.actions.map(action => h(ActionButton, { key: action.id, action, themeColor, onAction: handleAction }))
2922
+ )
2923
+ : h('div', { className: 'mt-8' },
2924
+ h('button', {
2925
+ className: 'cf-btn-primary w-full py-4 text-lg',
2926
+ style: { backgroundColor: themeColor },
2927
+ onClick: handleNext,
2928
+ }, page.submit_label || (isLastPage ? 'Submit' : 'Get Started'))
2929
+ )
2027
2930
  )
2028
2931
  )
2029
2932
  )
@@ -2061,26 +2964,49 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2061
2964
  h('div', { className: 'max-w-2xl mx-auto px-6 pb-8', style: { paddingTop: topBarEnabled ? '100px' : '60px' } },
2062
2965
  page.description ? h('p', { className: 'text-sm text-gray-400 mb-8 text-center font-medium tracking-wide', style: { fontFamily: 'var(--font-display)' } }, page.description) : null,
2063
2966
  h('div', { className: 'page-enter-active space-y-5' },
2064
- ...components.map((comp, i) => h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type },
2065
- h(RenderComponent, { comp, isCover: false, formState, onFieldChange })
2066
- )),
2067
- // Navigation button
2068
- !page.hide_navigation ? h('div', { className: 'mt-8' },
2069
- h('button', {
2070
- className: 'cf-btn-primary inline-flex items-center gap-2 text-base',
2071
- style: { backgroundColor: themeColor },
2072
- onClick: handleNext,
2073
- },
2074
- page.submit_label || (isLastPage ? 'Submit' : 'Continue'),
2075
- !isLastPage ? h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2.5 },
2076
- h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3' })
2077
- ) : null
2078
- ),
2079
- page.submit_reassurance ? h('p', { className: 'text-xs text-gray-400 mt-1.5' }, page.submit_reassurance) : null,
2080
- ) : null,
2967
+ ...components.map((comp, i) => {
2968
+ if (comp.prefill_mode === 'readonly' && formState[comp.id] != null && formState[comp.id] !== '') {
2969
+ return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: 'space-y-1' },
2970
+ comp.props?.label ? h('label', { className: 'block text-base font-medium text-gray-700' }, comp.props.label) : null,
2971
+ h('div', { className: 'px-4 py-3 bg-gray-50 rounded-xl text-gray-600 text-sm font-medium border border-gray-100' }, String(formState[comp.id]))
2972
+ );
2973
+ }
2974
+ const fieldError = validationErrors.find(e => e.componentId === comp.id);
2975
+ return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: fieldError ? 'cf-field-error' : '' },
2976
+ h(RenderComponent, { comp, isCover: false, formState, onFieldChange }),
2977
+ fieldError ? h('p', { className: 'text-xs text-red-500 font-medium mt-1', role: 'alert' }, fieldError.message) : null
2978
+ );
2979
+ }),
2980
+ // Actions or navigation button
2981
+ page.actions?.length > 0
2982
+ ? h('div', { className: 'mt-8 space-y-3' },
2983
+ ...page.actions.map(action => h(ActionButton, { key: action.id, action, themeColor, onAction: handleAction }))
2984
+ )
2985
+ : !page.hide_navigation ? h('div', { className: 'mt-8' },
2986
+ h('button', {
2987
+ className: 'cf-btn-primary inline-flex items-center gap-2 text-base',
2988
+ style: { backgroundColor: themeColor },
2989
+ onClick: handleNext,
2990
+ },
2991
+ page.submit_label || (isLastPage ? 'Submit' : 'Continue'),
2992
+ !isLastPage ? h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2.5 },
2993
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3' })
2994
+ ) : null
2995
+ ),
2996
+ page.submit_reassurance ? h('p', { className: 'text-xs text-gray-400 mt-1.5' }, page.submit_reassurance) : null,
2997
+ ) : null,
2081
2998
  ),
2082
2999
  h('div', { className: 'mt-10 text-center text-[11px] text-gray-300 font-medium tracking-wide', style: { fontFamily: 'var(--font-display)' } }, 'Powered by Catalog Kit'),
2083
- )
3000
+ ),
3001
+ // Sticky bottom bar
3002
+ (catalog.settings?.sticky_bar?.enabled || page.sticky_bar?.enabled)
3003
+ ? h(StickyBottomBar, {
3004
+ config: { ...(catalog.settings?.sticky_bar || {}), ...(page.sticky_bar || {}) },
3005
+ page, formState, cartItems, themeColor,
3006
+ onNext: handleNext, onAction: handleAction, onBack: handleBack,
3007
+ historyLen: history.length,
3008
+ })
3009
+ : null
2084
3010
  )
2085
3011
  );
2086
3012
  }
@@ -2129,17 +3055,113 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2129
3055
  const overlay = document.getElementById('pages-overlay');
2130
3056
  const closeBtn = document.getElementById('pages-close');
2131
3057
  const container = document.getElementById('mindmap-container');
3058
+ const zoomInBtn = document.getElementById('zoom-in');
3059
+ const zoomOutBtn = document.getElementById('zoom-out');
3060
+ const zoomFitBtn = document.getElementById('zoom-fit');
3061
+ const zoomLevelEl = document.getElementById('zoom-level');
2132
3062
  let currentPageRef = { id: schema.routing?.entry || Object.keys(schema.pages || {})[0] };
2133
3063
 
3064
+ // Pan & zoom state
3065
+ let scale = 1, panX = 0, panY = 0;
3066
+ let isDragging = false, dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0;
3067
+ let hasDragged = false;
3068
+ const MIN_SCALE = 0.15, MAX_SCALE = 3;
3069
+
3070
+ function applyTransform() {
3071
+ container.style.transform = 'translate(' + panX + 'px, ' + panY + 'px) scale(' + scale + ')';
3072
+ zoomLevelEl.textContent = Math.round(scale * 100) + '%';
3073
+ }
3074
+
3075
+ function fitToView() {
3076
+ const overlayRect = overlay.getBoundingClientRect();
3077
+ // Temporarily reset transform to measure natural size
3078
+ container.style.transform = 'none';
3079
+ requestAnimationFrame(() => {
3080
+ const contentRect = container.getBoundingClientRect();
3081
+ const padW = 80, padH = 80;
3082
+ const scaleX = (overlayRect.width - padW) / contentRect.width;
3083
+ const scaleY = (overlayRect.height - padH) / contentRect.height;
3084
+ scale = Math.min(scaleX, scaleY, 1.5);
3085
+ scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale));
3086
+ panX = (overlayRect.width - contentRect.width * scale) / 2;
3087
+ panY = (overlayRect.height - contentRect.height * scale) / 2;
3088
+ applyTransform();
3089
+ });
3090
+ }
3091
+
2134
3092
  // Expose setter for CatalogPreview to update current page
2135
3093
  window.__devSetCurrentPage = (id) => { currentPageRef.id = id; };
2136
3094
 
2137
3095
  btn.addEventListener('click', () => {
2138
3096
  overlay.classList.add('open');
2139
3097
  renderMindmap();
3098
+ requestAnimationFrame(() => fitToView());
2140
3099
  });
2141
3100
  closeBtn.addEventListener('click', () => overlay.classList.remove('open'));
2142
- overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
3101
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') overlay.classList.remove('open'); });
3102
+
3103
+ // Wheel zoom
3104
+ overlay.addEventListener('wheel', (e) => {
3105
+ e.preventDefault();
3106
+ const rect = overlay.getBoundingClientRect();
3107
+ const mouseX = e.clientX - rect.left;
3108
+ const mouseY = e.clientY - rect.top;
3109
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
3110
+ const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale * delta));
3111
+ // Zoom toward cursor
3112
+ panX = mouseX - (mouseX - panX) * (newScale / scale);
3113
+ panY = mouseY - (mouseY - panY) * (newScale / scale);
3114
+ scale = newScale;
3115
+ applyTransform();
3116
+ }, { passive: false });
3117
+
3118
+ // Pan via drag
3119
+ overlay.addEventListener('mousedown', (e) => {
3120
+ if (e.target.closest('.close-btn, .zoom-controls')) return;
3121
+ isDragging = true;
3122
+ hasDragged = false;
3123
+ dragStartX = e.clientX;
3124
+ dragStartY = e.clientY;
3125
+ dragStartPanX = panX;
3126
+ dragStartPanY = panY;
3127
+ overlay.classList.add('grabbing');
3128
+ });
3129
+ window.addEventListener('mousemove', (e) => {
3130
+ if (!isDragging) return;
3131
+ const dx = e.clientX - dragStartX;
3132
+ const dy = e.clientY - dragStartY;
3133
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) hasDragged = true;
3134
+ panX = dragStartPanX + dx;
3135
+ panY = dragStartPanY + dy;
3136
+ applyTransform();
3137
+ });
3138
+ window.addEventListener('mouseup', () => {
3139
+ isDragging = false;
3140
+ overlay.classList.remove('grabbing');
3141
+ });
3142
+
3143
+ // Close only via close button (not background click \u2014 allows free panning)
3144
+
3145
+ // Zoom buttons
3146
+ zoomInBtn.addEventListener('click', () => {
3147
+ const rect = overlay.getBoundingClientRect();
3148
+ const cx = rect.width / 2, cy = rect.height / 2;
3149
+ const newScale = Math.min(MAX_SCALE, scale * 1.25);
3150
+ panX = cx - (cx - panX) * (newScale / scale);
3151
+ panY = cy - (cy - panY) * (newScale / scale);
3152
+ scale = newScale;
3153
+ applyTransform();
3154
+ });
3155
+ zoomOutBtn.addEventListener('click', () => {
3156
+ const rect = overlay.getBoundingClientRect();
3157
+ const cx = rect.width / 2, cy = rect.height / 2;
3158
+ const newScale = Math.max(MIN_SCALE, scale * 0.8);
3159
+ panX = cx - (cx - panX) * (newScale / scale);
3160
+ panY = cy - (cy - panY) * (newScale / scale);
3161
+ scale = newScale;
3162
+ applyTransform();
3163
+ });
3164
+ zoomFitBtn.addEventListener('click', () => fitToView());
2143
3165
 
2144
3166
  function renderMindmap() {
2145
3167
  const pages = schema.pages || {};
@@ -2248,9 +3270,11 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2248
3270
  container.insertBefore(svgEl, container.firstChild);
2249
3271
  });
2250
3272
 
2251
- // Click nodes to navigate
3273
+ // Click nodes to navigate (ignore if user just dragged)
2252
3274
  container.querySelectorAll('[data-node-id]').forEach(el => {
2253
- el.addEventListener('click', () => {
3275
+ el.addEventListener('click', (e) => {
3276
+ if (hasDragged) return;
3277
+ e.stopPropagation();
2254
3278
  const id = el.dataset.nodeId;
2255
3279
  window.__devNavigateTo && window.__devNavigateTo(id);
2256
3280
  overlay.classList.remove('open');
@@ -2341,19 +3365,78 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2341
3365
  }, true);
2342
3366
  })();
2343
3367
 
2344
- // --- Validation banner ---
2345
- (function initValidationBanner() {
2346
- const banner = document.getElementById('validation-banner');
3368
+ // --- Validation in topbar ---
3369
+ (function initValidationTag() {
3370
+ const tag = document.getElementById('validation-tag');
2347
3371
  const validation = JSON.parse(document.getElementById('__validation_data').textContent);
2348
3372
  const errors = validation.errors || [];
2349
3373
  const warnings = validation.warnings || [];
2350
- if (errors.length === 0 && warnings.length === 0) return;
2351
- let html = '<div class="vb-header"><strong>' + errors.length + ' error(s), ' + warnings.length + ' warning(s)</strong><button class="vb-dismiss" id="vb-dismiss">Dismiss</button></div>';
2352
- for (const e of errors) html += '<div class="vb-error">ERROR: ' + e + '</div>';
2353
- for (const w of warnings) html += '<div class="vb-warn">WARN: ' + w + '</div>';
2354
- banner.innerHTML = html;
2355
- banner.style.display = 'block';
2356
- document.getElementById('vb-dismiss').addEventListener('click', () => { banner.style.display = 'none'; });
3374
+ const total = errors.length + warnings.length;
3375
+ const cleanClass = total === 0 ? ' clean' : '';
3376
+ const label = total === 0 ? '\u2713 Valid' : errors.length + ' error' + (errors.length !== 1 ? 's' : '') + ', ' + warnings.length + ' warn';
3377
+ let html = '<button class="vt-btn' + cleanClass + '" id="vt-toggle">' + label + '</button>';
3378
+ if (total > 0) {
3379
+ html += '<div class="vt-dropdown" id="vt-dropdown">';
3380
+ html += '<div class="vt-header"><span>' + errors.length + ' error(s), ' + warnings.length + ' warning(s)</span></div>';
3381
+ html += '<div class="vt-body">';
3382
+ for (const e of errors) html += '<div class="vt-error">ERROR: ' + e + '</div>';
3383
+ for (const w of warnings) html += '<div class="vt-warn">WARN: ' + w + '</div>';
3384
+ html += '</div></div>';
3385
+ }
3386
+ tag.innerHTML = html;
3387
+ if (total > 0) {
3388
+ const toggleBtn = document.getElementById('vt-toggle');
3389
+ const dropdown = document.getElementById('vt-dropdown');
3390
+ toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); dropdown.classList.toggle('open'); });
3391
+ document.addEventListener('click', (e) => { if (!tag.contains(e.target)) dropdown.classList.remove('open'); });
3392
+ }
3393
+ })();
3394
+
3395
+ // --- Banner minimize / restore ---
3396
+ (function initBannerMinimize() {
3397
+ const banner = document.getElementById('dev-banner');
3398
+ const minimizeBtn = document.getElementById('banner-minimize');
3399
+ const restoreBtn = document.getElementById('banner-restore');
3400
+ minimizeBtn.addEventListener('click', () => {
3401
+ banner.classList.add('minimized');
3402
+ restoreBtn.classList.add('visible');
3403
+ });
3404
+ restoreBtn.addEventListener('click', () => {
3405
+ banner.classList.remove('minimized');
3406
+ restoreBtn.classList.remove('visible');
3407
+ });
3408
+ })();
3409
+
3410
+ // --- Banner drag to reposition ---
3411
+ (function initBannerDrag() {
3412
+ const banner = document.getElementById('dev-banner');
3413
+ const handle = document.getElementById('banner-drag');
3414
+ let isDragging = false, startX = 0, startY = 0, origLeft = 0, origTop = 0;
3415
+
3416
+ handle.addEventListener('mousedown', (e) => {
3417
+ e.preventDefault();
3418
+ isDragging = true;
3419
+ const rect = banner.getBoundingClientRect();
3420
+ startX = e.clientX; startY = e.clientY;
3421
+ origLeft = rect.left; origTop = rect.top;
3422
+ // Switch to positioned mode
3423
+ banner.style.left = rect.left + 'px';
3424
+ banner.style.top = rect.top + 'px';
3425
+ banner.style.right = 'auto';
3426
+ banner.style.width = rect.width + 'px';
3427
+ handle.style.cursor = 'grabbing';
3428
+ });
3429
+ window.addEventListener('mousemove', (e) => {
3430
+ if (!isDragging) return;
3431
+ const dx = e.clientX - startX, dy = e.clientY - startY;
3432
+ banner.style.left = (origLeft + dx) + 'px';
3433
+ banner.style.top = (origTop + dy) + 'px';
3434
+ });
3435
+ window.addEventListener('mouseup', () => {
3436
+ if (!isDragging) return;
3437
+ isDragging = false;
3438
+ handle.style.cursor = '';
3439
+ });
2357
3440
  })();
2358
3441
 
2359
3442
  // --- Debug panel ---