@officexapp/catalogs-cli 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1082 -64
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { Command } from "commander";
5
5
  import { readFileSync as readFileSync5 } from "fs";
6
6
  import { fileURLToPath } from "url";
7
- import { dirname as dirname3, join as join3 } from "path";
7
+ import { dirname as dirname3, join as join4 } from "path";
8
8
 
9
9
  // src/config.ts
10
10
  var DEFAULT_API_URL = "https://api.catalogkit.cc";
@@ -233,7 +233,7 @@ Check status later: catalogs video status ${videoId}
233
233
  `);
234
234
  }
235
235
  function sleep(ms) {
236
- return new Promise((resolve4) => setTimeout(resolve4, ms));
236
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
237
237
  }
238
238
 
239
239
  // src/commands/video-status.ts
@@ -265,9 +265,7 @@ async function videoStatus(videoId) {
265
265
  }
266
266
 
267
267
  // src/commands/catalog-push.ts
268
- import { readFileSync as readFileSync3 } from "fs";
269
- import { resolve as resolve2, extname as extname2, dirname } from "path";
270
- import { pathToFileURL } from "url";
268
+ import { resolve as resolve3, dirname } from "path";
271
269
  import ora3 from "ora";
272
270
 
273
271
  // src/lib/serialize.ts
@@ -315,6 +313,97 @@ function validateCatalog(schema) {
315
313
  }
316
314
  return errors;
317
315
  }
316
+ function deepValidateCatalog(schema) {
317
+ const errors = [];
318
+ const warnings = [];
319
+ const pages = schema.pages || {};
320
+ const pageIds = new Set(Object.keys(pages));
321
+ const routing = schema.routing || {};
322
+ const edges = routing.edges || [];
323
+ const entry = routing.entry;
324
+ const KNOWN_TYPES = /* @__PURE__ */ new Set([
325
+ "heading",
326
+ "paragraph",
327
+ "image",
328
+ "video",
329
+ "html",
330
+ "banner",
331
+ "callout",
332
+ "divider",
333
+ "pricing_card",
334
+ "testimonial",
335
+ "faq",
336
+ "timeline",
337
+ "file_download",
338
+ "iframe",
339
+ "short_text",
340
+ "email",
341
+ "phone",
342
+ "url",
343
+ "number",
344
+ "password",
345
+ "long_text",
346
+ "multiple_choice",
347
+ "checkboxes",
348
+ "dropdown",
349
+ "slider",
350
+ "star_rating",
351
+ "switch",
352
+ "checkbox",
353
+ "payment"
354
+ ]);
355
+ if (entry && !pageIds.has(entry)) {
356
+ errors.push(`routing.entry "${entry}" does not exist in pages`);
357
+ }
358
+ for (const edge of edges) {
359
+ if (!pageIds.has(edge.from)) {
360
+ errors.push(`routing edge from "${edge.from}" references non-existent page`);
361
+ }
362
+ if (edge.to != null && !pageIds.has(edge.to)) {
363
+ errors.push(`routing edge to "${edge.to}" references non-existent page`);
364
+ }
365
+ }
366
+ for (const [pageId, page] of Object.entries(pages)) {
367
+ const components = page.components || [];
368
+ const compIds = /* @__PURE__ */ new Set();
369
+ for (const comp of components) {
370
+ if (comp.id && compIds.has(comp.id)) {
371
+ errors.push(`page "${pageId}": duplicate component ID "${comp.id}"`);
372
+ }
373
+ if (comp.id) compIds.add(comp.id);
374
+ if (comp.type && !KNOWN_TYPES.has(comp.type)) {
375
+ warnings.push(`page "${pageId}": unknown component type "${comp.type}"`);
376
+ }
377
+ }
378
+ if (page.offer?.accept_field) {
379
+ if (!compIds.has(page.offer.accept_field)) {
380
+ errors.push(
381
+ `page "${pageId}": offer.accept_field "${page.offer.accept_field}" does not match any component ID on this page`
382
+ );
383
+ }
384
+ }
385
+ }
386
+ if (entry && pageIds.has(entry)) {
387
+ const reachable = /* @__PURE__ */ new Set();
388
+ const queue = [entry];
389
+ reachable.add(entry);
390
+ while (queue.length > 0) {
391
+ const id = queue.shift();
392
+ for (const edge of edges) {
393
+ if (edge.from === id && !reachable.has(edge.to)) {
394
+ reachable.add(edge.to);
395
+ queue.push(edge.to);
396
+ }
397
+ }
398
+ }
399
+ for (const id of pageIds) {
400
+ if (!reachable.has(id)) {
401
+ warnings.push(`page "${id}" is unreachable from entry "${entry}"`);
402
+ }
403
+ }
404
+ }
405
+ return { errors, warnings };
406
+ }
318
407
 
319
408
  // src/lib/resolve-assets.ts
320
409
  import { existsSync, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
@@ -557,29 +646,33 @@ async function uploadFile(localPath, filename, api) {
557
646
  return downloadUrl;
558
647
  }
559
648
 
560
- // src/commands/catalog-push.ts
561
- async function loadTsFile(file) {
649
+ // src/lib/load-file.ts
650
+ import { resolve as resolve2, extname as extname2 } from "path";
651
+ import { readFileSync as readFileSync3 } from "fs";
652
+ async function loadCatalogFile(file) {
562
653
  const abs = resolve2(file);
563
- const { register } = await import("module");
564
- register("tsx/esm", pathToFileURL("./"));
565
- const mod = await import(pathToFileURL(abs).href);
566
- return mod.default ?? mod;
654
+ const ext = extname2(file).toLowerCase();
655
+ const isTs = ext === ".ts" || ext === ".mts";
656
+ if (isTs) {
657
+ const { tsImport } = await import("tsx/esm/api");
658
+ const mod = await tsImport(abs, import.meta.url);
659
+ let raw = mod.default ?? mod;
660
+ if (raw.__esModule && raw.default) raw = raw.default;
661
+ return serializeCatalog(raw);
662
+ } else {
663
+ const raw = readFileSync3(abs, "utf-8");
664
+ return JSON.parse(raw);
665
+ }
567
666
  }
667
+
668
+ // src/commands/catalog-push.ts
568
669
  async function catalogPush(file, opts) {
569
670
  const config = requireConfig();
570
671
  const api = new ApiClient(config);
571
672
  await printIdentity(api);
572
- const ext = extname2(file).toLowerCase();
573
- const isTs = ext === ".ts" || ext === ".mts";
574
673
  let schema;
575
674
  try {
576
- if (isTs) {
577
- const rawCatalog = await loadTsFile(file);
578
- schema = serializeCatalog(rawCatalog);
579
- } else {
580
- const raw = readFileSync3(file, "utf-8");
581
- schema = JSON.parse(raw);
582
- }
675
+ schema = await loadCatalogFile(file);
583
676
  } catch (err) {
584
677
  console.error(`Failed to read ${file}: ${err.message}`);
585
678
  process.exit(1);
@@ -591,7 +684,7 @@ async function catalogPush(file, opts) {
591
684
  }
592
685
  process.exit(1);
593
686
  }
594
- const catalogDir = dirname(resolve2(file));
687
+ const catalogDir = dirname(resolve3(file));
595
688
  const assetSpinner = ora3("Checking for local assets...").start();
596
689
  try {
597
690
  const result = await uploadLocalAssets(schema, catalogDir, api, (msg) => {
@@ -680,27 +773,11 @@ async function catalogList() {
680
773
  }
681
774
 
682
775
  // src/commands/catalog-dev.ts
683
- import { resolve as resolve3, dirname as dirname2, extname as extname3, join as join2 } from "path";
776
+ import { resolve as resolve4, dirname as dirname2, extname as extname3, join as join2 } from "path";
684
777
  import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync3, watch } from "fs";
685
- import { pathToFileURL as pathToFileURL2 } from "url";
686
778
  import { createServer } from "http";
687
779
  import ora5 from "ora";
688
780
  var DEFAULT_PORT = 3456;
689
- async function loadCatalogFile(file) {
690
- const abs = resolve3(file);
691
- const ext = extname3(file).toLowerCase();
692
- const isTs = ext === ".ts" || ext === ".mts";
693
- if (isTs) {
694
- const { register } = await import("module");
695
- register("tsx/esm", pathToFileURL2("./"));
696
- const url = pathToFileURL2(abs).href + `?t=${Date.now()}`;
697
- const mod = await import(url);
698
- return serializeCatalog(mod.default ?? mod);
699
- } else {
700
- const raw = readFileSync4(abs, "utf-8");
701
- return JSON.parse(raw);
702
- }
703
- }
704
781
  var MIME_TYPES = {
705
782
  ".html": "text/html",
706
783
  ".js": "application/javascript",
@@ -730,7 +807,7 @@ function getMime(filepath) {
730
807
  const ext = extname3(filepath).toLowerCase();
731
808
  return MIME_TYPES[ext] || "application/octet-stream";
732
809
  }
733
- function buildPreviewHtml(schema, port) {
810
+ function buildPreviewHtml(schema, port, validation, devConfig) {
734
811
  const schemaJson = JSON.stringify(schema).replace(/<\/script/gi, "<\\/script");
735
812
  const themeColor = schema.settings?.theme?.primary_color || "#6366f1";
736
813
  return `<!DOCTYPE html>
@@ -927,6 +1004,55 @@ function buildPreviewHtml(schema, port) {
927
1004
  padding: 32px; text-align: center;
928
1005
  box-shadow: 0 2px 12px rgba(0,0,0,0.04);
929
1006
  }
1007
+
1008
+ /* Validation banner */
1009
+ .validation-banner {
1010
+ position: fixed; bottom: 0; left: 0; right: 0; z-index: 99999;
1011
+ background: #7f1d1d; color: #fecaca; font-family: monospace; font-size: 12px;
1012
+ padding: 10px 16px; max-height: 200px; overflow-y: auto;
1013
+ border-top: 2px solid #ef4444;
1014
+ }
1015
+ .validation-banner .vb-header {
1016
+ display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;
1017
+ }
1018
+ .validation-banner .vb-header strong { color: #fca5a5; }
1019
+ .validation-banner .vb-dismiss {
1020
+ background: rgba(255,255,255,0.1); border: none; color: #fca5a5; border-radius: 4px;
1021
+ padding: 2px 8px; cursor: pointer; font-size: 11px;
1022
+ }
1023
+ .validation-banner .vb-dismiss:hover { background: rgba(255,255,255,0.2); }
1024
+ .validation-banner .vb-error { color: #fca5a5; }
1025
+ .validation-banner .vb-warn { color: #fde68a; }
1026
+
1027
+ /* Debug panel */
1028
+ .debug-panel {
1029
+ position: fixed; bottom: 0; right: 0; z-index: 99998;
1030
+ width: 380px; max-height: 60vh; background: #1e1b4b; color: #e0e7ff;
1031
+ font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px;
1032
+ border-top-left-radius: 12px; overflow: hidden;
1033
+ box-shadow: -4px -4px 24px rgba(0,0,0,0.3); display: none;
1034
+ }
1035
+ .debug-panel.open { display: flex; flex-direction: column; }
1036
+ .debug-panel .dp-header {
1037
+ display: flex; align-items: center; justify-content: space-between;
1038
+ padding: 8px 12px; background: #312e81; border-bottom: 1px solid #4338ca;
1039
+ font-weight: 600; font-size: 12px;
1040
+ }
1041
+ .debug-panel .dp-close {
1042
+ background: none; border: none; color: #a5b4fc; cursor: pointer; font-size: 14px;
1043
+ }
1044
+ .debug-panel .dp-body {
1045
+ padding: 10px 12px; overflow-y: auto; flex: 1;
1046
+ }
1047
+ .debug-panel .dp-section { margin-bottom: 10px; }
1048
+ .debug-panel .dp-label { color: #818cf8; font-weight: 600; font-size: 10px; text-transform: uppercase; margin-bottom: 4px; }
1049
+ .debug-panel .dp-badge {
1050
+ display: inline-block; background: #4338ca; color: #c7d2fe; padding: 1px 8px;
1051
+ border-radius: 4px; font-size: 11px; font-weight: 600;
1052
+ }
1053
+ .debug-panel pre { margin: 0; white-space: pre-wrap; word-break: break-all; color: #c7d2fe; line-height: 1.5; }
1054
+ .dev-banner .stub-tag.debug-btn { cursor: pointer; transition: all 0.15s ease; }
1055
+ .dev-banner .stub-tag.debug-btn:hover { background: rgba(255,255,255,0.2); color: #a5b4fc; }
930
1056
  </style>
931
1057
  </head>
932
1058
  <body>
@@ -935,9 +1061,10 @@ function buildPreviewHtml(schema, port) {
935
1061
  <span class="label">LOCAL DEV</span>
936
1062
  <span class="slug">${schema.slug || "catalog"}</span>
937
1063
  <span class="stub-tags">
938
- <span class="stub-tag">Checkout: stubbed</span>
939
- <span class="stub-tag">Analytics: off</span>
1064
+ <span class="stub-tag" id="checkout-tag">${devConfig?.stripeEnabled ? "Checkout: live (test)" : "Checkout: stubbed"}</span>
1065
+ <span class="stub-tag">Events: local</span>
940
1066
  <span class="stub-tag clickable" id="pages-btn">Pages</span>
1067
+ <span class="stub-tag debug-btn" id="debug-btn">Debug</span>
941
1068
  </span>
942
1069
  </div>
943
1070
  <div class="pages-overlay" id="pages-overlay">
@@ -948,8 +1075,15 @@ function buildPreviewHtml(schema, port) {
948
1075
  <div class="inspector-tooltip" id="inspector-tooltip" style="display:none"></div>
949
1076
  <div class="inspector-active-banner" id="inspector-banner" style="display:none">Inspector active &mdash; hover elements, click to copy</div>
950
1077
  <div id="catalog-root"></div>
1078
+ <div class="debug-panel" id="debug-panel">
1079
+ <div class="dp-header"><span>Debug Panel</span><button class="dp-close" id="debug-close">&times;</button></div>
1080
+ <div class="dp-body" id="debug-body"></div>
1081
+ </div>
1082
+ <div class="validation-banner" id="validation-banner" style="display:none"></div>
951
1083
 
952
1084
  <script id="__catalog_data" type="application/json">${schemaJson}</script>
1085
+ <script id="__validation_data" type="application/json">${JSON.stringify(validation || { errors: [], warnings: [] })}</script>
1086
+ <script id="__dev_config" type="application/json">${JSON.stringify(devConfig || { stripeEnabled: false, port })}</script>
953
1087
 
954
1088
  <script type="module">
955
1089
  import React from 'https://esm.sh/react@18';
@@ -959,6 +1093,61 @@ function buildPreviewHtml(schema, port) {
959
1093
  const schema = JSON.parse(document.getElementById('__catalog_data').textContent);
960
1094
  const themeColor = '${themeColor}';
961
1095
 
1096
+ // --- Visibility condition engine (ported from shared/engine/conditions.ts) ---
1097
+ const devContext = (() => {
1098
+ const params = {};
1099
+ new URLSearchParams(window.location.search).forEach((v, k) => { params[k] = v; });
1100
+ return { url_params: params, hints: {}, quiz_scores: null, video_state: null };
1101
+ })();
1102
+
1103
+ function resolveConditionValue(rule, formState, ctx) {
1104
+ const source = rule.source || 'field';
1105
+ if (source === 'field') return rule.field != null ? formState[rule.field] : undefined;
1106
+ if (source === 'url_param') return rule.param != null ? ctx.url_params[rule.param] : undefined;
1107
+ if (source === 'hint') return rule.param != null ? ctx.hints[rule.param] : undefined;
1108
+ if (source === 'score') return 0;
1109
+ if (source === 'video') return undefined;
1110
+ return undefined;
1111
+ }
1112
+
1113
+ function applyConditionOperator(op, actual, expected) {
1114
+ switch (op) {
1115
+ case 'equals': return String(actual ?? '') === String(expected ?? '');
1116
+ case 'not_equals': return String(actual ?? '') !== String(expected ?? '');
1117
+ case 'contains':
1118
+ if (typeof actual === 'string') return actual.includes(String(expected));
1119
+ if (Array.isArray(actual)) return actual.includes(expected);
1120
+ return false;
1121
+ case 'not_contains':
1122
+ if (typeof actual === 'string') return !actual.includes(String(expected));
1123
+ if (Array.isArray(actual)) return !actual.includes(expected);
1124
+ return true;
1125
+ case 'greater_than': return Number(actual) > Number(expected);
1126
+ case 'greater_than_or_equal': return Number(actual) >= Number(expected);
1127
+ case 'less_than': return Number(actual) < Number(expected);
1128
+ case 'less_than_or_equal': return Number(actual) <= Number(expected);
1129
+ case 'is_empty': return actual == null || actual === '' || (Array.isArray(actual) && actual.length === 0);
1130
+ case 'is_not_empty': return !(actual == null || actual === '' || (Array.isArray(actual) && actual.length === 0));
1131
+ case 'matches_regex':
1132
+ try { const p = String(expected); if (p.length > 200) return false; return new RegExp(p).test(String(actual ?? '')); } catch { return false; }
1133
+ case 'in':
1134
+ if (Array.isArray(expected)) return expected.includes(actual);
1135
+ if (typeof expected === 'string') return expected.split(',').map(s => s.trim()).includes(String(actual));
1136
+ return false;
1137
+ default: return false;
1138
+ }
1139
+ }
1140
+
1141
+ function evaluateConditionGroup(group, formState, ctx) {
1142
+ if (!group || !group.rules) return true;
1143
+ const evaluate = (item) => {
1144
+ if (item.match && item.rules) return evaluateConditionGroup(item, formState, ctx);
1145
+ const actual = resolveConditionValue(item, formState, ctx);
1146
+ return applyConditionOperator(item.operator, actual, item.value);
1147
+ };
1148
+ return group.match === 'all' ? group.rules.every(evaluate) : group.rules.some(evaluate);
1149
+ }
1150
+
962
1151
  // --- Markdown-ish text rendering ---
963
1152
  function inlineMarkdown(text) {
964
1153
  return text
@@ -1260,13 +1449,7 @@ function buildPreviewHtml(schema, port) {
1260
1449
  return h(SwitchInput, { comp, type, formState, onFieldChange, isCover, compClass, compStyle });
1261
1450
 
1262
1451
  case 'payment':
1263
- return h('div', { className: 'checkout-stub ' + compClass, style: compStyle },
1264
- h('h3', null, 'Stripe Checkout (Dev Stub)'),
1265
- h('p', null, 'Payment processing is disabled in local dev mode.'),
1266
- props.amount ? h('p', { className: 'mt-2 font-bold text-lg' },
1267
- (props.currency || 'USD').toUpperCase() + ' ' + (props.amount / 100).toFixed(2)
1268
- ) : null
1269
- );
1452
+ return h(PaymentComponent, { comp, formState, isCover, compClass, compStyle });
1270
1453
 
1271
1454
  default:
1272
1455
  return h('div', {
@@ -1427,6 +1610,102 @@ function buildPreviewHtml(schema, port) {
1427
1610
  );
1428
1611
  }
1429
1612
 
1613
+ // --- Dev Config ---
1614
+ const devConfig = JSON.parse(document.getElementById('__dev_config').textContent);
1615
+
1616
+ // --- Local Event Emitter ---
1617
+ const devEvents = {
1618
+ _sse: null,
1619
+ init() {
1620
+ this._sse = new EventSource('/__dev_events_stream');
1621
+ this._sse.onerror = () => {};
1622
+ },
1623
+ emit(type, data) {
1624
+ const event = { type, timestamp: new Date().toISOString(), data };
1625
+ // Fire to local SSE listeners (agents can listen via /__dev_events_stream)
1626
+ fetch('/__dev_event', {
1627
+ method: 'POST',
1628
+ headers: { 'Content-Type': 'application/json' },
1629
+ body: JSON.stringify(event),
1630
+ }).catch(() => {});
1631
+ // Also dispatch as browser event for debug panel
1632
+ window.dispatchEvent(new CustomEvent('devEvent', { detail: event }));
1633
+ }
1634
+ };
1635
+ devEvents.init();
1636
+
1637
+ // --- Payment Component ---
1638
+ function PaymentComponent({ comp, formState, isCover, compClass, compStyle }) {
1639
+ const props = comp.props || {};
1640
+ const [loading, setLoading] = React.useState(false);
1641
+ const [error, setError] = React.useState(null);
1642
+
1643
+ const handleCheckout = React.useCallback(async () => {
1644
+ devEvents.emit('checkout_started', { component_id: comp.id, amount: props.amount, currency: props.currency });
1645
+ setLoading(true);
1646
+ setError(null);
1647
+ try {
1648
+ const res = await fetch('/__dev_checkout', {
1649
+ method: 'POST',
1650
+ headers: { 'Content-Type': 'application/json' },
1651
+ body: JSON.stringify({
1652
+ line_items: [{
1653
+ title: props.title || comp.id,
1654
+ amount_cents: props.amount,
1655
+ currency: (props.currency || 'usd').toLowerCase(),
1656
+ quantity: 1,
1657
+ stripe_price_id: props.stripe_price_id,
1658
+ payment_type: props.checkout_type === 'redirect' ? 'one_time' : (schema.settings?.checkout?.payment_type || 'one_time'),
1659
+ }],
1660
+ form_state: formState,
1661
+ catalog_slug: schema.slug,
1662
+ }),
1663
+ });
1664
+ const data = await res.json();
1665
+ if (data.session_url) {
1666
+ devEvents.emit('checkout_redirect', { session_id: data.session_id });
1667
+ window.location.href = data.session_url;
1668
+ } else if (data.error) {
1669
+ setError(data.error);
1670
+ }
1671
+ } catch (err) {
1672
+ setError(err.message || 'Checkout failed');
1673
+ } finally {
1674
+ setLoading(false);
1675
+ }
1676
+ }, [comp.id, props, formState]);
1677
+
1678
+ // No Stripe key \u2014 show informative stub
1679
+ if (!devConfig.stripeEnabled) {
1680
+ return h('div', { className: 'checkout-stub ' + compClass, style: compStyle },
1681
+ h('h3', null, 'Stripe Checkout'),
1682
+ h('p', null, 'Add STRIPE_SECRET_KEY to your .env to enable real checkout in dev.'),
1683
+ props.amount ? h('p', { className: 'mt-2 font-bold text-lg' },
1684
+ (props.currency || 'USD').toUpperCase() + ' ' + (props.amount / 100).toFixed(2)
1685
+ ) : null,
1686
+ h('details', { className: 'mt-3 text-left', style: { fontSize: '11px', color: '#92400e' } },
1687
+ h('summary', { style: { cursor: 'pointer' } }, 'Checkout payload'),
1688
+ h('pre', { style: { whiteSpace: 'pre-wrap', marginTop: '4px' } },
1689
+ JSON.stringify({ amount: props.amount, currency: props.currency, stripe_price_id: props.stripe_price_id, payment_type: schema.settings?.checkout?.payment_type }, null, 2)
1690
+ )
1691
+ )
1692
+ );
1693
+ }
1694
+
1695
+ // Real Stripe checkout
1696
+ return h('div', { className: compClass, style: compStyle },
1697
+ h('button', {
1698
+ className: 'cf-btn-primary w-full text-white',
1699
+ style: { backgroundColor: themeColor, opacity: loading ? 0.7 : 1 },
1700
+ onClick: handleCheckout,
1701
+ disabled: loading,
1702
+ },
1703
+ loading ? 'Redirecting to Stripe...' : (props.button_text || (props.amount ? ((props.currency || 'USD').toUpperCase() + ' ' + (props.amount / 100).toFixed(2) + ' \u2014 Pay Now') : 'Checkout'))
1704
+ ),
1705
+ error ? h('p', { className: 'text-red-500 text-sm mt-2 text-center' }, error) : null
1706
+ );
1707
+ }
1708
+
1430
1709
  // --- Cart Components ---
1431
1710
  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
1711
 
@@ -1522,15 +1801,76 @@ function buildPreviewHtml(schema, port) {
1522
1801
  'Added to order'
1523
1802
  )
1524
1803
  ),
1525
- h('div', { className: 'checkout-stub' },
1526
- h('h3', null, 'Checkout (Dev Stub)'),
1527
- h('p', null, 'Payment processing is disabled in local dev mode.')
1528
- )
1804
+ h(CartCheckoutButton, { items, themeColor })
1529
1805
  ) : null
1530
1806
  )
1531
1807
  );
1532
1808
  }
1533
1809
 
1810
+ function CartCheckoutButton({ items, themeColor }) {
1811
+ const [loading, setLoading] = React.useState(false);
1812
+ const [error, setError] = React.useState(null);
1813
+
1814
+ const handleCheckout = React.useCallback(async () => {
1815
+ const state = window.__devDebugState;
1816
+ devEvents.emit('cart_checkout_started', { items: items.map(i => ({ offer_id: i.offer_id, title: i.title })) });
1817
+ setLoading(true);
1818
+ setError(null);
1819
+ try {
1820
+ const res = await fetch('/__dev_checkout', {
1821
+ method: 'POST',
1822
+ headers: { 'Content-Type': 'application/json' },
1823
+ body: JSON.stringify({
1824
+ line_items: items.map(item => ({
1825
+ title: item.title,
1826
+ amount_cents: item.amount_cents,
1827
+ currency: item.currency || 'usd',
1828
+ quantity: 1,
1829
+ stripe_price_id: item.stripe_price_id,
1830
+ payment_type: schema.settings?.checkout?.payment_type || 'one_time',
1831
+ })),
1832
+ form_state: state?.formState || {},
1833
+ catalog_slug: schema.slug,
1834
+ }),
1835
+ });
1836
+ const data = await res.json();
1837
+ if (data.session_url) {
1838
+ devEvents.emit('checkout_redirect', { session_id: data.session_id });
1839
+ window.location.href = data.session_url;
1840
+ } else if (data.error) {
1841
+ setError(data.error);
1842
+ }
1843
+ } catch (err) {
1844
+ setError(err.message || 'Checkout failed');
1845
+ } finally {
1846
+ setLoading(false);
1847
+ }
1848
+ }, [items]);
1849
+
1850
+ if (!devConfig.stripeEnabled) {
1851
+ return h('div', { className: 'checkout-stub' },
1852
+ h('h3', null, 'Checkout'),
1853
+ h('p', null, 'Add STRIPE_SECRET_KEY to .env for real checkout.'),
1854
+ h('details', { className: 'mt-2 text-left', style: { fontSize: '11px', color: '#92400e' } },
1855
+ h('summary', { style: { cursor: 'pointer' } }, 'Cart payload'),
1856
+ h('pre', { style: { whiteSpace: 'pre-wrap', marginTop: '4px' } },
1857
+ JSON.stringify(items.map(i => ({ offer_id: i.offer_id, title: i.title, price: i.price_display })), null, 2)
1858
+ )
1859
+ )
1860
+ );
1861
+ }
1862
+
1863
+ return h('div', { className: 'space-y-2' },
1864
+ h('button', {
1865
+ className: 'cf-btn-primary w-full text-white',
1866
+ style: { backgroundColor: themeColor, opacity: loading ? 0.7 : 1 },
1867
+ onClick: handleCheckout,
1868
+ disabled: loading,
1869
+ }, loading ? 'Redirecting to Stripe...' : 'Proceed to Checkout'),
1870
+ error ? h('p', { className: 'text-red-500 text-sm text-center' }, error) : null
1871
+ );
1872
+ }
1873
+
1534
1874
  // --- Main App ---
1535
1875
  function CatalogPreview({ catalog }) {
1536
1876
  const pages = catalog.pages || {};
@@ -1587,12 +1927,20 @@ function buildPreviewHtml(schema, port) {
1587
1927
  }
1588
1928
  }, [formState, pages, cartItems, addToCart, removeFromCart]);
1589
1929
 
1590
- // Expose navigation for mindmap
1930
+ // Expose navigation for mindmap + emit page_view
1591
1931
  React.useEffect(() => {
1592
1932
  window.__devNavigateTo = (id) => { setCurrentPageId(id); setHistory([]); window.scrollTo({ top: 0, behavior: 'smooth' }); };
1593
1933
  window.__devSetCurrentPage && window.__devSetCurrentPage(currentPageId);
1934
+ devEvents.emit('page_view', { page_id: currentPageId, catalog_slug: catalog.slug });
1594
1935
  }, [currentPageId]);
1595
1936
 
1937
+ // Expose debug state
1938
+ React.useEffect(() => {
1939
+ const edges = (routing.edges || []).filter(e => e.from === currentPageId);
1940
+ window.__devDebugState = { currentPageId, formState, cartItems, edges };
1941
+ window.dispatchEvent(new CustomEvent('devStateUpdate'));
1942
+ }, [currentPageId, formState, cartItems, routing]);
1943
+
1596
1944
  const page = currentPageId ? pages[currentPageId] : null;
1597
1945
  const isCover = page?.layout === 'cover';
1598
1946
  const isLastPage = (() => {
@@ -1602,7 +1950,8 @@ function buildPreviewHtml(schema, port) {
1602
1950
 
1603
1951
  const onFieldChange = React.useCallback((id, value) => {
1604
1952
  setFormState(prev => ({ ...prev, [id]: value }));
1605
- }, []);
1953
+ devEvents.emit('field_change', { field_id: id, value, page_id: currentPageId });
1954
+ }, [currentPageId]);
1606
1955
 
1607
1956
  const handleNext = React.useCallback(() => {
1608
1957
  // Check if page has an offer \u2014 treat "Next" as an accept action
@@ -1637,7 +1986,11 @@ function buildPreviewHtml(schema, port) {
1637
1986
  );
1638
1987
  }
1639
1988
 
1640
- const components = (page.components || []).filter(c => !c.hidden && !c.props?.hidden);
1989
+ const components = (page.components || []).filter(c => {
1990
+ if (c.hidden || c.props?.hidden) return false;
1991
+ if (c.visibility) return evaluateConditionGroup(c.visibility, formState, devContext);
1992
+ return true;
1993
+ });
1641
1994
  const bgImage = page.background_image || catalog.settings?.theme?.background_image;
1642
1995
 
1643
1996
  // Cart UI (shared between cover and standard)
@@ -1987,18 +2340,108 @@ function buildPreviewHtml(schema, port) {
1987
2340
  });
1988
2341
  }, true);
1989
2342
  })();
2343
+
2344
+ // --- Validation banner ---
2345
+ (function initValidationBanner() {
2346
+ const banner = document.getElementById('validation-banner');
2347
+ const validation = JSON.parse(document.getElementById('__validation_data').textContent);
2348
+ const errors = validation.errors || [];
2349
+ const warnings = validation.warnings || [];
2350
+ if (errors.length === 0 && warnings.length === 0) return;
2351
+ let html = '<div class="vb-header"><strong>' + errors.length + ' error(s), ' + warnings.length + ' warning(s)</strong><button class="vb-dismiss" id="vb-dismiss">Dismiss</button></div>';
2352
+ for (const e of errors) html += '<div class="vb-error">ERROR: ' + e + '</div>';
2353
+ for (const w of warnings) html += '<div class="vb-warn">WARN: ' + w + '</div>';
2354
+ banner.innerHTML = html;
2355
+ banner.style.display = 'block';
2356
+ document.getElementById('vb-dismiss').addEventListener('click', () => { banner.style.display = 'none'; });
2357
+ })();
2358
+
2359
+ // --- Debug panel ---
2360
+ (function initDebugPanel() {
2361
+ const panel = document.getElementById('debug-panel');
2362
+ const body = document.getElementById('debug-body');
2363
+ const btn = document.getElementById('debug-btn');
2364
+ const closeBtn = document.getElementById('debug-close');
2365
+ let isOpen = false;
2366
+
2367
+ function toggle() {
2368
+ isOpen = !isOpen;
2369
+ panel.classList.toggle('open', isOpen);
2370
+ if (isOpen) render();
2371
+ }
2372
+ btn.addEventListener('click', toggle);
2373
+ closeBtn.addEventListener('click', toggle);
2374
+
2375
+ document.addEventListener('keydown', (e) => {
2376
+ if (e.ctrlKey && e.key === 'd') { e.preventDefault(); toggle(); }
2377
+ });
2378
+
2379
+ const recentEvents = [];
2380
+ window.addEventListener('devEvent', (e) => {
2381
+ recentEvents.push(e.detail);
2382
+ if (recentEvents.length > 20) recentEvents.shift();
2383
+ if (isOpen) render();
2384
+ });
2385
+
2386
+ function render() {
2387
+ const state = window.__devDebugState;
2388
+ if (!state) { body.innerHTML = '<p>Waiting for state...</p>'; return; }
2389
+ let html = '<div class="dp-section"><div class="dp-label">Current Page</div><span class="dp-badge">' + (state.currentPageId || 'none') + '</span></div>';
2390
+ html += '<div class="dp-section"><div class="dp-label">Form State</div><pre>' + JSON.stringify(state.formState || {}, null, 2) + '</pre></div>';
2391
+ html += '<div class="dp-section"><div class="dp-label">Cart (' + (state.cartItems?.length || 0) + ')</div>';
2392
+ if (state.cartItems && state.cartItems.length > 0) {
2393
+ html += '<pre>' + JSON.stringify(state.cartItems.map(i => i.title || i.offer_id), null, 2) + '</pre>';
2394
+ } else {
2395
+ html += '<pre>empty</pre>';
2396
+ }
2397
+ html += '</div>';
2398
+ html += '<div class="dp-section"><div class="dp-label">Edges from here</div>';
2399
+ if (state.edges && state.edges.length > 0) {
2400
+ html += '<pre>' + state.edges.map(e => e.from + ' \u2192 ' + e.to + (e.conditions?.length ? ' (conditional)' : '')).join('\\n') + '</pre>';
2401
+ } else {
2402
+ html += '<pre>none (terminal page)</pre>';
2403
+ }
2404
+ html += '</div>';
2405
+ html += '<div class="dp-section"><div class="dp-label">Recent Events (' + recentEvents.length + ')</div>';
2406
+ if (recentEvents.length > 0) {
2407
+ html += '<pre>' + recentEvents.slice(-8).map(e => e.type + ' ' + JSON.stringify(e.data || {})).join('\\n') + '</pre>';
2408
+ } else {
2409
+ html += '<pre>none yet</pre>';
2410
+ }
2411
+ html += '</div>';
2412
+ body.innerHTML = html;
2413
+ }
2414
+
2415
+ window.addEventListener('devStateUpdate', () => { if (isOpen) render(); });
2416
+ })();
1990
2417
  </script>
1991
2418
  </body>
1992
2419
  </html>`;
1993
2420
  }
1994
2421
  async function catalogDev(file, opts) {
1995
- const abs = resolve3(file);
2422
+ const abs = resolve4(file);
1996
2423
  const catalogDir = dirname2(abs);
1997
2424
  const port = parseInt(opts.port || String(DEFAULT_PORT), 10);
1998
2425
  if (!existsSync2(abs)) {
1999
2426
  console.error(`File not found: ${file}`);
2000
2427
  process.exit(1);
2001
2428
  }
2429
+ let stripeSecretKey = process.env.STRIPE_SECRET_KEY || "";
2430
+ if (!stripeSecretKey) {
2431
+ for (const envFile of [".env", ".env.local", ".env.development"]) {
2432
+ const envPath = join2(catalogDir, envFile);
2433
+ if (existsSync2(envPath)) {
2434
+ const envContent = readFileSync4(envPath, "utf-8");
2435
+ const match = envContent.match(/^STRIPE_SECRET_KEY\s*=\s*"?([^"\n]+)"?/m);
2436
+ if (match) {
2437
+ stripeSecretKey = match[1].trim();
2438
+ break;
2439
+ }
2440
+ }
2441
+ }
2442
+ }
2443
+ const stripeEnabled = stripeSecretKey.length > 0;
2444
+ const devConfig = { stripeEnabled, port };
2002
2445
  const spinner = ora5("Loading catalog schema...").start();
2003
2446
  let schema;
2004
2447
  try {
@@ -2007,14 +2450,15 @@ async function catalogDev(file, opts) {
2007
2450
  spinner.fail(`Failed to load catalog: ${err.message}`);
2008
2451
  process.exit(1);
2009
2452
  }
2010
- const errors = validateCatalog(schema);
2011
- if (errors.length > 0) {
2453
+ const basicErrors = validateCatalog(schema);
2454
+ if (basicErrors.length > 0) {
2012
2455
  spinner.fail("Schema validation errors:");
2013
- for (const err of errors) {
2456
+ for (const err of basicErrors) {
2014
2457
  console.error(` - ${err}`);
2015
2458
  }
2016
2459
  process.exit(1);
2017
2460
  }
2461
+ let validation = deepValidateCatalog(schema);
2018
2462
  const localBaseUrl = `http://localhost:${port}/assets`;
2019
2463
  schema = resolveLocalAssets(schema, catalogDir, localBaseUrl);
2020
2464
  spinner.succeed(`Loaded: ${schema.slug || file}`);
@@ -2022,6 +2466,8 @@ async function catalogDev(file, opts) {
2022
2466
  console.log(` Entry: ${schema.routing?.entry || "first page"}`);
2023
2467
  console.log();
2024
2468
  const sseClients = /* @__PURE__ */ new Set();
2469
+ const eventSseClients = /* @__PURE__ */ new Set();
2470
+ const eventLog = [];
2025
2471
  function notifyReload() {
2026
2472
  sseClients.forEach((client) => {
2027
2473
  try {
@@ -2031,6 +2477,22 @@ async function catalogDev(file, opts) {
2031
2477
  }
2032
2478
  });
2033
2479
  }
2480
+ function emitDevEvent(event) {
2481
+ eventLog.push(event);
2482
+ if (eventLog.length > 500) eventLog.shift();
2483
+ const data = JSON.stringify(event);
2484
+ const ts = (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
2485
+ console.log(` \x1B[36m[event]\x1B[0m ${ts} ${event.type} ${JSON.stringify(event.data || {})}`);
2486
+ eventSseClients.forEach((client) => {
2487
+ try {
2488
+ client.write(`data: ${data}
2489
+
2490
+ `);
2491
+ } catch {
2492
+ eventSseClients.delete(client);
2493
+ }
2494
+ });
2495
+ }
2034
2496
  const server = createServer(async (req, res) => {
2035
2497
  const url = new URL(req.url || "/", `http://localhost:${port}`);
2036
2498
  if (url.pathname === "/__dev_sse") {
@@ -2045,11 +2507,127 @@ async function catalogDev(file, opts) {
2045
2507
  req.on("close", () => sseClients.delete(res));
2046
2508
  return;
2047
2509
  }
2510
+ if (url.pathname === "/__dev_events_stream") {
2511
+ res.writeHead(200, {
2512
+ "Content-Type": "text/event-stream",
2513
+ "Cache-Control": "no-cache",
2514
+ "Connection": "keep-alive",
2515
+ "Access-Control-Allow-Origin": "*"
2516
+ });
2517
+ res.write("data: connected\n\n");
2518
+ eventSseClients.add(res);
2519
+ req.on("close", () => eventSseClients.delete(res));
2520
+ return;
2521
+ }
2522
+ if (url.pathname === "/__dev_events" && req.method === "GET") {
2523
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
2524
+ const limit = parseInt(url.searchParams.get("limit") || "50", 10);
2525
+ res.end(JSON.stringify(eventLog.slice(-limit)));
2526
+ return;
2527
+ }
2528
+ if (url.pathname === "/__dev_event" && req.method === "POST") {
2529
+ let body = "";
2530
+ req.on("data", (chunk) => {
2531
+ body += chunk;
2532
+ });
2533
+ req.on("end", () => {
2534
+ try {
2535
+ const event = JSON.parse(body);
2536
+ emitDevEvent(event);
2537
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
2538
+ res.end('{"ok":true}');
2539
+ } catch {
2540
+ res.writeHead(400);
2541
+ res.end('{"error":"invalid json"}');
2542
+ }
2543
+ });
2544
+ return;
2545
+ }
2546
+ if (url.pathname === "/__dev_checkout" && req.method === "POST") {
2547
+ let body = "";
2548
+ req.on("data", (chunk) => {
2549
+ body += chunk;
2550
+ });
2551
+ req.on("end", async () => {
2552
+ res.setHeader("Content-Type", "application/json");
2553
+ res.setHeader("Access-Control-Allow-Origin", "*");
2554
+ if (!stripeEnabled) {
2555
+ res.writeHead(400);
2556
+ res.end(JSON.stringify({ error: "No STRIPE_SECRET_KEY found. Add it to .env in your catalog directory." }));
2557
+ return;
2558
+ }
2559
+ try {
2560
+ const { line_items, form_state, catalog_slug } = JSON.parse(body);
2561
+ const checkoutSettings = schema.settings?.checkout || {};
2562
+ const params = new URLSearchParams();
2563
+ params.set("mode", checkoutSettings.payment_type === "subscription" ? "subscription" : "payment");
2564
+ params.set("success_url", `http://localhost:${port}/?checkout=success&session_id={CHECKOUT_SESSION_ID}`);
2565
+ params.set("cancel_url", `http://localhost:${port}/?checkout=cancel`);
2566
+ const emailField = checkoutSettings.prefill_fields?.customer_email;
2567
+ if (emailField && form_state?.[emailField]) {
2568
+ params.set("customer_email", form_state[emailField]);
2569
+ }
2570
+ const methods = checkoutSettings.payment_methods || ["card"];
2571
+ methods.forEach((m, i) => params.set(`payment_method_types[${i}]`, m));
2572
+ if (checkoutSettings.allow_discount_codes) {
2573
+ params.set("allow_promotion_codes", "true");
2574
+ }
2575
+ line_items.forEach((item, i) => {
2576
+ if (item.stripe_price_id) {
2577
+ params.set(`line_items[${i}][price]`, item.stripe_price_id);
2578
+ params.set(`line_items[${i}][quantity]`, String(item.quantity || 1));
2579
+ } else if (item.amount_cents) {
2580
+ params.set(`line_items[${i}][price_data][currency]`, item.currency || "usd");
2581
+ params.set(`line_items[${i}][price_data][unit_amount]`, String(item.amount_cents));
2582
+ params.set(`line_items[${i}][price_data][product_data][name]`, item.title || "Item");
2583
+ if (checkoutSettings.payment_type === "subscription") {
2584
+ params.set(`line_items[${i}][price_data][recurring][interval]`, "month");
2585
+ }
2586
+ params.set(`line_items[${i}][quantity]`, String(item.quantity || 1));
2587
+ }
2588
+ });
2589
+ if (checkoutSettings.payment_type === "subscription" && checkoutSettings.free_trial?.enabled && checkoutSettings.free_trial.days) {
2590
+ params.set("subscription_data[trial_period_days]", String(checkoutSettings.free_trial.days));
2591
+ }
2592
+ const stripeRes = await fetch("https://api.stripe.com/v1/checkout/sessions", {
2593
+ method: "POST",
2594
+ headers: {
2595
+ "Authorization": `Basic ${Buffer.from(stripeSecretKey + ":").toString("base64")}`,
2596
+ "Content-Type": "application/x-www-form-urlencoded"
2597
+ },
2598
+ body: params.toString()
2599
+ });
2600
+ const stripeData = await stripeRes.json();
2601
+ if (!stripeRes.ok) {
2602
+ emitDevEvent({ type: "checkout_error", timestamp: (/* @__PURE__ */ new Date()).toISOString(), data: { error: stripeData.error?.message || "Stripe error" } });
2603
+ res.writeHead(stripeRes.status);
2604
+ res.end(JSON.stringify({ error: stripeData.error?.message || "Stripe session creation failed" }));
2605
+ return;
2606
+ }
2607
+ emitDevEvent({ type: "checkout_session_created", timestamp: (/* @__PURE__ */ new Date()).toISOString(), data: { session_id: stripeData.id, url: stripeData.url } });
2608
+ res.writeHead(200);
2609
+ res.end(JSON.stringify({ session_id: stripeData.id, session_url: stripeData.url }));
2610
+ } catch (err) {
2611
+ res.writeHead(500);
2612
+ res.end(JSON.stringify({ error: err.message }));
2613
+ }
2614
+ });
2615
+ return;
2616
+ }
2617
+ if (req.method === "OPTIONS") {
2618
+ res.writeHead(204, {
2619
+ "Access-Control-Allow-Origin": "*",
2620
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
2621
+ "Access-Control-Allow-Headers": "Content-Type"
2622
+ });
2623
+ res.end();
2624
+ return;
2625
+ }
2048
2626
  if (url.pathname.startsWith("/assets/")) {
2049
2627
  const relativePath = decodeURIComponent(url.pathname.slice("/assets/".length));
2050
2628
  const filePath = join2(catalogDir, relativePath);
2051
- const resolved = resolve3(filePath);
2052
- if (!resolved.startsWith(resolve3(catalogDir))) {
2629
+ const resolved = resolve4(filePath);
2630
+ if (!resolved.startsWith(resolve4(catalogDir))) {
2053
2631
  res.writeHead(403);
2054
2632
  res.end("Forbidden");
2055
2633
  return;
@@ -2077,11 +2655,13 @@ async function catalogDev(file, opts) {
2077
2655
  "Content-Type": "text/html; charset=utf-8",
2078
2656
  "Cache-Control": "no-cache"
2079
2657
  });
2080
- res.end(buildPreviewHtml(schema, port));
2658
+ res.end(buildPreviewHtml(schema, port, validation, devConfig));
2081
2659
  });
2082
2660
  server.listen(port, () => {
2083
2661
  console.log(` Local preview: http://localhost:${port}`);
2084
2662
  console.log(` Assets served from: ${catalogDir}`);
2663
+ 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"}`);
2664
+ console.log(` Events: \x1B[32mlocal\x1B[0m \u2014 stream at http://localhost:${port}/__dev_events_stream`);
2085
2665
  console.log(` Watching for changes...
2086
2666
  `);
2087
2667
  });
@@ -2098,6 +2678,7 @@ async function catalogDev(file, opts) {
2098
2678
  for (const e of errs) console.error(` - ${e}`);
2099
2679
  return;
2100
2680
  }
2681
+ validation = deepValidateCatalog(schema);
2101
2682
  schema = resolveLocalAssets(schema, catalogDir, localBaseUrl);
2102
2683
  reloadSpinner.succeed(`Reloaded \u2014 auto-refreshing browser`);
2103
2684
  notifyReload();
@@ -2108,7 +2689,7 @@ async function catalogDev(file, opts) {
2108
2689
  });
2109
2690
  try {
2110
2691
  watch(catalogDir, { recursive: true }, (event, filename) => {
2111
- if (!filename || filename.startsWith(".") || resolve3(join2(catalogDir, filename)) === abs) return;
2692
+ if (!filename || filename.startsWith(".") || resolve4(join2(catalogDir, filename)) === abs) return;
2112
2693
  });
2113
2694
  } catch {
2114
2695
  }
@@ -2119,6 +2700,439 @@ async function catalogDev(file, opts) {
2119
2700
  });
2120
2701
  }
2121
2702
 
2703
+ // src/commands/catalog-validate.ts
2704
+ import { resolve as resolve5 } from "path";
2705
+ import { existsSync as existsSync3 } from "fs";
2706
+ import ora6 from "ora";
2707
+ async function catalogValidate(file) {
2708
+ const abs = resolve5(file);
2709
+ if (!existsSync3(abs)) {
2710
+ console.error(`File not found: ${file}`);
2711
+ process.exit(1);
2712
+ }
2713
+ const spinner = ora6("Loading catalog schema...").start();
2714
+ let schema;
2715
+ try {
2716
+ schema = await loadCatalogFile(abs);
2717
+ } catch (err) {
2718
+ spinner.fail(`Failed to load catalog: ${err.message}`);
2719
+ process.exit(1);
2720
+ }
2721
+ spinner.succeed(`Loaded: ${schema.slug || file}`);
2722
+ const basicErrors = validateCatalog(schema);
2723
+ const { errors: deepErrors, warnings } = deepValidateCatalog(schema);
2724
+ const allErrors = [...basicErrors, ...deepErrors];
2725
+ const pages = schema.pages || {};
2726
+ const pageCount = Object.keys(pages).length;
2727
+ const componentCount = Object.values(pages).reduce(
2728
+ (sum, p) => sum + (p.components?.length || 0),
2729
+ 0
2730
+ );
2731
+ const edgeCount = schema.routing?.edges?.length || 0;
2732
+ const offerCount = Object.values(pages).filter((p) => p.offer).length;
2733
+ console.log();
2734
+ console.log(` Slug: ${schema.slug || "(none)"}`);
2735
+ console.log(` Version: ${schema.schema_version || "(none)"}`);
2736
+ console.log(` Pages: ${pageCount}`);
2737
+ console.log(` Components: ${componentCount}`);
2738
+ console.log(` Edges: ${edgeCount}`);
2739
+ console.log(` Offers: ${offerCount}`);
2740
+ console.log();
2741
+ if (allErrors.length > 0) {
2742
+ for (const err of allErrors) {
2743
+ console.error(` \x1B[31mERROR\x1B[0m ${err}`);
2744
+ }
2745
+ }
2746
+ if (warnings.length > 0) {
2747
+ for (const warn of warnings) {
2748
+ console.warn(` \x1B[33mWARN\x1B[0m ${warn}`);
2749
+ }
2750
+ }
2751
+ if (allErrors.length === 0 && warnings.length === 0) {
2752
+ console.log(" \x1B[32m\u2713 No issues found\x1B[0m");
2753
+ } else {
2754
+ console.log();
2755
+ console.log(
2756
+ ` ${allErrors.length} error(s), ${warnings.length} warning(s)`
2757
+ );
2758
+ }
2759
+ process.exit(allErrors.length > 0 ? 1 : 0);
2760
+ }
2761
+
2762
+ // src/commands/catalog-init.ts
2763
+ import { mkdirSync, writeFileSync, existsSync as existsSync4 } from "fs";
2764
+ import { join as join3 } from "path";
2765
+ import { createInterface } from "readline";
2766
+ function ask(rl, question) {
2767
+ return new Promise((resolve7) => rl.question(question, resolve7));
2768
+ }
2769
+ var TEMPLATES = {
2770
+ "quiz-funnel": {
2771
+ description: "3-page quiz funnel: welcome cover, quiz question, result with offer",
2772
+ content: (slug) => `import type { CatalogSchema } from "@shared/types";
2773
+
2774
+ const catalog = {
2775
+ schema_version: "1.0",
2776
+ slug: "${slug}",
2777
+ settings: {
2778
+ theme: { primary_color: "#6366f1" },
2779
+ progress_steps: [
2780
+ { id: "quiz", label: "Quiz", pages: ["welcome", "question"] },
2781
+ { id: "result", label: "Result", pages: ["result"] },
2782
+ ],
2783
+ },
2784
+ routing: {
2785
+ entry: "welcome",
2786
+ edges: [
2787
+ { from: "welcome", to: "question" },
2788
+ { from: "question", to: "result" },
2789
+ ],
2790
+ },
2791
+ pages: {
2792
+ welcome: {
2793
+ layout: "cover",
2794
+ components: [
2795
+ { id: "heading", type: "heading", props: { level: 1, text: "Find Your Perfect Plan", subtitle: "Answer a few quick questions to get a personalized recommendation." } },
2796
+ ],
2797
+ },
2798
+ question: {
2799
+ title: "About You",
2800
+ components: [
2801
+ { id: "role", type: "multiple_choice", props: { label: "What best describes you?", options: ["Solopreneur", "Agency", "E-commerce", "SaaS"], required: true } },
2802
+ ],
2803
+ },
2804
+ result: {
2805
+ title: "Your Result",
2806
+ components: [
2807
+ { id: "result_heading", type: "heading", props: { level: 2, text: "Here's our recommendation" } },
2808
+ { id: "result_text", type: "paragraph", props: { text: "Based on your answers, we think this plan is perfect for you." } },
2809
+ ],
2810
+ offer: {
2811
+ id: "main-offer",
2812
+ title: "Starter Plan",
2813
+ price_display: "$29/mo",
2814
+ },
2815
+ },
2816
+ },
2817
+ } satisfies CatalogSchema;
2818
+
2819
+ export default catalog;
2820
+ `
2821
+ },
2822
+ "lead-capture": {
2823
+ description: "2-page lead capture: info form + thank you",
2824
+ content: (slug) => `import type { CatalogSchema } from "@shared/types";
2825
+
2826
+ const catalog = {
2827
+ schema_version: "1.0",
2828
+ slug: "${slug}",
2829
+ settings: {
2830
+ theme: { primary_color: "#10b981" },
2831
+ },
2832
+ routing: {
2833
+ entry: "form",
2834
+ edges: [
2835
+ { from: "form", to: "thank_you" },
2836
+ ],
2837
+ },
2838
+ pages: {
2839
+ form: {
2840
+ title: "Get Started",
2841
+ components: [
2842
+ { id: "heading", type: "heading", props: { level: 2, text: "Tell us about yourself" } },
2843
+ { id: "name", type: "short_text", props: { label: "Full Name", placeholder: "Jane Doe", required: true } },
2844
+ { id: "email", type: "email", props: { label: "Email", placeholder: "you@example.com", required: true } },
2845
+ { id: "company", type: "short_text", props: { label: "Company", placeholder: "Acme Inc" } },
2846
+ ],
2847
+ },
2848
+ thank_you: {
2849
+ title: "Thank You!",
2850
+ hide_navigation: true,
2851
+ components: [
2852
+ { id: "thanks", type: "heading", props: { level: 2, text: "Thanks! We'll be in touch soon." } },
2853
+ { id: "note", type: "paragraph", props: { text: "Check your inbox for a confirmation email." } },
2854
+ ],
2855
+ },
2856
+ },
2857
+ } satisfies CatalogSchema;
2858
+
2859
+ export default catalog;
2860
+ `
2861
+ },
2862
+ "product-catalog": {
2863
+ description: "2-page product showcase: pricing cards + checkout",
2864
+ content: (slug) => `import type { CatalogSchema } from "@shared/types";
2865
+
2866
+ const catalog = {
2867
+ schema_version: "1.0",
2868
+ slug: "${slug}",
2869
+ settings: {
2870
+ theme: { primary_color: "#f59e0b" },
2871
+ },
2872
+ routing: {
2873
+ entry: "pricing",
2874
+ edges: [
2875
+ { from: "pricing", to: "checkout" },
2876
+ ],
2877
+ },
2878
+ pages: {
2879
+ pricing: {
2880
+ title: "Choose a Plan",
2881
+ components: [
2882
+ { id: "heading", type: "heading", props: { level: 1, text: "Simple, transparent pricing" } },
2883
+ { id: "starter", type: "pricing_card", props: { title: "Starter", price: "$19", period: "mo", features: ["5 projects", "Basic analytics", "Email support"], cta_text: "Get Started" } },
2884
+ { 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" } },
2885
+ ],
2886
+ },
2887
+ checkout: {
2888
+ title: "Complete Your Order",
2889
+ components: [
2890
+ { id: "checkout_pay", type: "payment", props: { amount: 4900, currency: "usd" } },
2891
+ ],
2892
+ },
2893
+ },
2894
+ } satisfies CatalogSchema;
2895
+
2896
+ export default catalog;
2897
+ `
2898
+ },
2899
+ blank: {
2900
+ description: "Minimal 1-page skeleton",
2901
+ content: (slug) => `import type { CatalogSchema } from "@shared/types";
2902
+
2903
+ const catalog = {
2904
+ schema_version: "1.0",
2905
+ slug: "${slug}",
2906
+ settings: {
2907
+ theme: { primary_color: "#6366f1" },
2908
+ },
2909
+ routing: {
2910
+ entry: "main",
2911
+ edges: [],
2912
+ },
2913
+ pages: {
2914
+ main: {
2915
+ title: "Welcome",
2916
+ components: [
2917
+ { id: "heading", type: "heading", props: { level: 1, text: "Hello, World!" } },
2918
+ { id: "text", type: "paragraph", props: { text: "Start building your catalog here." } },
2919
+ ],
2920
+ },
2921
+ },
2922
+ } satisfies CatalogSchema;
2923
+
2924
+ export default catalog;
2925
+ `
2926
+ }
2927
+ };
2928
+ async function catalogInit() {
2929
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2930
+ try {
2931
+ console.log("\n Catalog Kit \u2014 New Catalog Scaffold\n");
2932
+ const slug = (await ask(rl, " Catalog slug (e.g. my-quiz): ")).trim();
2933
+ if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
2934
+ console.error("\n Invalid slug. Use lowercase letters, numbers, and hyphens.");
2935
+ process.exit(1);
2936
+ }
2937
+ if (existsSync4(slug)) {
2938
+ console.error(`
2939
+ Directory "${slug}" already exists.`);
2940
+ process.exit(1);
2941
+ }
2942
+ console.log("\n Templates:");
2943
+ const templateKeys = Object.keys(TEMPLATES);
2944
+ for (let i = 0; i < templateKeys.length; i++) {
2945
+ const key = templateKeys[i];
2946
+ console.log(` ${i + 1}. ${key} \u2014 ${TEMPLATES[key].description}`);
2947
+ }
2948
+ const choice = (await ask(rl, `
2949
+ Pick a template (1-${templateKeys.length}): `)).trim();
2950
+ const idx = parseInt(choice, 10) - 1;
2951
+ if (isNaN(idx) || idx < 0 || idx >= templateKeys.length) {
2952
+ console.error("\n Invalid choice.");
2953
+ process.exit(1);
2954
+ }
2955
+ const templateKey = templateKeys[idx];
2956
+ const template = TEMPLATES[templateKey];
2957
+ mkdirSync(join3(slug, "images"), { recursive: true });
2958
+ writeFileSync(join3(slug, `${slug}.ts`), template.content(slug));
2959
+ writeFileSync(
2960
+ join3(slug, ".env.example"),
2961
+ `CATALOG_KIT_TOKEN=cfk_your_token_here
2962
+ `
2963
+ );
2964
+ console.log(`
2965
+ \x1B[32m\u2713 Created ${slug}/\x1B[0m`);
2966
+ console.log(` ${slug}/${slug}.ts`);
2967
+ console.log(` ${slug}/images/`);
2968
+ console.log(` ${slug}/.env.example`);
2969
+ console.log(`
2970
+ Next steps:`);
2971
+ console.log(` cd ${slug}`);
2972
+ console.log(` catalogs catalog dev ${slug}.ts`);
2973
+ console.log(` catalogs catalog validate ${slug}.ts`);
2974
+ console.log(` catalogs catalog push ${slug}.ts --publish
2975
+ `);
2976
+ } finally {
2977
+ rl.close();
2978
+ }
2979
+ }
2980
+
2981
+ // src/commands/catalog-diff.ts
2982
+ import { resolve as resolve6 } from "path";
2983
+ import { existsSync as existsSync5 } from "fs";
2984
+ import ora7 from "ora";
2985
+ async function catalogDiff(file) {
2986
+ const config = requireConfig();
2987
+ const api = new ApiClient(config);
2988
+ await printIdentity(api);
2989
+ const abs = resolve6(file);
2990
+ if (!existsSync5(abs)) {
2991
+ console.error(`File not found: ${file}`);
2992
+ process.exit(1);
2993
+ }
2994
+ const spinner = ora7("Loading local catalog...").start();
2995
+ let local;
2996
+ try {
2997
+ local = await loadCatalogFile(abs);
2998
+ } catch (err) {
2999
+ spinner.fail(`Failed to load catalog: ${err.message}`);
3000
+ process.exit(1);
3001
+ }
3002
+ spinner.succeed(`Local: ${local.slug || file}`);
3003
+ const fetchSpinner = ora7("Fetching remote catalog...").start();
3004
+ let remote = null;
3005
+ try {
3006
+ const listRes = await api.get("/api/v1/catalogs");
3007
+ const catalogs = listRes.data || [];
3008
+ const match = catalogs.find((c) => c.slug === local.slug);
3009
+ if (match) {
3010
+ remote = match.schema;
3011
+ }
3012
+ } catch (err) {
3013
+ fetchSpinner.fail(`Failed to fetch remote: ${err.message}`);
3014
+ process.exit(1);
3015
+ }
3016
+ if (!remote) {
3017
+ fetchSpinner.info("This would be a new catalog (slug not found remotely).");
3018
+ return;
3019
+ }
3020
+ fetchSpinner.succeed(`Remote: ${remote.slug || local.slug}`);
3021
+ console.log();
3022
+ const localPages = new Set(Object.keys(local.pages || {}));
3023
+ const remotePages = new Set(Object.keys(remote.pages || {}));
3024
+ const addedPages = [];
3025
+ const removedPages = [];
3026
+ const modifiedPages = [];
3027
+ for (const id of localPages) {
3028
+ if (!remotePages.has(id)) {
3029
+ addedPages.push(id);
3030
+ } else {
3031
+ const localComps = (local.pages[id].components || []).map((c) => `${c.id}:${c.type}`).join(",");
3032
+ const remoteComps = (remote.pages[id].components || []).map((c) => `${c.id}:${c.type}`).join(",");
3033
+ if (localComps !== remoteComps) {
3034
+ modifiedPages.push(id);
3035
+ }
3036
+ }
3037
+ }
3038
+ for (const id of remotePages) {
3039
+ if (!localPages.has(id)) {
3040
+ removedPages.push(id);
3041
+ }
3042
+ }
3043
+ if (addedPages.length + removedPages.length + modifiedPages.length === 0) {
3044
+ console.log(" Pages: no changes");
3045
+ } else {
3046
+ console.log(" Pages:");
3047
+ for (const id of addedPages) {
3048
+ console.log(` \x1B[32m+ ${id}\x1B[0m`);
3049
+ }
3050
+ for (const id of removedPages) {
3051
+ console.log(` \x1B[31m- ${id}\x1B[0m`);
3052
+ }
3053
+ for (const id of modifiedPages) {
3054
+ console.log(` \x1B[33m~ ${id}\x1B[0m`);
3055
+ const localCompIds = new Set((local.pages[id].components || []).map((c) => c.id));
3056
+ const remoteCompIds = new Set((remote.pages[id].components || []).map((c) => c.id));
3057
+ for (const cid of localCompIds) {
3058
+ if (!remoteCompIds.has(cid)) console.log(` \x1B[32m+ component: ${cid}\x1B[0m`);
3059
+ }
3060
+ for (const cid of remoteCompIds) {
3061
+ if (!localCompIds.has(cid)) console.log(` \x1B[31m- component: ${cid}\x1B[0m`);
3062
+ }
3063
+ }
3064
+ }
3065
+ const localEdges = (local.routing?.edges || []).map((e) => `${e.from}->${e.to}`);
3066
+ const remoteEdges = (remote.routing?.edges || []).map((e) => `${e.from}->${e.to}`);
3067
+ const localEdgeSet = new Set(localEdges);
3068
+ const remoteEdgeSet = new Set(remoteEdges);
3069
+ const addedEdges = localEdges.filter((e) => !remoteEdgeSet.has(e));
3070
+ const removedEdges = remoteEdges.filter((e) => !localEdgeSet.has(e));
3071
+ if (addedEdges.length + removedEdges.length === 0) {
3072
+ console.log(" Edges: no changes");
3073
+ } else {
3074
+ console.log(" Edges:");
3075
+ for (const e of addedEdges) console.log(` \x1B[32m+ ${e}\x1B[0m`);
3076
+ for (const e of removedEdges) console.log(` \x1B[31m- ${e}\x1B[0m`);
3077
+ }
3078
+ const totalChanges = addedPages.length + removedPages.length + modifiedPages.length + addedEdges.length + removedEdges.length;
3079
+ console.log();
3080
+ console.log(
3081
+ ` Summary: ${addedPages.length} page(s) added, ${removedPages.length} removed, ${modifiedPages.length} modified | ${addedEdges.length} edge(s) added, ${removedEdges.length} removed`
3082
+ );
3083
+ if (totalChanges === 0) {
3084
+ console.log(" \x1B[32m\u2713 Local matches remote\x1B[0m");
3085
+ }
3086
+ console.log();
3087
+ }
3088
+
3089
+ // src/commands/catalog-open.ts
3090
+ import { exec } from "child_process";
3091
+ import { platform } from "os";
3092
+ import ora8 from "ora";
3093
+ async function catalogOpen(slug) {
3094
+ const config = requireConfig();
3095
+ const api = new ApiClient(config);
3096
+ await printIdentity(api);
3097
+ const spinner = ora8(`Looking up catalog "${slug}"...`).start();
3098
+ try {
3099
+ const listRes = await api.get("/api/v1/catalogs");
3100
+ const catalogs = listRes.data || [];
3101
+ const catalog2 = catalogs.find((c) => c.slug === slug);
3102
+ if (!catalog2) {
3103
+ spinner.fail(`Catalog "${slug}" not found.`);
3104
+ process.exit(1);
3105
+ }
3106
+ let url = catalog2.url;
3107
+ if (!url) {
3108
+ try {
3109
+ const me = await api.get("/api/v1/me");
3110
+ const subdomain = me.data?.subdomain || me.data?.app_slug;
3111
+ if (subdomain) {
3112
+ url = `https://${subdomain}.catalogkit.cc/${slug}`;
3113
+ }
3114
+ } catch {
3115
+ }
3116
+ }
3117
+ if (!url) {
3118
+ url = `https://catalogkit.cc/c/${catalog2.catalog_id}`;
3119
+ }
3120
+ spinner.succeed(`Opening: ${url}`);
3121
+ const os = platform();
3122
+ const cmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open";
3123
+ exec(`${cmd} "${url}"`, (err) => {
3124
+ if (err) {
3125
+ console.log(`
3126
+ Could not open browser. Visit: ${url}
3127
+ `);
3128
+ }
3129
+ });
3130
+ } catch (err) {
3131
+ spinner.fail(`Failed: ${err.message}`);
3132
+ process.exit(1);
3133
+ }
3134
+ }
3135
+
2122
3136
  // src/commands/whoami.ts
2123
3137
  async function whoami() {
2124
3138
  const config = getConfig();
@@ -2177,7 +3191,7 @@ async function whoami() {
2177
3191
 
2178
3192
  // src/index.ts
2179
3193
  var __dirname = dirname3(fileURLToPath(import.meta.url));
2180
- var { version } = JSON.parse(readFileSync5(join3(__dirname, "../package.json"), "utf-8"));
3194
+ var { version } = JSON.parse(readFileSync5(join4(__dirname, "../package.json"), "utf-8"));
2181
3195
  var program = new Command();
2182
3196
  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
3197
  const opts = thisCommand.opts();
@@ -2192,5 +3206,9 @@ var catalog = program.command("catalog").description("Catalog schema management"
2192
3206
  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
3207
  catalog.command("list").description("List all catalogs").action(catalogList);
2194
3208
  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);
3209
+ catalog.command("validate <file>").description("Validate a catalog schema (no token required)").action(catalogValidate);
3210
+ catalog.command("init").description("Scaffold a new catalog from a template").action(catalogInit);
3211
+ catalog.command("diff <file>").description("Compare local catalog schema against remote").action(catalogDiff);
3212
+ catalog.command("open <slug>").description("Open a published catalog in the browser").action(catalogOpen);
2195
3213
  program.command("whoami").description("Show current authentication info").action(whoami);
2196
3214
  program.parse();