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