webcake-landing-mcp 1.0.2 → 1.0.3
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/library.js +450 -74
- package/dist/page-schema.json +1 -0
- package/dist/validate.js +6 -1
- 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/library.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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
9
|
export const CLICK_ACTIONS = {
|
|
10
10
|
none: "Do nothing.",
|
|
11
11
|
open_link: "Open a URL. target = URL (often with a `target`/`blank` flag for new tab).",
|
|
@@ -17,17 +17,20 @@ export const CLICK_ACTIONS = {
|
|
|
17
17
|
show_hide_element: "Toggle element visibility. target = element id.",
|
|
18
18
|
change_tab: "Switch tab. target = id.",
|
|
19
19
|
lightbox: "Open image in lightbox. target = image id/url.",
|
|
20
|
-
copy: "Copy
|
|
20
|
+
copy: "Copy to clipboard. target = the text; OR an element id when copyType='elementValue'.",
|
|
21
21
|
collapse: "Collapse/expand. target = id.",
|
|
22
22
|
set_field_value: "Set a form field value. target = field_name, plus set_value.",
|
|
23
|
-
back_to: "Go back. target =
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
back_to: "Go back in browser history (history.back()). target = none.",
|
|
24
|
+
share: "Share the current page URL. target = platform name: 'Facebook'|'Twitter'|'Custom'.",
|
|
25
|
+
play_audio: "Play audio. target = audio file URL (NOT an element id).",
|
|
26
|
+
stop_audio: "Stop audio. target = the same audio file URL (NOT an element id).",
|
|
27
|
+
open_sms: "Send SMS. target = phone number; optional smsBody for the message body.",
|
|
28
|
+
send_email: "Open mail client. target = email address (mailto:).",
|
|
29
|
+
download_file: "Download a file. target = file URL; optional nameFile overrides filename.",
|
|
30
|
+
close_webview: "Close a Facebook/Messenger in-app webview. target = none.",
|
|
28
31
|
open_cart: "Open cart.",
|
|
29
32
|
add_to_cart: "Add product to cart. target = product id.",
|
|
30
|
-
open_app: "Open chat/app.
|
|
33
|
+
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.",
|
|
31
34
|
change_color: "Change color.",
|
|
32
35
|
custom_js: "Run custom JS.",
|
|
33
36
|
};
|
|
@@ -37,7 +40,6 @@ export const HOVER_ACTIONS = {
|
|
|
37
40
|
change_text_color: "Change text color on hover.",
|
|
38
41
|
change_underline: "Underline on hover.",
|
|
39
42
|
change_overline: "Overline on hover.",
|
|
40
|
-
change_image: "Swap image on hover.",
|
|
41
43
|
animation_hover: "Play a hover animation.",
|
|
42
44
|
show_hide_element: "Reveal/hide a target element on hover.",
|
|
43
45
|
};
|
|
@@ -53,6 +55,12 @@ export const LIBRARY = {
|
|
|
53
55
|
custom_class: "extra css class; 'fixed'/'footer' influence role detection.",
|
|
54
56
|
imageCompression: "boolean — compress background images.",
|
|
55
57
|
video_background_thumbnail: "thumbnail for a video background.",
|
|
58
|
+
pageLoadEvent: "string — pubsub event name the section waits for before becoming visible (conditional show on page-load event).",
|
|
59
|
+
pageLoadEventDelay: "number (ms) — delay after pageLoadEvent fires before showing.",
|
|
60
|
+
loadDelayMultiplier: "number — multiplier applied to pageLoadEventDelay.",
|
|
61
|
+
afterPageLoadEvent: "string — pubsub event name that triggers hiding the section after it was shown.",
|
|
62
|
+
afterPageLoadEventDelay: "number (ms) — delay before hiding after afterPageLoadEvent fires.",
|
|
63
|
+
afterLoadDelayMultiplier: "number — multiplier applied to afterPageLoadEventDelay.",
|
|
56
64
|
},
|
|
57
65
|
},
|
|
58
66
|
"dynamic_page": {
|
|
@@ -63,15 +71,29 @@ export const LIBRARY = {
|
|
|
63
71
|
},
|
|
64
72
|
group: {
|
|
65
73
|
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: {
|
|
74
|
+
summary: "Groups children so they move and position together (position:absolute). In cart contexts can also act as a product variation selector group.",
|
|
75
|
+
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.",
|
|
76
|
+
keySpecials: {
|
|
77
|
+
sprod: "object {id: string} — product reference for variation selector mode. Set to bind this group to a specific product.",
|
|
78
|
+
ctype: "'field' | 'atc' — context type: 'field' means the group acts as a form field selector, 'atc' means add-to-cart trigger.",
|
|
79
|
+
sprod_attr: "string — product attribute name this group targets (e.g. 'Color', 'Size').",
|
|
80
|
+
sprod_val: "string — attribute value to pre-select.",
|
|
81
|
+
squantity: "number — quantity to add to cart (read by the add_to_cart event handler).",
|
|
82
|
+
svariant: "string — variant id override for add-to-cart.",
|
|
83
|
+
},
|
|
69
84
|
},
|
|
70
85
|
grid: {
|
|
71
86
|
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: {
|
|
87
|
+
summary: "Grid layout; config.column / config.row; children are grid-item. Can bind to an external dataset and paginate.",
|
|
88
|
+
useWhen: "Repeating cards in a regular N-column grid (features, team, gallery of cards). Use datasetId to drive content from a dataset.",
|
|
89
|
+
keySpecials: {
|
|
90
|
+
column: "(config) number of columns.",
|
|
91
|
+
row: "(config) number of rows.",
|
|
92
|
+
pagination: "(config) 0 = none | 1 = 'see more' button | 2 = auto-carousel/slide mode.",
|
|
93
|
+
timeSlide: "(config) auto-slide interval in ms when pagination=2.",
|
|
94
|
+
datasetId: "string — bind grid to an external dataset (each row renders one grid-item clone).",
|
|
95
|
+
attributeId: "(on child grid-item specials) string — bind that child element to a specific dataset column by attribute ID.",
|
|
96
|
+
},
|
|
75
97
|
},
|
|
76
98
|
"grid-item": {
|
|
77
99
|
type: "grid-item", category: "layout", container: true,
|
|
@@ -81,21 +103,37 @@ export const LIBRARY = {
|
|
|
81
103
|
},
|
|
82
104
|
carousel: {
|
|
83
105
|
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: {
|
|
106
|
+
summary: "Horizontal slider; config.slideWidth; children are slide. Supports autoplay, center mode, dataset binding.",
|
|
107
|
+
useWhen: "Testimonials, screenshots, or any swipeable set of slides. Use datasetId to drive slides from a dataset.",
|
|
108
|
+
keySpecials: {
|
|
109
|
+
slideWidth: "(config) width of each slide in px.",
|
|
110
|
+
centerMode: "(config) boolean — center the active slide with partial neighbors visible.",
|
|
111
|
+
slideToShow: "(config) number of slides visible at once.",
|
|
112
|
+
infinity: "(config) boolean — infinite loop.",
|
|
113
|
+
showNavigation: "(config) boolean — show prev/next arrow buttons.",
|
|
114
|
+
delayTimeMs: "(config) number (ms) — global autoplay interval between slides.",
|
|
115
|
+
autoplayMode: "(config) boolean | 'auto' — enable autoplay.",
|
|
116
|
+
datasetId: "string — bind carousel to an external dataset (each row renders one slide clone).",
|
|
117
|
+
},
|
|
87
118
|
},
|
|
88
119
|
slide: {
|
|
89
120
|
type: "slide", category: "layout", container: true,
|
|
90
121
|
summary: "One slide inside a carousel (movable:false).",
|
|
91
122
|
useWhen: "Only as a direct child of carousel.",
|
|
92
|
-
keySpecials: {
|
|
123
|
+
keySpecials: {
|
|
124
|
+
src: "string (URL) — slide background image (built into a CSS background, same pattern as image-block).",
|
|
125
|
+
resize: "number — background-image crop behavior on resize.",
|
|
126
|
+
},
|
|
93
127
|
},
|
|
94
128
|
popup: {
|
|
95
129
|
type: "popup", category: "layout", container: true,
|
|
96
130
|
summary: "Overlay popup. Hidden by default; opened/closed by events targeting its id.",
|
|
97
131
|
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: {
|
|
132
|
+
keySpecials: {
|
|
133
|
+
src: "string (URL) — background image (built into a CSS background, same pattern as image-block).",
|
|
134
|
+
resize: "number — background-image crop behavior on resize.",
|
|
135
|
+
video_background_thumbnail: "string (URL) — video thumbnail; renders a .video-background div for a video background.",
|
|
136
|
+
},
|
|
99
137
|
example: {
|
|
100
138
|
id: "popthanks", type: "popup",
|
|
101
139
|
properties: { name: "Thank you", movable: false, sync: true },
|
|
@@ -119,11 +157,19 @@ export const LIBRARY = {
|
|
|
119
157
|
// ---------------- content ----------------
|
|
120
158
|
"text-block": {
|
|
121
159
|
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.",
|
|
160
|
+
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
161
|
useWhen: "Any headline, paragraph, label. Use tag h1/h2 for headings, p for body. Style via responsive.styles (fontSize, color, fontWeight, textAlign).",
|
|
124
162
|
keySpecials: {
|
|
125
|
-
text: "string — the visible text; may include inline HTML (<b>, <br>, <span style>…).",
|
|
163
|
+
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
164
|
tag: "p | h1 | h2 | h3 | h4 | h5 | h6 | span | div.",
|
|
165
|
+
isFormula: "boolean — enable formula/computed numeric mode.",
|
|
166
|
+
formula: "string — formula expression evaluated to produce a numeric value (used when isFormula=true).",
|
|
167
|
+
fixed: "number — decimal places to display when isFormula=true.",
|
|
168
|
+
isTextParams: "boolean — populate text from a URL query parameter instead of specials.text.",
|
|
169
|
+
textParams: "string — URL query parameter name to read (used when isTextParams=true).",
|
|
170
|
+
isFormat: "boolean — apply a date format to template date variables.",
|
|
171
|
+
format: "string — dayjs format string (e.g. 'D/MM/YYYY') used when isFormat=true.",
|
|
172
|
+
formParamSeparator: "string — separator between items when rendering {{formId__form_items}} lists.",
|
|
127
173
|
},
|
|
128
174
|
example: {
|
|
129
175
|
id: "headline1", type: "text-block",
|
|
@@ -141,7 +187,7 @@ export const LIBRARY = {
|
|
|
141
187
|
summary: "Bulleted list. specials.text is a string of <li>…</li> items.",
|
|
142
188
|
useWhen: "Feature checklists, benefit lists. One <li> per bullet.",
|
|
143
189
|
keySpecials: {
|
|
144
|
-
text: "string of <li>item</li><li>item</li>… (no <ul> wrapper).",
|
|
190
|
+
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
191
|
iconSize: "(config) bullet icon size.", linePaddingLeft: "(config) text indent.",
|
|
146
192
|
},
|
|
147
193
|
},
|
|
@@ -149,7 +195,12 @@ export const LIBRARY = {
|
|
|
149
195
|
type: "image-block", category: "content", container: false,
|
|
150
196
|
summary: "Image. The editor renders the image from specials.src. config.overlay tints it.",
|
|
151
197
|
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: {
|
|
198
|
+
keySpecials: {
|
|
199
|
+
src: "image URL — REQUIRED. Use https://placehold.co/WxH (matching width×height) if you don't have a real image.",
|
|
200
|
+
resize: "number — image crop behavior on resize; a value other than 300 triggers keep_solution (no-crop) mode.",
|
|
201
|
+
enable_background_compare: "boolean — show a before/after image-comparison slider (companion config.backgroundCompare holds the second image).",
|
|
202
|
+
overlay: "(config) overlay color rgba(...).",
|
|
203
|
+
},
|
|
153
204
|
example: {
|
|
154
205
|
id: "hero_img", type: "image-block",
|
|
155
206
|
properties: { name: "Image Block", movable: true, sync: true },
|
|
@@ -175,11 +226,18 @@ export const LIBRARY = {
|
|
|
175
226
|
},
|
|
176
227
|
button: {
|
|
177
228
|
type: "button", category: "content", container: false,
|
|
178
|
-
summary: "Clickable button. Label in specials.text; behavior in the events array.",
|
|
229
|
+
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
230
|
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
231
|
keySpecials: {
|
|
181
|
-
text: "button label
|
|
182
|
-
|
|
232
|
+
text: "button label — supports template variables (same set as text-block: {{today}}, {{cart_total_price}}, {{formId__fieldName}}, etc.).",
|
|
233
|
+
required: "boolean — gate by form validity.",
|
|
234
|
+
isTextParams: "boolean — populate label from a URL query parameter instead of specials.text.",
|
|
235
|
+
textParams: "string — URL query parameter name to read (used when isTextParams=true).",
|
|
236
|
+
isFormat: "boolean — apply a date format to template date variables.",
|
|
237
|
+
format: "string — dayjs format string (e.g. 'D/MM/YYYY') used when isFormat=true.",
|
|
238
|
+
formParamSeparator: "string — separator between items when rendering {{formId__form_items}} lists.",
|
|
239
|
+
isConnectSurvey: "boolean — link this button to a survey element for required-field validation before submit.",
|
|
240
|
+
connectedSurvey: "string — id of the survey element to validate when isConnectSurvey=true.",
|
|
183
241
|
},
|
|
184
242
|
example: {
|
|
185
243
|
id: "cta_main", type: "button",
|
|
@@ -194,27 +252,48 @@ export const LIBRARY = {
|
|
|
194
252
|
},
|
|
195
253
|
video: {
|
|
196
254
|
type: "video", category: "content", container: false,
|
|
197
|
-
summary: "Video player (YouTube/upload/
|
|
255
|
+
summary: "Video player (YouTube/Vimeo/upload/CDN). Use specials.typeVideo to select the source type.",
|
|
198
256
|
useWhen: "Demo or promo videos. Set specials.img to a poster placeholder (https://placehold.co/640x360) when there's no real thumbnail.",
|
|
199
|
-
keySpecials: {
|
|
257
|
+
keySpecials: {
|
|
258
|
+
typeVideo: "'youtube' | 'vimeo' | 'webcake' | 'upload' — video source type.",
|
|
259
|
+
video: "string — raw video URL (for upload/webcake/CDN types).",
|
|
260
|
+
id: "string — YouTube video ID (for typeVideo='youtube').",
|
|
261
|
+
video_cdn: "string — CDN video URL or identifier.",
|
|
262
|
+
img: "string — poster/thumbnail image URL — use a placeholder if none (https://placehold.co/640x360).",
|
|
263
|
+
autoReplay: "boolean — loop the video.",
|
|
264
|
+
showControl: "boolean — show player controls.",
|
|
265
|
+
hideRelated: "boolean — hide YouTube related videos at end.",
|
|
266
|
+
muteOnPlay: "boolean — mute audio when video plays.",
|
|
267
|
+
autoPlay: "boolean — autoplay on load.",
|
|
268
|
+
},
|
|
200
269
|
},
|
|
201
270
|
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: {
|
|
271
|
+
type: "gallery", category: "content", container: false,
|
|
272
|
+
summary: "Multi-image/video gallery with thumbnail strip. Content comes entirely from specials.media — this is NOT a container and has no children.",
|
|
273
|
+
useWhen: "Photo grids/galleries with several images or videos. No image API — fill specials.media with placeholder URLs (https://placehold.co/600x400).",
|
|
274
|
+
keySpecials: {
|
|
275
|
+
media: "array of image URLs or video objects {type:'video', linkVideo:'<url>', typeVideo:'youtube'|'upload'} — use placeholder URLs if no real images.",
|
|
276
|
+
allowZoom: "'off' | 'carousel' | 'lightbox' — (config) zoom/lightbox mode when clicking an image.",
|
|
277
|
+
showNavigation: "boolean — (config) show prev/next navigation arrows.",
|
|
278
|
+
thumbnailAutoplay: "number (ms) | 'off' — (config) auto-advance thumbnails every N ms, or 'off' to disable.",
|
|
279
|
+
thumbnailAutoplayRepeat: "boolean — (config) loop thumbnail autoplay.",
|
|
280
|
+
thumbnailPosition: "'top' | 'bottom' | 'left' | 'right' — (config) position of the thumbnail strip relative to the main image.",
|
|
281
|
+
thumbnailWidth: "number — (config) width of each thumbnail in px.",
|
|
282
|
+
thumbnailHeight: "number — (config) height of each thumbnail in px.",
|
|
283
|
+
distanceAmong: "number — (config) gap between thumbnails in px.",
|
|
284
|
+
},
|
|
206
285
|
},
|
|
207
286
|
"html-box": {
|
|
208
287
|
type: "html-box", category: "content", container: false,
|
|
209
|
-
summary: "Raw HTML embed.",
|
|
288
|
+
summary: "Raw HTML embed. specials.html holds the markup (rendered via v-html).",
|
|
210
289
|
useWhen: "Embedding third-party widgets or custom markup the standard elements can't express.",
|
|
211
|
-
keySpecials: {},
|
|
290
|
+
keySpecials: { html: "string — raw HTML content (the only content key; stored HTML-escaped, unescaped at render)." },
|
|
212
291
|
},
|
|
213
292
|
"editor-blog": {
|
|
214
293
|
type: "editor-blog", category: "content", container: false,
|
|
215
|
-
summary: "Long-form rich text / article body.",
|
|
294
|
+
summary: "Long-form rich text / article body. specials.html holds the rich-text markup.",
|
|
216
295
|
useWhen: "Blog/article content blocks.",
|
|
217
|
-
keySpecials: {},
|
|
296
|
+
keySpecials: { html: "string — rich-text HTML content (stored HTML-escaped, unescaped at render)." },
|
|
218
297
|
},
|
|
219
298
|
// ---------------- form & inputs ----------------
|
|
220
299
|
form: {
|
|
@@ -222,11 +301,41 @@ export const LIBRARY = {
|
|
|
222
301
|
summary: "Wraps inputs; on submit creates a lead/FormData. Pixel tracking configured here.",
|
|
223
302
|
useWhen: "Any lead-capture / contact / registration form. Put input/textarea/select/button inside its children.",
|
|
224
303
|
keySpecials: {
|
|
225
|
-
|
|
226
|
-
submit_success: "
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
304
|
+
form_type: "'login' | undefined — 'login' runs the gated access-key flow on submit instead of the normal lead-data API.",
|
|
305
|
+
submit_success: "1 | 2 — 1 = open the success popup (popup_target); 2 = redirect to redirect_url.",
|
|
306
|
+
popup_target: "string (popup id) — popup to open when submit_success=1.",
|
|
307
|
+
redirect_url: "string (URL) — destination when submit_success=2.",
|
|
308
|
+
target_url: "'_self' | '_blank' — window target for the redirect / payment callback.",
|
|
309
|
+
open_link_with_params: "boolean — merge the current page's URL search params into redirect_url.",
|
|
310
|
+
merge_sub_form_data: "boolean — pass the previous form's form_data_id as sub_form_id on redirect.",
|
|
311
|
+
extra_url: "string (URL) — post-submit app/bot/WhatsApp URL used by app_target modes other than botcake.",
|
|
312
|
+
app_target: "'botcake' | 'botcake_dynamic' | 'whatsapp' | 'mess_prefill' | 'tiktok_prefill' | 'line_prefill' | 'others' — which app to open after submit.",
|
|
313
|
+
wa_custom_text: "string — message template for WhatsApp/Messenger/TikTok/LINE prefill (supports {{field_name}} placeholders).",
|
|
314
|
+
line_OA_id: "string — LINE Official Account id (with app_target='line_prefill').",
|
|
315
|
+
botcake_dynamic_ref: "string — ref appended to the m.me URL when app_target='botcake_dynamic'.",
|
|
316
|
+
others_link_params: "array of {key: elementId, name: string} — field→URL-param mappings when app_target='others'.",
|
|
317
|
+
partnerServiceId: "string — sent as partner_service_id (partner/affiliate tracking).",
|
|
318
|
+
fb_event_type: "Facebook pixel standard event fired on submit (e.g. CompleteRegistration, Purchase, Lead, none).",
|
|
319
|
+
fb_conversion_value: "string — FB pixel conversion value.",
|
|
320
|
+
fb_tracking_currency: "string — currency for the FB conversion value (VND…).",
|
|
321
|
+
fb_custom_tracking: "string — extra custom FB pixel event name to fire on submit.",
|
|
322
|
+
tiktok_event_type: "TikTok pixel event fired on submit (e.g. CompleteRegistration, none).",
|
|
323
|
+
tiktok_conversion_value: "string — TikTok conversion value.",
|
|
324
|
+
tiktok_tracking_currency: "string — currency for the TikTok conversion value.",
|
|
325
|
+
event_name_custom: "string | 'none' — custom name fired via fbq('trackCustom') + gtag('event') on submit.",
|
|
326
|
+
ggc_id: "string — Google Ads conversion tag id (single-conversion mode).",
|
|
327
|
+
ggc_label: "string — Google Ads conversion label.",
|
|
328
|
+
ggc_v: "string|number — Google Ads conversion value.",
|
|
329
|
+
ggc_c: "string — Google Ads conversion currency override.",
|
|
330
|
+
google_conversion_mode: "'single_conversion' | 'multi_conversion' | 'none' — fire one or many Google Ads conversions per submit.",
|
|
331
|
+
ggc_list: "array of {ggc_id, ggc_label, ggc_v, ggc_c} — conversions for multi_conversion mode.",
|
|
332
|
+
multiForm: "boolean — mark this as a child form whose submit copies data into multiFormParent.",
|
|
333
|
+
multiFormParent: "string (form id) — parent form this child form syncs into.",
|
|
334
|
+
customArrangementSheet: "boolean — order the backend spreadsheet columns by sheetOrder.",
|
|
335
|
+
sheetOrder: "array of {id: string} — custom child-id order for sheet column arrangement.",
|
|
336
|
+
validate: "validation config (legacy).",
|
|
337
|
+
field_type: "form field config (legacy).",
|
|
338
|
+
"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
339
|
},
|
|
231
340
|
},
|
|
232
341
|
input: {
|
|
@@ -234,8 +343,21 @@ export const LIBRARY = {
|
|
|
234
343
|
summary: "Single-line input. specials.field_name is the submitted data column (REQUIRED & unique).",
|
|
235
344
|
useWhen: "Name/email/phone fields. Set field_type to text/email/phone/number.",
|
|
236
345
|
keySpecials: {
|
|
237
|
-
field_name: "REQUIRED unique data key.
|
|
238
|
-
|
|
346
|
+
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).",
|
|
347
|
+
field_placeholder: "placeholder text.",
|
|
348
|
+
field_type: "text | email | phone | number | postal_code | date — 'postal_code' enables the postcode-detect helper; 'date' renders a date input.",
|
|
349
|
+
required: "boolean.",
|
|
350
|
+
validate: "boolean — enable extra pattern validation (phone regex / postal-code check).",
|
|
351
|
+
validate_country: "string dial code (e.g. '84','1') used for phone validation; exported as country_code in the field list.",
|
|
352
|
+
phone_validator: "string regex — custom phone validation pattern (falls back to CONST.REGEX_PHONE_VALIDATOR).",
|
|
353
|
+
detectAddress: "boolean — activate Vietnamese address autocomplete (only when field_name='address', country '84', no country-select sibling).",
|
|
354
|
+
isFormula: "boolean — formula/computed mode (input becomes read-only, value = evaluated formula).",
|
|
355
|
+
formula: "string — JS expression with {{field_name}} placeholders, e.g. '{{price}} * {{qty}}'.",
|
|
356
|
+
fixed: "string|number — decimal places for the formula result ('0' = integer).",
|
|
357
|
+
isTextParams: "boolean — fill the value from a URL query parameter (name from el.name / field_name).",
|
|
358
|
+
isConnectSurvey: "boolean — input is hidden until a connected survey selects it (required is dropped while hidden).",
|
|
359
|
+
connectedSurvey: "string — id of the survey element this input is connected to.",
|
|
360
|
+
defaultVariationId: "string — default product variation id registered on the parent form (for non-quantity inputs).",
|
|
239
361
|
},
|
|
240
362
|
example: {
|
|
241
363
|
id: "in_phone", type: "input",
|
|
@@ -248,53 +370,306 @@ export const LIBRARY = {
|
|
|
248
370
|
runtime: {}, events: [],
|
|
249
371
|
},
|
|
250
372
|
},
|
|
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
|
-
|
|
373
|
+
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." } },
|
|
374
|
+
select: {
|
|
375
|
+
type: "select", category: "form", container: false,
|
|
376
|
+
summary: "Dropdown select. Options live in specials.options (NOT children).",
|
|
377
|
+
useWhen: "Pick one from a list.",
|
|
378
|
+
keySpecials: {
|
|
379
|
+
field_name: "REQUIRED unique data key.",
|
|
380
|
+
field_placeholder: "placeholder/label.",
|
|
381
|
+
default_value: "string (option id) to pre-select; 'default-none' = no pre-selection.",
|
|
382
|
+
defaultVariationId: "string — default product variation id registered on the parent form regardless of selection.",
|
|
383
|
+
defaultVariationQuantity: "number — quantity registered with defaultVariationId.",
|
|
384
|
+
ignoreOnHidden: "boolean — when CSS-hidden, remove this element's variations from the form total.",
|
|
385
|
+
isConnectSurvey: "boolean — link to a survey for conditional show/hide.",
|
|
386
|
+
connectedSurvey: "string — id of the connected survey.",
|
|
387
|
+
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.",
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
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." } },
|
|
391
|
+
"checkbox-group": {
|
|
392
|
+
type: "checkbox-group", category: "form", container: true,
|
|
393
|
+
summary: "Multiple checkboxes. Choices live in specials.options (the multi-select group writes formData.checkbox[field_name]).",
|
|
394
|
+
useWhen: "Multi-select options.",
|
|
395
|
+
keySpecials: {
|
|
396
|
+
field_name: "REQUIRED unique data key (submitted as formData.checkbox[field_name], an array of checked option ids).",
|
|
397
|
+
required: "boolean — at least one checkbox must be checked.",
|
|
398
|
+
default_values: "array of option id strings checked by default on load.",
|
|
399
|
+
defaultVariationId: "string — default variation registered on the parent form.",
|
|
400
|
+
defaultVariationQuantity: "number — quantity for defaultVariationId.",
|
|
401
|
+
ignoreOnHidden: "boolean — when CSS-hidden, exclude this element's variations from the form total.",
|
|
402
|
+
isConnectSurvey: "boolean — link to a survey for conditional show/hide.",
|
|
403
|
+
connectedSurvey: "string — id of the connected survey.",
|
|
404
|
+
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.",
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
radio: {
|
|
408
|
+
type: "radio", category: "form", container: true,
|
|
409
|
+
summary: "Single-choice radio options. Choices live in specials.options (writes formData.radio[field_name]).",
|
|
410
|
+
useWhen: "Pick exactly one of a few. Common for payment-method selection.",
|
|
411
|
+
keySpecials: {
|
|
412
|
+
field_name: "REQUIRED unique data key (submitted as formData.radio[field_name], the single selected value).",
|
|
413
|
+
required: "boolean — one radio must be selected.",
|
|
414
|
+
default_value: "string (option id) | 'none' — option to pre-select; 'none' = nothing pre-selected.",
|
|
415
|
+
defaultVariationId: "string — default variation registered on the parent form.",
|
|
416
|
+
defaultVariationQuantity: "number — quantity for defaultVariationId.",
|
|
417
|
+
highlight: "boolean — give the selected radio item a background highlight.",
|
|
418
|
+
color_highlight: "CSS color — background applied to the selected item when highlight=true.",
|
|
419
|
+
ignoreOnHidden: "boolean — when hidden, exclude this element's variations from the form total.",
|
|
420
|
+
isConnectSurvey: "boolean — link to a survey for conditional show/hide.",
|
|
421
|
+
connectedSurvey: "string — id of the connected survey.",
|
|
422
|
+
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.",
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
address: {
|
|
426
|
+
type: "address", category: "form", container: false,
|
|
427
|
+
summary: "Province/District/Ward selector (multi-country).", useWhen: "Shipping/contact address.",
|
|
428
|
+
keySpecials: {
|
|
429
|
+
field_name: "REQUIRED unique data key.",
|
|
430
|
+
country: "string — numeric phone-prefix code (e.g. '84' VN, '1' US) selecting which province/district/commune data to load.",
|
|
431
|
+
use_search_box: "boolean — wrap the dropdowns in a typeahead SelectSearch widget.",
|
|
432
|
+
hidden_commune: "boolean — omit the commune (ward) tier entirely.",
|
|
433
|
+
hidden_province_list: "array of province id strings to exclude from the province dropdown.",
|
|
434
|
+
hidden_district_list: "array of district id strings to exclude from the district dropdown.",
|
|
435
|
+
hidden_commune_list: "array of commune id strings to exclude from the commune dropdown.",
|
|
436
|
+
required_commune: "boolean — require commune selection when communes exist for the district.",
|
|
437
|
+
hide_postal_code: "boolean (default true) — when false and the country supports it, expose a postal-code dropdown.",
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
"country-select": {
|
|
441
|
+
type: "country-select", category: "form", container: false,
|
|
442
|
+
summary: "Country picker. Auto-syncs sibling phone-number / postal-code / address fields.",
|
|
443
|
+
useWhen: "International forms.",
|
|
444
|
+
keySpecials: {
|
|
445
|
+
field_name: "REQUIRED unique data key.",
|
|
446
|
+
countries: "array of country dial-prefix codes (e.g. ['84','1','65']) shown in the dropdown and used to preload address data.",
|
|
447
|
+
autofill_phone: "boolean — listen to sibling phone_number inputs to auto-select the country by dial prefix and auto-prepend the dial code.",
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
"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." } },
|
|
451
|
+
"input-datetime": {
|
|
452
|
+
type: "input-datetime", category: "form", container: false,
|
|
453
|
+
summary: "Date/time picker.", useWhen: "Booking date, appointment.",
|
|
454
|
+
keySpecials: {
|
|
455
|
+
field_name: "REQUIRED unique data key.",
|
|
456
|
+
datetime_type: "'date' | 'time' | 'datetime-local' | 'time_slot_picker' — the HTML input type to render.",
|
|
457
|
+
limit_option_type: "'none' | 'dynamic' — 'dynamic' computes min/max from before_day/after_day offsets from today.",
|
|
458
|
+
before_day: "number — days/hours before today that stay selectable (when limit_option_type='dynamic').",
|
|
459
|
+
after_day: "number — days/hours after today that stay selectable (when limit_option_type='dynamic').",
|
|
460
|
+
sync_to_crm: "'none' | 'booking_crm' — 'booking_crm' validates against CRM availability (active only when shop_type=3).",
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
"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
464
|
signature: { type: "signature", category: "form", container: false, summary: "Hand-drawn signature pad.", useWhen: "Consent/contracts.", keySpecials: { field_name: "REQUIRED." } },
|
|
262
|
-
"verify-code": {
|
|
263
|
-
|
|
264
|
-
|
|
465
|
+
"verify-code": {
|
|
466
|
+
type: "verify-code", category: "form", container: false,
|
|
467
|
+
summary: "OTP / verification code field.", useWhen: "Phone/email verification.",
|
|
468
|
+
keySpecials: {
|
|
469
|
+
field_name: "REQUIRED unique data key for the OTP value.",
|
|
470
|
+
type_otp_input: "'one-input' | (other) — 'one-input' = single box accepting length_otp chars; otherwise multi-box auto-advance.",
|
|
471
|
+
length_otp: "number — number of OTP digits (typically 4–8); sent to the OTP endpoint.",
|
|
472
|
+
partner_id: "string — backend partner/tenant id sent to GET /partners/{partner_id}/get_otp (required for 'Get Code').",
|
|
473
|
+
field_type: "'postal_code' | absent — 'postal_code' switches validation from phone OTP to a postal-code regex.",
|
|
474
|
+
condition: "'limit_5' | 'limit_6' | 'custom' — postal-code regex selector (active when field_type='postal_code').",
|
|
475
|
+
pattern: "string regex — custom postal-code pattern when condition='custom'.",
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
"group-select": {
|
|
479
|
+
type: "group-select", category: "form", container: true,
|
|
480
|
+
summary: "Attribute/variant selector group (e.g. size+color+quantity); children are group-select-item.",
|
|
481
|
+
useWhen: "Product variants with quantity.",
|
|
482
|
+
keySpecials: {
|
|
483
|
+
field_name: "REQUIRED — variation slot key used when registering variations on the parent form.",
|
|
484
|
+
sprod: "object {id: string} — product reference for the child items ('custom' = read product id from a runtime DOM attr).",
|
|
485
|
+
alwayValue: "boolean (spelling exact, no trailing 's') — when true and the group is hidden, skip pushing its variation to the form.",
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
"group-select-item": {
|
|
489
|
+
type: "group-select-item", category: "form", container: false,
|
|
490
|
+
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.",
|
|
491
|
+
useWhen: "Child of group-select only.",
|
|
492
|
+
keySpecials: {
|
|
493
|
+
field_name: "REQUIRED unique data key (becomes 'quantity' when field_quantity=true).",
|
|
494
|
+
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.",
|
|
495
|
+
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.",
|
|
496
|
+
default_value: "string — pre-selected option value, or 'default-none'/empty for no default.",
|
|
497
|
+
required: "boolean — require selection when the item and its parent group are visible.",
|
|
498
|
+
},
|
|
499
|
+
},
|
|
265
500
|
// ---------------- commerce ----------------
|
|
266
|
-
"list-product": {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
501
|
+
"list-product": {
|
|
502
|
+
type: "list-product", category: "commerce", container: false,
|
|
503
|
+
summary: "Product list bound to the page store; clicking a card opens the popup-checkout overlay.",
|
|
504
|
+
useWhen: "Show purchasable products.",
|
|
505
|
+
keySpecials: {
|
|
506
|
+
select: "'product' | 'tag' | 'category' — which dimension to filter products by.",
|
|
507
|
+
type: "'expect' | 'except' — treat the expect*/except* arrays as an allowlist (expect) or denylist (except).",
|
|
508
|
+
expect: "array of product id strings to include (select='product', type='expect').",
|
|
509
|
+
except: "array of product id strings to exclude (select='product', type='except').",
|
|
510
|
+
expectCategory: "array of category ids to include (select='category').",
|
|
511
|
+
exceptCategory: "array of category ids to exclude (select='category').",
|
|
512
|
+
expectTags: "array of tag slugs to include (select='tag').",
|
|
513
|
+
exceptTags: "array of tag slugs to exclude (select='tag').",
|
|
514
|
+
format_title: "'default' | 'sku' | 'sku-name' | 'name-category' — product title composition.",
|
|
515
|
+
format_price: "'range' | 'discount' — 'discount' shows the % off badge when original > retail.",
|
|
516
|
+
direction: "'column' | (other) — 'column' = whole card is one click target; otherwise thumbnail + cart button each get handlers (and remain-quantity shows).",
|
|
517
|
+
numerical_order: "boolean — show numbered labels (01, 02 …) on thumbnails.",
|
|
518
|
+
remain_quantity_text: "string — low-stock label; {{value}} is replaced with the actual remaining count.",
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
"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: {} },
|
|
522
|
+
"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: {} },
|
|
523
|
+
"cart-quantity": {
|
|
524
|
+
type: "cart-quantity", category: "commerce", container: false,
|
|
525
|
+
summary: "Quantity stepper (+/-) that controls a field in the parent variation group; publishes <id>__quantity-change on each click.",
|
|
526
|
+
useWhen: "Per-variation quantity inside a cart/form group.",
|
|
527
|
+
keySpecials: {
|
|
528
|
+
field_name: "REQUIRED — identifies which field in the parent variation group this stepper controls.",
|
|
529
|
+
ignoreOnHidden: "boolean — when hidden, suppress this element's quantity contribution (calls _addIgnoreField/_removeIgnoreField on the parent vm).",
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
"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: {} },
|
|
533
|
+
table: {
|
|
534
|
+
type: "table", category: "commerce", container: false,
|
|
535
|
+
summary: "Data table rendered from a pre-fetched Google Sheets 2D array.",
|
|
536
|
+
useWhen: "Pricing/comparison/spec tables (data must be pre-loaded into specials).",
|
|
537
|
+
keySpecials: {
|
|
538
|
+
dataType: "0 | 1 — MUST be 1 to render anything; the renderer returns early when dataType != 1.",
|
|
539
|
+
source: "string — data source label (metadata only).",
|
|
540
|
+
sheetID: "string — Google Sheet document id (metadata only).",
|
|
541
|
+
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.",
|
|
542
|
+
},
|
|
543
|
+
},
|
|
272
544
|
// ---------------- marketing / dynamic ----------------
|
|
273
545
|
countdown: {
|
|
274
546
|
type: "countdown", category: "marketing", container: false,
|
|
275
547
|
summary: "Countdown timer (minute duration, fixed end time, or daily window).",
|
|
276
548
|
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
549
|
keySpecials: {
|
|
278
|
-
type: "minute |
|
|
279
|
-
startTime: "ISO start (
|
|
280
|
-
|
|
550
|
+
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').",
|
|
551
|
+
startTime: "ISO datetime string — start of the countdown window (used with type='duration' or 'daily').",
|
|
552
|
+
endTime: "ISO datetime string — end/deadline datetime (used with type='duration').",
|
|
553
|
+
dailyStart: "string 'HH:MM' — daily window open time (used with type='daily').",
|
|
554
|
+
dailyEnd: "string 'HH:MM' — daily window close time (used with type='daily').",
|
|
555
|
+
repeat: "boolean — restart the countdown when it reaches zero.",
|
|
556
|
+
customize: "boolean — enable custom message display when countdown finishes.",
|
|
557
|
+
customMessage: "string — message to display when countdown reaches zero (used when customize=true).",
|
|
558
|
+
showDay: "boolean — show the days segment.",
|
|
559
|
+
showSecond: "boolean — show the seconds segment.",
|
|
560
|
+
showText: "boolean — show unit labels (days/hours/minutes/seconds).",
|
|
561
|
+
language: "locale string for unit labels.",
|
|
562
|
+
customTranslation: "object — custom unit label overrides.",
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
timegroup: {
|
|
566
|
+
type: "timegroup", category: "marketing", container: false,
|
|
567
|
+
summary: "Live current date/time display rendered as text. Supports relative labels (today/yesterday/tomorrow) or fixed datetime, with multiple format presets.",
|
|
568
|
+
useWhen: "Show today's date, a relative date label, or a formatted timestamp on the page.",
|
|
569
|
+
keySpecials: {
|
|
570
|
+
currentTime: "'yesterday' | 'today' | 'nextday' | 'custom' — which date to display.",
|
|
571
|
+
formatType: "number 1–11 — selects a date/time format preset (1=short date, 2=long date, 3=time, 4=datetime, etc.).",
|
|
572
|
+
language: "string — locale for month/day names (e.g. 'vi', 'en').",
|
|
573
|
+
typeTimeGroup: "1 | 2 — 1=relative label (e.g. 'Today'), 2=fixed formatted datetime string.",
|
|
574
|
+
customTime: "string — ISO datetime string to display when currentTime='custom'.",
|
|
575
|
+
customDateJump: "number — day offset from customTime (positive=future, negative=past).",
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
"auto-number": {
|
|
579
|
+
type: "auto-number", category: "marketing", container: false,
|
|
580
|
+
summary: "Auto-incrementing number that counts up from startNumber to endNumber. Supports sync mode to mirror another element's value.",
|
|
581
|
+
useWhen: "Social-proof counters (views, orders, customers). Counts up on page load.",
|
|
582
|
+
keySpecials: {
|
|
583
|
+
startNumber: "number — value to start counting from.",
|
|
584
|
+
endNumber: "number — value to count up to.",
|
|
585
|
+
jumpNumber: "number — increment per animation step.",
|
|
586
|
+
timeDelayMs: "number (ms) — interval between steps (preferred key).",
|
|
587
|
+
timeDelay: "number (ms) — legacy interval key (use timeDelayMs instead).",
|
|
588
|
+
autoNumberMode: "'sync' | undefined — 'sync' mirrors the value of another auto-number element instead of counting independently.",
|
|
589
|
+
syncTarget: "string — element id to sync with when autoNumberMode='sync'.",
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
"random-number": {
|
|
593
|
+
type: "random-number", category: "marketing", container: false,
|
|
594
|
+
summary: "Displays a random number between startNumber and endNumber. Result is persisted in localStorage so it stays consistent across page reloads.",
|
|
595
|
+
useWhen: "Randomized social proof (e.g. 'X people viewed this').",
|
|
596
|
+
keySpecials: {
|
|
597
|
+
startNumber: "number — minimum value of the random range.",
|
|
598
|
+
endNumber: "number — maximum value of the random range.",
|
|
599
|
+
jumpNumber: "number — step granularity for the random value.",
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
notify: {
|
|
603
|
+
type: "notify", category: "marketing", container: false,
|
|
604
|
+
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.",
|
|
605
|
+
useWhen: "Social-proof popups showing recent purchases/signups.",
|
|
606
|
+
keySpecials: {
|
|
607
|
+
delay: "number (ms) — initial delay before the first notification appears.",
|
|
608
|
+
duration: "number (ms) — how long each notification is visible.",
|
|
609
|
+
delayStart: "number (ms) — pause between notifications.",
|
|
610
|
+
random: "boolean — randomize the order of notifications.",
|
|
611
|
+
soundMode: "boolean — play a sound when a notification appears.",
|
|
612
|
+
notifySoundLink: "string — URL to a custom notification sound file.",
|
|
613
|
+
dataType: "0 | 1 — 0=static data embedded in page, 1=Google Sheets.",
|
|
614
|
+
source: "string — data source label.",
|
|
615
|
+
sheetID: "string — Google Sheet ID (when dataType=1).",
|
|
616
|
+
dataSheet: "string — sheet tab name (when dataType=1).",
|
|
617
|
+
datasetId: "string — webcake dataset ID to pull notification data from.",
|
|
618
|
+
dataSetData: "array — pre-fetched dataset rows.",
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
"spin-wheel": {
|
|
622
|
+
type: "spin-wheel", category: "marketing", container: false,
|
|
623
|
+
summary: "Lucky-spin wheel with configurable prize segments and coupon codes. Can open a result popup after spinning and supports dataset-driven coupon lists.",
|
|
624
|
+
useWhen: "Gamified lead capture / promos. Users spin to win a coupon or prize.",
|
|
625
|
+
keySpecials: {
|
|
626
|
+
message: "array of strings — prize label for each wheel segment.",
|
|
627
|
+
spin: "object — spin configuration (segment colors, angles, etc.).",
|
|
628
|
+
code: "array of strings — coupon codes corresponding to each segment.",
|
|
629
|
+
dataType: "0 | 1 — 0=static codes, 1=dataset-driven codes.",
|
|
630
|
+
datasetId: "string — webcake dataset ID for coupon codes.",
|
|
631
|
+
codeDataset: "string — dataset column key for the coupon code.",
|
|
632
|
+
popup: "string — id of the popup to open after a successful spin (showing the prize).",
|
|
633
|
+
popupTurnOver: "string — id of the popup to open when the user has no turns left.",
|
|
634
|
+
showCoupon: "boolean — display the winning coupon code in the result popup.",
|
|
635
|
+
fontSize: "number — font size of segment labels.",
|
|
636
|
+
widthText: "number — text wrap width in segment labels.",
|
|
637
|
+
textAlign: "'left' | 'center' | 'right' — alignment of segment label text.",
|
|
638
|
+
assignCoupon: "boolean — assign a specific coupon to the user after spin.",
|
|
281
639
|
},
|
|
282
640
|
},
|
|
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
641
|
survey: {
|
|
289
642
|
type: "survey", category: "marketing", container: false,
|
|
290
643
|
summary: "Survey / image-choice question; each option submits a field.",
|
|
291
644
|
useWhen: "Quizzes, preference capture, image pickers.",
|
|
292
645
|
keySpecials: {
|
|
293
|
-
|
|
294
|
-
multiOption: "boolean — allow multiple
|
|
646
|
+
type: "'text-image' | 'text' | (other) — layout variant; controls whether option images render.",
|
|
647
|
+
multiOption: "boolean — allow selecting multiple options at once.",
|
|
648
|
+
limitOption: "number — max selectable options when multiOption=true (oldest is ejected past the limit).",
|
|
649
|
+
defaultOption: "array of option id strings to pre-select on load.",
|
|
650
|
+
required: "boolean — require at least one selection before the form submits.",
|
|
651
|
+
scrollAuto: "'yes' | (other) — horizontal auto-scroll strip mode (requires a form parent); also readable via config.scrollAuto.",
|
|
652
|
+
field_name: "string — form field key these selections map to in the parent form's variation data.",
|
|
653
|
+
connectedForm: "string — id of another form field element to receive the survey values (cross-form wiring; that field needs isConnectSurvey=true + connectedSurvey=this id).",
|
|
654
|
+
showInputQuantity: "boolean — render a +/- quantity stepper inside each option (uses each option's min_quantity/max_quantity).",
|
|
655
|
+
sprod_id: "string — product id when the survey acts as a WCart attribute selector.",
|
|
656
|
+
sprod_attr: "string — product attribute name this survey selects (e.g. 'Color').",
|
|
657
|
+
sprod_vals: "array of attribute value strings, one per option (indexed by option DOM order).",
|
|
658
|
+
imageHeight: "number (px) — option image height.",
|
|
659
|
+
imageWidth: "number (px) — option image width.",
|
|
660
|
+
alignment: "'center' | 'left' | 'right' — option alignment within the wrapper.",
|
|
661
|
+
selectedBackground: "CSS color — background of selected option cards.",
|
|
662
|
+
selectedBorder: "CSS color — border of selected option cards.",
|
|
663
|
+
hoveredBorder: "CSS color — border color on hover.",
|
|
664
|
+
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
665
|
},
|
|
296
666
|
},
|
|
297
|
-
"alertMessage": {
|
|
667
|
+
"alertMessage": {
|
|
668
|
+
type: "alertMessage", category: "marketing", container: false,
|
|
669
|
+
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.",
|
|
670
|
+
useWhen: "Never — this is not a user-facing element. Do not place this type in a page or popup.",
|
|
671
|
+
keySpecials: {},
|
|
672
|
+
},
|
|
298
673
|
};
|
|
299
674
|
export const GENERATION_GUIDE = `You are generating the JSON source of a Webcake landing page that the editor renders directly.
|
|
300
675
|
|
|
@@ -313,6 +688,7 @@ ELEMENT NODE (every element)
|
|
|
313
688
|
"responsive": { "desktop": { "config": {}, "styles": {} }, "mobile": { "config": {}, "styles": {} } },
|
|
314
689
|
"specials": { ...type-specific CONTENT... }, "runtime": {}, "events": [],
|
|
315
690
|
"children": [ ... ] } // children ONLY on container types
|
|
691
|
+
- 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
692
|
|
|
317
693
|
COORDINATE SYSTEM (critical)
|
|
318
694
|
- Absolute-positioning canvas (NOT flexbox). Children carry top/left/width/height in px (numbers).
|
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/validate.js
CHANGED
|
@@ -14,9 +14,14 @@ export const pageSchema = JSON.parse(readFileSync(new URL("./page-schema.json",
|
|
|
14
14
|
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
15
15
|
const validateSchema = ajv.compile(pageSchema);
|
|
16
16
|
// Actions whose `target` is expected to be an element id (vs a URL / text).
|
|
17
|
+
// Actions whose `target` is an existing element id (so a missing target is a
|
|
18
|
+
// dangling-reference warning). NOTE: play_audio/stop_audio are intentionally NOT
|
|
19
|
+
// here — their target is an audio file URL, not an element id (render_v4 event
|
|
20
|
+
// dispatcher), so checking them produced false-positive warnings. `collapse`
|
|
21
|
+
// targets an element id and IS checked.
|
|
17
22
|
const ELEMENT_TARGET_ACTIONS = new Set([
|
|
18
23
|
"open_popup", "close_popup", "scroll_to", "show_section", "hide_section",
|
|
19
|
-
"show_hide_element", "change_tab", "
|
|
24
|
+
"show_hide_element", "change_tab", "collapse",
|
|
20
25
|
]);
|
|
21
26
|
const TOP_LEVEL_TYPES = new Set(["section", "dynamic_page", "popup"]);
|
|
22
27
|
// Fixed canvas reference (matches library CANVAS) used for the layout/bounds check.
|
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.3",
|
|
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": {
|