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