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 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/get_promotions_advance`, { query });
498
+ return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/promotion_advance/all`, { query });
496
499
  }
497
500
  // ── Combos ──
498
501
  listCombos(query) {
@@ -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
  };
@@ -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: prefer the site THEME variables \`var(--color_00)\`, \`var(--color_01)\`,
73
- (the published site themes them); plain hex or rgba() also work.
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(--color_02)", color:"#fff",
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: reuse var(--color_00) (text), and an accent (var(--color_02)/var(--color_03))
100
- for buttons, prices, highlights — consistent accent = looks intentional.
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(--color_02)",
21
- onAccent: "#ffffff",
22
- text: "var(--color_00)",
23
- muted: "#6b7280",
24
- surface: "#ffffff",
25
- surfaceAlt: "#f7f5f2",
26
- border: "#e8e3dc",
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
  // ---------------------------------------------------------------------------
@@ -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
  ]
@@ -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
- const PAGE_TYPE_NUM = {
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
- if (slug || is_homepage) {
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, { ...(slug ? { slug } : {}), ...(is_homepage ? { is_homepage: true } : {}) })
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 CSS vars (var(--color_02) accent, var(--color_00) text)."),
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(--color_02)", color: "#fff", borderRadius: "8px", height: 48, fontWeight: "600" } } });
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
- const pal = resolvePalette(palette || {});
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(--color_02)" } } },
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
- const collections = (res && res.data) || res || [];
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 || collections.length,
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", {
@@ -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
- const orders = (res && res.data) || res || [];
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 || orders.length,
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.", {
@@ -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.string().optional().describe("Page type"),
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
- }, ({ name, slug, type, is_homepage }) => handle(async () => {
302
- const res = await api.createPage({ name, slug, type, is_homepage });
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
- return res;
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"),
@@ -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
- const products = (res && res.data) || res || [];
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 || products.length,
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.", {
@@ -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
- const promotions = (res && res.data && res.data.promotions) || (res && res.data) || [];
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(query);
128
- const data = (res && res.data && res.data.result) || (res && res.data) || res;
129
- return data;
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.26.0",
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",