@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.
- package/dist/index.js +2256 -155
- 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
|
|
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((
|
|
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 {
|
|
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/
|
|
561
|
-
|
|
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
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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;
|
|
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:
|
|
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
|
-
.
|
|
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"
|
|
939
|
-
<span class="stub-tag">
|
|
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">×</button>
|
|
1169
|
+
<div class="zoom-controls">
|
|
1170
|
+
<button class="zoom-btn" id="zoom-in">+</button>
|
|
1171
|
+
<div class="zoom-level" id="zoom-level">100%</div>
|
|
1172
|
+
<button class="zoom-btn" id="zoom-out">−</button>
|
|
1173
|
+
<button class="zoom-btn" id="zoom-fit" style="font-size:11px;margin-top:4px;">Fit</button>
|
|
1174
|
+
</div>
|
|
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 — 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">×</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
|
-
// ---
|
|
1005
|
-
function
|
|
1006
|
-
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
if (!
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
1535
|
-
|
|
1536
|
-
const
|
|
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
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
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
|
-
//
|
|
2666
|
+
// Validate before advancing
|
|
2667
|
+
if (!runValidation()) return;
|
|
2668
|
+
// Check video watch requirements
|
|
1609
2669
|
const currentPage = pages[currentPageId];
|
|
1610
|
-
if (currentPage
|
|
1611
|
-
const
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
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
|
-
|
|
1618
|
-
if (
|
|
1619
|
-
|
|
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
|
-
|
|
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 =>
|
|
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) =>
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
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) =>
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
2011
|
-
if (
|
|
3536
|
+
const basicErrors = validateCatalog(schema);
|
|
3537
|
+
if (basicErrors.length > 0) {
|
|
2012
3538
|
spinner.fail("Schema validation errors:");
|
|
2013
|
-
for (const err of
|
|
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 =
|
|
2052
|
-
if (!resolved.startsWith(
|
|
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(".") ||
|
|
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(
|
|
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();
|