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.
- package/dist/builder/guide.js +7 -3
- package/dist/builder/templates.js +399 -0
- package/dist/changelog.json +7 -7
- 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/package.json +1 -1
package/dist/builder/guide.js
CHANGED
|
@@ -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\`
|
|
165
|
-
|
|
166
|
-
|
|
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
|
+
];
|
package/dist/changelog.json
CHANGED
|
@@ -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
|
]
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-storefront-mcp",
|
|
3
|
-
"version": "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",
|