@officexapp/catalogs-cli 0.3.0 → 0.4.1

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 +1282 -165
  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;
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;
925
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,31 +1247,36 @@ 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 ---
1216
- function RenderComponent({ comp, isCover, formState, onFieldChange }) {
1217
- const props = comp.props || {};
1278
+ function RenderComponent({ comp, isCover, formState, onFieldChange, onSubmit, propOverrides }) {
1279
+ const props = { ...(comp.props || {}), ...(propOverrides?.[comp.id] || {}) };
1218
1280
  const type = comp.type;
1219
1281
  const compClass = comp.className || '';
1220
1282
  const compStyle = comp.style || {};
@@ -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':
@@ -1394,7 +1452,7 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1394
1452
  case 'url':
1395
1453
  case 'number':
1396
1454
  case 'password':
1397
- return h(TextInput, { comp, type, formState, onFieldChange, isCover, compClass, compStyle });
1455
+ return h(TextInput, { comp, type, formState, onFieldChange, isCover, compClass, compStyle, onSubmit });
1398
1456
 
1399
1457
  case 'long_text':
1400
1458
  return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
@@ -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,
@@ -1477,7 +1723,7 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1477
1723
  );
1478
1724
  }
1479
1725
 
1480
- function TextInput({ comp, type, formState, onFieldChange, isCover, compClass, compStyle }) {
1726
+ function TextInput({ comp, type, formState, onFieldChange, isCover, compClass, compStyle, onSubmit }) {
1481
1727
  const props = comp.props || {};
1482
1728
  const inputType = type === 'email' ? 'email' : type === 'phone' ? 'tel' : type === 'url' ? 'url' : type === 'number' ? 'number' : type === 'password' ? 'password' : 'text';
1483
1729
  return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
@@ -1493,6 +1739,7 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1493
1739
  placeholder: props.placeholder || '',
1494
1740
  value: formState[comp.id] ?? '',
1495
1741
  onChange: (e) => onFieldChange(comp.id, e.target.value),
1742
+ onKeyDown: (e) => { if (e.key === 'Enter' && onSubmit) { e.preventDefault(); onSubmit(); } },
1496
1743
  })
1497
1744
  );
1498
1745
  }
@@ -1610,6 +1857,226 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1610
1857
  );
1611
1858
  }
1612
1859
 
1860
+ // --- Additional input/display components ---
1861
+ function AddressInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1862
+ const props = comp.props || {};
1863
+ const addr = formState[comp.id] || {};
1864
+ const up = (field, val) => onFieldChange(comp.id, { ...addr, [field]: val });
1865
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1866
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1867
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null) : null,
1868
+ h('div', { className: 'space-y-2' },
1869
+ h('input', { type: 'text', className: 'cf-input', placeholder: 'Street address', value: addr.street || '', onChange: (e) => up('street', e.target.value) }),
1870
+ h('div', { className: 'grid grid-cols-2 gap-2' },
1871
+ h('input', { type: 'text', className: 'cf-input', placeholder: 'City', value: addr.city || '', onChange: (e) => up('city', e.target.value) }),
1872
+ h('input', { type: 'text', className: 'cf-input', placeholder: 'State', value: addr.state || '', onChange: (e) => up('state', e.target.value) })
1873
+ ),
1874
+ h('div', { className: 'grid grid-cols-2 gap-2' },
1875
+ h('input', { type: 'text', className: 'cf-input', placeholder: 'ZIP / Postal', value: addr.zip || '', onChange: (e) => up('zip', e.target.value) }),
1876
+ h('input', { type: 'text', className: 'cf-input', placeholder: 'Country', value: addr.country || '', onChange: (e) => up('country', e.target.value) })
1877
+ )
1878
+ )
1879
+ );
1880
+ }
1881
+
1882
+ function FileUploadInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1883
+ const props = comp.props || {};
1884
+ const fileName = formState[comp.id] || '';
1885
+ const fileRef = React.useRef(null);
1886
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1887
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1888
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null) : null,
1889
+ h('div', { className: 'border-2 border-dashed border-gray-200 rounded-xl p-6 text-center' },
1890
+ h('input', { type: 'file', ref: fileRef, className: 'hidden', accept: props.accept,
1891
+ onChange: (e) => { const f = e.target.files?.[0]; if (f) onFieldChange(comp.id, f.name); } }),
1892
+ fileName
1893
+ ? h('div', { className: 'flex items-center justify-center gap-2' },
1894
+ h('span', { className: 'text-sm text-gray-700' }, fileName),
1895
+ h('button', { className: 'text-xs text-red-500 hover:text-red-700', onClick: () => onFieldChange(comp.id, '') }, 'Remove'))
1896
+ : h('button', { className: 'text-sm font-medium', style: { color: themeColor }, onClick: () => fileRef.current?.click() }, props.button_text || 'Choose file'),
1897
+ h('p', { className: 'text-xs text-gray-400 mt-2' }, 'Files are not uploaded in dev mode')
1898
+ )
1899
+ );
1900
+ }
1901
+
1902
+ function SignatureInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1903
+ const props = comp.props || {};
1904
+ const signed = !!formState[comp.id];
1905
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1906
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1907
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null) : null,
1908
+ h('div', { className: 'border rounded-xl p-4' },
1909
+ signed
1910
+ ? h('div', { className: 'flex items-center justify-between' },
1911
+ h('span', { className: 'text-sm text-green-600 font-medium' }, '\\u2713 Signature captured'),
1912
+ h('button', { className: 'text-xs text-gray-400 hover:text-gray-600', onClick: () => onFieldChange(comp.id, '') }, 'Clear'))
1913
+ : h('button', {
1914
+ className: 'w-full py-8 text-center text-sm text-gray-400 border-2 border-dashed rounded-lg hover:bg-gray-50',
1915
+ onClick: () => onFieldChange(comp.id, 'signature_' + Date.now()),
1916
+ }, 'Click to sign (canvas stubbed in dev)')
1917
+ )
1918
+ );
1919
+ }
1920
+
1921
+ function TabsComponent({ comp, isCover, compClass, compStyle }) {
1922
+ const props = comp.props || {};
1923
+ const tabs = props.tabs || [];
1924
+ const [activeTab, setActiveTab] = React.useState(0);
1925
+ return h('div', { className: compClass, style: compStyle },
1926
+ h('div', { className: 'flex border-b border-gray-200 mb-4' },
1927
+ ...tabs.map((tab, i) => h('button', {
1928
+ key: i, className: 'px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px',
1929
+ style: i === activeTab ? { color: themeColor, borderColor: themeColor } : { color: '#9ca3af', borderColor: 'transparent' },
1930
+ onClick: () => setActiveTab(i),
1931
+ }, tab.label || tab.title || 'Tab ' + (i + 1)))
1932
+ ),
1933
+ tabs[activeTab] ? h('div', { className: 'text-sm text-gray-600', dangerouslySetInnerHTML: { __html: renderMarkdownish(tabs[activeTab].content || '') } }) : null
1934
+ );
1935
+ }
1936
+
1937
+ function CountdownComponent({ comp, compClass, compStyle }) {
1938
+ const props = comp.props || {};
1939
+ const [timeLeft, setTimeLeft] = React.useState({});
1940
+ React.useEffect(() => {
1941
+ const target = new Date(props.target_date).getTime();
1942
+ const update = () => {
1943
+ const diff = Math.max(0, target - Date.now());
1944
+ setTimeLeft({ days: Math.floor(diff / 86400000), hours: Math.floor((diff % 86400000) / 3600000), minutes: Math.floor((diff % 3600000) / 60000), seconds: Math.floor((diff % 60000) / 1000) });
1945
+ };
1946
+ update();
1947
+ const iv = setInterval(update, 1000);
1948
+ return () => clearInterval(iv);
1949
+ }, [props.target_date]);
1950
+ return h('div', { className: 'flex items-center justify-center gap-4 ' + compClass, style: compStyle },
1951
+ ...['days', 'hours', 'minutes', 'seconds'].map(unit =>
1952
+ h('div', { key: unit, className: 'text-center' },
1953
+ h('div', { className: 'text-3xl font-bold', style: { color: themeColor, fontFamily: 'var(--font-display)' } }, String(timeLeft[unit] ?? 0).padStart(2, '0')),
1954
+ h('div', { className: 'text-xs text-gray-400 uppercase tracking-wider mt-1' }, unit)
1955
+ )
1956
+ )
1957
+ );
1958
+ }
1959
+
1960
+ function ModalComponent({ comp, isCover, compClass, compStyle }) {
1961
+ const props = comp.props || {};
1962
+ const [open, setOpen] = React.useState(false);
1963
+ return h(React.Fragment, null,
1964
+ h('button', { className: 'cf-btn-primary text-white', style: { backgroundColor: themeColor, ...compStyle }, onClick: () => setOpen(true) },
1965
+ props.trigger_label || props.label || 'Open'),
1966
+ open ? h('div', { className: 'fixed inset-0 z-[99] flex items-center justify-center' },
1967
+ h('div', { className: 'absolute inset-0 bg-black/40 backdrop-blur-sm', onClick: () => setOpen(false) }),
1968
+ 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' },
1969
+ h('div', { className: 'flex justify-between items-center mb-4' },
1970
+ props.title ? h('h3', { className: 'text-lg font-bold text-gray-900' }, props.title) : null,
1971
+ h('button', { className: 'text-gray-400 hover:text-gray-600 text-xl', onClick: () => setOpen(false) }, '\\u2715')
1972
+ ),
1973
+ h('div', { className: 'text-sm text-gray-600', dangerouslySetInnerHTML: { __html: renderMarkdownish(props.content || props.body || '') } })
1974
+ )
1975
+ ) : null
1976
+ );
1977
+ }
1978
+
1979
+ function ActionButton({ action, themeColor, onAction }) {
1980
+ const st = action.style || 'primary';
1981
+ const hasSide = !!action.side_statement;
1982
+ const btnProps = st === 'primary'
1983
+ ? { className: 'cf-btn-primary text-white', style: { backgroundColor: themeColor } }
1984
+ : st === 'secondary'
1985
+ ? { className: 'cf-btn-secondary', style: { borderColor: themeColor, color: themeColor } }
1986
+ : st === 'danger'
1987
+ ? { className: 'cf-btn-primary text-white', style: { backgroundColor: '#ef4444' } }
1988
+ : { className: 'cf-btn-ghost', style: { color: themeColor + 'cc' } };
1989
+ const btn = h('button', {
1990
+ ...btnProps,
1991
+ className: btnProps.className + (hasSide ? ' flex-1' : ' w-full') + ' flex items-center justify-center',
1992
+ onClick: () => onAction(action),
1993
+ },
1994
+ action.icon ? h('span', { className: 'mr-2' }, action.icon) : null,
1995
+ action.label
1996
+ );
1997
+ return h('div', { className: 'w-full' },
1998
+ 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,
1999
+ action.reassurance ? h('p', { className: 'text-xs text-gray-400 mt-1.5 text-center' }, action.reassurance) : null
2000
+ );
2001
+ }
2002
+
2003
+ function VideoPlayer({ comp, isCover, compClass, compStyle }) {
2004
+ const props = comp.props || {};
2005
+ const videoRef = React.useRef(null);
2006
+ React.useEffect(() => {
2007
+ const video = videoRef.current;
2008
+ if (!video) return;
2009
+ const handler = () => {
2010
+ const pct = video.duration ? Math.round((video.currentTime / video.duration) * 100) : 0;
2011
+ window.__videoWatchState = window.__videoWatchState || {};
2012
+ window.__videoWatchState[comp.id] = { watch_percent: pct, playing: !video.paused, duration: video.duration };
2013
+ };
2014
+ video.addEventListener('timeupdate', handler);
2015
+ return () => video.removeEventListener('timeupdate', handler);
2016
+ }, []);
2017
+ return h('div', { className: 'w-full ' + compClass, style: compStyle },
2018
+ h('div', { className: 'relative w-full overflow-hidden rounded-2xl shadow-lg' },
2019
+ h('video', { ref: videoRef, src: props.hls_url || props.src, controls: true, playsInline: true, poster: props.poster, className: 'w-full' })
2020
+ )
2021
+ );
2022
+ }
2023
+
2024
+ function StickyBottomBar({ config, page, formState, cartItems, themeColor, onNext, onAction, onBack, historyLen }) {
2025
+ const [visible, setVisible] = React.useState(!config.delay_ms);
2026
+ const [scrollDir, setScrollDir] = React.useState('down');
2027
+ React.useEffect(() => {
2028
+ if (!config.delay_ms) return;
2029
+ const timer = setTimeout(() => setVisible(true), config.delay_ms);
2030
+ return () => clearTimeout(timer);
2031
+ }, [config.delay_ms]);
2032
+ React.useEffect(() => {
2033
+ if (config.scroll_behavior !== 'show_on_up') return;
2034
+ let lastY = window.scrollY;
2035
+ const handler = () => { const dir = window.scrollY > lastY ? 'down' : 'up'; setScrollDir(dir); lastY = window.scrollY; };
2036
+ window.addEventListener('scroll', handler, { passive: true });
2037
+ return () => window.removeEventListener('scroll', handler);
2038
+ }, []);
2039
+ const show = visible && (config.scroll_behavior !== 'show_on_up' || scrollDir === 'up');
2040
+ const interpolate = (text) => text ? text.replace(/\\{\\{(\\w+)\\}\\}/g, (_, id) => formState[id] ?? '') : text;
2041
+ const bgStyles = {
2042
+ solid: { backgroundColor: 'white', borderTop: '1px solid #e5e7eb' },
2043
+ glass: { backgroundColor: 'rgba(255,255,255,0.85)', backdropFilter: 'blur(16px)', borderTop: '1px solid rgba(0,0,0,0.05)' },
2044
+ glass_dark: { backgroundColor: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(16px)', color: 'white' },
2045
+ gradient: { background: 'linear-gradient(135deg, ' + themeColor + ' 0%, ' + themeColor + 'dd 100%)', color: 'white' },
2046
+ };
2047
+ const handlePrimary = () => {
2048
+ const dispatch = config.primary_action?.dispatch;
2049
+ if (!dispatch || dispatch === 'next') { onNext(); return; }
2050
+ if (dispatch.startsWith('action:')) {
2051
+ const actionId = dispatch.slice(7);
2052
+ const action = page.actions?.find(a => a.id === actionId);
2053
+ if (action) onAction(action); else onNext();
2054
+ } else { onNext(); }
2055
+ };
2056
+ return h('div', {
2057
+ className: 'cf-sticky-bar' + (show ? '' : ' hidden'),
2058
+ style: bgStyles[config.style || 'solid'] || bgStyles.solid,
2059
+ },
2060
+ h('div', { className: 'max-w-2xl mx-auto px-6 py-4 flex items-center justify-between gap-4' },
2061
+ config.show_back && historyLen > 0
2062
+ ? h('button', { className: 'text-sm text-gray-500 hover:text-gray-700', onClick: onBack }, '\\u2190 Back') : null,
2063
+ h('div', { className: 'flex-1 text-center' },
2064
+ config.subtitle ? h('p', { className: 'text-xs opacity-60 mb-0.5' }, interpolate(config.subtitle)) : null
2065
+ ),
2066
+ h('div', { className: 'flex items-center gap-3' },
2067
+ config.cart_badge && cartItems.length > 0
2068
+ ? 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,
2069
+ h('button', {
2070
+ className: 'cf-btn-primary text-white text-sm',
2071
+ style: { backgroundColor: config.style === 'gradient' ? 'rgba(255,255,255,0.9)' : themeColor, color: config.style === 'gradient' ? themeColor : 'white' },
2072
+ disabled: config.disabled,
2073
+ onClick: handlePrimary,
2074
+ }, interpolate(config.primary_action?.label || page.submit_label || 'Continue'))
2075
+ )
2076
+ )
2077
+ );
2078
+ }
2079
+
1613
2080
  // --- Dev Config ---
1614
2081
  const devConfig = JSON.parse(document.getElementById('__dev_config').textContent);
1615
2082
 
@@ -1711,9 +2178,11 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1711
2178
 
1712
2179
  function CartButton({ itemCount, onClick }) {
1713
2180
  if (itemCount === 0) return null;
2181
+ const cartPos = (schema.settings?.cart?.position) || 'bottom-right';
2182
+ 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
2183
  return h('button', {
1715
2184
  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',
2185
+ 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
2186
  style: { backgroundColor: themeColor, fontFamily: 'var(--font-display)' },
1718
2187
  },
1719
2188
  h('svg', { className: 'w-5 h-5', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
@@ -1752,7 +2221,7 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1752
2221
  )
1753
2222
  ),
1754
2223
  h('div', null,
1755
- h('h2', { className: 'text-lg font-bold text-gray-900' }, 'Your Cart'),
2224
+ h('h2', { className: 'text-lg font-bold text-gray-900' }, schema.settings?.cart?.title || 'Your Cart'),
1756
2225
  h('p', { className: 'text-xs text-gray-400' }, items.length + ' ' + (items.length === 1 ? 'item' : 'items') + ' selected')
1757
2226
  )
1758
2227
  ),
@@ -1872,15 +2341,82 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1872
2341
  }
1873
2342
 
1874
2343
  // --- Main App ---
1875
- function CatalogPreview({ catalog }) {
2344
+ function CatalogPreview({ catalog: rawCatalog }) {
2345
+ // --- Variant resolution ---
2346
+ const catalog = React.useMemo(() => {
2347
+ const urlParams = Object.fromEntries(new URLSearchParams(window.location.search));
2348
+ const variantSlug = urlParams.variant;
2349
+ const catalogHints = rawCatalog.hints || {};
2350
+ let hints = { ...(catalogHints.defaults || {}) };
2351
+ if (variantSlug && catalogHints.variants) {
2352
+ const variant = catalogHints.variants.find(v => v.slug === variantSlug);
2353
+ if (variant?.hints) hints = { ...hints, ...variant.hints };
2354
+ }
2355
+ devContext.hints = hints;
2356
+ if (Object.keys(hints).length === 0) return rawCatalog;
2357
+ const resolvedPages = JSON.parse(JSON.stringify(rawCatalog.pages || {}));
2358
+ for (const page of Object.values(resolvedPages)) {
2359
+ for (const comp of page.components || []) {
2360
+ comp.props = resolveComponentVariants(comp.props || {}, hints);
2361
+ }
2362
+ if (page.actions) {
2363
+ for (const action of page.actions) Object.assign(action, resolveComponentVariants(action, hints));
2364
+ }
2365
+ }
2366
+ return { ...rawCatalog, pages: resolvedPages };
2367
+ }, [rawCatalog]);
2368
+
1876
2369
  const pages = catalog.pages || {};
1877
2370
  const pageKeys = Object.keys(pages);
1878
2371
  const routing = catalog.routing || {};
1879
- const [currentPageId, setCurrentPageId] = React.useState(routing.entry || pageKeys[0] || null);
1880
- const [formState, setFormState] = React.useState({});
2372
+ const entryPageId = routing.entry || pageKeys[0] || null;
2373
+ const saveKey = 'cf_resume_' + (catalog.slug || 'dev');
2374
+
2375
+ const [currentPageId, setCurrentPageId] = React.useState(entryPageId);
2376
+ // --- Prefill / default values ---
2377
+ const [formState, setFormState] = React.useState(() => {
2378
+ const state = {};
2379
+ for (const page of Object.values(pages)) {
2380
+ for (const comp of page.components || []) {
2381
+ if (comp.props?.default_value != null) state[comp.id] = comp.props.default_value;
2382
+ if ((comp.type === 'checkboxes' || comp.type === 'multiple_choice') && Array.isArray(comp.props?.options)) {
2383
+ for (const opt of comp.props.options) {
2384
+ if (!opt.inputs) continue;
2385
+ for (const input of opt.inputs) {
2386
+ const nd = input.props?.default_value ?? input.default_value;
2387
+ if (nd != null) state[comp.id + '.' + opt.value + '.' + input.id] = nd;
2388
+ }
2389
+ }
2390
+ }
2391
+ }
2392
+ }
2393
+ const mappings = catalog.settings?.url_params?.prefill_mappings;
2394
+ if (mappings) {
2395
+ const urlParams = Object.fromEntries(new URLSearchParams(window.location.search));
2396
+ for (const [param, compId] of Object.entries(mappings)) {
2397
+ if (urlParams[param]) state[compId] = urlParams[param];
2398
+ }
2399
+ }
2400
+ return state;
2401
+ });
1881
2402
  const [history, setHistory] = React.useState([]);
1882
2403
  const [cartItems, setCartItems] = React.useState([]);
1883
2404
  const [cartOpen, setCartOpen] = React.useState(false);
2405
+ const [showCheckout, setShowCheckout] = React.useState(false);
2406
+ const [validationErrors, setValidationErrors] = React.useState([]);
2407
+ const [savedSession, setSavedSession] = React.useState(null);
2408
+ const [showResumeModal, setShowResumeModal] = React.useState(false);
2409
+ const [submitted, setSubmitted] = React.useState(() => {
2410
+ const params = new URLSearchParams(window.location.search);
2411
+ return params.get('checkout') === 'success';
2412
+ });
2413
+ const formStateRef = React.useRef(formState);
2414
+ formStateRef.current = formState;
2415
+ const historyRef = React.useRef(history);
2416
+ historyRef.current = history;
2417
+ const autoAdvanceTimer = React.useRef(null);
2418
+ const globalsRef = React.useRef({});
2419
+ const [compPropOverrides, setCompPropOverrides] = React.useState({});
1884
2420
 
1885
2421
  // --- Cart logic ---
1886
2422
  const addToCart = React.useCallback((pageId) => {
@@ -1896,6 +2432,11 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1896
2432
  price_display: offer.price_display,
1897
2433
  price_subtext: offer.price_subtext,
1898
2434
  image: offer.image,
2435
+ stripe_price_id: offer.stripe_price_id,
2436
+ amount_cents: offer.amount_cents,
2437
+ currency: offer.currency,
2438
+ payment_type: offer.payment_type,
2439
+ interval: offer.interval,
1899
2440
  }];
1900
2441
  });
1901
2442
  }, [pages]);
@@ -1927,11 +2468,18 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1927
2468
  }
1928
2469
  }, [formState, pages, cartItems, addToCart, removeFromCart]);
1929
2470
 
1930
- // Expose navigation for mindmap + emit page_view
2471
+ // Expose navigation for mindmap + emit page_view + fire CatalogKit events
1931
2472
  React.useEffect(() => {
1932
2473
  window.__devNavigateTo = (id) => { setCurrentPageId(id); setHistory([]); window.scrollTo({ top: 0, behavior: 'smooth' }); };
1933
2474
  window.__devSetCurrentPage && window.__devSetCurrentPage(currentPageId);
1934
2475
  devEvents.emit('page_view', { page_id: currentPageId, catalog_slug: catalog.slug });
2476
+ setValidationErrors([]);
2477
+ // CatalogKit pageenter event
2478
+ const listeners = window.__catalogKitListeners || {};
2479
+ for (const key of ['pageenter', 'pageenter:' + currentPageId]) {
2480
+ const set = listeners[key]; if (!set?.size) continue;
2481
+ for (const cb of set) { try { cb({ pageId: currentPageId }); } catch (e) { console.error('[CatalogKit]', key, e); } }
2482
+ }
1935
2483
  }, [currentPageId]);
1936
2484
 
1937
2485
  // Expose debug state
@@ -1941,6 +2489,103 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1941
2489
  window.dispatchEvent(new CustomEvent('devStateUpdate'));
1942
2490
  }, [currentPageId, formState, cartItems, routing]);
1943
2491
 
2492
+ // --- Browser history (pushState / popstate) ---
2493
+ React.useEffect(() => {
2494
+ window.history.replaceState({ pageId: entryPageId, history: [] }, '');
2495
+ const onPopState = (e) => {
2496
+ const pageId = e.state?.pageId;
2497
+ const prevHistory = e.state?.history || [];
2498
+ if (pageId && pages[pageId]) {
2499
+ setCurrentPageId(pageId);
2500
+ setHistory(prevHistory);
2501
+ setValidationErrors([]);
2502
+ window.scrollTo({ top: 0, behavior: 'smooth' });
2503
+ }
2504
+ };
2505
+ window.addEventListener('popstate', onPopState);
2506
+ return () => window.removeEventListener('popstate', onPopState);
2507
+ }, []);
2508
+
2509
+ // --- localStorage persistence ---
2510
+ React.useEffect(() => {
2511
+ if (!submitted) {
2512
+ try { localStorage.setItem(saveKey, JSON.stringify({ formState, currentPageId, history })); } catch {}
2513
+ }
2514
+ }, [formState, currentPageId, history, submitted]);
2515
+
2516
+ // --- Check for saved session on mount ---
2517
+ React.useEffect(() => {
2518
+ try {
2519
+ const raw = localStorage.getItem(saveKey);
2520
+ if (raw) {
2521
+ const data = JSON.parse(raw);
2522
+ if (data.currentPageId && data.currentPageId !== entryPageId && pages[data.currentPageId]) {
2523
+ setSavedSession(data);
2524
+ setShowResumeModal(true);
2525
+ }
2526
+ }
2527
+ } catch {}
2528
+ }, []);
2529
+
2530
+ // --- Auto-skip pages ---
2531
+ React.useEffect(() => {
2532
+ const page = pages[currentPageId];
2533
+ if (!page?.auto_skip) return;
2534
+ 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']);
2535
+ const visibleInputs = (page.components || []).filter(c => {
2536
+ if (!inputTypes.has(c.type)) return false;
2537
+ if (c.hidden || c.props?.hidden) return false;
2538
+ if (c.visibility && !evaluateConditionGroup(c.visibility, formState, devContext)) return false;
2539
+ return true;
2540
+ });
2541
+ const allFilled = visibleInputs.every(c => {
2542
+ if (!c.props?.required) return true;
2543
+ const val = formState[c.id];
2544
+ return val != null && val !== '' && !(Array.isArray(val) && val.length === 0);
2545
+ });
2546
+ if (allFilled && visibleInputs.length > 0) {
2547
+ devEvents.emit('page_auto_skipped', { page_id: currentPageId });
2548
+ const nextId = getNextPage(routing, currentPageId, formState, devContext);
2549
+ if (nextId && pages[nextId]) {
2550
+ setCurrentPageId(nextId);
2551
+ window.history.replaceState({ pageId: nextId, history: historyRef.current }, '');
2552
+ }
2553
+ }
2554
+ }, [currentPageId]);
2555
+
2556
+ // --- CatalogKit API (window.CatalogKit) ---
2557
+ React.useEffect(() => {
2558
+ const listeners = {};
2559
+ const instance = {
2560
+ getField: (id) => formStateRef.current[id],
2561
+ getAllFields: () => ({ ...formStateRef.current }),
2562
+ getPageId: () => currentPageId,
2563
+ setField: (id, value) => onFieldChangeRef.current?.(id, value),
2564
+ goNext: () => handleNextRef.current?.(),
2565
+ goBack: () => handleBackRef.current?.(),
2566
+ on: (event, cb) => { if (!listeners[event]) listeners[event] = new Set(); listeners[event].add(cb); },
2567
+ off: (event, cb) => { listeners[event]?.delete(cb); },
2568
+ openCart: () => setCartOpen(true),
2569
+ closeCart: () => setCartOpen(false),
2570
+ getCartItems: () => [...cartItems],
2571
+ getGlobal: (key) => globalsRef.current[key],
2572
+ setGlobal: (key, value) => { globalsRef.current[key] = value; },
2573
+ setComponentProp: (id, prop, value) => {
2574
+ setCompPropOverrides(prev => ({ ...prev, [id]: { ...(prev[id] || {}), [prop]: value } }));
2575
+ },
2576
+ setValidationError: (id, msg) => {
2577
+ setValidationErrors(prev => {
2578
+ const next = prev.filter(e => e.componentId !== id);
2579
+ if (msg) next.push({ componentId: id, message: msg });
2580
+ return next;
2581
+ });
2582
+ },
2583
+ };
2584
+ 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, setGlobal: instance.setGlobal, getGlobal: instance.getGlobal, setComponentProp: instance.setComponentProp };
2585
+ window.__catalogKitListeners = listeners;
2586
+ return () => { delete window.CatalogKit; delete window.__catalogKitListeners; };
2587
+ }, []);
2588
+
1944
2589
  const page = currentPageId ? pages[currentPageId] : null;
1945
2590
  const isCover = page?.layout === 'cover';
1946
2591
  const isLastPage = (() => {
@@ -1948,28 +2593,129 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1948
2593
  return !routing.edges.some(e => e.from === currentPageId);
1949
2594
  })();
1950
2595
 
2596
+ const navigateTo = React.useCallback((nextId) => {
2597
+ // Fire CatalogKit pageexit
2598
+ const ckListeners = window.__catalogKitListeners || {};
2599
+ for (const key of ['pageexit', 'pageexit:' + currentPageId]) {
2600
+ const set = ckListeners[key]; if (!set?.size) continue;
2601
+ for (const cb of set) { try { cb({ pageId: currentPageId }); } catch (e) { console.error('[CatalogKit]', key, e); } }
2602
+ }
2603
+ if (nextId && pages[nextId]) {
2604
+ const newHistory = [...history, currentPageId];
2605
+ setHistory(newHistory);
2606
+ setCurrentPageId(nextId);
2607
+ window.history.pushState({ pageId: nextId, history: newHistory }, '');
2608
+ window.scrollTo({ top: 0, behavior: 'smooth' });
2609
+ } else {
2610
+ // End of funnel \u2014 show checkout or completion
2611
+ if (catalog.settings?.checkout) {
2612
+ setShowCheckout(true);
2613
+ devEvents.emit('checkout_start', { item_count: cartItems.length });
2614
+ } else {
2615
+ setSubmitted(true);
2616
+ try { localStorage.removeItem(saveKey); } catch {}
2617
+ devEvents.emit('form_submit', { page_id: currentPageId, form_state: formState });
2618
+ }
2619
+ window.scrollTo({ top: 0, behavior: 'smooth' });
2620
+ }
2621
+ }, [currentPageId, pages, catalog, cartItems, formState, history]);
2622
+
1951
2623
  const onFieldChange = React.useCallback((id, value) => {
1952
2624
  setFormState(prev => ({ ...prev, [id]: value }));
1953
2625
  devEvents.emit('field_change', { field_id: id, value, page_id: currentPageId });
1954
- }, [currentPageId]);
2626
+ // Fire CatalogKit fieldchange (unscoped + scoped)
2627
+ const ckListeners = window.__catalogKitListeners || {};
2628
+ const fcPayload = { fieldId: id, value, pageId: currentPageId };
2629
+ const fcSet = ckListeners['fieldchange']; if (fcSet?.size) for (const cb of fcSet) { try { cb(fcPayload); } catch {} }
2630
+ const scopedSet = ckListeners['fieldchange:' + id]; if (scopedSet?.size) for (const cb of scopedSet) { try { cb(fcPayload); } catch {} }
2631
+
2632
+ // Auto-advance: if page has auto_advance and this is a selection-type input
2633
+ const pg = pages[currentPageId];
2634
+ if (pg?.auto_advance && value != null && value !== '') {
2635
+ const selectionTypes = ['multiple_choice', 'picture_choice', 'dropdown', 'checkboxes', 'multiselect'];
2636
+ const comp = (pg.components || []).find(c => c.id === id);
2637
+ if (comp && selectionTypes.includes(comp.type)) {
2638
+ const newFormState = { ...formState, [id]: value };
2639
+ const inputTypes = [...selectionTypes, 'short_text', 'long_text', 'rich_text', 'email', 'phone', 'url',
2640
+ 'address', 'number', 'currency', 'date', 'datetime', 'time', 'date_range', 'switch', 'checkbox',
2641
+ 'choice_matrix', 'ranking', 'star_rating', 'slider', 'opinion_scale', 'file_upload', 'signature',
2642
+ 'password', 'location'];
2643
+ const visibleInputs = (pg.components || []).filter(c => {
2644
+ if (!inputTypes.includes(c.type)) return false;
2645
+ if (c.hidden || c.props?.hidden) return false;
2646
+ if (c.visibility && !evaluateConditionGroup(c.visibility, newFormState, devContext)) return false;
2647
+ return true;
2648
+ });
2649
+ const lastInput = visibleInputs[visibleInputs.length - 1];
2650
+ if (lastInput && lastInput.id === id) {
2651
+ if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current);
2652
+ autoAdvanceTimer.current = setTimeout(() => {
2653
+ const nextId = getNextPage(routing, currentPageId, newFormState, devContext);
2654
+ navigateTo(nextId);
2655
+ }, 400);
2656
+ }
2657
+ }
2658
+ }
2659
+ }, [currentPageId, pages, formState, routing, navigateTo]);
2660
+ const onFieldChangeRef = React.useRef(onFieldChange);
2661
+ onFieldChangeRef.current = onFieldChange;
2662
+
2663
+ // --- Validation ---
2664
+ const runValidation = React.useCallback(() => {
2665
+ const page = pages[currentPageId];
2666
+ if (!page) return true;
2667
+ const errors = validatePage(page, formState, devContext);
2668
+ setValidationErrors(errors);
2669
+ if (errors.length > 0) {
2670
+ const el = document.querySelector('[data-component-id="' + errors[0].componentId + '"]');
2671
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
2672
+ return false;
2673
+ }
2674
+ return true;
2675
+ }, [currentPageId, pages, formState]);
1955
2676
 
1956
2677
  const handleNext = React.useCallback(() => {
1957
- // Check if page has an offer \u2014 treat "Next" as an accept action
2678
+ // Validate before advancing
2679
+ if (!runValidation()) return;
2680
+ // Check video watch requirements
1958
2681
  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);
2682
+ if (currentPage) {
2683
+ for (const comp of currentPage.components || []) {
2684
+ if (comp.type === 'video' && comp.props?.require_watch_percent) {
2685
+ const vs = window.__videoWatchState?.[comp.id];
2686
+ if (!vs || vs.watch_percent < comp.props.require_watch_percent) {
2687
+ const el = document.querySelector('[data-component-id="' + comp.id + '"]');
2688
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
2689
+ alert('Please watch at least ' + comp.props.require_watch_percent + '% of the video before continuing.');
2690
+ return;
2691
+ }
2692
+ }
1964
2693
  }
1965
2694
  }
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' });
2695
+ // Check if page has an offer \u2014 treat "Next" as an accept action
2696
+ if (currentPage?.offer) {
2697
+ if (!currentPage.offer.accept_field) addToCart(currentPageId);
2698
+ }
2699
+ // Fire CatalogKit beforenext event (scoped: "beforenext" + "beforenext:<pageId>")
2700
+ let prevented = false;
2701
+ let nextPageOverride;
2702
+ const beforeNextPayload = {
2703
+ pageId: currentPageId,
2704
+ preventDefault: () => { prevented = true; },
2705
+ setNextPage: (id) => { nextPageOverride = id; },
2706
+ };
2707
+ const ckListeners = window.__catalogKitListeners || {};
2708
+ for (const key of ['beforenext', 'beforenext:' + currentPageId]) {
2709
+ const set = ckListeners[key]; if (!set?.size) continue;
2710
+ for (const cb of set) { try { cb(beforeNextPayload); } catch (e) { console.error('[CatalogKit]', key, e); } }
1971
2711
  }
1972
- }, [currentPageId, routing, formState, pages, addToCart]);
2712
+ if (prevented) return;
2713
+ if (nextPageOverride !== undefined) { navigateTo(nextPageOverride); return; }
2714
+ const nextId = getNextPage(routing, currentPageId, formState, devContext);
2715
+ navigateTo(nextId);
2716
+ }, [currentPageId, routing, formState, pages, addToCart, navigateTo, runValidation]);
2717
+ const handleNextRef = React.useRef(handleNext);
2718
+ handleNextRef.current = handleNext;
1973
2719
 
1974
2720
  const handleBack = React.useCallback(() => {
1975
2721
  if (history.length > 0) {
@@ -1979,6 +2725,174 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1979
2725
  window.scrollTo({ top: 0, behavior: 'smooth' });
1980
2726
  }
1981
2727
  }, [history]);
2728
+ const handleBackRef = React.useRef(handleBack);
2729
+ handleBackRef.current = handleBack;
2730
+
2731
+ // --- Page Actions ---
2732
+ const handleAction = React.useCallback((action) => {
2733
+ devEvents.emit('action_click', { page_id: currentPageId, action_id: action.id });
2734
+ if (action.redirect_url) { window.open(action.redirect_url, '_blank'); return; }
2735
+ if (!runValidation()) return;
2736
+ const currentPage = pages[currentPageId];
2737
+ const currentOffer = currentPage?.offer;
2738
+ if (currentOffer) {
2739
+ const acceptValue = currentOffer.accept_value || 'accept';
2740
+ if (action.id === acceptValue) addToCart(currentPageId);
2741
+ }
2742
+ const actionKey = '__action_' + currentPageId;
2743
+ const newFormState = { ...formState, [actionKey]: action.id };
2744
+ setFormState(newFormState);
2745
+ const nextPageId = getNextPage(routing, currentPageId, newFormState, devContext);
2746
+ navigateTo(nextPageId);
2747
+ }, [currentPageId, routing, formState, pages, addToCart, navigateTo, runValidation]);
2748
+
2749
+ // --- Resume prompt ---
2750
+ if (showResumeModal) {
2751
+ return h('div', { className: 'cf-resume-backdrop' },
2752
+ h('div', { className: 'bg-white rounded-2xl max-w-sm w-full mx-4 p-8 shadow-2xl text-center' },
2753
+ h('h2', { className: 'text-xl font-bold text-gray-900 mb-2', style: { fontFamily: 'var(--font-display)' } }, 'Welcome back!'),
2754
+ h('p', { className: 'text-gray-500 mb-6 text-sm' }, 'Pick up where you left off?'),
2755
+ h('div', { className: 'space-y-3' },
2756
+ h('button', {
2757
+ className: 'cf-btn-primary w-full text-white', style: { backgroundColor: themeColor },
2758
+ onClick: () => {
2759
+ if (savedSession) {
2760
+ setFormState(savedSession.formState || {});
2761
+ setCurrentPageId(savedSession.currentPageId);
2762
+ setHistory(savedSession.history || []);
2763
+ window.history.replaceState({ pageId: savedSession.currentPageId, history: savedSession.history || [] }, '');
2764
+ }
2765
+ setShowResumeModal(false);
2766
+ },
2767
+ }, 'Resume'),
2768
+ h('button', {
2769
+ className: 'w-full px-4 py-2 text-sm text-gray-500 hover:text-gray-700 transition-colors',
2770
+ onClick: () => { setShowResumeModal(false); try { localStorage.removeItem(saveKey); } catch {} },
2771
+ }, 'Start Over')
2772
+ )
2773
+ )
2774
+ );
2775
+ }
2776
+
2777
+ // --- Completion screen ---
2778
+ if (submitted) {
2779
+ const completionSettings = catalog.settings?.completion;
2780
+ return h('div', { className: 'min-h-screen flex items-center justify-center', style: { background: 'linear-gradient(180deg, #f8f9fc 0%, #f0f2f7 100%)' } },
2781
+ h('div', { className: 'max-w-lg mx-auto text-center px-6 py-20 page-enter-active' },
2782
+ h('div', { className: 'w-20 h-20 mx-auto mb-6 rounded-full flex items-center justify-center', style: { backgroundColor: themeColor + '15' } },
2783
+ h('svg', { className: 'w-10 h-10', style: { color: themeColor }, fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2784
+ 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' })
2785
+ )
2786
+ ),
2787
+ h('h1', { className: 'text-3xl font-bold text-gray-900 mb-3', style: { fontFamily: 'var(--font-display)' } },
2788
+ completionSettings?.title || 'Thank You!'
2789
+ ),
2790
+ h('p', { className: 'text-gray-500 text-lg mb-8' },
2791
+ completionSettings?.message || 'Your submission has been received.'
2792
+ ),
2793
+ completionSettings?.redirect_url ? h('a', {
2794
+ href: completionSettings.redirect_url,
2795
+ className: 'inline-flex items-center gap-2 px-6 py-3 rounded-xl text-white font-semibold transition-all hover:scale-[1.02]',
2796
+ style: { backgroundColor: themeColor },
2797
+ }, completionSettings.redirect_label || 'Continue') : null,
2798
+ h('div', { className: 'mt-6' },
2799
+ h('button', {
2800
+ className: 'text-sm text-gray-400 hover:text-gray-600 transition-colors',
2801
+ onClick: () => { setSubmitted(false); setCurrentPageId(routing.entry || pageKeys[0]); setHistory([]); setFormState({}); setCartItems([]); try { localStorage.removeItem(saveKey); } catch {} },
2802
+ }, 'Start Over')
2803
+ )
2804
+ )
2805
+ );
2806
+ }
2807
+
2808
+ // --- Checkout screen ---
2809
+ if (showCheckout) {
2810
+ const checkoutSettings = catalog.settings?.checkout || {};
2811
+ const handleCheckoutBack = () => { setShowCheckout(false); };
2812
+ const handleCheckoutContinue = () => {
2813
+ setShowCheckout(false);
2814
+ setSubmitted(true);
2815
+ devEvents.emit('checkout_skip', { page_id: currentPageId });
2816
+ };
2817
+
2818
+ return h('div', { className: 'min-h-screen', style: { background: 'linear-gradient(180deg, #f8f9fc 0%, #f0f2f7 100%)', fontFamily: 'var(--font-display)' } },
2819
+ // Header
2820
+ h('div', { className: 'fixed top-[28px] left-0 right-0 z-50 bg-white/80 backdrop-blur-xl border-b border-gray-200/60' },
2821
+ h('div', { className: 'max-w-5xl mx-auto flex items-center justify-between px-6 py-3' },
2822
+ h('button', { onClick: handleCheckoutBack, className: 'flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 transition-colors' },
2823
+ h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2824
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M15.75 19.5L8.25 12l7.5-7.5' })
2825
+ ),
2826
+ 'Back'
2827
+ ),
2828
+ h('div', { className: 'flex items-center gap-2' },
2829
+ h('svg', { className: 'w-4 h-4 text-green-500', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2830
+ 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' })
2831
+ ),
2832
+ h('span', { className: 'text-xs font-medium text-gray-400' }, 'Secure Checkout')
2833
+ )
2834
+ )
2835
+ ),
2836
+ h('div', { className: 'max-w-5xl mx-auto px-6 pt-24 pb-12' },
2837
+ h('div', { className: 'text-center mb-10' },
2838
+ h('h1', { className: 'text-3xl sm:text-4xl font-bold text-gray-900', style: { letterSpacing: '-0.025em' } },
2839
+ checkoutSettings.title || 'Complete Your Order'
2840
+ )
2841
+ ),
2842
+ h('div', { className: 'grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 items-start' },
2843
+ // Order summary
2844
+ h('div', { className: 'lg:col-span-7' },
2845
+ h('div', { className: 'bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden' },
2846
+ h('div', { className: 'px-6 py-4 border-b border-gray-50' },
2847
+ h('h2', { className: 'text-sm font-bold text-gray-900 uppercase tracking-wide' }, 'Order Summary')
2848
+ ),
2849
+ h('div', { className: 'divide-y divide-gray-50' },
2850
+ cartItems.length === 0
2851
+ ? h('div', { className: 'flex items-center gap-5 px-6 py-5' },
2852
+ h('div', { className: 'flex-1 min-w-0' },
2853
+ h('h3', { className: 'text-base font-semibold text-gray-900' }, 'Complete Registration'),
2854
+ h('p', { className: 'text-sm text-gray-400 mt-0.5' }, 'No offers selected \u2014 continue for free')
2855
+ ),
2856
+ h('p', { className: 'text-base font-bold', style: { color: themeColor } }, '$0')
2857
+ )
2858
+ : cartItems.map(item => h('div', { key: item.offer_id, className: 'flex items-center gap-5 px-6 py-5' },
2859
+ 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,
2860
+ h('div', { className: 'flex-1 min-w-0' },
2861
+ h('h3', { className: 'text-base font-semibold text-gray-900' }, item.title),
2862
+ item.price_subtext ? h('p', { className: 'text-sm text-gray-400 mt-0.5' }, item.price_subtext) : null
2863
+ ),
2864
+ item.price_display ? h('p', { className: 'text-base font-bold', style: { color: themeColor } }, item.price_display) : null,
2865
+ 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' },
2866
+ h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2867
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M6 18L18 6M6 6l12 12' })
2868
+ )
2869
+ )
2870
+ ))
2871
+ )
2872
+ )
2873
+ ),
2874
+ // Payment action
2875
+ h('div', { className: 'lg:col-span-5' },
2876
+ h('div', { className: 'lg:sticky lg:top-20 space-y-5' },
2877
+ h('div', { className: 'bg-white rounded-2xl border border-gray-100 shadow-sm p-7 space-y-6' },
2878
+ h('div', { className: 'text-sm text-gray-500' },
2879
+ cartItems.length + ' ' + (cartItems.length === 1 ? 'item' : 'items')
2880
+ ),
2881
+ h(CartCheckoutButton, { items: cartItems, themeColor }),
2882
+ h('button', {
2883
+ onClick: handleCheckoutContinue,
2884
+ className: 'w-full text-center text-sm text-gray-400 hover:text-gray-600 font-medium transition-colors py-1',
2885
+ }, 'Continue without paying'),
2886
+ h('div', { className: 'flex items-center justify-center gap-3 pt-1' },
2887
+ h('span', { className: 'text-[10px] text-gray-400 font-medium' }, 'Powered by Stripe')
2888
+ )
2889
+ )
2890
+ )
2891
+ )
2892
+ )
2893
+ )
2894
+ );
2895
+ }
1982
2896
 
1983
2897
  if (!page) {
1984
2898
  return h('div', { className: 'min-h-screen flex items-center justify-center bg-gray-50' },
@@ -1988,14 +2902,21 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1988
2902
 
1989
2903
  const components = (page.components || []).filter(c => {
1990
2904
  if (c.hidden || c.props?.hidden) return false;
1991
- if (c.visibility) return evaluateConditionGroup(c.visibility, formState, devContext);
2905
+ if (c.visibility) { if (!evaluateConditionGroup(c.visibility, formState, devContext)) return false; }
2906
+ if (c.prefill_mode === 'hidden' && formState[c.id] != null && formState[c.id] !== '') return false;
1992
2907
  return true;
1993
2908
  });
2909
+ // Find the last input component for Enter-to-submit
2910
+ const inputTypes = ['short_text', 'long_text', 'email', 'phone', 'url', 'number', 'password', 'currency', 'date', 'datetime', 'time', 'address'];
2911
+ const lastInputComp = [...components].reverse().find(c => inputTypes.includes(c.type));
2912
+ const lastInputId = lastInputComp?.id;
2913
+
1994
2914
  const bgImage = page.background_image || catalog.settings?.theme?.background_image;
1995
2915
 
1996
2916
  // Cart UI (shared between cover and standard)
2917
+ const cartSettings = catalog.settings?.cart || {};
1997
2918
  const cartUI = h(React.Fragment, null,
1998
- h(CartButton, { itemCount: cartItems.length, onClick: toggleCart }),
2919
+ !cartSettings.hide_button ? h(CartButton, { itemCount: cartItems.length, onClick: toggleCart }) : null,
1999
2920
  h(CartDrawer, { items: cartItems, isOpen: cartOpen, onToggle: toggleCart, onRemove: removeFromCart })
2000
2921
  );
2001
2922
 
@@ -2013,17 +2934,32 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2013
2934
  h('div', { className: 'cf-cover-overlay absolute inset-0' }),
2014
2935
  h('div', { className: 'cf-cover-content relative max-w-2xl mx-auto px-6 py-20 text-center text-white' },
2015
2936
  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
- )
2937
+ ...components.map((comp, i) => {
2938
+ if (comp.prefill_mode === 'readonly' && formState[comp.id] != null && formState[comp.id] !== '') {
2939
+ return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: 'space-y-1' },
2940
+ comp.props?.label ? h('label', { className: 'block text-base font-medium text-white/80' }, comp.props.label) : null,
2941
+ 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]))
2942
+ );
2943
+ }
2944
+ const fieldError = validationErrors.find(e => e.componentId === comp.id);
2945
+ const submitHandler = comp.id === lastInputId ? handleNext : undefined;
2946
+ return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: fieldError ? 'cf-field-error' : '' },
2947
+ h(RenderComponent, { comp, isCover: true, formState, onFieldChange, onSubmit: submitHandler, propOverrides: compPropOverrides }),
2948
+ fieldError ? h('p', { className: 'text-xs text-red-300 font-medium mt-1', role: 'alert' }, fieldError.message) : null
2949
+ );
2950
+ }),
2951
+ // Actions or CTA button
2952
+ page.actions?.length > 0
2953
+ ? h('div', { className: 'mt-8 space-y-3' },
2954
+ ...page.actions.map(action => h(ActionButton, { key: action.id, action, themeColor, onAction: handleAction }))
2955
+ )
2956
+ : h('div', { className: 'mt-8' },
2957
+ h('button', {
2958
+ className: 'cf-btn-primary w-full py-4 text-lg',
2959
+ style: { backgroundColor: themeColor },
2960
+ onClick: handleNext,
2961
+ }, page.submit_label || (isLastPage ? 'Submit' : 'Get Started'))
2962
+ )
2027
2963
  )
2028
2964
  )
2029
2965
  )
@@ -2061,26 +2997,50 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2061
2997
  h('div', { className: 'max-w-2xl mx-auto px-6 pb-8', style: { paddingTop: topBarEnabled ? '100px' : '60px' } },
2062
2998
  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
2999
  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,
3000
+ ...components.map((comp, i) => {
3001
+ if (comp.prefill_mode === 'readonly' && formState[comp.id] != null && formState[comp.id] !== '') {
3002
+ return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: 'space-y-1' },
3003
+ comp.props?.label ? h('label', { className: 'block text-base font-medium text-gray-700' }, comp.props.label) : null,
3004
+ 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]))
3005
+ );
3006
+ }
3007
+ const fieldError = validationErrors.find(e => e.componentId === comp.id);
3008
+ const submitHandler = comp.id === lastInputId ? handleNext : undefined;
3009
+ return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: fieldError ? 'cf-field-error' : '' },
3010
+ h(RenderComponent, { comp, isCover: false, formState, onFieldChange, onSubmit: submitHandler, propOverrides: compPropOverrides }),
3011
+ fieldError ? h('p', { className: 'text-xs text-red-500 font-medium mt-1', role: 'alert' }, fieldError.message) : null
3012
+ );
3013
+ }),
3014
+ // Actions or navigation button
3015
+ page.actions?.length > 0
3016
+ ? h('div', { className: 'mt-8 space-y-3' },
3017
+ ...page.actions.map(action => h(ActionButton, { key: action.id, action, themeColor, onAction: handleAction }))
3018
+ )
3019
+ : !page.hide_navigation ? h('div', { className: 'mt-8' },
3020
+ h('button', {
3021
+ className: 'cf-btn-primary inline-flex items-center gap-2 text-base',
3022
+ style: { backgroundColor: themeColor },
3023
+ onClick: handleNext,
3024
+ },
3025
+ page.submit_label || (isLastPage ? 'Submit' : 'Continue'),
3026
+ !isLastPage ? h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2.5 },
3027
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3' })
3028
+ ) : null
3029
+ ),
3030
+ page.submit_reassurance ? h('p', { className: 'text-xs text-gray-400 mt-1.5' }, page.submit_reassurance) : null,
3031
+ ) : null,
2081
3032
  ),
2082
3033
  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
- )
3034
+ ),
3035
+ // Sticky bottom bar
3036
+ (catalog.settings?.sticky_bar?.enabled || page.sticky_bar?.enabled)
3037
+ ? h(StickyBottomBar, {
3038
+ config: { ...(catalog.settings?.sticky_bar || {}), ...(page.sticky_bar || {}) },
3039
+ page, formState, cartItems, themeColor,
3040
+ onNext: handleNext, onAction: handleAction, onBack: handleBack,
3041
+ historyLen: history.length,
3042
+ })
3043
+ : null
2084
3044
  )
2085
3045
  );
2086
3046
  }
@@ -2129,17 +3089,113 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2129
3089
  const overlay = document.getElementById('pages-overlay');
2130
3090
  const closeBtn = document.getElementById('pages-close');
2131
3091
  const container = document.getElementById('mindmap-container');
3092
+ const zoomInBtn = document.getElementById('zoom-in');
3093
+ const zoomOutBtn = document.getElementById('zoom-out');
3094
+ const zoomFitBtn = document.getElementById('zoom-fit');
3095
+ const zoomLevelEl = document.getElementById('zoom-level');
2132
3096
  let currentPageRef = { id: schema.routing?.entry || Object.keys(schema.pages || {})[0] };
2133
3097
 
3098
+ // Pan & zoom state
3099
+ let scale = 1, panX = 0, panY = 0;
3100
+ let isDragging = false, dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0;
3101
+ let hasDragged = false;
3102
+ const MIN_SCALE = 0.15, MAX_SCALE = 3;
3103
+
3104
+ function applyTransform() {
3105
+ container.style.transform = 'translate(' + panX + 'px, ' + panY + 'px) scale(' + scale + ')';
3106
+ zoomLevelEl.textContent = Math.round(scale * 100) + '%';
3107
+ }
3108
+
3109
+ function fitToView() {
3110
+ const overlayRect = overlay.getBoundingClientRect();
3111
+ // Temporarily reset transform to measure natural size
3112
+ container.style.transform = 'none';
3113
+ requestAnimationFrame(() => {
3114
+ const contentRect = container.getBoundingClientRect();
3115
+ const padW = 80, padH = 80;
3116
+ const scaleX = (overlayRect.width - padW) / contentRect.width;
3117
+ const scaleY = (overlayRect.height - padH) / contentRect.height;
3118
+ scale = Math.min(scaleX, scaleY, 1.5);
3119
+ scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale));
3120
+ panX = (overlayRect.width - contentRect.width * scale) / 2;
3121
+ panY = (overlayRect.height - contentRect.height * scale) / 2;
3122
+ applyTransform();
3123
+ });
3124
+ }
3125
+
2134
3126
  // Expose setter for CatalogPreview to update current page
2135
3127
  window.__devSetCurrentPage = (id) => { currentPageRef.id = id; };
2136
3128
 
2137
3129
  btn.addEventListener('click', () => {
2138
3130
  overlay.classList.add('open');
2139
3131
  renderMindmap();
3132
+ requestAnimationFrame(() => fitToView());
2140
3133
  });
2141
3134
  closeBtn.addEventListener('click', () => overlay.classList.remove('open'));
2142
- overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
3135
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') overlay.classList.remove('open'); });
3136
+
3137
+ // Wheel zoom
3138
+ overlay.addEventListener('wheel', (e) => {
3139
+ e.preventDefault();
3140
+ const rect = overlay.getBoundingClientRect();
3141
+ const mouseX = e.clientX - rect.left;
3142
+ const mouseY = e.clientY - rect.top;
3143
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
3144
+ const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale * delta));
3145
+ // Zoom toward cursor
3146
+ panX = mouseX - (mouseX - panX) * (newScale / scale);
3147
+ panY = mouseY - (mouseY - panY) * (newScale / scale);
3148
+ scale = newScale;
3149
+ applyTransform();
3150
+ }, { passive: false });
3151
+
3152
+ // Pan via drag
3153
+ overlay.addEventListener('mousedown', (e) => {
3154
+ if (e.target.closest('.close-btn, .zoom-controls')) return;
3155
+ isDragging = true;
3156
+ hasDragged = false;
3157
+ dragStartX = e.clientX;
3158
+ dragStartY = e.clientY;
3159
+ dragStartPanX = panX;
3160
+ dragStartPanY = panY;
3161
+ overlay.classList.add('grabbing');
3162
+ });
3163
+ window.addEventListener('mousemove', (e) => {
3164
+ if (!isDragging) return;
3165
+ const dx = e.clientX - dragStartX;
3166
+ const dy = e.clientY - dragStartY;
3167
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) hasDragged = true;
3168
+ panX = dragStartPanX + dx;
3169
+ panY = dragStartPanY + dy;
3170
+ applyTransform();
3171
+ });
3172
+ window.addEventListener('mouseup', () => {
3173
+ isDragging = false;
3174
+ overlay.classList.remove('grabbing');
3175
+ });
3176
+
3177
+ // Close only via close button (not background click \u2014 allows free panning)
3178
+
3179
+ // Zoom buttons
3180
+ zoomInBtn.addEventListener('click', () => {
3181
+ const rect = overlay.getBoundingClientRect();
3182
+ const cx = rect.width / 2, cy = rect.height / 2;
3183
+ const newScale = Math.min(MAX_SCALE, scale * 1.25);
3184
+ panX = cx - (cx - panX) * (newScale / scale);
3185
+ panY = cy - (cy - panY) * (newScale / scale);
3186
+ scale = newScale;
3187
+ applyTransform();
3188
+ });
3189
+ zoomOutBtn.addEventListener('click', () => {
3190
+ const rect = overlay.getBoundingClientRect();
3191
+ const cx = rect.width / 2, cy = rect.height / 2;
3192
+ const newScale = Math.max(MIN_SCALE, scale * 0.8);
3193
+ panX = cx - (cx - panX) * (newScale / scale);
3194
+ panY = cy - (cy - panY) * (newScale / scale);
3195
+ scale = newScale;
3196
+ applyTransform();
3197
+ });
3198
+ zoomFitBtn.addEventListener('click', () => fitToView());
2143
3199
 
2144
3200
  function renderMindmap() {
2145
3201
  const pages = schema.pages || {};
@@ -2248,9 +3304,11 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2248
3304
  container.insertBefore(svgEl, container.firstChild);
2249
3305
  });
2250
3306
 
2251
- // Click nodes to navigate
3307
+ // Click nodes to navigate (ignore if user just dragged)
2252
3308
  container.querySelectorAll('[data-node-id]').forEach(el => {
2253
- el.addEventListener('click', () => {
3309
+ el.addEventListener('click', (e) => {
3310
+ if (hasDragged) return;
3311
+ e.stopPropagation();
2254
3312
  const id = el.dataset.nodeId;
2255
3313
  window.__devNavigateTo && window.__devNavigateTo(id);
2256
3314
  overlay.classList.remove('open');
@@ -2341,19 +3399,78 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2341
3399
  }, true);
2342
3400
  })();
2343
3401
 
2344
- // --- Validation banner ---
2345
- (function initValidationBanner() {
2346
- const banner = document.getElementById('validation-banner');
3402
+ // --- Validation in topbar ---
3403
+ (function initValidationTag() {
3404
+ const tag = document.getElementById('validation-tag');
2347
3405
  const validation = JSON.parse(document.getElementById('__validation_data').textContent);
2348
3406
  const errors = validation.errors || [];
2349
3407
  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'; });
3408
+ const total = errors.length + warnings.length;
3409
+ const cleanClass = total === 0 ? ' clean' : '';
3410
+ const label = total === 0 ? '\u2713 Valid' : errors.length + ' error' + (errors.length !== 1 ? 's' : '') + ', ' + warnings.length + ' warn';
3411
+ let html = '<button class="vt-btn' + cleanClass + '" id="vt-toggle">' + label + '</button>';
3412
+ if (total > 0) {
3413
+ html += '<div class="vt-dropdown" id="vt-dropdown">';
3414
+ html += '<div class="vt-header"><span>' + errors.length + ' error(s), ' + warnings.length + ' warning(s)</span></div>';
3415
+ html += '<div class="vt-body">';
3416
+ for (const e of errors) html += '<div class="vt-error">ERROR: ' + e + '</div>';
3417
+ for (const w of warnings) html += '<div class="vt-warn">WARN: ' + w + '</div>';
3418
+ html += '</div></div>';
3419
+ }
3420
+ tag.innerHTML = html;
3421
+ if (total > 0) {
3422
+ const toggleBtn = document.getElementById('vt-toggle');
3423
+ const dropdown = document.getElementById('vt-dropdown');
3424
+ toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); dropdown.classList.toggle('open'); });
3425
+ document.addEventListener('click', (e) => { if (!tag.contains(e.target)) dropdown.classList.remove('open'); });
3426
+ }
3427
+ })();
3428
+
3429
+ // --- Banner minimize / restore ---
3430
+ (function initBannerMinimize() {
3431
+ const banner = document.getElementById('dev-banner');
3432
+ const minimizeBtn = document.getElementById('banner-minimize');
3433
+ const restoreBtn = document.getElementById('banner-restore');
3434
+ minimizeBtn.addEventListener('click', () => {
3435
+ banner.classList.add('minimized');
3436
+ restoreBtn.classList.add('visible');
3437
+ });
3438
+ restoreBtn.addEventListener('click', () => {
3439
+ banner.classList.remove('minimized');
3440
+ restoreBtn.classList.remove('visible');
3441
+ });
3442
+ })();
3443
+
3444
+ // --- Banner drag to reposition ---
3445
+ (function initBannerDrag() {
3446
+ const banner = document.getElementById('dev-banner');
3447
+ const handle = document.getElementById('banner-drag');
3448
+ let isDragging = false, startX = 0, startY = 0, origLeft = 0, origTop = 0;
3449
+
3450
+ handle.addEventListener('mousedown', (e) => {
3451
+ e.preventDefault();
3452
+ isDragging = true;
3453
+ const rect = banner.getBoundingClientRect();
3454
+ startX = e.clientX; startY = e.clientY;
3455
+ origLeft = rect.left; origTop = rect.top;
3456
+ // Switch to positioned mode
3457
+ banner.style.left = rect.left + 'px';
3458
+ banner.style.top = rect.top + 'px';
3459
+ banner.style.right = 'auto';
3460
+ banner.style.width = rect.width + 'px';
3461
+ handle.style.cursor = 'grabbing';
3462
+ });
3463
+ window.addEventListener('mousemove', (e) => {
3464
+ if (!isDragging) return;
3465
+ const dx = e.clientX - startX, dy = e.clientY - startY;
3466
+ banner.style.left = (origLeft + dx) + 'px';
3467
+ banner.style.top = (origTop + dy) + 'px';
3468
+ });
3469
+ window.addEventListener('mouseup', () => {
3470
+ if (!isDragging) return;
3471
+ isDragging = false;
3472
+ handle.style.cursor = '';
3473
+ });
2357
3474
  })();
2358
3475
 
2359
3476
  // --- Debug panel ---