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