webcake-landing-mcp 1.0.2 → 1.0.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.
package/dist/factory.js CHANGED
@@ -30,7 +30,8 @@ export const CONTAINER_TYPES = new Set([
30
30
  "slide",
31
31
  "popup",
32
32
  "form",
33
- "gallery",
33
+ // NOTE: "gallery" is intentionally NOT here — gallery.js reads specials.media only,
34
+ // it never reads vm.children. gallery is a leaf element.
34
35
  "checkbox-group",
35
36
  "radio",
36
37
  "group-select",
@@ -158,7 +159,7 @@ export function createElement(type, overrides = {}) {
158
159
  seedPosition(el);
159
160
  setBox(el, 350, 400);
160
161
  el.specials.media = [imgPlaceholder(600, 400, "1"), imgPlaceholder(600, 400, "2"), imgPlaceholder(600, 400, "3")];
161
- el.children = [];
162
+ // gallery has NO children content comes entirely from specials.media (gallery.js never reads vm.children)
162
163
  break;
163
164
  case "popup":
164
165
  el.properties.movable = false;
@@ -188,6 +189,9 @@ export function createElement(type, overrides = {}) {
188
189
  setBox(el, 150, 36);
189
190
  if (FIELD_TYPES.has(type))
190
191
  el.specials.field_name = `${type.replace(/-/g, "_")}_${el.id}`;
192
+ // cart-quantity is NOT in FIELD_TYPES but its renderer requires field_name — seed it explicitly.
193
+ if (type === "cart-quantity")
194
+ el.specials.field_name = `cart_quantity_${el.id}`;
191
195
  break;
192
196
  case "signature":
193
197
  seedPosition(el);
@@ -225,7 +229,7 @@ export function createElement(type, overrides = {}) {
225
229
  setStyle(el, "color", "rgba(255, 255, 255, 1)");
226
230
  setStyle(el, "background", "rgba(0, 0, 0, 1)");
227
231
  setStyle(el, "fontSize", 20);
228
- el.specials = { type: "minute", duration: "60", showDay: true, showSecond: true, showText: true };
232
+ el.specials = { type: "minute", duration: "60", showDay: true, showSecond: true, showText: true, repeat: false, customize: false, customMessage: "", dailyStart: "", dailyEnd: "" };
229
233
  break;
230
234
  case "timegroup":
231
235
  seedPosition(el);
@@ -245,10 +249,12 @@ export function createElement(type, overrides = {}) {
245
249
  el.responsive.mobile.styles.left = 0;
246
250
  el.responsive.desktop.styles.top = 0;
247
251
  el.responsive.desktop.styles.left = 0;
252
+ el.specials.html = "";
248
253
  break;
249
254
  case "html-box":
250
255
  seedPosition(el);
251
256
  setBox(el, 280, 310);
257
+ el.specials.html = "";
252
258
  break;
253
259
  case "spin-wheel":
254
260
  seedPosition(el);
@@ -316,6 +322,7 @@ export function createElement(type, overrides = {}) {
316
322
  imageWidth: 100,
317
323
  multiOption: false,
318
324
  alignment: "center",
325
+ hoveredBorder: "rgba(28,0,194,1)",
319
326
  options: [
320
327
  { id: randomId(), image: "", title: "Option 1", value: "value1", field_name: `sv_${el.id}_1` },
321
328
  { id: randomId(), image: "", title: "Option 2", value: "value2", field_name: `sv_${el.id}_2` },
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
19
  import { z } from "zod";
20
20
  import { createElement, createPageSource } from "./factory.js";
21
- import { LIBRARY, GENERATION_GUIDE, CANVAS, CLICK_ACTIONS, HOVER_ACTIONS, EVENT_TRIGGERS, } from "./library.js";
21
+ import { LIBRARY, GENERATION_GUIDE, CANVAS, CLICK_ACTIONS, HOVER_ACTIONS, SUCCESS_ACTIONS, ERROR_ACTIONS, DELAY_ACTIONS, EVENT_TRIGGERS, } from "./library.js";
22
22
  import { validatePage, coercePage, pageSchema } from "./validate.js";
23
23
  import { readConfig, buildRequestRedacted, createPage, listOrganizations, listPages, getPageSource, updatePageSource, buildUpdateRequestRedacted, } from "./webcake.js";
24
24
  const ALL_TYPES = Object.keys(LIBRARY);
@@ -52,6 +52,9 @@ server.tool("get_generation_guide", "Read this FIRST. Conventions for building a
52
52
  event_triggers: EVENT_TRIGGERS,
53
53
  click_actions: CLICK_ACTIONS,
54
54
  hover_actions: HOVER_ACTIONS,
55
+ success_actions: SUCCESS_ACTIONS,
56
+ error_actions: ERROR_ACTIONS,
57
+ delay_actions: DELAY_ACTIONS,
55
58
  }));
56
59
  // 2) List elements ------------------------------------------------------------
57
60
  server.tool("list_elements", "List every supported element type, grouped by category, with a one-line summary and whether it is a container (can hold children).", async () => {
@@ -100,7 +103,7 @@ server.tool("new_element", "Return a structurally-valid default element node for
100
103
  // 5) Page schema --------------------------------------------------------------
101
104
  server.tool("get_page_schema", "Return the full JSON Schema (Draft 2020-12) of a Webcake page source object { page: [...], settings: {...} }. Use it to understand the exact structure or for your own validation.", async () => text(pageSchema));
102
105
  // 6) Validate page ------------------------------------------------------------
103
- server.tool("validate_page", "Validate a generated page source against the schema + semantic rules (unique ids, dangling event targets, children only on containers, missing field_name, top-level types). Returns errors (must fix) and warnings. ALWAYS run before returning the final page.", {
106
+ server.tool("validate_page", "Validate a generated page source against the schema + semantic rules (unique ids, dangling event targets, children only on containers, missing field_name, top-level types) plus form-data bindings (duplicate field_name within one form, dangling option-event promoId / connectedSurvey / connectedForm / set_field_value targets). Returns errors (must fix) and warnings. ALWAYS run before returning the final page.", {
104
107
  page: z
105
108
  .any()
106
109
  .describe("The page source object { page:[...], settings:{} } OR a JSON string of it."),
package/dist/library.js CHANGED
@@ -5,41 +5,73 @@
5
5
  * See docs/ai/page-element-schema.md for the full reference.
6
6
  */
7
7
  export const CANVAS = { desktopWidth: 960, mobileWidth: 420, defaultSectionHeight: 800 };
8
- export const EVENT_TRIGGERS = ["click", "hover", "success", "unset"];
8
+ export const EVENT_TRIGGERS = ["click", "hover", "success", "error", "unset", "delay"];
9
+ // Click-trigger actions. "Extra:" lists the action-specific event-object fields
10
+ // the dispatcher reads beyond { id, type, action, target } (render_v4/event/index.js).
9
11
  export const CLICK_ACTIONS = {
10
12
  none: "Do nothing.",
11
- open_link: "Open a URL. target = URL (often with a `target`/`blank` flag for new tab).",
12
- open_popup: "Open a popup. target = popup element id.",
13
- close_popup: "Close a popup. target = popup element id.",
14
- scroll_to: "Smooth-scroll to an element. target = element/section id.",
13
+ open_link: "Open a URL. target = URL. Extra: targetURL ('_blank'|'_self'), open_link_with_params (bool), send_to_thank_page (bool), delayTime (seconds).",
14
+ open_popup: "Open a popup. target = popup element id. Extra: animation, reverseAnimation.",
15
+ close_popup: "Close a popup. target = popup element id. Extra: animation.",
16
+ scroll_to: "Smooth-scroll to an element. target = element/section id. Extra: scrollMore (bonus px offset).",
15
17
  show_section: "Show a hidden section. target = section id.",
16
18
  hide_section: "Hide a section. target = section id.",
17
- show_hide_element: "Toggle element visibility. target = element id.",
18
- change_tab: "Switch tab. target = id.",
19
- lightbox: "Open image in lightbox. target = image id/url.",
20
- copy: "Copy text to clipboard. target = the text to copy.",
21
- collapse: "Collapse/expand. target = id.",
22
- set_field_value: "Set a form field value. target = field_name, plus set_value.",
23
- back_to: "Go back. target = url/none.",
24
- back_home: "Go to home.",
25
- share: "Share. target = url / social network.",
26
- play_audio: "Play audio. target = id.",
27
- stop_audio: "Stop audio. target = id.",
28
- open_cart: "Open cart.",
29
- add_to_cart: "Add product to cart. target = product id.",
30
- open_app: "Open chat/app. target = provider: botcake | whatsapp | mess_prefill | tiktok_prefill | line_prefill.",
31
- change_color: "Change color.",
32
- custom_js: "Run custom JS.",
19
+ show_hide_element: "Toggle element visibility. target = element id (comma-separated list allowed). Extra: onlyMode ('show'|'hide'), animation, animationOut.",
20
+ change_tab: "Switch tab/slide in a gallery/carousel. target = container id. Extra: moveTo ('prev'|'next'|'index'), tabIndex.",
21
+ lightbox: "Open in a lightbox. target = image/video/iframe URL. Extra: typeLightbox ('image'|'video'|'iframe'), alt.",
22
+ copy: "Copy to clipboard. target = the text; OR an element id when copyType='elementValue'. Extra: copyType.",
23
+ collapse: "Collapse/expand. target = element id.",
24
+ set_field_value: "Set a form field value. target = field_name (or w-<element id>). Extra: set_value (the value to set).",
25
+ back_to: "Go back in browser history (history.back()). target = none.",
26
+ share: "Share the current page URL. target = platform name: 'Facebook'|'Twitter'|'Custom'.",
27
+ play_audio: "Play audio. target = audio file URL (NOT an element id).",
28
+ stop_audio: "Stop audio. target = the same audio file URL (NOT an element id).",
29
+ open_sms: "Send SMS. target = phone number. Extra: smsBody (message body).",
30
+ send_email: "Open mail client. target = email address (mailto:).",
31
+ download_file: "Download a file. target = file URL. Extra: nameFile (overrides the saved filename).",
32
+ close_webview: "Close a Facebook/Messenger in-app webview. target = none.",
33
+ open_cart: "Open the cart drawer (WCart).",
34
+ add_to_cart: "Add a product to the cart. Uses specials.sprod/svariant/squantity (or event.sprod_id/svariant/squantity); target unused.",
35
+ open_app: "Open chat/app. event.appTarget selects the provider (botcake|botcake_dynamic|whatsapp|mess_prefill|tiktok_prefill|line_prefill|others); target = destination URL/phone/ref. Extra: wa_custom_text, line_custom_text, formIdLink (per provider).",
36
+ change_color: "Change a color. Acts on the trigger element, or target_element for a cross-element change. Extra: change_color_type, change_color, target_mode, target_element.",
37
+ custom_js: "Run custom JS. Extra: custom_js (the code string).",
33
38
  };
34
39
  export const HOVER_ACTIONS = {
35
- change_color: "Change color on hover.",
36
- change_background: "Change background on hover.",
37
- change_text_color: "Change text color on hover.",
40
+ change_color: "Change color on hover. Extra: change_color, change_color_type, hoverText, hoverBorder, target_mode, target_element.",
41
+ change_background: "Change background on hover. Extra: hoverColor (applied via --hover-color).",
42
+ change_text_color: "Change text color on hover. Extra: hoverText.",
38
43
  change_underline: "Underline on hover.",
39
44
  change_overline: "Overline on hover.",
40
- change_image: "Swap image on hover.",
41
- animation_hover: "Play a hover animation.",
42
- show_hide_element: "Reveal/hide a target element on hover.",
45
+ animation_hover: "Play a hover animation. target = none.",
46
+ show_hide_element: "Reveal/hide a target element on hover. target = element id. Extra: animation, animationOut.",
47
+ };
48
+ // Actions on a FORM's own events array, fired AFTER a successful submit (type:"success").
49
+ // target semantics match the click action of the same name.
50
+ export const SUCCESS_ACTIONS = {
51
+ phone_call: "Call a number. target = phone number (tel:).",
52
+ open_sms: "Send SMS. target = phone number. Extra: smsBody.",
53
+ send_email: "Open mail client. target = email address.",
54
+ open_link: "Open a URL. target = URL. Extra: targetURL ('_blank'|'_self').",
55
+ scroll_to: "Scroll to an element. target = element id. Extra: scrollMore.",
56
+ open_popup: "Open a popup. target = popup id.",
57
+ close_popup: "Close a popup. target = popup id.",
58
+ download_file: "Download a file. target = file URL. Extra: nameFile.",
59
+ show_hide_element: "Toggle visibility. target = element id. Extra: onlyMode.",
60
+ show_section: "Show a section. target = section id.",
61
+ hide_section: "Hide a section. target = section id.",
62
+ close_webview: "Close a Facebook/Messenger webview. target = none.",
63
+ change_tab: "Switch tab/slide. target = container id. Extra: moveTo, tabIndex.",
64
+ };
65
+ // Actions on a FORM's events array, fired when validation FAILS (type:"error").
66
+ export const ERROR_ACTIONS = {
67
+ open_popup: "Open a popup. target = popup id.",
68
+ close_popup: "Close a popup. target = popup id.",
69
+ show_hide_element: "Toggle visibility. target = element id. Extra: onlyMode.",
70
+ };
71
+ // Actions on ANY element's events array, fired when it scrolls into view (type:"delay").
72
+ export const DELAY_ACTIONS = {
73
+ show_element: "Reveal this element after a delay. Extra: delay_multiplier (ms, default 1000).",
74
+ hide_element: "Hide this element after a delay. Extra: delay_multiplier (ms, default 1000).",
43
75
  };
44
76
  export const LIBRARY = {
45
77
  // ---------------- layout / containers ----------------
@@ -53,6 +85,12 @@ export const LIBRARY = {
53
85
  custom_class: "extra css class; 'fixed'/'footer' influence role detection.",
54
86
  imageCompression: "boolean — compress background images.",
55
87
  video_background_thumbnail: "thumbnail for a video background.",
88
+ pageLoadEvent: "string — pubsub event name the section waits for before becoming visible (conditional show on page-load event).",
89
+ pageLoadEventDelay: "number (ms) — delay after pageLoadEvent fires before showing.",
90
+ loadDelayMultiplier: "number — multiplier applied to pageLoadEventDelay.",
91
+ afterPageLoadEvent: "string — pubsub event name that triggers hiding the section after it was shown.",
92
+ afterPageLoadEventDelay: "number (ms) — delay before hiding after afterPageLoadEvent fires.",
93
+ afterLoadDelayMultiplier: "number — multiplier applied to afterPageLoadEventDelay.",
56
94
  },
57
95
  },
58
96
  "dynamic_page": {
@@ -63,15 +101,29 @@ export const LIBRARY = {
63
101
  },
64
102
  group: {
65
103
  type: "group", category: "layout", container: true,
66
- summary: "Groups children so they move and position together (position:absolute).",
67
- useWhen: "Bundling an icon+text card, a badge cluster, or any reusable mini-layout you want to move as one.",
68
- keySpecials: {},
104
+ summary: "Groups children so they move and position together (position:absolute). In cart contexts can also act as a product variation selector group.",
105
+ useWhen: "Bundling an icon+text card, a badge cluster, or any reusable mini-layout you want to move as one. Also used in cart forms to bind a group of children to a specific product variant.",
106
+ keySpecials: {
107
+ sprod: "object {id: string} — product reference for variation selector mode. Set to bind this group to a specific product.",
108
+ ctype: "'field' | 'atc' — context type: 'field' means the group acts as a form field selector, 'atc' means add-to-cart trigger.",
109
+ sprod_attr: "string — product attribute name this group targets (e.g. 'Color', 'Size').",
110
+ sprod_val: "string — attribute value to pre-select.",
111
+ squantity: "number — quantity to add to cart (read by the add_to_cart event handler).",
112
+ svariant: "string — variant id override for add-to-cart.",
113
+ },
69
114
  },
70
115
  grid: {
71
116
  type: "grid", category: "layout", container: true,
72
- summary: "Grid layout; config.column / config.row; children are grid-item.",
73
- useWhen: "Repeating cards in a regular N-column grid (features, team, gallery of cards).",
74
- keySpecials: { column: "(config) number of columns.", row: "(config) number of rows." },
117
+ summary: "Grid layout; config.column / config.row; children are grid-item. Can bind to an external dataset and paginate.",
118
+ useWhen: "Repeating cards in a regular N-column grid (features, team, gallery of cards). Use datasetId to drive content from a dataset.",
119
+ keySpecials: {
120
+ column: "(config) number of columns.",
121
+ row: "(config) number of rows.",
122
+ pagination: "(config) 0 = none | 1 = 'see more' button | 2 = auto-carousel/slide mode.",
123
+ timeSlide: "(config) auto-slide interval in ms when pagination=2.",
124
+ datasetId: "string — bind grid to an external dataset (each row renders one grid-item clone).",
125
+ attributeId: "(on child grid-item specials) string — bind that child element to a specific dataset column by attribute ID.",
126
+ },
75
127
  },
76
128
  "grid-item": {
77
129
  type: "grid-item", category: "layout", container: true,
@@ -81,21 +133,37 @@ export const LIBRARY = {
81
133
  },
82
134
  carousel: {
83
135
  type: "carousel", category: "layout", container: true,
84
- summary: "Horizontal slider; config.slideWidth; children are slide.",
85
- useWhen: "Testimonials, screenshots, or any swipeable set of slides.",
86
- keySpecials: { slideWidth: "(config) width of each slide in px." },
136
+ summary: "Horizontal slider; config.slideWidth; children are slide. Supports autoplay, center mode, dataset binding.",
137
+ useWhen: "Testimonials, screenshots, or any swipeable set of slides. Use datasetId to drive slides from a dataset.",
138
+ keySpecials: {
139
+ slideWidth: "(config) width of each slide in px.",
140
+ centerMode: "(config) boolean — center the active slide with partial neighbors visible.",
141
+ slideToShow: "(config) number of slides visible at once.",
142
+ infinity: "(config) boolean — infinite loop.",
143
+ showNavigation: "(config) boolean — show prev/next arrow buttons.",
144
+ delayTimeMs: "(config) number (ms) — global autoplay interval between slides.",
145
+ autoplayMode: "(config) boolean | 'auto' — enable autoplay.",
146
+ datasetId: "string — bind carousel to an external dataset (each row renders one slide clone).",
147
+ },
87
148
  },
88
149
  slide: {
89
150
  type: "slide", category: "layout", container: true,
90
151
  summary: "One slide inside a carousel (movable:false).",
91
152
  useWhen: "Only as a direct child of carousel.",
92
- keySpecials: {},
153
+ keySpecials: {
154
+ src: "string (URL) — slide background image (built into a CSS background, same pattern as image-block).",
155
+ resize: "number — background-image crop behavior on resize.",
156
+ },
93
157
  },
94
158
  popup: {
95
159
  type: "popup", category: "layout", container: true,
96
160
  summary: "Overlay popup. Hidden by default; opened/closed by events targeting its id.",
97
161
  useWhen: "Thank-you dialog, lead form modal, promo. Place popups at the top level of `page` and trigger via a button's open_popup event.",
98
- keySpecials: {},
162
+ keySpecials: {
163
+ src: "string (URL) — background image (built into a CSS background, same pattern as image-block).",
164
+ resize: "number — background-image crop behavior on resize.",
165
+ video_background_thumbnail: "string (URL) — video thumbnail; renders a .video-background div for a video background.",
166
+ },
99
167
  example: {
100
168
  id: "popthanks", type: "popup",
101
169
  properties: { name: "Thank you", movable: false, sync: true },
@@ -119,11 +187,19 @@ export const LIBRARY = {
119
187
  // ---------------- content ----------------
120
188
  "text-block": {
121
189
  type: "text-block", category: "content", container: false,
122
- summary: "Text. specials.text holds the content (may contain inline HTML); specials.tag sets the semantic tag.",
190
+ summary: "Text. specials.text holds the content (may contain inline HTML); specials.tag sets the semantic tag. Supports template variables ({{key}}), formula mode, URL-param injection, and date formatting.",
123
191
  useWhen: "Any headline, paragraph, label. Use tag h1/h2 for headings, p for body. Style via responsive.styles (fontSize, color, fontWeight, textAlign).",
124
192
  keySpecials: {
125
- text: "string — the visible text; may include inline HTML (<b>, <br>, <span style>…).",
193
+ text: "string — the visible text; may include inline HTML (<b>, <br>, <span style>…). Also supports template variables: {{today}}, {{yesterday}}, {{tomorrow}} (formatted dates), {{coupon_text}}, {{coupon_code}}, {{coupon_codes}}, {{spin_turn_left}}, {{cart_total_price}}, {{cart_subtotal}}, {{cart_shipping_fee}}, {{cart_discount_code}}, {{voucher_price_cart}}, {{cart_item}}, {{cart_bonus_item}}, {{form_error_log}}, {{total_cart}}. Dynamic form field binding: {{formId__fieldName}} substitutes a field value from a sibling form.",
126
194
  tag: "p | h1 | h2 | h3 | h4 | h5 | h6 | span | div.",
195
+ isFormula: "boolean — enable formula/computed numeric mode.",
196
+ formula: "string — formula expression evaluated to produce a numeric value (used when isFormula=true).",
197
+ fixed: "number — decimal places to display when isFormula=true.",
198
+ isTextParams: "boolean — populate text from a URL query parameter instead of specials.text.",
199
+ textParams: "string — URL query parameter name to read (used when isTextParams=true).",
200
+ isFormat: "boolean — apply a date format to template date variables.",
201
+ format: "string — dayjs format string (e.g. 'D/MM/YYYY') used when isFormat=true.",
202
+ formParamSeparator: "string — separator between items when rendering {{formId__form_items}} lists.",
127
203
  },
128
204
  example: {
129
205
  id: "headline1", type: "text-block",
@@ -141,7 +217,7 @@ export const LIBRARY = {
141
217
  summary: "Bulleted list. specials.text is a string of <li>…</li> items.",
142
218
  useWhen: "Feature checklists, benefit lists. One <li> per bullet.",
143
219
  keySpecials: {
144
- text: "string of <li>item</li><li>item</li>… (no <ul> wrapper).",
220
+ text: "string of <li>item</li><li>item</li>… (no <ul> wrapper). Bullet/icon styling lives in the per-breakpoint config, not specials: iconType ('shape'|'image'|'disc'|'circle'|'square'|'decimal'|'none'…), iconImage (SVG/URL), iconColor (rgba), iconFontSize, iconSize, iconTop, linePaddingLeft (text indent), linePaddingBottom (line spacing).",
145
221
  iconSize: "(config) bullet icon size.", linePaddingLeft: "(config) text indent.",
146
222
  },
147
223
  },
@@ -149,7 +225,12 @@ export const LIBRARY = {
149
225
  type: "image-block", category: "content", container: false,
150
226
  summary: "Image. The editor renders the image from specials.src. config.overlay tints it.",
151
227
  useWhen: "Add images where a landing page would have them: hero/product shot, feature icons, about photo, logos. There is NO image API yet — set specials.src to a PLACEHOLDER URL sized to the box: https://placehold.co/<width>x<height> (or https://picsum.photos/<w>/<h> for a photo). NEVER leave src empty (it renders blank). The user replaces placeholders later.",
152
- keySpecials: { src: "image URL — REQUIRED. Use https://placehold.co/WxH (matching width×height) if you don't have a real image.", resize: "resize mode.", overlay: "(config) overlay color rgba(...)." },
228
+ keySpecials: {
229
+ src: "image URL — REQUIRED. Use https://placehold.co/WxH (matching width×height) if you don't have a real image.",
230
+ resize: "number — image crop behavior on resize; a value other than 300 triggers keep_solution (no-crop) mode.",
231
+ enable_background_compare: "boolean — show a before/after image-comparison slider (companion config.backgroundCompare holds the second image).",
232
+ overlay: "(config) overlay color rgba(...).",
233
+ },
153
234
  example: {
154
235
  id: "hero_img", type: "image-block",
155
236
  properties: { name: "Image Block", movable: true, sync: true },
@@ -175,11 +256,18 @@ export const LIBRARY = {
175
256
  },
176
257
  button: {
177
258
  type: "button", category: "content", container: false,
178
- summary: "Clickable button. Label in specials.text; behavior in the events array.",
259
+ summary: "Clickable button. Label in specials.text; behavior in the events array. Supports the same template variable system as text-block ({{today}}, {{cart_total_price}}, {{formId__fieldName}}, etc.).",
179
260
  useWhen: "Every CTA. Add a click event: open_link (to URL), open_popup (lead modal), scroll_to (anchor). Inside a form, a button submits the form.",
180
261
  keySpecials: {
181
- text: "button label.", required: "boolean gate by form validity.",
182
- format: "value formatting.", connectedSurvey: "link to a survey element.",
262
+ text: "button label supports template variables (same set as text-block: {{today}}, {{cart_total_price}}, {{formId__fieldName}}, etc.).",
263
+ required: "boolean gate by form validity.",
264
+ isTextParams: "boolean — populate label from a URL query parameter instead of specials.text.",
265
+ textParams: "string — URL query parameter name to read (used when isTextParams=true).",
266
+ isFormat: "boolean — apply a date format to template date variables.",
267
+ format: "string — dayjs format string (e.g. 'D/MM/YYYY') used when isFormat=true.",
268
+ formParamSeparator: "string — separator between items when rendering {{formId__form_items}} lists.",
269
+ isConnectSurvey: "boolean — link this button to a survey element for required-field validation before submit.",
270
+ connectedSurvey: "string — id of the survey element to validate when isConnectSurvey=true.",
183
271
  },
184
272
  example: {
185
273
  id: "cta_main", type: "button",
@@ -194,27 +282,48 @@ export const LIBRARY = {
194
282
  },
195
283
  video: {
196
284
  type: "video", category: "content", container: false,
197
- summary: "Video player (YouTube/upload/etc).",
285
+ summary: "Video player (YouTube/Vimeo/upload/CDN). Use specials.typeVideo to select the source type.",
198
286
  useWhen: "Demo or promo videos. Set specials.img to a poster placeholder (https://placehold.co/640x360) when there's no real thumbnail.",
199
- keySpecials: { typeVideo: "youtube | upload | vimeo…", video_cdn: "video URL/id.", img: "poster/thumbnail — use a placeholder url if none.", autoReplay: "boolean — loop." },
287
+ keySpecials: {
288
+ typeVideo: "'youtube' | 'vimeo' | 'webcake' | 'upload' — video source type.",
289
+ video: "string — raw video URL (for upload/webcake/CDN types).",
290
+ id: "string — YouTube video ID (for typeVideo='youtube').",
291
+ video_cdn: "string — CDN video URL or identifier.",
292
+ img: "string — poster/thumbnail image URL — use a placeholder if none (https://placehold.co/640x360).",
293
+ autoReplay: "boolean — loop the video.",
294
+ showControl: "boolean — show player controls.",
295
+ hideRelated: "boolean — hide YouTube related videos at end.",
296
+ muteOnPlay: "boolean — mute audio when video plays.",
297
+ autoPlay: "boolean — autoplay on load.",
298
+ },
200
299
  },
201
300
  gallery: {
202
- type: "gallery", category: "content", container: true,
203
- summary: "Multi-image gallery.",
204
- useWhen: "Photo grids/galleries with several images. No image API — fill specials.media with placeholder URLs (https://placehold.co/600x400).",
205
- keySpecials: { media: "array of image URLs (use placeholders if you have no real images)." },
301
+ type: "gallery", category: "content", container: false,
302
+ summary: "Multi-image/video gallery with thumbnail strip. Content comes entirely from specials.media — this is NOT a container and has no children.",
303
+ useWhen: "Photo grids/galleries with several images or videos. No image API — fill specials.media with placeholder URLs (https://placehold.co/600x400).",
304
+ keySpecials: {
305
+ media: "array of image URLs or video objects {type:'video', linkVideo:'<url>', typeVideo:'youtube'|'upload'} — use placeholder URLs if no real images.",
306
+ allowZoom: "'off' | 'carousel' | 'lightbox' — (config) zoom/lightbox mode when clicking an image.",
307
+ showNavigation: "boolean — (config) show prev/next navigation arrows.",
308
+ thumbnailAutoplay: "number (ms) | 'off' — (config) auto-advance thumbnails every N ms, or 'off' to disable.",
309
+ thumbnailAutoplayRepeat: "boolean — (config) loop thumbnail autoplay.",
310
+ thumbnailPosition: "'top' | 'bottom' | 'left' | 'right' — (config) position of the thumbnail strip relative to the main image.",
311
+ thumbnailWidth: "number — (config) width of each thumbnail in px.",
312
+ thumbnailHeight: "number — (config) height of each thumbnail in px.",
313
+ distanceAmong: "number — (config) gap between thumbnails in px.",
314
+ },
206
315
  },
207
316
  "html-box": {
208
317
  type: "html-box", category: "content", container: false,
209
- summary: "Raw HTML embed.",
318
+ summary: "Raw HTML embed. specials.html holds the markup (rendered via v-html).",
210
319
  useWhen: "Embedding third-party widgets or custom markup the standard elements can't express.",
211
- keySpecials: {},
320
+ keySpecials: { html: "string — raw HTML content (the only content key; stored HTML-escaped, unescaped at render)." },
212
321
  },
213
322
  "editor-blog": {
214
323
  type: "editor-blog", category: "content", container: false,
215
- summary: "Long-form rich text / article body.",
324
+ summary: "Long-form rich text / article body. specials.html holds the rich-text markup.",
216
325
  useWhen: "Blog/article content blocks.",
217
- keySpecials: {},
326
+ keySpecials: { html: "string — rich-text HTML content (stored HTML-escaped, unescaped at render)." },
218
327
  },
219
328
  // ---------------- form & inputs ----------------
220
329
  form: {
@@ -222,11 +331,41 @@ export const LIBRARY = {
222
331
  summary: "Wraps inputs; on submit creates a lead/FormData. Pixel tracking configured here.",
223
332
  useWhen: "Any lead-capture / contact / registration form. Put input/textarea/select/button inside its children.",
224
333
  keySpecials: {
225
- field_type: "form field config.", form_type: "form behavior type.",
226
- submit_success: "post-submit action/message.", validate: "validation config.",
227
- fb_event_type: "Facebook pixel event (e.g. CompleteRegistration).",
228
- fb_conversion_value: "FB conversion value.", fb_tracking_currency: "currency (VND…).",
229
- tiktok_conversion_value: "TikTok conversion value.", tiktok_tracking_currency: "currency.",
334
+ form_type: "'login' | undefined 'login' runs the gated access-key flow on submit instead of the normal lead-data API.",
335
+ submit_success: "1 | 2 1 = open the success popup (popup_target); 2 = redirect to redirect_url.",
336
+ popup_target: "string (popup id) popup to open when submit_success=1.",
337
+ redirect_url: "string (URL) destination when submit_success=2.",
338
+ target_url: "'_self' | '_blank' window target for the redirect / payment callback.",
339
+ open_link_with_params: "boolean — merge the current page's URL search params into redirect_url.",
340
+ merge_sub_form_data: "boolean — pass the previous form's form_data_id as sub_form_id on redirect.",
341
+ extra_url: "string (URL) — post-submit app/bot/WhatsApp URL used by app_target modes other than botcake.",
342
+ app_target: "'botcake' | 'botcake_dynamic' | 'whatsapp' | 'mess_prefill' | 'tiktok_prefill' | 'line_prefill' | 'others' — which app to open after submit.",
343
+ wa_custom_text: "string — message template for WhatsApp/Messenger/TikTok/LINE prefill (supports {{field_name}} placeholders).",
344
+ line_OA_id: "string — LINE Official Account id (with app_target='line_prefill').",
345
+ botcake_dynamic_ref: "string — ref appended to the m.me URL when app_target='botcake_dynamic'.",
346
+ others_link_params: "array of {key: elementId, name: string} — field→URL-param mappings when app_target='others'.",
347
+ partnerServiceId: "string — sent as partner_service_id (partner/affiliate tracking).",
348
+ fb_event_type: "Facebook pixel standard event fired on submit (e.g. CompleteRegistration, Purchase, Lead, none).",
349
+ fb_conversion_value: "string — FB pixel conversion value.",
350
+ fb_tracking_currency: "string — currency for the FB conversion value (VND…).",
351
+ fb_custom_tracking: "string — extra custom FB pixel event name to fire on submit.",
352
+ tiktok_event_type: "TikTok pixel event fired on submit (e.g. CompleteRegistration, none).",
353
+ tiktok_conversion_value: "string — TikTok conversion value.",
354
+ tiktok_tracking_currency: "string — currency for the TikTok conversion value.",
355
+ event_name_custom: "string | 'none' — custom name fired via fbq('trackCustom') + gtag('event') on submit.",
356
+ ggc_id: "string — Google Ads conversion tag id (single-conversion mode).",
357
+ ggc_label: "string — Google Ads conversion label.",
358
+ ggc_v: "string|number — Google Ads conversion value.",
359
+ ggc_c: "string — Google Ads conversion currency override.",
360
+ google_conversion_mode: "'single_conversion' | 'multi_conversion' | 'none' — fire one or many Google Ads conversions per submit.",
361
+ ggc_list: "array of {ggc_id, ggc_label, ggc_v, ggc_c} — conversions for multi_conversion mode.",
362
+ multiForm: "boolean — mark this as a child form whose submit copies data into multiFormParent.",
363
+ multiFormParent: "string (form id) — parent form this child form syncs into.",
364
+ customArrangementSheet: "boolean — order the backend spreadsheet columns by sheetOrder.",
365
+ sheetOrder: "array of {id: string} — custom child-id order for sheet column arrangement.",
366
+ validate: "validation config (legacy).",
367
+ field_type: "form field config (legacy).",
368
+ "events[]": "the form's OWN events array supports type:'success' (12+ actions: phone_call, open_sms, send_email, open_link, scroll_to, open_popup, close_popup, download_file, show_hide_element, show_section, hide_section, close_webview, change_tab — fired after a successful submit) and type:'error' (open_popup, close_popup, show_hide_element — fired when validation fails).",
230
369
  },
231
370
  },
232
371
  input: {
@@ -234,8 +373,21 @@ export const LIBRARY = {
234
373
  summary: "Single-line input. specials.field_name is the submitted data column (REQUIRED & unique).",
235
374
  useWhen: "Name/email/phone fields. Set field_type to text/email/phone/number.",
236
375
  keySpecials: {
237
- field_name: "REQUIRED unique data key.", field_placeholder: "placeholder text.",
238
- field_type: "text | email | phone | number.", required: "boolean.", formula: "computed value.",
376
+ field_name: "REQUIRED unique data key. Special names: 'phone_number' (phone validation), 'coupon' (publishes form-info-change), 'address' (detect-address), 'postal_code' (postcode lookup), 'recheck_phone_number' (must match phone_number).",
377
+ field_placeholder: "placeholder text.",
378
+ field_type: "text | email | phone | number | postal_code | date — 'postal_code' enables the postcode-detect helper; 'date' renders a date input.",
379
+ required: "boolean.",
380
+ validate: "boolean — enable extra pattern validation (phone regex / postal-code check).",
381
+ validate_country: "string dial code (e.g. '84','1') used for phone validation; exported as country_code in the field list.",
382
+ phone_validator: "string regex — custom phone validation pattern (falls back to CONST.REGEX_PHONE_VALIDATOR).",
383
+ detectAddress: "boolean — activate Vietnamese address autocomplete (only when field_name='address', country '84', no country-select sibling).",
384
+ isFormula: "boolean — formula/computed mode (input becomes read-only, value = evaluated formula).",
385
+ formula: "string — JS expression with {{field_name}} placeholders, e.g. '{{price}} * {{qty}}'.",
386
+ fixed: "string|number — decimal places for the formula result ('0' = integer).",
387
+ isTextParams: "boolean — fill the value from a URL query parameter (name from el.name / field_name).",
388
+ isConnectSurvey: "boolean — input is hidden until a connected survey selects it (required is dropped while hidden).",
389
+ connectedSurvey: "string — id of the survey element this input is connected to.",
390
+ defaultVariationId: "string — default product variation id registered on the parent form (for non-quantity inputs).",
239
391
  },
240
392
  example: {
241
393
  id: "in_phone", type: "input",
@@ -248,53 +400,306 @@ export const LIBRARY = {
248
400
  runtime: {}, events: [],
249
401
  },
250
402
  },
251
- textarea: { type: "textarea", category: "form", container: false, summary: "Multi-line input.", useWhen: "Messages, notes.", keySpecials: { field_name: "REQUIRED unique key.", field_placeholder: "placeholder." } },
252
- select: { type: "select", category: "form", container: false, summary: "Dropdown select.", useWhen: "Pick one from a list.", keySpecials: { field_name: "REQUIRED.", options: "array of options." } },
253
- checkbox: { type: "checkbox", category: "form", container: false, summary: "Single checkbox (consent, opt-in).", useWhen: "Agree-to-terms, single toggle.", keySpecials: { field_name: "REQUIRED." } },
254
- "checkbox-group": { type: "checkbox-group", category: "form", container: true, summary: "Multiple checkboxes.", useWhen: "Multi-select options.", keySpecials: { field_name: "REQUIRED.", options: "array." } },
255
- radio: { type: "radio", category: "form", container: true, summary: "Single-choice radio options.", useWhen: "Pick exactly one of a few.", keySpecials: { field_name: "REQUIRED.", options: "array." } },
256
- address: { type: "address", category: "form", container: false, summary: "Province/District/Ward selector (multi-country).", useWhen: "Shipping/contact address.", keySpecials: { field_name: "REQUIRED.", detectAddress: "auto-detect.", hidden_commune: "hide ward level." } },
257
- "country-select": { type: "country-select", category: "form", container: false, summary: "Country picker.", useWhen: "International forms.", keySpecials: { field_name: "REQUIRED." } },
258
- "quantity_input": { type: "quantity_input", category: "form", container: false, summary: "Quantity stepper (+/-).", useWhen: "Order quantity.", keySpecials: { field_name: "REQUIRED." } },
259
- "input-datetime": { type: "input-datetime", category: "form", container: false, summary: "Date/time picker.", useWhen: "Booking date, appointment.", keySpecials: { field_name: "REQUIRED." } },
260
- "input-file": { type: "input-file", category: "form", container: false, summary: "File upload.", useWhen: "CV/receipt/photo upload.", keySpecials: { field_name: "REQUIRED." } },
403
+ textarea: { type: "textarea", category: "form", container: false, summary: "Multi-line input.", useWhen: "Messages, notes.", keySpecials: { field_name: "REQUIRED unique key.", field_placeholder: "placeholder.", isFormula: "boolean — formula/computed mode (same {{field_name}} expression system as input).", formula: "string — formula expression when isFormula=true." } },
404
+ select: {
405
+ type: "select", category: "form", container: false,
406
+ summary: "Dropdown select. Options live in specials.options (NOT children).",
407
+ useWhen: "Pick one from a list.",
408
+ keySpecials: {
409
+ field_name: "REQUIRED unique data key.",
410
+ field_placeholder: "placeholder/label.",
411
+ default_value: "string (option id) to pre-select; 'default-none' = no pre-selection.",
412
+ defaultVariationId: "string default product variation id registered on the parent form regardless of selection.",
413
+ defaultVariationQuantity: "number — quantity registered with defaultVariationId.",
414
+ ignoreOnHidden: "boolean — when CSS-hidden, remove this element's variations from the form total.",
415
+ isConnectSurvey: "boolean — link to a survey for conditional show/hide.",
416
+ connectedSurvey: "string — id of the connected survey.",
417
+ options: "array of rich option objects {id, name, value, variations:[{id,quantity,price}], attrOnly/prodId/attrName/attrVal/attrs (attribute mode), quantityOnly/quantityProd/quantityValue (quantity mode), tags:[], toggleEvent, events_option:[]} — events_option items drive show/hide, collapse, and price/discount/shipping adjustments. See docs/element-specials-reference.md for the full option schema.",
418
+ },
419
+ },
420
+ checkbox: { type: "checkbox", category: "form", container: false, summary: "Single checkbox (consent, opt-in).", useWhen: "Agree-to-terms, single toggle.", keySpecials: { field_name: "REQUIRED.", required: "boolean — must be checked to submit." } },
421
+ "checkbox-group": {
422
+ type: "checkbox-group", category: "form", container: true,
423
+ summary: "Multiple checkboxes. Choices live in specials.options (the multi-select group writes formData.checkbox[field_name]).",
424
+ useWhen: "Multi-select options.",
425
+ keySpecials: {
426
+ field_name: "REQUIRED unique data key (submitted as formData.checkbox[field_name], an array of checked option ids).",
427
+ required: "boolean — at least one checkbox must be checked.",
428
+ default_values: "array of option id strings checked by default on load.",
429
+ defaultVariationId: "string — default variation registered on the parent form.",
430
+ defaultVariationQuantity: "number — quantity for defaultVariationId.",
431
+ ignoreOnHidden: "boolean — when CSS-hidden, exclude this element's variations from the form total.",
432
+ isConnectSurvey: "boolean — link to a survey for conditional show/hide.",
433
+ connectedSurvey: "string — id of the connected survey.",
434
+ options: "array of rich option objects (same shape as select) — additionally supports the tcb_auto_banking event type in events_option (sets the storecake_tcb payment gateway). See docs/element-specials-reference.md for the full option schema.",
435
+ },
436
+ },
437
+ radio: {
438
+ type: "radio", category: "form", container: true,
439
+ summary: "Single-choice radio options. Choices live in specials.options (writes formData.radio[field_name]).",
440
+ useWhen: "Pick exactly one of a few. Common for payment-method selection.",
441
+ keySpecials: {
442
+ field_name: "REQUIRED unique data key (submitted as formData.radio[field_name], the single selected value).",
443
+ required: "boolean — one radio must be selected.",
444
+ default_value: "string (option id) | 'none' — option to pre-select; 'none' = nothing pre-selected.",
445
+ defaultVariationId: "string — default variation registered on the parent form.",
446
+ defaultVariationQuantity: "number — quantity for defaultVariationId.",
447
+ highlight: "boolean — give the selected radio item a background highlight.",
448
+ color_highlight: "CSS color — background applied to the selected item when highlight=true.",
449
+ ignoreOnHidden: "boolean — when hidden, exclude this element's variations from the form total.",
450
+ isConnectSurvey: "boolean — link to a survey for conditional show/hide.",
451
+ connectedSurvey: "string — id of the connected survey.",
452
+ options: "array of rich option objects (same shape as select) — events_option additionally supports 9 payment-gateway event types (tcb_auto_banking, xendit_banking, onepay_banking, mercadopago_banking, vnpay_banking, paymongo_banking, stripe_banking, paypal_banking, momopay_banking) that set the form's payment provider. See docs/element-specials-reference.md for the full option schema.",
453
+ },
454
+ },
455
+ address: {
456
+ type: "address", category: "form", container: false,
457
+ summary: "Province/District/Ward selector (multi-country).", useWhen: "Shipping/contact address.",
458
+ keySpecials: {
459
+ field_name: "REQUIRED unique data key.",
460
+ country: "string — numeric phone-prefix code (e.g. '84' VN, '1' US) selecting which province/district/commune data to load.",
461
+ use_search_box: "boolean — wrap the dropdowns in a typeahead SelectSearch widget.",
462
+ hidden_commune: "boolean — omit the commune (ward) tier entirely.",
463
+ hidden_province_list: "array of province id strings to exclude from the province dropdown.",
464
+ hidden_district_list: "array of district id strings to exclude from the district dropdown.",
465
+ hidden_commune_list: "array of commune id strings to exclude from the commune dropdown.",
466
+ required_commune: "boolean — require commune selection when communes exist for the district.",
467
+ hide_postal_code: "boolean (default true) — when false and the country supports it, expose a postal-code dropdown.",
468
+ },
469
+ },
470
+ "country-select": {
471
+ type: "country-select", category: "form", container: false,
472
+ summary: "Country picker. Auto-syncs sibling phone-number / postal-code / address fields.",
473
+ useWhen: "International forms.",
474
+ keySpecials: {
475
+ field_name: "REQUIRED unique data key.",
476
+ countries: "array of country dial-prefix codes (e.g. ['84','1','65']) shown in the dropdown and used to preload address data.",
477
+ autofill_phone: "boolean — listen to sibling phone_number inputs to auto-select the country by dial prefix and auto-prepend the dial code.",
478
+ },
479
+ },
480
+ "quantity_input": { type: "quantity_input", category: "form", container: false, summary: "Quantity stepper (+/-).", useWhen: "Order quantity.", keySpecials: { field_name: "REQUIRED unique data key.", ignoreOnHidden: "boolean — when hidden at mount, add field_name to the parent form's ignore list and publish quantity 0." } },
481
+ "input-datetime": {
482
+ type: "input-datetime", category: "form", container: false,
483
+ summary: "Date/time picker.", useWhen: "Booking date, appointment.",
484
+ keySpecials: {
485
+ field_name: "REQUIRED unique data key.",
486
+ datetime_type: "'date' | 'time' | 'datetime-local' | 'time_slot_picker' — the HTML input type to render.",
487
+ limit_option_type: "'none' | 'dynamic' — 'dynamic' computes min/max from before_day/after_day offsets from today.",
488
+ before_day: "number — days/hours before today that stay selectable (when limit_option_type='dynamic').",
489
+ after_day: "number — days/hours after today that stay selectable (when limit_option_type='dynamic').",
490
+ sync_to_crm: "'none' | 'booking_crm' — 'booking_crm' validates against CRM availability (active only when shop_type=3).",
491
+ },
492
+ },
493
+ "input-file": { type: "input-file", category: "form", container: false, summary: "File upload (renderer: upload.js).", useWhen: "CV/receipt/photo upload.", keySpecials: { field_name: "REQUIRED unique data key.", maxFile: "number — when truthy, enable multi-file upload and track the uploaded-file count. (UI variant config.display_type 'default'|'type-1' lives in the per-breakpoint config object, NOT in specials.)" } },
261
494
  signature: { type: "signature", category: "form", container: false, summary: "Hand-drawn signature pad.", useWhen: "Consent/contracts.", keySpecials: { field_name: "REQUIRED." } },
262
- "verify-code": { type: "verify-code", category: "form", container: false, summary: "OTP / verification code field.", useWhen: "Phone/email verification.", keySpecials: { field_name: "REQUIRED." } },
263
- "group-select": { type: "group-select", category: "form", container: true, summary: "Attribute/variant selector group (e.g. size+color+quantity).", useWhen: "Product variants with quantity.", keySpecials: {} },
264
- "group-select-item": { type: "group-select-item", category: "form", container: false, summary: "One attribute inside group-select.", useWhen: "Child of group-select only.", keySpecials: { field_placeholder: "label.", field_quantity: "boolean — is the quantity field.", options: "choices." } },
495
+ "verify-code": {
496
+ type: "verify-code", category: "form", container: false,
497
+ summary: "OTP / verification code field.", useWhen: "Phone/email verification.",
498
+ keySpecials: {
499
+ field_name: "REQUIRED unique data key for the OTP value.",
500
+ type_otp_input: "'one-input' | (other) — 'one-input' = single box accepting length_otp chars; otherwise multi-box auto-advance.",
501
+ length_otp: "number — number of OTP digits (typically 4–8); sent to the OTP endpoint.",
502
+ partner_id: "string — backend partner/tenant id sent to GET /partners/{partner_id}/get_otp (required for 'Get Code').",
503
+ field_type: "'postal_code' | absent — 'postal_code' switches validation from phone OTP to a postal-code regex.",
504
+ condition: "'limit_5' | 'limit_6' | 'custom' — postal-code regex selector (active when field_type='postal_code').",
505
+ pattern: "string regex — custom postal-code pattern when condition='custom'.",
506
+ },
507
+ },
508
+ "group-select": {
509
+ type: "group-select", category: "form", container: true,
510
+ summary: "Attribute/variant selector group (e.g. size+color+quantity); children are group-select-item.",
511
+ useWhen: "Product variants with quantity.",
512
+ keySpecials: {
513
+ field_name: "REQUIRED — variation slot key used when registering variations on the parent form.",
514
+ sprod: "object {id: string} — product reference for the child items ('custom' = read product id from a runtime DOM attr).",
515
+ alwayValue: "boolean (spelling exact, no trailing 's') — when true and the group is hidden, skip pushing its variation to the form.",
516
+ },
517
+ },
518
+ "group-select-item": {
519
+ type: "group-select-item", category: "form", container: false,
520
+ summary: "One attribute (or the quantity) inside group-select. Options are NOT static — they are populated from the product catalog (window.sync.products) at runtime based on attrName + the parent's sprod.",
521
+ useWhen: "Child of group-select only.",
522
+ keySpecials: {
523
+ field_name: "REQUIRED unique data key (becomes 'quantity' when field_quantity=true).",
524
+ field_quantity: "boolean — when true this item is the quantity selector (value goes to parent._setQuantity), not a product attribute. Only one item per group.",
525
+ attrName: "string — the product attribute name this item maps to (e.g. 'Color','Size'); numeric strings ('1','2') index product_attributes for custom products; 'sprod-name'/'sprod-sku' show the product name/SKU.",
526
+ default_value: "string — pre-selected option value, or 'default-none'/empty for no default.",
527
+ required: "boolean — require selection when the item and its parent group are visible.",
528
+ },
529
+ },
265
530
  // ---------------- commerce ----------------
266
- "list-product": { type: "list-product", category: "commerce", container: false, summary: "Product list bound to a dataset/store.", useWhen: "Show purchasable products.", keySpecials: { format_title: "title format (e.g. 'sku').", numerical_order: "boolean.", remain_quantity_text: "low-stock label." } },
267
- "search-list-product": { type: "search-list-product", category: "commerce", container: false, summary: "Search box + product list.", useWhen: "Searchable catalog.", keySpecials: {} },
268
- "cart-items": { type: "cart-items", category: "commerce", container: false, summary: "Items currently in the cart.", useWhen: "Cart/checkout area.", keySpecials: {} },
269
- "cart-quantity": { type: "cart-quantity", category: "commerce", container: false, summary: "Total cart quantity badge.", useWhen: "Cart icon counter.", keySpecials: {} },
270
- "product-select": { type: "product-select", category: "commerce", container: false, summary: "Product / variant selector.", useWhen: "Choose product before order.", keySpecials: {} },
271
- table: { type: "table", category: "commerce", container: false, summary: "Data table.", useWhen: "Pricing/comparison/spec tables.", keySpecials: {} },
531
+ "list-product": {
532
+ type: "list-product", category: "commerce", container: false,
533
+ summary: "Product list bound to the page store; clicking a card opens the popup-checkout overlay.",
534
+ useWhen: "Show purchasable products.",
535
+ keySpecials: {
536
+ select: "'product' | 'tag' | 'category' which dimension to filter products by.",
537
+ type: "'expect' | 'except' — treat the expect*/except* arrays as an allowlist (expect) or denylist (except).",
538
+ expect: "array of product id strings to include (select='product', type='expect').",
539
+ except: "array of product id strings to exclude (select='product', type='except').",
540
+ expectCategory: "array of category ids to include (select='category').",
541
+ exceptCategory: "array of category ids to exclude (select='category').",
542
+ expectTags: "array of tag slugs to include (select='tag').",
543
+ exceptTags: "array of tag slugs to exclude (select='tag').",
544
+ format_title: "'default' | 'sku' | 'sku-name' | 'name-category' — product title composition.",
545
+ format_price: "'range' | 'discount' — 'discount' shows the % off badge when original > retail.",
546
+ direction: "'column' | (other) — 'column' = whole card is one click target; otherwise thumbnail + cart button each get handlers (and remain-quantity shows).",
547
+ numerical_order: "boolean — show numbered labels (01, 02 …) on thumbnails.",
548
+ remain_quantity_text: "string — low-stock label; {{value}} is replaced with the actual remaining count.",
549
+ },
550
+ },
551
+ "search-list-product": { type: "search-list-product", category: "commerce", container: false, summary: "Search box + product results. Reads NO specials (all DOM-driven) and REQUIRES a co-existing list-product element on the page — openProduct() delegates to a list-product vm, so without one nothing opens.", useWhen: "Searchable catalog (pair it with a list-product element).", keySpecials: {} },
552
+ "cart-items": { type: "cart-items", category: "commerce", container: false, summary: "Items currently in the cart. Has NO element specials — the WCart system writes its inner HTML at runtime and it needs the cart active (is_cart_active). Item name/price/quantity font sizes come from the page-level cartConfigs.checkoutElements['CART-ITEM'] (itemNameSize/itemPriceSize/inputQuantitySize), not from this node.", useWhen: "Cart/checkout area (requires WCart active).", keySpecials: {} },
553
+ "cart-quantity": {
554
+ type: "cart-quantity", category: "commerce", container: false,
555
+ summary: "Quantity stepper (+/-) that controls a field in the parent variation group; publishes <id>__quantity-change on each click.",
556
+ useWhen: "Per-variation quantity inside a cart/form group.",
557
+ keySpecials: {
558
+ field_name: "REQUIRED — identifies which field in the parent variation group this stepper controls.",
559
+ ignoreOnHidden: "boolean — when hidden, suppress this element's quantity contribution (calls _addIgnoreField/_removeIgnoreField on the parent vm).",
560
+ },
561
+ },
562
+ "product-select": { type: "product-select", category: "commerce", container: false, summary: "STUB — no runtime renderer exists in render_v4, so placing this type produces a non-functional element that does nothing on the page. Use list-product (catalog) or form (order capture) instead.", useWhen: "Do NOT use — it is a reserved/legacy stub. Prefer list-product or form.", keySpecials: {} },
563
+ table: {
564
+ type: "table", category: "commerce", container: false,
565
+ summary: "Data table rendered from a pre-fetched Google Sheets 2D array.",
566
+ useWhen: "Pricing/comparison/spec tables (data must be pre-loaded into specials).",
567
+ keySpecials: {
568
+ dataType: "0 | 1 — MUST be 1 to render anything; the renderer returns early when dataType != 1.",
569
+ source: "string — data source label (metadata only).",
570
+ sheetID: "string — Google Sheet document id (metadata only).",
571
+ google_sheet_data: "string[][] — the 2D table data; row 0 = headers as 'Title|type' where type ∈ image|video|link|time (absent type = plain text); rows 1+ are data cells.",
572
+ },
573
+ },
272
574
  // ---------------- marketing / dynamic ----------------
273
575
  countdown: {
274
576
  type: "countdown", category: "marketing", container: false,
275
577
  summary: "Countdown timer (minute duration, fixed end time, or daily window).",
276
578
  useWhen: "Urgency: limited offer, flash sale. Renders a fixed FOUR-slot flex row (day·hour·minute·second); each visible segment is sized 1/4 of the width regardless of how many show, so HIDING a segment (showDay/showSecond:false) leaves an empty gap on the right (the row is left-aligned, no built-in re-centering). Keep all four (showDay+showSecond:true) so the row fills evenly, and CENTER the whole box on the canvas: left = round((canvas - width)/2).",
277
579
  keySpecials: {
278
- type: "minute | endTime | daily.", duration: "minutes (when type=minute).",
279
- startTime: "ISO start (endTime/daily).", endTime: "ISO end.",
280
- showDay: "bool.", showSecond: "bool.", showText: "bool.", language: "label locale.", customTranslation: "custom labels.",
580
+ type: "minute | duration | daily — countdown mode. 'minute' counts down from a fixed duration; 'duration' counts to a fixed end datetime; 'daily' resets in a daily window.", duration: "minutes to count down (when type='minute').",
581
+ startTime: "ISO datetime string — start of the countdown window (used with type='duration' or 'daily').",
582
+ endTime: "ISO datetime string end/deadline datetime (used with type='duration').",
583
+ dailyStart: "string 'HH:MM' — daily window open time (used with type='daily').",
584
+ dailyEnd: "string 'HH:MM' — daily window close time (used with type='daily').",
585
+ repeat: "boolean — restart the countdown when it reaches zero.",
586
+ customize: "boolean — enable custom message display when countdown finishes.",
587
+ customMessage: "string — message to display when countdown reaches zero (used when customize=true).",
588
+ showDay: "boolean — show the days segment.",
589
+ showSecond: "boolean — show the seconds segment.",
590
+ showText: "boolean — show unit labels (days/hours/minutes/seconds).",
591
+ language: "locale string for unit labels.",
592
+ customTranslation: "object — custom unit label overrides.",
593
+ },
594
+ },
595
+ timegroup: {
596
+ type: "timegroup", category: "marketing", container: false,
597
+ summary: "Live current date/time display rendered as text. Supports relative labels (today/yesterday/tomorrow) or fixed datetime, with multiple format presets.",
598
+ useWhen: "Show today's date, a relative date label, or a formatted timestamp on the page.",
599
+ keySpecials: {
600
+ currentTime: "'yesterday' | 'today' | 'nextday' | 'custom' — which date to display.",
601
+ formatType: "number 1–11 — selects a date/time format preset (1=short date, 2=long date, 3=time, 4=datetime, etc.).",
602
+ language: "string — locale for month/day names (e.g. 'vi', 'en').",
603
+ typeTimeGroup: "1 | 2 — 1=relative label (e.g. 'Today'), 2=fixed formatted datetime string.",
604
+ customTime: "string — ISO datetime string to display when currentTime='custom'.",
605
+ customDateJump: "number — day offset from customTime (positive=future, negative=past).",
606
+ },
607
+ },
608
+ "auto-number": {
609
+ type: "auto-number", category: "marketing", container: false,
610
+ summary: "Auto-incrementing number that counts up from startNumber to endNumber. Supports sync mode to mirror another element's value.",
611
+ useWhen: "Social-proof counters (views, orders, customers). Counts up on page load.",
612
+ keySpecials: {
613
+ startNumber: "number — value to start counting from.",
614
+ endNumber: "number — value to count up to.",
615
+ jumpNumber: "number — increment per animation step.",
616
+ timeDelayMs: "number (ms) — interval between steps (preferred key).",
617
+ timeDelay: "number (ms) — legacy interval key (use timeDelayMs instead).",
618
+ autoNumberMode: "'sync' | undefined — 'sync' mirrors the value of another auto-number element instead of counting independently.",
619
+ syncTarget: "string — element id to sync with when autoNumberMode='sync'.",
620
+ },
621
+ },
622
+ "random-number": {
623
+ type: "random-number", category: "marketing", container: false,
624
+ summary: "Displays a random number between startNumber and endNumber. Result is persisted in localStorage so it stays consistent across page reloads.",
625
+ useWhen: "Randomized social proof (e.g. 'X people viewed this').",
626
+ keySpecials: {
627
+ startNumber: "number — minimum value of the random range.",
628
+ endNumber: "number — maximum value of the random range.",
629
+ jumpNumber: "number — step granularity for the random value.",
630
+ },
631
+ },
632
+ notify: {
633
+ type: "notify", category: "marketing", container: false,
634
+ summary: "'Someone just bought…' toast notification strip. Cycles through a list of notifications with configurable timing. Supports static data, Google Sheets, or a webcake dataset as source.",
635
+ useWhen: "Social-proof popups showing recent purchases/signups.",
636
+ keySpecials: {
637
+ delay: "number (ms) — initial delay before the first notification appears.",
638
+ duration: "number (ms) — how long each notification is visible.",
639
+ delayStart: "number (ms) — pause between notifications.",
640
+ random: "boolean — randomize the order of notifications.",
641
+ soundMode: "boolean — play a sound when a notification appears.",
642
+ notifySoundLink: "string — URL to a custom notification sound file.",
643
+ dataType: "0 | 1 — 0=static data embedded in page, 1=Google Sheets.",
644
+ source: "string — data source label.",
645
+ sheetID: "string — Google Sheet ID (when dataType=1).",
646
+ dataSheet: "string — sheet tab name (when dataType=1).",
647
+ datasetId: "string — webcake dataset ID to pull notification data from.",
648
+ dataSetData: "array — pre-fetched dataset rows.",
649
+ },
650
+ },
651
+ "spin-wheel": {
652
+ type: "spin-wheel", category: "marketing", container: false,
653
+ summary: "Lucky-spin wheel with configurable prize segments and coupon codes. Can open a result popup after spinning and supports dataset-driven coupon lists.",
654
+ useWhen: "Gamified lead capture / promos. Users spin to win a coupon or prize.",
655
+ keySpecials: {
656
+ message: "array of strings — prize label for each wheel segment.",
657
+ spin: "object — spin configuration (segment colors, angles, etc.).",
658
+ code: "array of strings — coupon codes corresponding to each segment.",
659
+ dataType: "0 | 1 — 0=static codes, 1=dataset-driven codes.",
660
+ datasetId: "string — webcake dataset ID for coupon codes.",
661
+ codeDataset: "string — dataset column key for the coupon code.",
662
+ popup: "string — id of the popup to open after a successful spin (showing the prize).",
663
+ popupTurnOver: "string — id of the popup to open when the user has no turns left.",
664
+ showCoupon: "boolean — display the winning coupon code in the result popup.",
665
+ fontSize: "number — font size of segment labels.",
666
+ widthText: "number — text wrap width in segment labels.",
667
+ textAlign: "'left' | 'center' | 'right' — alignment of segment label text.",
668
+ assignCoupon: "boolean — assign a specific coupon to the user after spin.",
281
669
  },
282
670
  },
283
- timegroup: { type: "timegroup", category: "marketing", container: false, summary: "Live current date/time display.", useWhen: "Show today's date/time.", keySpecials: {} },
284
- "auto-number": { type: "auto-number", category: "marketing", container: false, summary: "Auto-incrementing number (e.g. fake view count).", useWhen: "Social-proof counters.", keySpecials: {} },
285
- "random-number": { type: "random-number", category: "marketing", container: false, summary: "Random number display.", useWhen: "Randomized social proof.", keySpecials: {} },
286
- notify: { type: "notify", category: "marketing", container: false, summary: "'Someone just bought…' toast notifications.", useWhen: "Social-proof popups.", keySpecials: {} },
287
- "spin-wheel": { type: "spin-wheel", category: "marketing", container: false, summary: "Lucky-spin wheel with prizes.", useWhen: "Gamified lead capture / promos.", keySpecials: {} },
288
671
  survey: {
289
672
  type: "survey", category: "marketing", container: false,
290
673
  summary: "Survey / image-choice question; each option submits a field.",
291
674
  useWhen: "Quizzes, preference capture, image pickers.",
292
675
  keySpecials: {
293
- options: "array of {id,image,title,value,field_name}.", type: "text-image | text",
294
- multiOption: "boolean — allow multiple.", selectedBackground: "selected bg color.", selectedBorder: "selected border color.",
676
+ type: "'text-image' | 'text' | (other) — layout variant; controls whether option images render.",
677
+ multiOption: "boolean — allow selecting multiple options at once.",
678
+ limitOption: "number — max selectable options when multiOption=true (oldest is ejected past the limit).",
679
+ defaultOption: "array of option id strings to pre-select on load.",
680
+ required: "boolean — require at least one selection before the form submits.",
681
+ scrollAuto: "'yes' | (other) — horizontal auto-scroll strip mode (requires a form parent); also readable via config.scrollAuto.",
682
+ field_name: "string — form field key these selections map to in the parent form's variation data.",
683
+ connectedForm: "string — id of another form field element to receive the survey values (cross-form wiring; that field needs isConnectSurvey=true + connectedSurvey=this id).",
684
+ showInputQuantity: "boolean — render a +/- quantity stepper inside each option (uses each option's min_quantity/max_quantity).",
685
+ sprod_id: "string — product id when the survey acts as a WCart attribute selector.",
686
+ sprod_attr: "string — product attribute name this survey selects (e.g. 'Color').",
687
+ sprod_vals: "array of attribute value strings, one per option (indexed by option DOM order).",
688
+ imageHeight: "number (px) — option image height.",
689
+ imageWidth: "number (px) — option image width.",
690
+ alignment: "'center' | 'left' | 'right' — option alignment within the wrapper.",
691
+ selectedBackground: "CSS color — background of selected option cards.",
692
+ selectedBorder: "CSS color — border of selected option cards.",
693
+ hoveredBorder: "CSS color — border color on hover.",
694
+ options: "array of option objects {id, field_name, title, image, value, min_quantity, max_quantity, toggleEvent, events_option:[], variations:[], attrOnly/prodId/attrName/attrVal/attrs, quantityOnly/quantityProd/quantityValue, params_value} — events_option supports showhide/collapse/custom_form_price/custom_form_discount/custom_form_shipping_fee/tcb_auto_banking. See docs/element-specials-reference.md for the full option schema.",
295
695
  },
296
696
  },
297
- "alertMessage": { type: "alertMessage", category: "marketing", container: false, summary: "Alert / announcement banner.", useWhen: "Top-of-page notices.", keySpecials: {} },
697
+ "alertMessage": {
698
+ type: "alertMessage", category: "marketing", container: false,
699
+ summary: "INTERNAL UTILITY FUNCTION — not a placeable page element. alertMessage(type, content, duration) is a JS helper called by other renderers (form, upload, verify-code, input-datetime, etc.) to show a transient toast. It has no vm.specials and cannot be placed on a page. Do NOT generate an element node of this type.",
700
+ useWhen: "Never — this is not a user-facing element. Do not place this type in a page or popup.",
701
+ keySpecials: {},
702
+ },
298
703
  };
299
704
  export const GENERATION_GUIDE = `You are generating the JSON source of a Webcake landing page that the editor renders directly.
300
705
 
@@ -313,6 +718,7 @@ ELEMENT NODE (every element)
313
718
  "responsive": { "desktop": { "config": {}, "styles": {} }, "mobile": { "config": {}, "styles": {} } },
314
719
  "specials": { ...type-specific CONTENT... }, "runtime": {}, "events": [],
315
720
  "children": [ ... ] } // children ONLY on container types
721
+ - Cross-cutting config keys apply to EVERY element via the per-breakpoint config (responsive.<bp>.config): sticky/stickyPosition/stickyTop/stickyBottom/stickyLeft/stickyRight/stickyWidth/stickyHeight/stickyUnpinAtSections…, animation, hide, lock. The full per-element specials reference (every renderer-read key, including the rich select/checkbox-group/radio/survey option-object schema) lives in docs/element-specials-reference.md.
316
722
 
317
723
  COORDINATE SYSTEM (critical)
318
724
  - Absolute-positioning canvas (NOT flexbox). Children carry top/left/width/height in px (numbers).
@@ -341,7 +747,7 @@ RULES
341
747
  - CONTRAST: text must contrast with the section background (dark text on light sections, light text on dark sections). Don't put light-gray text on white or faint text on a dark background.
342
748
  - movable:false for section/slide/grid-item/popup; otherwise true. runtime is always {}.
343
749
  - Every form input MUST have a unique specials.field_name.
344
- - events item: { "id", "type":"click"|"hover"|"success"|"unset", "action", "target", "appTarget":"", "hoverColor":"" }. For element-targeting actions (open_popup, close_popup, scroll_to, show_section, hide_section, show_hide_element) target = the target element's id; for open_link target = URL; for copy target = the text; target may be null (e.g. animation_hover).
750
+ - events item: { "id", "type", "action", "target", ...action-specific extra fields }. TRIGGER (type): click & hover on any element; success & error on a FORM (success = after a successful submit, error = on validation failure); delay on any element (when it scrolls into view); unset on init. Action vocab per trigger: click→CLICK_ACTIONS, hover→HOVER_ACTIONS, success→SUCCESS_ACTIONS, error→ERROR_ACTIONS, delay→DELAY_ACTIONS (all returned by get_generation_guide). For element-targeting actions (open_popup, close_popup, scroll_to, show_section, hide_section, show_hide_element, change_tab, collapse) target = the target element's id; open_link/download_file target = URL; open_sms/send_email/phone_call target = phone/email; copy target = text (or element id when copyType='elementValue'); set_field_value target = field_name; target may be null (e.g. animation_hover). Each action also reads extra fields (e.g. open_link→targetURL/delayTime, scroll_to→scrollMore, change_tab→moveTo/tabIndex, lightbox→typeLightbox/alt, show_hide_element→onlyMode, open_app→appTarget+provider fields, set_field_value→set_value) — see the action maps for the full list.
345
751
  - ANIMATION: each breakpoint's config has config.animation = { "name":"none", "delay":0, "duration":3, "repeat":null }. Keep "none" unless an entrance animation is wanted.
346
752
  - Do NOT invent prices, phone numbers, addresses, or statistics. Output text in the requested language.
347
753
 
@@ -33,6 +33,7 @@
33
33
  }
34
34
  },
35
35
  "cartConfigs": { "type": "object", "additionalProperties": true, "description": "Cart/commerce config; {} when unused." },
36
+ "svariations": { "description": "Product/variation data carried by editor-saved sources (cart/commerce pages). Open passthrough — preserve it verbatim across get_page -> edit -> update_page; do not drop it." },
36
37
  "settings": {
37
38
  "type": "object",
38
39
  "description": "Page-wide config + SEO + fonts + tracking + custom code (real pages carry ~20-40 keys).",
package/dist/smoke.js CHANGED
@@ -118,5 +118,62 @@ for (const [type, doc] of Object.entries(LIBRARY)) {
118
118
  const rr = validatePage(wrapped);
119
119
  check(`example ${type} valid`, rr.valid, rr.errors);
120
120
  }
121
+ console.log("== validate: form-data binding checks ==");
122
+ const mkBox = () => ({ desktop: { config: {}, styles: {} }, mobile: { config: {}, styles: {} } });
123
+ const bindingsBad = {
124
+ page: [
125
+ {
126
+ id: "secf", type: "section",
127
+ properties: { name: "F", movable: false, sync: true },
128
+ responsive: { desktop: { config: {}, styles: { position: "relative", height: 800 } }, mobile: { config: {}, styles: { position: "relative", height: 800 } } },
129
+ specials: {}, runtime: {}, events: [],
130
+ children: [
131
+ {
132
+ id: "frm1", type: "form",
133
+ properties: { name: "Form", movable: true, sync: true },
134
+ responsive: mkBox(), specials: {}, runtime: {}, events: [],
135
+ children: [
136
+ { id: "i1", type: "input", properties: {}, responsive: mkBox(), specials: { field_name: "phone_number" }, events: [] },
137
+ { id: "i2", type: "input", properties: {}, responsive: mkBox(), specials: { field_name: "phone_number" }, events: [] },
138
+ { id: "rad1", type: "radio", properties: {}, responsive: mkBox(),
139
+ specials: { field_name: "opt", options: [{ id: "o1", events_option: [{ id: "e", type: "showhide", promoId: "ghost_target" }] }] },
140
+ runtime: {}, events: [], children: [] },
141
+ { id: "sv1", type: "survey", properties: {}, responsive: mkBox(), specials: { field_name: "sv", connectedForm: "missing_field" }, events: [] },
142
+ { id: "b1", type: "button", properties: {}, responsive: mkBox(), specials: { text: "X" },
143
+ events: [{ id: "ev", type: "click", action: "set_field_value", target: "w-nope" }] },
144
+ ],
145
+ },
146
+ ],
147
+ },
148
+ ],
149
+ };
150
+ const rbb = validatePage(bindingsBad);
151
+ check("dup field_name in form warned", rbb.warnings.some((w) => w.includes('field_name "phone_number"') && w.includes("used 2")), rbb.warnings);
152
+ check("dangling option promoId warned", rbb.warnings.some((w) => w.includes("promoId") && w.includes("ghost_target")), rbb.warnings);
153
+ check("dangling connectedForm warned", rbb.warnings.some((w) => w.includes("connectedForm") && w.includes("missing_field")), rbb.warnings);
154
+ check("dangling set_field_value element ref warned", rbb.warnings.some((w) => w.includes("set_field_value") && w.includes("w-nope")), rbb.warnings);
155
+ const bindingsGood = {
156
+ page: [
157
+ {
158
+ id: "secg", type: "section",
159
+ properties: { name: "G", movable: false, sync: true },
160
+ responsive: { desktop: { config: {}, styles: { position: "relative", height: 800 } }, mobile: { config: {}, styles: { position: "relative", height: 800 } } },
161
+ specials: {}, runtime: {}, events: [],
162
+ children: [
163
+ {
164
+ id: "frm2", type: "form",
165
+ properties: { name: "Form", movable: true, sync: true },
166
+ responsive: mkBox(), specials: {}, runtime: {}, events: [],
167
+ children: [
168
+ { id: "n1", type: "input", properties: {}, responsive: mkBox(), specials: { field_name: "full_name" }, events: [] },
169
+ { id: "p1", type: "input", properties: {}, responsive: mkBox(), specials: { field_name: "phone_number" }, events: [] },
170
+ ],
171
+ },
172
+ ],
173
+ },
174
+ ],
175
+ };
176
+ const rbg = validatePage(bindingsGood);
177
+ check("clean form has no binding warnings", rbg.warnings.length === 0, rbg.warnings);
121
178
  console.log(`\n${failures === 0 ? "ALL GOOD" : failures + " FAILURE(S)"}`);
122
179
  process.exit(failures === 0 ? 0 : 1);
package/dist/validate.js CHANGED
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Page validation: JSON-Schema structural check (ajv, draft 2020-12) plus
3
3
  * semantic checks the schema can't express (unique ids, dangling event targets,
4
- * children only on containers, missing field_name, top-level types).
4
+ * children only on containers, missing field_name, top-level types). Also checks
5
+ * form-data bindings: duplicate field_name within a single form, and dangling
6
+ * option-level event targets (specials.options[].events_option promoId) and
7
+ * survey/field cross-wiring (connectedSurvey / connectedForm / set_field_value).
5
8
  */
6
9
  import { readFileSync } from "node:fs";
7
10
  import Ajv2020Module from "ajv/dist/2020.js";
@@ -14,9 +17,14 @@ export const pageSchema = JSON.parse(readFileSync(new URL("./page-schema.json",
14
17
  const ajv = new Ajv2020({ allErrors: true, strict: false });
15
18
  const validateSchema = ajv.compile(pageSchema);
16
19
  // Actions whose `target` is expected to be an element id (vs a URL / text).
20
+ // Actions whose `target` is an existing element id (so a missing target is a
21
+ // dangling-reference warning). NOTE: play_audio/stop_audio are intentionally NOT
22
+ // here — their target is an audio file URL, not an element id (render_v4 event
23
+ // dispatcher), so checking them produced false-positive warnings. `collapse`
24
+ // targets an element id and IS checked.
17
25
  const ELEMENT_TARGET_ACTIONS = new Set([
18
26
  "open_popup", "close_popup", "scroll_to", "show_section", "hide_section",
19
- "show_hide_element", "change_tab", "play_audio", "stop_audio",
27
+ "show_hide_element", "change_tab", "collapse",
20
28
  ]);
21
29
  const TOP_LEVEL_TYPES = new Set(["section", "dynamic_page", "popup"]);
22
30
  // Fixed canvas reference (matches library CANVAS) used for the layout/bounds check.
@@ -61,6 +69,12 @@ export function validatePage(input) {
61
69
  // 2) Semantic
62
70
  const ids = new Map();
63
71
  const eventTargets = [];
72
+ // option-level events (specials.options[].events_option) targeting an element id
73
+ const optionTargets = [];
74
+ // survey/field cross-wiring (specials.connectedSurvey / connectedForm)
75
+ const connectRefs = [];
76
+ // form nodes — used to check field_name uniqueness within each form's scope
77
+ const forms = [];
64
78
  let elementCount = 0;
65
79
  const topList = Array.isArray(page?.page)
66
80
  ? page.page
@@ -108,6 +122,33 @@ export function validatePage(input) {
108
122
  }
109
123
  }
110
124
  }
125
+ // collect form-data bindings: option-level events (showhide/collapse promoId)
126
+ // and survey/field cross-wiring; and remember form scopes for field_name checks.
127
+ const sp = node.specials;
128
+ if (sp && typeof sp === "object") {
129
+ if (Array.isArray(sp.options)) {
130
+ for (const opt of sp.options) {
131
+ if (!opt || !Array.isArray(opt.events_option))
132
+ continue;
133
+ for (const ev of opt.events_option) {
134
+ if (ev &&
135
+ (ev.type === "showhide" || ev.type === "collapse") &&
136
+ typeof ev.promoId === "string" &&
137
+ ev.promoId.trim() !== "") {
138
+ optionTargets.push({ from: node.id ?? path, kind: ev.type, target: ev.promoId });
139
+ }
140
+ }
141
+ }
142
+ }
143
+ for (const key of ["connectedSurvey", "connectedForm"]) {
144
+ const v = sp[key];
145
+ if (typeof v === "string" && v.trim() !== "") {
146
+ connectRefs.push({ from: node.id ?? path, key, target: v });
147
+ }
148
+ }
149
+ }
150
+ if (type === "form")
151
+ forms.push(node);
111
152
  if (Array.isArray(node.children)) {
112
153
  node.children.forEach((c, idx) => walk(c, `${path}.children[${idx}]`));
113
154
  }
@@ -128,14 +169,64 @@ export function validatePage(input) {
128
169
  if (count > 1)
129
170
  errors.push(`Duplicate id "${id}" used ${count} times — ids must be unique.`);
130
171
  }
172
+ // Does `target` fail to resolve to any element id? (ids may be stored with or
173
+ // without the runtime `w-`/`#w-` prefix.)
174
+ const danglesId = (target) => {
175
+ const cleaned = target.replace(/^#?w-/, "");
176
+ return !ids.has(target) && !ids.has(cleaned);
177
+ };
131
178
  // dangling element-target events
132
179
  for (const t of eventTargets) {
133
180
  if (ELEMENT_TARGET_ACTIONS.has(t.action)) {
134
- const cleaned = t.target.replace(/^#?w-/, "");
135
- if (!ids.has(t.target) && !ids.has(cleaned)) {
181
+ if (danglesId(t.target)) {
136
182
  warnings.push(`event on "${t.from}" action="${t.action}" target="${t.target}" does not match any element id.`);
137
183
  }
138
184
  }
185
+ else if (t.action === "set_field_value" && /^#?w-/.test(t.target) && danglesId(t.target)) {
186
+ // set_field_value target is a field_name OR an element id; only an explicit
187
+ // element ref (w- prefix) can dangle — a bare field_name is not an id.
188
+ warnings.push(`event on "${t.from}" action="set_field_value" target="${t.target}" looks like an element id but matches none.`);
189
+ }
190
+ }
191
+ // dangling option-level event targets (specials.options[].events_option promoId)
192
+ for (const t of optionTargets) {
193
+ if (danglesId(t.target)) {
194
+ warnings.push(`option event on "${t.from}" type="${t.kind}" promoId="${t.target}" does not match any element id.`);
195
+ }
196
+ }
197
+ // dangling survey/field cross-wiring
198
+ for (const r of connectRefs) {
199
+ if (danglesId(r.target)) {
200
+ warnings.push(`"${r.from}" specials.${r.key}="${r.target}" does not match any element id.`);
201
+ }
202
+ }
203
+ // field_name uniqueness WITHIN each form — duplicate names collide in the
204
+ // submitted data. (A nested form is its own data scope, so stop at one.)
205
+ const collectFieldNames = (n, acc) => {
206
+ if (!n || !Array.isArray(n.children))
207
+ return;
208
+ for (const c of n.children) {
209
+ if (!c || typeof c !== "object")
210
+ continue;
211
+ if (c.type === "form")
212
+ continue;
213
+ const fn = c.specials?.field_name;
214
+ if (typeof fn === "string" && fn.trim() !== "")
215
+ acc.push(fn.trim());
216
+ collectFieldNames(c, acc);
217
+ }
218
+ };
219
+ for (const form of forms) {
220
+ const names = [];
221
+ collectFieldNames(form, names);
222
+ const counts = new Map();
223
+ for (const fn of names)
224
+ counts.set(fn, (counts.get(fn) || 0) + 1);
225
+ for (const [fn, count] of counts) {
226
+ if (count > 1) {
227
+ warnings.push(`form "${form.id ?? "?"}": field_name "${fn}" used ${count} times — inputs in one form need a unique field_name (data collides on submit).`);
228
+ }
229
+ }
139
230
  }
140
231
  // 3) Layout bounds — flag children that fall off their container's canvas (a
141
232
  // common cause of "off-center / misaligned" pages). Warnings only.
package/dist/webcake.js CHANGED
@@ -129,11 +129,15 @@ export async function createPage(config, name, source, orgId) {
129
129
  const previewPath = data?.preview_url;
130
130
  const app = config.appBase;
131
131
  if (!res.ok || !pageId) {
132
+ // The backend's failure envelope is { success:false, message } on 422; auth
133
+ // plugs return plain-text 401/403 (json is null). Surface the real reason
134
+ // instead of a bare status so the user/LLM sees e.g. "Page not found…".
135
+ const backendMsg = json?.message ?? json?.reason ?? (json ? undefined : text.slice(0, 200));
132
136
  return {
133
137
  ok: false,
134
138
  status: res.status,
135
139
  raw: json ?? text.slice(0, 600),
136
- error: `Backend returned ${res.status}${pageId ? "" : " (no page_id in response)"}`,
140
+ error: `Backend returned ${res.status}${backendMsg ? `: ${backendMsg}` : pageId ? "" : " (no page_id in response)"}`,
137
141
  };
138
142
  }
139
143
  return {
@@ -224,7 +228,13 @@ export async function updatePageSource(config, pageId, source) {
224
228
  const pageIdOut = data?.page_id;
225
229
  const app = config.appBase;
226
230
  if (!res.ok || !pageIdOut) {
227
- return { ok: false, status: res.status, raw: json ?? text.slice(0, 600), error: `Backend returned ${res.status}` };
231
+ const backendMsg = json?.message ?? json?.reason ?? (json ? undefined : text.slice(0, 200));
232
+ return {
233
+ ok: false,
234
+ status: res.status,
235
+ raw: json ?? text.slice(0, 600),
236
+ error: `Backend returned ${res.status}${backendMsg ? `: ${backendMsg}` : ""}`,
237
+ };
228
238
  }
229
239
  return {
230
240
  ok: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "MCP server exposing Webcake landing-page element schemas + AI usage hints, and persisting LLM-generated page sources to a Webcake backend.",
5
5
  "type": "module",
6
6
  "bin": {