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.
- package/dist/builder/attributes.js +11 -11
- package/dist/builder/factory.js +50 -8
- package/dist/builder/guide.js +12 -3
- package/dist/builder/templates.js +61 -32
- package/dist/changelog.json +14 -14
- package/dist/tools/site-style.js +12 -1
- package/package.json +1 -1
|
@@ -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: "
|
|
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: "
|
|
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" },
|
package/dist/builder/factory.js
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
|
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 =
|
|
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 = {}) => {
|
package/dist/builder/guide.js
CHANGED
|
@@ -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:
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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: {
|
|
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 },
|
|
222
|
-
{ type: "phone-number", opts: { specials: { field_name: "phone_number", label: "Số điện thoại", required: true, show_label: true },
|
|
223
|
-
{ type: "
|
|
224
|
-
{ type: "
|
|
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: "
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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: {
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/changelog.json
CHANGED
|
@@ -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
|
]
|
package/dist/tools/site-style.js
CHANGED
|
@@ -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,
|
|
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.
|
|
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",
|