webcake-storefront-mcp 1.33.1 → 1.33.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -186,8 +186,15 @@ export class WebcakeCmsApi {
186
186
  listPageContents(query) {
187
187
  return this.request("GET", `/api/v1/site/${this.siteId}/page_contents`, { query });
188
188
  }
189
- updatePageContent(params) {
190
- return this.request("POST", `/api/v1/site/${this.siteId}/page_contents/page_content`, { body: params });
189
+ /** One page's translation in one language: { content: { [elementId]: {...} } } ({} when none yet). Read-only. */
190
+ getPageContent(page_id, lang) {
191
+ return this.request("POST", `/api/v1/site/${this.siteId}/page_contents/page_content`, { body: { page_id, lang } });
192
+ }
193
+ /** Upsert one page translation (matched by page_id + language_code) — the editor's multilingual save. */
194
+ updatePageContent({ page_id, language_code, content, meta_tags }) {
195
+ return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/multilingual/update_page_contents`, {
196
+ body: { pages: [{ contents: [{ page_id, language_code, content, meta_tags, site_id: this.siteId }] }] },
197
+ });
191
198
  }
192
199
  listGlobalSections() {
193
200
  return this.request("GET", `/api/v1/site/${this.siteId}/global_sections`);
@@ -647,14 +654,25 @@ export class WebcakeCmsApi {
647
654
  listCustomers(query) {
648
655
  return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/customer/all`, { query });
649
656
  }
657
+ /** /api/v1/cms_function/:site_id/* sits behind builderx_api's CmsFunctionAuth plug, which only
658
+ * accepts the site's CMS admin token (db_collections/token) as the Bearer — the session JWT 401s. */
659
+ async cmsFunction(method, path, body) {
660
+ await this.fetchCmsTokens();
661
+ if (!this._adminToken)
662
+ throw new Error("This site has no CMS admin token, so cms_function routes are unavailable.");
663
+ return this.request(method, `/api/v1/cms_function/${this.siteId}${path}`, {
664
+ body,
665
+ headers: { Authorization: `Bearer ${this._adminToken}` },
666
+ });
667
+ }
650
668
  findCustomerById(id) {
651
- return this.request("GET", `/api/v1/cms_function/${this.siteId}/customer/identity/${id}`);
669
+ return this.cmsFunction("GET", `/customer/identity/${encodeURIComponent(id)}`);
652
670
  }
653
671
  findCustomerByPhone(phone) {
654
- return this.request("GET", `/api/v1/cms_function/${this.siteId}/customer/phone/${phone}`);
672
+ return this.cmsFunction("GET", `/customer/phone/${encodeURIComponent(phone)}`);
655
673
  }
656
674
  findCustomerByEmail(email) {
657
- return this.request("GET", `/api/v1/cms_function/${this.siteId}/customer/email/${email}`);
675
+ return this.cmsFunction("GET", `/customer/email/${encodeURIComponent(email)}`);
658
676
  }
659
677
  // ── Global Sources (cart, popup, overview, etc.) ──
660
678
  getSourceCart() {
@@ -704,7 +722,7 @@ export class WebcakeCmsApi {
704
722
  return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/automations/delete`, { body: { ids } });
705
723
  }
706
724
  sendMail(params) {
707
- return this.request("POST", `/api/v1/cms_function/${this.siteId}/application/automation/send_mail`, { body: params });
725
+ return this.cmsFunction("POST", "/application/automation/send_mail", params);
708
726
  }
709
727
  // ── App lifecycle (applications/subcriptions) ──
710
728
  /** Uninstall an app by its subscription id (the `id` from list_apps / get_app). */
@@ -1143,13 +1161,7 @@ export class WebcakeCmsApi {
1143
1161
  getCourseMembers(query) {
1144
1162
  return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/course-app/members`, { query });
1145
1163
  }
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
- }
1164
+ // ── Sale channels: partner feeds + catalogs ──
1153
1165
  listPartnerFeeds(query) {
1154
1166
  return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/sale_channels/partner_feeds/all`, { query });
1155
1167
  }
@@ -174,7 +174,7 @@ 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
  },
@@ -322,7 +322,7 @@ export const ELEMENT_ATTRS = {
322
322
  "next-lesson-droppable": { events: "none", notes: "Next-lesson panel (course app)." },
323
323
  "list-lesson-droppable": { events: "none", notes: "Lessons droppable list (course app)." },
324
324
  // ── 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." },
325
+ 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
326
  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
327
  "random-number": { specials: { min: "Lowest value.", max: "Highest value." }, events: "none", notes: "Displays a random number (minigame / fake-stock urgency widget)." },
328
328
  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)." },
@@ -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 } = {}) => {
@@ -347,6 +347,16 @@ create the globals — they embed into each page's source. If you later overwrit
347
347
  (delete_global_section by the section NODE id, then create once) to re-embed cleanly. Edit a global
348
348
  later with update_global_section_element(s) and it updates on every page at once.
349
349
 
350
+ STICKY & PINNED (runtime.config, on a section or any element):
351
+ position:"sticky" sticks while scrolling — the usual sticky header: the header section gets
352
+ { position:"sticky", stickyPosition:"autotop", stickyTop:0 }. stickyPosition:
353
+ autotop (default) | autobottom | default (both); offsets stickyTop / stickyBottom (px).
354
+ position:"pinned" fixed to the viewport — floating hotline/Zalo button, back-to-top. ALSO set
355
+ runtime.style.position:"fixed". fixedPosition: top_left | top_center | top_right |
356
+ bottom_left | bottom_center | bottom_right | center_left | center_right; offsets
357
+ fixedTop / fixedLeft / fixedRight / fixedBottom (px, default 20).
358
+ Anything else (or unset) = normal flow. stickyPosition alone does nothing without position:"sticky".
359
+
350
360
  ### HEADER must be RESPONSIVE — add a MOBILE MENUBAR, don't just let nav stack
351
361
  A header built as one horizontal row of nav links looks fine on desktop but on a phone the links
352
362
  squash or collapse into an ugly vertical pile. Build a header that carries TWO navs and swap them
@@ -373,7 +383,7 @@ A popup is a GLOBAL SOURCE, not a page section. Compose it from elements (there
373
383
  shortcut), then save it as a "popup" global source:
374
384
  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
385
  - 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.)
386
+ - 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
387
  - OVERLAY is NOT on the popup node — it's a field on the open_popup EVENT (popup_overlay:true).
378
388
  2. Save it: create_global_source({ component:"popup", source:{ sections:[<popupNode>] } }) -> returns its id.
379
389
  3. Open/close from any element via events: a button { action:"open_popup", popup_id:"<id>", popup_overlay:true };
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "v": "1.33.2",
4
+ "d": "14/10/2026",
5
+ "type": "Added",
6
+ "en": "list_orders responses now include a status_name field alongside the numeric status.",
7
+ "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ố."
8
+ },
2
9
  {
3
10
  "v": "1.33.1",
4
11
  "d": "14/10/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Added",
34
41
  "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
42
  "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
  ]
@@ -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,
@@ -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"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-storefront-mcp",
3
- "version": "1.33.1",
3
+ "version": "1.33.2",
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",