webcake-storefront-mcp 1.33.1 → 1.34.0

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/api.js CHANGED
@@ -9,6 +9,7 @@ export class WebcakeCmsApi {
9
9
  sessionId;
10
10
  _adminToken;
11
11
  _cmsApiKey;
12
+ _siteBps = null;
12
13
  constructor({ baseUrl, token, siteId, sessionId }) {
13
14
  this.baseUrl = (baseUrl || "").replace(/\/$/, "");
14
15
  this.token = token;
@@ -101,6 +102,28 @@ export class WebcakeCmsApi {
101
102
  this.siteId = siteId;
102
103
  this._adminToken = null;
103
104
  this._cmsApiKey = null;
105
+ this._siteBps = null;
106
+ }
107
+ // ── Section template library (designer-made blocks behind the editor's template picker) ──
108
+ listTemplateCategories() {
109
+ return this.request("GET", `/api/v1/templates/categories`);
110
+ }
111
+ /** Templates (each { id, name, thumbnail, source: JSON string of ONE node, usually a SECTION }).
112
+ * Query: id = category id (omit for all), term, page, limit. */
113
+ listTemplates(query = {}) {
114
+ return this.request("GET", `/api/v1/templates`, { query });
115
+ }
116
+ /** The site's breakpoints ({ bpN: [minW, maxW] } from settings, which may be a JSON string),
117
+ * or null when unset/unreadable — callers then use the standard four. Cached per site. */
118
+ getSiteBreakpoints() {
119
+ this._siteBps ??= this.getSite()
120
+ .then((res) => {
121
+ const site = res?.data ?? res;
122
+ const settings = typeof site?.settings === "string" ? JSON.parse(site.settings || "{}") : site?.settings;
123
+ return settings?.breakpoints || null;
124
+ })
125
+ .catch(() => null);
126
+ return this._siteBps;
104
127
  }
105
128
  /** Update auth token (in-memory) */
106
129
  switchToken(token) {
@@ -186,8 +209,15 @@ export class WebcakeCmsApi {
186
209
  listPageContents(query) {
187
210
  return this.request("GET", `/api/v1/site/${this.siteId}/page_contents`, { query });
188
211
  }
189
- updatePageContent(params) {
190
- return this.request("POST", `/api/v1/site/${this.siteId}/page_contents/page_content`, { body: params });
212
+ /** One page's translation in one language: { content: { [elementId]: {...} } } ({} when none yet). Read-only. */
213
+ getPageContent(page_id, lang) {
214
+ return this.request("POST", `/api/v1/site/${this.siteId}/page_contents/page_content`, { body: { page_id, lang } });
215
+ }
216
+ /** Upsert one page translation (matched by page_id + language_code) — the editor's multilingual save. */
217
+ updatePageContent({ page_id, language_code, content, meta_tags }) {
218
+ return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/multilingual/update_page_contents`, {
219
+ body: { pages: [{ contents: [{ page_id, language_code, content, meta_tags, site_id: this.siteId }] }] },
220
+ });
191
221
  }
192
222
  listGlobalSections() {
193
223
  return this.request("GET", `/api/v1/site/${this.siteId}/global_sections`);
@@ -647,14 +677,25 @@ export class WebcakeCmsApi {
647
677
  listCustomers(query) {
648
678
  return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/customer/all`, { query });
649
679
  }
680
+ /** /api/v1/cms_function/:site_id/* sits behind builderx_api's CmsFunctionAuth plug, which only
681
+ * accepts the site's CMS admin token (db_collections/token) as the Bearer — the session JWT 401s. */
682
+ async cmsFunction(method, path, body) {
683
+ await this.fetchCmsTokens();
684
+ if (!this._adminToken)
685
+ throw new Error("This site has no CMS admin token, so cms_function routes are unavailable.");
686
+ return this.request(method, `/api/v1/cms_function/${this.siteId}${path}`, {
687
+ body,
688
+ headers: { Authorization: `Bearer ${this._adminToken}` },
689
+ });
690
+ }
650
691
  findCustomerById(id) {
651
- return this.request("GET", `/api/v1/cms_function/${this.siteId}/customer/identity/${id}`);
692
+ return this.cmsFunction("GET", `/customer/identity/${encodeURIComponent(id)}`);
652
693
  }
653
694
  findCustomerByPhone(phone) {
654
- return this.request("GET", `/api/v1/cms_function/${this.siteId}/customer/phone/${phone}`);
695
+ return this.cmsFunction("GET", `/customer/phone/${encodeURIComponent(phone)}`);
655
696
  }
656
697
  findCustomerByEmail(email) {
657
- return this.request("GET", `/api/v1/cms_function/${this.siteId}/customer/email/${email}`);
698
+ return this.cmsFunction("GET", `/customer/email/${encodeURIComponent(email)}`);
658
699
  }
659
700
  // ── Global Sources (cart, popup, overview, etc.) ──
660
701
  getSourceCart() {
@@ -704,7 +745,7 @@ export class WebcakeCmsApi {
704
745
  return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/automations/delete`, { body: { ids } });
705
746
  }
706
747
  sendMail(params) {
707
- return this.request("POST", `/api/v1/cms_function/${this.siteId}/application/automation/send_mail`, { body: params });
748
+ return this.cmsFunction("POST", "/application/automation/send_mail", params);
708
749
  }
709
750
  // ── App lifecycle (applications/subcriptions) ──
710
751
  /** Uninstall an app by its subscription id (the `id` from list_apps / get_app). */
@@ -1143,13 +1184,7 @@ export class WebcakeCmsApi {
1143
1184
  getCourseMembers(query) {
1144
1185
  return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/course-app/members`, { query });
1145
1186
  }
1146
- // ── Sale channels: sitemap + partner feeds + catalogs ──
1147
- sitemapSync(kind) {
1148
- return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/sale_channels/sitemap/sync_${kind}`, { body: {} });
1149
- }
1150
- sitemapRebuild(type) {
1151
- return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/sale_channels/sitemap/rebuild`, { body: { type } });
1152
- }
1187
+ // ── Sale channels: partner feeds + catalogs ──
1153
1188
  listPartnerFeeds(query) {
1154
1189
  return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/sale_channels/partner_feeds/all`, { query });
1155
1190
  }
@@ -23,7 +23,7 @@ export const SHARED_SPECIALS = {
23
23
  element_async: "Mark the element async/lazy (advanced — usually leave unset).",
24
24
  event_name_custom: "Custom analytics event name fired on interaction.",
25
25
  tabIndex: "Bind this element to a tab item (TAB-ITEM-…) so a tabs element shows/hides it.",
26
- text_animation_type: "Entrance animation for the element's text (none|…).",
26
+ text_animation_type: "Animated words in text/button: set to the type used by the <span class=\"animationText\" data-type …> inside specials.text (typed|typed2|clip|rotate3|animate.css name). See get_build_guide → Motion.",
27
27
  };
28
28
  // Generic layout keys every node's runtime.config may carry (documented in the build
29
29
  // guide); listed here so describeAttributes can flag them as 'generic, not element-specific'.
@@ -48,7 +48,7 @@ export const SHARED_KEY_DICT = {
48
48
  src: "Media URL (hosted).",
49
49
  image_alt: "Alt text (SEO / accessibility).",
50
50
  typographyTheme: "Apply a site typography preset.",
51
- text_animation_type: "Text entrance animation.",
51
+ text_animation_type: "Animated-words type (typed|typed2|clip|rotate3|…), paired with an animationText span in the text.",
52
52
  marker: "List marker style (none/dotList/numberList/imageList).",
53
53
  tabIndex: "Tab/focus order.",
54
54
  shapeType: "Shape kind (rectangle/etc.).",
@@ -157,7 +157,7 @@ export const ELEMENT_ATTRS = {
157
157
  "video-dataset": { specials: { autoRotateInMobile: "Rotate to landscape on mobile (boolean).", show_list_video_lesson: "Show the lesson playlist (boolean).", show_next_video_lesson: "Show the next-lesson button (boolean)." }, events: "none", bindings: ["lesson::lesson_video"], notes: "Video bound to a dataset field (course lesson video)." },
158
158
  rectangle: {
159
159
  specials: { shapeType: "Shape kind.", custom_css: "Raw CSS for the shape." },
160
- config: { mask: "Mask SVG/shape (clips the box into an icon).", maskId: "Mask reference.", custom_mask: "Custom mask SVG markup.", fixedPosition: "Pin/float the shape (bottom_right|…) — e.g. a floating badge.", animation: "Entrance animation." },
160
+ config: { mask: "Mask SVG/shape (clips the box into an icon).", maskId: "Mask reference.", custom_mask: "Custom mask SVG markup.", fixedPosition: "With position:'pinned' (+ style.position:'fixed'): where it floats — bottom_right|bottom_left|top_right|… — e.g. a floating badge. See get_build_guide → Sticky & pinned.", animation: "Entrance/attention animation { type, delay, duration, repeat }: type = animate.css name (fadeInUp, zoomIn, bounceIn, pulse…), delay/duration in s, repeat 1|2|3|'infinite'. See get_build_guide → Motion." },
161
161
  events: "click, hover — often used as a clickable card/background.",
162
162
  notes: "Coloured box / divider / background. Style via runtime.style (background, border*, boxShadow).",
163
163
  },
@@ -174,11 +174,11 @@ export const ELEMENT_ATTRS = {
174
174
  // ── layout ──
175
175
  section: {
176
176
  specials: { global: "Mark as a global/shared section (reused across pages).", custom_class: "CSS class.", layout: "Nested layout payload (advanced)." },
177
- config: { stickyPosition: "Make the section sticky: default | autobottom | none.", stickyTop: "Sticky offset from top (px).", showShadowWhenSticky: "Add a shadow when stuck (0/1).", video_background: "Section video background {background_display,…}.", pattern_overlay: "Overlay pattern image url.", apply_pattern_overlay: "Apply the overlay pattern (boolean).", nameLineClamp: "Clamp the section title to N lines." },
177
+ config: { position: "Scroll behaviour: default | sticky (sticks while scrolling — sticky header) | pinned (fixed to the viewport). See get_build_guide → Sticky & pinned.", stickyPosition: "With position:'sticky': autotop (default) | autobottom | default (both edges).", stickyTop: "Sticky offset from top (px, default 0).", stickyBottom: "Sticky offset from bottom (px, default 0).", showShadowWhenSticky: "Keep the section's boxShadow only while stuck (1, default) or always (0).", video_background: "Section video background {background_display,…}.", pattern_overlay: "Overlay pattern image url.", apply_pattern_overlay: "Apply the overlay pattern (boolean).", nameLineClamp: "Clamp the section title to N lines." },
178
178
  events: "none",
179
179
  notes: "Top-level band; centred 3-column grid. Children go in the centre column (new_section handles this). Make a header section sticky with config.stickyPosition; add a video/pattern background via config.",
180
180
  },
181
- container: { specials: { tabIndex: "Tab order.", custom_class: "CSS class.", custom_css: "Raw CSS." }, config: { maxWidth: "Max width.", maxWidthUnit: "Max-width unit.", fixedPosition: "Float/pin the box (e.g. bottom_right) — for back-to-top / chat bubbles.", fixedRight: "Pinned offset from right (px).", fixedBottom: "Pinned offset from bottom (px).", fixedLeft: "Pinned offset from left (px).", stickyPosition: "Sticky mode.", stickyTop: "Sticky offset from top (px).", stickyBottom: "Sticky offset from bottom (px).", pinnedPoint: "Section id where pinning starts.", unpinnedPoint: "Section id where pinning ends.", useAutoScroll: "Auto-scroll the box (boolean).", initMaxHeight: "Collapsed max-height (px).", isUseInitMaxHeight: "Animate from initMaxHeight (boolean).", maxHeight: "Expanded max-height (px).", animation: "Entrance animation." }, events: "click, hover — clickable groups are common.", notes: "Generic grid box; nest children with their own grid. Pin a floating button/sidebar via config.fixedPosition; sticky filters via stickyTop + pinnedPoint/unpinnedPoint." },
181
+ container: { specials: { tabIndex: "Tab order.", custom_class: "CSS class.", custom_css: "Raw CSS." }, config: { maxWidth: "Max width.", maxWidthUnit: "Max-width unit.", fixedPosition: "Float/pin the box (e.g. bottom_right) — for back-to-top / chat bubbles.", fixedRight: "Pinned offset from right (px).", fixedBottom: "Pinned offset from bottom (px).", fixedLeft: "Pinned offset from left (px).", stickyPosition: "Sticky mode.", stickyTop: "Sticky offset from top (px).", stickyBottom: "Sticky offset from bottom (px).", pinnedPoint: "Section id where pinning starts.", unpinnedPoint: "Section id where pinning ends.", useAutoScroll: "Auto-scroll the box (boolean).", initMaxHeight: "Collapsed max-height (px).", isUseInitMaxHeight: "Animate from initMaxHeight (boolean).", maxHeight: "Expanded max-height (px).", animation: "Entrance/attention animation { type, delay, duration, repeat }: type = animate.css name (fadeInUp, zoomIn, bounceIn, pulse…), delay/duration in s, repeat 1|2|3|'infinite'. See get_build_guide → Motion." }, events: "click, hover — clickable groups are common.", notes: "Generic grid box; nest children with their own grid. Pin a floating button/sidebar via config.fixedPosition; sticky filters via stickyTop + pinnedPoint/unpinnedPoint." },
182
182
  row: { events: "none", notes: "Simple horizontal wrapper." },
183
183
  tabs: { specials: { activeTab: "Default active tab index.", tabs: "Tab definitions [{id,name}].", navMode: "How tabs switch: toggle | …", showNav: "Show the tab nav (boolean).", showBullet: "Show bullets (boolean).", showDivider: "Show a divider (boolean)." }, config: { navPosition: "top|left|right|bottom.", alignNav: "Nav alignment.", nav_display_style: "Nav style." }, events: "tab — fires when the active tab changes.", notes: "Tabbed container; each tab maps to a tab item the content reacts to (e.g. account panels, product detail/description/reviews)." },
184
184
  collapse: { events: "none", notes: "Accordion container (collapse-item children)." },
@@ -288,7 +288,10 @@ export const ELEMENT_ATTRS = {
288
288
  "grid-category": { specials: { showCategories: "Categories to show [{id,...}] (else all).", isAutoCatchMenuLevelFirst: "Auto-pick top-level categories (boolean)." }, config: { columns: "Categories per row.", minimum_card_width: "Min card width (autofit, px).", sizeThumbnail: "{width,height} of the category image.", responsive: "fixed | autofit.", imageBorderRadius: "Image radius (px).", cardBackground: "Card background.", cardBorderRadius: "Card radius.", name_padding_y: "Padding around the name." }, events: "none", notes: "Lists product categories. Create categories first (create_product_category)." },
289
289
  "slider-category": { specials: { showCategories: "Categories to show [{id,...}].", on_hover: "Card hover effect (zoom-in|…)." }, config: { columns: "Visible categories.", minimum_card_width: "Min card width (px).", sizeThumbnail: "{width,height} of the image.", imageBorderRadius: "Image radius.", navSize: "Arrow size (px).", arrowColor: "Arrow colour." }, events: "none" },
290
290
  "customer-address": { events: "none", bindings: ["customer_address::full_name", "customer_address::address", "customer_address::phone_number", "customer_address::pdc", "customer_address::is_default"], notes: "Member saved addresses." },
291
- wishlist: { specials: { svg: "Icon SVG.", itemCount: "Badge count." }, config: { itemCountColor: "Badge text colour.", itemBackgroundColor: "Badge background.", itemCountFontSize: "Badge size.", itemCountFontWeight: "Badge weight.", itemCountBorderRadius: "Badge radius.", itemCountFontFamily: "Badge font." }, events: "click — opens the wishlist." },
291
+ wishlist: { specials: { svg: "Icon SVG.", itemCount: "Badge count." }, config: { itemCountColor: "Badge text colour.", itemBackgroundColor: "Badge background.", itemCountFontSize: "Badge size.", itemCountFontWeight: "Badge weight.", itemCountBorderRadius: "Badge radius.", itemCountFontFamily: "Badge font." }, events: "No built-in click: add an open_page event to your wishlist page (one holding a wishlist-container)." },
292
+ "wishlist-container": { notes: "Wishlist page block (store page). Children: ONE wishlist-item (template, repeated per saved product) + ONE wishlist-empty (shown when none). Editor default item = row of image-dataset (wishlist_item::product_image) | text-dataset name + price | button with event wishlist_remove_item; empty = text + button open_page back to the shop." },
293
+ "wishlist-item": { notes: "Per-product template inside wishlist-container — bind children to wishlist_item::product_name / product_price / product_original_price / product_image / product_short_description; a button with action wishlist_remove_item removes the product." },
294
+ "wishlist-empty": { notes: "Empty state inside wishlist-container (e.g. 'Chưa có sản phẩm yêu thích' + a button open_page to the shop)." },
292
295
  "favorite-icon": { config: { mask: "Heart icon SVG mask.", colorActive: "Colour when favourited." }, events: "click — toggle favourite (add_wishlist_product / wishlist_remove_item).", notes: "Heart toggle on a product card; pairs with the wishlist element in the header." },
293
296
  "bonus-items": { specials: { typeBonus: "combo_product | other." }, events: "none" },
294
297
  // ── navigation ──
@@ -322,7 +325,7 @@ export const ELEMENT_ATTRS = {
322
325
  "next-lesson-droppable": { events: "none", notes: "Next-lesson panel (course app)." },
323
326
  "list-lesson-droppable": { events: "none", notes: "Lessons droppable list (course app)." },
324
327
  // ── marketing / popups / misc widgets (rarely in page sources; built via apps/global_sources) ──
325
- popup: { specials: { effect: "Open animation (fade-in|slide|…).", timeAnim: "Animation duration (s).", trigger: "When it opens: load | delay | exit_intent | scroll.", delay: "Seconds before auto-open (trigger=delay).", onlyOnce: "Show once per visitor (boolean).", overlay: "Dim the page behind (boolean)." }, config: { popupHorizontalPosition: "left | center | right.", popupVerticalPosition: "top | center | bottom." }, events: "hide — fires when the popup closes.", notes: "Modal/overlay. NOT a page section — wrap a section in a popup node and save it as a global_source (component:'popup'). Open/close from any element via events open_popup/close_popup {popup_id}. See get_build_guide → Popups." },
328
+ popup: { specials: { effect: "Open animation (fade-in|slide|…).", timeAnim: "Animation duration (s).", openPopupAction: "Auto-open: none | openPopupWithTime (after timeOpenPopup s) | openPopupWhenScroll (when the element in `target` scrolls into view) | openWhenNonLogin (after timeOpenPopup s, only for logged-out visitors).", timeOpenPopup: "Delay in seconds for openPopupWithTime / openWhenNonLogin (default 0).", target: "Element id for openPopupWhenScroll.", page_ids: "Pages it auto-opens on (default ['all']).", countOpenPopup: "Max auto-opens per browser session (0/unset = unlimited)." }, config: { popupHorizontalPosition: "left | center | right.", popupVerticalPosition: "top | center | bottom." }, events: "hide — fires when the popup closes.", notes: "Modal/overlay. NOT a page section — wrap a section in a popup node and save it as a global_source (component:'popup'). Open/close from any element via events open_popup/close_popup {popup_id}. See get_build_guide → Popups." },
326
329
  notify: { specials: { type: "Toast/banner kind.", autoClose: "Auto-dismiss (boolean).", duration: "Visible time (s)." }, events: "none", notes: "Notification / toast banner (e.g. add-to-cart confirmation). Often referenced by add_to_cart's activeNotify." },
327
330
  "random-number": { specials: { min: "Lowest value.", max: "Highest value." }, events: "none", notes: "Displays a random number (minigame / fake-stock urgency widget)." },
328
331
  table: { specials: { datagrid_id: "Linked data-grid id (the table's data source).", tableItems: "Column definitions [{name,key}]." }, config: { titleBackground: "Header background.", titleColor: "Header text colour.", oddBackground: "Odd-row background.", evenBackground: "Even-row background." }, events: "none", notes: "Data table bound to a data grid (specs / comparison)." },
@@ -41,6 +41,16 @@ export const BINDING_DATASETS = {
41
41
  summary: "Sale/discount badge overlaid on a product card.",
42
42
  fields: ["product-overlay::product_sale", "product-overlay::product_discount", "product-overlay::product_total_sold", "product-overlay::product_url"],
43
43
  },
44
+ wishlist_item: {
45
+ name: "wishlist_item",
46
+ page_type: "store",
47
+ repeater_parents: ["wishlist-item"],
48
+ summary: "A product the shopper saved to their wishlist (children of wishlist-item).",
49
+ fields: [
50
+ "wishlist_item::product_name", "wishlist_item::product_price", "wishlist_item::product_original_price",
51
+ "wishlist_item::product_image", "wishlist_item::product_short_description",
52
+ ],
53
+ },
44
54
  cart_item: {
45
55
  name: "cart_item",
46
56
  page_type: "store",
@@ -292,7 +302,7 @@ export function describeBindingsCatalog() {
292
302
  return {
293
303
  note: "Dataset elements (text-dataset/image-dataset/…) and children of a repeater (grid-product, cart-items, post-list…) carry bindings:[{ id, name, target, ...meta }]. Build via new_element opts.bindings (ids auto-minted) — e.g. new_element('text-dataset',{ bindings:[{ target:'product::product_price', show_tax:true }] }). A target only resolves on a page whose type enables its dataset (store→use_store, member→use_member, blog→use_blog) — build_page handles the flag. The optional meta keys (name_style, prefix_content, value, attr_id, …) control HOW the value renders/combines — see meta_keys.",
294
304
  meta_keys: BINDING_META_KEYS,
295
- page_type_required: { store: ["product", "product-overlay", "cart_item", "order", "order_item", "category", "bonus_item", "promotion_item", "attr", "search_result"], member: ["customer", "customer_address"], blog: ["post", "blog"], any: ["form", "general_info", "agency"] },
305
+ page_type_required: { store: ["product", "product-overlay", "cart_item", "wishlist_item", "order", "order_item", "category", "bonus_item", "promotion_item", "attr", "search_result"], member: ["customer", "customer_address"], blog: ["post", "blog"], any: ["form", "general_info", "agency"] },
296
306
  repeater_context: REPEATER_CONTEXT,
297
307
  datasets: Object.values(BINDING_DATASETS).map((ds) => ({
298
308
  name: ds.name,
@@ -59,7 +59,7 @@ const CATEGORY_MEMBERS = {
59
59
  "detect-address", "postal-code", "country", "input-file", "input-number",
60
60
  "input-date", "input-search", "input-product-note", "otp-input", "rating-input",
61
61
  "two-point-range", "identity", "search-form", "search-droppable", "color-group",
62
- "switch",
62
+ "switch", "otp-method",
63
63
  ],
64
64
  commerce: [
65
65
  "grid-product", "slider-product", "product-gallery", "product-image-carousel",
@@ -69,7 +69,7 @@ const CATEGORY_MEMBERS = {
69
69
  "bonus-items", "flash-sale", "promotions", "promotions-short", "grid-category",
70
70
  "slider-category", "number-step", "warehouse", "warehouse-dataset",
71
71
  "customer-address", "reward-point", "referral-code", "lucky-wheel", "tee-form",
72
- "wishlist", "favorite-icon",
72
+ "wishlist", "favorite-icon", "wishlist-container", "wishlist-item", "wishlist-empty",
73
73
  ],
74
74
  navigation: [
75
75
  "menu", "menu-item", "menu-anchor-item", "submenu", "menu-droppable", "member-bar",
@@ -84,6 +84,7 @@ const CATEGORY_MEMBERS = {
84
84
  ],
85
85
  course: [
86
86
  "lesson-sidebar", "lesson-items", "next-lesson-droppable", "list-lesson-droppable",
87
+ "list-courses",
87
88
  ],
88
89
  };
89
90
  const CATEGORY_BY_TYPE = {};
@@ -122,6 +123,9 @@ const SUMMARY = {
122
123
  "grid-product": "Product grid (lists products from the store).",
123
124
  "slider-product": "Product slider/carousel.",
124
125
  "cart-items": "Shopping cart line items.",
126
+ "wishlist-container": "Wishlist page list: holds a wishlist-item template + a wishlist-empty state.",
127
+ "wishlist-item": "Template repeated per saved product (bind children to wishlist_item::…).",
128
+ "wishlist-empty": "Shown instead of the items when the wishlist is empty.",
125
129
  "cart-icon": "Cart icon with item count.",
126
130
  "quantity-input": "Quantity stepper for products.",
127
131
  payment: "Payment methods block.",
@@ -92,6 +92,21 @@ export const EVENT_ACTIONS = [
92
92
  { action: "lightbox", trigger: "click", category: "media", summary: "Open an image in a lightbox.", optional: ["lightboxLink"] },
93
93
  { action: "set_address_default", trigger: "click", category: "account", summary: "Mark a saved address as the default." },
94
94
  { action: "change_avatar", trigger: "click", category: "account", summary: "Change the member avatar." },
95
+ // From builderx_spa EventSetting.vue actions(); handled in the renderer (events.js, or
96
+ // common.ex / swiper.js / form.js / lesson-sidebar.js where noted).
97
+ { action: "filter", trigger: "click", category: "ui", summary: "Apply the product filters listed in `filters` (filter element ids; their selections become URL params) — e.g. an 'Apply' button on a filter panel. and_filters must all be chosen, or_filters at least one. redirect_type page|category (+ page_id / category_id) sends the shopper to that listing.", required: ["filters"], optional: ["and_filters", "or_filters", "or_filter_is_select_only_one", "product_id_auto_redirect", "redirect_type", "page_id", "category_id"], target_in_page: ["filters", "and_filters", "or_filters"] },
98
+ { action: "change_language", trigger: "click", category: "navigation", summary: "Language switcher: reopen the current page/product/category in another language. change_language = target language code (e.g. 'en').", required: ["change_language"] },
99
+ { action: "change_text_style", trigger: "click", category: "content", summary: "Restyle another text element (e.g. highlight the active option). text_style = style object.", required: ["target_id", "text_style"], target_in_page: ["target_id"] },
100
+ { action: "open_product_link", trigger: "click", category: "navigation", summary: "Open one of the product's custom links (target_custom_links = 1-based index), else default_url. target_href _self|_blank.", optional: ["target_custom_links", "default_url", "target_href"] },
101
+ { action: "add_bonus_product_to_cart", trigger: "click", category: "cart", summary: "Add a promotion's bonus/gift product to the cart (button inside a promotion bonus item).", optional: ["open_page", "productNoteElId"] },
102
+ { action: "send_botcake_mess", trigger: "click", category: "contact", summary: "Open a Botcake chatbot conversation.", required: ["botcake_ref_url"] },
103
+ { action: "delete_customer_address", trigger: "click", category: "account", summary: "Delete this saved address (inside a customer-address list on the member profile page)." },
104
+ { action: "course_submit", trigger: "click", category: "form", summary: "Submit a course assignment (uploads files). The customer must be logged in and have bought the course." },
105
+ { action: "auto_next_track", trigger: "click", category: "media", summary: "Course lesson sidebar switch: when on, auto-play the next lesson after a video ends (lesson-sidebar.js)." },
106
+ { action: "play_video", trigger: "tab", category: "media", summary: "When a slider reaches slide target_slide (1-based), play video element target_video (swiper.js).", required: ["target_slide", "target_video"], target_in_page: ["target_video"] },
107
+ { action: "close_search_droppable", trigger: "onenter", category: "ui", summary: "Close the search suggestions dropdown when Enter is pressed in the search box." },
108
+ { action: "bold", trigger: "hover", category: "hover", summary: "Make the text bold on hover (common.ex)." },
109
+ { action: "create_article", trigger: "submit", category: "form", summary: "Form submit creates a blog article from the form fields — user-submitted posts (form.js)." },
95
110
  ];
96
111
  const ACTION_BY_NAME = {};
97
112
  for (const a of EVENT_ACTIONS)
@@ -169,9 +184,9 @@ export function validateEvents(node, allIds) {
169
184
  warnings.push(`Event "${ev.action}" on "${where}" is missing required field "${f}".`);
170
185
  }
171
186
  for (const f of def.target_in_page || []) {
172
- const v = ev[f];
173
- if (v && !allIds.has(v))
174
- warnings.push(`Event "${ev.action}" on "${where}" targets missing element "${v}" (field ${f}).`);
187
+ for (const v of [].concat(ev[f] ?? [])) // a field may hold one id or a list (filter's filters)
188
+ if (v && !allIds.has(v))
189
+ warnings.push(`Event "${ev.action}" on "${where}" targets missing element "${v}" (field ${f}).`);
175
190
  }
176
191
  }
177
192
  return { errors, warnings };
@@ -507,6 +507,9 @@ export const createCartIcon = ({ svg, itemCount, config, style, children } = {})
507
507
  cart.specials.itemCount = itemCount ?? 0;
508
508
  if (children)
509
509
  cart.children = children || [];
510
+ // The renderer's cart-icon has no click handler of its own — opening the cart IS this event
511
+ // (SPA factory.js 9877f2c99). Without it a built cart icon does nothing when clicked.
512
+ cart.events = [{ id: 'EVENT-' + randomString(6), eventName: 'click', action: 'open_cart' }];
510
513
  return cart;
511
514
  };
512
515
  export const createWishlist = ({ svg, itemCount, config, children, style } = {}) => {
@@ -525,6 +528,22 @@ export const createWishlist = ({ svg, itemCount, config, children, style } = {})
525
528
  wistlist.children = children || [];
526
529
  return wistlist;
527
530
  };
531
+ // Wishlist page blocks. The editor has no factory for these (it seeds them from
532
+ // common/wishlist-container.json); the renderer clones the wishlist-item <template> once per
533
+ // saved product (assets/render/wishlist-container.js) and shows wishlist-empty when there are none.
534
+ const plainContainer = (prefix, type) => (opts = {}) => {
535
+ const el = cloneDeep(SKELETON);
536
+ el.id = prefix + randomString(8);
537
+ el.type = type;
538
+ el.runtime.style = { ...opts.style };
539
+ el.runtime.config = { ...opts.config };
540
+ el.children = opts.children || [];
541
+ el.specials = { ...opts.specials };
542
+ return el;
543
+ };
544
+ export const createWishlistContainer = plainContainer('WISHLIST-CONTAINER-', 'wishlist-container');
545
+ export const createWishlistItem = plainContainer('WISHLIST-ITEM-', 'wishlist-item');
546
+ export const createWishlistEmpty = plainContainer('WISHLIST-EMPTY-', 'wishlist-empty');
528
547
  export const createFavoriteIcon = (opts = {}) => {
529
548
  const favorite = cloneDeep(SKELETON);
530
549
  favorite.id = 'FAVORITE-ICON-' + randomString(8);
@@ -1760,3 +1779,24 @@ export const createLessonItems = (opts = {}) => {
1760
1779
  nextlesson.specials = { ...opts.specials };
1761
1780
  return nextlesson;
1762
1781
  };
1782
+ // Ported from builderx_spa factory.js (4ea6dd53e, e107a53e5).
1783
+ export const createOtpMethod = (opts = {}) => {
1784
+ const otpMethod = cloneDeep(SKELETON);
1785
+ otpMethod.id = 'OTP-METHOD-' + randomString(8);
1786
+ otpMethod.type = 'otp-method';
1787
+ otpMethod.children = opts.children || [];
1788
+ otpMethod.runtime.style = { ...opts.style };
1789
+ otpMethod.runtime.config = { ...opts.config };
1790
+ otpMethod.specials = { ...opts.specials };
1791
+ return otpMethod;
1792
+ };
1793
+ export const createListCourses = (opts = {}) => {
1794
+ const el = cloneDeep(SKELETON);
1795
+ el.id = 'LIST-COURSES-' + randomString(8);
1796
+ el.type = 'list-courses';
1797
+ el.runtime.style = { ...opts.style };
1798
+ el.runtime.config = { ...opts.config };
1799
+ el.children = opts.children || [];
1800
+ el.specials = { ...opts.specials };
1801
+ return el;
1802
+ };
@@ -4,7 +4,9 @@ export const BUILD_GUIDE = `# BuilderX page authoring guide
4
4
 
5
5
  ## DESIGN SYSTEM — lock this FIRST, every build (this is what makes free composition look DESIGNED)
6
6
  There are NO page templates here and NO seeded data — you COMPOSE every page from elements
7
- based on the user's actual goal. To make that composition look intentional (not a random pile
7
+ based on the user's actual goal. (One optional shortcut: a single section may start from the
8
+ designer section library — list_section_templates → add_section({ template }) — then restyle it
9
+ to the locked system below.) To make that composition look intentional (not a random pile
8
10
  of default elements), LOCK a small design system before you place anything, and reuse it on
9
11
  EVERY section and EVERY page:
10
12
  - PALETTE — from the site THEME matrix vars \`var(--color_RC)\` (see Colours below). Pick: page
@@ -347,6 +349,33 @@ create the globals — they embed into each page's source. If you later overwrit
347
349
  (delete_global_section by the section NODE id, then create once) to re-embed cleanly. Edit a global
348
350
  later with update_global_section_element(s) and it updates on every page at once.
349
351
 
352
+ STICKY & PINNED (runtime.config, on a section or a text/image/button/shape/container element):
353
+ position:"sticky" sticks while scrolling — the usual sticky header: the header section gets
354
+ { position:"sticky", stickyPosition:"autotop", stickyTop:0 }. stickyPosition:
355
+ autotop (default) | autobottom | default (both); offsets stickyTop / stickyBottom (px).
356
+ position:"pinned" fixed to the viewport — floating hotline/Zalo button, back-to-top. ALSO set
357
+ runtime.style.position:"fixed". fixedPosition: top_left | top_center | top_right |
358
+ bottom_left | bottom_center | bottom_right | center_left | center_right; offsets
359
+ fixedTop / fixedLeft / fixedRight / fixedBottom (px, default 20).
360
+ Anything else (or unset) = normal flow. stickyPosition alone does nothing without position:"sticky".
361
+
362
+ MOTION (text, image, button, shape, container) — tasteful: one entrance per block, short delays:
363
+ runtime.config.animation = { type:"fadeInUp", delay:0.2, duration:1, repeat:1 } // seconds; repeat 1|2|3|"infinite"
364
+ type = an animate.css name: fadeIn / fadeInUp / fadeInLeft / zoomIn / bounceIn / backInUp / slideInUp /
365
+ flipInX … (attention seekers pulse / heartBeat / tada / shakeX — "infinite" only on one small CTA).
366
+ Stagger a row of cards with delay 0, 0.15, 0.3 … so they cascade in.
367
+ ANIMATED WORDS (typing / rotating headline, text or button): wrap the words in specials.text with
368
+ <span class="animationText" data-type="typed" data-word="Cà phê\\nBánh ngọt\\nTrà sữa"
369
+ data-delay="0" data-duration="1" data-repeat="infinite">Cà phê</span>
370
+ and set specials.text_animation_type to the same type. data-type: typed | typed2 | clip | rotate3 (or an
371
+ animate.css name); data-word = the words to cycle, one per line.
372
+ HOVER feedback is an event: { eventName:"hover", action:"scale" | "transform_y" | "change_background" |
373
+ "change_text_color" | "bold" …, duration:0.2 } (duration = transition seconds, default 0.1).
374
+
375
+ TYPOGRAPHY PRESETS: the theme defines 9 text styles — use them so text follows the site theme:
376
+ style:{ font:"var(--font_N)", color:"var(--font_color_N)" } N: 0-5 = H1-H6, 6-8 = paragraph large/normal/small.
377
+ Don't also set fontSize/fontWeight/fontFamily on that node (they'd override the preset).
378
+
350
379
  ### HEADER must be RESPONSIVE — add a MOBILE MENUBAR, don't just let nav stack
351
380
  A header built as one horizontal row of nav links looks fine on desktop but on a phone the links
352
381
  squash or collapse into an ugly vertical pile. Build a header that carries TWO navs and swap them
@@ -373,7 +402,7 @@ A popup is a GLOBAL SOURCE, not a page section. Compose it from elements (there
373
402
  shortcut), then save it as a "popup" global source:
374
403
  1. Build the popup node: new_element("popup", { children:[…heading,text,form,close button…], style:{ width:480, background:"#fff", borderRadius:"12px" }, config:{ popupHorizontalPosition:"center", popupVerticalPosition:"center" }, specials:{ effect:"fade-in", timeAnim:0.5 } }).
375
404
  - SIZE + POSITION live in the popup's runtime style/config (width/height/background + popupHorizontalPosition/popupVerticalPosition), NOT in specials. createPopup seeds a centred-modal default.
376
- - TRIGGER (auto-open) lives in SPECIALS: \`openPopupAction:"openPopupWithTime"\` + \`timeOpenPopup:<seconds>\` for a delay; \`page_ids:[…]\` to limit which pages it shows on; \`effect\`/\`timeAnim\` for the animation. (There is no exit-intent/only-once flag in the node — those are app settings.)
405
+ - TRIGGER (auto-open) lives in SPECIALS: \`openPopupAction:"openPopupWithTime"\` + \`timeOpenPopup:<seconds>\` for a delay; \`page_ids:[…]\` to limit which pages it shows on; \`effect\`/\`timeAnim\` for the animation. Other triggers: \`openPopupWhenScroll\` + \`target:"<elementId>"\` (opens when that element scrolls into view), \`openWhenNonLogin\` (logged-out visitors only, after \`timeOpenPopup\`). \`countOpenPopup:<n>\` caps auto-opens per browser session (unset = every page view). There is no exit-intent trigger.
377
406
  - OVERLAY is NOT on the popup node — it's a field on the open_popup EVENT (popup_overlay:true).
378
407
  2. Save it: create_global_source({ component:"popup", source:{ sections:[<popupNode>] } }) -> returns its id.
379
408
  3. Open/close from any element via events: a button { action:"open_popup", popup_id:"<id>", popup_overlay:true };
@@ -4,11 +4,11 @@
4
4
  // their children in a CSS grid driven by runtime.config (grid / columns / rows) and each
5
5
  // child's runtime.config (columnStart/End, rowStart/End, constraintX/Y). The helpers here
6
6
  // produce that structure the same way the builder does, so generated pages render.
7
- import { buildElement, isKnownType, ELEMENT_TYPES } from "./catalog.js";
7
+ import { buildElement, isKnownType, ELEMENT_TYPES, CONTAINER_TYPE_SET } from "./catalog.js";
8
8
  import { randomString } from "./factory.js";
9
9
  import { validateEvents } from "./events.js";
10
10
  import { validateBindings } from "./bindings.js";
11
- import { BREAKPOINTS, genGridByBp, SECTION_CONTENT_COL_START, SECTION_CONTENT_COL_END, } from "./grid.js";
11
+ import { BREAKPOINTS, BREAKPOINT_KEYS, genGridByBp, SECTION_CONTENT_COL_START, SECTION_CONTENT_COL_END, } from "./grid.js";
12
12
  const clone = (o) => structuredClone(o);
13
13
  /** Parse a CSS px length ("52px") or a bare number (52) → number, else null. */
14
14
  function pxToNum(v) {
@@ -349,6 +349,25 @@ export function validatePage(source) {
349
349
  warnings.push(...r.warnings);
350
350
  }
351
351
  });
352
+ // Third pass (needs ancestry): structures the renderer silently mishandles. Warnings only.
353
+ const checkTree = (node, ancestors) => {
354
+ if (!node || typeof node !== "object")
355
+ return;
356
+ const type = node.type;
357
+ const kids = Array.isArray(node.children) ? node.children : [];
358
+ if (!ancestors.length && type && type !== "section" && type !== "popup")
359
+ warnings.push(`Top-level node "${node.id}" is a ${type}: pages render only sections at the top level — wrap it in a section.`);
360
+ if (kids.length && type && isKnownType(type) && !CONTAINER_TYPE_SET.has(type))
361
+ warnings.push(`"${node.id}" (${type}) has ${kids.length} child node(s), but ${type} is not a container — they won't render.`);
362
+ if (type === "submit-button" && !ancestors.some((t) => /form/.test(t)))
363
+ warnings.push(`Submit button "${node.id}" is not inside a form — it has nothing to submit.`);
364
+ if ((node.bindings || []).some((b) => b?.name === "wishlist_item") && !ancestors.includes("wishlist-item"))
365
+ warnings.push(`"${node.id}" binds wishlist_item::… outside a wishlist-item — those fields only resolve per saved product.`);
366
+ for (const c of kids)
367
+ checkTree(c, [...ancestors, type]);
368
+ };
369
+ for (const s of source.sections)
370
+ checkTree(s, []);
352
371
  return {
353
372
  valid: errors.length === 0,
354
373
  ...(errors.length ? { errors } : {}),
@@ -356,15 +375,39 @@ export function validatePage(source) {
356
375
  stats: { sections: source.sections.length, total_elements: total, element_types: typeCounts },
357
376
  };
358
377
  }
359
- /**
360
- * Expand one node's `runtime.{style,config}` into the per-breakpoint keys the storefront
361
- * renderer reads (bp1..bp4). Mirrors builderx_spa's syncBreakpoint: the authored
362
- * (desktop) values are copied onto every breakpoint. Sections additionally get their
363
- * centred 3-column grid recomputed per breakpoint via genGridByBp (the side-margin
364
- * widths shrink on smaller screens). Nodes already in breakpoint shape are left as-is,
365
- * so this is safe to run over a mixed source (e.g. add_section onto an existing page).
366
- */
367
- function expandNodeToBreakpoints(node) {
378
+ /** The site's breakpoints if they're a usable map that differs from the standard four, else null. */
379
+ function customBreakpoints(site) {
380
+ if (!site || typeof site !== "object")
381
+ return null;
382
+ const entries = Object.entries(site);
383
+ if (!entries.length || !entries.every(([, r]) => Array.isArray(r) && typeof r[0] === "number" && typeof r[1] === "number"))
384
+ return null;
385
+ const standard = entries.length === BREAKPOINT_KEYS.length &&
386
+ entries.every(([k, [lo, hi]]) => BREAKPOINTS[k] && BREAKPOINTS[k][0] === lo && BREAKPOINTS[k][1] === hi);
387
+ return standard ? null : site;
388
+ }
389
+ /** Re-key a freshly expanded node from bp1..bp4 onto the site's breakpoints. The renderer emits
390
+ * CSS only for the site's keys, and users can split/delete breakpoints in the editor (a split
391
+ * of bp1 at 1600 gives bp1:[1320,1600] + bp5:[1601,1e9]; a deleted name can be reused for a
392
+ * different range) — so match by WIDTH, never by name: each site breakpoint takes the standard
393
+ * breakpoint containing its min width; sections regenerate their grid for that width. */
394
+ function remapToSiteBreakpoints(node, site) {
395
+ const std = Object.entries(BREAKPOINTS);
396
+ const pick = (minW) => (std.find(([, [lo, hi]]) => minW >= lo && minW <= hi) || std[std.length - 1])[0];
397
+ const out = {};
398
+ for (const [key, [minW]] of Object.entries(site)) {
399
+ const from = node[pick(minW)];
400
+ if (!from)
401
+ continue;
402
+ out[key] = clone(from);
403
+ if (node.type === "section")
404
+ out[key].config.columns = clone(genGridByBp(minW).columns);
405
+ }
406
+ for (const k of BREAKPOINT_KEYS)
407
+ delete node[k];
408
+ Object.assign(node, out);
409
+ }
410
+ function expandNodeToBreakpoints(node, site) {
368
411
  const rt = node && node.runtime;
369
412
  if (rt && (rt.style || rt.config || rt.responsive)) {
370
413
  const baseConfig = { ...(rt.config || {}), loaded: true };
@@ -429,9 +472,11 @@ function expandNodeToBreakpoints(node) {
429
472
  node[bp] = { style, config };
430
473
  }
431
474
  delete node.runtime;
475
+ if (site)
476
+ remapToSiteBreakpoints(node, site);
432
477
  }
433
478
  for (const child of node.children || [])
434
- expandNodeToBreakpoints(child);
479
+ expandNodeToBreakpoints(child, site);
435
480
  return node;
436
481
  }
437
482
  /**
@@ -440,10 +485,11 @@ function expandNodeToBreakpoints(node) {
440
485
  * is removed. MUST be called before saving a page built with new_section/new_element —
441
486
  * otherwise the page renders with no styling or grid placement.
442
487
  */
443
- export function finalizeForRender(source) {
488
+ export function finalizeForRender(source, siteBreakpoints) {
444
489
  const sections = source && Array.isArray(source.sections) ? source.sections : [];
490
+ const custom = customBreakpoints(siteBreakpoints);
445
491
  for (const s of sections)
446
- expandNodeToBreakpoints(s);
492
+ expandNodeToBreakpoints(s, custom);
447
493
  return source;
448
494
  }
449
495
  export { ELEMENT_TYPES };
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.34.0",
4
+ "d": "14/10/2026",
5
+ "type": "Added",
6
+ "en": "New list_section_templates tool browses the designer-made section template library (categories, search by term, thumbnails) for starting a section…",
7
+ "vi": "Tool mới list_section_templates để duyệt thư viện mẫu section do designer tạo (theo danh mục, tìm kiếm bằng term, có thumbnail) làm điểm khởi đầu…"
8
+ },
9
+ {
10
+ "v": "1.33.2",
11
+ "d": "14/10/2026",
12
+ "type": "Added",
13
+ "en": "list_orders responses now include a status_name field alongside the numeric status.",
14
+ "vi": "Kết quả trả về của list_orders nay có thêm trường status_name bên cạnh status dạng số."
15
+ },
2
16
  {
3
17
  "v": "1.33.1",
4
18
  "d": "14/10/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Added",
27
41
  "en": "create_product, update_product, create_product_category, update_product_category, and create_blog_category accept a new seo object to write…",
28
42
  "vi": "create_product, update_product, create_product_category, update_product_category và create_blog_category nay nhận thêm đối tượng seo để ghi…"
29
- },
30
- {
31
- "v": "1.31.12",
32
- "d": "09/10/2026",
33
- "type": "Added",
34
- "en": "create_article and update_article accept a new seo object (page_title, meta_description, page_keywords, og_title, og_description, og_image) that…",
35
- "vi": "create_article và update_article nay nhận thêm đối tượng seo (page_title, meta_description, page_keywords, og_title, og_description, og_image) để…"
36
- },
37
- {
38
- "v": "1.31.11",
39
- "d": "11/09/2026",
40
- "type": "Changed",
41
- "en": "create_site and create_site_from_template now stamp the new site with by_ai: \"mcp\" (persisted to the backend sites.by_ai column), extending the…",
42
- "vi": "create_site và create_site_from_template nay đánh dấu site mới với by_ai: \"mcp\" (lưu vào cột sites.by_ai ở backend), mở rộng dấu hiệu AI-tạo vốn chỉ…"
43
43
  }
44
44
  ]
package/dist/server.js CHANGED
@@ -20,7 +20,7 @@ Tool discovery: the common tools (pages, builder, products, orders, blog, custom
20
20
 
21
21
  You can also BUILD pages: use get_build_guide, list_elements, get_element to learn the BuilderX component model, new_section/new_element to compose, validate_page to check, then build_page (dry_run first) to create. Publishing is site-level via publish_site.
22
22
 
23
- Build the WHOLE site by FREE COMPOSITION FROM ELEMENTS — there are NO page templates and NO seeded data. You compose every page from elements (new_section/new_element/new_row → build_page) based on the user's actual goal. Enrich EVERY page (home, category, product detail, cart, checkout, thank-you, …) to the same e-commerce standard with the same palette/header/footer. Make the Header and Footer GLOBAL (create_global_section type:"header"/"footer") so they're consistent on every page — don't inline them per page. For newsletter/promo offers, build a popup node (new_element "popup") and save it via create_global_source({component:"popup"}), then open it with an open_popup event. See get_build_guide for the design system, the per-page section archetypes, the global-section order rules, and the popup workflow.
23
+ Build the WHOLE site by FREE COMPOSITION FROM ELEMENTS — there are NO page templates and NO seeded data (a single section MAY start from the designer section library: list_section_templates → add_section({ template }), restyled to the brand). You compose every page from elements (new_section/new_element/new_row → build_page) based on the user's actual goal. Enrich EVERY page (home, category, product detail, cart, checkout, thank-you, …) to the same e-commerce standard with the same palette/header/footer. Make the Header and Footer GLOBAL (create_global_section type:"header"/"footer") so they're consistent on every page — don't inline them per page. For newsletter/promo offers, build a popup node (new_element "popup") and save it via create_global_source({component:"popup"}), then open it with an open_popup event. See get_build_guide for the design system, the per-page section archetypes, the global-section order rules, and the popup workflow.
24
24
 
25
25
  To make a generated site look real, also CREATE DATA so dataset bindings resolve: create_product_category + create_product (storefront), create_blog_category + create_article (blog). Get image URLs from search_images / upload_images first, then reference them. A good flow for a fresh site: create_site → create a few categories → create products in them → build_page (home + store/blog pages) → publish_site.
26
26
 
@@ -231,7 +231,13 @@ Two-step safety: dry_run=true (default) describes what will happen; dry_run=fals
231
231
  // is exactly that link (resolvePreviewUrl == link(): primary_domain wins, else subdomain).
232
232
  const res = await api.publishSite(preview_url ? { domain: preview_url } : {});
233
233
  const data = (res && res.data) || res;
234
- return { success: true, published_at: data && data.published_at, site: data && data.name, preview_url };
234
+ return {
235
+ success: true,
236
+ published_at: data && data.published_at,
237
+ site: data && data.name,
238
+ preview_url,
239
+ screenshots: "Homepage screenshots (desktop 1920_1080 + mobile 430_932) are captured in the background after a publish (at most once an hour). About a minute later get_site_info returns them in preview_img — open one with invoke_tool('read_image', { url }) to check the live result visually.",
240
+ };
235
241
  }));
236
242
  // ── Ingest reference HTML / URL into a blueprint ────────────────────────────
237
243
  function blueprintFromHtml(html) {
@@ -275,7 +275,7 @@ ONE page per site for type main (homepage) / error / maintain, and slugs are uni
275
275
  }
276
276
  }
277
277
  // Expand runtime -> bp1..bp4 so the saved source actually renders on the storefront.
278
- finalizeForRender(parsed);
278
+ finalizeForRender(parsed, await api.getSiteBreakpoints());
279
279
  // createPage saves the page AND its source in one call (source is required there).
280
280
  const created = await api.createPage({ name, source: parsed, ...(typeNum != null ? { type: typeNum } : {}) });
281
281
  const pageId = newPageId(created);
@@ -295,13 +295,64 @@ ONE page per site for type main (homepage) / error / maintain, and slugs are uni
295
295
  ...(metaWarning ? { warning: metaWarning } : {}),
296
296
  };
297
297
  }));
298
+ const templateRoot = (t) => {
299
+ try {
300
+ return JSON.parse(t.source || "null");
301
+ }
302
+ catch {
303
+ return null;
304
+ }
305
+ };
306
+ server.tool("list_section_templates", `Browse the designer-made section library (the editor's template picker): ready, styled blocks (hero, product showcase, testimonials, footer…) to start from instead of composing from scratch.
307
+ No args → categories. With category_id and/or term → templates { id, name, thumbnail, kind }. Look at a thumbnail with invoke_tool('read_image', { url }), then insert a kind:"section" template with add_section({ page_id, template:{ category_id, id } }) and restyle it to the brand.`, {
308
+ category_id: z.string().optional().describe("Category id (from the no-arg call)"),
309
+ term: z.string().optional().describe("Search template names"),
310
+ page: z.number().optional().describe("Page (default 1)"),
311
+ limit: z.number().optional().describe("Per page (default 12)"),
312
+ }, ({ category_id, term, page, limit }) => handle(async () => {
313
+ if (!category_id && !term) {
314
+ const res = await api.listTemplateCategories();
315
+ const cats = res?.templates ?? res?.data?.templates ?? res?.data ?? [];
316
+ return { categories: (Array.isArray(cats) ? cats : []).map((c) => ({ id: c.id, name: c.name })) };
317
+ }
318
+ const res = await api.listTemplates({ id: category_id, term, page: page || 1, limit: limit || 12 });
319
+ const data = res?.data ?? res;
320
+ return {
321
+ templates: (data?.templates || []).map((t) => {
322
+ const root = templateRoot(t);
323
+ return { id: t.id, name: t.name, thumbnail: t.thumbnail, kind: root?.type === "section" ? "section" : root?.type || "unknown" };
324
+ }),
325
+ page: data?.page,
326
+ total: data?.total_entries,
327
+ };
328
+ }));
298
329
  server.tool("add_section", `Append a section to an EXISTING page's source. Reads the current source, appends your section, validates, and (when dry_run=false) saves.
299
- The section is re-id'd to avoid collisions. Build it with new_section.
330
+ The section is re-id'd to avoid collisions. Pass a section built with new_section, OR template:{ category_id, id } to insert a library section (list_section_templates).
300
331
  Two-step safety: dry_run=true (default) previews; dry_run=false saves.`, {
301
332
  page_id: z.string().describe("Target page id"),
302
- section: z.any().describe("A section node (from new_section) — object or JSON string"),
333
+ section: z.any().optional().describe("A section node (from new_section) — object or JSON string"),
334
+ template: z
335
+ .object({ category_id: z.string().optional(), id: z.string() })
336
+ .optional()
337
+ .describe("Insert a library section instead (ids from list_section_templates)"),
303
338
  dry_run: z.boolean().default(true).describe("Preview only (true) or save (false)"),
304
- }, ({ page_id, section, dry_run }) => handle(async () => {
339
+ }, ({ page_id, section, template, dry_run }) => handle(async () => {
340
+ if (template) {
341
+ // No get-by-id route: page through the category until the template turns up.
342
+ let found = null;
343
+ for (let p = 1; p <= 10 && !found; p++) {
344
+ const res = await api.listTemplates({ id: template.category_id, page: p, limit: 50 });
345
+ const list = (res?.data ?? res)?.templates || [];
346
+ found = list.find((t) => t.id === template.id);
347
+ if (list.length < 50)
348
+ break;
349
+ }
350
+ if (!found)
351
+ return { error: `Template "${template.id}" not found${template.category_id ? " in that category" : ""}.` };
352
+ section = templateRoot(found);
353
+ }
354
+ if (!section)
355
+ return { error: "Pass a section (from new_section) or a template { category_id, id }." };
305
356
  const pagesRes = await api.listPages();
306
357
  const pages = (pagesRes && pagesRes.data) || pagesRes || [];
307
358
  const page = Array.isArray(pages) ? pages.find((p) => p.id === page_id) : null;
@@ -330,7 +381,7 @@ Two-step safety: dry_run=true (default) previews; dry_run=false saves.`, {
330
381
  return { error: "Validation failed — not saving.", validation };
331
382
  // Expand the newly-added section's runtime -> bp1..bp4 (existing sections are
332
383
  // already in breakpoint shape and are left untouched).
333
- finalizeForRender(source);
384
+ finalizeForRender(source, await api.getSiteBreakpoints());
334
385
  const saved = await api.updatePageSource(page_id, { source });
335
386
  return {
336
387
  success: true,
@@ -77,7 +77,7 @@ Two-step safety: dry_run=true (default) previews which pages change; dry_run=fal
77
77
  const validation = validatePage(wrap);
78
78
  if (!validation.valid)
79
79
  return { error: "Section failed validation.", validation };
80
- finalizeForRender(wrap);
80
+ finalizeForRender(wrap, await api.getSiteBreakpoints());
81
81
  const finalNode = wrap.sections[0];
82
82
  // Resolve target pages + inject the node into each one's source.
83
83
  const allPages = await loadPages(api);
@@ -1,9 +1,16 @@
1
1
  import { z } from "zod";
2
+ /** builderx_api Enum.OrderStatus (lib/builderx_api/enum.ex). */
3
+ const ORDER_STATUS = {
4
+ 0: "new", 1: "submitted", 2: "shipped", 3: "delivered", 4: "returning", 5: "returned", 6: "canceled",
5
+ 7: "removed", 8: "packing", 9: "pending", 11: "waiting", 12: "wait_print", 13: "printed",
6
+ 15: "part_returned", 16: "received_money", 17: "wait_submit", 20: "ordered", 22: "draft", 50: "blocked",
7
+ };
8
+ const STATUS_HELP = Object.entries(ORDER_STATUS).map(([k, v]) => `${k}=${v}`).join(", ");
2
9
  export function registerOrderTools(server, api, handle) {
3
10
  server.tool("list_orders", "List orders of the site (metadata only). Use get_order for full details including items", {
4
11
  page: z.number().optional().describe("Page number"),
5
12
  limit: z.number().optional().describe("Items per page"),
6
- status: z.number().optional().describe("Filter by status (0=pending, 50=confirmed, 100=shipping, 150=delivered, -1=cancelled)"),
13
+ status: z.number().optional().describe(`Filter by status: ${STATUS_HELP}. Omit or -1 = all (except removed/blocked).`),
7
14
  }, ({ page, limit, status }) => handle(async () => {
8
15
  const res = await api.listOrders({ page, limit, status });
9
16
  // Real shape: { orders: { data:[…], total_entries, count_status } }.
@@ -16,6 +23,7 @@ export function registerOrderTools(server, api, handle) {
16
23
  bill_full_name: o.bill_full_name,
17
24
  bill_phone_number: o.bill_phone_number,
18
25
  status: o.status,
26
+ status_name: ORDER_STATUS[o.status] ?? null,
19
27
  payment_status: o.payment_status,
20
28
  payment_method: o.payment_method,
21
29
  invoice_value: o.invoice_value || o.total,
@@ -134,7 +134,7 @@ RESUMABLE: if a request fails mid-commit, the draft keeps its page_id + committe
134
134
  }
135
135
  }
136
136
  // Expand runtime -> bp1..bp4 so the saved nodes actually render.
137
- finalizeForRender(full);
137
+ finalizeForRender(full, await api.getSiteBreakpoints());
138
138
  const finalizedSections = full.sections;
139
139
  try {
140
140
  // Create the page with only the FIRST section (a SMALL first request) if not already created.
@@ -453,31 +453,39 @@ For full rewrites, use update_site_custom_code instead.`, {
453
453
  server.tool("get_page_versions", "View version history of a page", {
454
454
  page_id: z.string().describe("Page ID"),
455
455
  }, ({ page_id }) => handle(() => api.getPageVersions(page_id)));
456
- server.tool("list_page_contents", "List multi-language contents of a page", {
457
- page_id: z.string().optional().describe("Filter by Page ID"),
458
- }, ({ page_id }) => handle(() => api.listPageContents({ page_id })));
456
+ server.tool("list_page_contents", "List multi-language contents (translations) of the site's pages and global sections. Pass page_id + language_code to read one page's translation.", {
457
+ page_id: z.string().optional().describe("Page ID — narrows the result to that page"),
458
+ language_code: z.string().optional().describe("With page_id: return just that language's content map"),
459
+ }, ({ page_id, language_code }) => handle(async () => {
460
+ if (page_id && language_code) {
461
+ const res = await api.getPageContent(page_id, language_code);
462
+ return res?.data ?? res;
463
+ }
464
+ const res = await api.listPageContents(); // backend ignores page_id here, so filter client-side
465
+ const data = res?.data ?? res;
466
+ return page_id ? { pages: (data?.pages || []).filter((p) => p.id === page_id) } : data;
467
+ }));
459
468
  server.tool("update_page_content", `Create/update page content for a specific language.
460
469
  IMPORTANT: Before calling, you MUST read existing content with list_page_contents first, then show the user what will change and get explicit confirmation. NEVER update without user approval.`, {
461
470
  page_id: z.string().describe("Page ID"),
462
471
  language_code: z.string().describe("Language code (e.g. 'en', 'vi')"),
463
- content: z.record(z.any()).describe("Page content"),
472
+ content: z
473
+ .record(z.any())
474
+ .describe('Translations keyed by element id → field, e.g. { "<elementId>": { "text": "Hello" } } (also "placeholder", or "options": [{ id, name }]). Replaces this language\'s whole map — merge into the existing one.'),
464
475
  meta_tags: z.array(z.record(z.any())).optional().describe("SEO meta tags"),
465
476
  }, ({ page_id, language_code, content, meta_tags }) => handle(async () => {
466
477
  // Safeguard: read existing content and block if new content is suspiciously smaller
467
- const existingRes = await api.listPageContents({ page_id });
468
- const existingContents = (existingRes && existingRes.data) || existingRes || [];
469
- if (Array.isArray(existingContents)) {
470
- const existing = existingContents.find((c) => c.language_code === language_code);
471
- if (existing && existing.content) {
472
- const existingStr = JSON.stringify(existing.content);
473
- const newStr = JSON.stringify(content);
474
- if (existingStr.length > 200 && newStr.length < existingStr.length * 0.5) {
475
- return {
476
- error: `BLOCKED: New content (${newStr.length} chars) is much smaller than existing (${existingStr.length} chars) — ${Math.round((newStr.length / existingStr.length) * 100)}% of original. This may indicate fabricated data or data loss. Read existing content with list_page_contents first, then merge your changes.`,
477
- existing_length: existingStr.length,
478
- new_length: newStr.length,
479
- };
480
- }
478
+ const existingRes = await api.getPageContent(page_id, language_code);
479
+ const existing = existingRes?.data ?? existingRes;
480
+ if (existing && existing.content) {
481
+ const existingStr = JSON.stringify(existing.content);
482
+ const newStr = JSON.stringify(content);
483
+ if (existingStr.length > 200 && newStr.length < existingStr.length * 0.5) {
484
+ return {
485
+ error: `BLOCKED: New content (${newStr.length} chars) is much smaller than existing (${existingStr.length} chars) — ${Math.round((newStr.length / existingStr.length) * 100)}% of original. This may indicate fabricated data or data loss. Read it with list_page_contents(page_id, language_code) first, then merge your changes.`,
486
+ existing_length: existingStr.length,
487
+ new_length: newStr.length,
488
+ };
481
489
  }
482
490
  }
483
491
  return api.updatePageContent({ page_id, language_code, content, meta_tags });
@@ -1,12 +1,9 @@
1
1
  import { z } from "zod";
2
- // Sale-channels app: sitemap generation, partner product feeds (Google/Facebook/etc.),
3
- // and catalog syncs. Google Ads / OAuth-connect flows are intentionally omitted —
2
+ // Sale-channels app: partner product feeds (Google/Facebook/etc.) and catalog syncs.
3
+ // Sitemaps need no tool: the backend builds them at render time (<domain>/root_sitemaps.xml). Google Ads / OAuth-connect flows are intentionally omitted —
4
4
  // they require an externally-authorized Google/Facebook partner.
5
5
  export function registerSaleChannelTools(server, api, handle) {
6
6
  const unwrap = (res, key) => (key && (res?.data?.[key] ?? res?.[key])) ?? res?.data ?? res;
7
- // ── Sitemap ──
8
- server.tool("sync_sitemap", "Sync a resource type into the site's sitemap. Requires sitemap enabled in site settings.", { kind: z.enum(["products", "categories", "articles", "pages"]).describe("What to sync into the sitemap") }, ({ kind }) => handle(() => api.sitemapSync(kind)));
9
- server.tool("rebuild_sitemap", "Rebuild the whole sitemap (async background job).", { type: z.string().optional().describe("Optional rebuild scope hint") }, ({ type }) => handle(() => api.sitemapRebuild(type)));
10
7
  // ── Partner feeds ──
11
8
  server.tool("list_partner_feeds", "List product feeds for sale channels (Google Shopping, Facebook, etc.).", {
12
9
  page: z.number().optional().describe("Page number"),
@@ -76,7 +76,7 @@ function parseThemeDescription(d) {
76
76
  }
77
77
  }
78
78
  export function registerSiteStyleTools(server, api, handle) {
79
- server.tool("get_site_info", "Get full site information: name, domain, settings (colors, typography, layout, language, payment methods, etc.)", {}, () => handle(async () => {
79
+ server.tool("get_site_info", "Get full site information: name, domain, settings (colors, typography, layout, language, payment methods, etc.) and preview_img — the latest homepage screenshots (1920_1080 desktop / 430_932 mobile), refreshed after publish_site.", {}, () => handle(async () => {
80
80
  const res = await api.getSite();
81
81
  const site = (res && res.data) || res;
82
82
  if (!site)
@@ -89,6 +89,7 @@ export function registerSiteStyleTools(server, api, handle) {
89
89
  logo: site.logo || undefined,
90
90
  favicon: site.favicon || undefined,
91
91
  settings: site.settings || {},
92
+ preview_img: site.preview_img || undefined,
92
93
  created_at: site.created_at,
93
94
  };
94
95
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-storefront-mcp",
3
- "version": "1.33.1",
3
+ "version": "1.34.0",
4
4
  "description": "MCP server for the WebCake/StoreCake storefront builder — page CRUD, page authoring, products, orders, and more",
5
5
  "mcpName": "io.github.vuluu2k/webcake-storefront-mcp",
6
6
  "license": "MIT",