x
This commit is contained in:
1 parent
0341b30b6d
commit
bd8f5882e0
9 files changed
+1410
-25
No files matched your search
@@ -186,6 +186,113 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
},
|
||||
)
|
||||
.use(adminOnly)
|
||||
.post(
|
||||
"/with-section",
|
||||
async ({ body, set }) => {
|
||||
const { page: pageInput, section: sectionInput } = body as {
|
||||
page: {
|
||||
title: string;
|
||||
slug: string;
|
||||
isPublished?: boolean;
|
||||
};
|
||||
section: {
|
||||
type: string;
|
||||
payload: unknown;
|
||||
order?: number;
|
||||
isActive?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
if (RESERVED_SLUGS.includes(pageInput.slug.toLowerCase())) {
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
error: "Validation failed",
|
||||
validationIssues: [
|
||||
{
|
||||
path: "page.slug",
|
||||
message: "Reserved slug cannot be used",
|
||||
code: "custom",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const validationResult = validateSectionPayload(
|
||||
sectionInput.type,
|
||||
sectionInput.payload,
|
||||
);
|
||||
if (!validationResult.success) {
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
error: "Validation failed",
|
||||
validationIssues: summarizeValidationIssues(
|
||||
validationResult.issues || [],
|
||||
).map((issue) => ({
|
||||
...issue,
|
||||
path:
|
||||
issue.path === "(root)"
|
||||
? "section.payload"
|
||||
: `section.payload.${issue.path}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const page = await tx.page.create({
|
||||
data: {
|
||||
title: pageInput.title,
|
||||
slug: pageInput.slug,
|
||||
isPublished: pageInput.isPublished ?? false,
|
||||
},
|
||||
});
|
||||
const section = await tx.pageSection.create({
|
||||
data: {
|
||||
pageId: page.id,
|
||||
type: sectionInput.type.toUpperCase(),
|
||||
payload: validationResult.data,
|
||||
order: sectionInput.order ?? 1,
|
||||
isActive: sectionInput.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
return { page, section };
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pageId: result.page.id,
|
||||
sectionId: result.section.id,
|
||||
page: result.page,
|
||||
section: result.section,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[Pages] Atomic page creation error:", err);
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: "Failed to create page with section",
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
page: t.Object({
|
||||
title: t.String(),
|
||||
slug: t.String(),
|
||||
isPublished: t.Optional(t.Boolean()),
|
||||
}),
|
||||
section: t.Object({
|
||||
type: t.String(),
|
||||
payload: t.Any(),
|
||||
order: t.Optional(t.Number()),
|
||||
isActive: t.Optional(t.Boolean()),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, set }) => {
|
||||
|
||||
+146
-5
@@ -7,7 +7,7 @@ import {
|
||||
import axios from "axios";
|
||||
import FormData from "form-data";
|
||||
import { MENU_TOOL_DEFINITIONS, handleMenuTool } from "./menu-toolkit";
|
||||
import { SECTION_TOOL_DEFINITIONS, handleSectionTool } from "./section-toolkit";
|
||||
import { SECTION_TOOL_DEFINITIONS, buildCmsPreview, handleSectionTool } from "./section-toolkit";
|
||||
const LOCAL_BACKEND_URL = process.env.MCP_BACKEND_URL_LOCAL || "http://localhost:3051/api";
|
||||
const PRODUCTION_BACKEND_URL = process.env.MCP_BACKEND_URL_PRODUCTION || "https://api.homaroma.shop/api";
|
||||
|
||||
@@ -22,6 +22,34 @@ export const api = axios.create({
|
||||
baseURL: BACKEND_URL,
|
||||
});
|
||||
|
||||
function currentTargetEnvironment() {
|
||||
if (BACKEND_URL === LOCAL_BACKEND_URL) return "local";
|
||||
if (BACKEND_URL === PRODUCTION_BACKEND_URL) return "production";
|
||||
return "custom";
|
||||
}
|
||||
|
||||
function normalizeToolError(err: any) {
|
||||
const data = err.response?.data;
|
||||
if (!data) {
|
||||
return err.message;
|
||||
}
|
||||
|
||||
const validationIssues = data.validationIssues ?? data.issues;
|
||||
if (Array.isArray(validationIssues) && validationIssues.length > 0) {
|
||||
const missingOrInvalid = validationIssues
|
||||
.map((issue: any) => `${issue.path ?? "(root)"}: ${issue.message ?? issue.code ?? "Invalid value"}`)
|
||||
.join("; ");
|
||||
return JSON.stringify({
|
||||
error: data.error ?? data.message ?? "Validation failed",
|
||||
summary: missingOrInvalid,
|
||||
validationIssues,
|
||||
raw: data,
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
// MCP server state is process-local because this server runs over stdio.
|
||||
|
||||
api.interceptors.request.use(async (config) => {
|
||||
@@ -81,6 +109,22 @@ function compactCategory(category: any) {
|
||||
};
|
||||
}
|
||||
|
||||
function compactProduct(product: any) {
|
||||
if (!product) return null;
|
||||
const images = Array.isArray(product.images) ? product.images : [];
|
||||
const firstImage = images[0];
|
||||
return {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
slug: product.slug ?? undefined,
|
||||
sku: product.sku ?? undefined,
|
||||
price: product.price ?? product.variants?.[0]?.price ?? undefined,
|
||||
imageUrl: firstImage?.url ?? firstImage?.imageUrl ?? product.imageUrl ?? null,
|
||||
category: product.category ? compactCategory(product.category) : undefined,
|
||||
brand: product.brand ? { id: product.brand.id, name: product.brand.name } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: "homaroma-mcp",
|
||||
@@ -96,6 +140,11 @@ const server = new Server(
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: "get_target_environment",
|
||||
description: "Return the MCP server's current backend target environment and base URL. Use this before write operations when you need to confirm local vs production.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "set_target_environment",
|
||||
description: "Switch the target environment for the MCP server (local or production). You can also pass a customUrl to test a specific project.",
|
||||
@@ -136,6 +185,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "list_categories_metadata",
|
||||
description: "List compact category IDs/names for Page Builder blocks. Use this before CATEGORIES.categoryIds or PRODUCTS.categoryId.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
lang: { type: "string", description: "Language code: th, en, ja, zh, vi, ko, lo, it, or all" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_category_products",
|
||||
description: "Fetch one category plus its products. Use this before delete_category to see whether products block deletion.",
|
||||
@@ -210,6 +269,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "search_products_compact",
|
||||
description: "Search products with compact output for Page Builder productIds. Prefer this over search_products before PRODUCTS.type=custom.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
search: { type: "string", description: "Search term" },
|
||||
limit: { type: "number", description: "Max results. Default 10." },
|
||||
lang: { type: "string", description: "Language code (e.g. th, en, all)" },
|
||||
brandId: { type: "number", description: "Filter by brand ID" },
|
||||
categoryId: { type: "number", description: "Filter by category ID" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_product_detail",
|
||||
description: "Get full product details including variants, images, and category",
|
||||
@@ -853,7 +926,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
type: "object",
|
||||
properties: {
|
||||
slug: { type: "string", description: "Slug of the page" },
|
||||
includeInactive: { type: "boolean", description: "Include inactive sections" }
|
||||
includeInactive: { type: "boolean", description: "Include inactive sections" },
|
||||
lang: { type: "string", description: "Preview/content language code. Default th." }
|
||||
},
|
||||
required: ["slug"]
|
||||
}
|
||||
@@ -867,6 +941,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
title: { type: "string", description: "Title of the page" },
|
||||
slug: { type: "string", description: "Unique slug identifier. Do not use reserved system slugs (e.g., shop, category, cart, checkout, account, article, etc.)." },
|
||||
isPublished: { type: "boolean", description: "Publish status. Optional; defaults to false if omitted." },
|
||||
lang: { type: "string", description: "Preview language code. Default th." },
|
||||
id: { type: "number", description: "Numeric page ID. Required if you are updating an existing page. Use list_dynamic_pages_metadata or get_dynamic_page_detail to find the ID first." }
|
||||
},
|
||||
required: ["title", "slug"]
|
||||
@@ -934,6 +1009,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
}
|
||||
|
||||
switch (request.params.name) {
|
||||
case "get_target_environment": {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
env: currentTargetEnvironment(),
|
||||
baseUrl: BACKEND_URL,
|
||||
localBaseUrl: LOCAL_BACKEND_URL,
|
||||
productionBaseUrl: PRODUCTION_BACKEND_URL,
|
||||
authenticated: Boolean(authToken),
|
||||
}, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
case "set_target_environment": {
|
||||
const env = params.env;
|
||||
const customUrl = params.customUrl;
|
||||
@@ -973,6 +1064,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const res = await api.get(`/categories${lang}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "list_categories_metadata": {
|
||||
const lang = params.lang ? `?lang=${encodeURIComponent(String(params.lang))}` : "";
|
||||
const res = await api.get(`/categories${lang}`);
|
||||
const categories = Array.isArray(res.data) ? res.data : (res.data?.data ?? []);
|
||||
const compact = Array.isArray(categories) ? categories.map(compactCategory) : [];
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
count: compact.length,
|
||||
categories: compact,
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
case "get_category_products": {
|
||||
const { id } = params as any;
|
||||
const query = new URLSearchParams();
|
||||
@@ -1132,6 +1239,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const res = await api.get(`/products?${search}${limit}${lang}${brandId}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "search_products_compact": {
|
||||
const query = new URLSearchParams();
|
||||
if (params.search) query.set("search", String(params.search));
|
||||
query.set("limit", String(params.limit ?? 10));
|
||||
if (params.lang) query.set("lang", String(params.lang));
|
||||
if (params.brandId) query.set("brandId", String(params.brandId));
|
||||
if (params.categoryId) query.set("categoryId", String(params.categoryId));
|
||||
const res = await api.get(`/products?${query.toString()}`);
|
||||
const products = Array.isArray(res.data) ? res.data : (res.data?.data ?? []);
|
||||
const compact = Array.isArray(products) ? products.map(compactProduct) : [];
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
count: compact.length,
|
||||
products: compact,
|
||||
meta: res.data?.meta ?? undefined,
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
case "get_product_detail": {
|
||||
const lang = params.lang ? `?lang=${params.lang}` : "";
|
||||
const res = await api.get(`/products/${params.id}${lang}`);
|
||||
@@ -1491,9 +1620,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
return { content: [{ type: "text", text: JSON.stringify(metadata, null, 2) }] };
|
||||
}
|
||||
case "get_dynamic_page_detail": {
|
||||
const url = `/pages/${params.slug}` + (params.includeInactive ? "?includeInactive=true" : "");
|
||||
const query = new URLSearchParams();
|
||||
if (params.includeInactive) query.set("includeInactive", "true");
|
||||
if (params.lang) query.set("lang", String(params.lang));
|
||||
const url = `/pages/${params.slug}` + (query.toString() ? `?${query.toString()}` : "");
|
||||
const res = await api.get(url);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
...res.data,
|
||||
preview: buildCmsPreview(params.slug, params.lang || "th"),
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
case "create_dynamic_page": {
|
||||
const payload: any = {
|
||||
@@ -1515,6 +1655,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
isPublished: res.data?.isPublished ?? pageData?.isPublished ?? payload.isPublished ?? false,
|
||||
publishBehavior: res.data?.publishBehavior ?? (payload.isPublished === undefined ? (params.id ? "preserve-existing" : "default-create-false") : "request"),
|
||||
diagnostic: res.data?.diagnostic ?? (params.id ? "Page updated successfully." : "Page created successfully."),
|
||||
preview: buildCmsPreview(params.slug, params.lang || "th"),
|
||||
page: pageData,
|
||||
};
|
||||
return { content: [{ type: "text", text: JSON.stringify(normalized, null, 2) }] };
|
||||
@@ -1567,7 +1708,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
||||
const errorMsg = normalizeToolError(err);
|
||||
return {
|
||||
content: [{ type: "text", text: `Error executing tool: ${errorMsg}` }],
|
||||
isError: true,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
getPageBlockSchema,
|
||||
handleSectionTool,
|
||||
validatePayloadAgainstMetadata,
|
||||
} from "./section-toolkit";
|
||||
|
||||
const websiteBlockTypes = [
|
||||
"HERO",
|
||||
"ANNOUNCEMENT_BAR",
|
||||
"NEWSLETTER",
|
||||
"EDITORIAL_TILE",
|
||||
"EDITORIAL_CAROUSEL",
|
||||
"PRODUCTS",
|
||||
"CATEGORIES",
|
||||
"TEXT",
|
||||
"BANNER",
|
||||
"HERO_ARTICLE",
|
||||
"RICH_TEXT",
|
||||
"SCENT_NOTES_BREAKDOWN",
|
||||
"QUOTE_CALLOUT",
|
||||
"PRODUCT_CAROUSEL",
|
||||
"ACCORDION",
|
||||
"CONTACT_INFO",
|
||||
"CONTACT_FORM",
|
||||
"HERO_V2",
|
||||
"USP_BAR",
|
||||
"CUSTOM",
|
||||
] as const;
|
||||
|
||||
describe("Page Builder block contracts", () => {
|
||||
for (const type of websiteBlockTypes) {
|
||||
test(`${type} exposes a valid recommended payload`, () => {
|
||||
const schema = getPageBlockSchema(type);
|
||||
|
||||
expect(schema.renderability.websitePageRenderable).toBe(true);
|
||||
expect(schema.recommendedPayload).not.toBeNull();
|
||||
expect(validatePayloadAgainstMetadata(type, schema.recommendedPayload).valid).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test("HERO_V2 rejects the legacy empty payload before backend writes", () => {
|
||||
const result = validatePayloadAgainstMetadata("HERO_V2", {});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.missingFields).toEqual(["title", "imageUrl"]);
|
||||
});
|
||||
|
||||
test("USP_BAR rejects an empty payload because the website renders nothing", () => {
|
||||
const result = validatePayloadAgainstMetadata("USP_BAR", {});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.missingFields).toContain("items");
|
||||
});
|
||||
|
||||
test("CUSTOM rejects component IDs missing from the website registry", () => {
|
||||
const result = validatePayloadAgainstMetadata("CUSTOM", {
|
||||
componentId: "UnknownComponent",
|
||||
props: {},
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.validationIssues).toContainEqual({
|
||||
path: "componentId",
|
||||
message: "Unsupported website custom component. Use BakeryCategories or StoryFeatureMosaic.",
|
||||
code: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
test("create_page_with_section rejects invalid payloads without calling backend", async () => {
|
||||
let postCalls = 0;
|
||||
const api = {
|
||||
post: async () => {
|
||||
postCalls += 1;
|
||||
throw new Error("Backend must not be called");
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = await handleSectionTool(api, "create_page_with_section", {
|
||||
title: "Invalid Hero",
|
||||
slug: "invalid-hero",
|
||||
type: "HERO_V2",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(result && "isError" in result ? result.isError : false).toBe(true);
|
||||
expect(postCalls).toBe(0);
|
||||
});
|
||||
|
||||
test("create_page_with_section uses one atomic backend call and returns compact output", async () => {
|
||||
const calls: Array<{ url: string; payload: unknown }> = [];
|
||||
const api = {
|
||||
post: async (url: string, payload: unknown) => {
|
||||
calls.push({ url, payload });
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
pageId: 50,
|
||||
sectionId: 120,
|
||||
page: { id: 50, slug: "hero-v2" },
|
||||
section: { id: 120, type: "HERO_V2" },
|
||||
},
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
const payload = getPageBlockSchema("HERO_V2").recommendedPayload;
|
||||
|
||||
const result = await handleSectionTool(api, "create_page_with_section", {
|
||||
title: "Hero V2",
|
||||
slug: "hero-v2",
|
||||
type: "HERO_V2",
|
||||
payload,
|
||||
isPublished: false,
|
||||
});
|
||||
const response = JSON.parse(result?.content[0]?.text ?? "{}");
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.url).toBe("/pages/with-section");
|
||||
expect(response).toEqual({
|
||||
success: true,
|
||||
tool: "create_page_with_section",
|
||||
pageId: 50,
|
||||
pageSlug: "hero-v2",
|
||||
sectionId: 120,
|
||||
type: "HERO_V2",
|
||||
preview: {
|
||||
lang: "th",
|
||||
previewPath: "/th/hero-v2",
|
||||
previewUrl: "/th/hero-v2",
|
||||
},
|
||||
warnings: [
|
||||
"contentAlignment must be left, center, or right. Use an image URL allowed by apps/website.",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -23,7 +23,7 @@ async function test() {
|
||||
const waitResponse = async (ms: number) => {
|
||||
await new Promise(resolve => setTimeout(resolve, ms));
|
||||
const lines = responseData.trim().split("\n");
|
||||
const lastLine = lines[lines.length - 1];
|
||||
const lastLine = lines[lines.length - 1] ?? "";
|
||||
try {
|
||||
return JSON.parse(lastLine);
|
||||
} catch (e) {
|
||||
|
||||
@@ -160,7 +160,7 @@ export default async function RootLayout({
|
||||
const themeStyle = buildThemeStyle(settings)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-page text-text-main" lang={lang} style={themeStyle}>
|
||||
<div className="site-theme-root min-h-screen bg-surface-page text-text-main" lang={lang} style={themeStyle}>
|
||||
<ClientProviders>
|
||||
<Navbar categories={categories} initialMenu={initialMenu} settings={settings} />
|
||||
<main className="global-main">
|
||||
|
||||
@@ -60,43 +60,49 @@
|
||||
@layer base {
|
||||
:root,
|
||||
html {
|
||||
--font-body-locale: "Sarabun", "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display-locale: "Sarabun", "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-body-locale: var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display-locale: var(--font-sarabun, "Sarabun"), "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif-locale: ui-serif;
|
||||
}
|
||||
|
||||
html[lang="en"] {
|
||||
[lang="en"] {
|
||||
--font-body-locale: "Inter", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display-locale: var(--font-body-locale);
|
||||
--font-serif-locale: ui-serif;
|
||||
}
|
||||
|
||||
html[lang="ja"] {
|
||||
[lang="ja"] {
|
||||
--font-body-locale: "Hiragino Sans", "Yu Gothic", "Noto Sans JP", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display-locale: var(--font-body-locale);
|
||||
--font-serif-locale: "Noto Serif JP", ui-serif;
|
||||
}
|
||||
|
||||
html[lang="zh"] {
|
||||
[lang="zh"] {
|
||||
--font-body-locale: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display-locale: var(--font-body-locale);
|
||||
--font-serif-locale: "Noto Serif SC", ui-serif;
|
||||
}
|
||||
|
||||
html[lang="ko"] {
|
||||
[lang="ko"] {
|
||||
--font-body-locale: "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display-locale: var(--font-body-locale);
|
||||
--font-serif-locale: "Noto Serif KR", ui-serif;
|
||||
}
|
||||
|
||||
html[lang="vi"] {
|
||||
[lang="vi"] {
|
||||
--font-body-locale: "Inter", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display-locale: var(--font-body-locale);
|
||||
--font-serif-locale: ui-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-body-locale);
|
||||
font-family: var(--font-body-active, var(--font-body-locale));
|
||||
}
|
||||
|
||||
.site-theme-root {
|
||||
--font-sans: var(--font-body-active, var(--font-body-locale));
|
||||
--font-serif: var(--font-display-active, var(--font-display-locale));
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -108,7 +114,7 @@
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
@apply text-text-main font-semibold tracking-tight;
|
||||
font-family: var(--font-display-locale);
|
||||
font-family: var(--font-display-active, var(--font-display-locale));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +272,7 @@
|
||||
|
||||
.ui-hero-title {
|
||||
color: var(--color-text-main);
|
||||
font-family: var(--font-display-locale);
|
||||
font-family: var(--font-display-active, var(--font-display-locale));
|
||||
font-size: 3rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.25;
|
||||
|
||||
@@ -26,7 +26,7 @@ const playfairDisplay = Playfair_Display({
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="th" className={`${sarabun.variable} ${kanit.variable} ${playfairDisplay.variable} scroll-smooth`}>
|
||||
<body className="min-h-screen bg-surface-page text-text-main font-sans antialiased">
|
||||
<body className="min-h-screen bg-surface-page text-text-main antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -629,6 +629,34 @@ export const PAGE_SECTION_TYPES = [
|
||||
] as const;
|
||||
export type SectionType = typeof PAGE_SECTION_TYPES[number];
|
||||
|
||||
export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
|
||||
HERO: HeroSectionPayloadSchema,
|
||||
ANNOUNCEMENT_BAR: AnnouncementBarSectionPayloadSchema,
|
||||
NEWSLETTER: NewsletterSectionPayloadSchema,
|
||||
EDITORIAL_TILE: EditorialTileSectionPayloadSchema,
|
||||
EDITORIAL_CAROUSEL: EditorialCarouselSectionPayloadSchema,
|
||||
PRODUCTS: ProductsSectionPayloadSchema,
|
||||
CATEGORIES: CategoriesSectionPayloadSchema,
|
||||
TEXT: TextSectionPayloadSchema,
|
||||
BANNER: BannerSectionPayloadSchema,
|
||||
HERO_ARTICLE: HeroArticleSectionPayloadSchema,
|
||||
RICH_TEXT: RichTextSectionPayloadSchema,
|
||||
SCENT_NOTES_BREAKDOWN: ScentNotesBreakdownSectionPayloadSchema,
|
||||
QUOTE_CALLOUT: QuoteCalloutSectionPayloadSchema,
|
||||
PRODUCT_CAROUSEL: ProductCarouselSectionPayloadSchema,
|
||||
ACCORDION: AccordionSectionPayloadSchema,
|
||||
CONTACT_INFO: ContactInfoSectionPayloadSchema,
|
||||
CONTACT_FORM: ContactFormSectionPayloadSchema,
|
||||
HERO_V2: HeroV2SectionPayloadSchema,
|
||||
USP_BAR: UspBarSectionPayloadSchema,
|
||||
PRODUCT_MAIN: ProductLayoutSectionPayloadSchema,
|
||||
PRODUCT_DETAILS: ProductLayoutSectionPayloadSchema,
|
||||
PRODUCT_SPECS: ProductLayoutSectionPayloadSchema,
|
||||
PRODUCT_REVIEWS: ProductLayoutSectionPayloadSchema,
|
||||
RELATED_PRODUCTS: ProductLayoutSectionPayloadSchema,
|
||||
CUSTOM: CustomSectionPayloadSchema,
|
||||
};
|
||||
|
||||
export const PRODUCT_DETAIL_SECTION_TYPES = [
|
||||
'PRODUCT_MAIN',
|
||||
'HERO',
|
||||
|
||||
Reference in new issue
Block a user