@officexapp/catalogs-cli 0.4.0 → 0.4.2

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 +124 -21
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1275,8 +1275,8 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1275
1275
  }
1276
1276
 
1277
1277
  // --- Component Renderers ---
1278
- function RenderComponent({ comp, isCover, formState, onFieldChange }) {
1279
- const props = comp.props || {};
1278
+ function RenderComponent({ comp, isCover, formState, onFieldChange, onSubmit, propOverrides }) {
1279
+ const props = { ...(comp.props || {}), ...(propOverrides?.[comp.id] || {}) };
1280
1280
  const type = comp.type;
1281
1281
  const compClass = comp.className || '';
1282
1282
  const compStyle = comp.style || {};
@@ -1340,7 +1340,7 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1340
1340
  }
1341
1341
 
1342
1342
  case 'html':
1343
- return h('div', { className: compClass, style: compStyle, dangerouslySetInnerHTML: { __html: props.content || '' } });
1343
+ return h(HtmlBlock, { content: props.content || '', className: compClass, style: compStyle, formState });
1344
1344
 
1345
1345
  case 'banner': {
1346
1346
  const variants = {
@@ -1452,7 +1452,7 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1452
1452
  case 'url':
1453
1453
  case 'number':
1454
1454
  case 'password':
1455
- return h(TextInput, { comp, type, formState, onFieldChange, isCover, compClass, compStyle });
1455
+ return h(TextInput, { comp, type, formState, onFieldChange, isCover, compClass, compStyle, onSubmit });
1456
1456
 
1457
1457
  case 'long_text':
1458
1458
  return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
@@ -1723,7 +1723,7 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1723
1723
  );
1724
1724
  }
1725
1725
 
1726
- function TextInput({ comp, type, formState, onFieldChange, isCover, compClass, compStyle }) {
1726
+ function TextInput({ comp, type, formState, onFieldChange, isCover, compClass, compStyle, onSubmit }) {
1727
1727
  const props = comp.props || {};
1728
1728
  const inputType = type === 'email' ? 'email' : type === 'phone' ? 'tel' : type === 'url' ? 'url' : type === 'number' ? 'number' : type === 'password' ? 'password' : 'text';
1729
1729
  return h('div', { className: 'space-y-1.5 ' + compClass, style: compStyle },
@@ -1739,6 +1739,7 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1739
1739
  placeholder: props.placeholder || '',
1740
1740
  value: formState[comp.id] ?? '',
1741
1741
  onChange: (e) => onFieldChange(comp.id, e.target.value),
1742
+ onKeyDown: (e) => { if (e.key === 'Enter' && onSubmit) { e.preventDefault(); onSubmit(); } },
1742
1743
  })
1743
1744
  );
1744
1745
  }
@@ -1975,6 +1976,38 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
1975
1976
  );
1976
1977
  }
1977
1978
 
1979
+ // --- HtmlBlock: renders HTML content and executes inline <script> tags ---
1980
+ function HtmlBlock({ content, className, style, formState }) {
1981
+ const ref = React.useRef(null);
1982
+ const executedRef = React.useRef(new Set());
1983
+ // Template interpolation: replace {{field_id}} with form state values
1984
+ const interpolated = React.useMemo(() =>
1985
+ (content || '').replace(/{{(w+)}}/g, (_, id) => formState?.[id] ?? ''),
1986
+ [content, formState]
1987
+ );
1988
+ React.useEffect(() => {
1989
+ const container = ref.current;
1990
+ if (!container) return;
1991
+ const scripts = container.querySelectorAll('script');
1992
+ scripts.forEach((orig) => {
1993
+ const key = orig.src || orig.textContent || '';
1994
+ if (executedRef.current.has(key)) return;
1995
+ executedRef.current.add(key);
1996
+ if (orig.src) {
1997
+ const s = document.createElement('script');
1998
+ s.src = orig.src;
1999
+ if (orig.type) s.type = orig.type;
2000
+ s.async = true;
2001
+ container.appendChild(s);
2002
+ } else if (orig.textContent) {
2003
+ try { new Function(orig.textContent)(); }
2004
+ catch (e) { console.error('[CatalogKit:dev] Inline script error:', e); }
2005
+ }
2006
+ });
2007
+ }, [interpolated]);
2008
+ return h('div', { ref, className: 'prose prose-sm max-w-none ' + (className || ''), style, dangerouslySetInnerHTML: { __html: interpolated } });
2009
+ }
2010
+
1978
2011
  function ActionButton({ action, themeColor, onAction }) {
1979
2012
  const st = action.style || 'primary';
1980
2013
  const hasSide = !!action.side_statement;
@@ -2020,7 +2053,7 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2020
2053
  );
2021
2054
  }
2022
2055
 
2023
- function StickyBottomBar({ config, page, formState, cartItems, themeColor, onNext, onAction, onBack, historyLen }) {
2056
+ function StickyBottomBar({ config, page, formState, cartItems, themeColor, onNext, onAction, onFieldAndNavigate, onBack, historyLen }) {
2024
2057
  const [visible, setVisible] = React.useState(!config.delay_ms);
2025
2058
  const [scrollDir, setScrollDir] = React.useState('down');
2026
2059
  React.useEffect(() => {
@@ -2043,15 +2076,27 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2043
2076
  glass_dark: { backgroundColor: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(16px)', color: 'white' },
2044
2077
  gradient: { background: 'linear-gradient(135deg, ' + themeColor + ' 0%, ' + themeColor + 'dd 100%)', color: 'white' },
2045
2078
  };
2046
- const handlePrimary = () => {
2047
- const dispatch = config.primary_action?.dispatch;
2048
- if (!dispatch || dispatch === 'next') { onNext(); return; }
2049
- if (dispatch.startsWith('action:')) {
2050
- const actionId = dispatch.slice(7);
2079
+ const dispatchAction = (act) => {
2080
+ const cmd = act?.action || 'next';
2081
+ if (cmd === 'next') { onNext(); return; }
2082
+ if (cmd.startsWith('action:')) {
2083
+ const actionId = cmd.slice(7);
2051
2084
  const action = page.actions?.find(a => a.id === actionId);
2052
2085
  if (action) onAction(action); else onNext();
2053
- } else { onNext(); }
2086
+ return;
2087
+ }
2088
+ if (cmd.startsWith('field:')) {
2089
+ const parts = cmd.slice(6).split(':');
2090
+ if (parts.length >= 2) { onFieldAndNavigate(parts[0], parts.slice(1).join(':')); }
2091
+ return;
2092
+ }
2093
+ onNext();
2054
2094
  };
2095
+ const primaryLabel = config.primary?.label
2096
+ ? interpolate(config.primary.label)
2097
+ : config.button_text || page.submit_label || 'Continue';
2098
+ const secondaryAction = config.secondary;
2099
+ const secondaryLabel = secondaryAction?.label ? interpolate(secondaryAction.label) : null;
2055
2100
  return h('div', {
2056
2101
  className: 'cf-sticky-bar' + (show ? '' : ' hidden'),
2057
2102
  style: bgStyles[config.style || 'solid'] || bgStyles.solid,
@@ -2065,12 +2110,19 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2065
2110
  h('div', { className: 'flex items-center gap-3' },
2066
2111
  config.cart_badge && cartItems.length > 0
2067
2112
  ? h('span', { className: 'bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center' }, cartItems.length) : null,
2113
+ secondaryLabel
2114
+ ? h('button', {
2115
+ className: 'text-sm font-medium hover:opacity-80 transition-opacity',
2116
+ style: { color: config.style === 'glass_dark' ? 'rgba(255,255,255,0.6)' : '#6b7280' },
2117
+ onClick: () => dispatchAction(secondaryAction),
2118
+ }, secondaryLabel)
2119
+ : null,
2068
2120
  h('button', {
2069
2121
  className: 'cf-btn-primary text-white text-sm',
2070
2122
  style: { backgroundColor: config.style === 'gradient' ? 'rgba(255,255,255,0.9)' : themeColor, color: config.style === 'gradient' ? themeColor : 'white' },
2071
2123
  disabled: config.disabled,
2072
- onClick: handlePrimary,
2073
- }, interpolate(config.primary_action?.label || page.submit_label || 'Continue'))
2124
+ onClick: () => dispatchAction(config.primary),
2125
+ }, primaryLabel)
2074
2126
  )
2075
2127
  )
2076
2128
  );
@@ -2414,6 +2466,8 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2414
2466
  const historyRef = React.useRef(history);
2415
2467
  historyRef.current = history;
2416
2468
  const autoAdvanceTimer = React.useRef(null);
2469
+ const globalsRef = React.useRef({});
2470
+ const [compPropOverrides, setCompPropOverrides] = React.useState({});
2417
2471
 
2418
2472
  // --- Cart logic ---
2419
2473
  const addToCart = React.useCallback((pageId) => {
@@ -2557,7 +2611,7 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2557
2611
  getField: (id) => formStateRef.current[id],
2558
2612
  getAllFields: () => ({ ...formStateRef.current }),
2559
2613
  getPageId: () => currentPageId,
2560
- setField: (id, value) => setFormState(prev => ({ ...prev, [id]: value })),
2614
+ setField: (id, value) => onFieldChangeRef.current?.(id, value),
2561
2615
  goNext: () => handleNextRef.current?.(),
2562
2616
  goBack: () => handleBackRef.current?.(),
2563
2617
  on: (event, cb) => { if (!listeners[event]) listeners[event] = new Set(); listeners[event].add(cb); },
@@ -2565,6 +2619,11 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2565
2619
  openCart: () => setCartOpen(true),
2566
2620
  closeCart: () => setCartOpen(false),
2567
2621
  getCartItems: () => [...cartItems],
2622
+ getGlobal: (key) => globalsRef.current[key],
2623
+ setGlobal: (key, value) => { globalsRef.current[key] = value; },
2624
+ setComponentProp: (id, prop, value) => {
2625
+ setCompPropOverrides(prev => ({ ...prev, [id]: { ...(prev[id] || {}), [prop]: value } }));
2626
+ },
2568
2627
  setValidationError: (id, msg) => {
2569
2628
  setValidationErrors(prev => {
2570
2629
  const next = prev.filter(e => e.componentId !== id);
@@ -2573,7 +2632,7 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2573
2632
  });
2574
2633
  },
2575
2634
  };
2576
- window.CatalogKit = { get: () => instance, getField: instance.getField, setField: instance.setField, getPageId: instance.getPageId, goNext: instance.goNext, goBack: instance.goBack, on: instance.on, off: instance.off };
2635
+ window.CatalogKit = { get: () => instance, getField: instance.getField, setField: instance.setField, getPageId: instance.getPageId, goNext: instance.goNext, goBack: instance.goBack, on: instance.on, off: instance.off, setGlobal: instance.setGlobal, getGlobal: instance.getGlobal, setComponentProp: instance.setComponentProp };
2577
2636
  window.__catalogKitListeners = listeners;
2578
2637
  return () => { delete window.CatalogKit; delete window.__catalogKitListeners; };
2579
2638
  }, []);
@@ -2615,9 +2674,11 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2615
2674
  const onFieldChange = React.useCallback((id, value) => {
2616
2675
  setFormState(prev => ({ ...prev, [id]: value }));
2617
2676
  devEvents.emit('field_change', { field_id: id, value, page_id: currentPageId });
2618
- // Fire CatalogKit fieldchange
2677
+ // Fire CatalogKit fieldchange (unscoped + scoped)
2619
2678
  const ckListeners = window.__catalogKitListeners || {};
2620
- const fcSet = ckListeners['fieldchange']; if (fcSet?.size) for (const cb of fcSet) { try { cb({ fieldId: id, value, pageId: currentPageId }); } catch {} }
2679
+ const fcPayload = { fieldId: id, value, pageId: currentPageId };
2680
+ const fcSet = ckListeners['fieldchange']; if (fcSet?.size) for (const cb of fcSet) { try { cb(fcPayload); } catch {} }
2681
+ const scopedSet = ckListeners['fieldchange:' + id]; if (scopedSet?.size) for (const cb of scopedSet) { try { cb(fcPayload); } catch {} }
2621
2682
 
2622
2683
  // Auto-advance: if page has auto_advance and this is a selection-type input
2623
2684
  const pg = pages[currentPageId];
@@ -2647,6 +2708,8 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2647
2708
  }
2648
2709
  }
2649
2710
  }, [currentPageId, pages, formState, routing, navigateTo]);
2711
+ const onFieldChangeRef = React.useRef(onFieldChange);
2712
+ onFieldChangeRef.current = onFieldChange;
2650
2713
 
2651
2714
  // --- Validation ---
2652
2715
  const runValidation = React.useCallback(() => {
@@ -2684,6 +2747,21 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2684
2747
  if (currentPage?.offer) {
2685
2748
  if (!currentPage.offer.accept_field) addToCart(currentPageId);
2686
2749
  }
2750
+ // Fire CatalogKit beforenext event (scoped: "beforenext" + "beforenext:<pageId>")
2751
+ let prevented = false;
2752
+ let nextPageOverride;
2753
+ const beforeNextPayload = {
2754
+ pageId: currentPageId,
2755
+ preventDefault: () => { prevented = true; },
2756
+ setNextPage: (id) => { nextPageOverride = id; },
2757
+ };
2758
+ const ckListeners = window.__catalogKitListeners || {};
2759
+ for (const key of ['beforenext', 'beforenext:' + currentPageId]) {
2760
+ const set = ckListeners[key]; if (!set?.size) continue;
2761
+ for (const cb of set) { try { cb(beforeNextPayload); } catch (e) { console.error('[CatalogKit]', key, e); } }
2762
+ }
2763
+ if (prevented) return;
2764
+ if (nextPageOverride !== undefined) { navigateTo(nextPageOverride); return; }
2687
2765
  const nextId = getNextPage(routing, currentPageId, formState, devContext);
2688
2766
  navigateTo(nextId);
2689
2767
  }, [currentPageId, routing, formState, pages, addToCart, navigateTo, runValidation]);
@@ -2719,6 +2797,24 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2719
2797
  navigateTo(nextPageId);
2720
2798
  }, [currentPageId, routing, formState, pages, addToCart, navigateTo, runValidation]);
2721
2799
 
2800
+ // --- Field + Navigate (for sticky bar field: dispatch) ---
2801
+ const handleFieldAndNavigate = React.useCallback((fieldId, value) => {
2802
+ const newFormState = { ...formState, [fieldId]: value };
2803
+ setFormState(newFormState);
2804
+ const page = pages[currentPageId];
2805
+ if (page) {
2806
+ const errors = validatePage(page, newFormState, devContext);
2807
+ setValidationErrors(errors);
2808
+ if (errors.length > 0) {
2809
+ const el = document.querySelector('[data-component-id="' + errors[0].componentId + '"]');
2810
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
2811
+ return;
2812
+ }
2813
+ }
2814
+ const nextPageId = getNextPage(routing, currentPageId, newFormState, devContext);
2815
+ navigateTo(nextPageId);
2816
+ }, [currentPageId, routing, formState, pages, navigateTo]);
2817
+
2722
2818
  // --- Resume prompt ---
2723
2819
  if (showResumeModal) {
2724
2820
  return h('div', { className: 'cf-resume-backdrop' },
@@ -2879,6 +2975,11 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2879
2975
  if (c.prefill_mode === 'hidden' && formState[c.id] != null && formState[c.id] !== '') return false;
2880
2976
  return true;
2881
2977
  });
2978
+ // Find the last input component for Enter-to-submit
2979
+ const inputTypes = ['short_text', 'long_text', 'email', 'phone', 'url', 'number', 'password', 'currency', 'date', 'datetime', 'time', 'address'];
2980
+ const lastInputComp = [...components].reverse().find(c => inputTypes.includes(c.type));
2981
+ const lastInputId = lastInputComp?.id;
2982
+
2882
2983
  const bgImage = page.background_image || catalog.settings?.theme?.background_image;
2883
2984
 
2884
2985
  // Cart UI (shared between cover and standard)
@@ -2910,8 +3011,9 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2910
3011
  );
2911
3012
  }
2912
3013
  const fieldError = validationErrors.find(e => e.componentId === comp.id);
3014
+ const submitHandler = comp.id === lastInputId ? handleNext : undefined;
2913
3015
  return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: fieldError ? 'cf-field-error' : '' },
2914
- h(RenderComponent, { comp, isCover: true, formState, onFieldChange }),
3016
+ h(RenderComponent, { comp, isCover: true, formState, onFieldChange, onSubmit: submitHandler, propOverrides: compPropOverrides }),
2915
3017
  fieldError ? h('p', { className: 'text-xs text-red-300 font-medium mt-1', role: 'alert' }, fieldError.message) : null
2916
3018
  );
2917
3019
  }),
@@ -2972,8 +3074,9 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
2972
3074
  );
2973
3075
  }
2974
3076
  const fieldError = validationErrors.find(e => e.componentId === comp.id);
3077
+ const submitHandler = comp.id === lastInputId ? handleNext : undefined;
2975
3078
  return h('div', { key: comp.id || i, 'data-component-id': comp.id, 'data-component-type': comp.type, className: fieldError ? 'cf-field-error' : '' },
2976
- h(RenderComponent, { comp, isCover: false, formState, onFieldChange }),
3079
+ h(RenderComponent, { comp, isCover: false, formState, onFieldChange, onSubmit: submitHandler, propOverrides: compPropOverrides }),
2977
3080
  fieldError ? h('p', { className: 'text-xs text-red-500 font-medium mt-1', role: 'alert' }, fieldError.message) : null
2978
3081
  );
2979
3082
  }),
@@ -3003,7 +3106,7 @@ function buildPreviewHtml(schema, port, validation, devConfig) {
3003
3106
  ? h(StickyBottomBar, {
3004
3107
  config: { ...(catalog.settings?.sticky_bar || {}), ...(page.sticky_bar || {}) },
3005
3108
  page, formState, cartItems, themeColor,
3006
- onNext: handleNext, onAction: handleAction, onBack: handleBack,
3109
+ onNext: handleNext, onAction: handleAction, onFieldAndNavigate: handleFieldAndNavigate, onBack: handleBack,
3007
3110
  historyLen: history.length,
3008
3111
  })
3009
3112
  : null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@officexapp/catalogs-cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "CLI for Catalog Kit — upload videos, push catalogs, manage assets",
5
5
  "type": "module",
6
6
  "bin": {