webcake-storefront-mcp 1.18.1 → 1.19.1
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 +19 -6
- package/dist/builder/templates.js +411 -0
- package/dist/changelog.json +14 -14
- package/dist/tools/builder.js +75 -37
- package/dist/tools/context.js +2 -2
- package/dist/tools/global-section-write.js +91 -0
- package/dist/tools/products.js +6 -1
- 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
|
|
|
@@ -161,9 +170,13 @@ set \`type\` accordingly so the binding source is turned on. A binding target li
|
|
|
161
170
|
## Build the WHOLE storefront — every page to the SAME standard (NOT just the home page)
|
|
162
171
|
A shop is multi-page. Build EACH page to a real e-commerce standard with the same palette,
|
|
163
172
|
spacing and header/footer — never leave the home page rich and the rest as bare stubs.
|
|
164
|
-
\`scaffold_store_pages\`
|
|
165
|
-
|
|
166
|
-
|
|
173
|
+
\`scaffold_store_pages\` now builds FULLY-DESIGNED, palette-aware store pages by DEFAULT
|
|
174
|
+
(style:"rich") — banner+breadcrumb+styled grid (Category), 2-col gallery|info with price/
|
|
175
|
+
quantity/add-to-cart+trust badges+related (Product), 2-col cart|summary, 2-col checkout
|
|
176
|
+
form|summary, centred thank-you — and auto-wires navigation between them. Then add chrome with
|
|
177
|
+
\`scaffold_global_sections({ brand, contact })\` for a designed Header+Footer in one call. Pass
|
|
178
|
+
style:"minimal" only if you want bare stubs to hand-build. The per-page recipe each rich page
|
|
179
|
+
follows (so you can match it when editing or building extra pages):
|
|
167
180
|
- Category (collections, type store): banner + heading + grid-product (+ optional intro/CTA).
|
|
168
181
|
- Product detail (products, type store): 2-col [product-gallery | info: text-dataset
|
|
169
182
|
product::product_name + product_price/original_price + short_description, quantity-input,
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// High-level, palette-aware PAGE & CHROME templates.
|
|
2
|
+
//
|
|
3
|
+
// The low-level builders (new_section/new_element/new_row -> buildSection/buildRow/
|
|
4
|
+
// buildElement) are flexible but leave the AI to hand-compose every node, so generated
|
|
5
|
+
// store pages end up bare. These templates encode the per-page recipes from the BUILD_GUIDE
|
|
6
|
+
// as ready-to-save `{ sections: [...] }` sources that already look like a real shop:
|
|
7
|
+
// styled headings, breadcrumbs, 2-column product detail, styled product-grid cards, trust
|
|
8
|
+
// badges, an order summary, and a designed header/footer.
|
|
9
|
+
//
|
|
10
|
+
// Everything is built through buildSection/buildRow so the output is the same runtime shape
|
|
11
|
+
// the builder emits; finalizeForRender() (called by the tools) expands it to bp1..bp4.
|
|
12
|
+
//
|
|
13
|
+
// Colours use the site theme CSS vars by default (var(--color_02) accent, var(--color_00)
|
|
14
|
+
// text) so a generated site matches whatever palette the theme already defines; callers can
|
|
15
|
+
// override any slot via a Palette object.
|
|
16
|
+
import { buildSection, walk } from "./page.js";
|
|
17
|
+
import { normalizeEvents } from "./events.js";
|
|
18
|
+
const DEFAULT_PALETTE = {
|
|
19
|
+
accent: "var(--color_02)",
|
|
20
|
+
onAccent: "#ffffff",
|
|
21
|
+
text: "var(--color_00)",
|
|
22
|
+
muted: "#6b7280",
|
|
23
|
+
surface: "#ffffff",
|
|
24
|
+
surfaceAlt: "#f7f5f2",
|
|
25
|
+
border: "#e8e3dc",
|
|
26
|
+
};
|
|
27
|
+
export function resolvePalette(p = {}) {
|
|
28
|
+
return { ...DEFAULT_PALETTE, ...Object.fromEntries(Object.entries(p).filter(([, v]) => v != null)) };
|
|
29
|
+
}
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// small spec helpers (return element specs for buildSection/buildRow children)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
const heading = (text, opts = {}) => ({
|
|
34
|
+
type: "text",
|
|
35
|
+
opts: {
|
|
36
|
+
text,
|
|
37
|
+
specials: { tag: opts.tag || "h2" },
|
|
38
|
+
style: {
|
|
39
|
+
fontSize: `${opts.size ?? 30}px`,
|
|
40
|
+
fontWeight: String(opts.weight ?? 700),
|
|
41
|
+
color: opts.color,
|
|
42
|
+
textAlign: opts.align,
|
|
43
|
+
lineHeight: "1.25",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const paragraph = (text, p, opts = {}) => ({
|
|
48
|
+
type: "text",
|
|
49
|
+
opts: {
|
|
50
|
+
text,
|
|
51
|
+
style: {
|
|
52
|
+
fontSize: `${opts.size ?? 16}px`,
|
|
53
|
+
fontWeight: "400",
|
|
54
|
+
color: opts.color || p.muted,
|
|
55
|
+
textAlign: opts.align,
|
|
56
|
+
lineHeight: "1.6",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
/** Accent CTA button. `navTo` is a slug sentinel the scaffolder resolves to a real
|
|
61
|
+
* open_page event once all pages exist (see wireNavigation). The sentinel rides INSIDE an
|
|
62
|
+
* events entry because the button factory keeps opts.events but drops arbitrary specials. */
|
|
63
|
+
const cta = (text, p, opts = {}) => ({
|
|
64
|
+
type: "button",
|
|
65
|
+
opts: {
|
|
66
|
+
text,
|
|
67
|
+
...(opts.events || opts.navTo ? { events: [...(opts.events || []), ...(opts.navTo ? [{ action: "open_page", _navTo: opts.navTo }] : [])] } : {}),
|
|
68
|
+
style: {
|
|
69
|
+
background: p.accent,
|
|
70
|
+
color: p.onAccent,
|
|
71
|
+
borderRadius: "10px",
|
|
72
|
+
height: opts.height ?? 50,
|
|
73
|
+
fontWeight: "600",
|
|
74
|
+
fontSize: "16px",
|
|
75
|
+
paddingLeft: "28px",
|
|
76
|
+
paddingRight: "28px",
|
|
77
|
+
...(opts.full ? { width: "100%" } : {}),
|
|
78
|
+
textAlign: "center",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const outlineBtn = (text, p, opts = {}) => ({
|
|
83
|
+
type: "button",
|
|
84
|
+
opts: {
|
|
85
|
+
text,
|
|
86
|
+
...(opts.navTo ? { events: [{ action: "open_page", _navTo: opts.navTo }] } : {}),
|
|
87
|
+
style: {
|
|
88
|
+
background: "transparent",
|
|
89
|
+
color: p.accent,
|
|
90
|
+
border: `1px solid ${p.accent}`,
|
|
91
|
+
borderRadius: "10px",
|
|
92
|
+
height: opts.height ?? 48,
|
|
93
|
+
fontWeight: "600",
|
|
94
|
+
fontSize: "15px",
|
|
95
|
+
paddingLeft: "24px",
|
|
96
|
+
paddingRight: "24px",
|
|
97
|
+
textAlign: "center",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
/** A small "icon + label" trust badge (free shipping, authentic, support…). */
|
|
102
|
+
const trustBadge = (icon, label, p) => ({
|
|
103
|
+
type: "container",
|
|
104
|
+
layout: "row",
|
|
105
|
+
columnGap: 10,
|
|
106
|
+
colWidths: [{ unit: "px", absValue: 30 }, { unit: "fr", value: 1 }],
|
|
107
|
+
collapse: { bp4: 2 },
|
|
108
|
+
children: [
|
|
109
|
+
{ type: "text", opts: { text: icon, style: { fontSize: "22px", lineHeight: "1.2" } } },
|
|
110
|
+
{ type: "text", opts: { text: label, style: { fontSize: "13px", fontWeight: "600", color: p.text, lineHeight: "1.3" } } },
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
/** A product-grid card config matching real designer templates. grid-product self-renders
|
|
114
|
+
* image + name + price; the factory now ships the real designer defaults (image_ratio 4/5,
|
|
115
|
+
* products_per_load 36, gap_column 30/gap_row 15, bold price, show original/discount price),
|
|
116
|
+
* so here we only set the column count + the price accent colour from the palette. */
|
|
117
|
+
const productGrid = (p, columns = 4) => ({
|
|
118
|
+
type: "grid-product",
|
|
119
|
+
opts: {
|
|
120
|
+
config: {
|
|
121
|
+
columns,
|
|
122
|
+
productPriceColor: p.accent,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
/** Dataset text bound to a product field (name/price/description). */
|
|
127
|
+
const productField = (field, style) => ({
|
|
128
|
+
type: "text-dataset",
|
|
129
|
+
opts: {
|
|
130
|
+
bindings: [{ id: "BINDING" + Math.random().toString(36).slice(2, 8), name: "product", target: `product::${field}` }],
|
|
131
|
+
style,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
const sectionStyle = (p, bg, padY = 64) => ({
|
|
135
|
+
style: { background: bg, paddingTop: padY, paddingBottom: padY, paddingLeft: 20, paddingRight: 20 },
|
|
136
|
+
});
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// STORE PAGE templates -> { sections: [...] }
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
export function categoryPageSource(p, opts = {}) {
|
|
141
|
+
const banner = buildSection([
|
|
142
|
+
{ type: "breadcrumb", opts: { style: { fontSize: "13px", color: p.muted } } },
|
|
143
|
+
heading(opts.title || "Tất cả sản phẩm", { tag: "h1", size: 40, color: p.text, align: "center" }),
|
|
144
|
+
paragraph(opts.subtitle || "Khám phá bộ sưu tập của chúng tôi", p, { align: "center", size: 17 }),
|
|
145
|
+
], { ...sectionStyle(p, p.surfaceAlt, 56), rowGap: 12 });
|
|
146
|
+
const grid = buildSection([productGrid(p, 4)], sectionStyle(p, p.surface, 56));
|
|
147
|
+
return { sections: [banner, grid] };
|
|
148
|
+
}
|
|
149
|
+
export function productPageSource(p) {
|
|
150
|
+
const breadcrumbRow = buildSection([{ type: "breadcrumb", opts: { style: { fontSize: "13px", color: p.muted } } }], { ...sectionStyle(p, p.surface, 24) });
|
|
151
|
+
const infoColumn = {
|
|
152
|
+
type: "container",
|
|
153
|
+
children: [
|
|
154
|
+
productField("product_name", { fontSize: "30px", fontWeight: "700", color: p.text, lineHeight: "1.25" }),
|
|
155
|
+
productField("product_price", { fontSize: "26px", fontWeight: "700", color: p.accent }),
|
|
156
|
+
productField("product_description", { fontSize: "15px", fontWeight: "400", color: p.muted, lineHeight: "1.6" }),
|
|
157
|
+
{ type: "quantity-input", opts: { style: { borderColor: p.border, borderRadius: "8px" } } },
|
|
158
|
+
cta("Thêm vào giỏ", p, { full: true, height: 52, events: [{ action: "add_to_cart", open_page: "cart" }] }),
|
|
159
|
+
{
|
|
160
|
+
type: "container",
|
|
161
|
+
layout: "row",
|
|
162
|
+
columnGap: 16,
|
|
163
|
+
collapse: { bp4: 1 },
|
|
164
|
+
children: [
|
|
165
|
+
trustBadge("🚚", "Miễn phí giao hàng", p),
|
|
166
|
+
trustBadge("✅", "Hàng chính hãng", p),
|
|
167
|
+
trustBadge("↩️", "Đổi trả 7 ngày", p),
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
};
|
|
172
|
+
const detail = buildSection([
|
|
173
|
+
{
|
|
174
|
+
type: "container",
|
|
175
|
+
layout: "row",
|
|
176
|
+
columnGap: 48,
|
|
177
|
+
colWidths: [{ unit: "fr", value: 1 }, { unit: "fr", value: 1 }],
|
|
178
|
+
collapse: { bp3: 1, bp4: 1 },
|
|
179
|
+
children: [{ type: "product-gallery", opts: { style: { borderRadius: "14px" } } }, infoColumn],
|
|
180
|
+
},
|
|
181
|
+
], sectionStyle(p, p.surface, 32));
|
|
182
|
+
const related = buildSection([heading("Sản phẩm liên quan", { tag: "h2", size: 26, color: p.text, align: "center" }), productGrid(p, 4)], { ...sectionStyle(p, p.surfaceAlt, 56), rowGap: 24 });
|
|
183
|
+
return { sections: [breadcrumbRow, detail, related] };
|
|
184
|
+
}
|
|
185
|
+
export function cartPageSource(p) {
|
|
186
|
+
const summary = {
|
|
187
|
+
type: "container",
|
|
188
|
+
opts: { style: { background: p.surfaceAlt, borderRadius: "14px", padding: "24px", border: `1px solid ${p.border}` } },
|
|
189
|
+
children: [
|
|
190
|
+
heading("Tóm tắt đơn hàng", { tag: "h3", size: 20, color: p.text }),
|
|
191
|
+
paragraph("Phí vận chuyển và mã giảm giá sẽ được tính ở bước thanh toán.", p, { size: 14 }),
|
|
192
|
+
cta("Tiến hành thanh toán", p, { full: true, height: 52, navTo: "checkout" }),
|
|
193
|
+
outlineBtn("Tiếp tục mua sắm", p, { navTo: "home" }),
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
const body = buildSection([
|
|
197
|
+
heading("Giỏ hàng của bạn", { tag: "h1", size: 34, color: p.text }),
|
|
198
|
+
{
|
|
199
|
+
type: "container",
|
|
200
|
+
layout: "row",
|
|
201
|
+
columnGap: 40,
|
|
202
|
+
colWidths: [{ unit: "fr", value: 2 }, { unit: "fr", value: 1 }],
|
|
203
|
+
collapse: { bp3: 1, bp4: 1 },
|
|
204
|
+
children: [{ type: "cart-items", opts: {} }, summary],
|
|
205
|
+
},
|
|
206
|
+
], { ...sectionStyle(p, p.surface, 48), rowGap: 24 });
|
|
207
|
+
return { sections: [body] };
|
|
208
|
+
}
|
|
209
|
+
export function checkoutPageSource(p) {
|
|
210
|
+
// form_order config keys mirror real designer templates: input background, placeholder
|
|
211
|
+
// colour, label colour/gap, input padding. The success event (eventName defaults to
|
|
212
|
+
// "success" for a form) redirects to the Thank-you page once it exists (navTo sentinel).
|
|
213
|
+
const fieldStyle = { borderColor: p.border, borderRadius: "8px" };
|
|
214
|
+
const formConfig = {
|
|
215
|
+
backgroundInput: "#ffffff",
|
|
216
|
+
placeholderColor: p.muted,
|
|
217
|
+
labelColor: p.text,
|
|
218
|
+
labelMarginBottom: 8,
|
|
219
|
+
textPadding: 15,
|
|
220
|
+
columnGap: 15,
|
|
221
|
+
rowGap: 15,
|
|
222
|
+
};
|
|
223
|
+
const form = {
|
|
224
|
+
type: "form",
|
|
225
|
+
opts: {
|
|
226
|
+
specials: { type: "form_order" },
|
|
227
|
+
config: formConfig,
|
|
228
|
+
events: [{ action: "open_page", _navTo: "complete" }],
|
|
229
|
+
style: { background: p.surface },
|
|
230
|
+
},
|
|
231
|
+
children: [
|
|
232
|
+
{ 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 } },
|
|
233
|
+
{ 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 } },
|
|
234
|
+
{ type: "email", opts: { specials: { field_name: "email", label: "Email", placeholder: "email@example.com", show_label: true }, config: formConfig, style: fieldStyle } },
|
|
235
|
+
{ type: "address", opts: { specials: { field_name: "address", label: "Địa chỉ nhận hàng", show_label: true }, config: formConfig, style: fieldStyle } },
|
|
236
|
+
{ 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 } },
|
|
237
|
+
{ 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" } } },
|
|
238
|
+
],
|
|
239
|
+
};
|
|
240
|
+
const summary = {
|
|
241
|
+
type: "container",
|
|
242
|
+
opts: { style: { background: p.surfaceAlt, borderRadius: "14px", padding: "24px", border: `1px solid ${p.border}` } },
|
|
243
|
+
children: [heading("Đơn hàng của bạn", { tag: "h3", size: 20, color: p.text }), { type: "cart-items", opts: {} }],
|
|
244
|
+
};
|
|
245
|
+
const body = buildSection([
|
|
246
|
+
heading("Thanh toán", { tag: "h1", size: 34, color: p.text }),
|
|
247
|
+
{
|
|
248
|
+
type: "container",
|
|
249
|
+
layout: "row",
|
|
250
|
+
columnGap: 40,
|
|
251
|
+
colWidths: [{ unit: "fr", value: 3 }, { unit: "fr", value: 2 }],
|
|
252
|
+
collapse: { bp3: 1, bp4: 1 },
|
|
253
|
+
children: [form, summary],
|
|
254
|
+
},
|
|
255
|
+
], { ...sectionStyle(p, p.surface, 48), rowGap: 24 });
|
|
256
|
+
return { sections: [body] };
|
|
257
|
+
}
|
|
258
|
+
export function thankYouPageSource(p) {
|
|
259
|
+
const body = buildSection([
|
|
260
|
+
heading("🎉", { tag: "h2", size: 56, align: "center" }),
|
|
261
|
+
heading("Cảm ơn bạn đã đặt hàng!", { tag: "h1", size: 34, color: p.text, align: "center" }),
|
|
262
|
+
paragraph("Chúng tôi đã nhận được đơn hàng của bạn và sẽ liên hệ xác nhận trong thời gian sớm nhất.", p, { align: "center", size: 17 }),
|
|
263
|
+
{ type: "order-items", opts: {} },
|
|
264
|
+
cta("Tiếp tục mua sắm", p, { navTo: "home" }),
|
|
265
|
+
], { ...sectionStyle(p, p.surface, 64), rowGap: 18 });
|
|
266
|
+
return { sections: [body] };
|
|
267
|
+
}
|
|
268
|
+
export function headerSection(opts = {}) {
|
|
269
|
+
const p = resolvePalette(opts.palette);
|
|
270
|
+
const links = opts.links && opts.links.length ? opts.links : [
|
|
271
|
+
{ label: "Trang chủ", navTo: "home" },
|
|
272
|
+
{ label: "Sản phẩm", navTo: "collections" },
|
|
273
|
+
{ label: "Giỏ hàng", navTo: "cart" },
|
|
274
|
+
];
|
|
275
|
+
const logo = { type: "text", opts: { text: opts.brand || "Shop", specials: { tag: "h2" }, style: { fontSize: "24px", fontWeight: "800", color: p.text } } };
|
|
276
|
+
const nav = {
|
|
277
|
+
type: "container",
|
|
278
|
+
layout: "row",
|
|
279
|
+
columnGap: 28,
|
|
280
|
+
collapse: { bp4: 3 },
|
|
281
|
+
children: links.map((l) => ({
|
|
282
|
+
type: "text",
|
|
283
|
+
opts: {
|
|
284
|
+
text: l.label,
|
|
285
|
+
...(l.navTo || l.url ? { specials: { ...(l.navTo ? { _navTo: l.navTo } : {}), ...(l.url ? { _navUrl: l.url } : {}) } } : {}),
|
|
286
|
+
style: { fontSize: "15px", fontWeight: "600", color: p.text, cursor: "pointer" },
|
|
287
|
+
},
|
|
288
|
+
})),
|
|
289
|
+
};
|
|
290
|
+
const actions = {
|
|
291
|
+
type: "container",
|
|
292
|
+
layout: "row",
|
|
293
|
+
columnGap: 16,
|
|
294
|
+
colWidths: [{ unit: "px", absValue: 32 }, { unit: "max-c" }],
|
|
295
|
+
collapse: { bp4: 2 },
|
|
296
|
+
children: [
|
|
297
|
+
{ type: "cart-icon", opts: { style: { width: 26, height: 26, color: p.text } } },
|
|
298
|
+
cta(opts.cta || "Đặt mua ngay", p, { navTo: "collections", height: 44 }),
|
|
299
|
+
],
|
|
300
|
+
};
|
|
301
|
+
return buildSection([
|
|
302
|
+
{
|
|
303
|
+
type: "container",
|
|
304
|
+
layout: "row",
|
|
305
|
+
columnGap: 24,
|
|
306
|
+
colWidths: [{ unit: "max-c" }, { unit: "fr", value: 1 }, { unit: "max-c" }],
|
|
307
|
+
collapse: { bp4: 1 },
|
|
308
|
+
children: [logo, nav, actions],
|
|
309
|
+
},
|
|
310
|
+
], { style: { background: p.surface, paddingTop: 16, paddingBottom: 16, paddingLeft: 20, paddingRight: 20, borderBottom: `1px solid ${p.border}` } });
|
|
311
|
+
}
|
|
312
|
+
export function footerSection(opts = {}) {
|
|
313
|
+
const p = resolvePalette(opts.palette);
|
|
314
|
+
const brandCol = {
|
|
315
|
+
type: "container",
|
|
316
|
+
children: [
|
|
317
|
+
{ type: "text", opts: { text: opts.brand || "Shop", specials: { tag: "h3" }, style: { fontSize: "20px", fontWeight: "800", color: "#fff" } } },
|
|
318
|
+
{ type: "text", opts: { text: opts.tagline || "Cảm ơn bạn đã ghé thăm cửa hàng.", style: { fontSize: "14px", color: "rgba(255,255,255,0.7)", lineHeight: "1.6" } } },
|
|
319
|
+
],
|
|
320
|
+
};
|
|
321
|
+
const linkCols = (opts.columns && opts.columns.length ? opts.columns : [
|
|
322
|
+
{ title: "Cửa hàng", links: ["Trang chủ", "Sản phẩm", "Giỏ hàng"] },
|
|
323
|
+
{ title: "Hỗ trợ", links: ["Chính sách đổi trả", "Giao hàng", "Liên hệ"] },
|
|
324
|
+
]).map((col) => ({
|
|
325
|
+
type: "container",
|
|
326
|
+
children: [
|
|
327
|
+
{ type: "text", opts: { text: col.title, style: { fontSize: "15px", fontWeight: "700", color: "#fff" } } },
|
|
328
|
+
...col.links.map((t) => ({ type: "text", opts: { text: t, style: { fontSize: "14px", color: "rgba(255,255,255,0.7)", lineHeight: "2" } } })),
|
|
329
|
+
],
|
|
330
|
+
}));
|
|
331
|
+
const c = opts.contact || {};
|
|
332
|
+
const contactCol = {
|
|
333
|
+
type: "container",
|
|
334
|
+
children: [
|
|
335
|
+
{ type: "text", opts: { text: "Liên hệ", style: { fontSize: "15px", fontWeight: "700", color: "#fff" } } },
|
|
336
|
+
...(c.phone ? [{ type: "text", opts: { text: `📞 ${c.phone}`, style: { fontSize: "14px", color: "rgba(255,255,255,0.7)", lineHeight: "2" } } }] : []),
|
|
337
|
+
...(c.email ? [{ type: "text", opts: { text: `✉️ ${c.email}`, style: { fontSize: "14px", color: "rgba(255,255,255,0.7)", lineHeight: "2" } } }] : []),
|
|
338
|
+
...(c.address ? [{ type: "text", opts: { text: `📍 ${c.address}`, style: { fontSize: "14px", color: "rgba(255,255,255,0.7)", lineHeight: "1.6" } } }] : []),
|
|
339
|
+
],
|
|
340
|
+
};
|
|
341
|
+
const topRow = {
|
|
342
|
+
type: "container",
|
|
343
|
+
layout: "row",
|
|
344
|
+
columnGap: 40,
|
|
345
|
+
colWidths: [{ unit: "fr", value: 2 }, { unit: "fr", value: 1 }, { unit: "fr", value: 1 }, { unit: "fr", value: 1 }],
|
|
346
|
+
collapse: { bp3: 2, bp4: 1 },
|
|
347
|
+
children: [brandCol, ...linkCols, contactCol],
|
|
348
|
+
};
|
|
349
|
+
const copyright = { type: "text", opts: { text: `© ${opts.brand || "Shop"}. All rights reserved.`, style: { fontSize: "13px", color: "rgba(255,255,255,0.5)", textAlign: "center" } } };
|
|
350
|
+
return buildSection([topRow, copyright], {
|
|
351
|
+
style: { background: "#1c1917", paddingTop: 56, paddingBottom: 32, paddingLeft: 20, paddingRight: 20 },
|
|
352
|
+
rowGap: 32,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// registry the scaffolder iterates
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
/**
|
|
359
|
+
* Resolve the `_navTo` / `_navUrl` sentinels the templates leave on buttons/links into real
|
|
360
|
+
* open_page / open_link events, now that every page exists and we know its id. `slugToId`
|
|
361
|
+
* maps a page slug (e.g. "checkout", "cart", "collections") to its page id; the special
|
|
362
|
+
* slug "home" maps to the homepage id. Returns the number of nodes wired so the caller can
|
|
363
|
+
* skip re-saving an unchanged page. Run on the raw source BEFORE finalizeForRender.
|
|
364
|
+
*/
|
|
365
|
+
export function wireNavigation(source, slugToId) {
|
|
366
|
+
let wired = 0;
|
|
367
|
+
const resolve = (slug) => slugToId[slug] || slugToId[slug.replace(/^\//, "")];
|
|
368
|
+
walk(source, (node) => {
|
|
369
|
+
// (a) text links carry the sentinel in specials (the text factory keeps it).
|
|
370
|
+
const sp = node && node.specials;
|
|
371
|
+
if (sp && sp._navTo) {
|
|
372
|
+
const id = resolve(sp._navTo);
|
|
373
|
+
if (id) {
|
|
374
|
+
node.events = normalizeEvents([{ action: "open_page", open_page_id: id }], node.type);
|
|
375
|
+
wired++;
|
|
376
|
+
}
|
|
377
|
+
delete sp._navTo;
|
|
378
|
+
}
|
|
379
|
+
if (sp && sp._navUrl) {
|
|
380
|
+
node.events = normalizeEvents([{ action: "open_link", link_target: sp._navUrl, link_target_url: sp._navUrl }], node.type);
|
|
381
|
+
delete sp._navUrl;
|
|
382
|
+
wired++;
|
|
383
|
+
}
|
|
384
|
+
// (b) buttons carry the sentinel inside an events entry (the button factory keeps events
|
|
385
|
+
// but drops arbitrary specials). Resolve _navTo -> open_page_id in place; drop the
|
|
386
|
+
// event entirely if its target page doesn't exist so we never emit a dead open_page.
|
|
387
|
+
if (Array.isArray(node && node.events) && node.events.length) {
|
|
388
|
+
node.events = node.events
|
|
389
|
+
.map((e) => {
|
|
390
|
+
if (e && e._navTo) {
|
|
391
|
+
const id = resolve(e._navTo);
|
|
392
|
+
const { _navTo, ...rest } = e;
|
|
393
|
+
if (!id)
|
|
394
|
+
return null;
|
|
395
|
+
wired++;
|
|
396
|
+
return { ...rest, open_page_id: id };
|
|
397
|
+
}
|
|
398
|
+
return e;
|
|
399
|
+
})
|
|
400
|
+
.filter(Boolean);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
return wired;
|
|
404
|
+
}
|
|
405
|
+
export const STORE_PAGE_TEMPLATES = [
|
|
406
|
+
{ name: "Category Page", slug: "collections", build: categoryPageSource },
|
|
407
|
+
{ name: "Product Page", slug: "products", build: productPageSource },
|
|
408
|
+
{ name: "Cart Page", slug: "cart", build: cartPageSource },
|
|
409
|
+
{ name: "Checkout Page", slug: "checkout", build: checkoutPageSource },
|
|
410
|
+
{ name: "Thank You Page", slug: "complete", build: thankYouPageSource },
|
|
411
|
+
];
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.19.1",
|
|
4
|
+
"d": "25/06/2026",
|
|
5
|
+
"type": "Fixed",
|
|
6
|
+
"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…",
|
|
7
|
+
"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ề…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.19.0",
|
|
11
|
+
"d": "25/06/2026",
|
|
12
|
+
"type": "Added",
|
|
13
|
+
"en": "New scaffold_global_sections tool generates a fully-designed global Header and Footer in one call — logo, nav links, cart icon, CTA button, and a…",
|
|
14
|
+
"vi": "Tool mới scaffold_global_sections tạo Header và Footer toàn cục đã thiết kế sẵn trong một lần gọi — logo, liên kết điều hướng, icon giỏ hàng, nút…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.18.1",
|
|
4
18
|
"d": "25/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Added",
|
|
27
41
|
"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…",
|
|
28
42
|
"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…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.16.0",
|
|
32
|
-
"d": "24/06/2026",
|
|
33
|
-
"type": "Added",
|
|
34
|
-
"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…",
|
|
35
|
-
"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…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.15.1",
|
|
39
|
-
"d": "24/06/2026",
|
|
40
|
-
"type": "Fixed",
|
|
41
|
-
"en": "update_page_element, update_page_elements, update_global_source_element, and update_global_source_elements now normalize events and bindings arrays…",
|
|
42
|
-
"vi": "update_page_element, update_page_elements, update_global_source_element và update_global_source_elements nay chuẩn hóa mảng events và bindings khi…"
|
|
43
43
|
}
|
|
44
44
|
]
|
package/dist/tools/builder.js
CHANGED
|
@@ -4,6 +4,7 @@ import { listElements, getElement, buildElement } from "../builder/catalog.js";
|
|
|
4
4
|
import { describeEventsCatalog } from "../builder/events.js";
|
|
5
5
|
import { describeBindingsCatalog } from "../builder/bindings.js";
|
|
6
6
|
import { buildSection, buildRow, newPageSkeleton, validatePage, finalizeForRender, reassignIds, } from "../builder/page.js";
|
|
7
|
+
import { STORE_PAGE_TEMPLATES, resolvePalette, wireNavigation } from "../builder/templates.js";
|
|
7
8
|
// Recursive spec for new_section / build_page children.
|
|
8
9
|
const elementSpec = z.object({
|
|
9
10
|
type: z.string().describe("Element type (see list_elements)"),
|
|
@@ -198,13 +199,19 @@ Creates only the ones MISSING (matched by slug): Category (collections), Product
|
|
|
198
199
|
Cart (cart), Checkout (checkout), Thank-you (complete) — all type 'store' (enables use_store),
|
|
199
200
|
plus optional member (login/register/profile) and blog (blog/post) pages.
|
|
200
201
|
Run this right after you create products/categories. dry_run=true (default) previews.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
By default (style:"rich") each store page is a FULLY-DESIGNED, palette-aware page — banner +
|
|
203
|
+
breadcrumb + styled product-grid cards (Category), 2-column gallery|info with price/quantity/
|
|
204
|
+
add-to-cart + trust badges + related products (Product), 2-col cart|summary, 2-col checkout
|
|
205
|
+
form|summary, and a centred thank-you — and navigation between them (cart→checkout, add-to-cart
|
|
206
|
+
→cart, thank-you→home) is auto-wired. Colours follow the site theme vars unless you pass a palette.
|
|
207
|
+
Still add a GLOBAL header/footer with scaffold_global_sections (or create_global_section).
|
|
208
|
+
Pass style:"minimal" for the old bare stubs (heading + binding element only).`, {
|
|
209
|
+
style: z.enum(["rich", "minimal"]).default("rich").describe("'rich' = fully-designed, palette-aware store pages (default). 'minimal' = bare starter stubs you must enrich yourself."),
|
|
210
|
+
palette: z.record(z.any()).optional().describe("Optional colour overrides for rich pages: { accent, onAccent, text, muted, surface, surfaceAlt, border }. Defaults to the site theme CSS vars (var(--color_02) accent, var(--color_00) text)."),
|
|
204
211
|
include_member: z.boolean().default(false).describe("Also create login/register/profile (type member, use_member)"),
|
|
205
212
|
include_blog: z.boolean().default(false).describe("Also create blog list + post pages (type blog, use_blog)"),
|
|
206
213
|
dry_run: z.boolean().default(true).describe("Preview (true) or actually create the missing pages (false)"),
|
|
207
|
-
}, ({ include_member, include_blog, dry_run }) => handle(async () => {
|
|
214
|
+
}, ({ style, palette, include_member, include_blog, dry_run }) => handle(async () => {
|
|
208
215
|
const bind = (name, field) => ({
|
|
209
216
|
id: "BINDING" + Math.random().toString(36).slice(2, 8),
|
|
210
217
|
name,
|
|
@@ -212,38 +219,38 @@ storefront"), and add a GLOBAL header/footer (create_global_section). Don't leav
|
|
|
212
219
|
});
|
|
213
220
|
const h1 = (text) => ({ type: "text", opts: { text, specials: { tag: "h1" }, style: { fontSize: "32px", fontWeight: "700" } } });
|
|
214
221
|
const accentBtn = (text, type = "button") => ({ type, opts: { text, style: { background: "var(--color_02)", color: "#fff", borderRadius: "8px", height: 48, fontWeight: "600" } } });
|
|
222
|
+
// Rich (default) store pages come from the designed, palette-aware templates;
|
|
223
|
+
// 'minimal' falls back to the original bare stubs.
|
|
224
|
+
const pal = resolvePalette(palette || {});
|
|
225
|
+
const minimalStore = {
|
|
226
|
+
collections: () => ({ sections: [buildSection([h1("Danh mục sản phẩm"), { type: "grid-product", opts: { config: { columns: 3, image_ratio: "1/1", gap_column: 24, gap_row: 32 } } }])] }),
|
|
227
|
+
products: () => ({ sections: [buildSection([
|
|
228
|
+
{ type: "product-gallery", opts: {} },
|
|
229
|
+
{ type: "text-dataset", opts: { bindings: [bind("product", "product_name")], style: { fontSize: "28px", fontWeight: "700" } } },
|
|
230
|
+
{ type: "text-dataset", opts: { bindings: [bind("product", "product_price")], style: { fontSize: "22px", fontWeight: "700", color: "var(--color_02)" } } },
|
|
231
|
+
{ type: "quantity-input", opts: {} },
|
|
232
|
+
accentBtn("Thêm vào giỏ"),
|
|
233
|
+
])] }),
|
|
234
|
+
cart: () => ({ sections: [buildSection([h1("Giỏ hàng"), { type: "cart-items", opts: {} }, accentBtn("Tiến hành thanh toán")])] }),
|
|
235
|
+
checkout: () => ({ sections: [buildSection([
|
|
236
|
+
h1("Thanh toán"),
|
|
237
|
+
{ type: "form", opts: { specials: { type: "form_order" } }, children: [
|
|
238
|
+
{ type: "input", opts: { specials: { field_name: "full_name", label: "Họ tên", placeholder: "Họ tên", required: true, show_label: true } } },
|
|
239
|
+
{ type: "phone-number", opts: { specials: { field_name: "phone_number", label: "Số điện thoại", required: true, show_label: true } } },
|
|
240
|
+
{ type: "address", opts: { specials: { field_name: "address", label: "Địa chỉ", show_label: true } } },
|
|
241
|
+
accentBtn("Đặt hàng", "submit-button"),
|
|
242
|
+
] },
|
|
243
|
+
])] }),
|
|
244
|
+
complete: () => ({ sections: [buildSection([h1("Cảm ơn bạn đã đặt hàng!"), { type: "order-items", opts: {} }])] }),
|
|
245
|
+
};
|
|
215
246
|
// Each spec: { name, slug, kind, build() -> { sections } }
|
|
216
|
-
const SPECS =
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
{ type: "text-dataset", opts: { bindings: [bind("product", "product_name")], style: { fontSize: "28px", fontWeight: "700" } } },
|
|
224
|
-
{ type: "text-dataset", opts: { bindings: [bind("product", "product_price")], style: { fontSize: "22px", fontWeight: "700", color: "var(--color_02)" } } },
|
|
225
|
-
{ type: "quantity-input", opts: {} },
|
|
226
|
-
accentBtn("Thêm vào giỏ"),
|
|
227
|
-
]),
|
|
228
|
-
] }) },
|
|
229
|
-
{ name: "Cart Page", slug: "cart", kind: "store", flag: "use_store", build: () => ({ sections: [
|
|
230
|
-
buildSection([h1("Giỏ hàng"), { type: "cart-items", opts: {} }, accentBtn("Tiến hành thanh toán")]),
|
|
231
|
-
] }) },
|
|
232
|
-
{ name: "Checkout Page", slug: "checkout", kind: "store", flag: "use_store", build: () => ({ sections: [
|
|
233
|
-
buildSection([
|
|
234
|
-
h1("Thanh toán"),
|
|
235
|
-
{ type: "form", opts: { specials: { type: "form_order" } }, children: [
|
|
236
|
-
{ type: "input", opts: { specials: { field_name: "full_name", label: "Họ tên", placeholder: "Họ tên", required: true, show_label: true } } },
|
|
237
|
-
{ type: "phone-number", opts: { specials: { field_name: "phone_number", label: "Số điện thoại", required: true, show_label: true } } },
|
|
238
|
-
{ type: "address", opts: { specials: { field_name: "address", label: "Địa chỉ", show_label: true } } },
|
|
239
|
-
accentBtn("Đặt hàng", "submit-button"),
|
|
240
|
-
] },
|
|
241
|
-
]),
|
|
242
|
-
] }) },
|
|
243
|
-
{ name: "Thank You Page", slug: "complete", kind: "store", flag: "use_store", build: () => ({ sections: [
|
|
244
|
-
buildSection([h1("Cảm ơn bạn đã đặt hàng!"), { type: "order-items", opts: {} }]),
|
|
245
|
-
] }) },
|
|
246
|
-
];
|
|
247
|
+
const SPECS = STORE_PAGE_TEMPLATES.map((t) => ({
|
|
248
|
+
name: t.name,
|
|
249
|
+
slug: t.slug,
|
|
250
|
+
kind: "store",
|
|
251
|
+
flag: "use_store",
|
|
252
|
+
build: () => (style === "minimal" ? minimalStore[t.slug]() : t.build(pal)),
|
|
253
|
+
}));
|
|
247
254
|
if (include_member) {
|
|
248
255
|
SPECS.push({ name: "Login Page", slug: "login", kind: "member", flag: "use_member", build: () => ({ sections: [
|
|
249
256
|
buildSection([h1("Đăng nhập"), { type: "form", opts: { specials: { type: "form_login" } }, children: [
|
|
@@ -282,6 +289,18 @@ storefront"), and add a GLOBAL header/footer (create_global_section). Don't leav
|
|
|
282
289
|
const created = [];
|
|
283
290
|
const errors = [];
|
|
284
291
|
const flagsEnabled = new Set();
|
|
292
|
+
// slug -> page id, seeded with the pages that already exist (so cross-page nav
|
|
293
|
+
// resolves even when only some store pages are newly created).
|
|
294
|
+
const slugToId = {};
|
|
295
|
+
for (const pg of Array.isArray(pages) ? pages : []) {
|
|
296
|
+
const sl = (pg.slug || "").replace(/^\//, "");
|
|
297
|
+
if (sl)
|
|
298
|
+
slugToId[sl] = pg.id;
|
|
299
|
+
if (pg.is_homepage)
|
|
300
|
+
slugToId["home"] = pg.id;
|
|
301
|
+
}
|
|
302
|
+
// Keep each newly-built source so we can wire navigation once every id is known.
|
|
303
|
+
const built = [];
|
|
285
304
|
for (const spec of missing) {
|
|
286
305
|
try {
|
|
287
306
|
if (!flagsEnabled.has(spec.flag)) {
|
|
@@ -297,21 +316,40 @@ storefront"), and add a GLOBAL header/footer (create_global_section). Don't leav
|
|
|
297
316
|
finalizeForRender(source);
|
|
298
317
|
const res = await api.createPage({ name: spec.name, source, type: PAGE_TYPE_NUM[spec.kind] });
|
|
299
318
|
const pid = newPageId(res);
|
|
300
|
-
if (pid)
|
|
319
|
+
if (pid) {
|
|
301
320
|
await api.updatePage(pid, { slug: spec.slug }).catch(() => { });
|
|
321
|
+
slugToId[spec.slug] = pid;
|
|
322
|
+
built.push({ spec, pid, source });
|
|
323
|
+
}
|
|
302
324
|
created.push({ name: spec.name, slug: spec.slug, type: spec.kind, page_id: pid });
|
|
303
325
|
}
|
|
304
326
|
catch (e) {
|
|
305
327
|
errors.push({ slug: spec.slug, error: e?.message ?? String(e) });
|
|
306
328
|
}
|
|
307
329
|
}
|
|
330
|
+
// Second pass: resolve the _navTo sentinels the templates left on buttons/links into
|
|
331
|
+
// real open_page events (cart→checkout, thank-you→home, …), now that every page id is
|
|
332
|
+
// known. Only re-save the pages that actually changed.
|
|
333
|
+
let nav_wired = 0;
|
|
334
|
+
for (const { pid, source } of built) {
|
|
335
|
+
try {
|
|
336
|
+
const n = wireNavigation(source, slugToId);
|
|
337
|
+
if (n > 0) {
|
|
338
|
+
nav_wired += n;
|
|
339
|
+
await api.updatePageSource(pid, { source }).catch(() => { });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch { /* nav wiring is best-effort */ }
|
|
343
|
+
}
|
|
308
344
|
return {
|
|
309
345
|
success: true,
|
|
346
|
+
style,
|
|
310
347
|
created,
|
|
311
348
|
already_exist: skipped,
|
|
312
349
|
...(errors.length ? { errors } : {}),
|
|
313
350
|
data_sources_enabled: [...flagsEnabled],
|
|
314
|
-
|
|
351
|
+
nav_links_wired: nav_wired,
|
|
352
|
+
note: "Add a header/footer with scaffold_global_sections, then publish_site to take the new pages live.",
|
|
315
353
|
};
|
|
316
354
|
}));
|
|
317
355
|
}
|
package/dist/tools/context.js
CHANGED
|
@@ -34,8 +34,8 @@ export function registerContextTools(server, api, handle) {
|
|
|
34
34
|
"create_site (tên + slug) → tự chuyển sang site mới",
|
|
35
35
|
"create_product_category + create_product cho từng sản phẩm (ảnh từ search_images/upload_images trước)",
|
|
36
36
|
"build_page trang chủ (type:'main', is_homepage:true) với hero, lưới sản phẩm, câu chuyện, CTA, …",
|
|
37
|
-
"scaffold_store_pages
|
|
38
|
-
"
|
|
37
|
+
"scaffold_store_pages (style:'rich' mặc định) → trang danh mục/chi tiết/giỏ/thanh toán/cảm ơn ĐÃ thiết kế sẵn, tự nối điều hướng",
|
|
38
|
+
"scaffold_global_sections({ brand, contact }) → Header + Footer thiết kế sẵn, dùng chung mọi trang (tự bỏ qua slot đã có)",
|
|
39
39
|
"publish_site (cũng rebuild CSS storefront)",
|
|
40
40
|
],
|
|
41
41
|
notes: "Sau khi build xong, QA trên builder editor (app_base/editor/:site_id) hoặc storefront đã publish; publish_site sẽ rebuild CSS để hết tình trạng trang thiếu style.",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { validatePage, finalizeForRender } from "../builder/page.js";
|
|
3
|
+
import { headerSection, footerSection, wireNavigation } from "../builder/templates.js";
|
|
3
4
|
/**
|
|
4
5
|
* Write tools for global SECTIONS (Header / Footer / reusable blocks).
|
|
5
6
|
*
|
|
@@ -124,6 +125,96 @@ Two-step safety: dry_run=true (default) previews which pages change; dry_run=fal
|
|
|
124
125
|
raw: ok ? undefined : res,
|
|
125
126
|
};
|
|
126
127
|
}));
|
|
128
|
+
server.tool("scaffold_global_sections", `Generate a DESIGNED global Header and Footer in one call and embed them on every page —
|
|
129
|
+
the fast path to consistent site chrome. The header has logo + nav links + cart icon + CTA;
|
|
130
|
+
the footer has brand blurb + link columns + contact + copyright. Navigation is auto-wired to
|
|
131
|
+
real pages (Home/Products/Cart). Colours follow the site theme vars unless you pass a palette.
|
|
132
|
+
SKIPS a slot that already has a global section (won't create a second header/footer) unless
|
|
133
|
+
force=true. Two-step safety: dry_run=true (default) previews; dry_run=false performs the atomic save.`, {
|
|
134
|
+
brand: z.string().describe("Shop / brand name shown as the logo and in the footer."),
|
|
135
|
+
links: z.array(z.object({ label: z.string(), navTo: z.string().optional(), url: z.string().optional() })).optional().describe("Header nav links. navTo = a page slug ('home','collections','cart') auto-wired to open_page; url = external link. Defaults to Home/Products/Cart."),
|
|
136
|
+
contact: z.object({ phone: z.string().optional(), email: z.string().optional(), address: z.string().optional() }).optional().describe("Real contact info for the footer (don't invent — omit what you don't have)."),
|
|
137
|
+
cta: z.string().optional().describe("Header call-to-action button label (default 'Đặt mua ngay')."),
|
|
138
|
+
palette: z.record(z.any()).optional().describe("Optional colour overrides { accent, onAccent, text, muted, surface, surfaceAlt, border }."),
|
|
139
|
+
include: z.enum(["both", "header", "footer"]).default("both").describe("Which chrome to generate."),
|
|
140
|
+
force: z.boolean().default(false).describe("Create even if a header/footer global already exists (otherwise that slot is skipped)."),
|
|
141
|
+
dry_run: z.boolean().default(true).describe("Preview (true) or perform the atomic save (false)."),
|
|
142
|
+
}, ({ brand, links, contact, cta, palette, include, force, dry_run }) => handle(async () => {
|
|
143
|
+
// Which slots already have a global section? (dedupe by type unless force)
|
|
144
|
+
const existingRes = await api.listGlobalSections().catch(() => null);
|
|
145
|
+
const existing = (existingRes && (existingRes.data || existingRes.global_sections || existingRes)) || [];
|
|
146
|
+
const slotTaken = (slot) => Array.isArray(existing) && existing.some((g) => (g.slot || "").toLowerCase() === slot || TYPE_NUM[slot] === g.type);
|
|
147
|
+
const wantHeader = include !== "footer";
|
|
148
|
+
const wantFooter = include !== "header";
|
|
149
|
+
const skipped = [];
|
|
150
|
+
// Resolve slug -> page id for nav wiring + page embedding.
|
|
151
|
+
const allPages = await loadPages(api);
|
|
152
|
+
const slugToId = {};
|
|
153
|
+
const pagesRes = await api.listPages();
|
|
154
|
+
for (const pg of (pagesRes && pagesRes.data) || pagesRes || []) {
|
|
155
|
+
const sl = (pg.slug || "").replace(/^\//, "");
|
|
156
|
+
if (sl)
|
|
157
|
+
slugToId[sl] = pg.id;
|
|
158
|
+
if (pg.is_homepage)
|
|
159
|
+
slugToId["home"] = pg.id;
|
|
160
|
+
}
|
|
161
|
+
// Build the finalized, nav-wired global nodes for each requested slot.
|
|
162
|
+
const toCreate = [];
|
|
163
|
+
const buildNode = (type, raw) => {
|
|
164
|
+
raw.specials = { ...(raw.specials || {}), global: type };
|
|
165
|
+
const wrap = { sections: [raw] };
|
|
166
|
+
const validation = validatePage(wrap);
|
|
167
|
+
if (!validation.valid)
|
|
168
|
+
throw new Error(`${type} failed validation: ${JSON.stringify(validation.errors)}`);
|
|
169
|
+
finalizeForRender(wrap);
|
|
170
|
+
wireNavigation(wrap, slugToId); // resolve _navTo on header links/CTA
|
|
171
|
+
return wrap.sections[0];
|
|
172
|
+
};
|
|
173
|
+
if (wantHeader) {
|
|
174
|
+
if (!force && slotTaken("header"))
|
|
175
|
+
skipped.push("header");
|
|
176
|
+
else
|
|
177
|
+
toCreate.push({ type: "header", name: "Header", node: buildNode("header", headerSection({ brand, links: links, cta, palette })) });
|
|
178
|
+
}
|
|
179
|
+
if (wantFooter) {
|
|
180
|
+
if (!force && slotTaken("footer"))
|
|
181
|
+
skipped.push("footer");
|
|
182
|
+
else
|
|
183
|
+
toCreate.push({ type: "footer", name: "Footer", node: buildNode("footer", footerSection({ brand, contact, palette })) });
|
|
184
|
+
}
|
|
185
|
+
if (!toCreate.length) {
|
|
186
|
+
return { success: true, created: [], skipped, note: skipped.length ? "Those slots already have a global section (pass force=true to add anyway)." : "Nothing to create." };
|
|
187
|
+
}
|
|
188
|
+
if (dry_run) {
|
|
189
|
+
return {
|
|
190
|
+
dry_run: true,
|
|
191
|
+
will_create: toCreate.map((t) => ({ type: t.type, section_id: t.node.id })),
|
|
192
|
+
skipped,
|
|
193
|
+
embeds_into_pages: allPages.length,
|
|
194
|
+
hint: "Call again with dry_run=false to create + embed the header/footer, then publish_site.",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
// Inject every node into every page, then one atomic /save with all globals + pages.
|
|
198
|
+
for (const t of toCreate)
|
|
199
|
+
for (const p of allPages)
|
|
200
|
+
injectNode(p.source, t.node, t.type);
|
|
201
|
+
const changedPages = allPages.map((p) => ({ id: p.id, source: JSON.stringify(p.source) }));
|
|
202
|
+
const changes = allPages.reduce((o, p) => { o[p.id] = 1; return o; }, {});
|
|
203
|
+
const global_sections = toCreate.map((t) => ({
|
|
204
|
+
section_id: t.node.id, name: t.name, type: TYPE_NUM[t.type],
|
|
205
|
+
pages: allPages.map((p) => p.id), section: t.node, status: "new", contents: [],
|
|
206
|
+
}));
|
|
207
|
+
const res = await api.saveGlobalSections({ global_sections, pages: changedPages, changes });
|
|
208
|
+
const ok = !!(res && (res.success || res.data));
|
|
209
|
+
return {
|
|
210
|
+
success: ok,
|
|
211
|
+
created: toCreate.map((t) => ({ type: t.type, section_id: t.node.id })),
|
|
212
|
+
skipped,
|
|
213
|
+
embedded_pages: changedPages.length,
|
|
214
|
+
note: "Publish the site (publish_site) to take the header/footer live.",
|
|
215
|
+
raw: ok ? undefined : res,
|
|
216
|
+
};
|
|
217
|
+
}));
|
|
127
218
|
server.tool("delete_global_section", `Delete a global section (Header/Footer/block) and remove its node from every page source.
|
|
128
219
|
Two-step safety: dry_run=true (default) shows which pages would change; dry_run=false performs the atomic save.`, {
|
|
129
220
|
section_id: z.string().describe("The global section's section_id (the section node id) — from list_global_sections."),
|
package/dist/tools/products.js
CHANGED
|
@@ -5,7 +5,12 @@ export function registerProductTools(server, api, handle) {
|
|
|
5
5
|
limit: z.number().optional().describe("Items per page"),
|
|
6
6
|
term: z.string().optional().describe("Search by product name"),
|
|
7
7
|
}, ({ page, limit, term }) => handle(async () => {
|
|
8
|
-
|
|
8
|
+
// Build a clean query — the /products/all endpoint 400s on undefined/blank keys,
|
|
9
|
+
// so default page/limit and drop term unless it's a real search string.
|
|
10
|
+
const query = { page: page ?? 1, limit: limit ?? 50 };
|
|
11
|
+
if (term && term.trim())
|
|
12
|
+
query.term = term.trim();
|
|
13
|
+
const res = await api.listProducts(query);
|
|
9
14
|
const products = (res && res.data) || res || [];
|
|
10
15
|
if (!Array.isArray(products))
|
|
11
16
|
return res;
|
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.19.1",
|
|
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",
|