webcake-storefront-mcp 1.19.0 → 1.20.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.
@@ -172,9 +172,9 @@ export const ELEMENT_ATTRS = {
172
172
  show_message: "Show a success message after submit (boolean).",
173
173
  show_message_time: "How long the success message stays (seconds).",
174
174
  },
175
- config: { backgroundInput: "Input background.", labelColor: "Label colour.", placeholderColor: "Placeholder colour.", textPadding: "Input padding (px).", labelMarginBottom: "Gap below label (px)." },
176
- events: "none",
177
- notes: "Wrap input fields as children; put a submit-button inside. specials.type selects what the form does on submit.",
175
+ config: { backgroundInput: "Input background.", labelColor: "Label colour.", placeholderColor: "Placeholder colour.", textPadding: "Input padding (px).", labelMarginBottom: "Gap below label (px).", borderColor: "Input border colour (e.g. #dbe0e6).", borderRadius: "Input corner radius (e.g. 8px)." },
176
+ events: "success — fire follow-up actions after a successful submit (a form_order commonly does open_page→Thank-you page). eventName defaults to 'success' on a form.",
177
+ notes: "Wrap input fields as children; put a submit-button inside. specials.type selects what the form does on submit. Real order forms split fields across an inner form via specials.multiForms:[innerFormId] (inner form sets specials.formParent). A form_order uses dedicated field types: input(full_name), phone-number, email, detect-address, address (×3 province/district/ward), text-area(note).",
178
178
  },
179
179
  input: { specials: { ...FIELD, type: "Input type (default text)." }, config: { textPadding: "Padding.", backgroundInput: "Background." }, events: "none" },
180
180
  email: { specials: { ...FIELD, validate_email: "Email regex." }, events: "none", notes: "field_name usually 'email'." },
@@ -205,22 +205,22 @@ export const ELEMENT_ATTRS = {
205
205
  switch: { events: "none", notes: "Toggle; style width/height." },
206
206
  // ── commerce ──
207
207
  "grid-product": {
208
- specials: { displayStyle: "default | only_image | border.", original_price_position: "left | right." },
209
- config: { columns: "Products per row.", responsive: "autofit | fixed.", item_show: "How many products to show.", image_ratio: "16/9|3/2|4/3|1/1|9/16|2/3|3/4.", img_object_fit: "cover|contain|fill|stretch.", gap_column: "Column gap.", gap_row: "Row gap.", product_info_alignment: "left|center|right." },
208
+ specials: { displayStyle: "default | only_image | border.", original_price_position: "left | right.", products_per_load: "Products fetched per page/scroll (real default 36).", show_original_price: "Show the compare-at price (boolean).", show_discount_on_price: "Show a discount badge on the price (boolean).", show_quick_view: "Show a quick-view button on hover (boolean)." },
209
+ config: { columns: "Products per row.", responsive: "autofit | fixed | custom.", item_show: "How many products to show.", image_ratio: "16/9|3/2|4/3|1/1|4/5|9/16|2/3|3/4 (designer templates use 4/5).", img_object_fit: "cover|contain|fill|stretch.", gap_column: "Column gap (real default 30).", gap_row: "Row gap (real default 15).", product_info_padding_y: "Vertical padding of the name/price block (px).", price_padding_y: "Vertical padding around the price (px).", product_info_alignment: "left|center|right.", productNameFont: "Product-name font family.", productNameFontSize: "Product-name font size (px).", productPriceFont: "Price font family.", productPriceFontSize: "Price font size (px).", productPriceFontWeight: "Price font weight (e.g. bold).", productOriginalPriceFontSize: "Compare-at price font size (px).", sizeThumbnail: "{ width, height } of the card image." },
210
210
  events: "none",
211
211
  bindings: ["product::product_image", "product::product_name", "product::product_price"],
212
- notes: "Lists store products. Requires a 'store' page / use_store. Configure columns + image_ratio.",
212
+ notes: "Lists store products (self-renders image+name+price card). Requires a 'store' page / use_store. Configure columns + image_ratio (4/5 reads as a fashion card); the price/name fonts + show_* flags style the card.",
213
213
  },
214
214
  "slider-product": { specials: { displayStyle: "default|only_image|border.", original_price_position: "left|right." }, config: { slideItems: "Visible products.", slideWidth: "Card width.", image_ratio: "Aspect ratio." }, events: "none", bindings: ["product::product_image", "product::product_name", "product::product_price"] },
215
215
  "product-gallery": { events: "none", notes: "Product detail image gallery (product page)." },
216
216
  "product-image-carousel": { events: "none", notes: "Product image carousel (product page)." },
217
- "cart-items": { events: "none", bindings: ["cart_item::cart_item_image", "cart_item::cart_item_name", "cart_item::cart_item_price", "cart_item::cart_item_total_price", "cart_item::cart_item_prod_attr"], notes: "Cart line items (cart page)." },
218
- "cart-icon": { specials: { svg: "Icon SVG.", itemCount: "Badge count." }, events: "click — opens cart." },
217
+ "cart-items": { events: "none", bindings: ["cart_item::cart_item_image", "cart_item::cart_item_name", "cart_item::cart_item_price", "cart_item::cart_item_total_price", "cart_item::cart_item_prod_attr"], notes: "Cart line items (cart page). A repeater whose config.columns defines the per-row column template — real templates use [70px image, 1fr name, ~240px price, ~116px qty, ~240px total, 34px remove]." },
218
+ "cart-icon": { specials: { svg: "Icon SVG (defaults to a bag icon; fill='currentColor' so it inherits style.color).", itemCount: "Badge count (0 placeholder)." }, config: { itemCountFontFamily: "Badge font family.", itemCountFontSize: "Badge font size (e.g. 11px).", itemCountColor: "Badge text colour.", itemCountBorderRadius: "Badge corner radius (50% = circle)." }, events: "click — opens cart. Real templates wrap it in a container with open_popup(mouseenter)/close_popup(mouseleave) for a mini-cart, plus a container with open_page→cart page." },
219
219
  "quantity-input": { events: "none", notes: "Quantity stepper (product/cart)." },
220
220
  attr: { events: "none", notes: "Product attribute selector (variations)." },
221
221
  payment: { events: "none", notes: "Payment methods block (checkout)." },
222
222
  "delivery-method": { specials: { options: "[{type: delivery|store_pickup, name, active}]." }, events: "none" },
223
- "order-items": { events: "none", bindings: ["order_item::product_image", "order_item::product_name", "order_item::product_quantity", "order_item::product_attrs", "order_item::items_sum_up_price"], notes: "Order summary lines." },
223
+ "order-items": { events: "none", bindings: ["order_item::product_image", "order_item::product_name", "order_item::product_quantity", "order_item::product_attrs", "order_item::items_sum_up_price"], notes: "Order summary lines (checkout/thank-you). Repeater whose config.columns is the per-row template — real templates use [75px image, 1fr name, 95px price]." },
224
224
  "order-history": { events: "none", notes: "Member order history (member page)." },
225
225
  coupon: { events: "none", notes: "Coupon / discount code input." },
226
226
  "product-overlay": { specials: { type: "discount | custom." }, events: "none", notes: "Badge overlaid on a product card." },
@@ -253,8 +253,8 @@ export const ELEMENT_ATTRS = {
253
253
  "favorite-icon": { events: "click — toggle favourite." },
254
254
  "bonus-items": { specials: { typeBonus: "combo_product | other." }, events: "none" },
255
255
  // ── navigation ──
256
- menu: { specials: { type: "horizontal | vertical | hamburger | anchor.", sync: "Auto-sync items with site pages (boolean)." }, events: "none", notes: "Holds menu-item children." },
257
- "menu-item": { specials: { name: "Link label.", isCustom: "Custom link (boolean).", linkType: "page | category | blog_category | brand | product_tag | custom.", linkCategory: "Linked category id.", categoryId: "Category id.", syncSubmenu: "Auto-build submenu (boolean)." }, events: "clicknavigates.", notes: "Set linkType + the matching id (or a custom href) to point the link." },
256
+ menu: { specials: { type: "horizontal | vertical | hamburger | anchor.", sync: "Auto-sync items with site pages (boolean).", fullField: "Stretch items to fill the menu width (boolean)." }, config: { showArrow: "Show the dropdown caret on items with a submenu (boolean).", textColor: "Item text colour (often a theme var like var(--color_20)).", colorHover: "Item hover colour.", "--padX": "Horizontal padding inside each item (CSS var, e.g. 15px)." }, events: "none", notes: "Holds menu-item children. Real templates colour items via theme vars (var(--color_20))." },
257
+ "menu-item": { specials: { name: "Link label.", isCustom: "Custom link (boolean).", linkType: "page | category | blog_category | brand | product_tag | custom.", linkPage: "Target page id (linkType=page).", pageId: "Target page id (mirror of linkPage, set both).", linkCategory: "Linked category id.", categoryId: "Category id.", syncSubmenu: "Auto-build submenu (boolean)." }, events: "navigation lives in SPECIALS here, NOT events set linkType + the matching id.", notes: "For a page link set linkType='page' + linkPage + pageId + name. (Generic text/container/button elements navigate via events:[{action:'open_page',open_page_id}] instead.)" },
258
258
  "menu-anchor-item": { specials: { name: "Label.", isCustom: "Custom.", linkType: "Link kind.", categoryId: "Category id." }, events: "click — scrolls to an anchor." },
259
259
  submenu: { events: "none", notes: "Dropdown under a menu-item." },
260
260
  "menu-droppable": { events: "none" },
@@ -363,15 +363,42 @@ export const buildElementWithBreakpoint = (el, breakpoint) => {
363
363
  el.specials = Object.assign({}, el.specials || {}, customSpecials);
364
364
  delete el.runtime;
365
365
  };
366
+ // Real designer-template grid-product defaults (mined from prod theme "Mode Féminine"):
367
+ // the card self-renders image + name + price; these keys control its look. Callers may
368
+ // override any of them via opts.config / opts.specials.
366
369
  export const createGridProduct = (opts = {}) => {
367
370
  const product = cloneDeep(SKELETON);
368
371
  product.id = 'GRID-PRODUCT-' + randomString(8);
369
372
  product.type = 'grid-product';
370
373
  product.runtime.style = {
371
374
  width: opts.width,
372
- height: 354
375
+ height: 354,
376
+ ...(opts.style || {})
377
+ };
378
+ product.runtime.config = {
379
+ columns: 4,
380
+ image_ratio: '4/5',
381
+ img_object_fit: 'cover',
382
+ gap_column: 30,
383
+ gap_row: 15,
384
+ product_info_padding_y: 15,
385
+ price_padding_y: 5,
386
+ productNameFontSize: 18,
387
+ productPriceFontSize: 18,
388
+ productPriceFontWeight: 'bold',
389
+ productOriginalPriceFontSize: 18,
390
+ responsive: 'custom',
391
+ heightUnit: 'auto',
392
+ ...(opts.config || {})
393
+ };
394
+ product.specials = {
395
+ element_async: true,
396
+ products_per_load: 36,
397
+ show_original_price: true,
398
+ show_discount_on_price: true,
399
+ show_quick_view: false,
400
+ ...(opts.specials || {})
373
401
  };
374
- product.runtime.config = { heightUnit: 'auto' };
375
402
  return product;
376
403
  };
377
404
  export const createSliderProduct = (opts = {}) => {
@@ -441,17 +468,28 @@ export const createMemberBar = (opts = {}) => {
441
468
  menuBar.specials = { ...opts.specials };
442
469
  return menuBar;
443
470
  };
444
- export const createCartIcon = ({ svg, itemCount, config, children } = {}) => {
471
+ // Default shopping-bag icon (real prod template svg, 28×28, fill=currentColor so it
472
+ // inherits the element's colour). Used when a caller doesn't supply its own svg, so a
473
+ // header cart-icon is never blank.
474
+ const DEFAULT_CART_SVG = '<svg width="100%" height="100%" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M14.0166 3.76758C10.2864 3.76758 7.38924 7.04233 7.38924 10.9179C7.38924 10.9179 7.38924 10.9179 7.38924 10.918H4.94829C4.21457 10.918 3.66298 11.5872 3.8031 12.3074L5.89025 23.0355C6.10354 24.1318 7.06378 24.9232 8.18064 24.9232H19.8444C20.9613 24.9232 21.9215 24.1318 22.1348 23.0355L24.2219 12.3074C24.3621 11.5872 23.8105 10.918 23.0767 10.918H20.644C20.644 10.9179 20.644 10.9179 20.644 10.9179C20.644 7.04233 17.7468 3.76758 14.0166 3.76758ZM18.894 10.918C18.894 10.9179 18.894 10.9179 18.894 10.9179C18.894 7.86235 16.6401 5.51758 14.0166 5.51758C11.3931 5.51758 9.13924 7.86235 9.13924 10.9179C9.13924 10.9179 9.13924 10.9179 9.13924 10.918H18.894ZM7.60804 22.7013L5.65605 12.668H22.369L20.417 22.7013C20.3637 22.9754 20.1236 23.1732 19.8444 23.1732H8.18064C7.90143 23.1732 7.66137 22.9754 7.60804 22.7013Z" fill="currentColor"></path></svg>';
475
+ export const createCartIcon = ({ svg, itemCount, config, style, children } = {}) => {
445
476
  const cart = cloneDeep(SKELETON);
446
477
  cart.id = 'CART-ICON-' + randomString(8);
447
478
  cart.type = 'cart-icon';
448
- cart.runtime.config = { ...config };
479
+ cart.runtime.config = {
480
+ itemCountFontFamily: 'Roboto',
481
+ itemCountFontSize: '11px',
482
+ itemCountColor: 'white',
483
+ itemCountBorderRadius: '50%',
484
+ ...config
485
+ };
449
486
  cart.runtime.style = {
450
487
  width: 24,
451
- height: 24
488
+ height: 24,
489
+ ...(style || {})
452
490
  };
453
- cart.specials.svg = svg;
454
- cart.specials.itemCount = itemCount;
491
+ cart.specials.svg = svg || DEFAULT_CART_SVG;
492
+ cart.specials.itemCount = itemCount ?? 0;
455
493
  if (children)
456
494
  cart.children = children || [];
457
495
  return cart;
@@ -558,7 +596,11 @@ export const createProductGallery = (opts = {}) => {
558
596
  gallery.id = 'PRODUCT-GALLERY-' + randomString(8);
559
597
  gallery.type = 'product-gallery';
560
598
  gallery.runtime.style = opts.style || {};
561
- gallery.runtime.config = opts.config || {};
599
+ gallery.runtime.config = {
600
+ sizeThumbnail: { width: 580, height: 580 },
601
+ heightUnit: 'auto',
602
+ ...(opts.config || {})
603
+ };
562
604
  return gallery;
563
605
  };
564
606
  export const createProductImagesCarousel = (opts = {}) => {
@@ -87,9 +87,13 @@ A bare stack of default elements looks unfinished. Apply real styling:
87
87
  - HERO: prefer a section with a background image + an overlay heading/sub/button on top
88
88
  (set section_opts.style.background or a full-width image, then text centered), rather than a
89
89
  small image stacked above text.
90
- - PRODUCT GRID: style the cards \`grid-product opts.config\`: { columns:3-4, image_ratio:"1/1",
91
- img_object_fit:"cover", gap_column:20, gap_row:28, cardBorderRadius:12, cardBoxShadow:"0 6px 24px rgba(0,0,0,.08)",
92
- productNameColor:"var(--color_00)", productPriceColor:"var(--color_02)" }.
90
+ - PRODUCT GRID: grid-product SELF-RENDERS the image+name+price card; you mostly set
91
+ \`opts.config\`: { columns:3-4, image_ratio:"4/5" (a tall fashion card; "1/1" for square),
92
+ img_object_fit:"cover", gap_column:30, gap_row:15, product_info_padding_y:15,
93
+ productNameFontSize:18, productPriceFontSize:18, productPriceFontWeight:"bold" } and
94
+ \`opts.specials\`: { products_per_load:36, show_original_price:true, show_discount_on_price:true }.
95
+ (These are the real designer-template defaults — the factory now ships them, so a bare
96
+ grid-product already looks right; there are NO cardBorderRadius/cardBoxShadow keys.)
93
97
  - BRAND COLOURS: reuse var(--color_00) (text), and an accent (var(--color_02)/var(--color_03))
94
98
  for buttons, prices, highlights — consistent accent = looks intentional.
95
99
  - IMAGES: must be WebCake-CDN urls (search_images cdn_url / upload_images), else they won't show.
@@ -137,7 +141,12 @@ fields); the builder mints the id and picks a sensible \`eventName\` (trigger).
137
141
  - Commerce: \`{ action:"add_to_cart", open_page:"cart" }\`, \`{ action:"buy_now" }\`, \`{ action:"apply_promotion" }\`.
138
142
  - Contact: \`{ action:"phone_call", phone_call_number:"+84…" }\`, \`{ action:"open_email", open_email:"hi@shop.vn" }\`.
139
143
  - Hover style (set eventName:"hover"): \`{ eventName:"hover", action:"scale" }\`.
144
+ - Form follow-up (eventName defaults to "success" on a form): a form_order commonly carries
145
+ \`{ action:"open_page", open_page_id:"<thank-you page>" }\` so a successful order redirects.
140
146
  Example: \`new_element("button", { text:"Mua ngay", style:{…}, events:[{ action:"add_to_cart", open_page:"cart" }] })\`.
147
+ NAV EXCEPTION — \`menu\`/\`menu-item\`: a menu-item's navigation lives in its SPECIALS, not events.
148
+ For a page link set \`menu-item specials\`: { linkType:"page", linkPage:"<page id>", pageId:"<page id>", name:"Trang chủ" }.
149
+ (Only generic elements — text/container/button/image/rectangle — navigate via the events array above.)
141
150
  validate_page warns on unknown actions, missing required fields, and events whose
142
151
  in-page target (toggle_id/scroll_to_id/…) doesn't exist on the page.
143
152
 
@@ -14,6 +14,7 @@
14
14
  // text) so a generated site matches whatever palette the theme already defines; callers can
15
15
  // override any slot via a Palette object.
16
16
  import { buildSection, walk } from "./page.js";
17
+ import { buildElement } from "./catalog.js";
17
18
  import { normalizeEvents } from "./events.js";
18
19
  const DEFAULT_PALETTE = {
19
20
  accent: "var(--color_02)",
@@ -110,23 +111,16 @@ const trustBadge = (icon, label, p) => ({
110
111
  { type: "text", opts: { text: label, style: { fontSize: "13px", fontWeight: "600", color: p.text, lineHeight: "1.3" } } },
111
112
  ],
112
113
  });
113
- /** A styled product grid card config that looks like a real shop, not a bare list. */
114
+ /** A product-grid card config matching real designer templates. grid-product self-renders
115
+ * image + name + price; the factory now ships the real designer defaults (image_ratio 4/5,
116
+ * products_per_load 36, gap_column 30/gap_row 15, bold price, show original/discount price),
117
+ * so here we only set the column count + the price accent colour from the palette. */
114
118
  const productGrid = (p, columns = 4) => ({
115
119
  type: "grid-product",
116
120
  opts: {
117
121
  config: {
118
122
  columns,
119
- image_ratio: "1/1",
120
- img_object_fit: "cover",
121
- gap_column: 24,
122
- gap_row: 32,
123
- cardBorderRadius: 14,
124
- cardBoxShadow: "0 6px 22px rgba(0,0,0,0.06)",
125
- cardPadding: 12,
126
- cardBackground: p.surface,
127
- productNameColor: p.text,
128
123
  productPriceColor: p.accent,
129
- showAddToCart: true,
130
124
  },
131
125
  },
132
126
  });
@@ -214,14 +208,33 @@ export function cartPageSource(p) {
214
208
  return { sections: [body] };
215
209
  }
216
210
  export function checkoutPageSource(p) {
211
+ // form_order config keys mirror real designer templates: input background, placeholder
212
+ // colour, label colour/gap, input padding. The success event (eventName defaults to
213
+ // "success" for a form) redirects to the Thank-you page once it exists (navTo sentinel).
214
+ const fieldStyle = { borderColor: p.border, borderRadius: "8px" };
215
+ const formConfig = {
216
+ backgroundInput: "#ffffff",
217
+ placeholderColor: p.muted,
218
+ labelColor: p.text,
219
+ labelMarginBottom: 8,
220
+ textPadding: 15,
221
+ columnGap: 15,
222
+ rowGap: 15,
223
+ };
217
224
  const form = {
218
225
  type: "form",
219
- opts: { specials: { type: "form_order" }, style: { background: p.surface } },
226
+ opts: {
227
+ specials: { type: "form_order" },
228
+ config: formConfig,
229
+ events: [{ action: "open_page", _navTo: "complete" }],
230
+ style: { background: p.surface },
231
+ },
220
232
  children: [
221
- { type: "input", opts: { specials: { field_name: "full_name", label: "Họ và tên", placeholder: "Nguyễn Văn A", required: true, show_label: true }, style: { borderColor: p.border, borderRadius: "8px" } } },
222
- { type: "phone-number", opts: { specials: { field_name: "phone_number", label: "Số điện thoại", required: true, show_label: true }, style: { borderColor: p.border, borderRadius: "8px" } } },
223
- { type: "address", opts: { specials: { field_name: "address", label: "Địa chỉ nhận hàng", show_label: true }, style: { borderColor: p.border, borderRadius: "8px" } } },
224
- { type: "text-area", opts: { specials: { field_name: "note", label: "Ghi chú", show_label: true }, style: { borderColor: p.border, borderRadius: "8px" } } },
233
+ { type: "input", opts: { specials: { field_name: "full_name", label: "Họ và tên", placeholder: "Nguyễn Văn A", required: true, show_label: true }, config: formConfig, style: fieldStyle } },
234
+ { type: "phone-number", opts: { specials: { field_name: "phone_number", label: "Số điện thoại", placeholder: "09xx xxx xxx", required: true, show_label: true }, config: formConfig, style: fieldStyle } },
235
+ { type: "email", opts: { specials: { field_name: "email", label: "Email", placeholder: "email@example.com", show_label: true }, config: formConfig, style: fieldStyle } },
236
+ { type: "address", opts: { specials: { field_name: "address", label: "Địa chỉ nhận hàng", show_label: true }, config: formConfig, style: fieldStyle } },
237
+ { type: "text-area", opts: { specials: { field_name: "note", label: "Ghi chú", placeholder: "Ghi chú cho đơn hàng (tuỳ chọn)", show_label: true }, config: formConfig, style: fieldStyle } },
225
238
  { type: "submit-button", opts: { text: "Đặt hàng", style: { background: p.accent, color: p.onAccent, borderRadius: "10px", height: 52, fontWeight: "600", width: "100%", textAlign: "center" } } },
226
239
  ],
227
240
  };
@@ -261,19 +274,20 @@ export function headerSection(opts = {}) {
261
274
  { label: "Giỏ hàng", navTo: "cart" },
262
275
  ];
263
276
  const logo = { type: "text", opts: { text: opts.brand || "Shop", specials: { tag: "h2" }, style: { fontSize: "24px", fontWeight: "800", color: p.text } } };
277
+ // Real designer templates use a `menu` of `menu-item`s, NOT text links — and a menu-item's
278
+ // navigation lives in its SPECIALS (linkType/linkPage/pageId), not events. We build the
279
+ // items directly (via buildElement, so buildFromSpec doesn't grid-stack the menu) and leave
280
+ // a _navTo sentinel that wireNavigation resolves into linkType:"page" + page ids.
281
+ const menuItems = links.map((l) => buildElement("menu-item", {
282
+ specials: { name: l.label, ...(l.navTo ? { _navTo: l.navTo } : {}), ...(l.url ? { _navUrl: l.url } : {}) },
283
+ }));
264
284
  const nav = {
265
- type: "container",
266
- layout: "row",
267
- columnGap: 28,
268
- collapse: { bp4: 3 },
269
- children: links.map((l) => ({
270
- type: "text",
271
- opts: {
272
- text: l.label,
273
- ...(l.navTo || l.url ? { specials: { ...(l.navTo ? { _navTo: l.navTo } : {}), ...(l.url ? { _navUrl: l.url } : {}) } } : {}),
274
- style: { fontSize: "15px", fontWeight: "600", color: p.text, cursor: "pointer" },
275
- },
276
- })),
285
+ type: "menu",
286
+ opts: {
287
+ specials: { type: "horizontal", sync: false, fullField: false },
288
+ config: { showArrow: true, textColor: p.text, colorHover: p.accent, "--padX": "15px", justifyContent: "center", fontSize: "15px", fontWeight: "600" },
289
+ children: menuItems,
290
+ },
277
291
  };
278
292
  const actions = {
279
293
  type: "container",
@@ -282,7 +296,7 @@ export function headerSection(opts = {}) {
282
296
  colWidths: [{ unit: "px", absValue: 32 }, { unit: "max-c" }],
283
297
  collapse: { bp4: 2 },
284
298
  children: [
285
- { type: "cart-icon", opts: { config: { color: p.text }, style: { width: 26, height: 26 } } },
299
+ { type: "cart-icon", opts: { style: { width: 26, height: 26, color: p.text } } },
286
300
  cta(opts.cta || "Đặt mua ngay", p, { navTo: "collections", height: 44 }),
287
301
  ],
288
302
  };
@@ -353,19 +367,34 @@ export function footerSection(opts = {}) {
353
367
  export function wireNavigation(source, slugToId) {
354
368
  let wired = 0;
355
369
  const resolve = (slug) => slugToId[slug] || slugToId[slug.replace(/^\//, "")];
370
+ const isMenuItem = (t) => t === "menu-item" || t === "menu-anchor-item";
356
371
  walk(source, (node) => {
357
- // (a) text links carry the sentinel in specials (the text factory keeps it).
372
+ // (a) elements carry the sentinel in specials (text/menu-item factories keep specials).
358
373
  const sp = node && node.specials;
359
374
  if (sp && sp._navTo) {
360
375
  const id = resolve(sp._navTo);
361
376
  if (id) {
362
- node.events = normalizeEvents([{ action: "open_page", open_page_id: id }], node.type);
377
+ // A menu-item navigates via SPECIALS (linkType/linkPage/pageId), like real templates;
378
+ // every other element navigates via an events open_page entry.
379
+ if (isMenuItem(node.type)) {
380
+ sp.linkType = "page";
381
+ sp.linkPage = id;
382
+ sp.pageId = id;
383
+ }
384
+ else
385
+ node.events = normalizeEvents([{ action: "open_page", open_page_id: id }], node.type);
363
386
  wired++;
364
387
  }
365
388
  delete sp._navTo;
366
389
  }
367
390
  if (sp && sp._navUrl) {
368
- node.events = normalizeEvents([{ action: "open_link", link_target: sp._navUrl, link_target_url: sp._navUrl }], node.type);
391
+ if (isMenuItem(node.type)) {
392
+ sp.linkType = "custom";
393
+ sp.isCustom = true;
394
+ sp.link = sp._navUrl;
395
+ }
396
+ else
397
+ node.events = normalizeEvents([{ action: "open_link", link_target: sp._navUrl, link_target_url: sp._navUrl }], node.type);
369
398
  delete sp._navUrl;
370
399
  wired++;
371
400
  }
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.20.0",
4
+ "d": "25/06/2026",
5
+ "type": "Changed",
6
+ "en": "The header generated by scaffold_global_sections now uses a menu element with menu-item children for navigation links, matching real production…",
7
+ "vi": "Header được tạo bởi scaffold_global_sections nay sử dụng phần tử menu với các phần tử con menu-item cho các liên kết điều hướng, khớp với cấu trúc…"
8
+ },
9
+ {
10
+ "v": "1.19.1",
11
+ "d": "25/06/2026",
12
+ "type": "Fixed",
13
+ "en": "create_site_from_template now auto-generates a URL-safe slug from the site name when none is supplied, fixing the 400 error returned by the backend…",
14
+ "vi": "create_site_from_template nay tự động tạo slug an toàn cho URL từ tên site khi không có slug nào được cung cấp, khắc phục lỗi 400 mà backend trả về…"
15
+ },
2
16
  {
3
17
  "v": "1.19.0",
4
18
  "d": "25/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Changed",
27
41
  "en": "get_http_function and get_site_custom_code now include a \"File / media uploads\" section in the embedded HTTP function guide, documenting three ways…",
28
42
  "vi": "get_http_function và get_site_custom_code nay bổ sung mục \"File / media uploads\" vào hướng dẫn HTTP function nhúng sẵn, tài liệu hóa ba cách tải…"
29
- },
30
- {
31
- "v": "1.17.0",
32
- "d": "24/06/2026",
33
- "type": "Added",
34
- "en": "New new_row tool builds a multi-column responsive row container where child elements are placed side by side (left to right); the row auto-collapses…",
35
- "vi": "Tool mới new_row tạo một container đa cột responsive với các phần tử con được đặt nằm ngang cạnh nhau (từ trái sang phải); hàng tự động thu gọn…"
36
- },
37
- {
38
- "v": "1.16.0",
39
- "d": "24/06/2026",
40
- "type": "Added",
41
- "en": "New uninstall_app, update_app, and update_app_review tools complete the app lifecycle: uninstall an installed app by subscription id, patch an app's…",
42
- "vi": "Tool mới uninstall_app, update_app và update_app_review hoàn thiện vòng đời ứng dụng: gỡ cài đặt theo subscription id, cập nhật config/status của…"
43
43
  }
44
44
  ]
@@ -172,8 +172,19 @@ update_page_element(s), colours/fonts via the site-style tools, then publish_sit
172
172
  switch_to: z.boolean().default(true).describe("Switch the session to the new site after creating it (saved for next session)"),
173
173
  }, ({ name, theme_id, slug, switch_to }) => handle(async () => {
174
174
  let res;
175
+ // The import_store_to_theme endpoint REQUIRES a slug — omitting it 400s ("Bad
176
+ // Request"). Auto-generate a URL-safe one from the name when the caller didn't pass it.
177
+ const safeSlug = (slug && slug.trim()) ||
178
+ (name || "site")
179
+ .toLowerCase()
180
+ .normalize("NFD")
181
+ .replace(/[̀-ͯ]/g, "")
182
+ .replace(/đ/g, "d")
183
+ .replace(/[^a-z0-9]+/g, "-")
184
+ .replace(/^-+|-+$/g, "")
185
+ .slice(0, 48) || `site-${Date.now().toString(36)}`;
175
186
  try {
176
- res = await api.importStoreToTheme({ id: theme_id, name, ...(slug ? { slug } : {}) });
187
+ res = await api.importStoreToTheme({ id: theme_id, name, slug: safeSlug });
177
188
  }
178
189
  catch (e) {
179
190
  const msg = e instanceof Error ? e.message : String(e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-storefront-mcp",
3
- "version": "1.19.0",
3
+ "version": "1.20.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",