webcake-storefront-mcp 1.26.0 → 1.27.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/api.js +4 -1
- package/dist/builder/factory.js +17 -0
- package/dist/builder/guide.js +10 -6
- package/dist/builder/templates.js +44 -7
- package/dist/changelog.json +14 -14
- package/dist/tools/builder.js +54 -12
- package/dist/tools/collections.js +4 -2
- package/dist/tools/orders.js +4 -2
- package/dist/tools/pages.js +23 -5
- package/dist/tools/products.js +3 -2
- package/dist/tools/promotions.js +16 -4
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -491,8 +491,11 @@ export class WebcakeCmsApi {
|
|
|
491
491
|
getActivePromotions() {
|
|
492
492
|
return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/promotion_advance/get_promotions_actived`);
|
|
493
493
|
}
|
|
494
|
+
/** Search/list promotions. The dedicated get_promotions_advance endpoint 404s on prod, so
|
|
495
|
+
* this uses the same /all list endpoint (which accepts a query); the tool filters the
|
|
496
|
+
* returned page client-side for type/status/keyword. */
|
|
494
497
|
searchPromotions(query) {
|
|
495
|
-
return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/promotion_advance/
|
|
498
|
+
return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/promotion_advance/all`, { query });
|
|
496
499
|
}
|
|
497
500
|
// ── Combos ──
|
|
498
501
|
listCombos(query) {
|
package/dist/builder/factory.js
CHANGED
|
@@ -1080,11 +1080,28 @@ export const createCoupon = (opts = {}) => {
|
|
|
1080
1080
|
}
|
|
1081
1081
|
return coupon;
|
|
1082
1082
|
};
|
|
1083
|
+
// Real popups (mined from templates) keep TRIGGER/animation in specials (effect, timeAnim,
|
|
1084
|
+
// openPopupAction, timeOpenPopup, page_ids) and GEOMETRY in the per-breakpoint keys: config
|
|
1085
|
+
// holds popupHorizontalPosition/popupVerticalPosition, style holds width/height/background.
|
|
1086
|
+
// Seed sensible runtime defaults (a centred modal) so finalizeForRender expands them into bpN —
|
|
1087
|
+
// otherwise the popup renders with no size/position/background.
|
|
1083
1088
|
export const createPopup = (opts = {}) => {
|
|
1084
1089
|
const popup = cloneDeep(SKELETON);
|
|
1085
1090
|
popup.id = 'POPUP-' + randomString(8);
|
|
1086
1091
|
popup.type = 'popup';
|
|
1087
1092
|
popup.specials = opts.specials || {};
|
|
1093
|
+
popup.runtime.config = {
|
|
1094
|
+
popupHorizontalPosition: 'center',
|
|
1095
|
+
popupVerticalPosition: 'center',
|
|
1096
|
+
heightUnit: 'auto',
|
|
1097
|
+
...(opts.config || {})
|
|
1098
|
+
};
|
|
1099
|
+
popup.runtime.style = {
|
|
1100
|
+
width: 480,
|
|
1101
|
+
background: '#ffffff',
|
|
1102
|
+
borderRadius: '12px',
|
|
1103
|
+
...(opts.style || {})
|
|
1104
|
+
};
|
|
1088
1105
|
popup.children = opts.children || [];
|
|
1089
1106
|
return popup;
|
|
1090
1107
|
};
|
package/dist/builder/guide.js
CHANGED
|
@@ -69,8 +69,11 @@ already in this shape — see the \`responsive\` field on get_page_element/updat
|
|
|
69
69
|
- \`runtime.style\` holds CSS-ish props: width/height (numbers = px), color, background,
|
|
70
70
|
fontSize ("16px"), fontWeight, textAlign, border*, boxShadow, etc.
|
|
71
71
|
- \`runtime.config.heightUnit\`: "auto" lets content set height (default for text/image).
|
|
72
|
-
- Colours:
|
|
73
|
-
(
|
|
72
|
+
- Colours: use the site THEME matrix vars \`var(--color_RC)\` (R=row 0-4, C=col 0-4). Row 0 is
|
|
73
|
+
greyscale: \`var(--color_00)\`=WHITE … \`var(--color_04)\`=BLACK. Row 2 is the BRAND row:
|
|
74
|
+
\`var(--color_20)\` is the brand primary. So TEXT = \`var(--color_04)\` (NOT color_00, that's white →
|
|
75
|
+
invisible), ACCENT/buttons/prices = \`var(--color_20)\` (darker shade \`var(--color_24)\` if you need
|
|
76
|
+
white-on-accent contrast), page background = \`var(--color_00)\`. Plain hex/rgba() also work.
|
|
74
77
|
|
|
75
78
|
## Make it look DESIGNED (not plain) — do this, every page
|
|
76
79
|
A bare stack of default elements looks unfinished. Apply real styling:
|
|
@@ -82,8 +85,8 @@ A bare stack of default elements looks unfinished. Apply real styling:
|
|
|
82
85
|
- TYPOGRAPHY hierarchy: h1 40–56px / fontWeight 700, h2 28–34px / 600, body 16–18px with
|
|
83
86
|
lineHeight "1.6", muted color for sub-text. Center hero text (textAlign:"center").
|
|
84
87
|
- BUTTONS HAVE NO DEFAULT COLOUR — you MUST style them or they look like plain text:
|
|
85
|
-
\`{ type:"button", opts:{ text:"Mua ngay", style:{ background:"var(--
|
|
86
|
-
borderRadius:"8px", fontWeight:"600", height:48 } } }
|
|
88
|
+
\`{ type:"button", opts:{ text:"Mua ngay", style:{ background:"var(--color_20)", color:"var(--color_00)",
|
|
89
|
+
borderRadius:"8px", fontWeight:"600", height:48 } } }\` (brand background, white label).
|
|
87
90
|
- HERO: prefer a section with a background image + an overlay heading/sub/button on top
|
|
88
91
|
(set section_opts.style.background or a full-width image, then text centered), rather than a
|
|
89
92
|
small image stacked above text.
|
|
@@ -96,8 +99,9 @@ A bare stack of default elements looks unfinished. Apply real styling:
|
|
|
96
99
|
and \`opts.specials\`: { products_per_load:8-36, on_hover:"zoom"|"swap", show_rating, show_ribbon }.
|
|
97
100
|
(There are NO cardBorderRadius/cardBoxShadow keys.) For variations use \`attr\` elements
|
|
98
101
|
(attrName:"auto"); steppers are \`quantity-input\` (defaults spinner:"hide-spin").
|
|
99
|
-
- BRAND COLOURS:
|
|
100
|
-
|
|
102
|
+
- BRAND COLOURS: text = var(--color_04) (black), accent = var(--color_20) (brand) for buttons,
|
|
103
|
+
prices, highlights — consistent accent = looks intentional. (var(--color_00) is WHITE — only
|
|
104
|
+
for backgrounds / labels on the accent, never for text on a white surface.)
|
|
101
105
|
- IMAGES: must be WebCake-CDN urls (search_images cdn_url / upload_images), else they won't show.
|
|
102
106
|
|
|
103
107
|
## Responsive breakpoints
|
|
@@ -16,18 +16,55 @@
|
|
|
16
16
|
import { buildSection, walk } from "./page.js";
|
|
17
17
|
import { buildElement } from "./catalog.js";
|
|
18
18
|
import { normalizeEvents } from "./events.js";
|
|
19
|
+
// Slots map onto the site theme's 5×5 colour matrix, surfaced as CSS vars var(--color_RC)
|
|
20
|
+
// (R=row, C=col). Row 0 is greyscale (00=white … 04=black); row 2 is the BRAND row. Using
|
|
21
|
+
// var(--color_00) for text was a bug — that's WHITE (invisible on the white surface).
|
|
19
22
|
const DEFAULT_PALETTE = {
|
|
20
|
-
accent: "var(--
|
|
21
|
-
onAccent: "
|
|
22
|
-
text: "var(--
|
|
23
|
-
muted: "
|
|
24
|
-
surface: "
|
|
25
|
-
surfaceAlt: "
|
|
26
|
-
border: "
|
|
23
|
+
accent: "var(--color_20)", // brand primary (row 2)
|
|
24
|
+
onAccent: "var(--color_00)", // white — label on the accent button
|
|
25
|
+
text: "var(--color_04)", // black — headings + body
|
|
26
|
+
muted: "var(--color_03)", // dark grey — secondary text
|
|
27
|
+
surface: "var(--color_00)", // white — page/card background
|
|
28
|
+
surfaceAlt: "var(--color_01)", // light grey — alternating band
|
|
29
|
+
border: "var(--color_01)", // hairline borders
|
|
27
30
|
};
|
|
28
31
|
export function resolvePalette(p = {}) {
|
|
29
32
|
return { ...DEFAULT_PALETTE, ...Object.fromEntries(Object.entries(p).filter(([, v]) => v != null)) };
|
|
30
33
|
}
|
|
34
|
+
/** Relative luminance of a #hex colour (0 dark … 1 light). */
|
|
35
|
+
function luminance(hex) {
|
|
36
|
+
const h = String(hex || "").replace("#", "").slice(0, 6);
|
|
37
|
+
if (h.length < 6)
|
|
38
|
+
return 0;
|
|
39
|
+
const r = parseInt(h.slice(0, 2), 16) / 255;
|
|
40
|
+
const g = parseInt(h.slice(2, 4), 16) / 255;
|
|
41
|
+
const b = parseInt(h.slice(4, 6), 16) / 255;
|
|
42
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read the site's active theme colour matrix and return a Palette override so generated pages
|
|
46
|
+
* stay BRAND-CONSISTENT and CONTRAST-SAFE. The default palette uses var(--color_20) (the brand
|
|
47
|
+
* seed) as the accent — but for a LIGHT brand seed (e.g. a beige store) a white button label on
|
|
48
|
+
* it is unreadable, so we switch the accent to var(--color_24) (the darkest brand shade). Best
|
|
49
|
+
* effort: returns {} on any error (the var(--color_NN) defaults still resolve per-site).
|
|
50
|
+
*/
|
|
51
|
+
export async function contrastSafePalette(api) {
|
|
52
|
+
try {
|
|
53
|
+
const res = await api.listThemes();
|
|
54
|
+
const themes = (res && res.data) || res || [];
|
|
55
|
+
const arr = Array.isArray(themes) ? themes : (themes.themes || []);
|
|
56
|
+
const th = arr.find((t) => t.is_selected) || arr[0];
|
|
57
|
+
const m = th && th.colors;
|
|
58
|
+
if (Array.isArray(m) && Array.isArray(m[2]) && m[2][0]) {
|
|
59
|
+
if (luminance(m[2][0]) > 0.62)
|
|
60
|
+
return { accent: "var(--color_24)" };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* keep the var()-based defaults */
|
|
65
|
+
}
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
31
68
|
// ---------------------------------------------------------------------------
|
|
32
69
|
// small spec helpers (return element specs for buildSection/buildRow children)
|
|
33
70
|
// ---------------------------------------------------------------------------
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.27.0",
|
|
4
|
+
"d": "26/06/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "build_page now accepts an seo object (title, description, keyword, favicon, thumbnail) that is written to page.settings.seo with Open Graph…",
|
|
7
|
+
"vi": "build_page nay nhận thêm đối tượng seo (title, description, keyword, favicon, thumbnail) và ghi vào page.settings.seo kèm mirroring Open Graph; hỗ…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.26.1",
|
|
11
|
+
"d": "26/06/2026",
|
|
12
|
+
"type": "Fixed",
|
|
13
|
+
"en": "list_products now correctly unwraps the { products, total_product } API response shape, returning a product array and accurate total instead of…",
|
|
14
|
+
"vi": "list_products nay giải nén đúng kiểu phản hồi { products, total_product } từ API, trả về mảng sản phẩm và tổng chính xác thay vì trả thẳng đối tượng…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.26.0",
|
|
4
18
|
"d": "26/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Added",
|
|
27
41
|
"en": "get_build_guide now includes a category-page filter recipe explaining how sidebar filter widgets (checkbox-group, color-group, tags,…",
|
|
28
42
|
"vi": "get_build_guide nay bổ sung công thức filter trang danh mục, giải thích cách các widget lọc trong sidebar (checkbox-group, color-group, tags,…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.22.0",
|
|
32
|
-
"d": "25/06/2026",
|
|
33
|
-
"type": "Added",
|
|
34
|
-
"en": "list_bindings now returns a meta_keys map documenting binding combine-keys mined from 34 production templates — name_style, attr_id, prefix_content,…",
|
|
35
|
-
"vi": "list_bindings nay trả về map meta_keys tài liệu hóa các combine-key liên kết dữ liệu được khai thác từ 34 template production — name_style, attr_id,…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.21.0",
|
|
39
|
-
"d": "25/06/2026",
|
|
40
|
-
"type": "Changed",
|
|
41
|
-
"en": "The grid-product factory now ships cross-industry neutral defaults (image_ratio:\"1/1\", gap_column/gap_row 24, products_per_load 12) instead of the…",
|
|
42
|
-
"vi": "Factory grid-product nay sử dụng các giá trị mặc định trung lập đa ngành (image_ratio:\"1/1\", gap_column/gap_row 24, products_per_load 12) thay vì…"
|
|
43
43
|
}
|
|
44
44
|
]
|
package/dist/tools/builder.js
CHANGED
|
@@ -4,7 +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
|
+
import { STORE_PAGE_TEMPLATES, resolvePalette, contrastSafePalette, wireNavigation } from "../builder/templates.js";
|
|
8
8
|
// Recursive spec for new_section / build_page children.
|
|
9
9
|
const elementSpec = z.object({
|
|
10
10
|
type: z.string().describe("Element type (see list_elements)"),
|
|
@@ -28,14 +28,39 @@ function newPageId(res) {
|
|
|
28
28
|
// builderx_spa); SPECIAL kinds also require a site-level data-source flag enabled on
|
|
29
29
|
// site.settings, otherwise components that bind to store/customer/blog data render
|
|
30
30
|
// with null bindings. build_page sets both for you.
|
|
31
|
-
|
|
31
|
+
// Page kind → numeric backend type. Exported so create_page maps the same way (the backend
|
|
32
|
+
// type is numeric 1–7, NOT a string).
|
|
33
|
+
export const PAGE_TYPE_NUM = {
|
|
32
34
|
main: 1, store: 2, member: 3, blog: 4, custom: 5, error: 6, maintain: 7,
|
|
33
35
|
};
|
|
34
36
|
const PAGE_TYPE_FLAG = {
|
|
35
37
|
store: "use_store", member: "use_member", blog: "use_blog",
|
|
36
38
|
error: "use_error", maintain: "use_maintain",
|
|
37
39
|
};
|
|
38
|
-
const PAGE_KINDS = ["main", "store", "member", "blog", "custom", "error", "maintain"];
|
|
40
|
+
export const PAGE_KINDS = ["main", "store", "member", "blog", "custom", "error", "maintain"];
|
|
41
|
+
/** Build the page.settings.seo block from simple inputs (the real shape; tokens like
|
|
42
|
+
* {{name_page}} / {{name_site}} are resolved by the storefront). */
|
|
43
|
+
export function buildPageSeo(seo = {}) {
|
|
44
|
+
const out = {};
|
|
45
|
+
if (seo.title)
|
|
46
|
+
out.title = seo.title;
|
|
47
|
+
if (seo.description)
|
|
48
|
+
out.description = seo.description;
|
|
49
|
+
if (seo.keyword)
|
|
50
|
+
out.keyword = seo.keyword;
|
|
51
|
+
if (seo.favicon)
|
|
52
|
+
out.favicon = seo.favicon;
|
|
53
|
+
if (seo.thumbnail)
|
|
54
|
+
out.thumbnail = seo.thumbnail;
|
|
55
|
+
// Open Graph mirrors title/description/thumbnail when not given explicitly.
|
|
56
|
+
if (seo.title || seo.og_title)
|
|
57
|
+
out.og_title = seo.og_title || seo.title;
|
|
58
|
+
if (seo.description || seo.og_description)
|
|
59
|
+
out.og_description = seo.og_description || seo.description;
|
|
60
|
+
if (seo.thumbnail)
|
|
61
|
+
out.og_image = seo.thumbnail;
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
39
64
|
export function registerBuilderTools(server, api, handle) {
|
|
40
65
|
server.tool("get_build_guide", "Get the BuilderX page authoring guide: page shape, the grid layout model, styling, breakpoints, forms/data, and the build workflow. Read this before building or heavily editing a page.", {}, () => handle(async () => ({ guide: BUILD_GUIDE })));
|
|
41
66
|
server.tool("list_elements", "List all BuilderX element/component types you can place on a page, grouped by category with a one-line summary and whether each is a container.", {}, () => handle(async () => listElements()));
|
|
@@ -90,8 +115,18 @@ The source must be { sections: [...] } — build sections with new_section. Vali
|
|
|
90
115
|
.optional()
|
|
91
116
|
.describe("Page kind. SPECIAL pages need a site data-source enabled — build_page does this automatically: store→use_store (product/cart bindings), member→use_member (customer/order bindings), blog→use_blog, error→use_error, maintain→use_maintain. 'main'/'custom' need nothing. Omit for a normal content page (defaults to 'main' for the homepage)."),
|
|
92
117
|
is_homepage: z.boolean().default(false).describe("Set as the site homepage"),
|
|
118
|
+
seo: z
|
|
119
|
+
.object({
|
|
120
|
+
title: z.string().optional().describe("SEO/browser title. Tokens allowed: {{name_page}}, {{name_site}}, {{name_product}}, {{name_category}}."),
|
|
121
|
+
description: z.string().optional().describe("Meta description (~155 chars)."),
|
|
122
|
+
keyword: z.string().optional().describe("Comma-separated keywords."),
|
|
123
|
+
favicon: z.string().optional().describe("Favicon URL (hosted)."),
|
|
124
|
+
thumbnail: z.string().optional().describe("Social/OG share image URL (hosted)."),
|
|
125
|
+
})
|
|
126
|
+
.optional()
|
|
127
|
+
.describe("SEO for this page → settings.seo. Without it the page publishes with an EMPTY title/description. For a store page a good default title is '{{name_product}} | {{name_site}}' (product) or '{{name_category}} | {{name_site}}' (category)."),
|
|
93
128
|
dry_run: z.boolean().default(true).describe("Preview+validate only (true) or create+save (false)"),
|
|
94
|
-
}, ({ name, slug, source, type, is_homepage, dry_run }) => handle(async () => {
|
|
129
|
+
}, ({ name, slug, source, type, is_homepage, seo, dry_run }) => handle(async () => {
|
|
95
130
|
const parsed = parseSource(source);
|
|
96
131
|
const validation = validatePage(parsed);
|
|
97
132
|
// Resolve numeric page type + the site data-source flag a special page needs.
|
|
@@ -132,10 +167,15 @@ The source must be { sections: [...] } — build sections with new_section. Vali
|
|
|
132
167
|
if (!pageId) {
|
|
133
168
|
return { error: "Page created but no id was returned.", created };
|
|
134
169
|
}
|
|
135
|
-
// slug / homepage are not applied at create — set them via update_page.
|
|
136
|
-
|
|
170
|
+
// slug / homepage / SEO are not applied at create — set them via update_page.
|
|
171
|
+
const seoBlock = seo ? buildPageSeo(seo) : null;
|
|
172
|
+
if (slug || is_homepage || (seoBlock && Object.keys(seoBlock).length)) {
|
|
137
173
|
await api
|
|
138
|
-
.updatePage(pageId, {
|
|
174
|
+
.updatePage(pageId, {
|
|
175
|
+
...(slug ? { slug } : {}),
|
|
176
|
+
...(is_homepage ? { is_homepage: true } : {}),
|
|
177
|
+
...(seoBlock && Object.keys(seoBlock).length ? { settings: { seo: seoBlock } } : {}),
|
|
178
|
+
})
|
|
139
179
|
.catch(() => { });
|
|
140
180
|
}
|
|
141
181
|
return {
|
|
@@ -207,7 +247,7 @@ form|summary, and a centred thank-you — and navigation between them (cart→ch
|
|
|
207
247
|
Still add a GLOBAL header/footer with scaffold_global_sections (or create_global_section).
|
|
208
248
|
Pass style:"minimal" for the old bare stubs (heading + binding element only).`, {
|
|
209
249
|
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
|
|
250
|
+
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 matrix vars (var(--color_20) brand accent, var(--color_04) black text, var(--color_00) white surface)."),
|
|
211
251
|
include_member: z.boolean().default(false).describe("Also create login/register/profile (type member, use_member)"),
|
|
212
252
|
include_blog: z.boolean().default(false).describe("Also create blog list + post pages (type blog, use_blog)"),
|
|
213
253
|
dry_run: z.boolean().default(true).describe("Preview (true) or actually create the missing pages (false)"),
|
|
@@ -218,16 +258,18 @@ Pass style:"minimal" for the old bare stubs (heading + binding element only).`,
|
|
|
218
258
|
target: `${name}::${field}`,
|
|
219
259
|
});
|
|
220
260
|
const h1 = (text) => ({ type: "text", opts: { text, specials: { tag: "h1" }, style: { fontSize: "32px", fontWeight: "700" } } });
|
|
221
|
-
const accentBtn = (text, type = "button") => ({ type, opts: { text, style: { background: "var(--
|
|
261
|
+
const accentBtn = (text, type = "button") => ({ type, opts: { text, style: { background: "var(--color_20)", color: "var(--color_00)", borderRadius: "8px", height: 48, fontWeight: "600" } } });
|
|
222
262
|
// Rich (default) store pages come from the designed, palette-aware templates;
|
|
223
|
-
// 'minimal' falls back to the original bare stubs.
|
|
224
|
-
|
|
263
|
+
// 'minimal' falls back to the original bare stubs. Derive a contrast-safe accent from
|
|
264
|
+
// the site's active theme (explicit palette overrides win).
|
|
265
|
+
const themePal = await contrastSafePalette(api);
|
|
266
|
+
const pal = resolvePalette({ ...themePal, ...(palette || {}) });
|
|
225
267
|
const minimalStore = {
|
|
226
268
|
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
269
|
products: () => ({ sections: [buildSection([
|
|
228
270
|
{ type: "product-gallery", opts: {} },
|
|
229
271
|
{ 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(--
|
|
272
|
+
{ type: "text-dataset", opts: { bindings: [bind("product", "product_price")], style: { fontSize: "22px", fontWeight: "700", color: "var(--color_20)" } } },
|
|
231
273
|
{ type: "quantity-input", opts: {} },
|
|
232
274
|
accentBtn("Thêm vào giỏ"),
|
|
233
275
|
])] }),
|
|
@@ -6,7 +6,9 @@ export function registerCollectionTools(server, api, handle) {
|
|
|
6
6
|
term: z.string().optional().describe("Search by collection name"),
|
|
7
7
|
}, ({ page, limit, term }) => handle(async () => {
|
|
8
8
|
const res = await api.listCollections({ page, limit, term });
|
|
9
|
-
|
|
9
|
+
// Real shape: { data: { data:[…], total_entries } }. Accept a flat data[] too.
|
|
10
|
+
const d = res && res.data;
|
|
11
|
+
const collections = (d && (Array.isArray(d) ? d : d.data)) || res || [];
|
|
10
12
|
if (!Array.isArray(collections))
|
|
11
13
|
return res;
|
|
12
14
|
return {
|
|
@@ -17,7 +19,7 @@ export function registerCollectionTools(server, api, handle) {
|
|
|
17
19
|
fields_count: (c.schema || []).length,
|
|
18
20
|
records_count: c.records_count || undefined,
|
|
19
21
|
})),
|
|
20
|
-
total: res.total
|
|
22
|
+
total: (d && !Array.isArray(d) && d.total_entries) ?? res.total ?? collections.length,
|
|
21
23
|
};
|
|
22
24
|
}));
|
|
23
25
|
server.tool("get_collection", "Get a specific collection's details including full schema (field names, types, constraints, references) and records", {
|
package/dist/tools/orders.js
CHANGED
|
@@ -6,7 +6,8 @@ export function registerOrderTools(server, api, handle) {
|
|
|
6
6
|
status: z.number().optional().describe("Filter by status (0=pending, 50=confirmed, 100=shipping, 150=delivered, -1=cancelled)"),
|
|
7
7
|
}, ({ page, limit, status }) => handle(async () => {
|
|
8
8
|
const res = await api.listOrders({ page, limit, status });
|
|
9
|
-
|
|
9
|
+
// Real shape: { orders: { data:[…], total_entries, count_status } }.
|
|
10
|
+
const orders = (res && (res.orders?.data || res.data)) || res || [];
|
|
10
11
|
if (!Array.isArray(orders))
|
|
11
12
|
return res;
|
|
12
13
|
return {
|
|
@@ -22,7 +23,8 @@ export function registerOrderTools(server, api, handle) {
|
|
|
22
23
|
created_at: o.created_at,
|
|
23
24
|
updated_at: o.updated_at,
|
|
24
25
|
})),
|
|
25
|
-
total: res.total
|
|
26
|
+
total: res.orders?.total_entries ?? res.total ?? orders.length,
|
|
27
|
+
...(res.orders?.count_status ? { count_status: res.orders.count_status } : {}),
|
|
26
28
|
};
|
|
27
29
|
}));
|
|
28
30
|
server.tool("get_order", "Get full order details by ID: customer info, items, payment, shipping, discounts, etc.", {
|
package/dist/tools/pages.js
CHANGED
|
@@ -3,6 +3,7 @@ import { CUSTOM_CODE_GUIDE } from "../guides.js";
|
|
|
3
3
|
import { getConfirmMode } from "./context.js";
|
|
4
4
|
import { normalizeEvents } from "../builder/events.js";
|
|
5
5
|
import { normalizeBindings } from "../builder/bindings.js";
|
|
6
|
+
import { PAGE_TYPE_NUM, PAGE_KINDS, buildPageSeo } from "./builder.js";
|
|
6
7
|
/**
|
|
7
8
|
* Page source utilities.
|
|
8
9
|
*
|
|
@@ -293,15 +294,32 @@ Examples:
|
|
|
293
294
|
const results = searchElements(source, filters);
|
|
294
295
|
return { page_id, matched: results.length, elements: results };
|
|
295
296
|
}));
|
|
296
|
-
server.tool("create_page", "Create a new page", {
|
|
297
|
+
server.tool("create_page", "Create a new (empty) page. For a page with content use build_page instead. type is a KIND (main/store/member/blog/custom/error/maintain) mapped to the numeric backend type; pass seo so it doesn't publish with an empty title.", {
|
|
297
298
|
name: z.string().describe("Page name"),
|
|
298
299
|
slug: z.string().describe("URL slug (e.g. '/about')"),
|
|
299
|
-
type: z.
|
|
300
|
+
type: z.enum(PAGE_KINDS).optional().describe("Page kind (main/store/member/blog/custom/error/maintain). store/member/blog need their data-source flag enabled — prefer build_page which auto-enables it."),
|
|
300
301
|
is_homepage: z.boolean().default(false).describe("Set as homepage"),
|
|
301
|
-
|
|
302
|
-
|
|
302
|
+
seo: z
|
|
303
|
+
.object({ title: z.string().optional(), description: z.string().optional(), keyword: z.string().optional(), favicon: z.string().optional(), thumbnail: z.string().optional() })
|
|
304
|
+
.optional()
|
|
305
|
+
.describe("SEO → settings.seo (title/description/keyword/favicon/thumbnail). Tokens {{name_page}}/{{name_site}} allowed."),
|
|
306
|
+
}, ({ name, slug, type, is_homepage, seo }) => handle(async () => {
|
|
307
|
+
const typeNum = type ? PAGE_TYPE_NUM[type] : undefined;
|
|
308
|
+
const created = await api.createPage({ name, ...(typeNum != null ? { type: typeNum } : {}) });
|
|
303
309
|
invalidatePageCache();
|
|
304
|
-
|
|
310
|
+
const pageId = (created && (created.id || created.data?.id || created.page?.id)) || null;
|
|
311
|
+
const seoBlock = seo ? buildPageSeo(seo) : null;
|
|
312
|
+
if (pageId && (slug || is_homepage || (seoBlock && Object.keys(seoBlock).length))) {
|
|
313
|
+
await api
|
|
314
|
+
.updatePage(pageId, {
|
|
315
|
+
...(slug ? { slug } : {}),
|
|
316
|
+
...(is_homepage ? { is_homepage: true } : {}),
|
|
317
|
+
...(seoBlock && Object.keys(seoBlock).length ? { settings: { seo: seoBlock } } : {}),
|
|
318
|
+
})
|
|
319
|
+
.catch(() => { });
|
|
320
|
+
invalidatePageCache();
|
|
321
|
+
}
|
|
322
|
+
return { success: true, page_id: pageId, name, slug, type: type ?? null, raw: pageId ? undefined : created };
|
|
305
323
|
}));
|
|
306
324
|
server.tool("update_page", "Update page properties (name, slug, settings, custom code)", {
|
|
307
325
|
page_id: z.string().describe("Page ID"),
|
package/dist/tools/products.js
CHANGED
|
@@ -11,7 +11,8 @@ export function registerProductTools(server, api, handle) {
|
|
|
11
11
|
if (term && term.trim())
|
|
12
12
|
query.term = term.trim();
|
|
13
13
|
const res = await api.listProducts(query);
|
|
14
|
-
|
|
14
|
+
// Real shape: { products:[…], total_product }. Fall back to data/array for safety.
|
|
15
|
+
const products = (res && (res.products || res.data)) || res || [];
|
|
15
16
|
if (!Array.isArray(products))
|
|
16
17
|
return res;
|
|
17
18
|
return {
|
|
@@ -28,7 +29,7 @@ export function registerProductTools(server, api, handle) {
|
|
|
28
29
|
categories: p.categories || undefined,
|
|
29
30
|
updated_at: p.updated_at,
|
|
30
31
|
})),
|
|
31
|
-
total: res.total
|
|
32
|
+
total: res.total_product ?? res.total ?? products.length,
|
|
32
33
|
};
|
|
33
34
|
}));
|
|
34
35
|
server.tool("get_product", "Get full product details by ID: name, description, price, variations, images, attributes, SEO, etc.", {
|
package/dist/tools/promotions.js
CHANGED
|
@@ -81,7 +81,8 @@ export function registerPromotionTools(server, api, handle) {
|
|
|
81
81
|
}));
|
|
82
82
|
server.tool("get_active_promotions", "Get all currently active promotions (is_activated=true and within start_time/end_time range)", {}, () => handle(async () => {
|
|
83
83
|
const res = await api.getActivePromotions();
|
|
84
|
-
|
|
84
|
+
// Real shape: { promotions:[…] } at the top level (not under data).
|
|
85
|
+
const promotions = (res && (res.promotions || (res.data && res.data.promotions) || res.data)) || [];
|
|
85
86
|
return {
|
|
86
87
|
data: Array.isArray(promotions)
|
|
87
88
|
? promotions.map((p) => ({
|
|
@@ -124,8 +125,19 @@ export function registerPromotionTools(server, api, handle) {
|
|
|
124
125
|
query.page = page;
|
|
125
126
|
if (limit)
|
|
126
127
|
query.limit = limit;
|
|
127
|
-
const res = await api.searchPromotions(
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
const res = await api.searchPromotions({ page: page ?? 1, limit: limit ?? 50 });
|
|
129
|
+
let list = (res && (res.data?.result || res.data)) || [];
|
|
130
|
+
if (!Array.isArray(list))
|
|
131
|
+
return res;
|
|
132
|
+
// The /all endpoint doesn't filter, so apply the requested filters client-side.
|
|
133
|
+
if (term)
|
|
134
|
+
list = list.filter((p) => (p.name || "").toLowerCase().includes(term.toLowerCase()));
|
|
135
|
+
if (type)
|
|
136
|
+
list = list.filter((p) => p.type === type);
|
|
137
|
+
if (status != null)
|
|
138
|
+
list = list.filter((p) => p.time_status === status || p.status === status);
|
|
139
|
+
if (is_activated != null)
|
|
140
|
+
list = list.filter((p) => Boolean(p.is_activated) === is_activated);
|
|
141
|
+
return { data: list, total: list.length, total_unfiltered: res.total_entries };
|
|
130
142
|
}));
|
|
131
143
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcake-storefront-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.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",
|