@officexapp/catalogs-cli 0.4.8 → 0.4.10

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 CHANGED
@@ -826,13 +826,6 @@ import { resolve as resolve4, dirname as dirname2, extname as extname3, join as
826
826
  import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync3, watch } from "fs";
827
827
  import { createServer } from "http";
828
828
  import ora5 from "ora";
829
-
830
- // src/lib/dev-engine.ts
831
- function buildEngineScript() {
832
- 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");
833
- }
834
-
835
- // src/commands/catalog-dev.ts
836
829
  var DEFAULT_PORT = 3456;
837
830
  var MIME_TYPES = {
838
831
  ".html": "text/html",
@@ -866,7 +859,22 @@ function getMime(filepath) {
866
859
  function buildPreviewHtml(schema, port, validation, devConfig) {
867
860
  const schemaJson = JSON.stringify(schema).replace(/<\/script/gi, "<\\/script");
868
861
  const themeColor = schema.settings?.theme?.primary_color || "#6366f1";
869
- const engineScript = buildEngineScript();
862
+ const stripeEnabled = devConfig?.stripeEnabled ?? false;
863
+ let validationHtml = "";
864
+ if (validation && (validation.errors.length > 0 || validation.warnings.length > 0)) {
865
+ const items = [
866
+ ...validation.errors.map((e) => `<div style="color:#ef4444;margin:4px 0">&#10007; ${e}</div>`),
867
+ ...validation.warnings.map((w) => `<div style="color:#f59e0b;margin:4px 0">&#9888; ${w}</div>`)
868
+ ].join("");
869
+ validationHtml = `
870
+ <div id="__dev_validation" style="position:fixed;bottom:16px;left:16px;right:16px;z-index:9999;max-width:600px;margin:0 auto;background:#1e1b4b;color:#e0e7ff;border-radius:16px;padding:16px 20px;font-family:system-ui,sans-serif;font-size:13px;box-shadow:0 8px 32px rgba(0,0,0,0.3);max-height:40vh;overflow-y:auto">
871
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
872
+ <strong style="font-size:14px">Schema Validation</strong>
873
+ <button onclick="this.parentElement.parentElement.remove()" style="background:none;border:none;color:#818cf8;cursor:pointer;font-size:18px">&times;</button>
874
+ </div>
875
+ ${items}
876
+ </div>`;
877
+ }
870
878
  return `<!DOCTYPE html>
871
879
  <html lang="en">
872
880
  <head>
@@ -876,2822 +884,129 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
876
884
  <link rel="preconnect" href="https://fonts.googleapis.com" />
877
885
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
878
886
  <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800;900&family=DM+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet" />
887
+ <link rel="stylesheet" href="/__renderer/index.css" />
879
888
  <script src="https://cdn.tailwindcss.com"></script>
880
- <style>
881
- /* \u2500\u2500 Production CSS \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
882
- :root {
883
- --font-display: 'Outfit', system-ui, sans-serif;
884
- --font-body: 'DM Sans', system-ui, sans-serif;
885
- --font-size-body: 1.125rem;
886
- --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
887
- --ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);
888
- --theme-color: ${themeColor};
889
- --theme-color-ring: ${themeColor}26;
890
- }
891
- *, *::before, *::after { box-sizing: border-box; }
892
- body {
893
- margin: 0;
894
- font-family: var(--font-body);
895
- -webkit-font-smoothing: antialiased;
896
- -moz-osx-font-smoothing: grayscale;
897
- color: #1a1a2e;
898
- }
899
- h1, h2, h3, h4, h5, h6 { font-family: var(--font-display); }
900
-
901
- /* Page transitions */
902
- .page-enter-active { animation: pageReveal 0.35s var(--ease-out-expo) both; }
903
- @keyframes pageReveal { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
904
- .page-enter-active > * { animation: staggerIn 0.3s var(--ease-out-expo) both; }
905
- .page-enter-active > *:nth-child(1) { animation-delay: 0.02s; }
906
- .page-enter-active > *:nth-child(2) { animation-delay: 0.05s; }
907
- .page-enter-active > *:nth-child(3) { animation-delay: 0.08s; }
908
- .page-enter-active > *:nth-child(4) { animation-delay: 0.11s; }
909
- .page-enter-active > *:nth-child(5) { animation-delay: 0.14s; }
910
- .page-enter-active > *:nth-child(6) { animation-delay: 0.17s; }
911
- .page-enter-active > *:nth-child(7) { animation-delay: 0.2s; }
912
- .page-enter-active > *:nth-child(8) { animation-delay: 0.23s; }
913
- @keyframes staggerIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
914
-
915
- /* Cover page */
916
- .cf-cover-overlay { background: linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.4) 50%, rgba(0,0,0,0.6) 100%); }
917
- .cf-cover-content { animation: coverFloat 0.8s var(--ease-out-expo) both; }
918
- @keyframes coverFloat { from { opacity: 0; transform: translateY(30px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
919
-
920
- /* Top bar */
921
- .cf-topbar { background: rgba(255,255,255,0.92); backdrop-filter: blur(16px) saturate(180%); -webkit-backdrop-filter: blur(16px) saturate(180%); }
922
-
923
- /* Card */
924
- .cf-card { background: white; border-radius: 20px; box-shadow: 0 0 0 1px rgba(0,0,0,0.03), 0 2px 4px rgba(0,0,0,0.02), 0 12px 40px rgba(0,0,0,0.06); }
925
-
926
- /* Inputs */
927
- .cf-input {
928
- font-family: var(--font-body); border-radius: 12px; border: 1.5px solid #e2e4e9;
929
- background: #fafbfc; padding: 12px 16px; font-size: var(--font-size-body);
930
- color: #1a1a2e; outline: none; transition: all 0.2s var(--ease-out-expo); width: 100%;
931
- }
932
- .cf-input::placeholder { color: #a0a3b1; }
933
- .cf-input:hover { border-color: #c8cbd4; background: #fff; }
934
- .cf-input:focus { background: #fff; border-color: var(--theme-color); box-shadow: 0 0 0 3px var(--theme-color-ring); }
935
-
936
- /* Buttons */
937
- .cf-btn-primary {
938
- font-family: var(--font-display); font-weight: 600; letter-spacing: -0.01em;
939
- border-radius: 14px; padding: 14px 28px; font-size: 16px; color: white; border: none;
940
- cursor: pointer; transition: all 0.25s var(--ease-out-expo);
941
- box-shadow: 0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08);
942
- position: relative; overflow: hidden;
889
+ <script type="importmap">
890
+ {
891
+ "imports": {
892
+ "react": "https://esm.sh/react@18.3.1",
893
+ "react-dom": "https://esm.sh/react-dom@18.3.1",
894
+ "react-dom/client": "https://esm.sh/react-dom@18.3.1/client",
895
+ "react/jsx-runtime": "https://esm.sh/react@18.3.1/jsx-runtime",
896
+ "hls.js": "https://esm.sh/hls.js@1.5.17"
943
897
  }
944
- .cf-btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(0,0,0,0.16), 0 2px 4px rgba(0,0,0,0.1); }
945
- .cf-btn-primary:active { transform: translateY(0) scale(0.98); }
946
-
947
- /* Choice buttons */
948
- .cf-choice {
949
- border-radius: 14px; border: 1.5px solid #e2e4e9; background: #fafbfc;
950
- transition: all 0.2s var(--ease-out-expo); cursor: pointer; width: 100%;
951
- text-align: left; padding: 14px 18px; font-size: 1rem; color: #1a1a2e;
952
- font-family: var(--font-body);
953
- }
954
- .cf-choice:hover { border-color: #c8cbd4; background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
955
- .cf-choice[data-selected="true"] { border-color: var(--theme-color); background: #fff; box-shadow: 0 0 0 3px var(--theme-color-ring), 0 2px 12px rgba(0,0,0,0.06); }
956
-
957
- /* Banner glass */
958
- .cf-banner-glass { background: rgba(255,255,255,0.12); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.18); }
959
-
960
- /* Images */
961
- .ck-img { width: 100%; height: auto; object-fit: cover; display: block; }
962
-
963
- /* Text balance */
964
- .cf-text-balance { text-wrap: balance; }
965
-
966
- /* Progress bar */
967
- .progress-bar-fill { transition: width 0.6s var(--ease-out-expo); }
968
-
969
- /* Noise texture */
970
- .cf-noise::before {
971
- content: ''; position: absolute; inset: 0; opacity: 0.03; pointer-events: none;
972
- background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
973
- background-size: 256px 256px;
974
- }
975
-
898
+ }
899
+ </script>
900
+ <style>
976
901
  /* Dev banner */
977
- .dev-banner {
978
- position: fixed; top: 0; left: 0; right: 0; z-index: 99999;
979
- background: #1a1a2e; color: #e0e0ff; font-size: 12px;
980
- padding: 4px 12px; display: flex; align-items: center; gap: 8px;
981
- font-family: monospace; border-bottom: 2px solid #6c63ff;
982
- transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s ease;
983
- }
984
- .dev-banner.minimized {
985
- transform: translateY(-100%); opacity: 0; pointer-events: none;
986
- }
987
- .dev-banner .drag-handle {
988
- cursor: grab; opacity: 0.4; font-size: 10px; user-select: none; letter-spacing: 1px;
989
- padding: 0 2px; transition: opacity 0.15s;
990
- }
991
- .dev-banner .drag-handle:hover { opacity: 0.8; }
992
- .dev-banner .drag-handle:active { cursor: grabbing; }
993
- .dev-banner .dot { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; animation: pulse 2s ease-in-out infinite; }
994
- @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
995
- .dev-banner .label { opacity: 0.7; }
996
- .dev-banner .slug { font-weight: bold; color: #a5b4fc; }
997
- .dev-banner .stub-tags { margin-left: auto; display: flex; gap: 6px; align-items: center; }
998
- .dev-banner .stub-tag { background: rgba(255,255,255,0.1); border-radius: 3px; padding: 1px 6px; font-size: 11px; color: #fbbf24; }
999
- .dev-banner .minimize-btn {
1000
- background: rgba(255,255,255,0.08); border: none; color: #a5b4fc; cursor: pointer;
1001
- font-size: 14px; width: 18px; height: 18px; border-radius: 4px;
1002
- display: flex; align-items: center; justify-content: center; padding: 0;
1003
- transition: background 0.15s;
1004
- }
1005
- .dev-banner .minimize-btn:hover { background: rgba(255,255,255,0.2); }
1006
- /* Minimized floating pill to restore banner */
1007
- .dev-banner-restore {
1008
- position: fixed; top: 8px; right: 8px; z-index: 99999;
1009
- background: #1a1a2e; color: #a5b4fc; border: 1px solid #6c63ff;
1010
- border-radius: 8px; padding: 4px 10px; font-size: 11px; font-family: monospace;
1011
- cursor: pointer; display: none; align-items: center; gap: 6px;
1012
- box-shadow: 0 2px 12px rgba(0,0,0,0.3); transition: all 0.15s ease;
1013
- }
1014
- .dev-banner-restore:hover { background: #2a2a4e; }
1015
- .dev-banner-restore .restore-dot { width: 6px; height: 6px; border-radius: 50%; background: #4ade80; animation: pulse 2s ease-in-out infinite; }
1016
- .dev-banner-restore.visible { display: flex; }
1017
- /* Validation tag in topbar */
1018
- .dev-banner .validation-tag { position: relative; }
1019
- .dev-banner .validation-tag .vt-btn {
1020
- background: rgba(239,68,68,0.2); border: none; color: #fca5a5; border-radius: 3px;
1021
- padding: 1px 6px; font-size: 11px; cursor: pointer; font-family: monospace;
1022
- transition: background 0.15s;
1023
- }
1024
- .dev-banner .validation-tag .vt-btn:hover { background: rgba(239,68,68,0.35); }
1025
- .dev-banner .validation-tag .vt-btn.clean {
1026
- background: rgba(74,222,128,0.15); color: #86efac;
1027
- }
1028
- .dev-banner .validation-tag .vt-btn.clean:hover { background: rgba(74,222,128,0.25); }
1029
- .dev-banner .vt-dropdown {
1030
- position: absolute; top: calc(100% + 8px); right: 0; min-width: 400px;
1031
- background: #1e1b2e; border: 1px solid #3b3660; border-radius: 10px;
1032
- box-shadow: 0 8px 32px rgba(0,0,0,0.4); display: none; overflow: hidden;
1033
- font-family: monospace; font-size: 12px;
1034
- }
1035
- .dev-banner .vt-dropdown.open { display: block; }
1036
- .dev-banner .vt-dropdown .vt-header {
1037
- padding: 8px 12px; background: #2a2550; border-bottom: 1px solid #3b3660;
1038
- font-weight: 600; color: #c4b5fd; display: flex; align-items: center; justify-content: space-between;
1039
- }
1040
- .dev-banner .vt-dropdown .vt-body { padding: 8px 12px; max-height: 240px; overflow-y: auto; }
1041
- .dev-banner .vt-dropdown .vt-error { color: #fca5a5; padding: 3px 0; }
1042
- .dev-banner .vt-dropdown .vt-warn { color: #fde68a; padding: 3px 0; }
1043
-
1044
- /* Pages mindmap overlay */
1045
- .pages-overlay {
1046
- position: fixed; inset: 0; z-index: 99990; background: rgba(10,10,20,0.92);
1047
- backdrop-filter: blur(8px); display: none;
1048
- font-family: var(--font-display); overflow: hidden; cursor: grab;
1049
- }
1050
- .pages-overlay.open { display: block; }
1051
- .pages-overlay.grabbing { cursor: grabbing; }
1052
- .pages-overlay .close-btn {
1053
- position: absolute; top: 16px; right: 16px; background: rgba(255,255,255,0.1);
1054
- border: none; color: white; width: 32px; height: 32px; border-radius: 8px;
1055
- cursor: pointer; font-size: 18px; display: flex; align-items: center; justify-content: center;
1056
- z-index: 10;
1057
- }
1058
- .pages-overlay .close-btn:hover { background: rgba(255,255,255,0.2); }
1059
- .pages-overlay .zoom-controls {
1060
- position: absolute; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 4px; z-index: 10;
1061
- }
1062
- .pages-overlay .zoom-btn {
1063
- width: 32px; height: 32px; border-radius: 8px; border: none;
1064
- background: rgba(255,255,255,0.1); color: white; font-size: 16px; cursor: pointer;
1065
- display: flex; align-items: center; justify-content: center; font-family: monospace;
1066
- }
1067
- .pages-overlay .zoom-btn:hover { background: rgba(255,255,255,0.2); }
1068
- .pages-overlay .zoom-level {
1069
- text-align: center; color: rgba(255,255,255,0.4); font-size: 10px; font-family: monospace;
1070
- }
1071
- .mindmap-container { position: absolute; top: 0; left: 0; transform-origin: 0 0; padding: 40px; }
1072
- .mindmap-container svg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
1073
- .mindmap-nodes { position: relative; display: flex; flex-wrap: wrap; gap: 24px; justify-content: center; align-items: flex-start; max-width: 900px; }
1074
- .mindmap-node {
1075
- background: rgba(255,255,255,0.08); border: 1.5px solid rgba(255,255,255,0.15);
1076
- border-radius: 14px; padding: 14px 20px; min-width: 140px; text-align: center;
1077
- cursor: pointer; transition: all 0.2s ease; position: relative;
1078
- }
1079
- .mindmap-node:hover { background: rgba(255,255,255,0.14); border-color: rgba(255,255,255,0.3); transform: translateY(-2px); }
1080
- .mindmap-node.entry { border-color: #4ade80; box-shadow: 0 0 0 3px rgba(74,222,128,0.15); }
1081
- .mindmap-node.current { border-color: var(--theme-color); box-shadow: 0 0 0 3px rgba(99,102,241,0.25); }
1082
- .mindmap-node .node-title { color: white; font-size: 13px; font-weight: 600; margin-bottom: 2px; }
1083
- .mindmap-node .node-id { color: rgba(255,255,255,0.35); font-size: 10px; font-family: monospace; }
1084
- .mindmap-node .node-badge {
1085
- position: absolute; top: -8px; right: -8px; background: #4ade80; color: #0a2010;
1086
- font-size: 9px; font-weight: 700; padding: 1px 6px; border-radius: 6px; text-transform: uppercase;
1087
- }
1088
- .mindmap-node .node-components { color: rgba(255,255,255,0.3); font-size: 10px; margin-top: 4px; }
1089
- .mindmap-edge-label {
1090
- position: absolute; background: rgba(251,191,36,0.15); color: #fbbf24;
1091
- font-size: 9px; padding: 1px 6px; border-radius: 4px; white-space: nowrap; pointer-events: none;
1092
- }
1093
- .dev-banner .stub-tag.clickable { cursor: pointer; transition: all 0.15s ease; }
1094
- .dev-banner .stub-tag.clickable:hover { background: rgba(255,255,255,0.2); color: #a5b4fc; }
1095
-
1096
- /* Element inspector (local dev) */
1097
- .inspector-highlight {
1098
- position: fixed; z-index: 99997; pointer-events: none; border: 2px solid #6366f1;
1099
- background: rgba(99,102,241,0.06); border-radius: 8px; transition: all 80ms ease-out;
1100
- }
1101
- .inspector-tooltip {
1102
- position: fixed; z-index: 99998; pointer-events: none; background: #1e1b4b;
1103
- color: #e0e7ff; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px;
1104
- padding: 6px 10px; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.4);
1105
- max-width: 360px;
1106
- }
1107
- .inspector-tooltip .ref { font-weight: 700; color: #a5b4fc; }
1108
- .inspector-tooltip .type { color: rgba(165,180,252,0.5); margin-left: 6px; }
1109
- .inspector-tooltip .hint { color: rgba(165,180,252,0.3); font-size: 9px; margin-top: 3px; }
1110
- .inspector-active-banner {
1111
- position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 99998;
1112
- background: rgba(99,102,241,0.92); color: white; font-size: 11px; font-weight: 600;
1113
- padding: 6px 14px; border-radius: 10px; font-family: system-ui; pointer-events: none;
1114
- box-shadow: 0 4px 16px rgba(0,0,0,0.3);
1115
- }
1116
-
1117
- /* Checkout stub */
1118
- .checkout-stub {
1119
- background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
1120
- border: 2px dashed #f59e0b; border-radius: 16px;
1121
- padding: 24px; text-align: center;
1122
- }
1123
- .checkout-stub h3 { margin: 0 0 8px; color: #92400e; font-family: var(--font-display); font-weight: 700; }
1124
- .checkout-stub p { margin: 0; color: #a16207; font-size: 14px; }
1125
-
1126
- /* Pricing card */
1127
- .pricing-card {
1128
- border-radius: 20px; border: 1.5px solid #e2e4e9; background: white;
1129
- padding: 32px; text-align: center;
1130
- box-shadow: 0 2px 12px rgba(0,0,0,0.04);
1131
- }
1132
-
1133
- /* (validation banner moved into topbar dropdown) */
1134
-
1135
- /* Debug panel */
1136
- .debug-panel {
1137
- position: fixed; bottom: 0; right: 0; z-index: 99998;
1138
- width: 380px; max-height: 60vh; background: #1e1b4b; color: #e0e7ff;
1139
- font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px;
1140
- border-top-left-radius: 12px; overflow: hidden;
1141
- box-shadow: -4px -4px 24px rgba(0,0,0,0.3); display: none;
1142
- }
1143
- .debug-panel.open { display: flex; flex-direction: column; }
1144
- .debug-panel .dp-header {
902
+ #__dev_banner {
903
+ position: fixed; top: 0; left: 0; right: 0; z-index: 10000;
904
+ background: linear-gradient(135deg, #1e1b4b 0%, #312e81 100%);
905
+ color: #c7d2fe; font-family: system-ui, sans-serif;
906
+ font-size: 12px; padding: 6px 16px;
1145
907
  display: flex; align-items: center; justify-content: space-between;
1146
- padding: 8px 12px; background: #312e81; border-bottom: 1px solid #4338ca;
1147
- font-weight: 600; font-size: 12px;
1148
- }
1149
- .debug-panel .dp-close {
1150
- background: none; border: none; color: #a5b4fc; cursor: pointer; font-size: 14px;
1151
- }
1152
- .debug-panel .dp-body {
1153
- padding: 10px 12px; overflow-y: auto; flex: 1;
1154
- }
1155
- .debug-panel .dp-section { margin-bottom: 10px; }
1156
- .debug-panel .dp-label { color: #818cf8; font-weight: 600; font-size: 10px; text-transform: uppercase; margin-bottom: 4px; }
1157
- .debug-panel .dp-badge {
1158
- display: inline-block; background: #4338ca; color: #c7d2fe; padding: 1px 8px;
1159
- border-radius: 4px; font-size: 11px; font-weight: 600;
1160
- }
1161
- .debug-panel pre { margin: 0; white-space: pre-wrap; word-break: break-all; color: #c7d2fe; line-height: 1.5; }
1162
- .dev-banner .stub-tag.debug-btn { cursor: pointer; transition: all 0.15s ease; }
1163
- .dev-banner .stub-tag.debug-btn:hover { background: rgba(255,255,255,0.2); color: #a5b4fc; }
1164
-
1165
- /* Field validation error */
1166
- .cf-field-error .cf-input { border-color: #ef4444 !important; box-shadow: 0 0 0 3px rgba(239,68,68,0.1) !important; }
1167
- .cf-field-error .cf-choice[data-selected="false"] { border-color: #fca5a5 !important; }
1168
-
1169
- /* Action button styles */
1170
- .cf-btn-secondary {
1171
- font-family: var(--font-display); font-weight: 600; letter-spacing: -0.01em;
1172
- border-radius: 14px; padding: 14px 28px; font-size: 16px; border: 2px solid;
1173
- cursor: pointer; transition: all 0.25s var(--ease-out-expo); background: transparent;
1174
- }
1175
- .cf-btn-secondary:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); transform: translateY(-1px); }
1176
- .cf-btn-ghost {
1177
- font-family: var(--font-display); font-weight: 500; letter-spacing: -0.01em;
1178
- border-radius: 14px; padding: 14px 28px; font-size: 16px; border: none;
1179
- cursor: pointer; transition: all 0.2s var(--ease-out-expo); background: transparent;
1180
- }
1181
- .cf-btn-ghost:hover { background: rgba(0,0,0,0.04); }
1182
-
1183
- /* Sticky bottom bar */
1184
- .cf-sticky-bar {
1185
- position: fixed; bottom: 0; left: 0; right: 0; z-index: 80;
1186
- transition: transform 0.3s var(--ease-out-expo);
1187
- }
1188
- .cf-sticky-bar.hidden { transform: translateY(100%); }
1189
-
1190
- /* Resume prompt */
1191
- .cf-resume-backdrop {
1192
- position: fixed; inset: 0; z-index: 99; background: rgba(0,0,0,0.2);
1193
- backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center;
908
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
1194
909
  }
910
+ #__dev_banner a { color: #a5b4fc; text-decoration: none; }
911
+ #__dev_banner a:hover { text-decoration: underline; }
912
+ body { padding-top: 32px; }
1195
913
  </style>
1196
914
  </head>
1197
915
  <body>
1198
- <div class="dev-banner" id="dev-banner">
1199
- <span class="drag-handle" id="banner-drag" title="Drag to reposition">\u283F</span>
1200
- <span class="dot"></span>
1201
- <span class="label">LOCAL DEV</span>
1202
- <span class="slug">${schema.slug || "catalog"}</span>
1203
- <span class="stub-tags">
1204
- <span class="stub-tag" id="checkout-tag">${devConfig?.stripeEnabled ? "Checkout: live (test)" : "Checkout: stubbed"}</span>
1205
- <span class="stub-tag">Events: local</span>
1206
- <span class="stub-tag clickable" id="pages-btn">Pages</span>
1207
- <span class="stub-tag debug-btn" id="debug-btn">Debug</span>
1208
- <span class="validation-tag" id="validation-tag"></span>
1209
- <button class="minimize-btn" id="banner-minimize" title="Minimize toolbar">\u25B4</button>
916
+ <!-- Dev Banner -->
917
+ <div id="__dev_banner">
918
+ <span><strong style="color:#e0e7ff">${schema.slug || "catalog"}</strong> &mdash; local preview</span>
919
+ <span>
920
+ ${stripeEnabled ? '<span style="color:#4ade80">&#9679;</span> Stripe' : '<span style="color:#fbbf24">&#9675;</span> Stripe stubbed'}
921
+ &nbsp;&middot;&nbsp;
922
+ <a href="/__dev_events" target="_blank">Events</a>
1210
923
  </span>
1211
924
  </div>
1212
- <div class="dev-banner-restore" id="banner-restore">
1213
- <span class="restore-dot"></span>
1214
- <span>DEV</span>
1215
- </div>
1216
- <div class="pages-overlay" id="pages-overlay">
1217
- <button class="close-btn" id="pages-close">&times;</button>
1218
- <div class="zoom-controls">
1219
- <button class="zoom-btn" id="zoom-in">+</button>
1220
- <div class="zoom-level" id="zoom-level">100%</div>
1221
- <button class="zoom-btn" id="zoom-out">&minus;</button>
1222
- <button class="zoom-btn" id="zoom-fit" style="font-size:11px;margin-top:4px;">Fit</button>
1223
- </div>
1224
- <div class="mindmap-container" id="mindmap-container"></div>
1225
- </div>
1226
- <div class="inspector-highlight" id="inspector-highlight" style="display:none"></div>
1227
- <div class="inspector-tooltip" id="inspector-tooltip" style="display:none"></div>
1228
- <div class="inspector-active-banner" id="inspector-banner" style="display:none">Inspector active &mdash; hover elements, click to copy</div>
1229
- <div id="catalog-root"></div>
1230
- <div class="debug-panel" id="debug-panel">
1231
- <div class="dp-header"><span>Debug Panel</span><button class="dp-close" id="debug-close">&times;</button></div>
1232
- <div class="dp-body" id="debug-body"></div>
1233
- </div>
1234
-
1235
- <script id="__catalog_data" type="application/json">${schemaJson}</script>
1236
- <script id="__validation_data" type="application/json">${JSON.stringify(validation || { errors: [], warnings: [] })}</script>
1237
- <script id="__dev_config" type="application/json">${JSON.stringify(devConfig || { stripeEnabled: false, port })}</script>
1238
-
1239
- <script type="module">
1240
- import React from 'https://esm.sh/react@18';
1241
- import ReactDOM from 'https://esm.sh/react-dom@18/client';
1242
-
1243
- const h = React.createElement;
1244
- const schema = JSON.parse(document.getElementById('__catalog_data').textContent);
1245
- const themeColor = '${themeColor}';
1246
-
1247
- // --- Shared Engine (auto-generated from shared/engine/{conditions,routing,validate}.ts) ---
1248
- ${engineScript}
1249
-
1250
- // --- Dev context for condition/routing evaluation ---
1251
- const devContext = (() => {
1252
- const params = {};
1253
- new URLSearchParams(window.location.search).forEach((v, k) => { params[k] = v; });
1254
- return { url_params: params, hints: {}, quiz_scores: null, video_state: null };
1255
- })();
1256
-
1257
- // --- Markdown-ish text rendering ---
1258
- function inlineMarkdown(text) {
1259
- return text
1260
- .replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
1261
- .replace(/\\*(.+?)\\*/g, '<em>$1</em>')
1262
- .replace(/~~(.+?)~~/g, '<del class="opacity-60">$1</del>')
1263
- .replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="underline decoration-1 underline-offset-2 hover:opacity-80 transition-opacity">$1</a>');
1264
- }
1265
-
1266
- function renderMarkdownish(text) {
1267
- const lines = text.split(/\\n/);
1268
- let html = '', inUl = false, inOl = false;
1269
- for (const line of lines) {
1270
- const trimmed = line.trim();
1271
- const bullet = trimmed.match(/^[-\u2022]\\s+(.*)/);
1272
- const ordered = trimmed.match(/^\\d+\\.\\s+(.*)/);
1273
- if (bullet) {
1274
- if (inOl) { html += '</ol>'; inOl = false; }
1275
- if (!inUl) { html += '<ul class="list-disc pl-5 my-3 space-y-1.5">'; inUl = true; }
1276
- html += '<li>' + inlineMarkdown(bullet[1]) + '</li>';
1277
- } else if (ordered) {
1278
- if (inUl) { html += '</ul>'; inUl = false; }
1279
- if (!inOl) { html += '<ol class="list-decimal pl-5 my-3 space-y-1.5">'; inOl = true; }
1280
- html += '<li>' + inlineMarkdown(ordered[1]) + '</li>';
1281
- } else {
1282
- if (inUl) { html += '</ul>'; inUl = false; }
1283
- if (inOl) { html += '</ol>'; inOl = false; }
1284
- if (trimmed === '') html += '<br/>';
1285
- else { if (html.length > 0 && !html.endsWith('>')) html += '<br/>'; html += inlineMarkdown(trimmed); }
1286
- }
1287
- }
1288
- if (inUl) html += '</ul>';
1289
- if (inOl) html += '</ol>';
1290
- return html;
1291
- }
1292
-
1293
- function alignClass(align) {
1294
- if (align === 'center') return 'text-center';
1295
- if (align === 'right') return 'text-right';
1296
- return 'text-left';
1297
- }
1298
925
 
1299
- // --- Variant resolution ---
1300
- function resolveComponentVariants(props, hints) {
1301
- const resolved = { ...props };
1302
- const variantKeys = Object.keys(props).filter(k => k.endsWith('__variants'));
1303
- for (const variantKey of variantKeys) {
1304
- const baseProp = variantKey.replace('__variants', '');
1305
- const variants = props[variantKey];
1306
- if (!variants || typeof variants !== 'object') continue;
1307
- let bestMatch = null;
1308
- for (const [conditionStr, value] of Object.entries(variants)) {
1309
- const conditions = conditionStr.split(',').map(c => c.trim());
1310
- let allMatch = true, score = 0;
1311
- for (const cond of conditions) {
1312
- const [hintKey, hintValue] = cond.split('=');
1313
- if (hints[hintKey] === hintValue) score++;
1314
- else { allMatch = false; break; }
1315
- }
1316
- if (allMatch && score > 0 && (!bestMatch || score > bestMatch.score)) {
1317
- bestMatch = { value, score };
1318
- }
1319
- }
1320
- if (bestMatch) resolved[baseProp] = bestMatch.value;
1321
- delete resolved[variantKey];
1322
- }
1323
- return resolved;
1324
- }
1325
-
1326
- // --- Component Renderers ---
1327
- function RenderComponent({ comp, isCover, formState, onFieldChange, onSubmit, propOverrides }) {
1328
- const props = { ...(comp.props || {}), ...(propOverrides?.[comp.id] || {}) };
1329
- const type = comp.type;
1330
- const compClass = comp.className || '';
1331
- const compStyle = comp.style || {};
1332
-
1333
- switch (type) {
1334
- case 'heading': {
1335
- const level = props.level ?? 1;
1336
- const tag = 'h' + Math.min(Math.max(level, 1), 6);
1337
- const sizes = {
1338
- 1: 'text-4xl sm:text-5xl font-extrabold tracking-tight leading-[1.1]',
1339
- 2: 'text-3xl sm:text-4xl font-bold tracking-tight leading-[1.15]',
1340
- 3: 'text-2xl sm:text-3xl font-bold leading-tight',
1341
- 4: 'text-xl sm:text-2xl font-semibold leading-snug',
1342
- 5: 'text-lg sm:text-xl font-semibold',
1343
- 6: 'text-base sm:text-lg font-medium',
1344
- };
1345
- return h('div', { className: alignClass(props.align) + ' space-y-3 ' + compClass, style: compStyle },
1346
- props.micro_heading ? h('p', { className: 'text-xs sm:text-sm font-medium uppercase tracking-widest ' + (isCover ? 'text-white/60' : 'text-gray-400') }, props.micro_heading) : null,
1347
- h(tag, {
1348
- className: (sizes[level] || sizes[1]) + ' ' + (isCover ? 'text-white drop-shadow-lg' : 'text-gray-900') + ' cf-text-balance',
1349
- style: level <= 2 ? { letterSpacing: '-0.025em' } : undefined,
1350
- }, props.text ?? props.content),
1351
- props.subtitle ? h('p', { className: 'text-base sm:text-lg font-normal leading-relaxed ' + (isCover ? 'text-white/75' : 'text-gray-500') }, props.subtitle) : null
1352
- );
1353
- }
1354
-
1355
- case 'paragraph':
1356
- return h('div', {
1357
- className: alignClass(props.align) + ' ' + (isCover ? 'text-white/85' : 'text-gray-600') + ' text-lg leading-[1.7] [&_ul]:text-left [&_ol]:text-left ' + compClass,
1358
- style: compStyle,
1359
- dangerouslySetInnerHTML: { __html: renderMarkdownish(props.text ?? props.content ?? '') }
1360
- });
1361
-
1362
- case 'image': {
1363
- const borderRadius = props.border_radius ?? 16;
1364
- const img = h('img', { src: props.src, alt: props.alt || '', className: 'ck-img', loading: 'lazy' });
1365
- return h('div', { className: 'w-full overflow-hidden ' + compClass, style: { borderRadius, ...compStyle } },
1366
- props.link ? h('a', { href: props.link, target: '_blank', rel: 'noopener noreferrer' }, img) : img
1367
- );
1368
- }
1369
-
1370
- case 'video': {
1371
- const src = props.src || '';
1372
- const isYT = /(?:youtube\\.com|youtu\\.be)/.test(src);
1373
- const isVimeo = /vimeo\\.com/.test(src);
1374
- if (isYT) {
1375
- const match = src.match(/(?:youtube\\.com\\/(?:watch\\?v=|embed\\/)|youtu\\.be\\/)([a-zA-Z0-9_-]{11})/);
1376
- const embedUrl = match ? 'https://www.youtube.com/embed/' + match[1] : src;
1377
- return h('div', { className: 'relative w-full overflow-hidden rounded-2xl shadow-lg ' + compClass, style: { paddingTop: '56.25%', ...compStyle } },
1378
- h('iframe', { src: embedUrl, className: 'absolute inset-0 w-full h-full', allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture', allowFullScreen: true })
1379
- );
1380
- }
1381
- if (isVimeo) {
1382
- const match = src.match(/vimeo\\.com\\/(\\d+)/);
1383
- const embedUrl = match ? 'https://player.vimeo.com/video/' + match[1] : src;
1384
- return h('div', { className: 'relative w-full overflow-hidden rounded-2xl shadow-lg ' + compClass, style: { paddingTop: '56.25%', ...compStyle } },
1385
- h('iframe', { src: embedUrl, className: 'absolute inset-0 w-full h-full', allow: 'autoplay; fullscreen; picture-in-picture', allowFullScreen: true })
1386
- );
1387
- }
1388
- return h(VideoPlayer, { comp, isCover, compClass, compStyle });
1389
- }
1390
-
1391
- case 'html':
1392
- return h(HtmlBlock, { content: props.content || '', className: compClass, style: compStyle, formState });
1393
-
1394
- case 'banner': {
1395
- const variants = {
1396
- info: { bg: 'bg-blue-50', border: 'border-blue-100', text: 'text-blue-700', icon: '\\u2139\\uFE0F' },
1397
- warning: { bg: 'bg-amber-50', border: 'border-amber-100', text: 'text-amber-700', icon: '\\u26A0\\uFE0F' },
1398
- success: { bg: 'bg-emerald-50', border: 'border-emerald-100', text: 'text-emerald-700', icon: '\\u2705' },
1399
- error: { bg: 'bg-red-50', border: 'border-red-100', text: 'text-red-700', icon: '\\u274C' },
1400
- };
1401
- const v = variants[props.variant ?? props.style] || variants.info;
1402
- const bannerText = props.text ?? props.content ?? '';
1403
- if (isCover) {
1404
- return h('div', { className: 'cf-banner-glass rounded-2xl px-5 py-3.5 flex items-center justify-center gap-3 ' + compClass, style: compStyle },
1405
- h('span', { className: 'text-base flex-shrink-0' }, v.icon),
1406
- h('div', { className: 'text-sm leading-relaxed text-white/90 font-medium', dangerouslySetInnerHTML: { __html: renderMarkdownish(bannerText) } })
1407
- );
1408
- }
1409
- return h('div', { className: v.bg + ' ' + v.border + ' ' + v.text + ' border rounded-2xl px-5 py-4 flex items-start gap-3 ' + compClass, style: compStyle },
1410
- h('span', { className: 'text-lg flex-shrink-0 mt-0.5' }, v.icon),
1411
- h('div', { className: 'text-sm leading-relaxed', dangerouslySetInnerHTML: { __html: renderMarkdownish(bannerText) } })
1412
- );
1413
- }
1414
-
1415
- case 'callout': {
1416
- const title = props.title || '';
1417
- const text = props.text || props.content || '';
1418
- return h('div', { className: 'rounded-2xl px-5 py-4 ' + compClass, style: { backgroundColor: (props.color || themeColor) + '0a', border: '1.5px solid ' + (props.color || themeColor) + '20', ...compStyle } },
1419
- title ? h('p', { className: 'font-semibold text-sm mb-1', style: { color: props.color || themeColor } }, title) : null,
1420
- h('div', { className: 'text-sm leading-relaxed text-gray-600', dangerouslySetInnerHTML: { __html: renderMarkdownish(text) } })
1421
- );
1422
- }
1423
-
1424
- case 'divider':
1425
- return h('hr', { className: 'border-t border-gray-100 my-4 ' + compClass, style: compStyle });
1426
-
1427
- case 'pricing_card': {
1428
- const features = props.features || [];
1429
- return h('div', { className: 'pricing-card ' + compClass, style: compStyle },
1430
- props.badge ? h('span', { className: 'inline-block text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full text-white mb-4', style: { backgroundColor: themeColor } }, props.badge) : null,
1431
- props.title ? h('h3', { className: 'text-2xl font-bold text-gray-900 mb-2', style: { fontFamily: 'var(--font-display)' } }, props.title) : null,
1432
- props.subtitle ? h('p', { className: 'text-gray-500 text-sm mb-6' }, props.subtitle) : null,
1433
- props.price != null ? h('div', { className: 'mb-6' },
1434
- props.original_price ? h('span', { className: 'text-lg text-gray-400 line-through mr-2' }, props.original_price) : null,
1435
- h('span', { className: 'text-4xl font-extrabold', style: { fontFamily: 'var(--font-display)', color: themeColor } }, typeof props.price === 'number' ? '$' + props.price : props.price),
1436
- props.period ? h('span', { className: 'text-gray-400 text-sm ml-1' }, '/' + props.period) : null
1437
- ) : null,
1438
- features.length > 0 ? h('ul', { className: 'text-left space-y-3 mb-6' },
1439
- ...features.map((f, i) => h('li', { key: i, className: 'flex items-start gap-2.5 text-sm text-gray-600' },
1440
- h('span', { className: 'flex-shrink-0 mt-0.5', style: { color: themeColor } }, '\\u2713'),
1441
- h('span', null, typeof f === 'string' ? f : f.text || f.label || '')
1442
- ))
1443
- ) : null,
1444
- props.cta_text ? h('button', { className: 'cf-btn-primary w-full text-white', style: { backgroundColor: themeColor } }, props.cta_text) : null,
1445
- props.reassurance ? h('p', { className: 'text-xs text-gray-400 mt-2' }, props.reassurance) : null
1446
- );
1447
- }
926
+ <!-- React mount point -->
927
+ <div id="root"></div>
1448
928
 
1449
- case 'testimonial':
1450
- return h('div', { className: 'cf-card p-6 ' + compClass, style: compStyle },
1451
- props.quote ? h('p', { className: 'text-gray-700 text-base leading-relaxed italic mb-4' }, '"' + props.quote + '"') : null,
1452
- h('div', { className: 'flex items-center gap-3' },
1453
- props.avatar ? h('img', { src: props.avatar, className: 'w-10 h-10 rounded-full object-cover' }) : null,
1454
- h('div', null,
1455
- props.name ? h('p', { className: 'font-semibold text-sm text-gray-900' }, props.name) : null,
1456
- props.title_text || props.role ? h('p', { className: 'text-xs text-gray-400' }, props.title_text || props.role) : null
1457
- )
1458
- )
1459
- );
929
+ ${validationHtml}
1460
930
 
1461
- case 'faq': {
1462
- const items = props.items || [];
1463
- return h('div', { className: 'space-y-3 ' + compClass, style: compStyle },
1464
- ...items.map((item, i) => h(FaqItem, { key: i, question: item.question || item.q, answer: item.answer || item.a, isCover }))
1465
- );
1466
- }
1467
-
1468
- case 'timeline': {
1469
- const items = props.items || [];
1470
- return h('div', { className: 'relative pl-8 space-y-8 ' + compClass, style: compStyle },
1471
- h('div', { className: 'absolute left-3 top-2 bottom-2 w-0.5 bg-gray-200' }),
1472
- ...items.map((item, i) => h('div', { key: i, className: 'relative' },
1473
- h('div', { className: 'absolute -left-5 w-3 h-3 rounded-full border-2 border-white', style: { backgroundColor: themeColor, boxShadow: '0 0 0 3px ' + themeColor + '20' } }),
1474
- h('div', null,
1475
- item.title ? h('p', { className: 'font-semibold text-sm text-gray-900' }, item.title) : null,
1476
- item.description ? h('p', { className: 'text-sm text-gray-500 mt-1' }, item.description) : null
1477
- )
1478
- ))
1479
- );
1480
- }
1481
-
1482
- case 'file_download':
1483
- return h('div', { className: compClass, style: compStyle },
1484
- h('a', { href: props.src || props.href || '#', download: props.filename || 'file', className: 'inline-flex items-center gap-3 border-1.5 border-gray-200 rounded-xl px-5 py-3.5 text-sm font-medium text-gray-700 hover:bg-gray-50 hover:border-gray-300 transition-all' },
1485
- h('svg', { className: 'w-5 h-5 text-gray-400', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
1486
- h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' })
1487
- ),
1488
- props.filename || props.label || 'Download File'
1489
- )
1490
- );
1491
-
1492
- case 'iframe':
1493
- return h('div', { className: 'w-full overflow-hidden rounded-2xl ' + compClass, style: { ...compStyle } },
1494
- h('iframe', { src: props.src || props.url, className: 'w-full border-0', style: { height: props.height || '400px' }, allow: props.allow || 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope', allowFullScreen: true })
1495
- );
1496
-
1497
- // --- Input components ---
1498
- case 'short_text':
1499
- case 'email':
1500
- case 'phone':
1501
- case 'url':
1502
- case 'number':
1503
- case 'password':
1504
- return h(TextInput, { comp, type, formState, onFieldChange, isCover, compClass, compStyle, onSubmit });
1505
-
1506
- case 'long_text':
1507
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1508
- props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1509
- props.label,
1510
- (props.required) ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1511
- ) : null,
1512
- props.description ? h('p', { className: 'text-xs ' + (isCover ? 'text-white/70' : 'text-gray-500') }, props.description) : null,
1513
- h('textarea', {
1514
- className: 'cf-input min-h-[80px] resize-y',
1515
- placeholder: props.placeholder || '',
1516
- rows: props.rows || 4,
1517
- value: formState[comp.id] ?? '',
1518
- onChange: (e) => onFieldChange(comp.id, e.target.value),
1519
- })
1520
- );
1521
-
1522
- case 'multiple_choice':
1523
- return h(MultipleChoiceInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1524
-
1525
- case 'checkboxes':
1526
- return h(CheckboxesInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1527
-
1528
- case 'dropdown':
1529
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1530
- props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1531
- props.label,
1532
- (props.required) ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1533
- ) : null,
1534
- h('select', {
1535
- className: 'cf-input',
1536
- value: formState[comp.id] ?? '',
1537
- onChange: (e) => onFieldChange(comp.id, e.target.value),
1538
- },
1539
- h('option', { value: '' }, props.placeholder || 'Select...'),
1540
- ...(props.options || []).map((opt, j) =>
1541
- h('option', { key: j, value: typeof opt === 'string' ? opt : opt.value },
1542
- typeof opt === 'string' ? opt : opt.label || opt.value || ''
1543
- )
1544
- )
1545
- )
1546
- );
1547
-
1548
- case 'slider':
1549
- return h(SliderInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1550
-
1551
- case 'star_rating':
1552
- return h(StarRatingInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1553
-
1554
- case 'switch':
1555
- case 'checkbox':
1556
- return h(SwitchInput, { comp, type, formState, onFieldChange, isCover, compClass, compStyle });
1557
-
1558
- case 'payment':
1559
- return h(PaymentComponent, { comp, formState, isCover, compClass, compStyle });
1560
-
1561
- case 'date':
1562
- case 'datetime':
1563
- case 'time': {
1564
- const htmlType = type === 'datetime' ? 'datetime-local' : type;
1565
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1566
- props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1567
- props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1568
- ) : null,
1569
- h('input', { type: htmlType, className: 'cf-input', value: formState[comp.id] ?? '', onChange: (e) => onFieldChange(comp.id, e.target.value) })
1570
- );
1571
- }
1572
-
1573
- case 'date_range': {
1574
- const rangeVal = formState[comp.id] || {};
1575
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1576
- props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1577
- props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1578
- ) : null,
1579
- h('div', { className: 'flex gap-3 items-center' },
1580
- h('input', { type: 'date', className: 'cf-input flex-1', value: rangeVal.start || '', onChange: (e) => onFieldChange(comp.id, { ...rangeVal, start: e.target.value }) }),
1581
- h('span', { className: 'text-gray-400 text-sm' }, 'to'),
1582
- h('input', { type: 'date', className: 'cf-input flex-1', value: rangeVal.end || '', onChange: (e) => onFieldChange(comp.id, { ...rangeVal, end: e.target.value }) })
1583
- )
1584
- );
1585
- }
1586
-
1587
- case 'picture_choice': {
1588
- const pcOptions = props.options || [];
1589
- const pcMultiple = props.multiple;
1590
- const pcSelected = formState[comp.id];
1591
- const pcCols = props.columns || Math.min(pcOptions.length, 3);
1592
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1593
- props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1594
- props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1595
- ) : null,
1596
- h('div', { className: 'grid gap-3', style: { gridTemplateColumns: 'repeat(' + pcCols + ', 1fr)' } },
1597
- ...(pcOptions).map((opt, j) => {
1598
- const val = typeof opt === 'string' ? opt : opt.value;
1599
- const lbl = typeof opt === 'string' ? opt : opt.label || opt.value;
1600
- const img = typeof opt === 'object' ? opt.image : null;
1601
- const isSel = pcMultiple ? Array.isArray(pcSelected) && pcSelected.includes(val) : pcSelected === val;
1602
- return h('button', {
1603
- key: j, className: 'rounded-xl border-2 overflow-hidden transition-all text-center p-2',
1604
- style: isSel ? { borderColor: themeColor, boxShadow: '0 0 0 3px ' + themeColor + '26' } : { borderColor: '#e2e4e9' },
1605
- onClick: () => {
1606
- if (pcMultiple) {
1607
- const arr = Array.isArray(pcSelected) ? [...pcSelected] : [];
1608
- onFieldChange(comp.id, isSel ? arr.filter(v => v !== val) : [...arr, val]);
1609
- } else { onFieldChange(comp.id, val); }
1610
- },
1611
- },
1612
- img ? h('img', { src: img, alt: lbl, className: 'w-full h-24 object-cover rounded-lg mb-2' }) : null,
1613
- h('span', { className: 'text-sm font-medium ' + (isCover ? 'text-white' : 'text-gray-700') }, lbl)
1614
- );
1615
- })
1616
- )
1617
- );
1618
- }
1619
-
1620
- case 'opinion_scale': {
1621
- const osMin = props.min ?? 1, osMax = props.max ?? 10;
1622
- const osValue = formState[comp.id];
1623
- const osButtons = [];
1624
- for (let i = osMin; i <= osMax; i++) osButtons.push(i);
1625
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1626
- props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1627
- props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1628
- ) : null,
1629
- h('div', { className: 'flex items-center gap-1' },
1630
- props.min_label ? h('span', { className: 'text-xs text-gray-400 mr-2 shrink-0' }, props.min_label) : null,
1631
- ...osButtons.map(n => h('button', {
1632
- key: n, className: 'flex-1 py-2.5 rounded-lg text-sm font-semibold transition-all border',
1633
- style: osValue === n ? { backgroundColor: themeColor, color: 'white', borderColor: themeColor } : { backgroundColor: 'transparent', color: isCover ? 'white' : '#374151', borderColor: '#e5e7eb' },
1634
- onClick: () => onFieldChange(comp.id, n),
1635
- }, String(n))),
1636
- props.max_label ? h('span', { className: 'text-xs text-gray-400 ml-2 shrink-0' }, props.max_label) : null
1637
- )
1638
- );
1639
- }
1640
-
1641
- case 'address':
1642
- return h(AddressInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1643
-
1644
- case 'currency': {
1645
- const currSymbol = props.currency_symbol || props.prefix || '$';
1646
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1647
- props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1648
- props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1649
- ) : null,
1650
- h('div', { className: 'relative' },
1651
- h('span', { className: 'absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 font-medium pointer-events-none' }, currSymbol),
1652
- h('input', { type: 'number', className: 'cf-input', style: { paddingLeft: '2.5rem' },
1653
- placeholder: props.placeholder || '0.00', step: props.step || '0.01',
1654
- value: formState[comp.id] ?? '', onChange: (e) => onFieldChange(comp.id, e.target.value) })
1655
- )
1656
- );
1657
- }
1658
-
1659
- case 'file_upload':
1660
- return h(FileUploadInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1661
-
1662
- case 'signature':
1663
- return h(SignatureInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1664
-
1665
- case 'table': {
1666
- const tHeaders = props.headers || [];
1667
- const tRows = props.rows || [];
1668
- return h('div', { className: 'overflow-x-auto rounded-xl border border-gray-200 ' + compClass, style: compStyle },
1669
- h('table', { className: 'w-full text-sm' },
1670
- tHeaders.length > 0 ? h('thead', null,
1671
- 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)))
1672
- ) : null,
1673
- h('tbody', null,
1674
- ...tRows.map((row, i) => h('tr', { key: i, className: i % 2 === 0 ? 'bg-white' : 'bg-gray-50' },
1675
- ...(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 ?? '')))
1676
- ))
1677
- )
1678
- )
1679
- );
1680
- }
1681
-
1682
- case 'social_links': {
1683
- const socialLinks = props.links || [];
1684
- return h('div', { className: 'flex items-center gap-3 ' + compClass, style: compStyle },
1685
- ...socialLinks.map((link, i) => h('a', {
1686
- key: i, href: link.url, target: '_blank', rel: 'noopener noreferrer',
1687
- className: 'w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-bold transition-transform hover:scale-110',
1688
- style: { backgroundColor: themeColor }, title: link.platform,
1689
- }, (link.platform || '?')[0].toUpperCase()))
1690
- );
1691
- }
1692
-
1693
- case 'accordion': {
1694
- const accItems = props.items || [];
1695
- return h('div', { className: 'space-y-3 ' + compClass, style: compStyle },
1696
- ...accItems.map((item, i) => h(FaqItem, { key: i, question: item.title || item.question, answer: item.content || item.answer, isCover }))
1697
- );
1698
- }
1699
-
1700
- case 'tabs':
1701
- return h(TabsComponent, { comp, isCover, compClass, compStyle });
1702
-
1703
- case 'countdown':
1704
- return h(CountdownComponent, { comp, compClass, compStyle });
1705
-
1706
- case 'comparison_table': {
1707
- const ctPlans = props.plans || [];
1708
- const ctFeatures = props.features || [];
1709
- return h('div', { className: 'overflow-x-auto ' + compClass, style: compStyle },
1710
- h('table', { className: 'w-full text-sm' },
1711
- h('thead', null,
1712
- h('tr', null,
1713
- h('th', { className: 'px-4 py-3 text-left text-gray-500 font-medium' }, 'Feature'),
1714
- ...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))
1715
- )
1716
- ),
1717
- h('tbody', null,
1718
- ...ctFeatures.map((feat, i) => h('tr', { key: i, className: i % 2 === 0 ? 'bg-white' : 'bg-gray-50' },
1719
- h('td', { className: 'px-4 py-3 text-gray-700 font-medium border-t border-gray-100' }, feat.name || feat.label),
1720
- ...ctPlans.map((plan, j) => {
1721
- const fVal = plan.features?.[feat.id || feat.name] ?? feat.values?.[j];
1722
- const fDisplay = fVal === true ? '\\u2713' : fVal === false ? '\\u2014' : String(fVal ?? '\\u2014');
1723
- return h('td', { key: j, className: 'px-4 py-3 text-center border-t border-gray-100', style: fVal === true ? { color: themeColor } : undefined }, fDisplay);
1724
- })
1725
- ))
1726
- )
1727
- )
1728
- );
1729
- }
1730
-
1731
- case 'progress_bar': {
1732
- const pbValue = props.value ?? 0;
1733
- const pbMax = props.max ?? 100;
1734
- const pbPct = Math.min(100, Math.max(0, (pbValue / pbMax) * 100));
1735
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1736
- props.label ? h('div', { className: 'flex justify-between text-sm' },
1737
- h('span', { className: 'font-medium text-gray-700' }, props.label),
1738
- h('span', { className: 'text-gray-400' }, Math.round(pbPct) + '%')
1739
- ) : null,
1740
- h('div', { className: 'w-full h-3 bg-gray-100 rounded-full overflow-hidden' },
1741
- h('div', { className: 'h-full rounded-full progress-bar-fill', style: { width: pbPct + '%', backgroundColor: themeColor } })
1742
- )
1743
- );
1744
- }
1745
-
1746
- case 'modal':
1747
- return h(ModalComponent, { comp, isCover, compClass, compStyle });
1748
-
1749
- default:
1750
- return h('div', {
1751
- className: 'border-2 border-dashed border-gray-300 rounded-xl p-4 text-center text-gray-400 text-sm ' + compClass,
1752
- style: compStyle,
1753
- }, type + (props.label ? ': ' + props.label : '') + ' (' + comp.id + ')');
1754
- }
1755
- }
1756
-
1757
- // --- Sub-components ---
1758
-
1759
- function FaqItem({ question, answer, isCover }) {
1760
- const [open, setOpen] = React.useState(false);
1761
- return h('div', { className: 'rounded-2xl border border-gray-200 overflow-hidden' },
1762
- h('button', {
1763
- className: 'w-full flex items-center justify-between px-5 py-4 text-left text-sm font-semibold ' + (isCover ? 'text-white' : 'text-gray-900'),
1764
- onClick: () => setOpen(!open),
1765
- },
1766
- h('span', null, question),
1767
- h('svg', { className: 'w-4 h-4 transition-transform ' + (open ? 'rotate-180' : ''), fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
1768
- h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M19 9l-7 7-7-7' })
1769
- )
1770
- ),
1771
- open ? h('div', { className: 'px-5 pb-4 text-sm text-gray-600 leading-relaxed', dangerouslySetInnerHTML: { __html: renderMarkdownish(answer || '') } }) : null
1772
- );
1773
- }
1774
-
1775
- function TextInput({ comp, type, formState, onFieldChange, isCover, compClass, compStyle, onSubmit }) {
1776
- const props = comp.props || {};
1777
- const inputType = type === 'email' ? 'email' : type === 'phone' ? 'tel' : type === 'url' ? 'url' : type === 'number' ? 'number' : type === 'password' ? 'password' : 'text';
1778
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1779
- props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1780
- props.label,
1781
- (props.required) ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1782
- ) : null,
1783
- (props.sublabel || props.subheading) ? h('p', { className: 'text-xs font-medium ' + (isCover ? 'text-white/60' : 'text-gray-400') }, props.sublabel || props.subheading) : null,
1784
- props.description ? h('p', { className: 'text-xs ' + (isCover ? 'text-white/70' : 'text-gray-500') }, props.description) : null,
1785
- h('input', {
1786
- type: inputType,
1787
- className: 'cf-input' + (isCover ? ' bg-white/10 text-white border-white/20 placeholder-white/40' : ''),
1788
- placeholder: props.placeholder || '',
1789
- value: formState[comp.id] ?? '',
1790
- onChange: (e) => onFieldChange(comp.id, e.target.value),
1791
- onKeyDown: (e) => { if (e.key === 'Enter' && onSubmit) { e.preventDefault(); onSubmit(); } },
1792
- })
1793
- );
1794
- }
1795
-
1796
- function MultipleChoiceInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1797
- const props = comp.props || {};
1798
- const selected = formState[comp.id] ?? null;
1799
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1800
- props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1801
- props.label,
1802
- (props.required) ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1803
- ) : null,
1804
- props.description ? h('p', { className: 'text-xs ' + (isCover ? 'text-white/70' : 'text-gray-500') }, props.description) : null,
1805
- h('div', { className: 'space-y-2.5' },
1806
- ...(props.options || []).map((opt, j) => {
1807
- const value = typeof opt === 'string' ? opt : opt.value;
1808
- const label = typeof opt === 'string' ? opt : opt.label || opt.value || '';
1809
- const isSelected = selected === value;
1810
- return h('button', {
1811
- key: j,
1812
- className: 'cf-choice flex items-center gap-3',
1813
- 'data-selected': isSelected ? 'true' : 'false',
1814
- style: isSelected ? { borderColor: themeColor, boxShadow: '0 0 0 3px ' + themeColor + '26' } : undefined,
1815
- onClick: () => onFieldChange(comp.id, value),
1816
- },
1817
- h('span', {
1818
- className: 'w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-all',
1819
- style: isSelected ? { borderColor: themeColor, backgroundColor: themeColor } : { borderColor: '#d1d5db' },
1820
- }, isSelected ? h('span', { className: 'w-2 h-2 rounded-full bg-white' }) : null),
1821
- h('span', { className: 'text-sm font-medium ' + (isCover ? 'text-white' : 'text-gray-700') }, label)
1822
- );
1823
- })
1824
- )
1825
- );
1826
- }
1827
-
1828
- function CheckboxesInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1829
- const props = comp.props || {};
1830
- const selected = formState[comp.id] || [];
1831
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1832
- props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1833
- props.label,
1834
- (props.required) ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1835
- ) : null,
1836
- h('div', { className: 'space-y-2.5' },
1837
- ...(props.options || []).map((opt, j) => {
1838
- const value = typeof opt === 'string' ? opt : opt.value;
1839
- const label = typeof opt === 'string' ? opt : opt.label || opt.value || '';
1840
- const isChecked = Array.isArray(selected) && selected.includes(value);
1841
- return h('button', {
1842
- key: j,
1843
- className: 'cf-choice flex items-center gap-3',
1844
- 'data-selected': isChecked ? 'true' : 'false',
1845
- style: isChecked ? { borderColor: themeColor, boxShadow: '0 0 0 3px ' + themeColor + '26' } : undefined,
1846
- onClick: () => {
1847
- const arr = Array.isArray(selected) ? [...selected] : [];
1848
- if (isChecked) onFieldChange(comp.id, arr.filter(v => v !== value));
1849
- else onFieldChange(comp.id, [...arr, value]);
1850
- },
1851
- },
1852
- h('span', {
1853
- className: 'w-5 h-5 rounded-md border-2 flex items-center justify-center flex-shrink-0 transition-all',
1854
- style: isChecked ? { borderColor: themeColor, backgroundColor: themeColor } : { borderColor: '#d1d5db' },
1855
- }, isChecked ? h('svg', { className: 'w-3 h-3 text-white', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 3 },
1856
- h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M5 13l4 4L19 7' })
1857
- ) : null),
1858
- h('span', { className: 'text-sm font-medium ' + (isCover ? 'text-white' : 'text-gray-700') }, label)
1859
- );
1860
- })
1861
- )
1862
- );
1863
- }
1864
-
1865
- function SliderInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1866
- const props = comp.props || {};
1867
- const min = props.min ?? 0, max = props.max ?? 100, step = props.step ?? 1;
1868
- const value = formState[comp.id] ?? props.default_value ?? min;
1869
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1870
- props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') }, props.label) : null,
1871
- h('div', { className: 'flex items-center gap-4' },
1872
- h('input', { type: 'range', min, max, step, value, className: 'flex-1', style: { accentColor: themeColor }, onChange: (e) => onFieldChange(comp.id, Number(e.target.value)) }),
1873
- h('span', { className: 'text-sm font-semibold min-w-[3ch] text-right', style: { color: themeColor } }, value)
1874
- )
1875
- );
1876
- }
1877
-
1878
- function StarRatingInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1879
- const props = comp.props || {};
1880
- const max = props.max ?? 5;
1881
- const value = formState[comp.id] ?? 0;
1882
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1883
- props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') }, props.label) : null,
1884
- h('div', { className: 'flex gap-1' },
1885
- ...Array.from({ length: max }, (_, i) => h('button', {
1886
- key: i,
1887
- className: 'text-2xl transition-transform hover:scale-125',
1888
- onClick: () => onFieldChange(comp.id, i + 1),
1889
- }, i < value ? '\\u2605' : '\\u2606'))
1890
- )
1891
- );
1892
- }
1893
-
1894
- function SwitchInput({ comp, type, formState, onFieldChange, isCover, compClass, compStyle }) {
1895
- const props = comp.props || {};
1896
- const checked = !!formState[comp.id];
1897
- return h('div', { className: 'flex items-center gap-3 ' + compClass, style: compStyle },
1898
- h('button', {
1899
- className: 'relative w-11 h-6 rounded-full transition-colors',
1900
- style: { backgroundColor: checked ? themeColor : '#d1d5db' },
1901
- onClick: () => onFieldChange(comp.id, !checked),
1902
- },
1903
- h('span', { className: 'absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform', style: { left: checked ? '22px' : '2px' } })
1904
- ),
1905
- props.label ? h('span', { className: 'text-sm font-medium ' + (isCover ? 'text-white' : 'text-gray-700') }, props.label) : null,
1906
- );
1907
- }
1908
-
1909
- // --- Additional input/display components ---
1910
- function AddressInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1911
- const props = comp.props || {};
1912
- const addr = formState[comp.id] || {};
1913
- const up = (field, val) => onFieldChange(comp.id, { ...addr, [field]: val });
1914
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1915
- props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1916
- props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null) : null,
1917
- h('div', { className: 'space-y-2' },
1918
- h('input', { type: 'text', className: 'cf-input', placeholder: 'Street address', value: addr.street || '', onChange: (e) => up('street', e.target.value) }),
1919
- h('div', { className: 'grid grid-cols-2 gap-2' },
1920
- h('input', { type: 'text', className: 'cf-input', placeholder: 'City', value: addr.city || '', onChange: (e) => up('city', e.target.value) }),
1921
- h('input', { type: 'text', className: 'cf-input', placeholder: 'State', value: addr.state || '', onChange: (e) => up('state', e.target.value) })
1922
- ),
1923
- h('div', { className: 'grid grid-cols-2 gap-2' },
1924
- h('input', { type: 'text', className: 'cf-input', placeholder: 'ZIP / Postal', value: addr.zip || '', onChange: (e) => up('zip', e.target.value) }),
1925
- h('input', { type: 'text', className: 'cf-input', placeholder: 'Country', value: addr.country || '', onChange: (e) => up('country', e.target.value) })
1926
- )
1927
- )
1928
- );
1929
- }
1930
-
1931
- function FileUploadInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1932
- const props = comp.props || {};
1933
- const fileName = formState[comp.id] || '';
1934
- const fileRef = React.useRef(null);
1935
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1936
- props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1937
- props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null) : null,
1938
- h('div', { className: 'border-2 border-dashed border-gray-200 rounded-xl p-6 text-center' },
1939
- h('input', { type: 'file', ref: fileRef, className: 'hidden', accept: props.accept,
1940
- onChange: (e) => { const f = e.target.files?.[0]; if (f) onFieldChange(comp.id, f.name); } }),
1941
- fileName
1942
- ? h('div', { className: 'flex items-center justify-center gap-2' },
1943
- h('span', { className: 'text-sm text-gray-700' }, fileName),
1944
- h('button', { className: 'text-xs text-red-500 hover:text-red-700', onClick: () => onFieldChange(comp.id, '') }, 'Remove'))
1945
- : h('button', { className: 'text-sm font-medium', style: { color: themeColor }, onClick: () => fileRef.current?.click() }, props.button_text || 'Choose file'),
1946
- h('p', { className: 'text-xs text-gray-400 mt-2' }, 'Files are not uploaded in dev mode')
1947
- )
1948
- );
1949
- }
1950
-
1951
- function SignatureInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1952
- const props = comp.props || {};
1953
- const signed = !!formState[comp.id];
1954
- return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1955
- props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1956
- props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null) : null,
1957
- h('div', { className: 'border rounded-xl p-4' },
1958
- signed
1959
- ? h('div', { className: 'flex items-center justify-between' },
1960
- h('span', { className: 'text-sm text-green-600 font-medium' }, '\\u2713 Signature captured'),
1961
- h('button', { className: 'text-xs text-gray-400 hover:text-gray-600', onClick: () => onFieldChange(comp.id, '') }, 'Clear'))
1962
- : h('button', {
1963
- className: 'w-full py-8 text-center text-sm text-gray-400 border-2 border-dashed rounded-lg hover:bg-gray-50',
1964
- onClick: () => onFieldChange(comp.id, 'signature_' + Date.now()),
1965
- }, 'Click to sign (canvas stubbed in dev)')
1966
- )
1967
- );
1968
- }
1969
-
1970
- function TabsComponent({ comp, isCover, compClass, compStyle }) {
1971
- const props = comp.props || {};
1972
- const tabs = props.tabs || [];
1973
- const [activeTab, setActiveTab] = React.useState(0);
1974
- return h('div', { className: compClass, style: compStyle },
1975
- h('div', { className: 'flex border-b border-gray-200 mb-4' },
1976
- ...tabs.map((tab, i) => h('button', {
1977
- key: i, className: 'px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px',
1978
- style: i === activeTab ? { color: themeColor, borderColor: themeColor } : { color: '#9ca3af', borderColor: 'transparent' },
1979
- onClick: () => setActiveTab(i),
1980
- }, tab.label || tab.title || 'Tab ' + (i + 1)))
1981
- ),
1982
- tabs[activeTab] ? h('div', { className: 'text-sm text-gray-600', dangerouslySetInnerHTML: { __html: renderMarkdownish(tabs[activeTab].content || '') } }) : null
1983
- );
1984
- }
1985
-
1986
- function CountdownComponent({ comp, compClass, compStyle }) {
1987
- const props = comp.props || {};
1988
- const [timeLeft, setTimeLeft] = React.useState({});
1989
- React.useEffect(() => {
1990
- const target = new Date(props.target_date).getTime();
1991
- const update = () => {
1992
- const diff = Math.max(0, target - Date.now());
1993
- setTimeLeft({ days: Math.floor(diff / 86400000), hours: Math.floor((diff % 86400000) / 3600000), minutes: Math.floor((diff % 3600000) / 60000), seconds: Math.floor((diff % 60000) / 1000) });
1994
- };
1995
- update();
1996
- const iv = setInterval(update, 1000);
1997
- return () => clearInterval(iv);
1998
- }, [props.target_date]);
1999
- return h('div', { className: 'flex items-center justify-center gap-4 ' + compClass, style: compStyle },
2000
- ...['days', 'hours', 'minutes', 'seconds'].map(unit =>
2001
- h('div', { key: unit, className: 'text-center' },
2002
- h('div', { className: 'text-3xl font-bold', style: { color: themeColor, fontFamily: 'var(--font-display)' } }, String(timeLeft[unit] ?? 0).padStart(2, '0')),
2003
- h('div', { className: 'text-xs text-gray-400 uppercase tracking-wider mt-1' }, unit)
2004
- )
2005
- )
2006
- );
2007
- }
2008
-
2009
- function ModalComponent({ comp, isCover, compClass, compStyle }) {
2010
- const props = comp.props || {};
2011
- const [open, setOpen] = React.useState(false);
2012
- return h(React.Fragment, null,
2013
- h('button', { className: 'cf-btn-primary text-white', style: { backgroundColor: themeColor, ...compStyle }, onClick: () => setOpen(true) },
2014
- props.trigger_label || props.label || 'Open'),
2015
- open ? h('div', { className: 'fixed inset-0 z-[99] flex items-center justify-center' },
2016
- h('div', { className: 'absolute inset-0 bg-black/40 backdrop-blur-sm', onClick: () => setOpen(false) }),
2017
- 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' },
2018
- h('div', { className: 'flex justify-between items-center mb-4' },
2019
- props.title ? h('h3', { className: 'text-lg font-bold text-gray-900' }, props.title) : null,
2020
- h('button', { className: 'text-gray-400 hover:text-gray-600 text-xl', onClick: () => setOpen(false) }, '\\u2715')
2021
- ),
2022
- h('div', { className: 'text-sm text-gray-600', dangerouslySetInnerHTML: { __html: renderMarkdownish(props.content || props.body || '') } })
2023
- )
2024
- ) : null
2025
- );
2026
- }
2027
-
2028
- // --- HtmlBlock: renders HTML content and executes inline <script> tags ---
2029
- function HtmlBlock({ content, className, style, formState }) {
2030
- const ref = React.useRef(null);
2031
- const executedRef = React.useRef(new Set());
2032
- // Template interpolation: replace {{field_id}} with form state values
2033
- const interpolated = React.useMemo(() =>
2034
- (content || '').replace(/{{(w+)}}/g, (_, id) => formState?.[id] ?? ''),
2035
- [content, formState]
2036
- );
2037
- React.useEffect(() => {
2038
- const container = ref.current;
2039
- if (!container) return;
2040
- const scripts = container.querySelectorAll('script');
2041
- scripts.forEach((orig) => {
2042
- const key = orig.src || orig.textContent || '';
2043
- if (executedRef.current.has(key)) return;
2044
- executedRef.current.add(key);
2045
- if (orig.src) {
2046
- const s = document.createElement('script');
2047
- s.src = orig.src;
2048
- if (orig.type) s.type = orig.type;
2049
- s.async = true;
2050
- container.appendChild(s);
2051
- } else if (orig.textContent) {
2052
- try { new Function(orig.textContent)(); }
2053
- catch (e) { console.error('[CatalogKit:dev] Inline script error:', e); }
2054
- }
2055
- });
2056
- }, [interpolated]);
2057
- return h('div', { ref, className: 'prose prose-sm max-w-none ' + (className || ''), style, dangerouslySetInnerHTML: { __html: interpolated } });
2058
- }
2059
-
2060
- function ActionButton({ action, themeColor, onAction }) {
2061
- const st = action.style || 'primary';
2062
- const hasSide = !!action.side_statement;
2063
- const btnProps = st === 'primary'
2064
- ? { className: 'cf-btn-primary text-white', style: { backgroundColor: themeColor } }
2065
- : st === 'secondary'
2066
- ? { className: 'cf-btn-secondary', style: { borderColor: themeColor, color: themeColor } }
2067
- : st === 'danger'
2068
- ? { className: 'cf-btn-primary text-white', style: { backgroundColor: '#ef4444' } }
2069
- : { className: 'cf-btn-ghost', style: { color: themeColor + 'cc' } };
2070
- const btn = h('button', {
2071
- ...btnProps,
2072
- className: btnProps.className + (hasSide ? ' flex-1' : ' w-full') + ' flex items-center justify-center',
2073
- onClick: () => onAction(action),
2074
- },
2075
- action.icon ? h('span', { className: 'mr-2' }, action.icon) : null,
2076
- action.label
2077
- );
2078
- return h('div', { className: 'w-full' },
2079
- 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,
2080
- action.reassurance ? h('p', { className: 'text-xs text-gray-400 mt-1.5 text-center' }, action.reassurance) : null
2081
- );
2082
- }
2083
-
2084
- function VideoPlayer({ comp, isCover, compClass, compStyle }) {
2085
- const props = comp.props || {};
2086
- const videoRef = React.useRef(null);
2087
- React.useEffect(() => {
2088
- const video = videoRef.current;
2089
- if (!video) return;
2090
- const handler = () => {
2091
- const pct = video.duration ? Math.round((video.currentTime / video.duration) * 100) : 0;
2092
- window.__videoWatchState = window.__videoWatchState || {};
2093
- window.__videoWatchState[comp.id] = { watch_percent: pct, playing: !video.paused, duration: video.duration };
2094
- };
2095
- video.addEventListener('timeupdate', handler);
2096
- return () => video.removeEventListener('timeupdate', handler);
2097
- }, []);
2098
- return h('div', { className: 'w-full ' + compClass, style: compStyle },
2099
- h('div', { className: 'relative w-full overflow-hidden rounded-2xl shadow-lg' },
2100
- h('video', { ref: videoRef, src: props.hls_url || props.src, controls: true, playsInline: true, poster: props.poster, className: 'w-full' })
2101
- )
2102
- );
2103
- }
2104
-
2105
- function StickyBottomBar({ config, page, formState, cartItems, themeColor, onNext, onAction, onFieldAndNavigate, onBack, historyLen }) {
2106
- const [visible, setVisible] = React.useState(!config.delay_ms);
2107
- const [scrollDir, setScrollDir] = React.useState('down');
2108
- React.useEffect(() => {
2109
- if (!config.delay_ms) return;
2110
- const timer = setTimeout(() => setVisible(true), config.delay_ms);
2111
- return () => clearTimeout(timer);
2112
- }, [config.delay_ms]);
2113
- React.useEffect(() => {
2114
- if (config.scroll_behavior !== 'show_on_up') return;
2115
- let lastY = window.scrollY;
2116
- const handler = () => { const dir = window.scrollY > lastY ? 'down' : 'up'; setScrollDir(dir); lastY = window.scrollY; };
2117
- window.addEventListener('scroll', handler, { passive: true });
2118
- return () => window.removeEventListener('scroll', handler);
2119
- }, []);
2120
- const show = visible && (config.scroll_behavior !== 'show_on_up' || scrollDir === 'up');
2121
- const interpolate = (text) => text ? text.replace(/\\{\\{(\\w+)\\}\\}/g, (_, id) => formState[id] ?? '') : text;
2122
- const bgStyles = {
2123
- solid: { backgroundColor: 'white', borderTop: '1px solid #e5e7eb' },
2124
- glass: { backgroundColor: 'rgba(255,255,255,0.85)', backdropFilter: 'blur(16px)', borderTop: '1px solid rgba(0,0,0,0.05)' },
2125
- glass_dark: { backgroundColor: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(16px)', color: 'white' },
2126
- gradient: { background: 'linear-gradient(135deg, ' + themeColor + ' 0%, ' + themeColor + 'dd 100%)', color: 'white' },
2127
- };
2128
- const dispatchAction = (act) => {
2129
- const cmd = act?.action || 'next';
2130
- if (cmd === 'next') { onNext(); return; }
2131
- if (cmd.startsWith('action:')) {
2132
- const actionId = cmd.slice(7);
2133
- const action = page.actions?.find(a => a.id === actionId);
2134
- if (action) onAction(action); else onNext();
2135
- return;
2136
- }
2137
- if (cmd.startsWith('field:')) {
2138
- const parts = cmd.slice(6).split(':');
2139
- if (parts.length >= 2) { onFieldAndNavigate(parts[0], parts.slice(1).join(':')); }
2140
- return;
2141
- }
2142
- onNext();
2143
- };
2144
- const primaryLabel = config.primary?.label
2145
- ? interpolate(config.primary.label)
2146
- : config.button_text || page.submit_label || 'Continue';
2147
- const secondaryAction = config.secondary;
2148
- const secondaryLabel = secondaryAction?.label ? interpolate(secondaryAction.label) : null;
2149
- return h('div', {
2150
- className: 'cf-sticky-bar' + (show ? '' : ' hidden'),
2151
- style: bgStyles[config.style || 'solid'] || bgStyles.solid,
931
+ <!-- Mount CatalogRenderer from renderer bundle -->
932
+ <script type="module">
933
+ import React from "react";
934
+ import { createRoot } from "react-dom/client";
935
+ import { CatalogRenderer } from "/__renderer/index.js";
936
+
937
+ const schema = ${schemaJson};
938
+ const port = ${port};
939
+
940
+ // --- Dev Services ---
941
+ const devTracker = {
942
+ track(payload) {
943
+ // Post to dev event endpoint
944
+ fetch("/__dev_event", {
945
+ method: "POST",
946
+ headers: { "Content-Type": "application/json" },
947
+ body: JSON.stringify({ type: payload.event_type, data: payload, ts: Date.now() }),
948
+ }).catch(() => {});
2152
949
  },
2153
- h('div', { className: 'max-w-2xl mx-auto px-6 py-4 flex items-center justify-between gap-4' },
2154
- config.show_back && historyLen > 0
2155
- ? h('button', { className: 'text-sm text-gray-500 hover:text-gray-700', onClick: onBack }, '\\u2190 Back') : null,
2156
- h('div', { className: 'flex-1 text-center' },
2157
- config.subtitle ? h('p', { className: 'text-xs opacity-60 mb-0.5' }, interpolate(config.subtitle)) : null
2158
- ),
2159
- h('div', { className: 'flex items-center gap-3' },
2160
- config.cart_badge && cartItems.length > 0
2161
- ? 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,
2162
- secondaryLabel
2163
- ? h('button', {
2164
- className: 'text-sm font-medium hover:opacity-80 transition-opacity',
2165
- style: { color: config.style === 'glass_dark' ? 'rgba(255,255,255,0.6)' : '#6b7280' },
2166
- onClick: () => dispatchAction(secondaryAction),
2167
- }, secondaryLabel)
2168
- : null,
2169
- h('button', {
2170
- className: 'cf-btn-primary text-white text-sm',
2171
- style: { backgroundColor: config.style === 'gradient' ? 'rgba(255,255,255,0.9)' : themeColor, color: config.style === 'gradient' ? themeColor : 'white' },
2172
- disabled: config.disabled,
2173
- onClick: () => dispatchAction(config.primary),
2174
- }, primaryLabel)
2175
- )
2176
- )
2177
- );
2178
- }
2179
-
2180
- // --- Dev Config ---
2181
- const devConfig = JSON.parse(document.getElementById('__dev_config').textContent);
950
+ };
2182
951
 
2183
- // --- Local Event Emitter ---
2184
- const devEvents = {
2185
- _sse: null,
2186
- init() {
2187
- this._sse = new EventSource('/__dev_events_stream');
2188
- this._sse.onerror = () => {};
2189
- },
2190
- emit(type, data) {
2191
- const event = { type, timestamp: new Date().toISOString(), data };
2192
- // Fire to local SSE listeners (agents can listen via /__dev_events_stream)
2193
- fetch('/__dev_event', {
2194
- method: 'POST',
2195
- headers: { 'Content-Type': 'application/json' },
952
+ const services = {
953
+ tracker: devTracker,
954
+ apiBaseUrl: "http://localhost:" + port,
955
+ onEvent(event) {
956
+ fetch("/__dev_event", {
957
+ method: "POST",
958
+ headers: { "Content-Type": "application/json" },
2196
959
  body: JSON.stringify(event),
2197
960
  }).catch(() => {});
2198
- // Also dispatch as browser event for debug panel
2199
- window.dispatchEvent(new CustomEvent('devEvent', { detail: event }));
2200
- }
2201
- };
2202
- devEvents.init();
2203
-
2204
- // --- Payment Component ---
2205
- function PaymentComponent({ comp, formState, isCover, compClass, compStyle }) {
2206
- const props = comp.props || {};
2207
- const [loading, setLoading] = React.useState(false);
2208
- const [error, setError] = React.useState(null);
2209
-
2210
- const handleCheckout = React.useCallback(async () => {
2211
- devEvents.emit('checkout_started', { component_id: comp.id, amount: props.amount, currency: props.currency });
2212
- setLoading(true);
2213
- setError(null);
2214
- try {
2215
- const res = await fetch('/__dev_checkout', {
2216
- method: 'POST',
2217
- headers: { 'Content-Type': 'application/json' },
2218
- body: JSON.stringify({
2219
- line_items: [{
2220
- title: props.title || comp.id,
2221
- amount_cents: props.amount,
2222
- currency: (props.currency || 'usd').toLowerCase(),
2223
- quantity: 1,
2224
- stripe_price_id: props.stripe_price_id,
2225
- payment_type: props.checkout_type === 'redirect' ? 'one_time' : (schema.settings?.checkout?.payment_type || 'one_time'),
2226
- }],
2227
- form_state: formState,
2228
- catalog_slug: schema.slug,
2229
- }),
2230
- });
2231
- const data = await res.json();
2232
- if (data.session_url) {
2233
- devEvents.emit('checkout_redirect', { session_id: data.session_id });
2234
- window.location.href = data.session_url;
2235
- } else if (data.error) {
2236
- setError(data.error);
2237
- }
2238
- } catch (err) {
2239
- setError(err.message || 'Checkout failed');
2240
- } finally {
2241
- setLoading(false);
2242
- }
2243
- }, [comp.id, props, formState]);
2244
-
2245
- // No Stripe key \u2014 show informative stub
2246
- if (!devConfig.stripeEnabled) {
2247
- return h('div', { className: 'checkout-stub ' + compClass, style: compStyle },
2248
- h('h3', null, 'Stripe Checkout'),
2249
- h('p', null, 'Add STRIPE_SECRET_KEY to your .env to enable real checkout in dev.'),
2250
- props.amount ? h('p', { className: 'mt-2 font-bold text-lg' },
2251
- (props.currency || 'USD').toUpperCase() + ' ' + (props.amount / 100).toFixed(2)
2252
- ) : null,
2253
- h('details', { className: 'mt-3 text-left', style: { fontSize: '11px', color: '#92400e' } },
2254
- h('summary', { style: { cursor: 'pointer' } }, 'Checkout payload'),
2255
- h('pre', { style: { whiteSpace: 'pre-wrap', marginTop: '4px' } },
2256
- JSON.stringify({ amount: props.amount, currency: props.currency, stripe_price_id: props.stripe_price_id, payment_type: schema.settings?.checkout?.payment_type }, null, 2)
2257
- )
2258
- )
2259
- );
2260
- }
2261
-
2262
- // Real Stripe checkout
2263
- return h('div', { className: compClass, style: compStyle },
2264
- h('button', {
2265
- className: 'cf-btn-primary w-full text-white',
2266
- style: { backgroundColor: themeColor, opacity: loading ? 0.7 : 1 },
2267
- onClick: handleCheckout,
2268
- disabled: loading,
2269
- },
2270
- loading ? 'Redirecting to Stripe...' : (props.button_text || (props.amount ? ((props.currency || 'USD').toUpperCase() + ' ' + (props.amount / 100).toFixed(2) + ' \u2014 Pay Now') : 'Checkout'))
2271
- ),
2272
- error ? h('p', { className: 'text-red-500 text-sm mt-2 text-center' }, error) : null
2273
- );
2274
- }
2275
-
2276
- // --- Cart Components ---
2277
- const cartIconPath = 'M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 00-16.536-1.84M7.5 14.25L5.106 5.272M6 20.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm12.75 0a.75.75 0 11-1.5 0 .75.75 0 011.5 0z';
2278
-
2279
- function CartButton({ itemCount, onClick }) {
2280
- if (itemCount === 0) return null;
2281
- const cartPos = (schema.settings?.cart?.position) || 'bottom-right';
2282
- 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';
2283
- return h('button', {
2284
- onClick,
2285
- 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,
2286
- style: { backgroundColor: themeColor, fontFamily: 'var(--font-display)' },
2287
961
  },
2288
- h('svg', { className: 'w-5 h-5', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2289
- h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: cartIconPath })
2290
- ),
2291
- h('span', null, itemCount),
2292
- h('span', { className: 'text-sm opacity-80' }, itemCount === 1 ? 'item' : 'items')
2293
- );
2294
- }
2295
-
2296
- function CartDrawer({ items, isOpen, onToggle, onRemove, onCheckout }) {
2297
- React.useEffect(() => {
2298
- if (!isOpen) return;
2299
- const handleKey = (e) => { if (e.key === 'Escape') onToggle(); };
2300
- window.addEventListener('keydown', handleKey);
2301
- return () => window.removeEventListener('keydown', handleKey);
2302
- }, [isOpen, onToggle]);
2303
-
2304
- return h(React.Fragment, null,
2305
- // Backdrop
2306
- h('div', {
2307
- className: 'fixed inset-0 z-[95] bg-black/30 backdrop-blur-sm transition-opacity duration-300 ' + (isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'),
2308
- onClick: onToggle,
2309
- }),
2310
- // Drawer
2311
- h('div', {
2312
- className: 'fixed top-0 right-0 bottom-0 z-[96] w-full max-w-md bg-white shadow-2xl transition-transform duration-300 ease-out flex flex-col ' + (isOpen ? 'translate-x-0' : 'translate-x-full'),
2313
- style: { fontFamily: 'var(--font-display)' },
2314
- },
2315
- // Header
2316
- h('div', { className: 'flex items-center justify-between px-6 py-5 border-b border-gray-100' },
2317
- h('div', { className: 'flex items-center gap-3' },
2318
- h('div', { className: 'w-9 h-9 rounded-xl flex items-center justify-center', style: { backgroundColor: themeColor + '12' } },
2319
- h('svg', { className: 'w-5 h-5', style: { color: themeColor }, fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2320
- h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: cartIconPath })
2321
- )
2322
- ),
2323
- h('div', null,
2324
- h('h2', { className: 'text-lg font-bold text-gray-900' }, schema.settings?.cart?.title || 'Your Cart'),
2325
- h('p', { className: 'text-xs text-gray-400' }, items.length + ' ' + (items.length === 1 ? 'item' : 'items') + ' selected')
2326
- )
2327
- ),
2328
- h('button', { onClick: onToggle, className: 'w-9 h-9 flex items-center justify-center rounded-xl text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-all' },
2329
- h('svg', { className: 'w-5 h-5', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2330
- h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M6 18L18 6M6 6l12 12' })
2331
- )
2332
- )
2333
- ),
2334
- // Items
2335
- h('div', { className: 'flex-1 overflow-y-auto px-6 py-4' },
2336
- items.length === 0
2337
- ? h('div', { className: 'flex flex-col items-center justify-center h-full text-center py-16' },
2338
- h('div', { className: 'w-16 h-16 rounded-2xl flex items-center justify-center mb-4', style: { backgroundColor: themeColor + '08' } },
2339
- h('svg', { className: 'w-8 h-8 text-gray-300', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 1.5 },
2340
- h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: cartIconPath })
2341
- )
2342
- ),
2343
- h('p', { className: 'text-gray-400 text-sm font-medium' }, 'No offers accepted yet'),
2344
- h('p', { className: 'text-gray-300 text-xs mt-1' }, 'Accept offers as you browse to add them here')
2345
- )
2346
- : h('div', { className: 'space-y-3' },
2347
- ...items.map(item => h('div', { key: item.offer_id, className: 'group flex items-start gap-4 p-4 rounded-2xl border border-gray-100 bg-gray-50/50 hover:bg-white hover:border-gray-200 hover:shadow-sm transition-all duration-200' },
2348
- item.image ? h('img', { src: item.image, alt: item.title, className: 'w-14 h-14 rounded-xl object-cover flex-shrink-0' }) : null,
2349
- h('div', { className: 'flex-1 min-w-0' },
2350
- h('h3', { className: 'text-sm font-semibold text-gray-900 truncate' }, item.title),
2351
- item.price_display ? h('p', { className: 'text-sm font-bold mt-0.5', style: { color: themeColor } }, item.price_display) : null,
2352
- item.price_subtext ? h('p', { className: 'text-xs text-gray-400 mt-0.5' }, item.price_subtext) : null
2353
- ),
2354
- h('button', { onClick: () => onRemove(item.offer_id), className: 'w-7 h-7 flex-shrink-0 flex items-center justify-center rounded-lg text-gray-300 hover:text-red-500 hover:bg-red-50 transition-all opacity-0 group-hover:opacity-100' },
2355
- h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2356
- h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0' })
2357
- )
2358
- )
2359
- ))
2360
- )
2361
- ),
2362
- // Footer
2363
- items.length > 0 ? h('div', { className: 'border-t border-gray-100 px-6 py-5 space-y-4' },
2364
- h('div', { className: 'flex items-center justify-between' },
2365
- h('span', { className: 'text-sm font-medium text-gray-500' }, items.length + ' ' + (items.length === 1 ? 'offer' : 'offers') + ' selected'),
2366
- h('div', { className: 'flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold', style: { backgroundColor: themeColor + '10', color: themeColor } },
2367
- h('svg', { className: 'w-3.5 h-3.5', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2368
- 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' })
2369
- ),
2370
- 'Added to order'
2371
- )
2372
- ),
2373
- onCheckout ? h('button', {
2374
- className: 'cf-btn-primary w-full text-white flex items-center justify-center gap-2',
2375
- style: { backgroundColor: themeColor },
2376
- onClick: onCheckout,
2377
- },
2378
- h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2379
- 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' })
2380
- ),
2381
- schema.settings?.cart?.checkout_button_text || 'Proceed to Checkout'
2382
- ) : null
2383
- ) : null
2384
- )
2385
- );
2386
- }
2387
-
2388
- function CartCheckoutButton({ items, themeColor }) {
2389
- const [loading, setLoading] = React.useState(false);
2390
- const [error, setError] = React.useState(null);
2391
-
2392
- const handleCheckout = React.useCallback(async () => {
2393
- const state = window.__devDebugState;
2394
- devEvents.emit('cart_checkout_started', { items: items.map(i => ({ offer_id: i.offer_id, title: i.title })) });
2395
- setLoading(true);
2396
- setError(null);
2397
- try {
2398
- const res = await fetch('/__dev_checkout', {
2399
- method: 'POST',
2400
- headers: { 'Content-Type': 'application/json' },
2401
- body: JSON.stringify({
2402
- line_items: items.map(item => ({
2403
- title: item.title,
2404
- amount_cents: item.amount_cents,
2405
- currency: item.currency || 'usd',
2406
- quantity: 1,
2407
- stripe_price_id: item.stripe_price_id,
2408
- payment_type: schema.settings?.checkout?.payment_type || 'one_time',
2409
- })),
2410
- form_state: state?.formState || {},
2411
- catalog_slug: schema.slug,
2412
- }),
2413
- });
2414
- const data = await res.json();
2415
- if (data.session_url) {
2416
- devEvents.emit('checkout_redirect', { session_id: data.session_id });
2417
- window.location.href = data.session_url;
2418
- } else if (data.error) {
2419
- setError(data.error);
2420
- }
2421
- } catch (err) {
2422
- setError(err.message || 'Checkout failed');
2423
- } finally {
2424
- setLoading(false);
2425
- }
2426
- }, [items]);
2427
-
2428
- if (!devConfig.stripeEnabled) {
2429
- return h('div', { className: 'checkout-stub' },
2430
- h('h3', null, 'Checkout'),
2431
- h('p', null, 'Add STRIPE_SECRET_KEY to .env for real checkout.'),
2432
- h('details', { className: 'mt-2 text-left', style: { fontSize: '11px', color: '#92400e' } },
2433
- h('summary', { style: { cursor: 'pointer' } }, 'Cart payload'),
2434
- h('pre', { style: { whiteSpace: 'pre-wrap', marginTop: '4px' } },
2435
- JSON.stringify(items.map(i => ({ offer_id: i.offer_id, title: i.title, price: i.price_display })), null, 2)
2436
- )
2437
- )
2438
- );
2439
- }
2440
-
2441
- return h('div', { className: 'space-y-2' },
2442
- h('button', {
2443
- className: 'cf-btn-primary w-full text-white',
2444
- style: { backgroundColor: themeColor, opacity: loading ? 0.7 : 1 },
2445
- onClick: handleCheckout,
2446
- disabled: loading,
2447
- }, loading ? 'Redirecting to Stripe...' : 'Proceed to Checkout'),
2448
- error ? h('p', { className: 'text-red-500 text-sm text-center' }, error) : null
2449
- );
2450
- }
2451
-
2452
- // --- Main App ---
2453
- function CatalogPreview({ catalog: rawCatalog }) {
2454
- // --- Variant resolution ---
2455
- const catalog = React.useMemo(() => {
2456
- const urlParams = Object.fromEntries(new URLSearchParams(window.location.search));
2457
- const variantSlug = urlParams.variant;
2458
- const catalogHints = rawCatalog.hints || {};
2459
- let hints = { ...(catalogHints.defaults || {}) };
2460
- if (variantSlug && catalogHints.variants) {
2461
- const variant = catalogHints.variants.find(v => v.slug === variantSlug);
2462
- if (variant?.hints) hints = { ...hints, ...variant.hints };
2463
- }
2464
- devContext.hints = hints;
2465
- if (Object.keys(hints).length === 0) return rawCatalog;
2466
- const resolvedPages = JSON.parse(JSON.stringify(rawCatalog.pages || {}));
2467
- for (const page of Object.values(resolvedPages)) {
2468
- for (const comp of page.components || []) {
2469
- comp.props = resolveComponentVariants(comp.props || {}, hints);
2470
- }
2471
- if (page.actions) {
2472
- for (const action of page.actions) Object.assign(action, resolveComponentVariants(action, hints));
2473
- }
2474
- }
2475
- return { ...rawCatalog, pages: resolvedPages };
2476
- }, [rawCatalog]);
2477
-
2478
- const pages = catalog.pages || {};
2479
- const pageKeys = Object.keys(pages);
2480
- const routing = catalog.routing || {};
2481
- const entryPageId = routing.entry || pageKeys[0] || null;
2482
- const saveKey = 'cf_resume_' + (catalog.slug || 'dev');
2483
-
2484
- const [currentPageId, setCurrentPageId] = React.useState(entryPageId);
2485
- // --- Prefill / default values ---
2486
- const [formState, setFormState] = React.useState(() => {
2487
- const state = {};
2488
- for (const page of Object.values(pages)) {
2489
- for (const comp of page.components || []) {
2490
- if (comp.props?.default_value != null) state[comp.id] = comp.props.default_value;
2491
- if ((comp.type === 'checkboxes' || comp.type === 'multiple_choice') && Array.isArray(comp.props?.options)) {
2492
- for (const opt of comp.props.options) {
2493
- if (!opt.inputs) continue;
2494
- for (const input of opt.inputs) {
2495
- const nd = input.props?.default_value ?? input.default_value;
2496
- if (nd != null) state[comp.id + '.' + opt.value + '.' + input.id] = nd;
2497
- }
2498
- }
2499
- }
2500
- }
2501
- }
2502
- const mappings = catalog.settings?.url_params?.prefill_mappings;
2503
- if (mappings) {
2504
- const urlParams = Object.fromEntries(new URLSearchParams(window.location.search));
2505
- for (const [param, compId] of Object.entries(mappings)) {
2506
- if (urlParams[param]) state[compId] = urlParams[param];
2507
- }
2508
- }
2509
- return state;
2510
- });
2511
- const [history, setHistory] = React.useState([]);
2512
- const [cartItems, setCartItems] = React.useState([]);
2513
- const [cartOpen, setCartOpen] = React.useState(false);
2514
- const [showCheckout, setShowCheckout] = React.useState(false);
2515
- const [validationErrors, setValidationErrors] = React.useState([]);
2516
- const [savedSession, setSavedSession] = React.useState(null);
2517
- const [showResumeModal, setShowResumeModal] = React.useState(false);
2518
- const [submitted, setSubmitted] = React.useState(() => {
2519
- const params = new URLSearchParams(window.location.search);
2520
- return params.get('checkout') === 'success';
2521
- });
2522
- const formStateRef = React.useRef(formState);
2523
- formStateRef.current = formState;
2524
- const historyRef = React.useRef(history);
2525
- historyRef.current = history;
2526
- const autoAdvanceTimer = React.useRef(null);
2527
- const globalsRef = React.useRef({});
2528
- const [compPropOverrides, setCompPropOverrides] = React.useState({});
2529
-
2530
- // --- Cart logic ---
2531
- const addToCart = React.useCallback((pageId) => {
2532
- const pg = pages[pageId];
2533
- if (!pg?.offer) return;
2534
- const offer = pg.offer;
2535
- setCartItems(prev => {
2536
- if (prev.some(item => item.offer_id === offer.id)) return prev;
2537
- return [...prev, {
2538
- offer_id: offer.id,
2539
- page_id: pageId,
2540
- title: offer.title || pageId,
2541
- price_display: offer.price_display,
2542
- price_subtext: offer.price_subtext,
2543
- image: offer.image,
2544
- stripe_price_id: offer.stripe_price_id,
2545
- amount_cents: offer.amount_cents,
2546
- currency: offer.currency,
2547
- payment_type: offer.payment_type,
2548
- interval: offer.interval,
2549
- }];
2550
- });
2551
- }, [pages]);
2552
-
2553
- const removeFromCart = React.useCallback((offerId) => {
2554
- setCartItems(prev => prev.filter(item => item.offer_id !== offerId));
2555
- // Clear accept_field so it doesn't re-add
2556
- for (const [pid, pg] of Object.entries(pages)) {
2557
- if (pg.offer?.id === offerId && pg.offer.accept_field) {
2558
- setFormState(prev => { const next = { ...prev }; delete next[pg.offer.accept_field]; return next; });
2559
- }
2560
- }
2561
- }, [pages]);
2562
-
2563
- const toggleCart = React.useCallback(() => setCartOpen(prev => !prev), []);
2564
-
2565
- // Detect offer acceptance from field changes
2566
- React.useEffect(() => {
2567
- for (const [pageId, pg] of Object.entries(pages)) {
2568
- const offer = pg.offer;
2569
- if (!offer?.accept_field) continue;
2570
- const fieldValue = formState[offer.accept_field];
2571
- const acceptValue = offer.accept_value || 'accept';
2572
- if (fieldValue === acceptValue) {
2573
- if (!cartItems.some(item => item.offer_id === offer.id)) addToCart(pageId);
2574
- } else {
2575
- if (cartItems.some(item => item.offer_id === offer.id) && fieldValue !== undefined) removeFromCart(offer.id);
2576
- }
2577
- }
2578
- }, [formState, pages, cartItems, addToCart, removeFromCart]);
2579
-
2580
- // Expose navigation for mindmap + emit page_view + fire CatalogKit events
2581
- React.useEffect(() => {
2582
- window.__devNavigateTo = (id) => { setCurrentPageId(id); setHistory([]); window.scrollTo({ top: 0, behavior: 'smooth' }); };
2583
- window.__devSetCurrentPage && window.__devSetCurrentPage(currentPageId);
2584
- devEvents.emit('page_view', { page_id: currentPageId, catalog_slug: catalog.slug });
2585
- setValidationErrors([]);
2586
- // CatalogKit pageenter event
2587
- const listeners = window.__catalogKitListeners || {};
2588
- for (const key of ['pageenter', 'pageenter:' + currentPageId]) {
2589
- const set = listeners[key]; if (!set?.size) continue;
2590
- for (const cb of set) { try { cb({ pageId: currentPageId }); } catch (e) { console.error('[CatalogKit]', key, e); } }
2591
- }
2592
- }, [currentPageId]);
2593
-
2594
- // Expose debug state
2595
- React.useEffect(() => {
2596
- const edges = (routing.edges || []).filter(e => e.from === currentPageId);
2597
- window.__devDebugState = { currentPageId, formState, cartItems, edges };
2598
- window.dispatchEvent(new CustomEvent('devStateUpdate'));
2599
- }, [currentPageId, formState, cartItems, routing]);
2600
-
2601
- // --- Browser history (pushState / popstate) ---
2602
- React.useEffect(() => {
2603
- window.history.replaceState({ pageId: entryPageId, history: [] }, '');
2604
- const onPopState = (e) => {
2605
- const pageId = e.state?.pageId;
2606
- const prevHistory = e.state?.history || [];
2607
- if (pageId && pages[pageId]) {
2608
- setCurrentPageId(pageId);
2609
- setHistory(prevHistory);
2610
- setValidationErrors([]);
2611
- window.scrollTo({ top: 0, behavior: 'smooth' });
2612
- }
2613
- };
2614
- window.addEventListener('popstate', onPopState);
2615
- return () => window.removeEventListener('popstate', onPopState);
2616
- }, []);
2617
-
2618
- // --- localStorage persistence ---
2619
- React.useEffect(() => {
2620
- if (!submitted) {
2621
- try { localStorage.setItem(saveKey, JSON.stringify({ formState, currentPageId, history })); } catch {}
2622
- }
2623
- }, [formState, currentPageId, history, submitted]);
2624
-
2625
- // --- Check for saved session on mount ---
2626
- React.useEffect(() => {
2627
- try {
2628
- const raw = localStorage.getItem(saveKey);
2629
- if (raw) {
2630
- const data = JSON.parse(raw);
2631
- if (data.currentPageId && data.currentPageId !== entryPageId && pages[data.currentPageId]) {
2632
- setSavedSession(data);
2633
- setShowResumeModal(true);
2634
- }
2635
- }
2636
- } catch {}
2637
- }, []);
2638
-
2639
- // --- Auto-skip pages ---
2640
- React.useEffect(() => {
2641
- const page = pages[currentPageId];
2642
- if (!page?.auto_skip) return;
2643
- 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']);
2644
- const visibleInputs = (page.components || []).filter(c => {
2645
- if (!inputTypes.has(c.type)) return false;
2646
- if (c.hidden || c.props?.hidden) return false;
2647
- if (c.visibility && !evaluateConditionGroup(c.visibility, formState, devContext)) return false;
2648
- return true;
2649
- });
2650
- const allFilled = visibleInputs.every(c => {
2651
- if (!c.props?.required) return true;
2652
- const val = formState[c.id];
2653
- return val != null && val !== '' && !(Array.isArray(val) && val.length === 0);
2654
- });
2655
- if (allFilled && visibleInputs.length > 0) {
2656
- devEvents.emit('page_auto_skipped', { page_id: currentPageId });
2657
- const nextId = getNextPage(routing, currentPageId, formState, devContext);
2658
- if (nextId && pages[nextId]) {
2659
- setCurrentPageId(nextId);
2660
- window.history.replaceState({ pageId: nextId, history: historyRef.current }, '');
2661
- }
2662
- }
2663
- }, [currentPageId]);
2664
-
2665
- // --- CatalogKit API (window.CatalogKit) ---
2666
- React.useEffect(() => {
2667
- const listeners = {};
2668
- const instance = {
2669
- getField: (id) => formStateRef.current[id],
2670
- getAllFields: () => ({ ...formStateRef.current }),
2671
- getPageId: () => currentPageId,
2672
- setField: (id, value) => onFieldChangeRef.current?.(id, value),
2673
- goNext: () => handleNextRef.current?.(),
2674
- goBack: () => handleBackRef.current?.(),
2675
- on: (event, cb) => { if (!listeners[event]) listeners[event] = new Set(); listeners[event].add(cb); },
2676
- off: (event, cb) => { listeners[event]?.delete(cb); },
2677
- openCart: () => setCartOpen(true),
2678
- closeCart: () => setCartOpen(false),
2679
- getCartItems: () => [...cartItems],
2680
- startCheckout: () => {
2681
- // Fire before_checkout listeners, then show checkout
2682
- let prevented = false;
2683
- const payload = { items: [...cartItems], preventDefault: () => { prevented = true; } };
2684
- const bcSet = listeners['before_checkout'];
2685
- if (bcSet) { for (const cb of bcSet) { try { cb(payload); } catch (e) { console.error('[CatalogKit] before_checkout error:', e); } } }
2686
- if (prevented) return;
2687
- setCartOpen(false);
2688
- setShowCheckout(true);
2689
- devEvents.emit('checkout_start', { item_count: cartItems.length });
2690
- },
2691
- getGlobal: (key) => globalsRef.current[key],
2692
- setGlobal: (key, value) => { globalsRef.current[key] = value; },
2693
- setComponentProp: (id, prop, value) => {
2694
- setCompPropOverrides(prev => ({ ...prev, [id]: { ...(prev[id] || {}), [prop]: value } }));
2695
- },
2696
- setValidationError: (id, msg) => {
2697
- setValidationErrors(prev => {
2698
- const next = prev.filter(e => e.componentId !== id);
2699
- if (msg) next.push({ componentId: id, message: msg });
2700
- return next;
2701
- });
2702
- },
2703
- };
2704
- 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 };
2705
- window.__catalogKitListeners = listeners;
2706
- return () => { delete window.CatalogKit; delete window.__catalogKitListeners; };
2707
- }, []);
2708
-
2709
- const page = currentPageId ? pages[currentPageId] : null;
2710
- const isCover = page?.layout === 'cover';
2711
- const isLastPage = (() => {
2712
- if (!routing.edges) return true;
2713
- return !routing.edges.some(e => e.from === currentPageId);
2714
- })();
2715
-
2716
- const navigateTo = React.useCallback((nextId) => {
2717
- // Fire CatalogKit pageexit
2718
- const ckListeners = window.__catalogKitListeners || {};
2719
- for (const key of ['pageexit', 'pageexit:' + currentPageId]) {
2720
- const set = ckListeners[key]; if (!set?.size) continue;
2721
- for (const cb of set) { try { cb({ pageId: currentPageId }); } catch (e) { console.error('[CatalogKit]', key, e); } }
2722
- }
2723
- if (nextId && pages[nextId]) {
2724
- const newHistory = [...history, currentPageId];
2725
- setHistory(newHistory);
2726
- setCurrentPageId(nextId);
2727
- window.history.pushState({ pageId: nextId, history: newHistory }, '');
2728
- window.scrollTo({ top: 0, behavior: 'smooth' });
2729
- } else {
2730
- // End of funnel \u2014 show checkout or completion
2731
- if (catalog.settings?.checkout) {
2732
- setShowCheckout(true);
2733
- devEvents.emit('checkout_start', { item_count: cartItems.length });
2734
- } else {
2735
- setSubmitted(true);
2736
- try { localStorage.removeItem(saveKey); } catch {}
2737
- devEvents.emit('form_submit', { page_id: currentPageId, form_state: formState });
2738
- }
2739
- window.scrollTo({ top: 0, behavior: 'smooth' });
2740
- }
2741
- }, [currentPageId, pages, catalog, cartItems, formState, history]);
2742
-
2743
- const onFieldChange = React.useCallback((id, value) => {
2744
- setFormState(prev => ({ ...prev, [id]: value }));
2745
- devEvents.emit('field_change', { field_id: id, value, page_id: currentPageId });
2746
- // Fire CatalogKit fieldchange (unscoped + scoped)
2747
- const ckListeners = window.__catalogKitListeners || {};
2748
- const fcPayload = { fieldId: id, value, pageId: currentPageId };
2749
- const fcSet = ckListeners['fieldchange']; if (fcSet?.size) for (const cb of fcSet) { try { cb(fcPayload); } catch {} }
2750
- const scopedSet = ckListeners['fieldchange:' + id]; if (scopedSet?.size) for (const cb of scopedSet) { try { cb(fcPayload); } catch {} }
2751
-
2752
- // Auto-advance: if page has auto_advance and this is a selection-type input
2753
- const pg = pages[currentPageId];
2754
- if (pg?.auto_advance && value != null && value !== '') {
2755
- const selectionTypes = ['multiple_choice', 'picture_choice', 'dropdown', 'checkboxes', 'multiselect'];
2756
- const comp = (pg.components || []).find(c => c.id === id);
2757
- if (comp && selectionTypes.includes(comp.type)) {
2758
- const newFormState = { ...formState, [id]: value };
2759
- const inputTypes = [...selectionTypes, 'short_text', 'long_text', 'rich_text', 'email', 'phone', 'url',
2760
- 'address', 'number', 'currency', 'date', 'datetime', 'time', 'date_range', 'switch', 'checkbox',
2761
- 'choice_matrix', 'ranking', 'star_rating', 'slider', 'opinion_scale', 'file_upload', 'signature',
2762
- 'password', 'location'];
2763
- const visibleInputs = (pg.components || []).filter(c => {
2764
- if (!inputTypes.includes(c.type)) return false;
2765
- if (c.hidden || c.props?.hidden) return false;
2766
- if (c.visibility && !evaluateConditionGroup(c.visibility, newFormState, devContext)) return false;
2767
- return true;
2768
- });
2769
- const lastInput = visibleInputs[visibleInputs.length - 1];
2770
- if (lastInput && lastInput.id === id) {
2771
- if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current);
2772
- autoAdvanceTimer.current = setTimeout(() => {
2773
- const nextId = getNextPage(routing, currentPageId, newFormState, devContext);
2774
- navigateTo(nextId);
2775
- }, 400);
2776
- }
2777
- }
2778
- }
2779
- }, [currentPageId, pages, formState, routing, navigateTo]);
2780
- const onFieldChangeRef = React.useRef(onFieldChange);
2781
- onFieldChangeRef.current = onFieldChange;
2782
-
2783
- // --- Validation ---
2784
- const runValidation = React.useCallback(() => {
2785
- const page = pages[currentPageId];
2786
- if (!page) return true;
2787
- const errors = validatePage(page, formState, devContext);
2788
- setValidationErrors(errors);
2789
- if (errors.length > 0) {
2790
- const el = document.querySelector('[data-component-id="' + errors[0].componentId + '"]');
2791
- if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
2792
- return false;
2793
- }
2794
- return true;
2795
- }, [currentPageId, pages, formState]);
2796
-
2797
- const handleNext = React.useCallback(() => {
2798
- // Validate before advancing
2799
- if (!runValidation()) return;
2800
- // Check video watch requirements
2801
- const currentPage = pages[currentPageId];
2802
- if (currentPage) {
2803
- for (const comp of currentPage.components || []) {
2804
- if (comp.type === 'video' && comp.props?.require_watch_percent) {
2805
- const vs = window.__videoWatchState?.[comp.id];
2806
- if (!vs || vs.watch_percent < comp.props.require_watch_percent) {
2807
- const el = document.querySelector('[data-component-id="' + comp.id + '"]');
2808
- if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
2809
- alert('Please watch at least ' + comp.props.require_watch_percent + '% of the video before continuing.');
2810
- return;
2811
- }
2812
- }
2813
- }
2814
- }
2815
- // Check if page has an offer \u2014 treat "Next" as an accept action
2816
- if (currentPage?.offer) {
2817
- if (!currentPage.offer.accept_field) addToCart(currentPageId);
2818
- }
2819
- // Fire CatalogKit beforenext event (scoped: "beforenext" + "beforenext:<pageId>")
2820
- let prevented = false;
2821
- let nextPageOverride;
2822
- const beforeNextPayload = {
2823
- pageId: currentPageId,
2824
- preventDefault: () => { prevented = true; },
2825
- setNextPage: (id) => { nextPageOverride = id; },
2826
- };
2827
- const ckListeners = window.__catalogKitListeners || {};
2828
- for (const key of ['beforenext', 'beforenext:' + currentPageId]) {
2829
- const set = ckListeners[key]; if (!set?.size) continue;
2830
- for (const cb of set) { try { cb(beforeNextPayload); } catch (e) { console.error('[CatalogKit]', key, e); } }
2831
- }
2832
- if (prevented) return;
2833
- if (nextPageOverride !== undefined) { navigateTo(nextPageOverride); return; }
2834
- const nextId = getNextPage(routing, currentPageId, formState, devContext);
2835
- navigateTo(nextId);
2836
- }, [currentPageId, routing, formState, pages, addToCart, navigateTo, runValidation]);
2837
- const handleNextRef = React.useRef(handleNext);
2838
- handleNextRef.current = handleNext;
2839
-
2840
- const handleBack = React.useCallback(() => {
2841
- if (history.length > 0) {
2842
- const prev = history[history.length - 1];
2843
- setHistory(h => h.slice(0, -1));
2844
- setCurrentPageId(prev);
2845
- window.scrollTo({ top: 0, behavior: 'smooth' });
2846
- }
2847
- }, [history]);
2848
- const handleBackRef = React.useRef(handleBack);
2849
- handleBackRef.current = handleBack;
2850
-
2851
- // --- Page Actions ---
2852
- const handleAction = React.useCallback((action) => {
2853
- devEvents.emit('action_click', { page_id: currentPageId, action_id: action.id });
2854
- if (action.redirect_url) { window.open(action.redirect_url, '_blank'); return; }
2855
- if (!runValidation()) return;
2856
- const currentPage = pages[currentPageId];
2857
- const currentOffer = currentPage?.offer;
2858
- if (currentOffer) {
2859
- const acceptValue = currentOffer.accept_value || 'accept';
2860
- if (action.id === acceptValue) addToCart(currentPageId);
2861
- }
2862
- const actionKey = '__action_' + currentPageId;
2863
- const newFormState = { ...formState, [actionKey]: action.id };
2864
- setFormState(newFormState);
2865
- const nextPageId = getNextPage(routing, currentPageId, newFormState, devContext);
2866
- navigateTo(nextPageId);
2867
- }, [currentPageId, routing, formState, pages, addToCart, navigateTo, runValidation]);
2868
-
2869
- // --- Field + Navigate (for sticky bar field: dispatch) ---
2870
- const handleFieldAndNavigate = React.useCallback((fieldId, value) => {
2871
- const newFormState = { ...formState, [fieldId]: value };
2872
- setFormState(newFormState);
2873
- const page = pages[currentPageId];
2874
- if (page) {
2875
- const errors = validatePage(page, newFormState, devContext);
2876
- setValidationErrors(errors);
2877
- if (errors.length > 0) {
2878
- const el = document.querySelector('[data-component-id="' + errors[0].componentId + '"]');
2879
- if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
2880
- return;
2881
- }
2882
- }
2883
- const nextPageId = getNextPage(routing, currentPageId, newFormState, devContext);
2884
- navigateTo(nextPageId);
2885
- }, [currentPageId, routing, formState, pages, navigateTo]);
2886
-
2887
- // --- Resume prompt ---
2888
- if (showResumeModal) {
2889
- return h('div', { className: 'cf-resume-backdrop' },
2890
- h('div', { className: 'bg-white rounded-2xl max-w-sm w-full mx-4 p-8 shadow-2xl text-center' },
2891
- h('h2', { className: 'text-xl font-bold text-gray-900 mb-2', style: { fontFamily: 'var(--font-display)' } }, 'Welcome back!'),
2892
- h('p', { className: 'text-gray-500 mb-6 text-sm' }, 'Pick up where you left off?'),
2893
- h('div', { className: 'space-y-3' },
2894
- h('button', {
2895
- className: 'cf-btn-primary w-full text-white', style: { backgroundColor: themeColor },
2896
- onClick: () => {
2897
- if (savedSession) {
2898
- setFormState(savedSession.formState || {});
2899
- setCurrentPageId(savedSession.currentPageId);
2900
- setHistory(savedSession.history || []);
2901
- window.history.replaceState({ pageId: savedSession.currentPageId, history: savedSession.history || [] }, '');
2902
- }
2903
- setShowResumeModal(false);
2904
- },
2905
- }, 'Resume'),
2906
- h('button', {
2907
- className: 'w-full px-4 py-2 text-sm text-gray-500 hover:text-gray-700 transition-colors',
2908
- onClick: () => { setShowResumeModal(false); try { localStorage.removeItem(saveKey); } catch {} },
2909
- }, 'Start Over')
2910
- )
2911
- )
2912
- );
2913
- }
2914
-
2915
- // --- Completion screen ---
2916
- if (submitted) {
2917
- const completionSettings = catalog.settings?.completion;
2918
- return h('div', { className: 'min-h-screen flex items-center justify-center', style: { background: 'linear-gradient(180deg, #f8f9fc 0%, #f0f2f7 100%)' } },
2919
- h('div', { className: 'max-w-lg mx-auto text-center px-6 py-20 page-enter-active' },
2920
- h('div', { className: 'w-20 h-20 mx-auto mb-6 rounded-full flex items-center justify-center', style: { backgroundColor: themeColor + '15' } },
2921
- h('svg', { className: 'w-10 h-10', style: { color: themeColor }, fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2922
- 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' })
2923
- )
2924
- ),
2925
- h('h1', { className: 'text-3xl font-bold text-gray-900 mb-3', style: { fontFamily: 'var(--font-display)' } },
2926
- completionSettings?.title || 'Thank You!'
2927
- ),
2928
- h('p', { className: 'text-gray-500 text-lg mb-8' },
2929
- completionSettings?.message || 'Your submission has been received.'
2930
- ),
2931
- completionSettings?.redirect_url ? h('a', {
2932
- href: completionSettings.redirect_url,
2933
- className: 'inline-flex items-center gap-2 px-6 py-3 rounded-xl text-white font-semibold transition-all hover:scale-[1.02]',
2934
- style: { backgroundColor: themeColor },
2935
- }, completionSettings.redirect_label || 'Continue') : null,
2936
- h('div', { className: 'mt-6' },
2937
- h('button', {
2938
- className: 'text-sm text-gray-400 hover:text-gray-600 transition-colors',
2939
- onClick: () => { setSubmitted(false); setCurrentPageId(routing.entry || pageKeys[0]); setHistory([]); setFormState({}); setCartItems([]); try { localStorage.removeItem(saveKey); } catch {} },
2940
- }, 'Start Over')
2941
- )
2942
- )
2943
- );
2944
- }
2945
-
2946
- // --- Checkout screen ---
2947
- if (showCheckout) {
2948
- const checkoutSettings = catalog.settings?.checkout || {};
2949
- const handleCheckoutBack = () => { setShowCheckout(false); };
2950
- const handleCheckoutContinue = () => {
2951
- setShowCheckout(false);
2952
- setSubmitted(true);
2953
- devEvents.emit('checkout_skip', { page_id: currentPageId });
2954
- };
2955
-
2956
- return h('div', { className: 'min-h-screen', style: { background: 'linear-gradient(180deg, #f8f9fc 0%, #f0f2f7 100%)', fontFamily: 'var(--font-display)' } },
2957
- // Header
2958
- h('div', { className: 'fixed top-[28px] left-0 right-0 z-50 bg-white/80 backdrop-blur-xl border-b border-gray-200/60' },
2959
- h('div', { className: 'max-w-5xl mx-auto flex items-center justify-between px-6 py-3' },
2960
- h('button', { onClick: handleCheckoutBack, className: 'flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 transition-colors' },
2961
- h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2962
- h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M15.75 19.5L8.25 12l7.5-7.5' })
2963
- ),
2964
- 'Back'
2965
- ),
2966
- h('div', { className: 'flex items-center gap-2' },
2967
- h('svg', { className: 'w-4 h-4 text-green-500', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2968
- 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' })
2969
- ),
2970
- h('span', { className: 'text-xs font-medium text-gray-400' }, 'Secure Checkout')
2971
- )
2972
- )
2973
- ),
2974
- h('div', { className: 'max-w-5xl mx-auto px-6 pt-24 pb-12' },
2975
- h('div', { className: 'text-center mb-10' },
2976
- h('h1', { className: 'text-3xl sm:text-4xl font-bold text-gray-900', style: { letterSpacing: '-0.025em' } },
2977
- checkoutSettings.title || 'Complete Your Order'
2978
- )
2979
- ),
2980
- h('div', { className: 'grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 items-start' },
2981
- // Order summary
2982
- h('div', { className: 'lg:col-span-7' },
2983
- h('div', { className: 'bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden' },
2984
- h('div', { className: 'px-6 py-4 border-b border-gray-50' },
2985
- h('h2', { className: 'text-sm font-bold text-gray-900 uppercase tracking-wide' }, 'Order Summary')
2986
- ),
2987
- h('div', { className: 'divide-y divide-gray-50' },
2988
- cartItems.length === 0
2989
- ? h('div', { className: 'flex items-center gap-5 px-6 py-5' },
2990
- h('div', { className: 'flex-1 min-w-0' },
2991
- h('h3', { className: 'text-base font-semibold text-gray-900' }, 'Complete Registration'),
2992
- h('p', { className: 'text-sm text-gray-400 mt-0.5' }, 'No offers selected \u2014 continue for free')
2993
- ),
2994
- h('p', { className: 'text-base font-bold', style: { color: themeColor } }, '$0')
2995
- )
2996
- : cartItems.map(item => h('div', { key: item.offer_id, className: 'flex items-center gap-5 px-6 py-5' },
2997
- 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,
2998
- h('div', { className: 'flex-1 min-w-0' },
2999
- h('h3', { className: 'text-base font-semibold text-gray-900' }, item.title),
3000
- item.price_subtext ? h('p', { className: 'text-sm text-gray-400 mt-0.5' }, item.price_subtext) : null
3001
- ),
3002
- item.price_display ? h('p', { className: 'text-base font-bold', style: { color: themeColor } }, item.price_display) : null,
3003
- 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' },
3004
- h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
3005
- h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M6 18L18 6M6 6l12 12' })
3006
- )
3007
- )
3008
- ))
3009
- )
3010
- )
3011
- ),
3012
- // Payment action
3013
- h('div', { className: 'lg:col-span-5' },
3014
- h('div', { className: 'lg:sticky lg:top-20 space-y-5' },
3015
- h('div', { className: 'bg-white rounded-2xl border border-gray-100 shadow-sm p-7 space-y-6' },
3016
- h('div', { className: 'text-sm text-gray-500' },
3017
- cartItems.length + ' ' + (cartItems.length === 1 ? 'item' : 'items')
3018
- ),
3019
- h(CartCheckoutButton, { items: cartItems, themeColor }),
3020
- h('button', {
3021
- onClick: handleCheckoutContinue,
3022
- className: 'w-full text-center text-sm text-gray-400 hover:text-gray-600 font-medium transition-colors py-1',
3023
- }, 'Continue without paying'),
3024
- h('div', { className: 'flex items-center justify-center gap-3 pt-1' },
3025
- h('span', { className: 'text-[10px] text-gray-400 font-medium' }, 'Powered by Stripe')
3026
- )
3027
- )
3028
- )
3029
- )
3030
- )
3031
- )
3032
- );
3033
- }
3034
-
3035
- if (!page) {
3036
- return h('div', { className: 'min-h-screen flex items-center justify-center bg-gray-50' },
3037
- h('p', { className: 'text-gray-500' }, 'No pages found in catalog.')
3038
- );
3039
- }
3040
-
3041
- const components = (page.components || []).filter(c => {
3042
- if (c.hidden || c.props?.hidden) return false;
3043
- if (c.visibility) { if (!evaluateConditionGroup(c.visibility, formState, devContext)) return false; }
3044
- if (c.prefill_mode === 'hidden' && formState[c.id] != null && formState[c.id] !== '') return false;
3045
- return true;
3046
- });
3047
- // Find the last input component for Enter-to-submit
3048
- const inputTypes = ['short_text', 'long_text', 'email', 'phone', 'url', 'number', 'password', 'currency', 'date', 'datetime', 'time', 'address'];
3049
- const lastInputComp = [...components].reverse().find(c => inputTypes.includes(c.type));
3050
- const lastInputId = lastInputComp?.id;
3051
-
3052
- const bgImage = page.background_image || catalog.settings?.theme?.background_image;
3053
-
3054
- // Cart UI (shared between cover and standard)
3055
- const cartSettings = catalog.settings?.cart || {};
3056
- const cartUI = h(React.Fragment, null,
3057
- !cartSettings.hide_button ? h(CartButton, { itemCount: cartItems.length, onClick: toggleCart }) : null,
3058
- h(CartDrawer, { items: cartItems, isOpen: cartOpen, onToggle: toggleCart, onRemove: removeFromCart, onCheckout: catalog.settings?.checkout ? () => { setCartOpen(false); setShowCheckout(true); devEvents.emit('checkout_start', { item_count: cartItems.length }); } : undefined })
3059
- );
3060
-
3061
- // Cover page layout
3062
- if (isCover) {
3063
- return h('div', { 'data-page-id': currentPageId },
3064
- cartUI,
3065
- h('div', {
3066
- className: 'cf-page cf-noise min-h-screen flex items-center justify-center relative overflow-hidden',
3067
- style: {
3068
- backgroundImage: bgImage ? 'url(' + bgImage + ')' : 'linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%)',
3069
- backgroundSize: 'cover', backgroundPosition: 'center',
3070
- },
3071
- },
3072
- h('div', { className: 'cf-cover-overlay absolute inset-0' }),
3073
- h('div', { className: 'cf-cover-content relative max-w-2xl mx-auto px-6 py-20 text-center text-white' },
3074
- h('div', { className: 'page-enter-active space-y-7 flex flex-col items-stretch text-center' },
3075
- ...components.map((comp, i) => {
3076
- if (comp.prefill_mode === 'readonly' && formState[comp.id] != null && formState[comp.id] !== '') {
3077
- return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: 'space-y-1' },
3078
- comp.props?.label ? h('label', { className: 'block text-base font-medium text-white/80' }, comp.props.label) : null,
3079
- 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]))
3080
- );
3081
- }
3082
- const fieldError = validationErrors.find(e => e.componentId === comp.id);
3083
- const submitHandler = comp.id === lastInputId ? handleNext : undefined;
3084
- return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: fieldError ? 'cf-field-error' : '' },
3085
- h(RenderComponent, { comp, isCover: true, formState, onFieldChange, onSubmit: submitHandler, propOverrides: compPropOverrides }),
3086
- fieldError ? h('p', { className: 'text-xs text-red-300 font-medium mt-1', role: 'alert' }, fieldError.message) : null
3087
- );
3088
- }),
3089
- // Actions or CTA button
3090
- page.actions?.length > 0
3091
- ? h('div', { className: 'mt-8 space-y-3' },
3092
- ...page.actions.map(action => h(ActionButton, { key: action.id, action, themeColor, onAction: handleAction }))
3093
- )
3094
- : h('div', { className: 'mt-8' },
3095
- h('button', {
3096
- className: 'cf-btn-primary w-full py-4 text-lg',
3097
- style: { backgroundColor: themeColor },
3098
- onClick: handleNext,
3099
- }, page.submit_label || (isLastPage ? 'Submit' : 'Get Started'))
3100
- )
3101
- )
3102
- )
3103
- )
3104
- );
3105
- }
3106
-
3107
- // Standard page layout
3108
- const topBar = catalog.settings?.top_bar;
3109
- const progressSteps = catalog.settings?.progress_steps;
3110
- const topBarEnabled = topBar?.enabled !== false && catalog.settings?.top_bar;
3111
-
3112
- return h('div', { 'data-page-id': currentPageId },
3113
- cartUI,
3114
- h('div', {
3115
- className: 'cf-page min-h-screen',
3116
- style: { background: 'linear-gradient(180deg, #f8f9fc 0%, #f0f2f7 100%)' },
3117
- },
3118
- // Top bar
3119
- topBarEnabled ? h('div', { className: 'cf-topbar fixed top-[28px] left-0 right-0 z-50 border-b border-gray-200/60' },
3120
- // Announcement strip
3121
- topBar?.announcement && (topBar.announcement.text || topBar.announcement.html)
3122
- ? h('div', {
3123
- className: 'cf-topbar-announcement text-center text-sm py-2 px-4 ' + (topBar.announcement.className || ''),
3124
- style: {
3125
- backgroundColor: topBar.announcement.bg_color || themeColor,
3126
- color: topBar.announcement.text_color || '#ffffff',
3127
- ...(topBar.announcement.style || {}),
3128
- },
3129
- ...(topBar.announcement.html ? { dangerouslySetInnerHTML: { __html: topBar.announcement.html } } : {}),
3130
- }, topBar.announcement.html ? undefined : topBar.announcement.text)
3131
- : null,
3132
- // Nav row
3133
- h('div', { className: 'relative flex items-center justify-center px-4 py-3 min-h-[48px]' },
3134
- history.length > 0 ? h('button', {
3135
- className: 'absolute left-3 top-1/2 -translate-y-1/2 w-9 h-9 flex items-center justify-center rounded-xl text-gray-400 hover:text-gray-700 hover:bg-gray-100/80 transition-all',
3136
- onClick: handleBack,
3137
- },
3138
- h('svg', { className: 'w-5 h-5', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
3139
- h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M15.75 19.5L8.25 12l7.5-7.5' })
3140
- )
3141
- ) : null,
3142
- topBar?.custom_html
3143
- ? h('div', { dangerouslySetInnerHTML: { __html: topBar.custom_html } })
3144
- : h(React.Fragment, null,
3145
- topBar?.title ? h('span', { className: 'text-sm text-gray-700 ' + (
3146
- topBar.title_weight === 'light' ? 'font-light' :
3147
- topBar.title_weight === 'normal' ? 'font-normal' :
3148
- topBar.title_weight === 'semibold' ? 'font-semibold' :
3149
- topBar.title_weight === 'bold' ? 'font-bold' :
3150
- 'font-medium'
3151
- ) }, topBar.title) : null,
3152
- progressSteps ? h(Stepper, { steps: progressSteps, currentPageId, themeColor }) : null,
3153
- ),
3154
- )
3155
- ) : null,
3156
-
3157
- // Page content
3158
- h('div', { className: 'max-w-2xl mx-auto px-6 pb-8', style: { paddingTop: topBarEnabled ? '100px' : '60px' } },
3159
- 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,
3160
- h('div', { className: 'page-enter-active space-y-5' },
3161
- ...components.map((comp, i) => {
3162
- if (comp.prefill_mode === 'readonly' && formState[comp.id] != null && formState[comp.id] !== '') {
3163
- return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: 'space-y-1' },
3164
- comp.props?.label ? h('label', { className: 'block text-base font-medium text-gray-700' }, comp.props.label) : null,
3165
- 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]))
3166
- );
3167
- }
3168
- const fieldError = validationErrors.find(e => e.componentId === comp.id);
3169
- const submitHandler = comp.id === lastInputId ? handleNext : undefined;
3170
- return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: fieldError ? 'cf-field-error' : '' },
3171
- h(RenderComponent, { comp, isCover: false, formState, onFieldChange, onSubmit: submitHandler, propOverrides: compPropOverrides }),
3172
- fieldError ? h('p', { className: 'text-xs text-red-500 font-medium mt-1', role: 'alert' }, fieldError.message) : null
3173
- );
3174
- }),
3175
- // Actions or navigation button
3176
- page.actions?.length > 0
3177
- ? h('div', { className: 'mt-8 space-y-3' },
3178
- ...page.actions.map(action => h(ActionButton, { key: action.id, action, themeColor, onAction: handleAction }))
3179
- )
3180
- : !page.hide_navigation ? h('div', { className: 'mt-8' },
3181
- h('button', {
3182
- className: 'cf-btn-primary inline-flex items-center gap-2 text-base',
3183
- style: { backgroundColor: themeColor },
3184
- onClick: handleNext,
3185
- },
3186
- page.submit_label || (isLastPage ? 'Submit' : 'Continue'),
3187
- !isLastPage ? h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2.5 },
3188
- h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3' })
3189
- ) : null
3190
- ),
3191
- page.submit_reassurance ? h('p', { className: 'text-xs text-gray-400 mt-1.5' }, page.submit_reassurance) : null,
3192
- ) : null,
3193
- ),
3194
- 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'),
3195
- ),
3196
- // Sticky bottom bar
3197
- (catalog.settings?.sticky_bar?.enabled || page.sticky_bar?.enabled)
3198
- ? h(StickyBottomBar, {
3199
- config: { ...(catalog.settings?.sticky_bar || {}), ...(page.sticky_bar || {}) },
3200
- page, formState, cartItems, themeColor,
3201
- onNext: handleNext, onAction: handleAction, onFieldAndNavigate: handleFieldAndNavigate, onBack: handleBack,
3202
- historyLen: history.length,
3203
- })
3204
- : null
3205
- )
3206
- );
3207
- }
3208
-
3209
- function Stepper({ steps, currentPageId, themeColor }) {
3210
- const currentStepIndex = steps.findIndex(s => s.pages && s.pages.includes(currentPageId));
3211
- return h('div', { className: 'flex items-center justify-center gap-0', style: { fontFamily: 'var(--font-display)' } },
3212
- ...steps.map((step, i) => h(React.Fragment, { key: step.id || i },
3213
- i > 0 ? h('div', { className: 'w-10 sm:w-16 h-0.5 rounded-full transition-all duration-500', style: { backgroundColor: i <= currentStepIndex ? themeColor : '#e8e9ee' } }) : null,
3214
- h('div', { className: 'flex items-center gap-1.5' },
3215
- h('div', { className: 'w-7 h-7 rounded-full flex items-center justify-center transition-all duration-300', style: {
3216
- backgroundColor: i <= currentStepIndex ? themeColor : '#f0f1f5',
3217
- boxShadow: i === currentStepIndex ? '0 0 0 4px ' + themeColor + '20' : 'none',
3218
- } },
3219
- i < currentStepIndex
3220
- ? h('svg', { className: 'w-3.5 h-3.5 text-white', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 3 },
3221
- h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M5 13l4 4L19 7' }))
3222
- : i === currentStepIndex
3223
- ? h('div', { className: 'w-2 h-2 rounded-full bg-white' })
3224
- : h('div', { className: 'w-2 h-2 rounded-full bg-gray-300' })
3225
- ),
3226
- h('span', {
3227
- className: 'text-[11px] font-bold tracking-wider uppercase transition-colors duration-300 ' + (i <= currentStepIndex ? '' : 'text-gray-400'),
3228
- style: i <= currentStepIndex ? { color: themeColor } : undefined,
3229
- }, step.label)
3230
- )
3231
- ))
3232
- );
3233
- }
3234
-
3235
- // --- Auto-reload via SSE ---
3236
- const evtSource = new EventSource('/__dev_sse');
3237
- evtSource.onmessage = (e) => {
3238
- if (e.data === 'connected') return; // ignore initial handshake
3239
- window.location.reload();
962
+ onPageChange(pageId) {
963
+ fetch("/__dev_event", {
964
+ method: "POST",
965
+ headers: { "Content-Type": "application/json" },
966
+ body: JSON.stringify({ type: "page_change", data: { page_id: pageId }, ts: Date.now() }),
967
+ }).catch(() => {});
968
+ },
3240
969
  };
3241
- evtSource.onerror = () => {};
3242
970
 
3243
971
  // --- Mount ---
3244
- const root = ReactDOM.createRoot(document.getElementById('catalog-root'));
3245
- root.render(h(CatalogPreview, { catalog: schema }));
3246
-
3247
- // --- Pages mindmap ---
3248
- (function initPagesMindmap() {
3249
- const btn = document.getElementById('pages-btn');
3250
- const overlay = document.getElementById('pages-overlay');
3251
- const closeBtn = document.getElementById('pages-close');
3252
- const container = document.getElementById('mindmap-container');
3253
- const zoomInBtn = document.getElementById('zoom-in');
3254
- const zoomOutBtn = document.getElementById('zoom-out');
3255
- const zoomFitBtn = document.getElementById('zoom-fit');
3256
- const zoomLevelEl = document.getElementById('zoom-level');
3257
- let currentPageRef = { id: schema.routing?.entry || Object.keys(schema.pages || {})[0] };
3258
-
3259
- // Pan & zoom state
3260
- let scale = 1, panX = 0, panY = 0;
3261
- let isDragging = false, dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0;
3262
- let hasDragged = false;
3263
- const MIN_SCALE = 0.15, MAX_SCALE = 3;
3264
-
3265
- function applyTransform() {
3266
- container.style.transform = 'translate(' + panX + 'px, ' + panY + 'px) scale(' + scale + ')';
3267
- zoomLevelEl.textContent = Math.round(scale * 100) + '%';
3268
- }
3269
-
3270
- function fitToView() {
3271
- const overlayRect = overlay.getBoundingClientRect();
3272
- // Temporarily reset transform to measure natural size
3273
- container.style.transform = 'none';
3274
- requestAnimationFrame(() => {
3275
- const contentRect = container.getBoundingClientRect();
3276
- const padW = 80, padH = 80;
3277
- const scaleX = (overlayRect.width - padW) / contentRect.width;
3278
- const scaleY = (overlayRect.height - padH) / contentRect.height;
3279
- scale = Math.min(scaleX, scaleY, 1.5);
3280
- scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale));
3281
- panX = (overlayRect.width - contentRect.width * scale) / 2;
3282
- panY = (overlayRect.height - contentRect.height * scale) / 2;
3283
- applyTransform();
3284
- });
3285
- }
3286
-
3287
- // Expose setter for CatalogPreview to update current page
3288
- window.__devSetCurrentPage = (id) => { currentPageRef.id = id; };
3289
-
3290
- btn.addEventListener('click', () => {
3291
- overlay.classList.add('open');
3292
- renderMindmap();
3293
- requestAnimationFrame(() => fitToView());
3294
- });
3295
- closeBtn.addEventListener('click', () => overlay.classList.remove('open'));
3296
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') overlay.classList.remove('open'); });
3297
-
3298
- // Wheel zoom
3299
- overlay.addEventListener('wheel', (e) => {
3300
- e.preventDefault();
3301
- const rect = overlay.getBoundingClientRect();
3302
- const mouseX = e.clientX - rect.left;
3303
- const mouseY = e.clientY - rect.top;
3304
- const delta = e.deltaY > 0 ? 0.9 : 1.1;
3305
- const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale * delta));
3306
- // Zoom toward cursor
3307
- panX = mouseX - (mouseX - panX) * (newScale / scale);
3308
- panY = mouseY - (mouseY - panY) * (newScale / scale);
3309
- scale = newScale;
3310
- applyTransform();
3311
- }, { passive: false });
3312
-
3313
- // Pan via drag
3314
- overlay.addEventListener('mousedown', (e) => {
3315
- if (e.target.closest('.close-btn, .zoom-controls')) return;
3316
- isDragging = true;
3317
- hasDragged = false;
3318
- dragStartX = e.clientX;
3319
- dragStartY = e.clientY;
3320
- dragStartPanX = panX;
3321
- dragStartPanY = panY;
3322
- overlay.classList.add('grabbing');
3323
- });
3324
- window.addEventListener('mousemove', (e) => {
3325
- if (!isDragging) return;
3326
- const dx = e.clientX - dragStartX;
3327
- const dy = e.clientY - dragStartY;
3328
- if (Math.abs(dx) > 3 || Math.abs(dy) > 3) hasDragged = true;
3329
- panX = dragStartPanX + dx;
3330
- panY = dragStartPanY + dy;
3331
- applyTransform();
3332
- });
3333
- window.addEventListener('mouseup', () => {
3334
- isDragging = false;
3335
- overlay.classList.remove('grabbing');
3336
- });
3337
-
3338
- // Close only via close button (not background click \u2014 allows free panning)
3339
-
3340
- // Zoom buttons
3341
- zoomInBtn.addEventListener('click', () => {
3342
- const rect = overlay.getBoundingClientRect();
3343
- const cx = rect.width / 2, cy = rect.height / 2;
3344
- const newScale = Math.min(MAX_SCALE, scale * 1.25);
3345
- panX = cx - (cx - panX) * (newScale / scale);
3346
- panY = cy - (cy - panY) * (newScale / scale);
3347
- scale = newScale;
3348
- applyTransform();
3349
- });
3350
- zoomOutBtn.addEventListener('click', () => {
3351
- const rect = overlay.getBoundingClientRect();
3352
- const cx = rect.width / 2, cy = rect.height / 2;
3353
- const newScale = Math.max(MIN_SCALE, scale * 0.8);
3354
- panX = cx - (cx - panX) * (newScale / scale);
3355
- panY = cy - (cy - panY) * (newScale / scale);
3356
- scale = newScale;
3357
- applyTransform();
3358
- });
3359
- zoomFitBtn.addEventListener('click', () => fitToView());
3360
-
3361
- function renderMindmap() {
3362
- const pages = schema.pages || {};
3363
- const routing = schema.routing || {};
3364
- const edges = routing.edges || [];
3365
- const entry = routing.entry || Object.keys(pages)[0];
3366
- const pageIds = Object.keys(pages);
3367
-
3368
- // Build adjacency for layout (BFS layers)
3369
- const adj = {};
3370
- pageIds.forEach(id => { adj[id] = []; });
3371
- edges.forEach(e => { if (adj[e.from]) adj[e.from].push(e.to); });
3372
-
3373
- // BFS to assign layers
3374
- const layers = {};
3375
- const visited = new Set();
3376
- const queue = [entry];
3377
- visited.add(entry);
3378
- layers[entry] = 0;
3379
- while (queue.length > 0) {
3380
- const id = queue.shift();
3381
- for (const next of (adj[id] || [])) {
3382
- if (!visited.has(next)) {
3383
- visited.add(next);
3384
- layers[next] = (layers[id] || 0) + 1;
3385
- queue.push(next);
3386
- }
3387
- }
3388
- }
3389
- // Assign orphans
3390
- pageIds.forEach(id => { if (layers[id] === undefined) layers[id] = 999; });
3391
-
3392
- // Group by layer
3393
- const layerGroups = {};
3394
- pageIds.forEach(id => {
3395
- const l = layers[id];
3396
- if (!layerGroups[l]) layerGroups[l] = [];
3397
- layerGroups[l].push(id);
3398
- });
3399
- const sortedLayers = Object.keys(layerGroups).map(Number).sort((a, b) => a - b);
3400
-
3401
- // Render nodes in rows
3402
- let html = '<div style="display:flex;flex-direction:column;align-items:center;gap:32px;">';
3403
- const nodePositions = {};
3404
- let rowIdx = 0;
3405
- for (const layer of sortedLayers) {
3406
- const ids = layerGroups[layer];
3407
- html += '<div style="display:flex;gap:20px;justify-content:center;flex-wrap:wrap;">';
3408
- ids.forEach(id => {
3409
- const page = pages[id];
3410
- const isEntry = id === entry;
3411
- const isCurrent = id === currentPageRef.id;
3412
- const compCount = (page.components || []).length;
3413
- const cls = 'mindmap-node' + (isEntry ? ' entry' : '') + (isCurrent ? ' current' : '');
3414
- html += '<div class="' + cls + '" data-node-id="' + id + '">';
3415
- if (isEntry) html += '<span class="node-badge">entry</span>';
3416
- html += '<div class="node-title">' + (page.title || id) + '</div>';
3417
- html += '<div class="node-id">' + id + '</div>';
3418
- html += '<div class="node-components">' + compCount + ' component' + (compCount !== 1 ? 's' : '') + '</div>';
3419
- html += '</div>';
3420
- });
3421
- html += '</div>';
3422
- rowIdx++;
3423
- }
3424
- html += '</div>';
3425
-
3426
- container.innerHTML = html;
3427
-
3428
- // Draw SVG edges after layout
3429
- requestAnimationFrame(() => {
3430
- const containerRect = container.getBoundingClientRect();
3431
- const nodeEls = container.querySelectorAll('[data-node-id]');
3432
- const nodeRects = {};
3433
- nodeEls.forEach(el => { nodeRects[el.dataset.nodeId] = el.getBoundingClientRect(); });
3434
-
3435
- let svgContent = '';
3436
- edges.forEach(edge => {
3437
- const fromRect = nodeRects[edge.from];
3438
- const toRect = nodeRects[edge.to];
3439
- if (!fromRect || !toRect) return;
3440
- const x1 = fromRect.left + fromRect.width / 2 - containerRect.left;
3441
- const y1 = fromRect.top + fromRect.height - containerRect.top;
3442
- const x2 = toRect.left + toRect.width / 2 - containerRect.left;
3443
- const y2 = toRect.top - containerRect.top;
3444
- const midY = (y1 + y2) / 2;
3445
- const hasConditions = edge.conditions && edge.conditions.length > 0;
3446
- const color = hasConditions ? '#fbbf24' : 'rgba(255,255,255,0.25)';
3447
- svgContent += '<path d="M' + x1 + ',' + y1 + ' C' + x1 + ',' + midY + ' ' + x2 + ',' + midY + ' ' + x2 + ',' + y2 + '" fill="none" stroke="' + color + '" stroke-width="2" stroke-dasharray="' + (hasConditions ? '6,4' : 'none') + '"/>';
3448
- // Arrow
3449
- svgContent += '<polygon points="' + (x2-4) + ',' + (y2-6) + ' ' + x2 + ',' + y2 + ' ' + (x2+4) + ',' + (y2-6) + '" fill="' + color + '"/>';
3450
- // Condition label
3451
- if (hasConditions) {
3452
- const lx = (x1 + x2) / 2;
3453
- const ly = midY - 8;
3454
- const label = edge.conditions.map(c => c.field + ' ' + c.operator + ' ' + c.value).join(', ');
3455
- svgContent += '<text x="' + lx + '" y="' + ly + '" text-anchor="middle" fill="#fbbf24" font-size="9" font-family="monospace">' + label + '</text>';
3456
- }
3457
- });
3458
-
3459
- const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
3460
- svgEl.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:visible;';
3461
- svgEl.innerHTML = svgContent;
3462
- // Remove old svg
3463
- const oldSvg = container.querySelector('svg');
3464
- if (oldSvg) oldSvg.remove();
3465
- container.insertBefore(svgEl, container.firstChild);
3466
- });
3467
-
3468
- // Click nodes to navigate (ignore if user just dragged)
3469
- container.querySelectorAll('[data-node-id]').forEach(el => {
3470
- el.addEventListener('click', (e) => {
3471
- if (hasDragged) return;
3472
- e.stopPropagation();
3473
- const id = el.dataset.nodeId;
3474
- window.__devNavigateTo && window.__devNavigateTo(id);
3475
- overlay.classList.remove('open');
3476
- });
3477
- });
3478
- }
3479
- })();
3480
-
3481
- // --- Element Inspector (Shift+Alt) ---
3482
- (function initInspector() {
3483
- const highlight = document.getElementById('inspector-highlight');
3484
- const tooltip = document.getElementById('inspector-tooltip');
3485
- const banner = document.getElementById('inspector-banner');
3486
- let active = false;
3487
-
3488
- document.addEventListener('keydown', (e) => {
3489
- if (e.shiftKey && e.altKey && !active) {
3490
- active = true;
3491
- banner.style.display = 'block';
3492
- document.body.style.cursor = 'crosshair';
3493
- }
3494
- });
3495
- document.addEventListener('keyup', (e) => {
3496
- if (!e.shiftKey || !e.altKey) {
3497
- active = false;
3498
- banner.style.display = 'none';
3499
- highlight.style.display = 'none';
3500
- tooltip.style.display = 'none';
3501
- document.body.style.cursor = '';
3502
- }
3503
- });
3504
- window.addEventListener('blur', () => {
3505
- active = false;
3506
- banner.style.display = 'none';
3507
- highlight.style.display = 'none';
3508
- tooltip.style.display = 'none';
3509
- document.body.style.cursor = '';
3510
- });
3511
-
3512
- document.addEventListener('mousemove', (e) => {
3513
- if (!active) return;
3514
- let el = e.target;
3515
- // Walk up to find data-component-id
3516
- while (el && !el.dataset?.componentId) el = el.parentElement;
3517
- if (!el) {
3518
- highlight.style.display = 'none';
3519
- tooltip.style.display = 'none';
3520
- return;
3521
- }
3522
- const compId = el.dataset.componentId;
3523
- const compType = el.dataset.componentType || 'unknown';
3524
- let pageEl = el;
3525
- while (pageEl && !pageEl.dataset?.pageId) pageEl = pageEl.parentElement;
3526
- const pageId = pageEl?.dataset?.pageId || 'unknown';
3527
- const rect = el.getBoundingClientRect();
3528
- highlight.style.display = 'block';
3529
- highlight.style.top = (rect.top - 2) + 'px';
3530
- highlight.style.left = (rect.left - 2) + 'px';
3531
- highlight.style.width = (rect.width + 4) + 'px';
3532
- highlight.style.height = (rect.height + 4) + 'px';
3533
- const ref = pageId + '/' + compId;
3534
- tooltip.style.display = 'block';
3535
- tooltip.style.top = Math.max(8, rect.top - 44) + 'px';
3536
- tooltip.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - 340)) + 'px';
3537
- tooltip.innerHTML = '<span class="ref">' + ref + '</span><span class="type">(' + compType + ')</span><div class="hint">click to copy</div>';
3538
- tooltip.dataset.ref = ref;
3539
- tooltip.dataset.compId = compId;
3540
- tooltip.dataset.compType = compType;
3541
- tooltip.dataset.pageId = pageId;
3542
- });
3543
-
3544
- document.addEventListener('click', (e) => {
3545
- if (!active || tooltip.style.display === 'none') return;
3546
- e.stopPropagation();
3547
- e.preventDefault();
3548
- const data = {
3549
- ref: tooltip.dataset.ref,
3550
- page_id: tooltip.dataset.pageId,
3551
- component_id: tooltip.dataset.compId,
3552
- component_type: tooltip.dataset.compType,
3553
- schema_path: 'schema.pages.' + tooltip.dataset.pageId + '.components[id="' + tooltip.dataset.compId + '"]',
3554
- catalog_slug: schema.slug || '',
3555
- };
3556
- navigator.clipboard.writeText(JSON.stringify(data, null, 2)).then(() => {
3557
- tooltip.innerHTML = '<span style="color:#86efac;font-weight:700;">Copied!</span>';
3558
- setTimeout(() => { tooltip.style.display = 'none'; }, 1200);
3559
- });
3560
- }, true);
3561
- })();
3562
-
3563
- // --- Validation in topbar ---
3564
- (function initValidationTag() {
3565
- const tag = document.getElementById('validation-tag');
3566
- const validation = JSON.parse(document.getElementById('__validation_data').textContent);
3567
- const errors = validation.errors || [];
3568
- const warnings = validation.warnings || [];
3569
- const total = errors.length + warnings.length;
3570
- const cleanClass = total === 0 ? ' clean' : '';
3571
- const label = total === 0 ? '\u2713 Valid' : errors.length + ' error' + (errors.length !== 1 ? 's' : '') + ', ' + warnings.length + ' warn';
3572
- let html = '<button class="vt-btn' + cleanClass + '" id="vt-toggle">' + label + '</button>';
3573
- if (total > 0) {
3574
- html += '<div class="vt-dropdown" id="vt-dropdown">';
3575
- html += '<div class="vt-header"><span>' + errors.length + ' error(s), ' + warnings.length + ' warning(s)</span></div>';
3576
- html += '<div class="vt-body">';
3577
- for (const e of errors) html += '<div class="vt-error">ERROR: ' + e + '</div>';
3578
- for (const w of warnings) html += '<div class="vt-warn">WARN: ' + w + '</div>';
3579
- html += '</div></div>';
3580
- }
3581
- tag.innerHTML = html;
3582
- if (total > 0) {
3583
- const toggleBtn = document.getElementById('vt-toggle');
3584
- const dropdown = document.getElementById('vt-dropdown');
3585
- toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); dropdown.classList.toggle('open'); });
3586
- document.addEventListener('click', (e) => { if (!tag.contains(e.target)) dropdown.classList.remove('open'); });
3587
- }
3588
- })();
3589
-
3590
- // --- Banner minimize / restore ---
3591
- (function initBannerMinimize() {
3592
- const banner = document.getElementById('dev-banner');
3593
- const minimizeBtn = document.getElementById('banner-minimize');
3594
- const restoreBtn = document.getElementById('banner-restore');
3595
- minimizeBtn.addEventListener('click', () => {
3596
- banner.classList.add('minimized');
3597
- restoreBtn.classList.add('visible');
3598
- });
3599
- restoreBtn.addEventListener('click', () => {
3600
- banner.classList.remove('minimized');
3601
- restoreBtn.classList.remove('visible');
3602
- });
3603
- })();
3604
-
3605
- // --- Banner drag to reposition ---
3606
- (function initBannerDrag() {
3607
- const banner = document.getElementById('dev-banner');
3608
- const handle = document.getElementById('banner-drag');
3609
- let isDragging = false, startX = 0, startY = 0, origLeft = 0, origTop = 0;
3610
-
3611
- handle.addEventListener('mousedown', (e) => {
3612
- e.preventDefault();
3613
- isDragging = true;
3614
- const rect = banner.getBoundingClientRect();
3615
- startX = e.clientX; startY = e.clientY;
3616
- origLeft = rect.left; origTop = rect.top;
3617
- // Switch to positioned mode
3618
- banner.style.left = rect.left + 'px';
3619
- banner.style.top = rect.top + 'px';
3620
- banner.style.right = 'auto';
3621
- banner.style.width = rect.width + 'px';
3622
- handle.style.cursor = 'grabbing';
3623
- });
3624
- window.addEventListener('mousemove', (e) => {
3625
- if (!isDragging) return;
3626
- const dx = e.clientX - startX, dy = e.clientY - startY;
3627
- banner.style.left = (origLeft + dx) + 'px';
3628
- banner.style.top = (origTop + dy) + 'px';
3629
- });
3630
- window.addEventListener('mouseup', () => {
3631
- if (!isDragging) return;
3632
- isDragging = false;
3633
- handle.style.cursor = '';
3634
- });
3635
- })();
972
+ const root = createRoot(document.getElementById("root"));
973
+ root.render(
974
+ React.createElement(CatalogRenderer, {
975
+ catalog: schema,
976
+ userId: "dev-user",
977
+ trackingEnabled: true,
978
+ services: services,
979
+ })
980
+ );
3636
981
 
3637
- // --- Debug panel ---
3638
- (function initDebugPanel() {
3639
- const panel = document.getElementById('debug-panel');
3640
- const body = document.getElementById('debug-body');
3641
- const btn = document.getElementById('debug-btn');
3642
- const closeBtn = document.getElementById('debug-close');
3643
- let isOpen = false;
982
+ // --- Auto-reload via SSE ---
983
+ const sse = new EventSource("/__dev_sse");
984
+ sse.onmessage = (e) => {
985
+ if (e.data === "reload") window.location.reload();
986
+ };
3644
987
 
3645
- function toggle() {
3646
- isOpen = !isOpen;
3647
- panel.classList.toggle('open', isOpen);
3648
- if (isOpen) render();
988
+ // --- Checkout redirect override for local dev ---
989
+ ${stripeEnabled ? `
990
+ // Override checkout endpoint to use local dev server
991
+ const _origFetch = window.fetch;
992
+ window.fetch = function(url, opts) {
993
+ if (typeof url === "string" && url.includes("/checkout/session")) {
994
+ return _origFetch("/__dev_checkout", opts);
3649
995
  }
3650
- btn.addEventListener('click', toggle);
3651
- closeBtn.addEventListener('click', toggle);
3652
-
3653
- document.addEventListener('keydown', (e) => {
3654
- if (e.ctrlKey && e.key === 'd') { e.preventDefault(); toggle(); }
3655
- });
3656
-
3657
- const recentEvents = [];
3658
- window.addEventListener('devEvent', (e) => {
3659
- recentEvents.push(e.detail);
3660
- if (recentEvents.length > 20) recentEvents.shift();
3661
- if (isOpen) render();
3662
- });
3663
-
3664
- function render() {
3665
- const state = window.__devDebugState;
3666
- if (!state) { body.innerHTML = '<p>Waiting for state...</p>'; return; }
3667
- let html = '<div class="dp-section"><div class="dp-label">Current Page</div><span class="dp-badge">' + (state.currentPageId || 'none') + '</span></div>';
3668
- html += '<div class="dp-section"><div class="dp-label">Form State</div><pre>' + JSON.stringify(state.formState || {}, null, 2) + '</pre></div>';
3669
- html += '<div class="dp-section"><div class="dp-label">Cart (' + (state.cartItems?.length || 0) + ')</div>';
3670
- if (state.cartItems && state.cartItems.length > 0) {
3671
- html += '<pre>' + JSON.stringify(state.cartItems.map(i => i.title || i.offer_id), null, 2) + '</pre>';
3672
- } else {
3673
- html += '<pre>empty</pre>';
3674
- }
3675
- html += '</div>';
3676
- html += '<div class="dp-section"><div class="dp-label">Edges from here</div>';
3677
- if (state.edges && state.edges.length > 0) {
3678
- html += '<pre>' + state.edges.map(e => e.from + ' \u2192 ' + e.to + (e.conditions?.length ? ' (conditional)' : '')).join('\\n') + '</pre>';
3679
- } else {
3680
- html += '<pre>none (terminal page)</pre>';
3681
- }
3682
- html += '</div>';
3683
- html += '<div class="dp-section"><div class="dp-label">Recent Events (' + recentEvents.length + ')</div>';
3684
- if (recentEvents.length > 0) {
3685
- html += '<pre>' + recentEvents.slice(-8).map(e => e.type + ' ' + JSON.stringify(e.data || {})).join('\\n') + '</pre>';
3686
- } else {
3687
- html += '<pre>none yet</pre>';
3688
- }
3689
- html += '</div>';
3690
- body.innerHTML = html;
996
+ return _origFetch.apply(this, arguments);
997
+ };
998
+ ` : `
999
+ // Stub checkout \u2014 no Stripe key
1000
+ const _origFetch = window.fetch;
1001
+ window.fetch = function(url, opts) {
1002
+ if (typeof url === "string" && url.includes("/checkout/session")) {
1003
+ return Promise.resolve(new Response(JSON.stringify({
1004
+ session_url: "javascript:alert('Stripe not configured. Add STRIPE_SECRET_KEY to .env')"
1005
+ }), { status: 200, headers: { "Content-Type": "application/json" } }));
3691
1006
  }
3692
-
3693
- window.addEventListener('devStateUpdate', () => { if (isOpen) render(); });
3694
- })();
1007
+ return _origFetch.apply(this, arguments);
1008
+ };
1009
+ `}
3695
1010
  </script>
3696
1011
  </body>
3697
1012
  </html>`;
@@ -3901,6 +1216,31 @@ async function catalogDev(file, opts) {
3901
1216
  res.end();
3902
1217
  return;
3903
1218
  }
1219
+ if (url.pathname.startsWith("/__renderer/")) {
1220
+ const fileName = url.pathname.slice("/__renderer/".length);
1221
+ const cliDistDir = dirname2(new URL(import.meta.url).pathname);
1222
+ const candidates = [
1223
+ join2(cliDistDir, "renderer", fileName),
1224
+ join2(cliDistDir, "../../renderer/dist", fileName),
1225
+ join2(cliDistDir, "../../../renderer/dist", fileName)
1226
+ ];
1227
+ for (const candidate of candidates) {
1228
+ const resolved = resolve4(candidate);
1229
+ if (existsSync2(resolved) && statSync3(resolved).isFile()) {
1230
+ const content = readFileSync4(resolved);
1231
+ res.writeHead(200, {
1232
+ "Content-Type": getMime(resolved),
1233
+ "Cache-Control": "no-cache",
1234
+ "Access-Control-Allow-Origin": "*"
1235
+ });
1236
+ res.end(content);
1237
+ return;
1238
+ }
1239
+ }
1240
+ res.writeHead(404);
1241
+ res.end("Renderer file not found: " + fileName);
1242
+ return;
1243
+ }
3904
1244
  if (url.pathname.startsWith("/assets/")) {
3905
1245
  const relativePath = decodeURIComponent(url.pathname.slice("/assets/".length));
3906
1246
  const filePath = join2(catalogDir, relativePath);