@officexapp/catalogs-cli 0.2.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +2256 -155
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { Command } from "commander";
5
5
  import { readFileSync as readFileSync5 } from "fs";
6
6
  import { fileURLToPath } from "url";
7
- import { dirname as dirname3, join as join3 } from "path";
7
+ import { dirname as dirname3, join as join4 } from "path";
8
8
 
9
9
  // src/config.ts
10
10
  var DEFAULT_API_URL = "https://api.catalogkit.cc";
@@ -233,7 +233,7 @@ Check status later: catalogs video status ${videoId}
233
233
  `);
234
234
  }
235
235
  function sleep(ms) {
236
- return new Promise((resolve4) => setTimeout(resolve4, ms));
236
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
237
237
  }
238
238
 
239
239
  // src/commands/video-status.ts
@@ -265,9 +265,7 @@ async function videoStatus(videoId) {
265
265
  }
266
266
 
267
267
  // src/commands/catalog-push.ts
268
- import { readFileSync as readFileSync3 } from "fs";
269
- import { resolve as resolve2, extname as extname2, dirname } from "path";
270
- import { pathToFileURL } from "url";
268
+ import { resolve as resolve3, dirname } from "path";
271
269
  import ora3 from "ora";
272
270
 
273
271
  // src/lib/serialize.ts
@@ -315,6 +313,97 @@ function validateCatalog(schema) {
315
313
  }
316
314
  return errors;
317
315
  }
316
+ function deepValidateCatalog(schema) {
317
+ const errors = [];
318
+ const warnings = [];
319
+ const pages = schema.pages || {};
320
+ const pageIds = new Set(Object.keys(pages));
321
+ const routing = schema.routing || {};
322
+ const edges = routing.edges || [];
323
+ const entry = routing.entry;
324
+ const KNOWN_TYPES = /* @__PURE__ */ new Set([
325
+ "heading",
326
+ "paragraph",
327
+ "image",
328
+ "video",
329
+ "html",
330
+ "banner",
331
+ "callout",
332
+ "divider",
333
+ "pricing_card",
334
+ "testimonial",
335
+ "faq",
336
+ "timeline",
337
+ "file_download",
338
+ "iframe",
339
+ "short_text",
340
+ "email",
341
+ "phone",
342
+ "url",
343
+ "number",
344
+ "password",
345
+ "long_text",
346
+ "multiple_choice",
347
+ "checkboxes",
348
+ "dropdown",
349
+ "slider",
350
+ "star_rating",
351
+ "switch",
352
+ "checkbox",
353
+ "payment"
354
+ ]);
355
+ if (entry && !pageIds.has(entry)) {
356
+ errors.push(`routing.entry "${entry}" does not exist in pages`);
357
+ }
358
+ for (const edge of edges) {
359
+ if (!pageIds.has(edge.from)) {
360
+ errors.push(`routing edge from "${edge.from}" references non-existent page`);
361
+ }
362
+ if (edge.to != null && !pageIds.has(edge.to)) {
363
+ errors.push(`routing edge to "${edge.to}" references non-existent page`);
364
+ }
365
+ }
366
+ for (const [pageId, page] of Object.entries(pages)) {
367
+ const components = page.components || [];
368
+ const compIds = /* @__PURE__ */ new Set();
369
+ for (const comp of components) {
370
+ if (comp.id && compIds.has(comp.id)) {
371
+ errors.push(`page "${pageId}": duplicate component ID "${comp.id}"`);
372
+ }
373
+ if (comp.id) compIds.add(comp.id);
374
+ if (comp.type && !KNOWN_TYPES.has(comp.type)) {
375
+ warnings.push(`page "${pageId}": unknown component type "${comp.type}"`);
376
+ }
377
+ }
378
+ if (page.offer?.accept_field) {
379
+ if (!compIds.has(page.offer.accept_field)) {
380
+ errors.push(
381
+ `page "${pageId}": offer.accept_field "${page.offer.accept_field}" does not match any component ID on this page`
382
+ );
383
+ }
384
+ }
385
+ }
386
+ if (entry && pageIds.has(entry)) {
387
+ const reachable = /* @__PURE__ */ new Set();
388
+ const queue = [entry];
389
+ reachable.add(entry);
390
+ while (queue.length > 0) {
391
+ const id = queue.shift();
392
+ for (const edge of edges) {
393
+ if (edge.from === id && !reachable.has(edge.to)) {
394
+ reachable.add(edge.to);
395
+ queue.push(edge.to);
396
+ }
397
+ }
398
+ }
399
+ for (const id of pageIds) {
400
+ if (!reachable.has(id)) {
401
+ warnings.push(`page "${id}" is unreachable from entry "${entry}"`);
402
+ }
403
+ }
404
+ }
405
+ return { errors, warnings };
406
+ }
318
407
 
319
408
  // src/lib/resolve-assets.ts
320
409
  import { existsSync, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
@@ -557,29 +646,33 @@ async function uploadFile(localPath, filename, api) {
557
646
  return downloadUrl;
558
647
  }
559
648
 
560
- // src/commands/catalog-push.ts
561
- async function loadTsFile(file) {
649
+ // src/lib/load-file.ts
650
+ import { resolve as resolve2, extname as extname2 } from "path";
651
+ import { readFileSync as readFileSync3 } from "fs";
652
+ async function loadCatalogFile(file) {
562
653
  const abs = resolve2(file);
563
- const { register } = await import("module");
564
- register("tsx/esm", pathToFileURL("./"));
565
- const mod = await import(pathToFileURL(abs).href);
566
- return mod.default ?? mod;
654
+ const ext = extname2(file).toLowerCase();
655
+ const isTs = ext === ".ts" || ext === ".mts";
656
+ if (isTs) {
657
+ const { tsImport } = await import("tsx/esm/api");
658
+ const mod = await tsImport(abs, import.meta.url);
659
+ let raw = mod.default ?? mod;
660
+ if (raw.__esModule && raw.default) raw = raw.default;
661
+ return serializeCatalog(raw);
662
+ } else {
663
+ const raw = readFileSync3(abs, "utf-8");
664
+ return JSON.parse(raw);
665
+ }
567
666
  }
667
+
668
+ // src/commands/catalog-push.ts
568
669
  async function catalogPush(file, opts) {
569
670
  const config = requireConfig();
570
671
  const api = new ApiClient(config);
571
672
  await printIdentity(api);
572
- const ext = extname2(file).toLowerCase();
573
- const isTs = ext === ".ts" || ext === ".mts";
574
673
  let schema;
575
674
  try {
576
- if (isTs) {
577
- const rawCatalog = await loadTsFile(file);
578
- schema = serializeCatalog(rawCatalog);
579
- } else {
580
- const raw = readFileSync3(file, "utf-8");
581
- schema = JSON.parse(raw);
582
- }
675
+ schema = await loadCatalogFile(file);
583
676
  } catch (err) {
584
677
  console.error(`Failed to read ${file}: ${err.message}`);
585
678
  process.exit(1);
@@ -591,7 +684,7 @@ async function catalogPush(file, opts) {
591
684
  }
592
685
  process.exit(1);
593
686
  }
594
- const catalogDir = dirname(resolve2(file));
687
+ const catalogDir = dirname(resolve3(file));
595
688
  const assetSpinner = ora3("Checking for local assets...").start();
596
689
  try {
597
690
  const result = await uploadLocalAssets(schema, catalogDir, api, (msg) => {
@@ -680,27 +773,18 @@ async function catalogList() {
680
773
  }
681
774
 
682
775
  // src/commands/catalog-dev.ts
683
- import { resolve as resolve3, dirname as dirname2, extname as extname3, join as join2 } from "path";
776
+ import { resolve as resolve4, dirname as dirname2, extname as extname3, join as join2 } from "path";
684
777
  import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync3, watch } from "fs";
685
- import { pathToFileURL as pathToFileURL2 } from "url";
686
778
  import { createServer } from "http";
687
779
  import ora5 from "ora";
688
- var DEFAULT_PORT = 3456;
689
- async function loadCatalogFile(file) {
690
- const abs = resolve3(file);
691
- const ext = extname3(file).toLowerCase();
692
- const isTs = ext === ".ts" || ext === ".mts";
693
- if (isTs) {
694
- const { register } = await import("module");
695
- register("tsx/esm", pathToFileURL2("./"));
696
- const url = pathToFileURL2(abs).href + `?t=${Date.now()}`;
697
- const mod = await import(url);
698
- return serializeCatalog(mod.default ?? mod);
699
- } else {
700
- const raw = readFileSync4(abs, "utf-8");
701
- return JSON.parse(raw);
702
- }
780
+
781
+ // src/lib/dev-engine.ts
782
+ function buildEngineScript() {
783
+ return 'function resolveValue(rule, formState, context) {\n const source = rule.source ?? "field";\n switch (source) {\n case "field":\n return rule.field != null ? formState[rule.field] : void 0;\n case "url_param":\n return rule.param != null ? context.url_params[rule.param] : void 0;\n case "hint":\n return rule.param != null ? context.hints[rule.param] : void 0;\n case "tracer_prop":\n return rule.param != null ? context[rule.param] : void 0;\n case "score": {\n const scores = context.quiz_scores;\n if (!scores) return 0;\n const param = rule.param ?? rule.field ?? "total";\n switch (param) {\n case "total":\n return scores.total;\n case "max":\n return scores.max;\n case "percent":\n return scores.percent;\n case "correct_count":\n return scores.correct_count;\n case "question_count":\n return scores.question_count;\n default: {\n const answer = scores.answers.find((a) => a.component_id === param);\n return answer ? answer.points_earned : 0;\n }\n }\n }\n case "video": {\n const videoState = context.video_state;\n if (!videoState) return void 0;\n const compId = rule.field;\n if (!compId || !videoState[compId]) return void 0;\n const metric = rule.param ?? "watch_percent";\n return videoState[compId][metric];\n }\n default:\n return void 0;\n }\n}\nfunction applyOperator(operator, actual, expected) {\n switch (operator) {\n case "equals":\n return String(actual ?? "") === String(expected ?? "");\n case "not_equals":\n return String(actual ?? "") !== String(expected ?? "");\n case "contains":\n if (typeof actual === "string") return actual.includes(String(expected));\n if (Array.isArray(actual)) return actual.includes(expected);\n return false;\n case "not_contains":\n if (typeof actual === "string") return !actual.includes(String(expected));\n if (Array.isArray(actual)) return !actual.includes(expected);\n return true;\n case "greater_than":\n return Number(actual) > Number(expected);\n case "greater_than_or_equal":\n return Number(actual) >= Number(expected);\n case "less_than":\n return Number(actual) < Number(expected);\n case "less_than_or_equal":\n return Number(actual) <= Number(expected);\n case "is_empty":\n return actual == null || actual === "" || Array.isArray(actual) && actual.length === 0;\n case "is_not_empty":\n return !(actual == null || actual === "" || Array.isArray(actual) && actual.length === 0);\n case "matches_regex":\n try {\n const pattern = String(expected);\n if (pattern.length > 200 || /(\\+\\+|\\*\\*|\\{\\d{3,}\\})/.test(pattern)) return false;\n return new RegExp(pattern).test(String(actual ?? ""));\n } catch {\n return false;\n }\n case "in":\n if (Array.isArray(expected)) return expected.includes(actual);\n if (typeof expected === "string")\n return expected.split(",").map((s) => s.trim()).includes(String(actual));\n return false;\n default:\n return false;\n }\n}\nfunction isConditionGroup(rule) {\n return "match" in rule && "rules" in rule;\n}\nfunction evaluateCondition(rule, formState, context) {\n const actual = resolveValue(rule, formState, context);\n return applyOperator(rule.operator, actual, rule.value);\n}\nfunction evaluateConditionGroup(group, formState, context) {\n const evaluator = (item) => {\n if (isConditionGroup(item)) {\n return evaluateConditionGroup(item, formState, context);\n }\n return evaluateCondition(item, formState, context);\n };\n if (group.match === "all") {\n return group.rules.every(evaluator);\n }\n return group.rules.some(evaluator);\n}\nfunction getNextPage(routing, currentPageId, formState, context) {\n const outgoing = routing.edges.filter((e) => e.from === currentPageId);\n if (outgoing.length === 0) return null;\n const sorted = [...outgoing].sort((a, b) => {\n if (a.is_default && !b.is_default) return 1;\n if (!a.is_default && b.is_default) return -1;\n return (a.priority ?? Infinity) - (b.priority ?? Infinity);\n });\n for (const edge of sorted) {\n if (!edge.conditions) {\n return edge.to;\n }\n if (evaluateConditionGroup(edge.conditions, formState, context)) {\n return edge.to;\n }\n }\n const defaultEdge = sorted.find((e) => e.is_default);\n return defaultEdge?.to ?? null;\n}\nfunction getProgress(routing, currentPageId, pages) {\n const adjacency = /* @__PURE__ */ new Map();\n for (const edge of routing.edges) {\n if (!edge.to) continue;\n if (!adjacency.has(edge.from)) adjacency.set(edge.from, /* @__PURE__ */ new Set());\n adjacency.get(edge.from).add(edge.to);\n }\n const depthMap = /* @__PURE__ */ new Map();\n const queue = [[routing.entry, 0]];\n depthMap.set(routing.entry, 0);\n while (queue.length > 0) {\n const [node, depth] = queue.shift();\n const neighbours = adjacency.get(node);\n if (!neighbours) continue;\n for (const next of neighbours) {\n if (!depthMap.has(next)) {\n depthMap.set(next, depth + 1);\n queue.push([next, depth + 1]);\n }\n }\n }\n const currentDepth = depthMap.get(currentPageId) ?? 0;\n const maxDepth = Math.max(...depthMap.values(), 0);\n if (maxDepth === 0) return 0;\n return Math.round(currentDepth / maxDepth * 100);\n}\nconst INPUT_TYPES = /* @__PURE__ */ new Set([\n "short_text",\n "long_text",\n "rich_text",\n "email",\n "phone",\n "url",\n "address",\n "number",\n "currency",\n "date",\n "datetime",\n "time",\n "date_range",\n "dropdown",\n "multiselect",\n "multiple_choice",\n "checkboxes",\n "picture_choice",\n "switch",\n "checkbox",\n "choice_matrix",\n "ranking",\n "star_rating",\n "slider",\n "opinion_scale",\n "file_upload",\n "signature",\n "password",\n "location"\n]);\nconst BOOLEAN_TYPES = /* @__PURE__ */ new Set(["switch", "checkbox"]);\nfunction validatePage(page, formState, context, prefilledIds, overrides) {\n const errors = [];\n for (const comp of page.components) {\n if (!INPUT_TYPES.has(comp.type)) continue;\n const props = { ...comp.props, ...overrides?.[comp.id] };\n if (comp.hidden || props.hidden) continue;\n if (comp.visibility) {\n if (!evaluateConditionGroup(comp.visibility, formState, context)) continue;\n }\n if (comp.prefill_mode === "hidden" && formState[comp.id] != null && formState[comp.id] !== "") continue;\n const value = formState[comp.id];\n if (props.readonly || comp.prefill_mode === "readonly" && value != null && value !== "") continue;\n if (props.required) {\n if (BOOLEAN_TYPES.has(comp.type)) {\n if (!value) {\n errors.push({ componentId: comp.id, message: "This field is required" });\n continue;\n }\n } else {\n const isEmpty = value == null || value === "" || Array.isArray(value) && value.length === 0;\n if (isEmpty) {\n errors.push({ componentId: comp.id, message: "This field is required" });\n continue;\n }\n }\n }\n if (value != null && value !== "") {\n if (comp.type === "email") {\n if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value))) {\n errors.push({ componentId: comp.id, message: "Please enter a valid email address" });\n }\n } else if (comp.type === "url") {\n try {\n const urlStr = String(value);\n new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`);\n } catch {\n errors.push({ componentId: comp.id, message: "Please enter a valid URL" });\n }\n } else if (comp.type === "number") {\n const num = Number(value);\n if (isNaN(num)) {\n errors.push({ componentId: comp.id, message: "Please enter a valid number" });\n } else {\n if (props.min != null && num < props.min) {\n errors.push({ componentId: comp.id, message: `Must be at least ${props.min}` });\n }\n if (props.max != null && num > props.max) {\n errors.push({ componentId: comp.id, message: `Must be at most ${props.max}` });\n }\n }\n } else if (comp.type === "short_text" || comp.type === "long_text") {\n const str = String(value);\n if (props.min_length && str.length < props.min_length) {\n errors.push({ componentId: comp.id, message: `Must be at least ${props.min_length} characters` });\n }\n }\n }\n if ((comp.type === "checkboxes" || comp.type === "multiple_choice") && Array.isArray(value) && props.required) {\n const options = props.options || [];\n if (props.require_all) {\n for (const opt of options) {\n if (!value.includes(opt.value)) {\n errors.push({ componentId: comp.id, message: "All options must be selected" });\n break;\n }\n }\n }\n for (const opt of options) {\n if (!opt.inputs || opt.inputs.length === 0) continue;\n if (!props.require_all && !value.includes(opt.value)) continue;\n for (const input of opt.inputs) {\n const isRequired = input.required || input.props?.required;\n if (!isRequired) continue;\n const nestedId = `${comp.id}.${opt.value}.${input.id}`;\n const nestedValue = formState[nestedId];\n if (nestedValue == null || nestedValue === "" || Array.isArray(nestedValue) && nestedValue.length === 0) {\n errors.push({ componentId: nestedId, message: "This field is required" });\n }\n }\n }\n }\n }\n return errors;\n}\nfunction getVisibleFields(page, formState, context) {\n const fields = [];\n for (const comp of page.components) {\n if (!INPUT_TYPES.has(comp.type)) continue;\n const props = comp.props || {};\n if (comp.hidden || props.hidden) continue;\n if (comp.visibility) {\n if (!evaluateConditionGroup(comp.visibility, formState, context)) continue;\n }\n if (comp.prefill_mode === "hidden" && formState[comp.id] != null && formState[comp.id] !== "") continue;\n const field = {\n id: comp.id,\n type: comp.type,\n label: props.label,\n required: !!props.required,\n agent_hint: comp.agent_hint,\n placeholder: props.placeholder,\n default_value: props.default_value\n };\n if (props.options) {\n field.options = props.options.map((opt) => ({\n value: opt.value,\n label: opt.label,\n description: opt.description,\n agent_hint: opt.agent_hint\n }));\n }\n if (props.min != null) field.min = props.min;\n if (props.max != null) field.max = props.max;\n if (props.min_length != null) field.min_length = props.min_length;\n if (props.max_length != null) field.max_length = props.max_length;\n fields.push(field);\n }\n return fields;\n}\n'.replace(/<\/script/gi, "<\\/script");
703
784
  }
785
+
786
+ // src/commands/catalog-dev.ts
787
+ var DEFAULT_PORT = 3456;
704
788
  var MIME_TYPES = {
705
789
  ".html": "text/html",
706
790
  ".js": "application/javascript",
@@ -730,9 +814,10 @@ function getMime(filepath) {
730
814
  const ext = extname3(filepath).toLowerCase();
731
815
  return MIME_TYPES[ext] || "application/octet-stream";
732
816
  }
733
- function buildPreviewHtml(schema, port) {
817
+ function buildPreviewHtml(schema, port, validation, devConfig) {
734
818
  const schemaJson = JSON.stringify(schema).replace(/<\/script/gi, "<\\/script");
735
819
  const themeColor = schema.settings?.theme?.primary_color || "#6366f1";
820
+ const engineScript = buildEngineScript();
736
821
  return `<!DOCTYPE html>
737
822
  <html lang="en">
738
823
  <head>
@@ -845,28 +930,96 @@ function buildPreviewHtml(schema, port) {
845
930
  background: #1a1a2e; color: #e0e0ff; font-size: 12px;
846
931
  padding: 4px 12px; display: flex; align-items: center; gap: 8px;
847
932
  font-family: monospace; border-bottom: 2px solid #6c63ff;
933
+ transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s ease;
934
+ }
935
+ .dev-banner.minimized {
936
+ transform: translateY(-100%); opacity: 0; pointer-events: none;
848
937
  }
938
+ .dev-banner .drag-handle {
939
+ cursor: grab; opacity: 0.4; font-size: 10px; user-select: none; letter-spacing: 1px;
940
+ padding: 0 2px; transition: opacity 0.15s;
941
+ }
942
+ .dev-banner .drag-handle:hover { opacity: 0.8; }
943
+ .dev-banner .drag-handle:active { cursor: grabbing; }
849
944
  .dev-banner .dot { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; animation: pulse 2s ease-in-out infinite; }
850
945
  @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
851
946
  .dev-banner .label { opacity: 0.7; }
852
947
  .dev-banner .slug { font-weight: bold; color: #a5b4fc; }
853
- .dev-banner .stub-tags { margin-left: auto; display: flex; gap: 6px; }
948
+ .dev-banner .stub-tags { margin-left: auto; display: flex; gap: 6px; align-items: center; }
854
949
  .dev-banner .stub-tag { background: rgba(255,255,255,0.1); border-radius: 3px; padding: 1px 6px; font-size: 11px; color: #fbbf24; }
950
+ .dev-banner .minimize-btn {
951
+ background: rgba(255,255,255,0.08); border: none; color: #a5b4fc; cursor: pointer;
952
+ font-size: 14px; width: 18px; height: 18px; border-radius: 4px;
953
+ display: flex; align-items: center; justify-content: center; padding: 0;
954
+ transition: background 0.15s;
955
+ }
956
+ .dev-banner .minimize-btn:hover { background: rgba(255,255,255,0.2); }
957
+ /* Minimized floating pill to restore banner */
958
+ .dev-banner-restore {
959
+ position: fixed; top: 8px; right: 8px; z-index: 99999;
960
+ background: #1a1a2e; color: #a5b4fc; border: 1px solid #6c63ff;
961
+ border-radius: 8px; padding: 4px 10px; font-size: 11px; font-family: monospace;
962
+ cursor: pointer; display: none; align-items: center; gap: 6px;
963
+ box-shadow: 0 2px 12px rgba(0,0,0,0.3); transition: all 0.15s ease;
964
+ }
965
+ .dev-banner-restore:hover { background: #2a2a4e; }
966
+ .dev-banner-restore .restore-dot { width: 6px; height: 6px; border-radius: 50%; background: #4ade80; animation: pulse 2s ease-in-out infinite; }
967
+ .dev-banner-restore.visible { display: flex; }
968
+ /* Validation tag in topbar */
969
+ .dev-banner .validation-tag { position: relative; }
970
+ .dev-banner .validation-tag .vt-btn {
971
+ background: rgba(239,68,68,0.2); border: none; color: #fca5a5; border-radius: 3px;
972
+ padding: 1px 6px; font-size: 11px; cursor: pointer; font-family: monospace;
973
+ transition: background 0.15s;
974
+ }
975
+ .dev-banner .validation-tag .vt-btn:hover { background: rgba(239,68,68,0.35); }
976
+ .dev-banner .validation-tag .vt-btn.clean {
977
+ background: rgba(74,222,128,0.15); color: #86efac;
978
+ }
979
+ .dev-banner .validation-tag .vt-btn.clean:hover { background: rgba(74,222,128,0.25); }
980
+ .dev-banner .vt-dropdown {
981
+ position: absolute; top: calc(100% + 8px); right: 0; min-width: 400px;
982
+ background: #1e1b2e; border: 1px solid #3b3660; border-radius: 10px;
983
+ box-shadow: 0 8px 32px rgba(0,0,0,0.4); display: none; overflow: hidden;
984
+ font-family: monospace; font-size: 12px;
985
+ }
986
+ .dev-banner .vt-dropdown.open { display: block; }
987
+ .dev-banner .vt-dropdown .vt-header {
988
+ padding: 8px 12px; background: #2a2550; border-bottom: 1px solid #3b3660;
989
+ font-weight: 600; color: #c4b5fd; display: flex; align-items: center; justify-content: space-between;
990
+ }
991
+ .dev-banner .vt-dropdown .vt-body { padding: 8px 12px; max-height: 240px; overflow-y: auto; }
992
+ .dev-banner .vt-dropdown .vt-error { color: #fca5a5; padding: 3px 0; }
993
+ .dev-banner .vt-dropdown .vt-warn { color: #fde68a; padding: 3px 0; }
855
994
 
856
995
  /* Pages mindmap overlay */
857
996
  .pages-overlay {
858
997
  position: fixed; inset: 0; z-index: 99990; background: rgba(10,10,20,0.92);
859
- backdrop-filter: blur(8px); display: none; align-items: center; justify-content: center;
860
- font-family: var(--font-display);
998
+ backdrop-filter: blur(8px); display: none;
999
+ font-family: var(--font-display); overflow: hidden; cursor: grab;
861
1000
  }
862
- .pages-overlay.open { display: flex; }
1001
+ .pages-overlay.open { display: block; }
1002
+ .pages-overlay.grabbing { cursor: grabbing; }
863
1003
  .pages-overlay .close-btn {
864
1004
  position: absolute; top: 16px; right: 16px; background: rgba(255,255,255,0.1);
865
1005
  border: none; color: white; width: 32px; height: 32px; border-radius: 8px;
866
1006
  cursor: pointer; font-size: 18px; display: flex; align-items: center; justify-content: center;
1007
+ z-index: 10;
867
1008
  }
868
1009
  .pages-overlay .close-btn:hover { background: rgba(255,255,255,0.2); }
869
- .mindmap-container { position: relative; padding: 40px; }
1010
+ .pages-overlay .zoom-controls {
1011
+ position: absolute; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 4px; z-index: 10;
1012
+ }
1013
+ .pages-overlay .zoom-btn {
1014
+ width: 32px; height: 32px; border-radius: 8px; border: none;
1015
+ background: rgba(255,255,255,0.1); color: white; font-size: 16px; cursor: pointer;
1016
+ display: flex; align-items: center; justify-content: center; font-family: monospace;
1017
+ }
1018
+ .pages-overlay .zoom-btn:hover { background: rgba(255,255,255,0.2); }
1019
+ .pages-overlay .zoom-level {
1020
+ text-align: center; color: rgba(255,255,255,0.4); font-size: 10px; font-family: monospace;
1021
+ }
1022
+ .mindmap-container { position: absolute; top: 0; left: 0; transform-origin: 0 0; padding: 40px; }
870
1023
  .mindmap-container svg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
871
1024
  .mindmap-nodes { position: relative; display: flex; flex-wrap: wrap; gap: 24px; justify-content: center; align-items: flex-start; max-width: 900px; }
872
1025
  .mindmap-node {
@@ -927,29 +1080,112 @@ function buildPreviewHtml(schema, port) {
927
1080
  padding: 32px; text-align: center;
928
1081
  box-shadow: 0 2px 12px rgba(0,0,0,0.04);
929
1082
  }
1083
+
1084
+ /* (validation banner moved into topbar dropdown) */
1085
+
1086
+ /* Debug panel */
1087
+ .debug-panel {
1088
+ position: fixed; bottom: 0; right: 0; z-index: 99998;
1089
+ width: 380px; max-height: 60vh; background: #1e1b4b; color: #e0e7ff;
1090
+ font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px;
1091
+ border-top-left-radius: 12px; overflow: hidden;
1092
+ box-shadow: -4px -4px 24px rgba(0,0,0,0.3); display: none;
1093
+ }
1094
+ .debug-panel.open { display: flex; flex-direction: column; }
1095
+ .debug-panel .dp-header {
1096
+ display: flex; align-items: center; justify-content: space-between;
1097
+ padding: 8px 12px; background: #312e81; border-bottom: 1px solid #4338ca;
1098
+ font-weight: 600; font-size: 12px;
1099
+ }
1100
+ .debug-panel .dp-close {
1101
+ background: none; border: none; color: #a5b4fc; cursor: pointer; font-size: 14px;
1102
+ }
1103
+ .debug-panel .dp-body {
1104
+ padding: 10px 12px; overflow-y: auto; flex: 1;
1105
+ }
1106
+ .debug-panel .dp-section { margin-bottom: 10px; }
1107
+ .debug-panel .dp-label { color: #818cf8; font-weight: 600; font-size: 10px; text-transform: uppercase; margin-bottom: 4px; }
1108
+ .debug-panel .dp-badge {
1109
+ display: inline-block; background: #4338ca; color: #c7d2fe; padding: 1px 8px;
1110
+ border-radius: 4px; font-size: 11px; font-weight: 600;
1111
+ }
1112
+ .debug-panel pre { margin: 0; white-space: pre-wrap; word-break: break-all; color: #c7d2fe; line-height: 1.5; }
1113
+ .dev-banner .stub-tag.debug-btn { cursor: pointer; transition: all 0.15s ease; }
1114
+ .dev-banner .stub-tag.debug-btn:hover { background: rgba(255,255,255,0.2); color: #a5b4fc; }
1115
+
1116
+ /* Field validation error */
1117
+ .cf-field-error .cf-input { border-color: #ef4444 !important; box-shadow: 0 0 0 3px rgba(239,68,68,0.1) !important; }
1118
+ .cf-field-error .cf-choice[data-selected="false"] { border-color: #fca5a5 !important; }
1119
+
1120
+ /* Action button styles */
1121
+ .cf-btn-secondary {
1122
+ font-family: var(--font-display); font-weight: 600; letter-spacing: -0.01em;
1123
+ border-radius: 14px; padding: 14px 28px; font-size: 16px; border: 2px solid;
1124
+ cursor: pointer; transition: all 0.25s var(--ease-out-expo); background: transparent;
1125
+ }
1126
+ .cf-btn-secondary:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); transform: translateY(-1px); }
1127
+ .cf-btn-ghost {
1128
+ font-family: var(--font-display); font-weight: 500; letter-spacing: -0.01em;
1129
+ border-radius: 14px; padding: 14px 28px; font-size: 16px; border: none;
1130
+ cursor: pointer; transition: all 0.2s var(--ease-out-expo); background: transparent;
1131
+ }
1132
+ .cf-btn-ghost:hover { background: rgba(0,0,0,0.04); }
1133
+
1134
+ /* Sticky bottom bar */
1135
+ .cf-sticky-bar {
1136
+ position: fixed; bottom: 0; left: 0; right: 0; z-index: 80;
1137
+ transition: transform 0.3s var(--ease-out-expo);
1138
+ }
1139
+ .cf-sticky-bar.hidden { transform: translateY(100%); }
1140
+
1141
+ /* Resume prompt */
1142
+ .cf-resume-backdrop {
1143
+ position: fixed; inset: 0; z-index: 99; background: rgba(0,0,0,0.2);
1144
+ backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center;
1145
+ }
930
1146
  </style>
931
1147
  </head>
932
1148
  <body>
933
- <div class="dev-banner">
1149
+ <div class="dev-banner" id="dev-banner">
1150
+ <span class="drag-handle" id="banner-drag" title="Drag to reposition">\u283F</span>
934
1151
  <span class="dot"></span>
935
1152
  <span class="label">LOCAL DEV</span>
936
1153
  <span class="slug">${schema.slug || "catalog"}</span>
937
1154
  <span class="stub-tags">
938
- <span class="stub-tag">Checkout: stubbed</span>
939
- <span class="stub-tag">Analytics: off</span>
1155
+ <span class="stub-tag" id="checkout-tag">${devConfig?.stripeEnabled ? "Checkout: live (test)" : "Checkout: stubbed"}</span>
1156
+ <span class="stub-tag">Events: local</span>
940
1157
  <span class="stub-tag clickable" id="pages-btn">Pages</span>
1158
+ <span class="stub-tag debug-btn" id="debug-btn">Debug</span>
1159
+ <span class="validation-tag" id="validation-tag"></span>
1160
+ <button class="minimize-btn" id="banner-minimize" title="Minimize toolbar">\u25B4</button>
941
1161
  </span>
942
1162
  </div>
1163
+ <div class="dev-banner-restore" id="banner-restore">
1164
+ <span class="restore-dot"></span>
1165
+ <span>DEV</span>
1166
+ </div>
943
1167
  <div class="pages-overlay" id="pages-overlay">
944
1168
  <button class="close-btn" id="pages-close">&times;</button>
1169
+ <div class="zoom-controls">
1170
+ <button class="zoom-btn" id="zoom-in">+</button>
1171
+ <div class="zoom-level" id="zoom-level">100%</div>
1172
+ <button class="zoom-btn" id="zoom-out">&minus;</button>
1173
+ <button class="zoom-btn" id="zoom-fit" style="font-size:11px;margin-top:4px;">Fit</button>
1174
+ </div>
945
1175
  <div class="mindmap-container" id="mindmap-container"></div>
946
1176
  </div>
947
1177
  <div class="inspector-highlight" id="inspector-highlight" style="display:none"></div>
948
1178
  <div class="inspector-tooltip" id="inspector-tooltip" style="display:none"></div>
949
1179
  <div class="inspector-active-banner" id="inspector-banner" style="display:none">Inspector active &mdash; hover elements, click to copy</div>
950
1180
  <div id="catalog-root"></div>
1181
+ <div class="debug-panel" id="debug-panel">
1182
+ <div class="dp-header"><span>Debug Panel</span><button class="dp-close" id="debug-close">&times;</button></div>
1183
+ <div class="dp-body" id="debug-body"></div>
1184
+ </div>
951
1185
 
952
1186
  <script id="__catalog_data" type="application/json">${schemaJson}</script>
1187
+ <script id="__validation_data" type="application/json">${JSON.stringify(validation || { errors: [], warnings: [] })}</script>
1188
+ <script id="__dev_config" type="application/json">${JSON.stringify(devConfig || { stripeEnabled: false, port })}</script>
953
1189
 
954
1190
  <script type="module">
955
1191
  import React from 'https://esm.sh/react@18';
@@ -959,6 +1195,16 @@ function buildPreviewHtml(schema, port) {
959
1195
  const schema = JSON.parse(document.getElementById('__catalog_data').textContent);
960
1196
  const themeColor = '${themeColor}';
961
1197
 
1198
+ // --- Shared Engine (auto-generated from shared/engine/{conditions,routing,validate}.ts) ---
1199
+ ${engineScript}
1200
+
1201
+ // --- Dev context for condition/routing evaluation ---
1202
+ const devContext = (() => {
1203
+ const params = {};
1204
+ new URLSearchParams(window.location.search).forEach((v, k) => { params[k] = v; });
1205
+ return { url_params: params, hints: {}, quiz_scores: null, video_state: null };
1206
+ })();
1207
+
962
1208
  // --- Markdown-ish text rendering ---
963
1209
  function inlineMarkdown(text) {
964
1210
  return text
@@ -1001,26 +1247,31 @@ function buildPreviewHtml(schema, port) {
1001
1247
  return 'text-left';
1002
1248
  }
1003
1249
 
1004
- // --- Routing helpers ---
1005
- function getNextPageId(currentId, routing, formState) {
1006
- if (!routing || !routing.edges) return null;
1007
- const edges = routing.edges.filter(e => e.from === currentId);
1008
- // Sort by priority (lower = higher priority)
1009
- edges.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));
1010
- for (const edge of edges) {
1011
- if (!edge.conditions || edge.conditions.length === 0) return edge.to;
1012
- const match = edge.conditions.every(cond => {
1013
- const val = formState[cond.field];
1014
- if (cond.operator === 'equals') return val === cond.value;
1015
- if (cond.operator === 'not_equals') return val !== cond.value;
1016
- if (cond.operator === 'contains') return typeof val === 'string' && val.includes(cond.value);
1017
- return true;
1018
- });
1019
- if (match) return edge.to;
1250
+ // --- Variant resolution ---
1251
+ function resolveComponentVariants(props, hints) {
1252
+ const resolved = { ...props };
1253
+ const variantKeys = Object.keys(props).filter(k => k.endsWith('__variants'));
1254
+ for (const variantKey of variantKeys) {
1255
+ const baseProp = variantKey.replace('__variants', '');
1256
+ const variants = props[variantKey];
1257
+ if (!variants || typeof variants !== 'object') continue;
1258
+ let bestMatch = null;
1259
+ for (const [conditionStr, value] of Object.entries(variants)) {
1260
+ const conditions = conditionStr.split(',').map(c => c.trim());
1261
+ let allMatch = true, score = 0;
1262
+ for (const cond of conditions) {
1263
+ const [hintKey, hintValue] = cond.split('=');
1264
+ if (hints[hintKey] === hintValue) score++;
1265
+ else { allMatch = false; break; }
1266
+ }
1267
+ if (allMatch && score > 0 && (!bestMatch || score > bestMatch.score)) {
1268
+ bestMatch = { value, score };
1269
+ }
1270
+ }
1271
+ if (bestMatch) resolved[baseProp] = bestMatch.value;
1272
+ delete resolved[variantKey];
1020
1273
  }
1021
- // Fallback: default edge (no conditions)
1022
- const defaultEdge = edges.find(e => !e.conditions || e.conditions.length === 0);
1023
- return defaultEdge ? defaultEdge.to : null;
1274
+ return resolved;
1024
1275
  }
1025
1276
 
1026
1277
  // --- Component Renderers ---
@@ -1085,11 +1336,7 @@ function buildPreviewHtml(schema, port) {
1085
1336
  h('iframe', { src: embedUrl, className: 'absolute inset-0 w-full h-full', allow: 'autoplay; fullscreen; picture-in-picture', allowFullScreen: true })
1086
1337
  );
1087
1338
  }
1088
- return h('div', { className: 'w-full ' + compClass, style: compStyle },
1089
- h('div', { className: 'relative w-full overflow-hidden rounded-2xl shadow-lg' },
1090
- h('video', { src: props.hls_url || src, controls: true, playsInline: true, poster: props.poster, className: 'w-full' })
1091
- )
1092
- );
1339
+ return h(VideoPlayer, { comp, isCover, compClass, compStyle });
1093
1340
  }
1094
1341
 
1095
1342
  case 'html':
@@ -1260,13 +1507,195 @@ function buildPreviewHtml(schema, port) {
1260
1507
  return h(SwitchInput, { comp, type, formState, onFieldChange, isCover, compClass, compStyle });
1261
1508
 
1262
1509
  case 'payment':
1263
- return h('div', { className: 'checkout-stub ' + compClass, style: compStyle },
1264
- h('h3', null, 'Stripe Checkout (Dev Stub)'),
1265
- h('p', null, 'Payment processing is disabled in local dev mode.'),
1266
- props.amount ? h('p', { className: 'mt-2 font-bold text-lg' },
1267
- (props.currency || 'USD').toUpperCase() + ' ' + (props.amount / 100).toFixed(2)
1268
- ) : null
1510
+ return h(PaymentComponent, { comp, formState, isCover, compClass, compStyle });
1511
+
1512
+ case 'date':
1513
+ case 'datetime':
1514
+ case 'time': {
1515
+ const htmlType = type === 'datetime' ? 'datetime-local' : type;
1516
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1517
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1518
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1519
+ ) : null,
1520
+ h('input', { type: htmlType, className: 'cf-input', value: formState[comp.id] ?? '', onChange: (e) => onFieldChange(comp.id, e.target.value) })
1521
+ );
1522
+ }
1523
+
1524
+ case 'date_range': {
1525
+ const rangeVal = formState[comp.id] || {};
1526
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1527
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1528
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1529
+ ) : null,
1530
+ h('div', { className: 'flex gap-3 items-center' },
1531
+ h('input', { type: 'date', className: 'cf-input flex-1', value: rangeVal.start || '', onChange: (e) => onFieldChange(comp.id, { ...rangeVal, start: e.target.value }) }),
1532
+ h('span', { className: 'text-gray-400 text-sm' }, 'to'),
1533
+ h('input', { type: 'date', className: 'cf-input flex-1', value: rangeVal.end || '', onChange: (e) => onFieldChange(comp.id, { ...rangeVal, end: e.target.value }) })
1534
+ )
1535
+ );
1536
+ }
1537
+
1538
+ case 'picture_choice': {
1539
+ const pcOptions = props.options || [];
1540
+ const pcMultiple = props.multiple;
1541
+ const pcSelected = formState[comp.id];
1542
+ const pcCols = props.columns || Math.min(pcOptions.length, 3);
1543
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1544
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1545
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1546
+ ) : null,
1547
+ h('div', { className: 'grid gap-3', style: { gridTemplateColumns: 'repeat(' + pcCols + ', 1fr)' } },
1548
+ ...(pcOptions).map((opt, j) => {
1549
+ const val = typeof opt === 'string' ? opt : opt.value;
1550
+ const lbl = typeof opt === 'string' ? opt : opt.label || opt.value;
1551
+ const img = typeof opt === 'object' ? opt.image : null;
1552
+ const isSel = pcMultiple ? Array.isArray(pcSelected) && pcSelected.includes(val) : pcSelected === val;
1553
+ return h('button', {
1554
+ key: j, className: 'rounded-xl border-2 overflow-hidden transition-all text-center p-2',
1555
+ style: isSel ? { borderColor: themeColor, boxShadow: '0 0 0 3px ' + themeColor + '26' } : { borderColor: '#e2e4e9' },
1556
+ onClick: () => {
1557
+ if (pcMultiple) {
1558
+ const arr = Array.isArray(pcSelected) ? [...pcSelected] : [];
1559
+ onFieldChange(comp.id, isSel ? arr.filter(v => v !== val) : [...arr, val]);
1560
+ } else { onFieldChange(comp.id, val); }
1561
+ },
1562
+ },
1563
+ img ? h('img', { src: img, alt: lbl, className: 'w-full h-24 object-cover rounded-lg mb-2' }) : null,
1564
+ h('span', { className: 'text-sm font-medium ' + (isCover ? 'text-white' : 'text-gray-700') }, lbl)
1565
+ );
1566
+ })
1567
+ )
1568
+ );
1569
+ }
1570
+
1571
+ case 'opinion_scale': {
1572
+ const osMin = props.min ?? 1, osMax = props.max ?? 10;
1573
+ const osValue = formState[comp.id];
1574
+ const osButtons = [];
1575
+ for (let i = osMin; i <= osMax; i++) osButtons.push(i);
1576
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1577
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1578
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1579
+ ) : null,
1580
+ h('div', { className: 'flex items-center gap-1' },
1581
+ props.min_label ? h('span', { className: 'text-xs text-gray-400 mr-2 shrink-0' }, props.min_label) : null,
1582
+ ...osButtons.map(n => h('button', {
1583
+ key: n, className: 'flex-1 py-2.5 rounded-lg text-sm font-semibold transition-all border',
1584
+ style: osValue === n ? { backgroundColor: themeColor, color: 'white', borderColor: themeColor } : { backgroundColor: 'transparent', color: isCover ? 'white' : '#374151', borderColor: '#e5e7eb' },
1585
+ onClick: () => onFieldChange(comp.id, n),
1586
+ }, String(n))),
1587
+ props.max_label ? h('span', { className: 'text-xs text-gray-400 ml-2 shrink-0' }, props.max_label) : null
1588
+ )
1589
+ );
1590
+ }
1591
+
1592
+ case 'address':
1593
+ return h(AddressInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1594
+
1595
+ case 'currency': {
1596
+ const currSymbol = props.currency_symbol || props.prefix || '$';
1597
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1598
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1599
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1600
+ ) : null,
1601
+ h('div', { className: 'relative' },
1602
+ h('span', { className: 'absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 font-medium pointer-events-none' }, currSymbol),
1603
+ h('input', { type: 'number', className: 'cf-input', style: { paddingLeft: '2.5rem' },
1604
+ placeholder: props.placeholder || '0.00', step: props.step || '0.01',
1605
+ value: formState[comp.id] ?? '', onChange: (e) => onFieldChange(comp.id, e.target.value) })
1606
+ )
1607
+ );
1608
+ }
1609
+
1610
+ case 'file_upload':
1611
+ return h(FileUploadInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1612
+
1613
+ case 'signature':
1614
+ return h(SignatureInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1615
+
1616
+ case 'table': {
1617
+ const tHeaders = props.headers || [];
1618
+ const tRows = props.rows || [];
1619
+ return h('div', { className: 'overflow-x-auto rounded-xl border border-gray-200 ' + compClass, style: compStyle },
1620
+ h('table', { className: 'w-full text-sm' },
1621
+ tHeaders.length > 0 ? h('thead', null,
1622
+ h('tr', null, ...tHeaders.map((hdr, i) => h('th', { key: i, className: 'px-4 py-3 text-left font-semibold text-white', style: { backgroundColor: themeColor } }, hdr)))
1623
+ ) : null,
1624
+ h('tbody', null,
1625
+ ...tRows.map((row, i) => h('tr', { key: i, className: i % 2 === 0 ? 'bg-white' : 'bg-gray-50' },
1626
+ ...(Array.isArray(row) ? row : Object.values(row)).map((cell, j) => h('td', { key: j, className: 'px-4 py-3 text-gray-700 border-t border-gray-100' }, String(cell ?? '')))
1627
+ ))
1628
+ )
1629
+ )
1630
+ );
1631
+ }
1632
+
1633
+ case 'social_links': {
1634
+ const socialLinks = props.links || [];
1635
+ return h('div', { className: 'flex items-center gap-3 ' + compClass, style: compStyle },
1636
+ ...socialLinks.map((link, i) => h('a', {
1637
+ key: i, href: link.url, target: '_blank', rel: 'noopener noreferrer',
1638
+ className: 'w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-bold transition-transform hover:scale-110',
1639
+ style: { backgroundColor: themeColor }, title: link.platform,
1640
+ }, (link.platform || '?')[0].toUpperCase()))
1641
+ );
1642
+ }
1643
+
1644
+ case 'accordion': {
1645
+ const accItems = props.items || [];
1646
+ return h('div', { className: 'space-y-3 ' + compClass, style: compStyle },
1647
+ ...accItems.map((item, i) => h(FaqItem, { key: i, question: item.title || item.question, answer: item.content || item.answer, isCover }))
1648
+ );
1649
+ }
1650
+
1651
+ case 'tabs':
1652
+ return h(TabsComponent, { comp, isCover, compClass, compStyle });
1653
+
1654
+ case 'countdown':
1655
+ return h(CountdownComponent, { comp, compClass, compStyle });
1656
+
1657
+ case 'comparison_table': {
1658
+ const ctPlans = props.plans || [];
1659
+ const ctFeatures = props.features || [];
1660
+ return h('div', { className: 'overflow-x-auto ' + compClass, style: compStyle },
1661
+ h('table', { className: 'w-full text-sm' },
1662
+ h('thead', null,
1663
+ h('tr', null,
1664
+ h('th', { className: 'px-4 py-3 text-left text-gray-500 font-medium' }, 'Feature'),
1665
+ ...ctPlans.map((plan, i) => h('th', { key: i, className: 'px-4 py-3 text-center font-bold', style: plan.highlighted ? { color: themeColor } : undefined }, plan.name || plan.label))
1666
+ )
1667
+ ),
1668
+ h('tbody', null,
1669
+ ...ctFeatures.map((feat, i) => h('tr', { key: i, className: i % 2 === 0 ? 'bg-white' : 'bg-gray-50' },
1670
+ h('td', { className: 'px-4 py-3 text-gray-700 font-medium border-t border-gray-100' }, feat.name || feat.label),
1671
+ ...ctPlans.map((plan, j) => {
1672
+ const fVal = plan.features?.[feat.id || feat.name] ?? feat.values?.[j];
1673
+ const fDisplay = fVal === true ? '\\u2713' : fVal === false ? '\\u2014' : String(fVal ?? '\\u2014');
1674
+ return h('td', { key: j, className: 'px-4 py-3 text-center border-t border-gray-100', style: fVal === true ? { color: themeColor } : undefined }, fDisplay);
1675
+ })
1676
+ ))
1677
+ )
1678
+ )
1679
+ );
1680
+ }
1681
+
1682
+ case 'progress_bar': {
1683
+ const pbValue = props.value ?? 0;
1684
+ const pbMax = props.max ?? 100;
1685
+ const pbPct = Math.min(100, Math.max(0, (pbValue / pbMax) * 100));
1686
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1687
+ props.label ? h('div', { className: 'flex justify-between text-sm' },
1688
+ h('span', { className: 'font-medium text-gray-700' }, props.label),
1689
+ h('span', { className: 'text-gray-400' }, Math.round(pbPct) + '%')
1690
+ ) : null,
1691
+ h('div', { className: 'w-full h-3 bg-gray-100 rounded-full overflow-hidden' },
1692
+ h('div', { className: 'h-full rounded-full progress-bar-fill', style: { width: pbPct + '%', backgroundColor: themeColor } })
1693
+ )
1269
1694
  );
1695
+ }
1696
+
1697
+ case 'modal':
1698
+ return h(ModalComponent, { comp, isCover, compClass, compStyle });
1270
1699
 
1271
1700
  default:
1272
1701
  return h('div', {
@@ -1427,14 +1856,332 @@ function buildPreviewHtml(schema, port) {
1427
1856
  );
1428
1857
  }
1429
1858
 
1859
+ // --- Additional input/display components ---
1860
+ function AddressInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1861
+ const props = comp.props || {};
1862
+ const addr = formState[comp.id] || {};
1863
+ const up = (field, val) => onFieldChange(comp.id, { ...addr, [field]: val });
1864
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1865
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1866
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null) : null,
1867
+ h('div', { className: 'space-y-2' },
1868
+ h('input', { type: 'text', className: 'cf-input', placeholder: 'Street address', value: addr.street || '', onChange: (e) => up('street', e.target.value) }),
1869
+ h('div', { className: 'grid grid-cols-2 gap-2' },
1870
+ h('input', { type: 'text', className: 'cf-input', placeholder: 'City', value: addr.city || '', onChange: (e) => up('city', e.target.value) }),
1871
+ h('input', { type: 'text', className: 'cf-input', placeholder: 'State', value: addr.state || '', onChange: (e) => up('state', e.target.value) })
1872
+ ),
1873
+ h('div', { className: 'grid grid-cols-2 gap-2' },
1874
+ h('input', { type: 'text', className: 'cf-input', placeholder: 'ZIP / Postal', value: addr.zip || '', onChange: (e) => up('zip', e.target.value) }),
1875
+ h('input', { type: 'text', className: 'cf-input', placeholder: 'Country', value: addr.country || '', onChange: (e) => up('country', e.target.value) })
1876
+ )
1877
+ )
1878
+ );
1879
+ }
1880
+
1881
+ function FileUploadInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1882
+ const props = comp.props || {};
1883
+ const fileName = formState[comp.id] || '';
1884
+ const fileRef = React.useRef(null);
1885
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1886
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1887
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null) : null,
1888
+ h('div', { className: 'border-2 border-dashed border-gray-200 rounded-xl p-6 text-center' },
1889
+ h('input', { type: 'file', ref: fileRef, className: 'hidden', accept: props.accept,
1890
+ onChange: (e) => { const f = e.target.files?.[0]; if (f) onFieldChange(comp.id, f.name); } }),
1891
+ fileName
1892
+ ? h('div', { className: 'flex items-center justify-center gap-2' },
1893
+ h('span', { className: 'text-sm text-gray-700' }, fileName),
1894
+ h('button', { className: 'text-xs text-red-500 hover:text-red-700', onClick: () => onFieldChange(comp.id, '') }, 'Remove'))
1895
+ : h('button', { className: 'text-sm font-medium', style: { color: themeColor }, onClick: () => fileRef.current?.click() }, props.button_text || 'Choose file'),
1896
+ h('p', { className: 'text-xs text-gray-400 mt-2' }, 'Files are not uploaded in dev mode')
1897
+ )
1898
+ );
1899
+ }
1900
+
1901
+ function SignatureInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1902
+ const props = comp.props || {};
1903
+ const signed = !!formState[comp.id];
1904
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1905
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1906
+ props.label, props.required ? h('span', { className: 'text-red-500 ml-1' }, '*') : null) : null,
1907
+ h('div', { className: 'border rounded-xl p-4' },
1908
+ signed
1909
+ ? h('div', { className: 'flex items-center justify-between' },
1910
+ h('span', { className: 'text-sm text-green-600 font-medium' }, '\\u2713 Signature captured'),
1911
+ h('button', { className: 'text-xs text-gray-400 hover:text-gray-600', onClick: () => onFieldChange(comp.id, '') }, 'Clear'))
1912
+ : h('button', {
1913
+ className: 'w-full py-8 text-center text-sm text-gray-400 border-2 border-dashed rounded-lg hover:bg-gray-50',
1914
+ onClick: () => onFieldChange(comp.id, 'signature_' + Date.now()),
1915
+ }, 'Click to sign (canvas stubbed in dev)')
1916
+ )
1917
+ );
1918
+ }
1919
+
1920
+ function TabsComponent({ comp, isCover, compClass, compStyle }) {
1921
+ const props = comp.props || {};
1922
+ const tabs = props.tabs || [];
1923
+ const [activeTab, setActiveTab] = React.useState(0);
1924
+ return h('div', { className: compClass, style: compStyle },
1925
+ h('div', { className: 'flex border-b border-gray-200 mb-4' },
1926
+ ...tabs.map((tab, i) => h('button', {
1927
+ key: i, className: 'px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px',
1928
+ style: i === activeTab ? { color: themeColor, borderColor: themeColor } : { color: '#9ca3af', borderColor: 'transparent' },
1929
+ onClick: () => setActiveTab(i),
1930
+ }, tab.label || tab.title || 'Tab ' + (i + 1)))
1931
+ ),
1932
+ tabs[activeTab] ? h('div', { className: 'text-sm text-gray-600', dangerouslySetInnerHTML: { __html: renderMarkdownish(tabs[activeTab].content || '') } }) : null
1933
+ );
1934
+ }
1935
+
1936
+ function CountdownComponent({ comp, compClass, compStyle }) {
1937
+ const props = comp.props || {};
1938
+ const [timeLeft, setTimeLeft] = React.useState({});
1939
+ React.useEffect(() => {
1940
+ const target = new Date(props.target_date).getTime();
1941
+ const update = () => {
1942
+ const diff = Math.max(0, target - Date.now());
1943
+ setTimeLeft({ days: Math.floor(diff / 86400000), hours: Math.floor((diff % 86400000) / 3600000), minutes: Math.floor((diff % 3600000) / 60000), seconds: Math.floor((diff % 60000) / 1000) });
1944
+ };
1945
+ update();
1946
+ const iv = setInterval(update, 1000);
1947
+ return () => clearInterval(iv);
1948
+ }, [props.target_date]);
1949
+ return h('div', { className: 'flex items-center justify-center gap-4 ' + compClass, style: compStyle },
1950
+ ...['days', 'hours', 'minutes', 'seconds'].map(unit =>
1951
+ h('div', { key: unit, className: 'text-center' },
1952
+ h('div', { className: 'text-3xl font-bold', style: { color: themeColor, fontFamily: 'var(--font-display)' } }, String(timeLeft[unit] ?? 0).padStart(2, '0')),
1953
+ h('div', { className: 'text-xs text-gray-400 uppercase tracking-wider mt-1' }, unit)
1954
+ )
1955
+ )
1956
+ );
1957
+ }
1958
+
1959
+ function ModalComponent({ comp, isCover, compClass, compStyle }) {
1960
+ const props = comp.props || {};
1961
+ const [open, setOpen] = React.useState(false);
1962
+ return h(React.Fragment, null,
1963
+ h('button', { className: 'cf-btn-primary text-white', style: { backgroundColor: themeColor, ...compStyle }, onClick: () => setOpen(true) },
1964
+ props.trigger_label || props.label || 'Open'),
1965
+ open ? h('div', { className: 'fixed inset-0 z-[99] flex items-center justify-center' },
1966
+ h('div', { className: 'absolute inset-0 bg-black/40 backdrop-blur-sm', onClick: () => setOpen(false) }),
1967
+ h('div', { className: 'relative bg-white rounded-2xl max-w-lg w-full mx-4 p-6 shadow-2xl max-h-[80vh] overflow-y-auto' },
1968
+ h('div', { className: 'flex justify-between items-center mb-4' },
1969
+ props.title ? h('h3', { className: 'text-lg font-bold text-gray-900' }, props.title) : null,
1970
+ h('button', { className: 'text-gray-400 hover:text-gray-600 text-xl', onClick: () => setOpen(false) }, '\\u2715')
1971
+ ),
1972
+ h('div', { className: 'text-sm text-gray-600', dangerouslySetInnerHTML: { __html: renderMarkdownish(props.content || props.body || '') } })
1973
+ )
1974
+ ) : null
1975
+ );
1976
+ }
1977
+
1978
+ function ActionButton({ action, themeColor, onAction }) {
1979
+ const st = action.style || 'primary';
1980
+ const hasSide = !!action.side_statement;
1981
+ const btnProps = st === 'primary'
1982
+ ? { className: 'cf-btn-primary text-white', style: { backgroundColor: themeColor } }
1983
+ : st === 'secondary'
1984
+ ? { className: 'cf-btn-secondary', style: { borderColor: themeColor, color: themeColor } }
1985
+ : st === 'danger'
1986
+ ? { className: 'cf-btn-primary text-white', style: { backgroundColor: '#ef4444' } }
1987
+ : { className: 'cf-btn-ghost', style: { color: themeColor + 'cc' } };
1988
+ const btn = h('button', {
1989
+ ...btnProps,
1990
+ className: btnProps.className + (hasSide ? ' flex-1' : ' w-full') + ' flex items-center justify-center',
1991
+ onClick: () => onAction(action),
1992
+ },
1993
+ action.icon ? h('span', { className: 'mr-2' }, action.icon) : null,
1994
+ action.label
1995
+ );
1996
+ return h('div', { className: 'w-full' },
1997
+ hasSide ? h('div', { className: 'flex items-center gap-4' }, btn, h('span', { className: 'text-sm font-medium text-gray-600 shrink-0' }, action.side_statement)) : btn,
1998
+ action.reassurance ? h('p', { className: 'text-xs text-gray-400 mt-1.5 text-center' }, action.reassurance) : null
1999
+ );
2000
+ }
2001
+
2002
+ function VideoPlayer({ comp, isCover, compClass, compStyle }) {
2003
+ const props = comp.props || {};
2004
+ const videoRef = React.useRef(null);
2005
+ React.useEffect(() => {
2006
+ const video = videoRef.current;
2007
+ if (!video) return;
2008
+ const handler = () => {
2009
+ const pct = video.duration ? Math.round((video.currentTime / video.duration) * 100) : 0;
2010
+ window.__videoWatchState = window.__videoWatchState || {};
2011
+ window.__videoWatchState[comp.id] = { watch_percent: pct, playing: !video.paused, duration: video.duration };
2012
+ };
2013
+ video.addEventListener('timeupdate', handler);
2014
+ return () => video.removeEventListener('timeupdate', handler);
2015
+ }, []);
2016
+ return h('div', { className: 'w-full ' + compClass, style: compStyle },
2017
+ h('div', { className: 'relative w-full overflow-hidden rounded-2xl shadow-lg' },
2018
+ h('video', { ref: videoRef, src: props.hls_url || props.src, controls: true, playsInline: true, poster: props.poster, className: 'w-full' })
2019
+ )
2020
+ );
2021
+ }
2022
+
2023
+ function StickyBottomBar({ config, page, formState, cartItems, themeColor, onNext, onAction, onBack, historyLen }) {
2024
+ const [visible, setVisible] = React.useState(!config.delay_ms);
2025
+ const [scrollDir, setScrollDir] = React.useState('down');
2026
+ React.useEffect(() => {
2027
+ if (!config.delay_ms) return;
2028
+ const timer = setTimeout(() => setVisible(true), config.delay_ms);
2029
+ return () => clearTimeout(timer);
2030
+ }, [config.delay_ms]);
2031
+ React.useEffect(() => {
2032
+ if (config.scroll_behavior !== 'show_on_up') return;
2033
+ let lastY = window.scrollY;
2034
+ const handler = () => { const dir = window.scrollY > lastY ? 'down' : 'up'; setScrollDir(dir); lastY = window.scrollY; };
2035
+ window.addEventListener('scroll', handler, { passive: true });
2036
+ return () => window.removeEventListener('scroll', handler);
2037
+ }, []);
2038
+ const show = visible && (config.scroll_behavior !== 'show_on_up' || scrollDir === 'up');
2039
+ const interpolate = (text) => text ? text.replace(/\\{\\{(\\w+)\\}\\}/g, (_, id) => formState[id] ?? '') : text;
2040
+ const bgStyles = {
2041
+ solid: { backgroundColor: 'white', borderTop: '1px solid #e5e7eb' },
2042
+ glass: { backgroundColor: 'rgba(255,255,255,0.85)', backdropFilter: 'blur(16px)', borderTop: '1px solid rgba(0,0,0,0.05)' },
2043
+ glass_dark: { backgroundColor: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(16px)', color: 'white' },
2044
+ gradient: { background: 'linear-gradient(135deg, ' + themeColor + ' 0%, ' + themeColor + 'dd 100%)', color: 'white' },
2045
+ };
2046
+ const handlePrimary = () => {
2047
+ const dispatch = config.primary_action?.dispatch;
2048
+ if (!dispatch || dispatch === 'next') { onNext(); return; }
2049
+ if (dispatch.startsWith('action:')) {
2050
+ const actionId = dispatch.slice(7);
2051
+ const action = page.actions?.find(a => a.id === actionId);
2052
+ if (action) onAction(action); else onNext();
2053
+ } else { onNext(); }
2054
+ };
2055
+ return h('div', {
2056
+ className: 'cf-sticky-bar' + (show ? '' : ' hidden'),
2057
+ style: bgStyles[config.style || 'solid'] || bgStyles.solid,
2058
+ },
2059
+ h('div', { className: 'max-w-2xl mx-auto px-6 py-4 flex items-center justify-between gap-4' },
2060
+ config.show_back && historyLen > 0
2061
+ ? h('button', { className: 'text-sm text-gray-500 hover:text-gray-700', onClick: onBack }, '\\u2190 Back') : null,
2062
+ h('div', { className: 'flex-1 text-center' },
2063
+ config.subtitle ? h('p', { className: 'text-xs opacity-60 mb-0.5' }, interpolate(config.subtitle)) : null
2064
+ ),
2065
+ h('div', { className: 'flex items-center gap-3' },
2066
+ config.cart_badge && cartItems.length > 0
2067
+ ? h('span', { className: 'bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center' }, cartItems.length) : null,
2068
+ h('button', {
2069
+ className: 'cf-btn-primary text-white text-sm',
2070
+ style: { backgroundColor: config.style === 'gradient' ? 'rgba(255,255,255,0.9)' : themeColor, color: config.style === 'gradient' ? themeColor : 'white' },
2071
+ disabled: config.disabled,
2072
+ onClick: handlePrimary,
2073
+ }, interpolate(config.primary_action?.label || page.submit_label || 'Continue'))
2074
+ )
2075
+ )
2076
+ );
2077
+ }
2078
+
2079
+ // --- Dev Config ---
2080
+ const devConfig = JSON.parse(document.getElementById('__dev_config').textContent);
2081
+
2082
+ // --- Local Event Emitter ---
2083
+ const devEvents = {
2084
+ _sse: null,
2085
+ init() {
2086
+ this._sse = new EventSource('/__dev_events_stream');
2087
+ this._sse.onerror = () => {};
2088
+ },
2089
+ emit(type, data) {
2090
+ const event = { type, timestamp: new Date().toISOString(), data };
2091
+ // Fire to local SSE listeners (agents can listen via /__dev_events_stream)
2092
+ fetch('/__dev_event', {
2093
+ method: 'POST',
2094
+ headers: { 'Content-Type': 'application/json' },
2095
+ body: JSON.stringify(event),
2096
+ }).catch(() => {});
2097
+ // Also dispatch as browser event for debug panel
2098
+ window.dispatchEvent(new CustomEvent('devEvent', { detail: event }));
2099
+ }
2100
+ };
2101
+ devEvents.init();
2102
+
2103
+ // --- Payment Component ---
2104
+ function PaymentComponent({ comp, formState, isCover, compClass, compStyle }) {
2105
+ const props = comp.props || {};
2106
+ const [loading, setLoading] = React.useState(false);
2107
+ const [error, setError] = React.useState(null);
2108
+
2109
+ const handleCheckout = React.useCallback(async () => {
2110
+ devEvents.emit('checkout_started', { component_id: comp.id, amount: props.amount, currency: props.currency });
2111
+ setLoading(true);
2112
+ setError(null);
2113
+ try {
2114
+ const res = await fetch('/__dev_checkout', {
2115
+ method: 'POST',
2116
+ headers: { 'Content-Type': 'application/json' },
2117
+ body: JSON.stringify({
2118
+ line_items: [{
2119
+ title: props.title || comp.id,
2120
+ amount_cents: props.amount,
2121
+ currency: (props.currency || 'usd').toLowerCase(),
2122
+ quantity: 1,
2123
+ stripe_price_id: props.stripe_price_id,
2124
+ payment_type: props.checkout_type === 'redirect' ? 'one_time' : (schema.settings?.checkout?.payment_type || 'one_time'),
2125
+ }],
2126
+ form_state: formState,
2127
+ catalog_slug: schema.slug,
2128
+ }),
2129
+ });
2130
+ const data = await res.json();
2131
+ if (data.session_url) {
2132
+ devEvents.emit('checkout_redirect', { session_id: data.session_id });
2133
+ window.location.href = data.session_url;
2134
+ } else if (data.error) {
2135
+ setError(data.error);
2136
+ }
2137
+ } catch (err) {
2138
+ setError(err.message || 'Checkout failed');
2139
+ } finally {
2140
+ setLoading(false);
2141
+ }
2142
+ }, [comp.id, props, formState]);
2143
+
2144
+ // No Stripe key \u2014 show informative stub
2145
+ if (!devConfig.stripeEnabled) {
2146
+ return h('div', { className: 'checkout-stub ' + compClass, style: compStyle },
2147
+ h('h3', null, 'Stripe Checkout'),
2148
+ h('p', null, 'Add STRIPE_SECRET_KEY to your .env to enable real checkout in dev.'),
2149
+ props.amount ? h('p', { className: 'mt-2 font-bold text-lg' },
2150
+ (props.currency || 'USD').toUpperCase() + ' ' + (props.amount / 100).toFixed(2)
2151
+ ) : null,
2152
+ h('details', { className: 'mt-3 text-left', style: { fontSize: '11px', color: '#92400e' } },
2153
+ h('summary', { style: { cursor: 'pointer' } }, 'Checkout payload'),
2154
+ h('pre', { style: { whiteSpace: 'pre-wrap', marginTop: '4px' } },
2155
+ JSON.stringify({ amount: props.amount, currency: props.currency, stripe_price_id: props.stripe_price_id, payment_type: schema.settings?.checkout?.payment_type }, null, 2)
2156
+ )
2157
+ )
2158
+ );
2159
+ }
2160
+
2161
+ // Real Stripe checkout
2162
+ return h('div', { className: compClass, style: compStyle },
2163
+ h('button', {
2164
+ className: 'cf-btn-primary w-full text-white',
2165
+ style: { backgroundColor: themeColor, opacity: loading ? 0.7 : 1 },
2166
+ onClick: handleCheckout,
2167
+ disabled: loading,
2168
+ },
2169
+ loading ? 'Redirecting to Stripe...' : (props.button_text || (props.amount ? ((props.currency || 'USD').toUpperCase() + ' ' + (props.amount / 100).toFixed(2) + ' \u2014 Pay Now') : 'Checkout'))
2170
+ ),
2171
+ error ? h('p', { className: 'text-red-500 text-sm mt-2 text-center' }, error) : null
2172
+ );
2173
+ }
2174
+
1430
2175
  // --- Cart Components ---
1431
2176
  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';
1432
2177
 
1433
2178
  function CartButton({ itemCount, onClick }) {
1434
2179
  if (itemCount === 0) return null;
2180
+ const cartPos = (schema.settings?.cart?.position) || 'bottom-right';
2181
+ const posClass = { 'bottom-right': 'bottom-6 right-6', 'bottom-left': 'bottom-6 left-6', 'top-right': 'top-20 right-6', 'top-left': 'top-20 left-6' }[cartPos] || 'bottom-6 right-6';
1435
2182
  return h('button', {
1436
2183
  onClick,
1437
- className: 'fixed bottom-6 right-6 z-[90] flex items-center gap-2 rounded-full px-5 py-3.5 text-white font-semibold shadow-xl transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95',
2184
+ className: 'fixed z-[90] flex items-center gap-2 rounded-full px-5 py-3.5 text-white font-semibold shadow-xl transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95 ' + posClass,
1438
2185
  style: { backgroundColor: themeColor, fontFamily: 'var(--font-display)' },
1439
2186
  },
1440
2187
  h('svg', { className: 'w-5 h-5', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
@@ -1473,7 +2220,7 @@ function buildPreviewHtml(schema, port) {
1473
2220
  )
1474
2221
  ),
1475
2222
  h('div', null,
1476
- h('h2', { className: 'text-lg font-bold text-gray-900' }, 'Your Cart'),
2223
+ h('h2', { className: 'text-lg font-bold text-gray-900' }, schema.settings?.cart?.title || 'Your Cart'),
1477
2224
  h('p', { className: 'text-xs text-gray-400' }, items.length + ' ' + (items.length === 1 ? 'item' : 'items') + ' selected')
1478
2225
  )
1479
2226
  ),
@@ -1522,31 +2269,157 @@ function buildPreviewHtml(schema, port) {
1522
2269
  'Added to order'
1523
2270
  )
1524
2271
  ),
1525
- h('div', { className: 'checkout-stub' },
1526
- h('h3', null, 'Checkout (Dev Stub)'),
1527
- h('p', null, 'Payment processing is disabled in local dev mode.')
1528
- )
2272
+ h(CartCheckoutButton, { items, themeColor })
1529
2273
  ) : null
1530
2274
  )
1531
2275
  );
1532
2276
  }
1533
2277
 
1534
- // --- Main App ---
1535
- function CatalogPreview({ catalog }) {
1536
- const pages = catalog.pages || {};
1537
- const pageKeys = Object.keys(pages);
1538
- const routing = catalog.routing || {};
1539
- const [currentPageId, setCurrentPageId] = React.useState(routing.entry || pageKeys[0] || null);
1540
- const [formState, setFormState] = React.useState({});
1541
- const [history, setHistory] = React.useState([]);
1542
- const [cartItems, setCartItems] = React.useState([]);
1543
- const [cartOpen, setCartOpen] = React.useState(false);
2278
+ function CartCheckoutButton({ items, themeColor }) {
2279
+ const [loading, setLoading] = React.useState(false);
2280
+ const [error, setError] = React.useState(null);
1544
2281
 
1545
- // --- Cart logic ---
1546
- const addToCart = React.useCallback((pageId) => {
1547
- const pg = pages[pageId];
1548
- if (!pg?.offer) return;
1549
- const offer = pg.offer;
2282
+ const handleCheckout = React.useCallback(async () => {
2283
+ const state = window.__devDebugState;
2284
+ devEvents.emit('cart_checkout_started', { items: items.map(i => ({ offer_id: i.offer_id, title: i.title })) });
2285
+ setLoading(true);
2286
+ setError(null);
2287
+ try {
2288
+ const res = await fetch('/__dev_checkout', {
2289
+ method: 'POST',
2290
+ headers: { 'Content-Type': 'application/json' },
2291
+ body: JSON.stringify({
2292
+ line_items: items.map(item => ({
2293
+ title: item.title,
2294
+ amount_cents: item.amount_cents,
2295
+ currency: item.currency || 'usd',
2296
+ quantity: 1,
2297
+ stripe_price_id: item.stripe_price_id,
2298
+ payment_type: schema.settings?.checkout?.payment_type || 'one_time',
2299
+ })),
2300
+ form_state: state?.formState || {},
2301
+ catalog_slug: schema.slug,
2302
+ }),
2303
+ });
2304
+ const data = await res.json();
2305
+ if (data.session_url) {
2306
+ devEvents.emit('checkout_redirect', { session_id: data.session_id });
2307
+ window.location.href = data.session_url;
2308
+ } else if (data.error) {
2309
+ setError(data.error);
2310
+ }
2311
+ } catch (err) {
2312
+ setError(err.message || 'Checkout failed');
2313
+ } finally {
2314
+ setLoading(false);
2315
+ }
2316
+ }, [items]);
2317
+
2318
+ if (!devConfig.stripeEnabled) {
2319
+ return h('div', { className: 'checkout-stub' },
2320
+ h('h3', null, 'Checkout'),
2321
+ h('p', null, 'Add STRIPE_SECRET_KEY to .env for real checkout.'),
2322
+ h('details', { className: 'mt-2 text-left', style: { fontSize: '11px', color: '#92400e' } },
2323
+ h('summary', { style: { cursor: 'pointer' } }, 'Cart payload'),
2324
+ h('pre', { style: { whiteSpace: 'pre-wrap', marginTop: '4px' } },
2325
+ JSON.stringify(items.map(i => ({ offer_id: i.offer_id, title: i.title, price: i.price_display })), null, 2)
2326
+ )
2327
+ )
2328
+ );
2329
+ }
2330
+
2331
+ return h('div', { className: 'space-y-2' },
2332
+ h('button', {
2333
+ className: 'cf-btn-primary w-full text-white',
2334
+ style: { backgroundColor: themeColor, opacity: loading ? 0.7 : 1 },
2335
+ onClick: handleCheckout,
2336
+ disabled: loading,
2337
+ }, loading ? 'Redirecting to Stripe...' : 'Proceed to Checkout'),
2338
+ error ? h('p', { className: 'text-red-500 text-sm text-center' }, error) : null
2339
+ );
2340
+ }
2341
+
2342
+ // --- Main App ---
2343
+ function CatalogPreview({ catalog: rawCatalog }) {
2344
+ // --- Variant resolution ---
2345
+ const catalog = React.useMemo(() => {
2346
+ const urlParams = Object.fromEntries(new URLSearchParams(window.location.search));
2347
+ const variantSlug = urlParams.variant;
2348
+ const catalogHints = rawCatalog.hints || {};
2349
+ let hints = { ...(catalogHints.defaults || {}) };
2350
+ if (variantSlug && catalogHints.variants) {
2351
+ const variant = catalogHints.variants.find(v => v.slug === variantSlug);
2352
+ if (variant?.hints) hints = { ...hints, ...variant.hints };
2353
+ }
2354
+ devContext.hints = hints;
2355
+ if (Object.keys(hints).length === 0) return rawCatalog;
2356
+ const resolvedPages = JSON.parse(JSON.stringify(rawCatalog.pages || {}));
2357
+ for (const page of Object.values(resolvedPages)) {
2358
+ for (const comp of page.components || []) {
2359
+ comp.props = resolveComponentVariants(comp.props || {}, hints);
2360
+ }
2361
+ if (page.actions) {
2362
+ for (const action of page.actions) Object.assign(action, resolveComponentVariants(action, hints));
2363
+ }
2364
+ }
2365
+ return { ...rawCatalog, pages: resolvedPages };
2366
+ }, [rawCatalog]);
2367
+
2368
+ const pages = catalog.pages || {};
2369
+ const pageKeys = Object.keys(pages);
2370
+ const routing = catalog.routing || {};
2371
+ const entryPageId = routing.entry || pageKeys[0] || null;
2372
+ const saveKey = 'cf_resume_' + (catalog.slug || 'dev');
2373
+
2374
+ const [currentPageId, setCurrentPageId] = React.useState(entryPageId);
2375
+ // --- Prefill / default values ---
2376
+ const [formState, setFormState] = React.useState(() => {
2377
+ const state = {};
2378
+ for (const page of Object.values(pages)) {
2379
+ for (const comp of page.components || []) {
2380
+ if (comp.props?.default_value != null) state[comp.id] = comp.props.default_value;
2381
+ if ((comp.type === 'checkboxes' || comp.type === 'multiple_choice') && Array.isArray(comp.props?.options)) {
2382
+ for (const opt of comp.props.options) {
2383
+ if (!opt.inputs) continue;
2384
+ for (const input of opt.inputs) {
2385
+ const nd = input.props?.default_value ?? input.default_value;
2386
+ if (nd != null) state[comp.id + '.' + opt.value + '.' + input.id] = nd;
2387
+ }
2388
+ }
2389
+ }
2390
+ }
2391
+ }
2392
+ const mappings = catalog.settings?.url_params?.prefill_mappings;
2393
+ if (mappings) {
2394
+ const urlParams = Object.fromEntries(new URLSearchParams(window.location.search));
2395
+ for (const [param, compId] of Object.entries(mappings)) {
2396
+ if (urlParams[param]) state[compId] = urlParams[param];
2397
+ }
2398
+ }
2399
+ return state;
2400
+ });
2401
+ const [history, setHistory] = React.useState([]);
2402
+ const [cartItems, setCartItems] = React.useState([]);
2403
+ const [cartOpen, setCartOpen] = React.useState(false);
2404
+ const [showCheckout, setShowCheckout] = React.useState(false);
2405
+ const [validationErrors, setValidationErrors] = React.useState([]);
2406
+ const [savedSession, setSavedSession] = React.useState(null);
2407
+ const [showResumeModal, setShowResumeModal] = React.useState(false);
2408
+ const [submitted, setSubmitted] = React.useState(() => {
2409
+ const params = new URLSearchParams(window.location.search);
2410
+ return params.get('checkout') === 'success';
2411
+ });
2412
+ const formStateRef = React.useRef(formState);
2413
+ formStateRef.current = formState;
2414
+ const historyRef = React.useRef(history);
2415
+ historyRef.current = history;
2416
+ const autoAdvanceTimer = React.useRef(null);
2417
+
2418
+ // --- Cart logic ---
2419
+ const addToCart = React.useCallback((pageId) => {
2420
+ const pg = pages[pageId];
2421
+ if (!pg?.offer) return;
2422
+ const offer = pg.offer;
1550
2423
  setCartItems(prev => {
1551
2424
  if (prev.some(item => item.offer_id === offer.id)) return prev;
1552
2425
  return [...prev, {
@@ -1556,6 +2429,11 @@ function buildPreviewHtml(schema, port) {
1556
2429
  price_display: offer.price_display,
1557
2430
  price_subtext: offer.price_subtext,
1558
2431
  image: offer.image,
2432
+ stripe_price_id: offer.stripe_price_id,
2433
+ amount_cents: offer.amount_cents,
2434
+ currency: offer.currency,
2435
+ payment_type: offer.payment_type,
2436
+ interval: offer.interval,
1559
2437
  }];
1560
2438
  });
1561
2439
  }, [pages]);
@@ -1587,12 +2465,119 @@ function buildPreviewHtml(schema, port) {
1587
2465
  }
1588
2466
  }, [formState, pages, cartItems, addToCart, removeFromCart]);
1589
2467
 
1590
- // Expose navigation for mindmap
2468
+ // Expose navigation for mindmap + emit page_view + fire CatalogKit events
1591
2469
  React.useEffect(() => {
1592
2470
  window.__devNavigateTo = (id) => { setCurrentPageId(id); setHistory([]); window.scrollTo({ top: 0, behavior: 'smooth' }); };
1593
2471
  window.__devSetCurrentPage && window.__devSetCurrentPage(currentPageId);
2472
+ devEvents.emit('page_view', { page_id: currentPageId, catalog_slug: catalog.slug });
2473
+ setValidationErrors([]);
2474
+ // CatalogKit pageenter event
2475
+ const listeners = window.__catalogKitListeners || {};
2476
+ for (const key of ['pageenter', 'pageenter:' + currentPageId]) {
2477
+ const set = listeners[key]; if (!set?.size) continue;
2478
+ for (const cb of set) { try { cb({ pageId: currentPageId }); } catch (e) { console.error('[CatalogKit]', key, e); } }
2479
+ }
1594
2480
  }, [currentPageId]);
1595
2481
 
2482
+ // Expose debug state
2483
+ React.useEffect(() => {
2484
+ const edges = (routing.edges || []).filter(e => e.from === currentPageId);
2485
+ window.__devDebugState = { currentPageId, formState, cartItems, edges };
2486
+ window.dispatchEvent(new CustomEvent('devStateUpdate'));
2487
+ }, [currentPageId, formState, cartItems, routing]);
2488
+
2489
+ // --- Browser history (pushState / popstate) ---
2490
+ React.useEffect(() => {
2491
+ window.history.replaceState({ pageId: entryPageId, history: [] }, '');
2492
+ const onPopState = (e) => {
2493
+ const pageId = e.state?.pageId;
2494
+ const prevHistory = e.state?.history || [];
2495
+ if (pageId && pages[pageId]) {
2496
+ setCurrentPageId(pageId);
2497
+ setHistory(prevHistory);
2498
+ setValidationErrors([]);
2499
+ window.scrollTo({ top: 0, behavior: 'smooth' });
2500
+ }
2501
+ };
2502
+ window.addEventListener('popstate', onPopState);
2503
+ return () => window.removeEventListener('popstate', onPopState);
2504
+ }, []);
2505
+
2506
+ // --- localStorage persistence ---
2507
+ React.useEffect(() => {
2508
+ if (!submitted) {
2509
+ try { localStorage.setItem(saveKey, JSON.stringify({ formState, currentPageId, history })); } catch {}
2510
+ }
2511
+ }, [formState, currentPageId, history, submitted]);
2512
+
2513
+ // --- Check for saved session on mount ---
2514
+ React.useEffect(() => {
2515
+ try {
2516
+ const raw = localStorage.getItem(saveKey);
2517
+ if (raw) {
2518
+ const data = JSON.parse(raw);
2519
+ if (data.currentPageId && data.currentPageId !== entryPageId && pages[data.currentPageId]) {
2520
+ setSavedSession(data);
2521
+ setShowResumeModal(true);
2522
+ }
2523
+ }
2524
+ } catch {}
2525
+ }, []);
2526
+
2527
+ // --- Auto-skip pages ---
2528
+ React.useEffect(() => {
2529
+ const page = pages[currentPageId];
2530
+ if (!page?.auto_skip) return;
2531
+ const inputTypes = new Set(['short_text','long_text','rich_text','email','phone','url','address','number','currency','date','datetime','time','date_range','dropdown','multiselect','multiple_choice','checkboxes','picture_choice','switch','checkbox','choice_matrix','ranking','star_rating','slider','opinion_scale','file_upload','signature','password','location']);
2532
+ const visibleInputs = (page.components || []).filter(c => {
2533
+ if (!inputTypes.has(c.type)) return false;
2534
+ if (c.hidden || c.props?.hidden) return false;
2535
+ if (c.visibility && !evaluateConditionGroup(c.visibility, formState, devContext)) return false;
2536
+ return true;
2537
+ });
2538
+ const allFilled = visibleInputs.every(c => {
2539
+ if (!c.props?.required) return true;
2540
+ const val = formState[c.id];
2541
+ return val != null && val !== '' && !(Array.isArray(val) && val.length === 0);
2542
+ });
2543
+ if (allFilled && visibleInputs.length > 0) {
2544
+ devEvents.emit('page_auto_skipped', { page_id: currentPageId });
2545
+ const nextId = getNextPage(routing, currentPageId, formState, devContext);
2546
+ if (nextId && pages[nextId]) {
2547
+ setCurrentPageId(nextId);
2548
+ window.history.replaceState({ pageId: nextId, history: historyRef.current }, '');
2549
+ }
2550
+ }
2551
+ }, [currentPageId]);
2552
+
2553
+ // --- CatalogKit API (window.CatalogKit) ---
2554
+ React.useEffect(() => {
2555
+ const listeners = {};
2556
+ const instance = {
2557
+ getField: (id) => formStateRef.current[id],
2558
+ getAllFields: () => ({ ...formStateRef.current }),
2559
+ getPageId: () => currentPageId,
2560
+ setField: (id, value) => setFormState(prev => ({ ...prev, [id]: value })),
2561
+ goNext: () => handleNextRef.current?.(),
2562
+ goBack: () => handleBackRef.current?.(),
2563
+ on: (event, cb) => { if (!listeners[event]) listeners[event] = new Set(); listeners[event].add(cb); },
2564
+ off: (event, cb) => { listeners[event]?.delete(cb); },
2565
+ openCart: () => setCartOpen(true),
2566
+ closeCart: () => setCartOpen(false),
2567
+ getCartItems: () => [...cartItems],
2568
+ setValidationError: (id, msg) => {
2569
+ setValidationErrors(prev => {
2570
+ const next = prev.filter(e => e.componentId !== id);
2571
+ if (msg) next.push({ componentId: id, message: msg });
2572
+ return next;
2573
+ });
2574
+ },
2575
+ };
2576
+ window.CatalogKit = { get: () => instance, getField: instance.getField, setField: instance.setField, getPageId: instance.getPageId, goNext: instance.goNext, goBack: instance.goBack, on: instance.on, off: instance.off };
2577
+ window.__catalogKitListeners = listeners;
2578
+ return () => { delete window.CatalogKit; delete window.__catalogKitListeners; };
2579
+ }, []);
2580
+
1596
2581
  const page = currentPageId ? pages[currentPageId] : null;
1597
2582
  const isCover = page?.layout === 'cover';
1598
2583
  const isLastPage = (() => {
@@ -1600,27 +2585,110 @@ function buildPreviewHtml(schema, port) {
1600
2585
  return !routing.edges.some(e => e.from === currentPageId);
1601
2586
  })();
1602
2587
 
2588
+ const navigateTo = React.useCallback((nextId) => {
2589
+ // Fire CatalogKit pageexit
2590
+ const ckListeners = window.__catalogKitListeners || {};
2591
+ for (const key of ['pageexit', 'pageexit:' + currentPageId]) {
2592
+ const set = ckListeners[key]; if (!set?.size) continue;
2593
+ for (const cb of set) { try { cb({ pageId: currentPageId }); } catch (e) { console.error('[CatalogKit]', key, e); } }
2594
+ }
2595
+ if (nextId && pages[nextId]) {
2596
+ const newHistory = [...history, currentPageId];
2597
+ setHistory(newHistory);
2598
+ setCurrentPageId(nextId);
2599
+ window.history.pushState({ pageId: nextId, history: newHistory }, '');
2600
+ window.scrollTo({ top: 0, behavior: 'smooth' });
2601
+ } else {
2602
+ // End of funnel \u2014 show checkout or completion
2603
+ if (catalog.settings?.checkout) {
2604
+ setShowCheckout(true);
2605
+ devEvents.emit('checkout_start', { item_count: cartItems.length });
2606
+ } else {
2607
+ setSubmitted(true);
2608
+ try { localStorage.removeItem(saveKey); } catch {}
2609
+ devEvents.emit('form_submit', { page_id: currentPageId, form_state: formState });
2610
+ }
2611
+ window.scrollTo({ top: 0, behavior: 'smooth' });
2612
+ }
2613
+ }, [currentPageId, pages, catalog, cartItems, formState, history]);
2614
+
1603
2615
  const onFieldChange = React.useCallback((id, value) => {
1604
2616
  setFormState(prev => ({ ...prev, [id]: value }));
1605
- }, []);
2617
+ devEvents.emit('field_change', { field_id: id, value, page_id: currentPageId });
2618
+ // Fire CatalogKit fieldchange
2619
+ const ckListeners = window.__catalogKitListeners || {};
2620
+ const fcSet = ckListeners['fieldchange']; if (fcSet?.size) for (const cb of fcSet) { try { cb({ fieldId: id, value, pageId: currentPageId }); } catch {} }
2621
+
2622
+ // Auto-advance: if page has auto_advance and this is a selection-type input
2623
+ const pg = pages[currentPageId];
2624
+ if (pg?.auto_advance && value != null && value !== '') {
2625
+ const selectionTypes = ['multiple_choice', 'picture_choice', 'dropdown', 'checkboxes', 'multiselect'];
2626
+ const comp = (pg.components || []).find(c => c.id === id);
2627
+ if (comp && selectionTypes.includes(comp.type)) {
2628
+ const newFormState = { ...formState, [id]: value };
2629
+ const inputTypes = [...selectionTypes, 'short_text', 'long_text', 'rich_text', 'email', 'phone', 'url',
2630
+ 'address', 'number', 'currency', 'date', 'datetime', 'time', 'date_range', 'switch', 'checkbox',
2631
+ 'choice_matrix', 'ranking', 'star_rating', 'slider', 'opinion_scale', 'file_upload', 'signature',
2632
+ 'password', 'location'];
2633
+ const visibleInputs = (pg.components || []).filter(c => {
2634
+ if (!inputTypes.includes(c.type)) return false;
2635
+ if (c.hidden || c.props?.hidden) return false;
2636
+ if (c.visibility && !evaluateConditionGroup(c.visibility, newFormState, devContext)) return false;
2637
+ return true;
2638
+ });
2639
+ const lastInput = visibleInputs[visibleInputs.length - 1];
2640
+ if (lastInput && lastInput.id === id) {
2641
+ if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current);
2642
+ autoAdvanceTimer.current = setTimeout(() => {
2643
+ const nextId = getNextPage(routing, currentPageId, newFormState, devContext);
2644
+ navigateTo(nextId);
2645
+ }, 400);
2646
+ }
2647
+ }
2648
+ }
2649
+ }, [currentPageId, pages, formState, routing, navigateTo]);
2650
+
2651
+ // --- Validation ---
2652
+ const runValidation = React.useCallback(() => {
2653
+ const page = pages[currentPageId];
2654
+ if (!page) return true;
2655
+ const errors = validatePage(page, formState, devContext);
2656
+ setValidationErrors(errors);
2657
+ if (errors.length > 0) {
2658
+ const el = document.querySelector('[data-component-id="' + errors[0].componentId + '"]');
2659
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
2660
+ return false;
2661
+ }
2662
+ return true;
2663
+ }, [currentPageId, pages, formState]);
1606
2664
 
1607
2665
  const handleNext = React.useCallback(() => {
1608
- // Check if page has an offer \u2014 treat "Next" as an accept action
2666
+ // Validate before advancing
2667
+ if (!runValidation()) return;
2668
+ // Check video watch requirements
1609
2669
  const currentPage = pages[currentPageId];
1610
- if (currentPage?.offer) {
1611
- const acceptValue = currentPage.offer.accept_value || 'accept';
1612
- // If there's no accept_field, the CTA button itself is the accept trigger
1613
- if (!currentPage.offer.accept_field) {
1614
- addToCart(currentPageId);
2670
+ if (currentPage) {
2671
+ for (const comp of currentPage.components || []) {
2672
+ if (comp.type === 'video' && comp.props?.require_watch_percent) {
2673
+ const vs = window.__videoWatchState?.[comp.id];
2674
+ if (!vs || vs.watch_percent < comp.props.require_watch_percent) {
2675
+ const el = document.querySelector('[data-component-id="' + comp.id + '"]');
2676
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
2677
+ alert('Please watch at least ' + comp.props.require_watch_percent + '% of the video before continuing.');
2678
+ return;
2679
+ }
2680
+ }
1615
2681
  }
1616
2682
  }
1617
- const nextId = getNextPageId(currentPageId, routing, formState);
1618
- if (nextId && pages[nextId]) {
1619
- setHistory(prev => [...prev, currentPageId]);
1620
- setCurrentPageId(nextId);
1621
- window.scrollTo({ top: 0, behavior: 'smooth' });
2683
+ // Check if page has an offer \u2014 treat "Next" as an accept action
2684
+ if (currentPage?.offer) {
2685
+ if (!currentPage.offer.accept_field) addToCart(currentPageId);
1622
2686
  }
1623
- }, [currentPageId, routing, formState, pages, addToCart]);
2687
+ const nextId = getNextPage(routing, currentPageId, formState, devContext);
2688
+ navigateTo(nextId);
2689
+ }, [currentPageId, routing, formState, pages, addToCart, navigateTo, runValidation]);
2690
+ const handleNextRef = React.useRef(handleNext);
2691
+ handleNextRef.current = handleNext;
1624
2692
 
1625
2693
  const handleBack = React.useCallback(() => {
1626
2694
  if (history.length > 0) {
@@ -1630,6 +2698,174 @@ function buildPreviewHtml(schema, port) {
1630
2698
  window.scrollTo({ top: 0, behavior: 'smooth' });
1631
2699
  }
1632
2700
  }, [history]);
2701
+ const handleBackRef = React.useRef(handleBack);
2702
+ handleBackRef.current = handleBack;
2703
+
2704
+ // --- Page Actions ---
2705
+ const handleAction = React.useCallback((action) => {
2706
+ devEvents.emit('action_click', { page_id: currentPageId, action_id: action.id });
2707
+ if (action.redirect_url) { window.open(action.redirect_url, '_blank'); return; }
2708
+ if (!runValidation()) return;
2709
+ const currentPage = pages[currentPageId];
2710
+ const currentOffer = currentPage?.offer;
2711
+ if (currentOffer) {
2712
+ const acceptValue = currentOffer.accept_value || 'accept';
2713
+ if (action.id === acceptValue) addToCart(currentPageId);
2714
+ }
2715
+ const actionKey = '__action_' + currentPageId;
2716
+ const newFormState = { ...formState, [actionKey]: action.id };
2717
+ setFormState(newFormState);
2718
+ const nextPageId = getNextPage(routing, currentPageId, newFormState, devContext);
2719
+ navigateTo(nextPageId);
2720
+ }, [currentPageId, routing, formState, pages, addToCart, navigateTo, runValidation]);
2721
+
2722
+ // --- Resume prompt ---
2723
+ if (showResumeModal) {
2724
+ return h('div', { className: 'cf-resume-backdrop' },
2725
+ h('div', { className: 'bg-white rounded-2xl max-w-sm w-full mx-4 p-8 shadow-2xl text-center' },
2726
+ h('h2', { className: 'text-xl font-bold text-gray-900 mb-2', style: { fontFamily: 'var(--font-display)' } }, 'Welcome back!'),
2727
+ h('p', { className: 'text-gray-500 mb-6 text-sm' }, 'Pick up where you left off?'),
2728
+ h('div', { className: 'space-y-3' },
2729
+ h('button', {
2730
+ className: 'cf-btn-primary w-full text-white', style: { backgroundColor: themeColor },
2731
+ onClick: () => {
2732
+ if (savedSession) {
2733
+ setFormState(savedSession.formState || {});
2734
+ setCurrentPageId(savedSession.currentPageId);
2735
+ setHistory(savedSession.history || []);
2736
+ window.history.replaceState({ pageId: savedSession.currentPageId, history: savedSession.history || [] }, '');
2737
+ }
2738
+ setShowResumeModal(false);
2739
+ },
2740
+ }, 'Resume'),
2741
+ h('button', {
2742
+ className: 'w-full px-4 py-2 text-sm text-gray-500 hover:text-gray-700 transition-colors',
2743
+ onClick: () => { setShowResumeModal(false); try { localStorage.removeItem(saveKey); } catch {} },
2744
+ }, 'Start Over')
2745
+ )
2746
+ )
2747
+ );
2748
+ }
2749
+
2750
+ // --- Completion screen ---
2751
+ if (submitted) {
2752
+ const completionSettings = catalog.settings?.completion;
2753
+ return h('div', { className: 'min-h-screen flex items-center justify-center', style: { background: 'linear-gradient(180deg, #f8f9fc 0%, #f0f2f7 100%)' } },
2754
+ h('div', { className: 'max-w-lg mx-auto text-center px-6 py-20 page-enter-active' },
2755
+ h('div', { className: 'w-20 h-20 mx-auto mb-6 rounded-full flex items-center justify-center', style: { backgroundColor: themeColor + '15' } },
2756
+ h('svg', { className: 'w-10 h-10', style: { color: themeColor }, fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2757
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z' })
2758
+ )
2759
+ ),
2760
+ h('h1', { className: 'text-3xl font-bold text-gray-900 mb-3', style: { fontFamily: 'var(--font-display)' } },
2761
+ completionSettings?.title || 'Thank You!'
2762
+ ),
2763
+ h('p', { className: 'text-gray-500 text-lg mb-8' },
2764
+ completionSettings?.message || 'Your submission has been received.'
2765
+ ),
2766
+ completionSettings?.redirect_url ? h('a', {
2767
+ href: completionSettings.redirect_url,
2768
+ className: 'inline-flex items-center gap-2 px-6 py-3 rounded-xl text-white font-semibold transition-all hover:scale-[1.02]',
2769
+ style: { backgroundColor: themeColor },
2770
+ }, completionSettings.redirect_label || 'Continue') : null,
2771
+ h('div', { className: 'mt-6' },
2772
+ h('button', {
2773
+ className: 'text-sm text-gray-400 hover:text-gray-600 transition-colors',
2774
+ onClick: () => { setSubmitted(false); setCurrentPageId(routing.entry || pageKeys[0]); setHistory([]); setFormState({}); setCartItems([]); try { localStorage.removeItem(saveKey); } catch {} },
2775
+ }, 'Start Over')
2776
+ )
2777
+ )
2778
+ );
2779
+ }
2780
+
2781
+ // --- Checkout screen ---
2782
+ if (showCheckout) {
2783
+ const checkoutSettings = catalog.settings?.checkout || {};
2784
+ const handleCheckoutBack = () => { setShowCheckout(false); };
2785
+ const handleCheckoutContinue = () => {
2786
+ setShowCheckout(false);
2787
+ setSubmitted(true);
2788
+ devEvents.emit('checkout_skip', { page_id: currentPageId });
2789
+ };
2790
+
2791
+ return h('div', { className: 'min-h-screen', style: { background: 'linear-gradient(180deg, #f8f9fc 0%, #f0f2f7 100%)', fontFamily: 'var(--font-display)' } },
2792
+ // Header
2793
+ h('div', { className: 'fixed top-[28px] left-0 right-0 z-50 bg-white/80 backdrop-blur-xl border-b border-gray-200/60' },
2794
+ h('div', { className: 'max-w-5xl mx-auto flex items-center justify-between px-6 py-3' },
2795
+ h('button', { onClick: handleCheckoutBack, className: 'flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 transition-colors' },
2796
+ h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2797
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M15.75 19.5L8.25 12l7.5-7.5' })
2798
+ ),
2799
+ 'Back'
2800
+ ),
2801
+ h('div', { className: 'flex items-center gap-2' },
2802
+ h('svg', { className: 'w-4 h-4 text-green-500', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2803
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z' })
2804
+ ),
2805
+ h('span', { className: 'text-xs font-medium text-gray-400' }, 'Secure Checkout')
2806
+ )
2807
+ )
2808
+ ),
2809
+ h('div', { className: 'max-w-5xl mx-auto px-6 pt-24 pb-12' },
2810
+ h('div', { className: 'text-center mb-10' },
2811
+ h('h1', { className: 'text-3xl sm:text-4xl font-bold text-gray-900', style: { letterSpacing: '-0.025em' } },
2812
+ checkoutSettings.title || 'Complete Your Order'
2813
+ )
2814
+ ),
2815
+ h('div', { className: 'grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 items-start' },
2816
+ // Order summary
2817
+ h('div', { className: 'lg:col-span-7' },
2818
+ h('div', { className: 'bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden' },
2819
+ h('div', { className: 'px-6 py-4 border-b border-gray-50' },
2820
+ h('h2', { className: 'text-sm font-bold text-gray-900 uppercase tracking-wide' }, 'Order Summary')
2821
+ ),
2822
+ h('div', { className: 'divide-y divide-gray-50' },
2823
+ cartItems.length === 0
2824
+ ? h('div', { className: 'flex items-center gap-5 px-6 py-5' },
2825
+ h('div', { className: 'flex-1 min-w-0' },
2826
+ h('h3', { className: 'text-base font-semibold text-gray-900' }, 'Complete Registration'),
2827
+ h('p', { className: 'text-sm text-gray-400 mt-0.5' }, 'No offers selected \u2014 continue for free')
2828
+ ),
2829
+ h('p', { className: 'text-base font-bold', style: { color: themeColor } }, '$0')
2830
+ )
2831
+ : cartItems.map(item => h('div', { key: item.offer_id, className: 'flex items-center gap-5 px-6 py-5' },
2832
+ item.image ? h('img', { src: item.image, alt: item.title, className: 'w-16 h-16 rounded-xl object-cover flex-shrink-0 border border-gray-100' }) : null,
2833
+ h('div', { className: 'flex-1 min-w-0' },
2834
+ h('h3', { className: 'text-base font-semibold text-gray-900' }, item.title),
2835
+ item.price_subtext ? h('p', { className: 'text-sm text-gray-400 mt-0.5' }, item.price_subtext) : null
2836
+ ),
2837
+ item.price_display ? h('p', { className: 'text-base font-bold', style: { color: themeColor } }, item.price_display) : null,
2838
+ h('button', { onClick: () => removeFromCart(item.offer_id), className: 'w-8 h-8 flex-shrink-0 flex items-center justify-center rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 transition-all' },
2839
+ h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
2840
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M6 18L18 6M6 6l12 12' })
2841
+ )
2842
+ )
2843
+ ))
2844
+ )
2845
+ )
2846
+ ),
2847
+ // Payment action
2848
+ h('div', { className: 'lg:col-span-5' },
2849
+ h('div', { className: 'lg:sticky lg:top-20 space-y-5' },
2850
+ h('div', { className: 'bg-white rounded-2xl border border-gray-100 shadow-sm p-7 space-y-6' },
2851
+ h('div', { className: 'text-sm text-gray-500' },
2852
+ cartItems.length + ' ' + (cartItems.length === 1 ? 'item' : 'items')
2853
+ ),
2854
+ h(CartCheckoutButton, { items: cartItems, themeColor }),
2855
+ h('button', {
2856
+ onClick: handleCheckoutContinue,
2857
+ className: 'w-full text-center text-sm text-gray-400 hover:text-gray-600 font-medium transition-colors py-1',
2858
+ }, 'Continue without paying'),
2859
+ h('div', { className: 'flex items-center justify-center gap-3 pt-1' },
2860
+ h('span', { className: 'text-[10px] text-gray-400 font-medium' }, 'Powered by Stripe')
2861
+ )
2862
+ )
2863
+ )
2864
+ )
2865
+ )
2866
+ )
2867
+ );
2868
+ }
1633
2869
 
1634
2870
  if (!page) {
1635
2871
  return h('div', { className: 'min-h-screen flex items-center justify-center bg-gray-50' },
@@ -1637,12 +2873,18 @@ function buildPreviewHtml(schema, port) {
1637
2873
  );
1638
2874
  }
1639
2875
 
1640
- const components = (page.components || []).filter(c => !c.hidden && !c.props?.hidden);
2876
+ const components = (page.components || []).filter(c => {
2877
+ if (c.hidden || c.props?.hidden) return false;
2878
+ if (c.visibility) { if (!evaluateConditionGroup(c.visibility, formState, devContext)) return false; }
2879
+ if (c.prefill_mode === 'hidden' && formState[c.id] != null && formState[c.id] !== '') return false;
2880
+ return true;
2881
+ });
1641
2882
  const bgImage = page.background_image || catalog.settings?.theme?.background_image;
1642
2883
 
1643
2884
  // Cart UI (shared between cover and standard)
2885
+ const cartSettings = catalog.settings?.cart || {};
1644
2886
  const cartUI = h(React.Fragment, null,
1645
- h(CartButton, { itemCount: cartItems.length, onClick: toggleCart }),
2887
+ !cartSettings.hide_button ? h(CartButton, { itemCount: cartItems.length, onClick: toggleCart }) : null,
1646
2888
  h(CartDrawer, { items: cartItems, isOpen: cartOpen, onToggle: toggleCart, onRemove: removeFromCart })
1647
2889
  );
1648
2890
 
@@ -1660,17 +2902,31 @@ function buildPreviewHtml(schema, port) {
1660
2902
  h('div', { className: 'cf-cover-overlay absolute inset-0' }),
1661
2903
  h('div', { className: 'cf-cover-content relative max-w-2xl mx-auto px-6 py-20 text-center text-white' },
1662
2904
  h('div', { className: 'page-enter-active space-y-7 flex flex-col items-stretch text-center' },
1663
- ...components.map((comp, i) => h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type },
1664
- h(RenderComponent, { comp, isCover: true, formState, onFieldChange })
1665
- )),
1666
- // CTA button
1667
- h('div', { className: 'mt-8' },
1668
- h('button', {
1669
- className: 'cf-btn-primary w-full py-4 text-lg',
1670
- style: { backgroundColor: themeColor },
1671
- onClick: handleNext,
1672
- }, page.submit_label || (isLastPage ? 'Submit' : 'Get Started'))
1673
- )
2905
+ ...components.map((comp, i) => {
2906
+ if (comp.prefill_mode === 'readonly' && formState[comp.id] != null && formState[comp.id] !== '') {
2907
+ return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: 'space-y-1' },
2908
+ comp.props?.label ? h('label', { className: 'block text-base font-medium text-white/80' }, comp.props.label) : null,
2909
+ h('div', { className: 'px-4 py-3 bg-white/10 rounded-xl text-white text-sm font-medium border border-white/20' }, String(formState[comp.id]))
2910
+ );
2911
+ }
2912
+ const fieldError = validationErrors.find(e => e.componentId === comp.id);
2913
+ return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: fieldError ? 'cf-field-error' : '' },
2914
+ h(RenderComponent, { comp, isCover: true, formState, onFieldChange }),
2915
+ fieldError ? h('p', { className: 'text-xs text-red-300 font-medium mt-1', role: 'alert' }, fieldError.message) : null
2916
+ );
2917
+ }),
2918
+ // Actions or CTA button
2919
+ page.actions?.length > 0
2920
+ ? h('div', { className: 'mt-8 space-y-3' },
2921
+ ...page.actions.map(action => h(ActionButton, { key: action.id, action, themeColor, onAction: handleAction }))
2922
+ )
2923
+ : h('div', { className: 'mt-8' },
2924
+ h('button', {
2925
+ className: 'cf-btn-primary w-full py-4 text-lg',
2926
+ style: { backgroundColor: themeColor },
2927
+ onClick: handleNext,
2928
+ }, page.submit_label || (isLastPage ? 'Submit' : 'Get Started'))
2929
+ )
1674
2930
  )
1675
2931
  )
1676
2932
  )
@@ -1708,26 +2964,49 @@ function buildPreviewHtml(schema, port) {
1708
2964
  h('div', { className: 'max-w-2xl mx-auto px-6 pb-8', style: { paddingTop: topBarEnabled ? '100px' : '60px' } },
1709
2965
  page.description ? h('p', { className: 'text-sm text-gray-400 mb-8 text-center font-medium tracking-wide', style: { fontFamily: 'var(--font-display)' } }, page.description) : null,
1710
2966
  h('div', { className: 'page-enter-active space-y-5' },
1711
- ...components.map((comp, i) => h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type },
1712
- h(RenderComponent, { comp, isCover: false, formState, onFieldChange })
1713
- )),
1714
- // Navigation button
1715
- !page.hide_navigation ? h('div', { className: 'mt-8' },
1716
- h('button', {
1717
- className: 'cf-btn-primary inline-flex items-center gap-2 text-base',
1718
- style: { backgroundColor: themeColor },
1719
- onClick: handleNext,
1720
- },
1721
- page.submit_label || (isLastPage ? 'Submit' : 'Continue'),
1722
- !isLastPage ? h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2.5 },
1723
- h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3' })
1724
- ) : null
1725
- ),
1726
- page.submit_reassurance ? h('p', { className: 'text-xs text-gray-400 mt-1.5' }, page.submit_reassurance) : null,
1727
- ) : null,
2967
+ ...components.map((comp, i) => {
2968
+ if (comp.prefill_mode === 'readonly' && formState[comp.id] != null && formState[comp.id] !== '') {
2969
+ return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: 'space-y-1' },
2970
+ comp.props?.label ? h('label', { className: 'block text-base font-medium text-gray-700' }, comp.props.label) : null,
2971
+ h('div', { className: 'px-4 py-3 bg-gray-50 rounded-xl text-gray-600 text-sm font-medium border border-gray-100' }, String(formState[comp.id]))
2972
+ );
2973
+ }
2974
+ const fieldError = validationErrors.find(e => e.componentId === comp.id);
2975
+ return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: fieldError ? 'cf-field-error' : '' },
2976
+ h(RenderComponent, { comp, isCover: false, formState, onFieldChange }),
2977
+ fieldError ? h('p', { className: 'text-xs text-red-500 font-medium mt-1', role: 'alert' }, fieldError.message) : null
2978
+ );
2979
+ }),
2980
+ // Actions or navigation button
2981
+ page.actions?.length > 0
2982
+ ? h('div', { className: 'mt-8 space-y-3' },
2983
+ ...page.actions.map(action => h(ActionButton, { key: action.id, action, themeColor, onAction: handleAction }))
2984
+ )
2985
+ : !page.hide_navigation ? h('div', { className: 'mt-8' },
2986
+ h('button', {
2987
+ className: 'cf-btn-primary inline-flex items-center gap-2 text-base',
2988
+ style: { backgroundColor: themeColor },
2989
+ onClick: handleNext,
2990
+ },
2991
+ page.submit_label || (isLastPage ? 'Submit' : 'Continue'),
2992
+ !isLastPage ? h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2.5 },
2993
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3' })
2994
+ ) : null
2995
+ ),
2996
+ page.submit_reassurance ? h('p', { className: 'text-xs text-gray-400 mt-1.5' }, page.submit_reassurance) : null,
2997
+ ) : null,
1728
2998
  ),
1729
2999
  h('div', { className: 'mt-10 text-center text-[11px] text-gray-300 font-medium tracking-wide', style: { fontFamily: 'var(--font-display)' } }, 'Powered by Catalog Kit'),
1730
- )
3000
+ ),
3001
+ // Sticky bottom bar
3002
+ (catalog.settings?.sticky_bar?.enabled || page.sticky_bar?.enabled)
3003
+ ? h(StickyBottomBar, {
3004
+ config: { ...(catalog.settings?.sticky_bar || {}), ...(page.sticky_bar || {}) },
3005
+ page, formState, cartItems, themeColor,
3006
+ onNext: handleNext, onAction: handleAction, onBack: handleBack,
3007
+ historyLen: history.length,
3008
+ })
3009
+ : null
1731
3010
  )
1732
3011
  );
1733
3012
  }
@@ -1776,17 +3055,113 @@ function buildPreviewHtml(schema, port) {
1776
3055
  const overlay = document.getElementById('pages-overlay');
1777
3056
  const closeBtn = document.getElementById('pages-close');
1778
3057
  const container = document.getElementById('mindmap-container');
3058
+ const zoomInBtn = document.getElementById('zoom-in');
3059
+ const zoomOutBtn = document.getElementById('zoom-out');
3060
+ const zoomFitBtn = document.getElementById('zoom-fit');
3061
+ const zoomLevelEl = document.getElementById('zoom-level');
1779
3062
  let currentPageRef = { id: schema.routing?.entry || Object.keys(schema.pages || {})[0] };
1780
3063
 
3064
+ // Pan & zoom state
3065
+ let scale = 1, panX = 0, panY = 0;
3066
+ let isDragging = false, dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0;
3067
+ let hasDragged = false;
3068
+ const MIN_SCALE = 0.15, MAX_SCALE = 3;
3069
+
3070
+ function applyTransform() {
3071
+ container.style.transform = 'translate(' + panX + 'px, ' + panY + 'px) scale(' + scale + ')';
3072
+ zoomLevelEl.textContent = Math.round(scale * 100) + '%';
3073
+ }
3074
+
3075
+ function fitToView() {
3076
+ const overlayRect = overlay.getBoundingClientRect();
3077
+ // Temporarily reset transform to measure natural size
3078
+ container.style.transform = 'none';
3079
+ requestAnimationFrame(() => {
3080
+ const contentRect = container.getBoundingClientRect();
3081
+ const padW = 80, padH = 80;
3082
+ const scaleX = (overlayRect.width - padW) / contentRect.width;
3083
+ const scaleY = (overlayRect.height - padH) / contentRect.height;
3084
+ scale = Math.min(scaleX, scaleY, 1.5);
3085
+ scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale));
3086
+ panX = (overlayRect.width - contentRect.width * scale) / 2;
3087
+ panY = (overlayRect.height - contentRect.height * scale) / 2;
3088
+ applyTransform();
3089
+ });
3090
+ }
3091
+
1781
3092
  // Expose setter for CatalogPreview to update current page
1782
3093
  window.__devSetCurrentPage = (id) => { currentPageRef.id = id; };
1783
3094
 
1784
3095
  btn.addEventListener('click', () => {
1785
3096
  overlay.classList.add('open');
1786
3097
  renderMindmap();
3098
+ requestAnimationFrame(() => fitToView());
1787
3099
  });
1788
3100
  closeBtn.addEventListener('click', () => overlay.classList.remove('open'));
1789
- overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('open'); });
3101
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') overlay.classList.remove('open'); });
3102
+
3103
+ // Wheel zoom
3104
+ overlay.addEventListener('wheel', (e) => {
3105
+ e.preventDefault();
3106
+ const rect = overlay.getBoundingClientRect();
3107
+ const mouseX = e.clientX - rect.left;
3108
+ const mouseY = e.clientY - rect.top;
3109
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
3110
+ const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale * delta));
3111
+ // Zoom toward cursor
3112
+ panX = mouseX - (mouseX - panX) * (newScale / scale);
3113
+ panY = mouseY - (mouseY - panY) * (newScale / scale);
3114
+ scale = newScale;
3115
+ applyTransform();
3116
+ }, { passive: false });
3117
+
3118
+ // Pan via drag
3119
+ overlay.addEventListener('mousedown', (e) => {
3120
+ if (e.target.closest('.close-btn, .zoom-controls')) return;
3121
+ isDragging = true;
3122
+ hasDragged = false;
3123
+ dragStartX = e.clientX;
3124
+ dragStartY = e.clientY;
3125
+ dragStartPanX = panX;
3126
+ dragStartPanY = panY;
3127
+ overlay.classList.add('grabbing');
3128
+ });
3129
+ window.addEventListener('mousemove', (e) => {
3130
+ if (!isDragging) return;
3131
+ const dx = e.clientX - dragStartX;
3132
+ const dy = e.clientY - dragStartY;
3133
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) hasDragged = true;
3134
+ panX = dragStartPanX + dx;
3135
+ panY = dragStartPanY + dy;
3136
+ applyTransform();
3137
+ });
3138
+ window.addEventListener('mouseup', () => {
3139
+ isDragging = false;
3140
+ overlay.classList.remove('grabbing');
3141
+ });
3142
+
3143
+ // Close only via close button (not background click \u2014 allows free panning)
3144
+
3145
+ // Zoom buttons
3146
+ zoomInBtn.addEventListener('click', () => {
3147
+ const rect = overlay.getBoundingClientRect();
3148
+ const cx = rect.width / 2, cy = rect.height / 2;
3149
+ const newScale = Math.min(MAX_SCALE, scale * 1.25);
3150
+ panX = cx - (cx - panX) * (newScale / scale);
3151
+ panY = cy - (cy - panY) * (newScale / scale);
3152
+ scale = newScale;
3153
+ applyTransform();
3154
+ });
3155
+ zoomOutBtn.addEventListener('click', () => {
3156
+ const rect = overlay.getBoundingClientRect();
3157
+ const cx = rect.width / 2, cy = rect.height / 2;
3158
+ const newScale = Math.max(MIN_SCALE, scale * 0.8);
3159
+ panX = cx - (cx - panX) * (newScale / scale);
3160
+ panY = cy - (cy - panY) * (newScale / scale);
3161
+ scale = newScale;
3162
+ applyTransform();
3163
+ });
3164
+ zoomFitBtn.addEventListener('click', () => fitToView());
1790
3165
 
1791
3166
  function renderMindmap() {
1792
3167
  const pages = schema.pages || {};
@@ -1895,9 +3270,11 @@ function buildPreviewHtml(schema, port) {
1895
3270
  container.insertBefore(svgEl, container.firstChild);
1896
3271
  });
1897
3272
 
1898
- // Click nodes to navigate
3273
+ // Click nodes to navigate (ignore if user just dragged)
1899
3274
  container.querySelectorAll('[data-node-id]').forEach(el => {
1900
- el.addEventListener('click', () => {
3275
+ el.addEventListener('click', (e) => {
3276
+ if (hasDragged) return;
3277
+ e.stopPropagation();
1901
3278
  const id = el.dataset.nodeId;
1902
3279
  window.__devNavigateTo && window.__devNavigateTo(id);
1903
3280
  overlay.classList.remove('open');
@@ -1987,18 +3364,167 @@ function buildPreviewHtml(schema, port) {
1987
3364
  });
1988
3365
  }, true);
1989
3366
  })();
3367
+
3368
+ // --- Validation in topbar ---
3369
+ (function initValidationTag() {
3370
+ const tag = document.getElementById('validation-tag');
3371
+ const validation = JSON.parse(document.getElementById('__validation_data').textContent);
3372
+ const errors = validation.errors || [];
3373
+ const warnings = validation.warnings || [];
3374
+ const total = errors.length + warnings.length;
3375
+ const cleanClass = total === 0 ? ' clean' : '';
3376
+ const label = total === 0 ? '\u2713 Valid' : errors.length + ' error' + (errors.length !== 1 ? 's' : '') + ', ' + warnings.length + ' warn';
3377
+ let html = '<button class="vt-btn' + cleanClass + '" id="vt-toggle">' + label + '</button>';
3378
+ if (total > 0) {
3379
+ html += '<div class="vt-dropdown" id="vt-dropdown">';
3380
+ html += '<div class="vt-header"><span>' + errors.length + ' error(s), ' + warnings.length + ' warning(s)</span></div>';
3381
+ html += '<div class="vt-body">';
3382
+ for (const e of errors) html += '<div class="vt-error">ERROR: ' + e + '</div>';
3383
+ for (const w of warnings) html += '<div class="vt-warn">WARN: ' + w + '</div>';
3384
+ html += '</div></div>';
3385
+ }
3386
+ tag.innerHTML = html;
3387
+ if (total > 0) {
3388
+ const toggleBtn = document.getElementById('vt-toggle');
3389
+ const dropdown = document.getElementById('vt-dropdown');
3390
+ toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); dropdown.classList.toggle('open'); });
3391
+ document.addEventListener('click', (e) => { if (!tag.contains(e.target)) dropdown.classList.remove('open'); });
3392
+ }
3393
+ })();
3394
+
3395
+ // --- Banner minimize / restore ---
3396
+ (function initBannerMinimize() {
3397
+ const banner = document.getElementById('dev-banner');
3398
+ const minimizeBtn = document.getElementById('banner-minimize');
3399
+ const restoreBtn = document.getElementById('banner-restore');
3400
+ minimizeBtn.addEventListener('click', () => {
3401
+ banner.classList.add('minimized');
3402
+ restoreBtn.classList.add('visible');
3403
+ });
3404
+ restoreBtn.addEventListener('click', () => {
3405
+ banner.classList.remove('minimized');
3406
+ restoreBtn.classList.remove('visible');
3407
+ });
3408
+ })();
3409
+
3410
+ // --- Banner drag to reposition ---
3411
+ (function initBannerDrag() {
3412
+ const banner = document.getElementById('dev-banner');
3413
+ const handle = document.getElementById('banner-drag');
3414
+ let isDragging = false, startX = 0, startY = 0, origLeft = 0, origTop = 0;
3415
+
3416
+ handle.addEventListener('mousedown', (e) => {
3417
+ e.preventDefault();
3418
+ isDragging = true;
3419
+ const rect = banner.getBoundingClientRect();
3420
+ startX = e.clientX; startY = e.clientY;
3421
+ origLeft = rect.left; origTop = rect.top;
3422
+ // Switch to positioned mode
3423
+ banner.style.left = rect.left + 'px';
3424
+ banner.style.top = rect.top + 'px';
3425
+ banner.style.right = 'auto';
3426
+ banner.style.width = rect.width + 'px';
3427
+ handle.style.cursor = 'grabbing';
3428
+ });
3429
+ window.addEventListener('mousemove', (e) => {
3430
+ if (!isDragging) return;
3431
+ const dx = e.clientX - startX, dy = e.clientY - startY;
3432
+ banner.style.left = (origLeft + dx) + 'px';
3433
+ banner.style.top = (origTop + dy) + 'px';
3434
+ });
3435
+ window.addEventListener('mouseup', () => {
3436
+ if (!isDragging) return;
3437
+ isDragging = false;
3438
+ handle.style.cursor = '';
3439
+ });
3440
+ })();
3441
+
3442
+ // --- Debug panel ---
3443
+ (function initDebugPanel() {
3444
+ const panel = document.getElementById('debug-panel');
3445
+ const body = document.getElementById('debug-body');
3446
+ const btn = document.getElementById('debug-btn');
3447
+ const closeBtn = document.getElementById('debug-close');
3448
+ let isOpen = false;
3449
+
3450
+ function toggle() {
3451
+ isOpen = !isOpen;
3452
+ panel.classList.toggle('open', isOpen);
3453
+ if (isOpen) render();
3454
+ }
3455
+ btn.addEventListener('click', toggle);
3456
+ closeBtn.addEventListener('click', toggle);
3457
+
3458
+ document.addEventListener('keydown', (e) => {
3459
+ if (e.ctrlKey && e.key === 'd') { e.preventDefault(); toggle(); }
3460
+ });
3461
+
3462
+ const recentEvents = [];
3463
+ window.addEventListener('devEvent', (e) => {
3464
+ recentEvents.push(e.detail);
3465
+ if (recentEvents.length > 20) recentEvents.shift();
3466
+ if (isOpen) render();
3467
+ });
3468
+
3469
+ function render() {
3470
+ const state = window.__devDebugState;
3471
+ if (!state) { body.innerHTML = '<p>Waiting for state...</p>'; return; }
3472
+ let html = '<div class="dp-section"><div class="dp-label">Current Page</div><span class="dp-badge">' + (state.currentPageId || 'none') + '</span></div>';
3473
+ html += '<div class="dp-section"><div class="dp-label">Form State</div><pre>' + JSON.stringify(state.formState || {}, null, 2) + '</pre></div>';
3474
+ html += '<div class="dp-section"><div class="dp-label">Cart (' + (state.cartItems?.length || 0) + ')</div>';
3475
+ if (state.cartItems && state.cartItems.length > 0) {
3476
+ html += '<pre>' + JSON.stringify(state.cartItems.map(i => i.title || i.offer_id), null, 2) + '</pre>';
3477
+ } else {
3478
+ html += '<pre>empty</pre>';
3479
+ }
3480
+ html += '</div>';
3481
+ html += '<div class="dp-section"><div class="dp-label">Edges from here</div>';
3482
+ if (state.edges && state.edges.length > 0) {
3483
+ html += '<pre>' + state.edges.map(e => e.from + ' \u2192 ' + e.to + (e.conditions?.length ? ' (conditional)' : '')).join('\\n') + '</pre>';
3484
+ } else {
3485
+ html += '<pre>none (terminal page)</pre>';
3486
+ }
3487
+ html += '</div>';
3488
+ html += '<div class="dp-section"><div class="dp-label">Recent Events (' + recentEvents.length + ')</div>';
3489
+ if (recentEvents.length > 0) {
3490
+ html += '<pre>' + recentEvents.slice(-8).map(e => e.type + ' ' + JSON.stringify(e.data || {})).join('\\n') + '</pre>';
3491
+ } else {
3492
+ html += '<pre>none yet</pre>';
3493
+ }
3494
+ html += '</div>';
3495
+ body.innerHTML = html;
3496
+ }
3497
+
3498
+ window.addEventListener('devStateUpdate', () => { if (isOpen) render(); });
3499
+ })();
1990
3500
  </script>
1991
3501
  </body>
1992
3502
  </html>`;
1993
3503
  }
1994
3504
  async function catalogDev(file, opts) {
1995
- const abs = resolve3(file);
3505
+ const abs = resolve4(file);
1996
3506
  const catalogDir = dirname2(abs);
1997
3507
  const port = parseInt(opts.port || String(DEFAULT_PORT), 10);
1998
3508
  if (!existsSync2(abs)) {
1999
3509
  console.error(`File not found: ${file}`);
2000
3510
  process.exit(1);
2001
3511
  }
3512
+ let stripeSecretKey = process.env.STRIPE_SECRET_KEY || "";
3513
+ if (!stripeSecretKey) {
3514
+ for (const envFile of [".env", ".env.local", ".env.development"]) {
3515
+ const envPath = join2(catalogDir, envFile);
3516
+ if (existsSync2(envPath)) {
3517
+ const envContent = readFileSync4(envPath, "utf-8");
3518
+ const match = envContent.match(/^STRIPE_SECRET_KEY\s*=\s*"?([^"\n]+)"?/m);
3519
+ if (match) {
3520
+ stripeSecretKey = match[1].trim();
3521
+ break;
3522
+ }
3523
+ }
3524
+ }
3525
+ }
3526
+ const stripeEnabled = stripeSecretKey.length > 0;
3527
+ const devConfig = { stripeEnabled, port };
2002
3528
  const spinner = ora5("Loading catalog schema...").start();
2003
3529
  let schema;
2004
3530
  try {
@@ -2007,14 +3533,15 @@ async function catalogDev(file, opts) {
2007
3533
  spinner.fail(`Failed to load catalog: ${err.message}`);
2008
3534
  process.exit(1);
2009
3535
  }
2010
- const errors = validateCatalog(schema);
2011
- if (errors.length > 0) {
3536
+ const basicErrors = validateCatalog(schema);
3537
+ if (basicErrors.length > 0) {
2012
3538
  spinner.fail("Schema validation errors:");
2013
- for (const err of errors) {
3539
+ for (const err of basicErrors) {
2014
3540
  console.error(` - ${err}`);
2015
3541
  }
2016
3542
  process.exit(1);
2017
3543
  }
3544
+ let validation = deepValidateCatalog(schema);
2018
3545
  const localBaseUrl = `http://localhost:${port}/assets`;
2019
3546
  schema = resolveLocalAssets(schema, catalogDir, localBaseUrl);
2020
3547
  spinner.succeed(`Loaded: ${schema.slug || file}`);
@@ -2022,6 +3549,8 @@ async function catalogDev(file, opts) {
2022
3549
  console.log(` Entry: ${schema.routing?.entry || "first page"}`);
2023
3550
  console.log();
2024
3551
  const sseClients = /* @__PURE__ */ new Set();
3552
+ const eventSseClients = /* @__PURE__ */ new Set();
3553
+ const eventLog = [];
2025
3554
  function notifyReload() {
2026
3555
  sseClients.forEach((client) => {
2027
3556
  try {
@@ -2031,6 +3560,22 @@ async function catalogDev(file, opts) {
2031
3560
  }
2032
3561
  });
2033
3562
  }
3563
+ function emitDevEvent(event) {
3564
+ eventLog.push(event);
3565
+ if (eventLog.length > 500) eventLog.shift();
3566
+ const data = JSON.stringify(event);
3567
+ const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
3568
+ console.log(` \x1B[36m[event]\x1B[0m ${ts} ${event.type} ${JSON.stringify(event.data || {})}`);
3569
+ eventSseClients.forEach((client) => {
3570
+ try {
3571
+ client.write(`data: ${data}
3572
+
3573
+ `);
3574
+ } catch {
3575
+ eventSseClients.delete(client);
3576
+ }
3577
+ });
3578
+ }
2034
3579
  const server = createServer(async (req, res) => {
2035
3580
  const url = new URL(req.url || "/", `http://localhost:${port}`);
2036
3581
  if (url.pathname === "/__dev_sse") {
@@ -2045,11 +3590,127 @@ async function catalogDev(file, opts) {
2045
3590
  req.on("close", () => sseClients.delete(res));
2046
3591
  return;
2047
3592
  }
3593
+ if (url.pathname === "/__dev_events_stream") {
3594
+ res.writeHead(200, {
3595
+ "Content-Type": "text/event-stream",
3596
+ "Cache-Control": "no-cache",
3597
+ "Connection": "keep-alive",
3598
+ "Access-Control-Allow-Origin": "*"
3599
+ });
3600
+ res.write("data: connected\n\n");
3601
+ eventSseClients.add(res);
3602
+ req.on("close", () => eventSseClients.delete(res));
3603
+ return;
3604
+ }
3605
+ if (url.pathname === "/__dev_events" && req.method === "GET") {
3606
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
3607
+ const limit = parseInt(url.searchParams.get("limit") || "50", 10);
3608
+ res.end(JSON.stringify(eventLog.slice(-limit)));
3609
+ return;
3610
+ }
3611
+ if (url.pathname === "/__dev_event" && req.method === "POST") {
3612
+ let body = "";
3613
+ req.on("data", (chunk) => {
3614
+ body += chunk;
3615
+ });
3616
+ req.on("end", () => {
3617
+ try {
3618
+ const event = JSON.parse(body);
3619
+ emitDevEvent(event);
3620
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
3621
+ res.end('{"ok":true}');
3622
+ } catch {
3623
+ res.writeHead(400);
3624
+ res.end('{"error":"invalid json"}');
3625
+ }
3626
+ });
3627
+ return;
3628
+ }
3629
+ if (url.pathname === "/__dev_checkout" && req.method === "POST") {
3630
+ let body = "";
3631
+ req.on("data", (chunk) => {
3632
+ body += chunk;
3633
+ });
3634
+ req.on("end", async () => {
3635
+ res.setHeader("Content-Type", "application/json");
3636
+ res.setHeader("Access-Control-Allow-Origin", "*");
3637
+ if (!stripeEnabled) {
3638
+ res.writeHead(400);
3639
+ res.end(JSON.stringify({ error: "No STRIPE_SECRET_KEY found. Add it to .env in your catalog directory." }));
3640
+ return;
3641
+ }
3642
+ try {
3643
+ const { line_items, form_state, catalog_slug } = JSON.parse(body);
3644
+ const checkoutSettings = schema.settings?.checkout || {};
3645
+ const params = new URLSearchParams();
3646
+ params.set("mode", checkoutSettings.payment_type === "subscription" ? "subscription" : "payment");
3647
+ params.set("success_url", `http://localhost:${port}/?checkout=success&session_id={CHECKOUT_SESSION_ID}`);
3648
+ params.set("cancel_url", `http://localhost:${port}/?checkout=cancel`);
3649
+ const emailField = checkoutSettings.prefill_fields?.customer_email;
3650
+ if (emailField && form_state?.[emailField]) {
3651
+ params.set("customer_email", form_state[emailField]);
3652
+ }
3653
+ const methods = checkoutSettings.payment_methods || ["card"];
3654
+ methods.forEach((m, i) => params.set(`payment_method_types[${i}]`, m));
3655
+ if (checkoutSettings.allow_discount_codes) {
3656
+ params.set("allow_promotion_codes", "true");
3657
+ }
3658
+ line_items.forEach((item, i) => {
3659
+ if (item.stripe_price_id) {
3660
+ params.set(`line_items[${i}][price]`, item.stripe_price_id);
3661
+ params.set(`line_items[${i}][quantity]`, String(item.quantity || 1));
3662
+ } else if (item.amount_cents) {
3663
+ params.set(`line_items[${i}][price_data][currency]`, item.currency || "usd");
3664
+ params.set(`line_items[${i}][price_data][unit_amount]`, String(item.amount_cents));
3665
+ params.set(`line_items[${i}][price_data][product_data][name]`, item.title || "Item");
3666
+ if (checkoutSettings.payment_type === "subscription") {
3667
+ params.set(`line_items[${i}][price_data][recurring][interval]`, "month");
3668
+ }
3669
+ params.set(`line_items[${i}][quantity]`, String(item.quantity || 1));
3670
+ }
3671
+ });
3672
+ if (checkoutSettings.payment_type === "subscription" && checkoutSettings.free_trial?.enabled && checkoutSettings.free_trial.days) {
3673
+ params.set("subscription_data[trial_period_days]", String(checkoutSettings.free_trial.days));
3674
+ }
3675
+ const stripeRes = await fetch("https://api.stripe.com/v1/checkout/sessions", {
3676
+ method: "POST",
3677
+ headers: {
3678
+ "Authorization": `Basic ${Buffer.from(stripeSecretKey + ":").toString("base64")}`,
3679
+ "Content-Type": "application/x-www-form-urlencoded"
3680
+ },
3681
+ body: params.toString()
3682
+ });
3683
+ const stripeData = await stripeRes.json();
3684
+ if (!stripeRes.ok) {
3685
+ emitDevEvent({ type: "checkout_error", timestamp: (/* @__PURE__ */ new Date()).toISOString(), data: { error: stripeData.error?.message || "Stripe error" } });
3686
+ res.writeHead(stripeRes.status);
3687
+ res.end(JSON.stringify({ error: stripeData.error?.message || "Stripe session creation failed" }));
3688
+ return;
3689
+ }
3690
+ emitDevEvent({ type: "checkout_session_created", timestamp: (/* @__PURE__ */ new Date()).toISOString(), data: { session_id: stripeData.id, url: stripeData.url } });
3691
+ res.writeHead(200);
3692
+ res.end(JSON.stringify({ session_id: stripeData.id, session_url: stripeData.url }));
3693
+ } catch (err) {
3694
+ res.writeHead(500);
3695
+ res.end(JSON.stringify({ error: err.message }));
3696
+ }
3697
+ });
3698
+ return;
3699
+ }
3700
+ if (req.method === "OPTIONS") {
3701
+ res.writeHead(204, {
3702
+ "Access-Control-Allow-Origin": "*",
3703
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
3704
+ "Access-Control-Allow-Headers": "Content-Type"
3705
+ });
3706
+ res.end();
3707
+ return;
3708
+ }
2048
3709
  if (url.pathname.startsWith("/assets/")) {
2049
3710
  const relativePath = decodeURIComponent(url.pathname.slice("/assets/".length));
2050
3711
  const filePath = join2(catalogDir, relativePath);
2051
- const resolved = resolve3(filePath);
2052
- if (!resolved.startsWith(resolve3(catalogDir))) {
3712
+ const resolved = resolve4(filePath);
3713
+ if (!resolved.startsWith(resolve4(catalogDir))) {
2053
3714
  res.writeHead(403);
2054
3715
  res.end("Forbidden");
2055
3716
  return;
@@ -2077,11 +3738,13 @@ async function catalogDev(file, opts) {
2077
3738
  "Content-Type": "text/html; charset=utf-8",
2078
3739
  "Cache-Control": "no-cache"
2079
3740
  });
2080
- res.end(buildPreviewHtml(schema, port));
3741
+ res.end(buildPreviewHtml(schema, port, validation, devConfig));
2081
3742
  });
2082
3743
  server.listen(port, () => {
2083
3744
  console.log(` Local preview: http://localhost:${port}`);
2084
3745
  console.log(` Assets served from: ${catalogDir}`);
3746
+ console.log(` Checkout: ${stripeEnabled ? `\x1B[32menabled\x1B[0m (${stripeSecretKey.startsWith("sk_test_") ? "test mode" : "live mode"})` : "\x1B[33mstubbed\x1B[0m \u2014 add STRIPE_SECRET_KEY to .env"}`);
3747
+ console.log(` Events: \x1B[32mlocal\x1B[0m \u2014 stream at http://localhost:${port}/__dev_events_stream`);
2085
3748
  console.log(` Watching for changes...
2086
3749
  `);
2087
3750
  });
@@ -2098,6 +3761,7 @@ async function catalogDev(file, opts) {
2098
3761
  for (const e of errs) console.error(` - ${e}`);
2099
3762
  return;
2100
3763
  }
3764
+ validation = deepValidateCatalog(schema);
2101
3765
  schema = resolveLocalAssets(schema, catalogDir, localBaseUrl);
2102
3766
  reloadSpinner.succeed(`Reloaded \u2014 auto-refreshing browser`);
2103
3767
  notifyReload();
@@ -2108,7 +3772,7 @@ async function catalogDev(file, opts) {
2108
3772
  });
2109
3773
  try {
2110
3774
  watch(catalogDir, { recursive: true }, (event, filename) => {
2111
- if (!filename || filename.startsWith(".") || resolve3(join2(catalogDir, filename)) === abs) return;
3775
+ if (!filename || filename.startsWith(".") || resolve4(join2(catalogDir, filename)) === abs) return;
2112
3776
  });
2113
3777
  } catch {
2114
3778
  }
@@ -2119,6 +3783,439 @@ async function catalogDev(file, opts) {
2119
3783
  });
2120
3784
  }
2121
3785
 
3786
+ // src/commands/catalog-validate.ts
3787
+ import { resolve as resolve5 } from "path";
3788
+ import { existsSync as existsSync3 } from "fs";
3789
+ import ora6 from "ora";
3790
+ async function catalogValidate(file) {
3791
+ const abs = resolve5(file);
3792
+ if (!existsSync3(abs)) {
3793
+ console.error(`File not found: ${file}`);
3794
+ process.exit(1);
3795
+ }
3796
+ const spinner = ora6("Loading catalog schema...").start();
3797
+ let schema;
3798
+ try {
3799
+ schema = await loadCatalogFile(abs);
3800
+ } catch (err) {
3801
+ spinner.fail(`Failed to load catalog: ${err.message}`);
3802
+ process.exit(1);
3803
+ }
3804
+ spinner.succeed(`Loaded: ${schema.slug || file}`);
3805
+ const basicErrors = validateCatalog(schema);
3806
+ const { errors: deepErrors, warnings } = deepValidateCatalog(schema);
3807
+ const allErrors = [...basicErrors, ...deepErrors];
3808
+ const pages = schema.pages || {};
3809
+ const pageCount = Object.keys(pages).length;
3810
+ const componentCount = Object.values(pages).reduce(
3811
+ (sum, p) => sum + (p.components?.length || 0),
3812
+ 0
3813
+ );
3814
+ const edgeCount = schema.routing?.edges?.length || 0;
3815
+ const offerCount = Object.values(pages).filter((p) => p.offer).length;
3816
+ console.log();
3817
+ console.log(` Slug: ${schema.slug || "(none)"}`);
3818
+ console.log(` Version: ${schema.schema_version || "(none)"}`);
3819
+ console.log(` Pages: ${pageCount}`);
3820
+ console.log(` Components: ${componentCount}`);
3821
+ console.log(` Edges: ${edgeCount}`);
3822
+ console.log(` Offers: ${offerCount}`);
3823
+ console.log();
3824
+ if (allErrors.length > 0) {
3825
+ for (const err of allErrors) {
3826
+ console.error(` \x1B[31mERROR\x1B[0m ${err}`);
3827
+ }
3828
+ }
3829
+ if (warnings.length > 0) {
3830
+ for (const warn of warnings) {
3831
+ console.warn(` \x1B[33mWARN\x1B[0m ${warn}`);
3832
+ }
3833
+ }
3834
+ if (allErrors.length === 0 && warnings.length === 0) {
3835
+ console.log(" \x1B[32m\u2713 No issues found\x1B[0m");
3836
+ } else {
3837
+ console.log();
3838
+ console.log(
3839
+ ` ${allErrors.length} error(s), ${warnings.length} warning(s)`
3840
+ );
3841
+ }
3842
+ process.exit(allErrors.length > 0 ? 1 : 0);
3843
+ }
3844
+
3845
+ // src/commands/catalog-init.ts
3846
+ import { mkdirSync, writeFileSync, existsSync as existsSync4 } from "fs";
3847
+ import { join as join3 } from "path";
3848
+ import { createInterface } from "readline";
3849
+ function ask(rl, question) {
3850
+ return new Promise((resolve7) => rl.question(question, resolve7));
3851
+ }
3852
+ var TEMPLATES = {
3853
+ "quiz-funnel": {
3854
+ description: "3-page quiz funnel: welcome cover, quiz question, result with offer",
3855
+ content: (slug) => `import type { CatalogSchema } from "@shared/types";
3856
+
3857
+ const catalog = {
3858
+ schema_version: "1.0",
3859
+ slug: "${slug}",
3860
+ settings: {
3861
+ theme: { primary_color: "#6366f1" },
3862
+ progress_steps: [
3863
+ { id: "quiz", label: "Quiz", pages: ["welcome", "question"] },
3864
+ { id: "result", label: "Result", pages: ["result"] },
3865
+ ],
3866
+ },
3867
+ routing: {
3868
+ entry: "welcome",
3869
+ edges: [
3870
+ { from: "welcome", to: "question" },
3871
+ { from: "question", to: "result" },
3872
+ ],
3873
+ },
3874
+ pages: {
3875
+ welcome: {
3876
+ layout: "cover",
3877
+ components: [
3878
+ { id: "heading", type: "heading", props: { level: 1, text: "Find Your Perfect Plan", subtitle: "Answer a few quick questions to get a personalized recommendation." } },
3879
+ ],
3880
+ },
3881
+ question: {
3882
+ title: "About You",
3883
+ components: [
3884
+ { id: "role", type: "multiple_choice", props: { label: "What best describes you?", options: ["Solopreneur", "Agency", "E-commerce", "SaaS"], required: true } },
3885
+ ],
3886
+ },
3887
+ result: {
3888
+ title: "Your Result",
3889
+ components: [
3890
+ { id: "result_heading", type: "heading", props: { level: 2, text: "Here's our recommendation" } },
3891
+ { id: "result_text", type: "paragraph", props: { text: "Based on your answers, we think this plan is perfect for you." } },
3892
+ ],
3893
+ offer: {
3894
+ id: "main-offer",
3895
+ title: "Starter Plan",
3896
+ price_display: "$29/mo",
3897
+ },
3898
+ },
3899
+ },
3900
+ } satisfies CatalogSchema;
3901
+
3902
+ export default catalog;
3903
+ `
3904
+ },
3905
+ "lead-capture": {
3906
+ description: "2-page lead capture: info form + thank you",
3907
+ content: (slug) => `import type { CatalogSchema } from "@shared/types";
3908
+
3909
+ const catalog = {
3910
+ schema_version: "1.0",
3911
+ slug: "${slug}",
3912
+ settings: {
3913
+ theme: { primary_color: "#10b981" },
3914
+ },
3915
+ routing: {
3916
+ entry: "form",
3917
+ edges: [
3918
+ { from: "form", to: "thank_you" },
3919
+ ],
3920
+ },
3921
+ pages: {
3922
+ form: {
3923
+ title: "Get Started",
3924
+ components: [
3925
+ { id: "heading", type: "heading", props: { level: 2, text: "Tell us about yourself" } },
3926
+ { id: "name", type: "short_text", props: { label: "Full Name", placeholder: "Jane Doe", required: true } },
3927
+ { id: "email", type: "email", props: { label: "Email", placeholder: "you@example.com", required: true } },
3928
+ { id: "company", type: "short_text", props: { label: "Company", placeholder: "Acme Inc" } },
3929
+ ],
3930
+ },
3931
+ thank_you: {
3932
+ title: "Thank You!",
3933
+ hide_navigation: true,
3934
+ components: [
3935
+ { id: "thanks", type: "heading", props: { level: 2, text: "Thanks! We'll be in touch soon." } },
3936
+ { id: "note", type: "paragraph", props: { text: "Check your inbox for a confirmation email." } },
3937
+ ],
3938
+ },
3939
+ },
3940
+ } satisfies CatalogSchema;
3941
+
3942
+ export default catalog;
3943
+ `
3944
+ },
3945
+ "product-catalog": {
3946
+ description: "2-page product showcase: pricing cards + checkout",
3947
+ content: (slug) => `import type { CatalogSchema } from "@shared/types";
3948
+
3949
+ const catalog = {
3950
+ schema_version: "1.0",
3951
+ slug: "${slug}",
3952
+ settings: {
3953
+ theme: { primary_color: "#f59e0b" },
3954
+ },
3955
+ routing: {
3956
+ entry: "pricing",
3957
+ edges: [
3958
+ { from: "pricing", to: "checkout" },
3959
+ ],
3960
+ },
3961
+ pages: {
3962
+ pricing: {
3963
+ title: "Choose a Plan",
3964
+ components: [
3965
+ { id: "heading", type: "heading", props: { level: 1, text: "Simple, transparent pricing" } },
3966
+ { id: "starter", type: "pricing_card", props: { title: "Starter", price: "$19", period: "mo", features: ["5 projects", "Basic analytics", "Email support"], cta_text: "Get Started" } },
3967
+ { id: "pro", type: "pricing_card", props: { title: "Pro", badge: "Popular", price: "$49", period: "mo", features: ["Unlimited projects", "Advanced analytics", "Priority support", "Custom domain"], cta_text: "Go Pro" } },
3968
+ ],
3969
+ },
3970
+ checkout: {
3971
+ title: "Complete Your Order",
3972
+ components: [
3973
+ { id: "checkout_pay", type: "payment", props: { amount: 4900, currency: "usd" } },
3974
+ ],
3975
+ },
3976
+ },
3977
+ } satisfies CatalogSchema;
3978
+
3979
+ export default catalog;
3980
+ `
3981
+ },
3982
+ blank: {
3983
+ description: "Minimal 1-page skeleton",
3984
+ content: (slug) => `import type { CatalogSchema } from "@shared/types";
3985
+
3986
+ const catalog = {
3987
+ schema_version: "1.0",
3988
+ slug: "${slug}",
3989
+ settings: {
3990
+ theme: { primary_color: "#6366f1" },
3991
+ },
3992
+ routing: {
3993
+ entry: "main",
3994
+ edges: [],
3995
+ },
3996
+ pages: {
3997
+ main: {
3998
+ title: "Welcome",
3999
+ components: [
4000
+ { id: "heading", type: "heading", props: { level: 1, text: "Hello, World!" } },
4001
+ { id: "text", type: "paragraph", props: { text: "Start building your catalog here." } },
4002
+ ],
4003
+ },
4004
+ },
4005
+ } satisfies CatalogSchema;
4006
+
4007
+ export default catalog;
4008
+ `
4009
+ }
4010
+ };
4011
+ async function catalogInit() {
4012
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
4013
+ try {
4014
+ console.log("\n Catalog Kit \u2014 New Catalog Scaffold\n");
4015
+ const slug = (await ask(rl, " Catalog slug (e.g. my-quiz): ")).trim();
4016
+ if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
4017
+ console.error("\n Invalid slug. Use lowercase letters, numbers, and hyphens.");
4018
+ process.exit(1);
4019
+ }
4020
+ if (existsSync4(slug)) {
4021
+ console.error(`
4022
+ Directory "${slug}" already exists.`);
4023
+ process.exit(1);
4024
+ }
4025
+ console.log("\n Templates:");
4026
+ const templateKeys = Object.keys(TEMPLATES);
4027
+ for (let i = 0; i < templateKeys.length; i++) {
4028
+ const key = templateKeys[i];
4029
+ console.log(` ${i + 1}. ${key} \u2014 ${TEMPLATES[key].description}`);
4030
+ }
4031
+ const choice = (await ask(rl, `
4032
+ Pick a template (1-${templateKeys.length}): `)).trim();
4033
+ const idx = parseInt(choice, 10) - 1;
4034
+ if (isNaN(idx) || idx < 0 || idx >= templateKeys.length) {
4035
+ console.error("\n Invalid choice.");
4036
+ process.exit(1);
4037
+ }
4038
+ const templateKey = templateKeys[idx];
4039
+ const template = TEMPLATES[templateKey];
4040
+ mkdirSync(join3(slug, "images"), { recursive: true });
4041
+ writeFileSync(join3(slug, `${slug}.ts`), template.content(slug));
4042
+ writeFileSync(
4043
+ join3(slug, ".env.example"),
4044
+ `CATALOG_KIT_TOKEN=cfk_your_token_here
4045
+ `
4046
+ );
4047
+ console.log(`
4048
+ \x1B[32m\u2713 Created ${slug}/\x1B[0m`);
4049
+ console.log(` ${slug}/${slug}.ts`);
4050
+ console.log(` ${slug}/images/`);
4051
+ console.log(` ${slug}/.env.example`);
4052
+ console.log(`
4053
+ Next steps:`);
4054
+ console.log(` cd ${slug}`);
4055
+ console.log(` catalogs catalog dev ${slug}.ts`);
4056
+ console.log(` catalogs catalog validate ${slug}.ts`);
4057
+ console.log(` catalogs catalog push ${slug}.ts --publish
4058
+ `);
4059
+ } finally {
4060
+ rl.close();
4061
+ }
4062
+ }
4063
+
4064
+ // src/commands/catalog-diff.ts
4065
+ import { resolve as resolve6 } from "path";
4066
+ import { existsSync as existsSync5 } from "fs";
4067
+ import ora7 from "ora";
4068
+ async function catalogDiff(file) {
4069
+ const config = requireConfig();
4070
+ const api = new ApiClient(config);
4071
+ await printIdentity(api);
4072
+ const abs = resolve6(file);
4073
+ if (!existsSync5(abs)) {
4074
+ console.error(`File not found: ${file}`);
4075
+ process.exit(1);
4076
+ }
4077
+ const spinner = ora7("Loading local catalog...").start();
4078
+ let local;
4079
+ try {
4080
+ local = await loadCatalogFile(abs);
4081
+ } catch (err) {
4082
+ spinner.fail(`Failed to load catalog: ${err.message}`);
4083
+ process.exit(1);
4084
+ }
4085
+ spinner.succeed(`Local: ${local.slug || file}`);
4086
+ const fetchSpinner = ora7("Fetching remote catalog...").start();
4087
+ let remote = null;
4088
+ try {
4089
+ const listRes = await api.get("/api/v1/catalogs");
4090
+ const catalogs = listRes.data || [];
4091
+ const match = catalogs.find((c) => c.slug === local.slug);
4092
+ if (match) {
4093
+ remote = match.schema;
4094
+ }
4095
+ } catch (err) {
4096
+ fetchSpinner.fail(`Failed to fetch remote: ${err.message}`);
4097
+ process.exit(1);
4098
+ }
4099
+ if (!remote) {
4100
+ fetchSpinner.info("This would be a new catalog (slug not found remotely).");
4101
+ return;
4102
+ }
4103
+ fetchSpinner.succeed(`Remote: ${remote.slug || local.slug}`);
4104
+ console.log();
4105
+ const localPages = new Set(Object.keys(local.pages || {}));
4106
+ const remotePages = new Set(Object.keys(remote.pages || {}));
4107
+ const addedPages = [];
4108
+ const removedPages = [];
4109
+ const modifiedPages = [];
4110
+ for (const id of localPages) {
4111
+ if (!remotePages.has(id)) {
4112
+ addedPages.push(id);
4113
+ } else {
4114
+ const localComps = (local.pages[id].components || []).map((c) => `${c.id}:${c.type}`).join(",");
4115
+ const remoteComps = (remote.pages[id].components || []).map((c) => `${c.id}:${c.type}`).join(",");
4116
+ if (localComps !== remoteComps) {
4117
+ modifiedPages.push(id);
4118
+ }
4119
+ }
4120
+ }
4121
+ for (const id of remotePages) {
4122
+ if (!localPages.has(id)) {
4123
+ removedPages.push(id);
4124
+ }
4125
+ }
4126
+ if (addedPages.length + removedPages.length + modifiedPages.length === 0) {
4127
+ console.log(" Pages: no changes");
4128
+ } else {
4129
+ console.log(" Pages:");
4130
+ for (const id of addedPages) {
4131
+ console.log(` \x1B[32m+ ${id}\x1B[0m`);
4132
+ }
4133
+ for (const id of removedPages) {
4134
+ console.log(` \x1B[31m- ${id}\x1B[0m`);
4135
+ }
4136
+ for (const id of modifiedPages) {
4137
+ console.log(` \x1B[33m~ ${id}\x1B[0m`);
4138
+ const localCompIds = new Set((local.pages[id].components || []).map((c) => c.id));
4139
+ const remoteCompIds = new Set((remote.pages[id].components || []).map((c) => c.id));
4140
+ for (const cid of localCompIds) {
4141
+ if (!remoteCompIds.has(cid)) console.log(` \x1B[32m+ component: ${cid}\x1B[0m`);
4142
+ }
4143
+ for (const cid of remoteCompIds) {
4144
+ if (!localCompIds.has(cid)) console.log(` \x1B[31m- component: ${cid}\x1B[0m`);
4145
+ }
4146
+ }
4147
+ }
4148
+ const localEdges = (local.routing?.edges || []).map((e) => `${e.from}->${e.to}`);
4149
+ const remoteEdges = (remote.routing?.edges || []).map((e) => `${e.from}->${e.to}`);
4150
+ const localEdgeSet = new Set(localEdges);
4151
+ const remoteEdgeSet = new Set(remoteEdges);
4152
+ const addedEdges = localEdges.filter((e) => !remoteEdgeSet.has(e));
4153
+ const removedEdges = remoteEdges.filter((e) => !localEdgeSet.has(e));
4154
+ if (addedEdges.length + removedEdges.length === 0) {
4155
+ console.log(" Edges: no changes");
4156
+ } else {
4157
+ console.log(" Edges:");
4158
+ for (const e of addedEdges) console.log(` \x1B[32m+ ${e}\x1B[0m`);
4159
+ for (const e of removedEdges) console.log(` \x1B[31m- ${e}\x1B[0m`);
4160
+ }
4161
+ const totalChanges = addedPages.length + removedPages.length + modifiedPages.length + addedEdges.length + removedEdges.length;
4162
+ console.log();
4163
+ console.log(
4164
+ ` Summary: ${addedPages.length} page(s) added, ${removedPages.length} removed, ${modifiedPages.length} modified | ${addedEdges.length} edge(s) added, ${removedEdges.length} removed`
4165
+ );
4166
+ if (totalChanges === 0) {
4167
+ console.log(" \x1B[32m\u2713 Local matches remote\x1B[0m");
4168
+ }
4169
+ console.log();
4170
+ }
4171
+
4172
+ // src/commands/catalog-open.ts
4173
+ import { exec } from "child_process";
4174
+ import { platform } from "os";
4175
+ import ora8 from "ora";
4176
+ async function catalogOpen(slug) {
4177
+ const config = requireConfig();
4178
+ const api = new ApiClient(config);
4179
+ await printIdentity(api);
4180
+ const spinner = ora8(`Looking up catalog "${slug}"...`).start();
4181
+ try {
4182
+ const listRes = await api.get("/api/v1/catalogs");
4183
+ const catalogs = listRes.data || [];
4184
+ const catalog2 = catalogs.find((c) => c.slug === slug);
4185
+ if (!catalog2) {
4186
+ spinner.fail(`Catalog "${slug}" not found.`);
4187
+ process.exit(1);
4188
+ }
4189
+ let url = catalog2.url;
4190
+ if (!url) {
4191
+ try {
4192
+ const me = await api.get("/api/v1/me");
4193
+ const subdomain = me.data?.subdomain || me.data?.app_slug;
4194
+ if (subdomain) {
4195
+ url = `https://${subdomain}.catalogkit.cc/${slug}`;
4196
+ }
4197
+ } catch {
4198
+ }
4199
+ }
4200
+ if (!url) {
4201
+ url = `https://catalogkit.cc/c/${catalog2.catalog_id}`;
4202
+ }
4203
+ spinner.succeed(`Opening: ${url}`);
4204
+ const os = platform();
4205
+ const cmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
4206
+ exec(`${cmd} "${url}"`, (err) => {
4207
+ if (err) {
4208
+ console.log(`
4209
+ Could not open browser. Visit: ${url}
4210
+ `);
4211
+ }
4212
+ });
4213
+ } catch (err) {
4214
+ spinner.fail(`Failed: ${err.message}`);
4215
+ process.exit(1);
4216
+ }
4217
+ }
4218
+
2122
4219
  // src/commands/whoami.ts
2123
4220
  async function whoami() {
2124
4221
  const config = getConfig();
@@ -2177,7 +4274,7 @@ async function whoami() {
2177
4274
 
2178
4275
  // src/index.ts
2179
4276
  var __dirname = dirname3(fileURLToPath(import.meta.url));
2180
- var { version } = JSON.parse(readFileSync5(join3(__dirname, "../package.json"), "utf-8"));
4277
+ var { version } = JSON.parse(readFileSync5(join4(__dirname, "../package.json"), "utf-8"));
2181
4278
  var program = new Command();
2182
4279
  program.name("catalogs").description("CLI for Catalog Kit \u2014 upload videos, push catalogs, manage assets").version(version).option("--token <token>", "Auth token (overrides CATALOG_KIT_TOKEN env var)").hook("preAction", (thisCommand) => {
2183
4280
  const opts = thisCommand.opts();
@@ -2192,5 +4289,9 @@ var catalog = program.command("catalog").description("Catalog schema management"
2192
4289
  catalog.command("push <file>").description("Create or update a catalog from a JSON or TypeScript schema file").option("--publish", "Set status to published (default: draft)").action(catalogPush);
2193
4290
  catalog.command("list").description("List all catalogs").action(catalogList);
2194
4291
  catalog.command("dev <file>").description("Preview a catalog locally with hot reload and local asset serving").option("--port <port>", "Port to serve on (default: 3456)").action(catalogDev);
4292
+ catalog.command("validate <file>").description("Validate a catalog schema (no token required)").action(catalogValidate);
4293
+ catalog.command("init").description("Scaffold a new catalog from a template").action(catalogInit);
4294
+ catalog.command("diff <file>").description("Compare local catalog schema against remote").action(catalogDiff);
4295
+ catalog.command("open <slug>").description("Open a published catalog in the browser").action(catalogOpen);
2195
4296
  program.command("whoami").description("Show current authentication info").action(whoami);
2196
4297
  program.parse();