@officexapp/catalogs-cli 0.2.8 → 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 +1247 -61
  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,267 @@ 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
+
1709
+ // --- Cart Components ---
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';
1711
+
1712
+ function CartButton({ itemCount, onClick }) {
1713
+ if (itemCount === 0) return null;
1714
+ return h('button', {
1715
+ onClick,
1716
+ className: 'fixed bottom-6 right-6 z-[90] flex items-center gap-2 rounded-full px-5 py-3.5 text-white font-semibold shadow-xl transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95',
1717
+ style: { backgroundColor: themeColor, fontFamily: 'var(--font-display)' },
1718
+ },
1719
+ h('svg', { className: 'w-5 h-5', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
1720
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: cartIconPath })
1721
+ ),
1722
+ h('span', null, itemCount),
1723
+ h('span', { className: 'text-sm opacity-80' }, itemCount === 1 ? 'item' : 'items')
1724
+ );
1725
+ }
1726
+
1727
+ function CartDrawer({ items, isOpen, onToggle, onRemove }) {
1728
+ React.useEffect(() => {
1729
+ if (!isOpen) return;
1730
+ const handleKey = (e) => { if (e.key === 'Escape') onToggle(); };
1731
+ window.addEventListener('keydown', handleKey);
1732
+ return () => window.removeEventListener('keydown', handleKey);
1733
+ }, [isOpen, onToggle]);
1734
+
1735
+ return h(React.Fragment, null,
1736
+ // Backdrop
1737
+ h('div', {
1738
+ className: 'fixed inset-0 z-[95] bg-black/30 backdrop-blur-sm transition-opacity duration-300 ' + (isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'),
1739
+ onClick: onToggle,
1740
+ }),
1741
+ // Drawer
1742
+ h('div', {
1743
+ className: 'fixed top-0 right-0 bottom-0 z-[96] w-full max-w-md bg-white shadow-2xl transition-transform duration-300 ease-out flex flex-col ' + (isOpen ? 'translate-x-0' : 'translate-x-full'),
1744
+ style: { fontFamily: 'var(--font-display)' },
1745
+ },
1746
+ // Header
1747
+ h('div', { className: 'flex items-center justify-between px-6 py-5 border-b border-gray-100' },
1748
+ h('div', { className: 'flex items-center gap-3' },
1749
+ h('div', { className: 'w-9 h-9 rounded-xl flex items-center justify-center', style: { backgroundColor: themeColor + '12' } },
1750
+ h('svg', { className: 'w-5 h-5', style: { color: themeColor }, fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
1751
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: cartIconPath })
1752
+ )
1753
+ ),
1754
+ h('div', null,
1755
+ h('h2', { className: 'text-lg font-bold text-gray-900' }, 'Your Cart'),
1756
+ h('p', { className: 'text-xs text-gray-400' }, items.length + ' ' + (items.length === 1 ? 'item' : 'items') + ' selected')
1757
+ )
1758
+ ),
1759
+ h('button', { onClick: onToggle, className: 'w-9 h-9 flex items-center justify-center rounded-xl text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-all' },
1760
+ h('svg', { className: 'w-5 h-5', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
1761
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M6 18L18 6M6 6l12 12' })
1762
+ )
1763
+ )
1764
+ ),
1765
+ // Items
1766
+ h('div', { className: 'flex-1 overflow-y-auto px-6 py-4' },
1767
+ items.length === 0
1768
+ ? h('div', { className: 'flex flex-col items-center justify-center h-full text-center py-16' },
1769
+ h('div', { className: 'w-16 h-16 rounded-2xl flex items-center justify-center mb-4', style: { backgroundColor: themeColor + '08' } },
1770
+ h('svg', { className: 'w-8 h-8 text-gray-300', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 1.5 },
1771
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: cartIconPath })
1772
+ )
1773
+ ),
1774
+ h('p', { className: 'text-gray-400 text-sm font-medium' }, 'No offers accepted yet'),
1775
+ h('p', { className: 'text-gray-300 text-xs mt-1' }, 'Accept offers as you browse to add them here')
1776
+ )
1777
+ : h('div', { className: 'space-y-3' },
1778
+ ...items.map(item => h('div', { key: item.offer_id, className: 'group flex items-start gap-4 p-4 rounded-2xl border border-gray-100 bg-gray-50/50 hover:bg-white hover:border-gray-200 hover:shadow-sm transition-all duration-200' },
1779
+ item.image ? h('img', { src: item.image, alt: item.title, className: 'w-14 h-14 rounded-xl object-cover flex-shrink-0' }) : null,
1780
+ h('div', { className: 'flex-1 min-w-0' },
1781
+ h('h3', { className: 'text-sm font-semibold text-gray-900 truncate' }, item.title),
1782
+ item.price_display ? h('p', { className: 'text-sm font-bold mt-0.5', style: { color: themeColor } }, item.price_display) : null,
1783
+ item.price_subtext ? h('p', { className: 'text-xs text-gray-400 mt-0.5' }, item.price_subtext) : null
1784
+ ),
1785
+ h('button', { onClick: () => onRemove(item.offer_id), className: 'w-7 h-7 flex-shrink-0 flex items-center justify-center rounded-lg text-gray-300 hover:text-red-500 hover:bg-red-50 transition-all opacity-0 group-hover:opacity-100' },
1786
+ h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
1787
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0' })
1788
+ )
1789
+ )
1790
+ ))
1791
+ )
1792
+ ),
1793
+ // Footer
1794
+ items.length > 0 ? h('div', { className: 'border-t border-gray-100 px-6 py-5 space-y-4' },
1795
+ h('div', { className: 'flex items-center justify-between' },
1796
+ h('span', { className: 'text-sm font-medium text-gray-500' }, items.length + ' ' + (items.length === 1 ? 'offer' : 'offers') + ' selected'),
1797
+ h('div', { className: 'flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold', style: { backgroundColor: themeColor + '10', color: themeColor } },
1798
+ h('svg', { className: 'w-3.5 h-3.5', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
1799
+ 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' })
1800
+ ),
1801
+ 'Added to order'
1802
+ )
1803
+ ),
1804
+ h(CartCheckoutButton, { items, themeColor })
1805
+ ) : null
1806
+ )
1807
+ );
1808
+ }
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
+
1430
1874
  // --- Main App ---
1431
1875
  function CatalogPreview({ catalog }) {
1432
1876
  const pages = catalog.pages || {};
@@ -1435,13 +1879,68 @@ function buildPreviewHtml(schema, port) {
1435
1879
  const [currentPageId, setCurrentPageId] = React.useState(routing.entry || pageKeys[0] || null);
1436
1880
  const [formState, setFormState] = React.useState({});
1437
1881
  const [history, setHistory] = React.useState([]);
1882
+ const [cartItems, setCartItems] = React.useState([]);
1883
+ const [cartOpen, setCartOpen] = React.useState(false);
1884
+
1885
+ // --- Cart logic ---
1886
+ const addToCart = React.useCallback((pageId) => {
1887
+ const pg = pages[pageId];
1888
+ if (!pg?.offer) return;
1889
+ const offer = pg.offer;
1890
+ setCartItems(prev => {
1891
+ if (prev.some(item => item.offer_id === offer.id)) return prev;
1892
+ return [...prev, {
1893
+ offer_id: offer.id,
1894
+ page_id: pageId,
1895
+ title: offer.title || pageId,
1896
+ price_display: offer.price_display,
1897
+ price_subtext: offer.price_subtext,
1898
+ image: offer.image,
1899
+ }];
1900
+ });
1901
+ }, [pages]);
1902
+
1903
+ const removeFromCart = React.useCallback((offerId) => {
1904
+ setCartItems(prev => prev.filter(item => item.offer_id !== offerId));
1905
+ // Clear accept_field so it doesn't re-add
1906
+ for (const [pid, pg] of Object.entries(pages)) {
1907
+ if (pg.offer?.id === offerId && pg.offer.accept_field) {
1908
+ setFormState(prev => { const next = { ...prev }; delete next[pg.offer.accept_field]; return next; });
1909
+ }
1910
+ }
1911
+ }, [pages]);
1912
+
1913
+ const toggleCart = React.useCallback(() => setCartOpen(prev => !prev), []);
1438
1914
 
1439
- // Expose navigation for mindmap
1915
+ // Detect offer acceptance from field changes
1916
+ React.useEffect(() => {
1917
+ for (const [pageId, pg] of Object.entries(pages)) {
1918
+ const offer = pg.offer;
1919
+ if (!offer?.accept_field) continue;
1920
+ const fieldValue = formState[offer.accept_field];
1921
+ const acceptValue = offer.accept_value || 'accept';
1922
+ if (fieldValue === acceptValue) {
1923
+ if (!cartItems.some(item => item.offer_id === offer.id)) addToCart(pageId);
1924
+ } else {
1925
+ if (cartItems.some(item => item.offer_id === offer.id) && fieldValue !== undefined) removeFromCart(offer.id);
1926
+ }
1927
+ }
1928
+ }, [formState, pages, cartItems, addToCart, removeFromCart]);
1929
+
1930
+ // Expose navigation for mindmap + emit page_view
1440
1931
  React.useEffect(() => {
1441
1932
  window.__devNavigateTo = (id) => { setCurrentPageId(id); setHistory([]); window.scrollTo({ top: 0, behavior: 'smooth' }); };
1442
1933
  window.__devSetCurrentPage && window.__devSetCurrentPage(currentPageId);
1934
+ devEvents.emit('page_view', { page_id: currentPageId, catalog_slug: catalog.slug });
1443
1935
  }, [currentPageId]);
1444
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
+
1445
1944
  const page = currentPageId ? pages[currentPageId] : null;
1446
1945
  const isCover = page?.layout === 'cover';
1447
1946
  const isLastPage = (() => {
@@ -1451,16 +1950,26 @@ function buildPreviewHtml(schema, port) {
1451
1950
 
1452
1951
  const onFieldChange = React.useCallback((id, value) => {
1453
1952
  setFormState(prev => ({ ...prev, [id]: value }));
1454
- }, []);
1953
+ devEvents.emit('field_change', { field_id: id, value, page_id: currentPageId });
1954
+ }, [currentPageId]);
1455
1955
 
1456
1956
  const handleNext = React.useCallback(() => {
1957
+ // Check if page has an offer \u2014 treat "Next" as an accept action
1958
+ const currentPage = pages[currentPageId];
1959
+ if (currentPage?.offer) {
1960
+ const acceptValue = currentPage.offer.accept_value || 'accept';
1961
+ // If there's no accept_field, the CTA button itself is the accept trigger
1962
+ if (!currentPage.offer.accept_field) {
1963
+ addToCart(currentPageId);
1964
+ }
1965
+ }
1457
1966
  const nextId = getNextPageId(currentPageId, routing, formState);
1458
1967
  if (nextId && pages[nextId]) {
1459
1968
  setHistory(prev => [...prev, currentPageId]);
1460
1969
  setCurrentPageId(nextId);
1461
1970
  window.scrollTo({ top: 0, behavior: 'smooth' });
1462
1971
  }
1463
- }, [currentPageId, routing, formState, pages]);
1972
+ }, [currentPageId, routing, formState, pages, addToCart]);
1464
1973
 
1465
1974
  const handleBack = React.useCallback(() => {
1466
1975
  if (history.length > 0) {
@@ -1477,12 +1986,23 @@ function buildPreviewHtml(schema, port) {
1477
1986
  );
1478
1987
  }
1479
1988
 
1480
- 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
+ });
1481
1994
  const bgImage = page.background_image || catalog.settings?.theme?.background_image;
1482
1995
 
1996
+ // Cart UI (shared between cover and standard)
1997
+ const cartUI = h(React.Fragment, null,
1998
+ h(CartButton, { itemCount: cartItems.length, onClick: toggleCart }),
1999
+ h(CartDrawer, { items: cartItems, isOpen: cartOpen, onToggle: toggleCart, onRemove: removeFromCart })
2000
+ );
2001
+
1483
2002
  // Cover page layout
1484
2003
  if (isCover) {
1485
2004
  return h('div', { 'data-page-id': currentPageId },
2005
+ cartUI,
1486
2006
  h('div', {
1487
2007
  className: 'cf-page cf-noise min-h-screen flex items-center justify-center relative overflow-hidden',
1488
2008
  style: {
@@ -1516,6 +2036,7 @@ function buildPreviewHtml(schema, port) {
1516
2036
  const topBarEnabled = topBar?.enabled !== false && catalog.settings?.top_bar;
1517
2037
 
1518
2038
  return h('div', { 'data-page-id': currentPageId },
2039
+ cartUI,
1519
2040
  h('div', {
1520
2041
  className: 'cf-page min-h-screen',
1521
2042
  style: { background: 'linear-gradient(180deg, #f8f9fc 0%, #f0f2f7 100%)' },
@@ -1819,18 +2340,108 @@ function buildPreviewHtml(schema, port) {
1819
2340
  });
1820
2341
  }, true);
1821
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
+ })();
1822
2417
  </script>
1823
2418
  </body>
1824
2419
  </html>`;
1825
2420
  }
1826
2421
  async function catalogDev(file, opts) {
1827
- const abs = resolve3(file);
2422
+ const abs = resolve4(file);
1828
2423
  const catalogDir = dirname2(abs);
1829
2424
  const port = parseInt(opts.port || String(DEFAULT_PORT), 10);
1830
2425
  if (!existsSync2(abs)) {
1831
2426
  console.error(`File not found: ${file}`);
1832
2427
  process.exit(1);
1833
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 };
1834
2445
  const spinner = ora5("Loading catalog schema...").start();
1835
2446
  let schema;
1836
2447
  try {
@@ -1839,14 +2450,15 @@ async function catalogDev(file, opts) {
1839
2450
  spinner.fail(`Failed to load catalog: ${err.message}`);
1840
2451
  process.exit(1);
1841
2452
  }
1842
- const errors = validateCatalog(schema);
1843
- if (errors.length > 0) {
2453
+ const basicErrors = validateCatalog(schema);
2454
+ if (basicErrors.length > 0) {
1844
2455
  spinner.fail("Schema validation errors:");
1845
- for (const err of errors) {
2456
+ for (const err of basicErrors) {
1846
2457
  console.error(` - ${err}`);
1847
2458
  }
1848
2459
  process.exit(1);
1849
2460
  }
2461
+ let validation = deepValidateCatalog(schema);
1850
2462
  const localBaseUrl = `http://localhost:${port}/assets`;
1851
2463
  schema = resolveLocalAssets(schema, catalogDir, localBaseUrl);
1852
2464
  spinner.succeed(`Loaded: ${schema.slug || file}`);
@@ -1854,6 +2466,8 @@ async function catalogDev(file, opts) {
1854
2466
  console.log(` Entry: ${schema.routing?.entry || "first page"}`);
1855
2467
  console.log();
1856
2468
  const sseClients = /* @__PURE__ */ new Set();
2469
+ const eventSseClients = /* @__PURE__ */ new Set();
2470
+ const eventLog = [];
1857
2471
  function notifyReload() {
1858
2472
  sseClients.forEach((client) => {
1859
2473
  try {
@@ -1863,6 +2477,22 @@ async function catalogDev(file, opts) {
1863
2477
  }
1864
2478
  });
1865
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
+ }
1866
2496
  const server = createServer(async (req, res) => {
1867
2497
  const url = new URL(req.url || "/", `http://localhost:${port}`);
1868
2498
  if (url.pathname === "/__dev_sse") {
@@ -1877,11 +2507,127 @@ async function catalogDev(file, opts) {
1877
2507
  req.on("close", () => sseClients.delete(res));
1878
2508
  return;
1879
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
+ }
1880
2626
  if (url.pathname.startsWith("/assets/")) {
1881
2627
  const relativePath = decodeURIComponent(url.pathname.slice("/assets/".length));
1882
2628
  const filePath = join2(catalogDir, relativePath);
1883
- const resolved = resolve3(filePath);
1884
- if (!resolved.startsWith(resolve3(catalogDir))) {
2629
+ const resolved = resolve4(filePath);
2630
+ if (!resolved.startsWith(resolve4(catalogDir))) {
1885
2631
  res.writeHead(403);
1886
2632
  res.end("Forbidden");
1887
2633
  return;
@@ -1909,11 +2655,13 @@ async function catalogDev(file, opts) {
1909
2655
  "Content-Type": "text/html; charset=utf-8",
1910
2656
  "Cache-Control": "no-cache"
1911
2657
  });
1912
- res.end(buildPreviewHtml(schema, port));
2658
+ res.end(buildPreviewHtml(schema, port, validation, devConfig));
1913
2659
  });
1914
2660
  server.listen(port, () => {
1915
2661
  console.log(` Local preview: http://localhost:${port}`);
1916
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`);
1917
2665
  console.log(` Watching for changes...
1918
2666
  `);
1919
2667
  });
@@ -1930,6 +2678,7 @@ async function catalogDev(file, opts) {
1930
2678
  for (const e of errs) console.error(` - ${e}`);
1931
2679
  return;
1932
2680
  }
2681
+ validation = deepValidateCatalog(schema);
1933
2682
  schema = resolveLocalAssets(schema, catalogDir, localBaseUrl);
1934
2683
  reloadSpinner.succeed(`Reloaded \u2014 auto-refreshing browser`);
1935
2684
  notifyReload();
@@ -1940,7 +2689,7 @@ async function catalogDev(file, opts) {
1940
2689
  });
1941
2690
  try {
1942
2691
  watch(catalogDir, { recursive: true }, (event, filename) => {
1943
- if (!filename || filename.startsWith(".") || resolve3(join2(catalogDir, filename)) === abs) return;
2692
+ if (!filename || filename.startsWith(".") || resolve4(join2(catalogDir, filename)) === abs) return;
1944
2693
  });
1945
2694
  } catch {
1946
2695
  }
@@ -1951,6 +2700,439 @@ async function catalogDev(file, opts) {
1951
2700
  });
1952
2701
  }
1953
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
+
1954
3136
  // src/commands/whoami.ts
1955
3137
  async function whoami() {
1956
3138
  const config = getConfig();
@@ -2009,7 +3191,7 @@ async function whoami() {
2009
3191
 
2010
3192
  // src/index.ts
2011
3193
  var __dirname = dirname3(fileURLToPath(import.meta.url));
2012
- var { version } = JSON.parse(readFileSync5(join3(__dirname, "../package.json"), "utf-8"));
3194
+ var { version } = JSON.parse(readFileSync5(join4(__dirname, "../package.json"), "utf-8"));
2013
3195
  var program = new Command();
2014
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) => {
2015
3197
  const opts = thisCommand.opts();
@@ -2024,5 +3206,9 @@ var catalog = program.command("catalog").description("Catalog schema management"
2024
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);
2025
3207
  catalog.command("list").description("List all catalogs").action(catalogList);
2026
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);
2027
3213
  program.command("whoami").description("Show current authentication info").action(whoami);
2028
3214
  program.parse();