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 +10 -3
- package/dist/index.js +5 -2
- package/dist/library.js +499 -93
- package/dist/page-schema.json +1 -0
- package/dist/smoke.js +57 -0
- package/dist/validate.js +95 -4
- package/dist/webcake.js +12 -2
- package/package.json +1 -1
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
|
-
|
|
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 (
|
|
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
|
|
20
|
-
copy: "Copy
|
|
21
|
-
collapse: "Collapse/expand. target = id.",
|
|
22
|
-
set_field_value: "Set a form field value. target = field_name
|
|
23
|
-
back_to: "Go back. target =
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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
|
|
182
|
-
|
|
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/
|
|
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: {
|
|
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:
|
|
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: {
|
|
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
|
-
|
|
226
|
-
submit_success: "
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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.
|
|
238
|
-
|
|
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: {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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": {
|
|
263
|
-
|
|
264
|
-
|
|
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": {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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 |
|
|
279
|
-
startTime: "ISO start (
|
|
280
|
-
|
|
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
|
-
|
|
294
|
-
multiOption: "boolean — allow multiple
|
|
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": {
|
|
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"
|
|
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
|
|
package/dist/page-schema.json
CHANGED
|
@@ -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", "
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|