webcake-storefront-mcp 1.18.1 → 1.19.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.
@@ -161,9 +161,13 @@ set \`type\` accordingly so the binding source is turned on. A binding target li
161
161
  ## Build the WHOLE storefront — every page to the SAME standard (NOT just the home page)
162
162
  A shop is multi-page. Build EACH page to a real e-commerce standard with the same palette,
163
163
  spacing and header/footer — never leave the home page rich and the rest as bare stubs.
164
- \`scaffold_store_pages\` creates FUNCTIONAL but MINIMAL pages (a heading + the binding element);
165
- treat them as STARTERS to enrich, not the finished page. After scaffolding, rebuild each page's
166
- source so it looks designed:
164
+ \`scaffold_store_pages\` now builds FULLY-DESIGNED, palette-aware store pages by DEFAULT
165
+ (style:"rich") banner+breadcrumb+styled grid (Category), 2-col gallery|info with price/
166
+ quantity/add-to-cart+trust badges+related (Product), 2-col cart|summary, 2-col checkout
167
+ form|summary, centred thank-you — and auto-wires navigation between them. Then add chrome with
168
+ \`scaffold_global_sections({ brand, contact })\` for a designed Header+Footer in one call. Pass
169
+ style:"minimal" only if you want bare stubs to hand-build. The per-page recipe each rich page
170
+ follows (so you can match it when editing or building extra pages):
167
171
  - Category (collections, type store): banner + heading + grid-product (+ optional intro/CTA).
168
172
  - Product detail (products, type store): 2-col [product-gallery | info: text-dataset
169
173
  product::product_name + product_price/original_price + short_description, quantity-input,
@@ -0,0 +1,399 @@
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 styled product grid card config that looks like a real shop, not a bare list. */
114
+ const productGrid = (p, columns = 4) => ({
115
+ type: "grid-product",
116
+ opts: {
117
+ config: {
118
+ 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
+ productPriceColor: p.accent,
129
+ showAddToCart: true,
130
+ },
131
+ },
132
+ });
133
+ /** Dataset text bound to a product field (name/price/description). */
134
+ const productField = (field, style) => ({
135
+ type: "text-dataset",
136
+ opts: {
137
+ bindings: [{ id: "BINDING" + Math.random().toString(36).slice(2, 8), name: "product", target: `product::${field}` }],
138
+ style,
139
+ },
140
+ });
141
+ const sectionStyle = (p, bg, padY = 64) => ({
142
+ style: { background: bg, paddingTop: padY, paddingBottom: padY, paddingLeft: 20, paddingRight: 20 },
143
+ });
144
+ // ---------------------------------------------------------------------------
145
+ // STORE PAGE templates -> { sections: [...] }
146
+ // ---------------------------------------------------------------------------
147
+ export function categoryPageSource(p, opts = {}) {
148
+ const banner = buildSection([
149
+ { type: "breadcrumb", opts: { style: { fontSize: "13px", color: p.muted } } },
150
+ heading(opts.title || "Tất cả sản phẩm", { tag: "h1", size: 40, color: p.text, align: "center" }),
151
+ paragraph(opts.subtitle || "Khám phá bộ sưu tập của chúng tôi", p, { align: "center", size: 17 }),
152
+ ], { ...sectionStyle(p, p.surfaceAlt, 56), rowGap: 12 });
153
+ const grid = buildSection([productGrid(p, 4)], sectionStyle(p, p.surface, 56));
154
+ return { sections: [banner, grid] };
155
+ }
156
+ export function productPageSource(p) {
157
+ const breadcrumbRow = buildSection([{ type: "breadcrumb", opts: { style: { fontSize: "13px", color: p.muted } } }], { ...sectionStyle(p, p.surface, 24) });
158
+ const infoColumn = {
159
+ type: "container",
160
+ children: [
161
+ productField("product_name", { fontSize: "30px", fontWeight: "700", color: p.text, lineHeight: "1.25" }),
162
+ productField("product_price", { fontSize: "26px", fontWeight: "700", color: p.accent }),
163
+ productField("product_description", { fontSize: "15px", fontWeight: "400", color: p.muted, lineHeight: "1.6" }),
164
+ { type: "quantity-input", opts: { style: { borderColor: p.border, borderRadius: "8px" } } },
165
+ cta("Thêm vào giỏ", p, { full: true, height: 52, events: [{ action: "add_to_cart", open_page: "cart" }] }),
166
+ {
167
+ type: "container",
168
+ layout: "row",
169
+ columnGap: 16,
170
+ collapse: { bp4: 1 },
171
+ children: [
172
+ trustBadge("🚚", "Miễn phí giao hàng", p),
173
+ trustBadge("✅", "Hàng chính hãng", p),
174
+ trustBadge("↩️", "Đổi trả 7 ngày", p),
175
+ ],
176
+ },
177
+ ],
178
+ };
179
+ const detail = buildSection([
180
+ {
181
+ type: "container",
182
+ layout: "row",
183
+ columnGap: 48,
184
+ colWidths: [{ unit: "fr", value: 1 }, { unit: "fr", value: 1 }],
185
+ collapse: { bp3: 1, bp4: 1 },
186
+ children: [{ type: "product-gallery", opts: { style: { borderRadius: "14px" } } }, infoColumn],
187
+ },
188
+ ], sectionStyle(p, p.surface, 32));
189
+ 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 });
190
+ return { sections: [breadcrumbRow, detail, related] };
191
+ }
192
+ export function cartPageSource(p) {
193
+ const summary = {
194
+ type: "container",
195
+ opts: { style: { background: p.surfaceAlt, borderRadius: "14px", padding: "24px", border: `1px solid ${p.border}` } },
196
+ children: [
197
+ heading("Tóm tắt đơn hàng", { tag: "h3", size: 20, color: p.text }),
198
+ paragraph("Phí vận chuyển và mã giảm giá sẽ được tính ở bước thanh toán.", p, { size: 14 }),
199
+ cta("Tiến hành thanh toán", p, { full: true, height: 52, navTo: "checkout" }),
200
+ outlineBtn("Tiếp tục mua sắm", p, { navTo: "home" }),
201
+ ],
202
+ };
203
+ const body = buildSection([
204
+ heading("Giỏ hàng của bạn", { tag: "h1", size: 34, color: p.text }),
205
+ {
206
+ type: "container",
207
+ layout: "row",
208
+ columnGap: 40,
209
+ colWidths: [{ unit: "fr", value: 2 }, { unit: "fr", value: 1 }],
210
+ collapse: { bp3: 1, bp4: 1 },
211
+ children: [{ type: "cart-items", opts: {} }, summary],
212
+ },
213
+ ], { ...sectionStyle(p, p.surface, 48), rowGap: 24 });
214
+ return { sections: [body] };
215
+ }
216
+ export function checkoutPageSource(p) {
217
+ const form = {
218
+ type: "form",
219
+ opts: { specials: { type: "form_order" }, style: { background: p.surface } },
220
+ children: [
221
+ { type: "input", opts: { specials: { field_name: "full_name", label: "Họ và tên", placeholder: "Nguyễn Văn A", required: true, show_label: true }, style: { borderColor: p.border, borderRadius: "8px" } } },
222
+ { type: "phone-number", opts: { specials: { field_name: "phone_number", label: "Số điện thoại", required: true, show_label: true }, style: { borderColor: p.border, borderRadius: "8px" } } },
223
+ { type: "address", opts: { specials: { field_name: "address", label: "Địa chỉ nhận hàng", show_label: true }, style: { borderColor: p.border, borderRadius: "8px" } } },
224
+ { type: "text-area", opts: { specials: { field_name: "note", label: "Ghi chú", show_label: true }, style: { borderColor: p.border, borderRadius: "8px" } } },
225
+ { 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
+ ],
227
+ };
228
+ const summary = {
229
+ type: "container",
230
+ opts: { style: { background: p.surfaceAlt, borderRadius: "14px", padding: "24px", border: `1px solid ${p.border}` } },
231
+ children: [heading("Đơn hàng của bạn", { tag: "h3", size: 20, color: p.text }), { type: "cart-items", opts: {} }],
232
+ };
233
+ const body = buildSection([
234
+ heading("Thanh toán", { tag: "h1", size: 34, color: p.text }),
235
+ {
236
+ type: "container",
237
+ layout: "row",
238
+ columnGap: 40,
239
+ colWidths: [{ unit: "fr", value: 3 }, { unit: "fr", value: 2 }],
240
+ collapse: { bp3: 1, bp4: 1 },
241
+ children: [form, summary],
242
+ },
243
+ ], { ...sectionStyle(p, p.surface, 48), rowGap: 24 });
244
+ return { sections: [body] };
245
+ }
246
+ export function thankYouPageSource(p) {
247
+ const body = buildSection([
248
+ heading("🎉", { tag: "h2", size: 56, align: "center" }),
249
+ heading("Cảm ơn bạn đã đặt hàng!", { tag: "h1", size: 34, color: p.text, align: "center" }),
250
+ 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 }),
251
+ { type: "order-items", opts: {} },
252
+ cta("Tiếp tục mua sắm", p, { navTo: "home" }),
253
+ ], { ...sectionStyle(p, p.surface, 64), rowGap: 18 });
254
+ return { sections: [body] };
255
+ }
256
+ export function headerSection(opts = {}) {
257
+ const p = resolvePalette(opts.palette);
258
+ const links = opts.links && opts.links.length ? opts.links : [
259
+ { label: "Trang chủ", navTo: "home" },
260
+ { label: "Sản phẩm", navTo: "collections" },
261
+ { label: "Giỏ hàng", navTo: "cart" },
262
+ ];
263
+ const logo = { type: "text", opts: { text: opts.brand || "Shop", specials: { tag: "h2" }, style: { fontSize: "24px", fontWeight: "800", color: p.text } } };
264
+ const nav = {
265
+ type: "container",
266
+ layout: "row",
267
+ columnGap: 28,
268
+ collapse: { bp4: 3 },
269
+ children: links.map((l) => ({
270
+ type: "text",
271
+ opts: {
272
+ text: l.label,
273
+ ...(l.navTo || l.url ? { specials: { ...(l.navTo ? { _navTo: l.navTo } : {}), ...(l.url ? { _navUrl: l.url } : {}) } } : {}),
274
+ style: { fontSize: "15px", fontWeight: "600", color: p.text, cursor: "pointer" },
275
+ },
276
+ })),
277
+ };
278
+ const actions = {
279
+ type: "container",
280
+ layout: "row",
281
+ columnGap: 16,
282
+ colWidths: [{ unit: "px", absValue: 32 }, { unit: "max-c" }],
283
+ collapse: { bp4: 2 },
284
+ children: [
285
+ { type: "cart-icon", opts: { config: { color: p.text }, style: { width: 26, height: 26 } } },
286
+ cta(opts.cta || "Đặt mua ngay", p, { navTo: "collections", height: 44 }),
287
+ ],
288
+ };
289
+ return buildSection([
290
+ {
291
+ type: "container",
292
+ layout: "row",
293
+ columnGap: 24,
294
+ colWidths: [{ unit: "max-c" }, { unit: "fr", value: 1 }, { unit: "max-c" }],
295
+ collapse: { bp4: 1 },
296
+ children: [logo, nav, actions],
297
+ },
298
+ ], { style: { background: p.surface, paddingTop: 16, paddingBottom: 16, paddingLeft: 20, paddingRight: 20, borderBottom: `1px solid ${p.border}` } });
299
+ }
300
+ export function footerSection(opts = {}) {
301
+ const p = resolvePalette(opts.palette);
302
+ const brandCol = {
303
+ type: "container",
304
+ children: [
305
+ { type: "text", opts: { text: opts.brand || "Shop", specials: { tag: "h3" }, style: { fontSize: "20px", fontWeight: "800", color: "#fff" } } },
306
+ { 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" } } },
307
+ ],
308
+ };
309
+ const linkCols = (opts.columns && opts.columns.length ? opts.columns : [
310
+ { title: "Cửa hàng", links: ["Trang chủ", "Sản phẩm", "Giỏ hàng"] },
311
+ { title: "Hỗ trợ", links: ["Chính sách đổi trả", "Giao hàng", "Liên hệ"] },
312
+ ]).map((col) => ({
313
+ type: "container",
314
+ children: [
315
+ { type: "text", opts: { text: col.title, style: { fontSize: "15px", fontWeight: "700", color: "#fff" } } },
316
+ ...col.links.map((t) => ({ type: "text", opts: { text: t, style: { fontSize: "14px", color: "rgba(255,255,255,0.7)", lineHeight: "2" } } })),
317
+ ],
318
+ }));
319
+ const c = opts.contact || {};
320
+ const contactCol = {
321
+ type: "container",
322
+ children: [
323
+ { type: "text", opts: { text: "Liên hệ", style: { fontSize: "15px", fontWeight: "700", color: "#fff" } } },
324
+ ...(c.phone ? [{ type: "text", opts: { text: `📞 ${c.phone}`, style: { fontSize: "14px", color: "rgba(255,255,255,0.7)", lineHeight: "2" } } }] : []),
325
+ ...(c.email ? [{ type: "text", opts: { text: `✉️ ${c.email}`, style: { fontSize: "14px", color: "rgba(255,255,255,0.7)", lineHeight: "2" } } }] : []),
326
+ ...(c.address ? [{ type: "text", opts: { text: `📍 ${c.address}`, style: { fontSize: "14px", color: "rgba(255,255,255,0.7)", lineHeight: "1.6" } } }] : []),
327
+ ],
328
+ };
329
+ const topRow = {
330
+ type: "container",
331
+ layout: "row",
332
+ columnGap: 40,
333
+ colWidths: [{ unit: "fr", value: 2 }, { unit: "fr", value: 1 }, { unit: "fr", value: 1 }, { unit: "fr", value: 1 }],
334
+ collapse: { bp3: 2, bp4: 1 },
335
+ children: [brandCol, ...linkCols, contactCol],
336
+ };
337
+ const copyright = { type: "text", opts: { text: `© ${opts.brand || "Shop"}. All rights reserved.`, style: { fontSize: "13px", color: "rgba(255,255,255,0.5)", textAlign: "center" } } };
338
+ return buildSection([topRow, copyright], {
339
+ style: { background: "#1c1917", paddingTop: 56, paddingBottom: 32, paddingLeft: 20, paddingRight: 20 },
340
+ rowGap: 32,
341
+ });
342
+ }
343
+ // ---------------------------------------------------------------------------
344
+ // registry the scaffolder iterates
345
+ // ---------------------------------------------------------------------------
346
+ /**
347
+ * Resolve the `_navTo` / `_navUrl` sentinels the templates leave on buttons/links into real
348
+ * open_page / open_link events, now that every page exists and we know its id. `slugToId`
349
+ * maps a page slug (e.g. "checkout", "cart", "collections") to its page id; the special
350
+ * slug "home" maps to the homepage id. Returns the number of nodes wired so the caller can
351
+ * skip re-saving an unchanged page. Run on the raw source BEFORE finalizeForRender.
352
+ */
353
+ export function wireNavigation(source, slugToId) {
354
+ let wired = 0;
355
+ const resolve = (slug) => slugToId[slug] || slugToId[slug.replace(/^\//, "")];
356
+ walk(source, (node) => {
357
+ // (a) text links carry the sentinel in specials (the text factory keeps it).
358
+ const sp = node && node.specials;
359
+ if (sp && sp._navTo) {
360
+ const id = resolve(sp._navTo);
361
+ if (id) {
362
+ node.events = normalizeEvents([{ action: "open_page", open_page_id: id }], node.type);
363
+ wired++;
364
+ }
365
+ delete sp._navTo;
366
+ }
367
+ if (sp && sp._navUrl) {
368
+ node.events = normalizeEvents([{ action: "open_link", link_target: sp._navUrl, link_target_url: sp._navUrl }], node.type);
369
+ delete sp._navUrl;
370
+ wired++;
371
+ }
372
+ // (b) buttons carry the sentinel inside an events entry (the button factory keeps events
373
+ // but drops arbitrary specials). Resolve _navTo -> open_page_id in place; drop the
374
+ // event entirely if its target page doesn't exist so we never emit a dead open_page.
375
+ if (Array.isArray(node && node.events) && node.events.length) {
376
+ node.events = node.events
377
+ .map((e) => {
378
+ if (e && e._navTo) {
379
+ const id = resolve(e._navTo);
380
+ const { _navTo, ...rest } = e;
381
+ if (!id)
382
+ return null;
383
+ wired++;
384
+ return { ...rest, open_page_id: id };
385
+ }
386
+ return e;
387
+ })
388
+ .filter(Boolean);
389
+ }
390
+ });
391
+ return wired;
392
+ }
393
+ export const STORE_PAGE_TEMPLATES = [
394
+ { name: "Category Page", slug: "collections", build: categoryPageSource },
395
+ { name: "Product Page", slug: "products", build: productPageSource },
396
+ { name: "Cart Page", slug: "cart", build: cartPageSource },
397
+ { name: "Checkout Page", slug: "checkout", build: checkoutPageSource },
398
+ { name: "Thank You Page", slug: "complete", build: thankYouPageSource },
399
+ ];
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "v": "1.19.0",
4
+ "d": "25/06/2026",
5
+ "type": "Added",
6
+ "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…",
7
+ "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…"
8
+ },
2
9
  {
3
10
  "v": "1.18.1",
4
11
  "d": "25/06/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Added",
34
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…",
35
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…"
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
  ]
@@ -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
- NOTE: these are MINIMAL STARTER pages (a heading + the binding element) you MUST then
202
- enrich EACH one to the same standard as the home page (see get_build_guide "Build the WHOLE
203
- storefront"), and add a GLOBAL header/footer (create_global_section). Don't leave them bare.`, {
202
+ By default (style:"rich") each store page is a FULLY-DESIGNED, palette-aware pagebanner +
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
- { name: "Category Page", slug: "collections", kind: "store", flag: "use_store", build: () => ({ sections: [
218
- 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 } } }]),
219
- ] }) },
220
- { name: "Product Page", slug: "products", kind: "store", flag: "use_store", build: () => ({ sections: [
221
- buildSection([
222
- { type: "product-gallery", opts: {} },
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
- note: "Publish the site (publish_site) to take the new pages live.",
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
  }
@@ -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 để tạo trang danh mục/chi tiết/giỏ/thanh toán/cảm ơn",
38
- "Tách Header/Footer thành global section (create_global_section) để dùng chung mọi trang",
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."),
@@ -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
- const res = await api.listProducts({ page, limit, term });
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-storefront-mcp",
3
- "version": "1.18.1",
3
+ "version": "1.19.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",