@officexapp/catalogs-cli 0.2.2 → 0.2.4

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 +773 -164
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -731,39 +731,159 @@ function getMime(filepath) {
731
731
  return MIME_TYPES[ext] || "application/octet-stream";
732
732
  }
733
733
  function buildPreviewHtml(schema, port) {
734
- const schemaJson = JSON.stringify(schema);
734
+ const schemaJson = JSON.stringify(schema).replace(/<\/script/gi, "<\\/script");
735
+ const themeColor = schema.settings?.theme?.primary_color || "#6366f1";
735
736
  return `<!DOCTYPE html>
736
737
  <html lang="en">
737
738
  <head>
738
739
  <meta charset="UTF-8" />
739
740
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
740
741
  <title>${schema.slug || "Catalog"} \u2014 Local Preview</title>
742
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
743
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
744
+ <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800;900&family=DM+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet" />
745
+ <script src="https://cdn.tailwindcss.com"></script>
741
746
  <style>
747
+ /* \u2500\u2500 Production CSS \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
748
+ :root {
749
+ --font-display: 'Outfit', system-ui, sans-serif;
750
+ --font-body: 'DM Sans', system-ui, sans-serif;
751
+ --font-size-body: 1.125rem;
752
+ --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
753
+ --ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);
754
+ --theme-color: ${themeColor};
755
+ --theme-color-ring: ${themeColor}26;
756
+ }
742
757
  *, *::before, *::after { box-sizing: border-box; }
743
- body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
758
+ body {
759
+ margin: 0;
760
+ font-family: var(--font-body);
761
+ -webkit-font-smoothing: antialiased;
762
+ -moz-osx-font-smoothing: grayscale;
763
+ color: #1a1a2e;
764
+ }
765
+ h1, h2, h3, h4, h5, h6 { font-family: var(--font-display); }
766
+
767
+ /* Page transitions */
768
+ .page-enter-active { animation: pageReveal 0.35s var(--ease-out-expo) both; }
769
+ @keyframes pageReveal { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
770
+ .page-enter-active > * { animation: staggerIn 0.3s var(--ease-out-expo) both; }
771
+ .page-enter-active > *:nth-child(1) { animation-delay: 0.02s; }
772
+ .page-enter-active > *:nth-child(2) { animation-delay: 0.05s; }
773
+ .page-enter-active > *:nth-child(3) { animation-delay: 0.08s; }
774
+ .page-enter-active > *:nth-child(4) { animation-delay: 0.11s; }
775
+ .page-enter-active > *:nth-child(5) { animation-delay: 0.14s; }
776
+ .page-enter-active > *:nth-child(6) { animation-delay: 0.17s; }
777
+ .page-enter-active > *:nth-child(7) { animation-delay: 0.2s; }
778
+ .page-enter-active > *:nth-child(8) { animation-delay: 0.23s; }
779
+ @keyframes staggerIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
780
+
781
+ /* Cover page */
782
+ .cf-cover-overlay { background: linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.4) 50%, rgba(0,0,0,0.6) 100%); }
783
+ .cf-cover-content { animation: coverFloat 0.8s var(--ease-out-expo) both; }
784
+ @keyframes coverFloat { from { opacity: 0; transform: translateY(30px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
785
+
786
+ /* Top bar */
787
+ .cf-topbar { background: rgba(255,255,255,0.92); backdrop-filter: blur(16px) saturate(180%); -webkit-backdrop-filter: blur(16px) saturate(180%); }
788
+
789
+ /* Card */
790
+ .cf-card { background: white; border-radius: 20px; box-shadow: 0 0 0 1px rgba(0,0,0,0.03), 0 2px 4px rgba(0,0,0,0.02), 0 12px 40px rgba(0,0,0,0.06); }
791
+
792
+ /* Inputs */
793
+ .cf-input {
794
+ font-family: var(--font-body); border-radius: 12px; border: 1.5px solid #e2e4e9;
795
+ background: #fafbfc; padding: 12px 16px; font-size: var(--font-size-body);
796
+ color: #1a1a2e; outline: none; transition: all 0.2s var(--ease-out-expo); width: 100%;
797
+ }
798
+ .cf-input::placeholder { color: #a0a3b1; }
799
+ .cf-input:hover { border-color: #c8cbd4; background: #fff; }
800
+ .cf-input:focus { background: #fff; border-color: var(--theme-color); box-shadow: 0 0 0 3px var(--theme-color-ring); }
801
+
802
+ /* Buttons */
803
+ .cf-btn-primary {
804
+ font-family: var(--font-display); font-weight: 600; letter-spacing: -0.01em;
805
+ border-radius: 14px; padding: 14px 28px; font-size: 16px; color: white; border: none;
806
+ cursor: pointer; transition: all 0.25s var(--ease-out-expo);
807
+ box-shadow: 0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08);
808
+ position: relative; overflow: hidden;
809
+ }
810
+ .cf-btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(0,0,0,0.16), 0 2px 4px rgba(0,0,0,0.1); }
811
+ .cf-btn-primary:active { transform: translateY(0) scale(0.98); }
812
+
813
+ /* Choice buttons */
814
+ .cf-choice {
815
+ border-radius: 14px; border: 1.5px solid #e2e4e9; background: #fafbfc;
816
+ transition: all 0.2s var(--ease-out-expo); cursor: pointer; width: 100%;
817
+ text-align: left; padding: 14px 18px; font-size: 1rem; color: #1a1a2e;
818
+ font-family: var(--font-body);
819
+ }
820
+ .cf-choice:hover { border-color: #c8cbd4; background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
821
+ .cf-choice[data-selected="true"] { border-color: var(--theme-color); background: #fff; box-shadow: 0 0 0 3px var(--theme-color-ring), 0 2px 12px rgba(0,0,0,0.06); }
822
+
823
+ /* Banner glass */
824
+ .cf-banner-glass { background: rgba(255,255,255,0.12); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.18); }
825
+
826
+ /* Images */
827
+ .ck-img { width: 100%; height: auto; object-fit: cover; display: block; }
828
+
829
+ /* Text balance */
830
+ .cf-text-balance { text-wrap: balance; }
831
+
832
+ /* Progress bar */
833
+ .progress-bar-fill { transition: width 0.6s var(--ease-out-expo); }
834
+
835
+ /* Noise texture */
836
+ .cf-noise::before {
837
+ content: ''; position: absolute; inset: 0; opacity: 0.03; pointer-events: none;
838
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
839
+ background-size: 256px 256px;
840
+ }
841
+
842
+ /* Dev banner */
744
843
  .dev-banner {
745
844
  position: fixed; top: 0; left: 0; right: 0; z-index: 99999;
746
845
  background: #1a1a2e; color: #e0e0ff; font-size: 12px;
747
846
  padding: 4px 12px; display: flex; align-items: center; gap: 8px;
748
847
  font-family: monospace; border-bottom: 2px solid #6c63ff;
749
848
  }
750
- .dev-banner .dot { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; }
849
+ .dev-banner .dot { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; animation: pulse 2s ease-in-out infinite; }
850
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
751
851
  .dev-banner .label { opacity: 0.7; }
752
852
  .dev-banner .slug { font-weight: bold; color: #a5b4fc; }
753
853
  .dev-banner .stub-tags { margin-left: auto; display: flex; gap: 6px; }
754
- .dev-banner .stub-tag {
755
- background: rgba(255,255,255,0.1); border-radius: 3px;
756
- padding: 1px 6px; font-size: 11px; color: #fbbf24;
854
+ .dev-banner .stub-tag { background: rgba(255,255,255,0.1); border-radius: 3px; padding: 1px 6px; font-size: 11px; color: #fbbf24; }
855
+
856
+ /* Dev page nav */
857
+ .dev-page-nav {
858
+ position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 99998;
859
+ background: rgba(26,26,46,0.95); backdrop-filter: blur(12px); border-radius: 16px;
860
+ padding: 6px; display: flex; gap: 4px; box-shadow: 0 8px 32px rgba(0,0,0,0.3);
861
+ max-width: calc(100vw - 32px); overflow-x: auto;
862
+ }
863
+ .dev-page-nav button {
864
+ padding: 6px 14px; border-radius: 12px; font-size: 12px; font-family: var(--font-display);
865
+ font-weight: 500; border: none; cursor: pointer; white-space: nowrap;
866
+ transition: all 0.2s ease; color: rgba(255,255,255,0.6); background: transparent;
757
867
  }
758
- #catalog-root { padding-top: 28px; }
868
+ .dev-page-nav button:hover { color: white; background: rgba(255,255,255,0.1); }
869
+ .dev-page-nav button.active { color: white; background: var(--theme-color); }
870
+
871
+ /* Checkout stub */
759
872
  .checkout-stub {
760
- background: #fef3c7; border: 2px dashed #f59e0b; border-radius: 8px;
761
- padding: 20px; text-align: center; margin: 16px;
873
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
874
+ border: 2px dashed #f59e0b; border-radius: 16px;
875
+ padding: 24px; text-align: center;
762
876
  }
763
- .checkout-stub h3 { margin: 0 0 8px; color: #92400e; }
877
+ .checkout-stub h3 { margin: 0 0 8px; color: #92400e; font-family: var(--font-display); font-weight: 700; }
764
878
  .checkout-stub p { margin: 0; color: #a16207; font-size: 14px; }
879
+
880
+ /* Pricing card */
881
+ .pricing-card {
882
+ border-radius: 20px; border: 1.5px solid #e2e4e9; background: white;
883
+ padding: 32px; text-align: center;
884
+ box-shadow: 0 2px 12px rgba(0,0,0,0.04);
885
+ }
765
886
  </style>
766
- <script src="https://cdn.tailwindcss.com"></script>
767
887
  </head>
768
888
  <body>
769
889
  <div class="dev-banner">
@@ -777,193 +897,659 @@ function buildPreviewHtml(schema, port) {
777
897
  </div>
778
898
  <div id="catalog-root"></div>
779
899
 
900
+ <script id="__catalog_data" type="application/json">${schemaJson}</script>
901
+
780
902
  <script type="module">
781
903
  import React from 'https://esm.sh/react@18?bundle';
782
904
  import ReactDOM from 'https://esm.sh/react-dom@18/client?bundle';
783
905
 
784
- const schema = ${schemaJson};
906
+ const h = React.createElement;
907
+ const schema = JSON.parse(document.getElementById('__catalog_data').textContent);
908
+ const themeColor = '${themeColor}';
785
909
 
786
- // Render catalog schema as a formatted preview
787
- function CatalogPreview({ catalog }) {
788
- const pages = catalog.pages || {};
789
- const pageKeys = Object.keys(pages);
790
- const [currentPage, setCurrentPage] = React.useState(catalog.routing?.entry || pageKeys[0] || null);
791
-
792
- const page = currentPage ? pages[currentPage] : null;
793
-
794
- function renderComponent(comp, i) {
795
- const props = comp.props || {};
796
- const type = comp.type;
797
-
798
- switch (type) {
799
- case 'heading':
800
- const Tag = props.level === 2 ? 'h2' : props.level === 3 ? 'h3' : 'h1';
801
- return React.createElement(Tag, { key: i, className: 'text-2xl font-bold mb-4 px-4', dangerouslySetInnerHTML: { __html: props.text || '' } });
802
-
803
- case 'paragraph':
804
- return React.createElement('p', { key: i, className: 'text-gray-700 mb-4 px-4 leading-relaxed', dangerouslySetInnerHTML: { __html: props.text || '' } });
805
-
806
- case 'image':
807
- return React.createElement('div', { key: i, className: 'px-4 mb-4' },
808
- React.createElement('img', {
809
- src: props.src || '',
810
- alt: props.alt || '',
811
- className: 'max-w-full rounded-lg',
812
- style: { maxHeight: '400px', objectFit: 'contain' }
813
- })
814
- );
910
+ // --- Markdown-ish text rendering ---
911
+ function inlineMarkdown(text) {
912
+ return text
913
+ .replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
914
+ .replace(/\\*(.+?)\\*/g, '<em>$1</em>')
915
+ .replace(/~~(.+?)~~/g, '<del class="opacity-60">$1</del>')
916
+ .replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="underline decoration-1 underline-offset-2 hover:opacity-80 transition-opacity">$1</a>');
917
+ }
815
918
 
816
- case 'video':
817
- const videoSrc = props.src || props.hls_url || '';
818
- return React.createElement('div', { key: i, className: 'px-4 mb-4' },
819
- React.createElement('video', {
820
- src: videoSrc,
821
- controls: true,
822
- className: 'max-w-full rounded-lg',
823
- poster: props.poster || undefined
824
- })
825
- );
919
+ function renderMarkdownish(text) {
920
+ const lines = text.split(/\\n/);
921
+ let html = '', inUl = false, inOl = false;
922
+ for (const line of lines) {
923
+ const trimmed = line.trim();
924
+ const bullet = trimmed.match(/^[-\u2022]\\s+(.*)/);
925
+ const ordered = trimmed.match(/^\\d+\\.\\s+(.*)/);
926
+ if (bullet) {
927
+ if (inOl) { html += '</ol>'; inOl = false; }
928
+ if (!inUl) { html += '<ul class="list-disc pl-5 my-3 space-y-1.5">'; inUl = true; }
929
+ html += '<li>' + inlineMarkdown(bullet[1]) + '</li>';
930
+ } else if (ordered) {
931
+ if (inUl) { html += '</ul>'; inUl = false; }
932
+ if (!inOl) { html += '<ol class="list-decimal pl-5 my-3 space-y-1.5">'; inOl = true; }
933
+ html += '<li>' + inlineMarkdown(ordered[1]) + '</li>';
934
+ } else {
935
+ if (inUl) { html += '</ul>'; inUl = false; }
936
+ if (inOl) { html += '</ol>'; inOl = false; }
937
+ if (trimmed === '') html += '<br/>';
938
+ else { if (html.length > 0 && !html.endsWith('>')) html += '<br/>'; html += inlineMarkdown(trimmed); }
939
+ }
940
+ }
941
+ if (inUl) html += '</ul>';
942
+ if (inOl) html += '</ol>';
943
+ return html;
944
+ }
826
945
 
827
- case 'html':
828
- return React.createElement('div', {
829
- key: i,
830
- className: 'px-4 mb-4',
831
- dangerouslySetInnerHTML: { __html: props.content || '' }
832
- });
833
-
834
- case 'short_text':
835
- case 'email':
836
- case 'phone':
837
- case 'url':
838
- return React.createElement('div', { key: i, className: 'px-4 mb-4' },
839
- props.label ? React.createElement('label', { className: 'block text-sm font-medium text-gray-700 mb-1' }, props.label) : null,
840
- React.createElement('input', {
841
- type: type === 'email' ? 'email' : type === 'phone' ? 'tel' : type === 'url' ? 'url' : 'text',
842
- placeholder: props.placeholder || '',
843
- className: 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm'
844
- })
946
+ function alignClass(align) {
947
+ if (align === 'center') return 'text-center';
948
+ if (align === 'right') return 'text-right';
949
+ return 'text-left';
950
+ }
951
+
952
+ // --- Routing helpers ---
953
+ function getNextPageId(currentId, routing, formState) {
954
+ if (!routing || !routing.edges) return null;
955
+ const edges = routing.edges.filter(e => e.from === currentId);
956
+ // Sort by priority (lower = higher priority)
957
+ edges.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));
958
+ for (const edge of edges) {
959
+ if (!edge.conditions || edge.conditions.length === 0) return edge.to;
960
+ const match = edge.conditions.every(cond => {
961
+ const val = formState[cond.field];
962
+ if (cond.operator === 'equals') return val === cond.value;
963
+ if (cond.operator === 'not_equals') return val !== cond.value;
964
+ if (cond.operator === 'contains') return typeof val === 'string' && val.includes(cond.value);
965
+ return true;
966
+ });
967
+ if (match) return edge.to;
968
+ }
969
+ // Fallback: default edge (no conditions)
970
+ const defaultEdge = edges.find(e => !e.conditions || e.conditions.length === 0);
971
+ return defaultEdge ? defaultEdge.to : null;
972
+ }
973
+
974
+ // --- Component Renderers ---
975
+ function RenderComponent({ comp, isCover, formState, onFieldChange }) {
976
+ const props = comp.props || {};
977
+ const type = comp.type;
978
+ const compClass = comp.className || '';
979
+ const compStyle = comp.style || {};
980
+
981
+ switch (type) {
982
+ case 'heading': {
983
+ const level = props.level ?? 1;
984
+ const tag = 'h' + Math.min(Math.max(level, 1), 6);
985
+ const sizes = {
986
+ 1: 'text-4xl sm:text-5xl font-extrabold tracking-tight leading-[1.1]',
987
+ 2: 'text-3xl sm:text-4xl font-bold tracking-tight leading-[1.15]',
988
+ 3: 'text-2xl sm:text-3xl font-bold leading-tight',
989
+ 4: 'text-xl sm:text-2xl font-semibold leading-snug',
990
+ 5: 'text-lg sm:text-xl font-semibold',
991
+ 6: 'text-base sm:text-lg font-medium',
992
+ };
993
+ return h('div', { className: alignClass(props.align) + ' space-y-3 ' + compClass, style: compStyle },
994
+ props.micro_heading ? h('p', { className: 'text-xs sm:text-sm font-medium uppercase tracking-widest ' + (isCover ? 'text-white/60' : 'text-gray-400') }, props.micro_heading) : null,
995
+ h(tag, {
996
+ className: (sizes[level] || sizes[1]) + ' ' + (isCover ? 'text-white drop-shadow-lg' : 'text-gray-900') + ' cf-text-balance',
997
+ style: level <= 2 ? { letterSpacing: '-0.025em' } : undefined,
998
+ }, props.text ?? props.content),
999
+ props.subtitle ? h('p', { className: 'text-base sm:text-lg font-normal leading-relaxed ' + (isCover ? 'text-white/75' : 'text-gray-500') }, props.subtitle) : null
1000
+ );
1001
+ }
1002
+
1003
+ case 'paragraph':
1004
+ return h('div', {
1005
+ className: alignClass(props.align) + ' ' + (isCover ? 'text-white/85' : 'text-gray-600') + ' text-lg leading-[1.7] [&_ul]:text-left [&_ol]:text-left ' + compClass,
1006
+ style: compStyle,
1007
+ dangerouslySetInnerHTML: { __html: renderMarkdownish(props.text ?? props.content ?? '') }
1008
+ });
1009
+
1010
+ case 'image': {
1011
+ const borderRadius = props.border_radius ?? 16;
1012
+ const img = h('img', { src: props.src, alt: props.alt || '', className: 'ck-img', loading: 'lazy' });
1013
+ return h('div', { className: 'w-full overflow-hidden ' + compClass, style: { borderRadius, ...compStyle } },
1014
+ props.link ? h('a', { href: props.link, target: '_blank', rel: 'noopener noreferrer' }, img) : img
1015
+ );
1016
+ }
1017
+
1018
+ case 'video': {
1019
+ const src = props.src || '';
1020
+ const isYT = /(?:youtube\\.com|youtu\\.be)/.test(src);
1021
+ const isVimeo = /vimeo\\.com/.test(src);
1022
+ if (isYT) {
1023
+ const match = src.match(/(?:youtube\\.com\\/(?:watch\\?v=|embed\\/)|youtu\\.be\\/)([a-zA-Z0-9_-]{11})/);
1024
+ const embedUrl = match ? 'https://www.youtube.com/embed/' + match[1] : src;
1025
+ return h('div', { className: 'relative w-full overflow-hidden rounded-2xl shadow-lg ' + compClass, style: { paddingTop: '56.25%', ...compStyle } },
1026
+ h('iframe', { src: embedUrl, className: 'absolute inset-0 w-full h-full', allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture', allowFullScreen: true })
845
1027
  );
1028
+ }
1029
+ if (isVimeo) {
1030
+ const match = src.match(/vimeo\\.com\\/(\\d+)/);
1031
+ const embedUrl = match ? 'https://player.vimeo.com/video/' + match[1] : src;
1032
+ return h('div', { className: 'relative w-full overflow-hidden rounded-2xl shadow-lg ' + compClass, style: { paddingTop: '56.25%', ...compStyle } },
1033
+ h('iframe', { src: embedUrl, className: 'absolute inset-0 w-full h-full', allow: 'autoplay; fullscreen; picture-in-picture', allowFullScreen: true })
1034
+ );
1035
+ }
1036
+ return h('div', { className: 'w-full ' + compClass, style: compStyle },
1037
+ h('div', { className: 'relative w-full overflow-hidden rounded-2xl shadow-lg' },
1038
+ h('video', { src: props.hls_url || src, controls: true, playsInline: true, poster: props.poster, className: 'w-full' })
1039
+ )
1040
+ );
1041
+ }
1042
+
1043
+ case 'html':
1044
+ return h('div', { className: compClass, style: compStyle, dangerouslySetInnerHTML: { __html: props.content || '' } });
846
1045
 
847
- case 'long_text':
848
- return React.createElement('div', { key: i, className: 'px-4 mb-4' },
849
- props.label ? React.createElement('label', { className: 'block text-sm font-medium text-gray-700 mb-1' }, props.label) : null,
850
- React.createElement('textarea', {
851
- placeholder: props.placeholder || '',
852
- className: 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm',
853
- rows: 4
854
- })
1046
+ case 'banner': {
1047
+ const variants = {
1048
+ info: { bg: 'bg-blue-50', border: 'border-blue-100', text: 'text-blue-700', icon: '\\u2139\\uFE0F' },
1049
+ warning: { bg: 'bg-amber-50', border: 'border-amber-100', text: 'text-amber-700', icon: '\\u26A0\\uFE0F' },
1050
+ success: { bg: 'bg-emerald-50', border: 'border-emerald-100', text: 'text-emerald-700', icon: '\\u2705' },
1051
+ error: { bg: 'bg-red-50', border: 'border-red-100', text: 'text-red-700', icon: '\\u274C' },
1052
+ };
1053
+ const v = variants[props.variant ?? props.style] || variants.info;
1054
+ const bannerText = props.text ?? props.content ?? '';
1055
+ if (isCover) {
1056
+ return h('div', { className: 'cf-banner-glass rounded-2xl px-5 py-3.5 flex items-center justify-center gap-3 ' + compClass, style: compStyle },
1057
+ h('span', { className: 'text-base flex-shrink-0' }, v.icon),
1058
+ h('div', { className: 'text-sm leading-relaxed text-white/90 font-medium', dangerouslySetInnerHTML: { __html: renderMarkdownish(bannerText) } })
855
1059
  );
1060
+ }
1061
+ return h('div', { className: v.bg + ' ' + v.border + ' ' + v.text + ' border rounded-2xl px-5 py-4 flex items-start gap-3 ' + compClass, style: compStyle },
1062
+ h('span', { className: 'text-lg flex-shrink-0 mt-0.5' }, v.icon),
1063
+ h('div', { className: 'text-sm leading-relaxed', dangerouslySetInnerHTML: { __html: renderMarkdownish(bannerText) } })
1064
+ );
1065
+ }
856
1066
 
857
- case 'multiple_choice':
858
- return React.createElement('div', { key: i, className: 'px-4 mb-4' },
859
- props.label ? React.createElement('label', { className: 'block text-sm font-medium text-gray-700 mb-2' }, props.label) : null,
860
- React.createElement('div', { className: 'space-y-2' },
861
- ...(props.options || []).map((opt, j) =>
862
- React.createElement('button', {
863
- key: j,
864
- className: 'block w-full text-left border border-gray-300 rounded-lg px-4 py-3 text-sm hover:border-blue-500 hover:bg-blue-50 transition'
865
- }, typeof opt === 'string' ? opt : opt.label || opt.value || '')
866
- )
1067
+ case 'callout': {
1068
+ const title = props.title || '';
1069
+ const text = props.text || props.content || '';
1070
+ return h('div', { className: 'rounded-2xl px-5 py-4 ' + compClass, style: { backgroundColor: (props.color || themeColor) + '0a', border: '1.5px solid ' + (props.color || themeColor) + '20', ...compStyle } },
1071
+ title ? h('p', { className: 'font-semibold text-sm mb-1', style: { color: props.color || themeColor } }, title) : null,
1072
+ h('div', { className: 'text-sm leading-relaxed text-gray-600', dangerouslySetInnerHTML: { __html: renderMarkdownish(text) } })
1073
+ );
1074
+ }
1075
+
1076
+ case 'divider':
1077
+ return h('hr', { className: 'border-t border-gray-100 my-4 ' + compClass, style: compStyle });
1078
+
1079
+ case 'pricing_card': {
1080
+ const features = props.features || [];
1081
+ return h('div', { className: 'pricing-card ' + compClass, style: compStyle },
1082
+ props.badge ? h('span', { className: 'inline-block text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full text-white mb-4', style: { backgroundColor: themeColor } }, props.badge) : null,
1083
+ props.title ? h('h3', { className: 'text-2xl font-bold text-gray-900 mb-2', style: { fontFamily: 'var(--font-display)' } }, props.title) : null,
1084
+ props.subtitle ? h('p', { className: 'text-gray-500 text-sm mb-6' }, props.subtitle) : null,
1085
+ props.price != null ? h('div', { className: 'mb-6' },
1086
+ props.original_price ? h('span', { className: 'text-lg text-gray-400 line-through mr-2' }, props.original_price) : null,
1087
+ h('span', { className: 'text-4xl font-extrabold', style: { fontFamily: 'var(--font-display)', color: themeColor } }, typeof props.price === 'number' ? '$' + props.price : props.price),
1088
+ props.period ? h('span', { className: 'text-gray-400 text-sm ml-1' }, '/' + props.period) : null
1089
+ ) : null,
1090
+ features.length > 0 ? h('ul', { className: 'text-left space-y-3 mb-6' },
1091
+ ...features.map((f, i) => h('li', { key: i, className: 'flex items-start gap-2.5 text-sm text-gray-600' },
1092
+ h('span', { className: 'flex-shrink-0 mt-0.5', style: { color: themeColor } }, '\\u2713'),
1093
+ h('span', null, typeof f === 'string' ? f : f.text || f.label || '')
1094
+ ))
1095
+ ) : null,
1096
+ props.cta_text ? h('button', { className: 'cf-btn-primary w-full text-white', style: { backgroundColor: themeColor } }, props.cta_text) : null,
1097
+ props.reassurance ? h('p', { className: 'text-xs text-gray-400 mt-2' }, props.reassurance) : null
1098
+ );
1099
+ }
1100
+
1101
+ case 'testimonial':
1102
+ return h('div', { className: 'cf-card p-6 ' + compClass, style: compStyle },
1103
+ props.quote ? h('p', { className: 'text-gray-700 text-base leading-relaxed italic mb-4' }, '"' + props.quote + '"') : null,
1104
+ h('div', { className: 'flex items-center gap-3' },
1105
+ props.avatar ? h('img', { src: props.avatar, className: 'w-10 h-10 rounded-full object-cover' }) : null,
1106
+ h('div', null,
1107
+ props.name ? h('p', { className: 'font-semibold text-sm text-gray-900' }, props.name) : null,
1108
+ props.title_text || props.role ? h('p', { className: 'text-xs text-gray-400' }, props.title_text || props.role) : null
867
1109
  )
868
- );
1110
+ )
1111
+ );
1112
+
1113
+ case 'faq': {
1114
+ const items = props.items || [];
1115
+ return h('div', { className: 'space-y-3 ' + compClass, style: compStyle },
1116
+ ...items.map((item, i) => h(FaqItem, { key: i, question: item.question || item.q, answer: item.answer || item.a, isCover }))
1117
+ );
1118
+ }
1119
+
1120
+ case 'timeline': {
1121
+ const items = props.items || [];
1122
+ return h('div', { className: 'relative pl-8 space-y-8 ' + compClass, style: compStyle },
1123
+ h('div', { className: 'absolute left-3 top-2 bottom-2 w-0.5 bg-gray-200' }),
1124
+ ...items.map((item, i) => h('div', { key: i, className: 'relative' },
1125
+ h('div', { className: 'absolute -left-5 w-3 h-3 rounded-full border-2 border-white', style: { backgroundColor: themeColor, boxShadow: '0 0 0 3px ' + themeColor + '20' } }),
1126
+ h('div', null,
1127
+ item.title ? h('p', { className: 'font-semibold text-sm text-gray-900' }, item.title) : null,
1128
+ item.description ? h('p', { className: 'text-sm text-gray-500 mt-1' }, item.description) : null
1129
+ )
1130
+ ))
1131
+ );
1132
+ }
1133
+
1134
+ case 'file_download':
1135
+ return h('div', { className: compClass, style: compStyle },
1136
+ h('a', { href: props.src || props.href || '#', download: props.filename || 'file', className: 'inline-flex items-center gap-3 border-1.5 border-gray-200 rounded-xl px-5 py-3.5 text-sm font-medium text-gray-700 hover:bg-gray-50 hover:border-gray-300 transition-all' },
1137
+ h('svg', { className: 'w-5 h-5 text-gray-400', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
1138
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' })
1139
+ ),
1140
+ props.filename || props.label || 'Download File'
1141
+ )
1142
+ );
1143
+
1144
+ case 'iframe':
1145
+ return h('div', { className: 'w-full overflow-hidden rounded-2xl ' + compClass, style: { ...compStyle } },
1146
+ h('iframe', { src: props.src || props.url, className: 'w-full border-0', style: { height: props.height || '400px' }, allow: props.allow || 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope', allowFullScreen: true })
1147
+ );
1148
+
1149
+ // --- Input components ---
1150
+ case 'short_text':
1151
+ case 'email':
1152
+ case 'phone':
1153
+ case 'url':
1154
+ case 'number':
1155
+ case 'password':
1156
+ return h(TextInput, { comp, type, formState, onFieldChange, isCover, compClass, compStyle });
1157
+
1158
+ case 'long_text':
1159
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1160
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1161
+ props.label,
1162
+ (props.required) ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1163
+ ) : null,
1164
+ props.description ? h('p', { className: 'text-xs ' + (isCover ? 'text-white/70' : 'text-gray-500') }, props.description) : null,
1165
+ h('textarea', {
1166
+ className: 'cf-input min-h-[80px] resize-y',
1167
+ placeholder: props.placeholder || '',
1168
+ rows: props.rows || 4,
1169
+ value: formState[comp.id] ?? '',
1170
+ onChange: (e) => onFieldChange(comp.id, e.target.value),
1171
+ })
1172
+ );
1173
+
1174
+ case 'multiple_choice':
1175
+ return h(MultipleChoiceInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1176
+
1177
+ case 'checkboxes':
1178
+ return h(CheckboxesInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
869
1179
 
870
- case 'dropdown':
871
- return React.createElement('div', { key: i, className: 'px-4 mb-4' },
872
- props.label ? React.createElement('label', { className: 'block text-sm font-medium text-gray-700 mb-1' }, props.label) : null,
873
- React.createElement('select', { className: 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm' },
874
- React.createElement('option', { value: '' }, props.placeholder || 'Select...'),
875
- ...(props.options || []).map((opt, j) =>
876
- React.createElement('option', { key: j, value: typeof opt === 'string' ? opt : opt.value },
877
- typeof opt === 'string' ? opt : opt.label || opt.value || ''
878
- )
1180
+ case 'dropdown':
1181
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1182
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1183
+ props.label,
1184
+ (props.required) ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1185
+ ) : null,
1186
+ h('select', {
1187
+ className: 'cf-input',
1188
+ value: formState[comp.id] ?? '',
1189
+ onChange: (e) => onFieldChange(comp.id, e.target.value),
1190
+ },
1191
+ h('option', { value: '' }, props.placeholder || 'Select...'),
1192
+ ...(props.options || []).map((opt, j) =>
1193
+ h('option', { key: j, value: typeof opt === 'string' ? opt : opt.value },
1194
+ typeof opt === 'string' ? opt : opt.label || opt.value || ''
879
1195
  )
880
1196
  )
881
- );
1197
+ )
1198
+ );
1199
+
1200
+ case 'slider':
1201
+ return h(SliderInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1202
+
1203
+ case 'star_rating':
1204
+ return h(StarRatingInput, { comp, formState, onFieldChange, isCover, compClass, compStyle });
1205
+
1206
+ case 'switch':
1207
+ case 'checkbox':
1208
+ return h(SwitchInput, { comp, type, formState, onFieldChange, isCover, compClass, compStyle });
1209
+
1210
+ case 'payment':
1211
+ return h('div', { className: 'checkout-stub ' + compClass, style: compStyle },
1212
+ h('h3', null, 'Stripe Checkout (Dev Stub)'),
1213
+ h('p', null, 'Payment processing is disabled in local dev mode.'),
1214
+ props.amount ? h('p', { className: 'mt-2 font-bold text-lg' },
1215
+ (props.currency || 'USD').toUpperCase() + ' ' + (props.amount / 100).toFixed(2)
1216
+ ) : null
1217
+ );
1218
+
1219
+ default:
1220
+ return h('div', {
1221
+ className: 'border-2 border-dashed border-gray-300 rounded-xl p-4 text-center text-gray-400 text-sm ' + compClass,
1222
+ style: compStyle,
1223
+ }, type + (props.label ? ': ' + props.label : '') + ' (' + comp.id + ')');
1224
+ }
1225
+ }
1226
+
1227
+ // --- Sub-components ---
882
1228
 
883
- case 'payment':
884
- return React.createElement('div', { key: i, className: 'checkout-stub' },
885
- React.createElement('h3', null, 'Stripe Checkout'),
886
- React.createElement('p', null, 'Payment would trigger here in production.'),
887
- props.amount ? React.createElement('p', { className: 'mt-2 font-bold' },
888
- (props.currency || 'USD').toUpperCase() + ' ' + (props.amount / 100).toFixed(2)
889
- ) : null
1229
+ function FaqItem({ question, answer, isCover }) {
1230
+ const [open, setOpen] = React.useState(false);
1231
+ return h('div', { className: 'rounded-2xl border border-gray-200 overflow-hidden' },
1232
+ h('button', {
1233
+ className: 'w-full flex items-center justify-between px-5 py-4 text-left text-sm font-semibold ' + (isCover ? 'text-white' : 'text-gray-900'),
1234
+ onClick: () => setOpen(!open),
1235
+ },
1236
+ h('span', null, question),
1237
+ h('svg', { className: 'w-4 h-4 transition-transform ' + (open ? 'rotate-180' : ''), fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
1238
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M19 9l-7 7-7-7' })
1239
+ )
1240
+ ),
1241
+ open ? h('div', { className: 'px-5 pb-4 text-sm text-gray-600 leading-relaxed', dangerouslySetInnerHTML: { __html: renderMarkdownish(answer || '') } }) : null
1242
+ );
1243
+ }
1244
+
1245
+ function TextInput({ comp, type, formState, onFieldChange, isCover, compClass, compStyle }) {
1246
+ const props = comp.props || {};
1247
+ const inputType = type === 'email' ? 'email' : type === 'phone' ? 'tel' : type === 'url' ? 'url' : type === 'number' ? 'number' : type === 'password' ? 'password' : 'text';
1248
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1249
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1250
+ props.label,
1251
+ (props.required) ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1252
+ ) : null,
1253
+ (props.sublabel || props.subheading) ? h('p', { className: 'text-xs font-medium ' + (isCover ? 'text-white/60' : 'text-gray-400') }, props.sublabel || props.subheading) : null,
1254
+ props.description ? h('p', { className: 'text-xs ' + (isCover ? 'text-white/70' : 'text-gray-500') }, props.description) : null,
1255
+ h('input', {
1256
+ type: inputType,
1257
+ className: 'cf-input' + (isCover ? ' bg-white/10 text-white border-white/20 placeholder-white/40' : ''),
1258
+ placeholder: props.placeholder || '',
1259
+ value: formState[comp.id] ?? '',
1260
+ onChange: (e) => onFieldChange(comp.id, e.target.value),
1261
+ })
1262
+ );
1263
+ }
1264
+
1265
+ function MultipleChoiceInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1266
+ const props = comp.props || {};
1267
+ const selected = formState[comp.id] ?? null;
1268
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1269
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1270
+ props.label,
1271
+ (props.required) ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1272
+ ) : null,
1273
+ props.description ? h('p', { className: 'text-xs ' + (isCover ? 'text-white/70' : 'text-gray-500') }, props.description) : null,
1274
+ h('div', { className: 'space-y-2.5' },
1275
+ ...(props.options || []).map((opt, j) => {
1276
+ const value = typeof opt === 'string' ? opt : opt.value;
1277
+ const label = typeof opt === 'string' ? opt : opt.label || opt.value || '';
1278
+ const isSelected = selected === value;
1279
+ return h('button', {
1280
+ key: j,
1281
+ className: 'cf-choice flex items-center gap-3',
1282
+ 'data-selected': isSelected ? 'true' : 'false',
1283
+ style: isSelected ? { borderColor: themeColor, boxShadow: '0 0 0 3px ' + themeColor + '26' } : undefined,
1284
+ onClick: () => onFieldChange(comp.id, value),
1285
+ },
1286
+ h('span', {
1287
+ className: 'w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-all',
1288
+ style: isSelected ? { borderColor: themeColor, backgroundColor: themeColor } : { borderColor: '#d1d5db' },
1289
+ }, isSelected ? h('span', { className: 'w-2 h-2 rounded-full bg-white' }) : null),
1290
+ h('span', { className: 'text-sm font-medium ' + (isCover ? 'text-white' : 'text-gray-700') }, label)
890
1291
  );
1292
+ })
1293
+ )
1294
+ );
1295
+ }
891
1296
 
892
- case 'banner':
893
- const bannerColors = {
894
- info: 'bg-blue-50 border-blue-200 text-blue-800',
895
- success: 'bg-green-50 border-green-200 text-green-800',
896
- warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
897
- error: 'bg-red-50 border-red-200 text-red-800',
898
- };
899
- return React.createElement('div', {
900
- key: i,
901
- className: 'px-4 mb-4'
1297
+ function CheckboxesInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1298
+ const props = comp.props || {};
1299
+ const selected = formState[comp.id] || [];
1300
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1301
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') },
1302
+ props.label,
1303
+ (props.required) ? h('span', { className: 'text-red-500 ml-1' }, '*') : null
1304
+ ) : null,
1305
+ h('div', { className: 'space-y-2.5' },
1306
+ ...(props.options || []).map((opt, j) => {
1307
+ const value = typeof opt === 'string' ? opt : opt.value;
1308
+ const label = typeof opt === 'string' ? opt : opt.label || opt.value || '';
1309
+ const isChecked = Array.isArray(selected) && selected.includes(value);
1310
+ return h('button', {
1311
+ key: j,
1312
+ className: 'cf-choice flex items-center gap-3',
1313
+ 'data-selected': isChecked ? 'true' : 'false',
1314
+ style: isChecked ? { borderColor: themeColor, boxShadow: '0 0 0 3px ' + themeColor + '26' } : undefined,
1315
+ onClick: () => {
1316
+ const arr = Array.isArray(selected) ? [...selected] : [];
1317
+ if (isChecked) onFieldChange(comp.id, arr.filter(v => v !== value));
1318
+ else onFieldChange(comp.id, [...arr, value]);
1319
+ },
902
1320
  },
903
- React.createElement('div', {
904
- className: 'border rounded-lg px-4 py-3 text-sm ' + (bannerColors[props.style] || bannerColors.info),
905
- dangerouslySetInnerHTML: { __html: props.text || '' }
906
- })
1321
+ h('span', {
1322
+ className: 'w-5 h-5 rounded-md border-2 flex items-center justify-center flex-shrink-0 transition-all',
1323
+ style: isChecked ? { borderColor: themeColor, backgroundColor: themeColor } : { borderColor: '#d1d5db' },
1324
+ }, isChecked ? h('svg', { className: 'w-3 h-3 text-white', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 3 },
1325
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M5 13l4 4L19 7' })
1326
+ ) : null),
1327
+ h('span', { className: 'text-sm font-medium ' + (isCover ? 'text-white' : 'text-gray-700') }, label)
907
1328
  );
1329
+ })
1330
+ )
1331
+ );
1332
+ }
1333
+
1334
+ function SliderInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1335
+ const props = comp.props || {};
1336
+ const min = props.min ?? 0, max = props.max ?? 100, step = props.step ?? 1;
1337
+ const value = formState[comp.id] ?? props.default_value ?? min;
1338
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1339
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') }, props.label) : null,
1340
+ h('div', { className: 'flex items-center gap-4' },
1341
+ h('input', { type: 'range', min, max, step, value, className: 'flex-1', style: { accentColor: themeColor }, onChange: (e) => onFieldChange(comp.id, Number(e.target.value)) }),
1342
+ h('span', { className: 'text-sm font-semibold min-w-[3ch] text-right', style: { color: themeColor } }, value)
1343
+ )
1344
+ );
1345
+ }
908
1346
 
909
- case 'divider':
910
- return React.createElement('hr', { key: i, className: 'my-6 mx-4 border-gray-200' });
1347
+ function StarRatingInput({ comp, formState, onFieldChange, isCover, compClass, compStyle }) {
1348
+ const props = comp.props || {};
1349
+ const max = props.max ?? 5;
1350
+ const value = formState[comp.id] ?? 0;
1351
+ return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
1352
+ props.label ? h('label', { className: 'block text-base font-medium ' + (isCover ? 'text-white' : 'text-gray-700') }, props.label) : null,
1353
+ h('div', { className: 'flex gap-1' },
1354
+ ...Array.from({ length: max }, (_, i) => h('button', {
1355
+ key: i,
1356
+ className: 'text-2xl transition-transform hover:scale-125',
1357
+ onClick: () => onFieldChange(comp.id, i + 1),
1358
+ }, i < value ? '\\u2605' : '\\u2606'))
1359
+ )
1360
+ );
1361
+ }
911
1362
 
912
- case 'file_download':
913
- return React.createElement('div', { key: i, className: 'px-4 mb-4' },
914
- React.createElement('a', {
915
- href: props.src || '#',
916
- download: props.filename || 'file',
917
- className: 'inline-flex items-center gap-2 border border-gray-300 rounded-lg px-4 py-2 text-sm hover:bg-gray-50'
918
- }, '\u{1F4E5} ' + (props.filename || props.label || 'Download File'))
919
- );
1363
+ function SwitchInput({ comp, type, formState, onFieldChange, isCover, compClass, compStyle }) {
1364
+ const props = comp.props || {};
1365
+ const checked = !!formState[comp.id];
1366
+ return h('div', { className: 'flex items-center gap-3 ' + compClass, style: compStyle },
1367
+ h('button', {
1368
+ className: 'relative w-11 h-6 rounded-full transition-colors',
1369
+ style: { backgroundColor: checked ? themeColor : '#d1d5db' },
1370
+ onClick: () => onFieldChange(comp.id, !checked),
1371
+ },
1372
+ h('span', { className: 'absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform', style: { left: checked ? '22px' : '2px' } })
1373
+ ),
1374
+ props.label ? h('span', { className: 'text-sm font-medium ' + (isCover ? 'text-white' : 'text-gray-700') }, props.label) : null,
1375
+ );
1376
+ }
920
1377
 
921
- default:
922
- return React.createElement('div', {
923
- key: i,
924
- className: 'px-4 mb-4 bg-gray-50 border border-dashed border-gray-300 rounded-lg p-3 text-xs text-gray-500'
925
- }, '[' + type + '] ' + (props.label || props.text || comp.id || ''));
1378
+ // --- Main App ---
1379
+ function CatalogPreview({ catalog }) {
1380
+ const pages = catalog.pages || {};
1381
+ const pageKeys = Object.keys(pages);
1382
+ const routing = catalog.routing || {};
1383
+ const [currentPageId, setCurrentPageId] = React.useState(routing.entry || pageKeys[0] || null);
1384
+ const [formState, setFormState] = React.useState({});
1385
+ const [history, setHistory] = React.useState([]);
1386
+
1387
+ const page = currentPageId ? pages[currentPageId] : null;
1388
+ const isCover = page?.layout === 'cover';
1389
+ const isLastPage = (() => {
1390
+ if (!routing.edges) return true;
1391
+ return !routing.edges.some(e => e.from === currentPageId);
1392
+ })();
1393
+
1394
+ const onFieldChange = React.useCallback((id, value) => {
1395
+ setFormState(prev => ({ ...prev, [id]: value }));
1396
+ }, []);
1397
+
1398
+ const handleNext = React.useCallback(() => {
1399
+ const nextId = getNextPageId(currentPageId, routing, formState);
1400
+ if (nextId && pages[nextId]) {
1401
+ setHistory(prev => [...prev, currentPageId]);
1402
+ setCurrentPageId(nextId);
1403
+ window.scrollTo({ top: 0, behavior: 'smooth' });
926
1404
  }
927
- }
1405
+ }, [currentPageId, routing, formState, pages]);
1406
+
1407
+ const handleBack = React.useCallback(() => {
1408
+ if (history.length > 0) {
1409
+ const prev = history[history.length - 1];
1410
+ setHistory(h => h.slice(0, -1));
1411
+ setCurrentPageId(prev);
1412
+ window.scrollTo({ top: 0, behavior: 'smooth' });
1413
+ }
1414
+ }, [history]);
928
1415
 
929
1416
  if (!page) {
930
- return React.createElement('div', { className: 'p-8 text-center text-gray-500' }, 'No pages found in catalog.');
1417
+ return h('div', { className: 'min-h-screen flex items-center justify-center bg-gray-50' },
1418
+ h('p', { className: 'text-gray-500' }, 'No pages found in catalog.')
1419
+ );
931
1420
  }
932
1421
 
933
1422
  const components = page.components || [];
934
- const pageTitle = page.title;
935
-
936
- return React.createElement('div', { className: 'max-w-2xl mx-auto py-8' },
937
- // Page navigation
938
- React.createElement('div', { className: 'flex gap-1 px-4 mb-6 overflow-x-auto' },
939
- ...pageKeys.map(key =>
940
- React.createElement('button', {
941
- key: key,
942
- onClick: () => setCurrentPage(key),
943
- className: 'px-3 py-1 text-xs rounded-full whitespace-nowrap ' +
944
- (key === currentPage ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200')
945
- }, pages[key].title || key)
1423
+ const bgImage = page.background_image || catalog.settings?.theme?.background_image;
1424
+
1425
+ // Cover page layout
1426
+ if (isCover) {
1427
+ return h('div', null,
1428
+ h('div', {
1429
+ className: 'cf-page cf-noise min-h-screen flex items-center justify-center relative overflow-hidden',
1430
+ style: {
1431
+ backgroundImage: bgImage ? 'url(' + bgImage + ')' : 'linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%)',
1432
+ backgroundSize: 'cover', backgroundPosition: 'center',
1433
+ },
1434
+ },
1435
+ h('div', { className: 'cf-cover-overlay absolute inset-0' }),
1436
+ h('div', { className: 'cf-cover-content relative max-w-2xl mx-auto px-6 py-20 text-center text-white' },
1437
+ h('div', { className: 'page-enter-active space-y-7 flex flex-col items-stretch text-center' },
1438
+ ...components.map((comp, i) => h(RenderComponent, { key: comp.id || i, comp, isCover: true, formState, onFieldChange })),
1439
+ // CTA button
1440
+ h('div', { className: 'mt-8' },
1441
+ h('button', {
1442
+ className: 'cf-btn-primary w-full py-4 text-lg',
1443
+ style: { backgroundColor: themeColor },
1444
+ onClick: handleNext,
1445
+ }, page.submit_label || (isLastPage ? 'Submit' : 'Get Started'))
1446
+ )
1447
+ )
1448
+ )
1449
+ ),
1450
+ // Page nav
1451
+ h(PageNav, { pageKeys, pages, currentPageId, onSelect: (id) => { setCurrentPageId(id); setHistory([]); } })
1452
+ );
1453
+ }
1454
+
1455
+ // Standard page layout
1456
+ const topBar = catalog.settings?.top_bar;
1457
+ const progressSteps = catalog.settings?.progress_steps;
1458
+ const topBarEnabled = topBar?.enabled !== false && catalog.settings?.top_bar;
1459
+
1460
+ return h('div', null,
1461
+ h('div', {
1462
+ className: 'cf-page min-h-screen',
1463
+ style: { background: 'linear-gradient(180deg, #f8f9fc 0%, #f0f2f7 100%)' },
1464
+ },
1465
+ // Top bar
1466
+ topBarEnabled ? h('div', { className: 'cf-topbar fixed top-[28px] left-0 right-0 z-50 border-b border-gray-200/60' },
1467
+ h('div', { className: 'relative flex items-center justify-center px-4 py-3 min-h-[48px]' },
1468
+ history.length > 0 ? h('button', {
1469
+ className: 'absolute left-3 top-1/2 -translate-y-1/2 w-9 h-9 flex items-center justify-center rounded-xl text-gray-400 hover:text-gray-700 hover:bg-gray-100/80 transition-all',
1470
+ onClick: handleBack,
1471
+ },
1472
+ h('svg', { className: 'w-5 h-5', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2 },
1473
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M15.75 19.5L8.25 12l7.5-7.5' })
1474
+ )
1475
+ ) : null,
1476
+ topBar?.title ? h('span', { className: 'text-sm font-medium text-gray-700' }, topBar.title) : null,
1477
+ progressSteps ? h(Stepper, { steps: progressSteps, currentPageId, themeColor }) : null,
1478
+ )
1479
+ ) : null,
1480
+
1481
+ // Page content
1482
+ h('div', { className: 'max-w-2xl mx-auto px-6 pb-8', style: { paddingTop: topBarEnabled ? '100px' : '60px' } },
1483
+ page.description ? h('p', { className: 'text-sm text-gray-400 mb-8 text-center font-medium tracking-wide', style: { fontFamily: 'var(--font-display)' } }, page.description) : null,
1484
+ h('div', { className: 'page-enter-active space-y-5' },
1485
+ ...components.map((comp, i) => h(RenderComponent, { key: comp.id || i, comp, isCover: false, formState, onFieldChange })),
1486
+ // Navigation button
1487
+ !page.hide_navigation ? h('div', { className: 'mt-8' },
1488
+ h('button', {
1489
+ className: 'cf-btn-primary inline-flex items-center gap-2 text-base',
1490
+ style: { backgroundColor: themeColor },
1491
+ onClick: handleNext,
1492
+ },
1493
+ page.submit_label || (isLastPage ? 'Submit' : 'Continue'),
1494
+ !isLastPage ? h('svg', { className: 'w-4 h-4', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 2.5 },
1495
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3' })
1496
+ ) : null
1497
+ ),
1498
+ page.submit_reassurance ? h('p', { className: 'text-xs text-gray-400 mt-1.5' }, page.submit_reassurance) : null,
1499
+ ) : null,
1500
+ ),
1501
+ h('div', { className: 'mt-10 text-center text-[11px] text-gray-300 font-medium tracking-wide', style: { fontFamily: 'var(--font-display)' } }, 'Powered by Catalog Kit'),
946
1502
  )
947
1503
  ),
948
- // Page title
949
- pageTitle ? React.createElement('h1', { className: 'text-xl font-bold px-4 mb-4' }, pageTitle) : null,
950
- // Components
951
- ...components.map(renderComponent),
952
- // Navigation buttons
953
- React.createElement('div', { className: 'px-4 mt-6 flex gap-3' },
954
- React.createElement('button', {
955
- className: 'px-6 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700',
956
- onClick: () => {
957
- const idx = pageKeys.indexOf(currentPage);
958
- if (idx < pageKeys.length - 1) setCurrentPage(pageKeys[idx + 1]);
959
- }
960
- }, page.cta_text || 'Continue'),
961
- )
1504
+ // Page nav
1505
+ h(PageNav, { pageKeys, pages, currentPageId, onSelect: (id) => { setCurrentPageId(id); setHistory([]); } })
1506
+ );
1507
+ }
1508
+
1509
+ function Stepper({ steps, currentPageId, themeColor }) {
1510
+ const currentStepIndex = steps.findIndex(s => s.pages && s.pages.includes(currentPageId));
1511
+ return h('div', { className: 'flex items-center justify-center gap-0', style: { fontFamily: 'var(--font-display)' } },
1512
+ ...steps.map((step, i) => h(React.Fragment, { key: step.id || i },
1513
+ i > 0 ? h('div', { className: 'w-10 sm:w-16 h-0.5 rounded-full transition-all duration-500', style: { backgroundColor: i <= currentStepIndex ? themeColor : '#e8e9ee' } }) : null,
1514
+ h('div', { className: 'flex items-center gap-1.5' },
1515
+ h('div', { className: 'w-7 h-7 rounded-full flex items-center justify-center transition-all duration-300', style: {
1516
+ backgroundColor: i <= currentStepIndex ? themeColor : '#f0f1f5',
1517
+ boxShadow: i === currentStepIndex ? '0 0 0 4px ' + themeColor + '20' : 'none',
1518
+ } },
1519
+ i < currentStepIndex
1520
+ ? h('svg', { className: 'w-3.5 h-3.5 text-white', fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', strokeWidth: 3 },
1521
+ h('path', { strokeLinecap: 'round', strokeLinejoin: 'round', d: 'M5 13l4 4L19 7' }))
1522
+ : i === currentStepIndex
1523
+ ? h('div', { className: 'w-2 h-2 rounded-full bg-white' })
1524
+ : h('div', { className: 'w-2 h-2 rounded-full bg-gray-300' })
1525
+ ),
1526
+ h('span', {
1527
+ className: 'text-[11px] font-bold tracking-wider uppercase transition-colors duration-300 ' + (i <= currentStepIndex ? '' : 'text-gray-400'),
1528
+ style: i <= currentStepIndex ? { color: themeColor } : undefined,
1529
+ }, step.label)
1530
+ )
1531
+ ))
1532
+ );
1533
+ }
1534
+
1535
+ function PageNav({ pageKeys, pages, currentPageId, onSelect }) {
1536
+ return h('div', { className: 'dev-page-nav' },
1537
+ ...pageKeys.map(key => h('button', {
1538
+ key,
1539
+ className: key === currentPageId ? 'active' : '',
1540
+ onClick: () => onSelect(key),
1541
+ }, pages[key].title || key))
962
1542
  );
963
1543
  }
964
1544
 
1545
+ // --- Auto-reload via SSE ---
1546
+ const evtSource = new EventSource('/__dev_sse');
1547
+ evtSource.onmessage = () => window.location.reload();
1548
+ evtSource.onerror = () => {};
1549
+
1550
+ // --- Mount ---
965
1551
  const root = ReactDOM.createRoot(document.getElementById('catalog-root'));
966
- root.render(React.createElement(CatalogPreview, { catalog: schema }));
1552
+ root.render(h(CatalogPreview, { catalog: schema }));
967
1553
  </script>
968
1554
  </body>
969
1555
  </html>`;
@@ -998,8 +1584,30 @@ async function catalogDev(file, opts) {
998
1584
  console.log(` Pages: ${Object.keys(schema.pages || {}).length}`);
999
1585
  console.log(` Entry: ${schema.routing?.entry || "first page"}`);
1000
1586
  console.log();
1587
+ const sseClients = /* @__PURE__ */ new Set();
1588
+ function notifyReload() {
1589
+ sseClients.forEach((client) => {
1590
+ try {
1591
+ client.write("data: reload\n\n");
1592
+ } catch {
1593
+ sseClients.delete(client);
1594
+ }
1595
+ });
1596
+ }
1001
1597
  const server = createServer(async (req, res) => {
1002
1598
  const url = new URL(req.url || "/", `http://localhost:${port}`);
1599
+ if (url.pathname === "/__dev_sse") {
1600
+ res.writeHead(200, {
1601
+ "Content-Type": "text/event-stream",
1602
+ "Cache-Control": "no-cache",
1603
+ "Connection": "keep-alive",
1604
+ "Access-Control-Allow-Origin": "*"
1605
+ });
1606
+ res.write("data: connected\n\n");
1607
+ sseClients.add(res);
1608
+ req.on("close", () => sseClients.delete(res));
1609
+ return;
1610
+ }
1003
1611
  if (url.pathname.startsWith("/assets/")) {
1004
1612
  const relativePath = decodeURIComponent(url.pathname.slice("/assets/".length));
1005
1613
  const filePath = join2(catalogDir, relativePath);
@@ -1054,7 +1662,8 @@ async function catalogDev(file, opts) {
1054
1662
  return;
1055
1663
  }
1056
1664
  schema = resolveLocalAssets(schema, catalogDir, localBaseUrl);
1057
- reloadSpinner.succeed(`Reloaded \u2014 refresh browser to see changes`);
1665
+ reloadSpinner.succeed(`Reloaded \u2014 auto-refreshing browser`);
1666
+ notifyReload();
1058
1667
  } catch (err) {
1059
1668
  reloadSpinner.warn(`Reload failed: ${err.message}`);
1060
1669
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@officexapp/catalogs-cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "CLI for Catalog Kit — upload videos, push catalogs, manage assets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "license": "UNLICENSED",
29
29
  "engines": {
30
- "node": ">=18"
30
+ "node": ">=18 <24"
31
31
  },
32
32
  "dependencies": {
33
33
  "commander": "^12.1.0",