14 KiB
Custom Block Flow Guide
เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ HomAroma Page Builder โดยใช้ BakeryCategories เป็นตัวอย่างจริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง
แนวคิดหลัก
Custom Block คือ section ประเภท CUSTOM ที่เก็บชื่อ component และ props ไว้ใน CMS payload:
{
"componentId": "BakeryCategories",
"props": {
"title": {
"th": "หมวดหมู่เบเกอรี่",
"en": "Bakery Categories"
}
}
}
Renderer จะอ่าน componentId แล้วไปหา component จาก customRegistry ของแต่ละ frontend:
apps/website/src/config/customRegistry.tsapps/spa/src/config/customRegistry.ts
ดังนั้นการเพิ่ม Custom Block ใหม่ต้องทำให้ครบ 4 ชั้น:
- Shared type และ backend validation
- Admin Page Builder form
- Frontend registry
- Frontend component render จริง
Step 1: ออกแบบ Payload
เริ่มจากกำหนด JSON shape ก่อนเสมอ ห้ามเดา field ตอนเขียน component
ตัวอย่าง payload ของ BakeryCategories:
{
"componentId": "BakeryCategories",
"props": {
"title": {
"th": "หมวดหมู่เบเกอรี่",
"en": "Bakery Categories",
"ja": "ベーカリーカテゴリー",
"zh": "烘焙类别",
"vi": "Danh mục bánh nướng",
"ko": "베이커리 카테고리"
},
"subtitle": {
"th": "เลือกซื้อขนมอบตามหมวดหมู่ที่คุณชอบ",
"en": "Explore fresh-baked favorites by collection.",
"ja": "お好みのカテゴリーから焼きたての商品をお選びください。",
"zh": "按类别浏览新鲜烘焙的精选产品。",
"vi": "Khám phá các món bánh nướng mới theo từng bộ sưu tập.",
"ko": "카테고리별로 갓 구운 인기 제품을 살펴보세요."
},
"layout": "horizontal",
"items": [
{
"title": {
"th": "ขนมปัง",
"en": "Breads",
"ja": "パン",
"zh": "面包",
"vi": "Bánh mì",
"ko": "빵"
},
"description": {
"th": "ขนมปังอบสดใหม่ทุกวัน",
"en": "Daily loaves, sourdough, and soft sandwich breads.",
"ja": "毎日焼き上げる食パン、サワードウ、やわらかなサンドイッチブレッド。",
"zh": "每日新鲜出炉的面包、酸面包和柔软三明治面包。",
"vi": "Bánh mì ổ, sourdough và bánh mì sandwich mềm được nướng mỗi ngày.",
"ko": "매일 구운 식빵, 사워도우, 부드러운 샌드위치 브레드."
},
"href": "/shop",
"icon": "bakery",
"imageUrl": "https://example.com/bread.jpg"
}
]
}
}
กฎสำหรับ multilingual content:
- ใช้ language keys ชุดเดียวกับระบบ:
th,en,ja,zh,vi,ko - field ที่เป็นข้อความควรรองรับ localized object เสมอ
- field ที่เป็น route หรือ URL เช่น
href,imageUrlเป็น string ปกติ iconเป็น optional string สำหรับเลือก line icon ในวงกลม เช่นdessert,bakery,cake,gift,cookie,breadimageUrlเป็น optional ถ้าไม่ใส่ component ต้องมี fallback ที่หน้าไม่พัง
Step 2: เพิ่ม Shared Type
ไฟล์หลัก:
packages/types/index.ts
ต้องมี schema สำหรับ CUSTOM:
export const CustomSectionPayloadSchema = z.object({
componentId: z.string().min(1),
props: z.record(z.string(), z.unknown()).optional().default({}),
}).strict();
export type CustomSectionPayload = z.infer<typeof CustomSectionPayloadSchema>;
และเพิ่ม CUSTOM เข้า PAGE_SECTION_TYPES:
export const PAGE_SECTION_TYPES = [
// existing section types...
'CUSTOM'
] as const;
เหตุผล:
- Admin, MCP, backend, frontend จะใช้ source of truth เดียวกัน
- Backend validate ได้ว่า
componentIdต้องมีจริง propsเปิดเป็น object เพื่อให้ custom block แต่ละตัวมี shape ของตัวเอง
Step 3: เพิ่ม Backend Validation
ไฟล์หลัก:
apps/backend/src/routes/pages.ts
เพิ่ม import:
import { CustomSectionPayloadSchema } from "@homaroma/types";
เพิ่ม case ใน validateSectionPayload:
case "CUSTOM":
schema = CustomSectionPayloadSchema;
break;
ผลลัพธ์ที่ต้องได้:
- สร้าง section type
CUSTOMได้ - update payload ของ custom section ได้
- ถ้าไม่มี
componentIdbackend ต้อง reject
Step 4: เพิ่ม Admin Page Builder
ไฟล์หลัก:
apps/admin/src/pages/PageBuilderPage.tsx
apps/admin/src/components/page-builder/CustomBlockForm.tsx
ใน PageBuilderPage.tsx ต้องทำ 3 จุด:
- Import form:
import CustomBlockForm from '../components/page-builder/CustomBlockForm';
- เพิ่ม default payload เมื่อเลือก
CUSTOM:
case 'CUSTOM':
defaultPayload = {
componentId: 'BakeryCategories',
props: {
title: {
th: 'หมวดหมู่เบเกอรี่',
en: 'Bakery Categories'
},
layout: 'horizontal',
items: []
}
};
break;
- เพิ่ม option และ render form:
<option value="CUSTOM">CUSTOM (Registered component)</option>
{editingSection.type?.toUpperCase() === 'CUSTOM' && (
<CustomBlockForm
editingSection={editingSection}
setEditingSection={setEditingSection}
/>
)}
CustomBlockForm มีหน้าที่รับ:
Component IDProps JSON
สำหรับ BakeryCategories ให้ใส่:
Component ID: BakeryCategories
แล้วใส่ JSON ใน Props JSON
Step 5: เพิ่ม Website Registry
ไฟล์หลัก:
apps/website/src/config/customRegistry.ts
ตัวอย่าง:
import type { ComponentType } from 'react'
import type { CustomSectionPayload } from '@homaroma/types'
import BakeryCategories from '../components/custom/BakeryCategories'
export type CustomBlockComponentProps = {
props: CustomSectionPayload['props']
lang: string
}
export type CustomBlockComponent = ComponentType<CustomBlockComponentProps>
export const customRegistry: Record<string, CustomBlockComponent> = {
BakeryCategories,
}
กฎสำคัญ:
- key ใน registry ต้องตรงกับ
componentIdใน Admin - ถ้า Admin ใส่
BakeryCategoriesแต่ registry ไม่มี key นี้ block จะไม่ render - website ต้องส่ง
langเข้า custom component เพราะ route เป็น/th,/en, etc.
Step 6: เพิ่ม SPA Registry
ไฟล์หลัก:
apps/spa/src/config/customRegistry.ts
ตัวอย่าง:
import type { ComponentType } from 'react';
import type { CustomSectionPayload } from '@homaroma/types';
import BakeryCategories from '../components/custom/BakeryCategories';
export type CustomBlockComponentProps = {
props: CustomSectionPayload['props'];
};
export type CustomBlockComponent = ComponentType<CustomBlockComponentProps>;
export const customRegistry: Record<string, CustomBlockComponent> = {
BakeryCategories,
};
SPA ไม่ต้องรับ lang จาก registry เพราะ component ใช้ useTranslation() อ่าน i18n.language เอง
Step 7: เพิ่ม Renderer Case
Website renderer:
apps/website/src/components/dynamic/DynamicPageRenderer.tsx
SPA renderer:
apps/spa/src/components/DynamicPageRenderer.tsx
Renderer ต้องมี case CUSTOM ที่ทำงานประมาณนี้:
case 'CUSTOM': {
const componentId = typeof payload.componentId === 'string' ? payload.componentId : ''
const props = toRecord(payload.props)
const CustomComponent = customRegistry[componentId]
if (!CustomComponent) {
return null
}
return (
<ScrollReveal key={section.id}>
<CustomComponent props={props} lang={lang} />
</ScrollReveal>
)
}
สำหรับ SPA จะไม่มี lang={lang}:
<CustomComponent props={payload.props ?? {}} />
กฎสำคัญ:
- Renderer ไม่ควรรู้ logic เฉพาะของ
BakeryCategories - Renderer แค่ map
componentIdไป component เท่านั้น - ถ้า component ไม่เจอ ให้ return
nullและ warn เฉพาะ dev ได้
Step 8: สร้าง Component จริง
Website:
apps/website/src/components/custom/BakeryCategories.tsx
SPA:
apps/spa/src/components/custom/BakeryCategories.tsx
หน้าที่ของ component:
- อ่าน
props.title - อ่าน
props.subtitle - อ่าน
props.layout - อ่าน
props.items - resolve localized text ตามภาษาปัจจุบัน
- render UI โดยใช้ design tokens/classes ของ app นั้น ๆ
ตัวอย่าง field ที่ component รองรับ:
type BakeryCategory = {
title: string | Record<string, string>
description?: string | Record<string, string>
href: string
imageUrl?: string
icon?: string
}
สำหรับ website ใช้:
resolveLocalized(value, lang)
สำหรับ SPA ใช้:
getLocalizedField(value, i18n.language)
Step 9: อัปเดต MCP Guidance
ไฟล์หลัก:
apps/mcp-server/section-toolkit.ts
เพิ่ม CUSTOM ใน guidance:
CUSTOM: {
required: ["componentId"],
example: `{ "componentId": "BakeryCategories", "props": { "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "layout": "horizontal", "items": [{ "title": ${localizedStringExample}, "description": ${localizedStringExample}, "href": "/shop", "icon": "bakery" }] } }`
}
เหตุผล:
- AI/MCP tools จะรู้ว่า
CUSTOMใช้ได้ - ผู้ใช้ tool จะเห็น minimal example ที่ถูกต้อง
- ลดโอกาสสร้าง payload ที่ backend reject
Step 10: ทดสอบ
ควรตรวจอย่างน้อย:
bun run --cwd apps/website build
bun run --cwd apps/spa build
bun run --cwd apps/admin build
Acceptance criteria:
- Admin สร้าง section
CUSTOMได้ - Backend รับ payload ที่มี
componentIdได้ - Website render
BakeryCategoriesได้ - SPA render
BakeryCategoriesได้ - เปลี่ยนภาษาแล้ว title/subtitle/items แสดงตาม language key
- ถ้า
imageUrlไม่มี ต้องไม่ทำให้หน้า error
Checklist สำหรับ Custom Block ตัวถัดไป
ใช้ checklist นี้ทุกครั้งที่เพิ่ม custom block ใหม่:
- ตั้งชื่อ componentId แล้ว เช่น
BakeryCategories - ออกแบบ props JSON ให้ชัดเจน
- ข้อความทุก field รองรับ localized object
- เพิ่ม component ใน
apps/website/src/components/custom - เพิ่ม component ใน
apps/spa/src/components/custom - register ใน
apps/website/src/config/customRegistry.ts - register ใน
apps/spa/src/config/customRegistry.ts - ถ้าต้องการ default ใน Admin ให้เพิ่มใน
PageBuilderPage.tsx - ถ้าต้องการให้ Admin กรอกง่ายขึ้น ให้ปรับ
CustomBlockForm.tsx - ถ้าต้องการให้ MCP ใช้งานได้ ให้เพิ่ม guidance ใน
section-toolkit.ts - build ทั้ง website/spa/admin ผ่าน
Common Mistakes
componentIdไม่ตรงกับ registry key
Admin ใส่:
{ "componentId": "BakeryCategory" }
แต่ registry มี:
BakeryCategories
ผลคือ block ไม่แสดง
- ใส่ข้อความเป็นภาษาเดียว
{ "title": "Bakery Categories" }
แบบนี้ยัง render ได้ แต่ไม่เหมาะกับระบบหลายภาษา ควรใช้:
{
"title": {
"th": "หมวดหมู่เบเกอรี่",
"en": "Bakery Categories",
"ja": "ベーカリーカテゴリー",
"zh": "烘焙类别",
"vi": "Danh mục bánh nướng",
"ko": "베이커리 카테고리"
}
}
- ใส่
itemsเป็น object แทน array
ผิด:
{
"items": {
"title": { "th": "ขนมปัง" }
}
}
ถูก:
{
"items": [
{
"title": { "th": "ขนมปัง" },
"href": "/shop"
}
]
}
- ลืมเพิ่มทั้ง website และ SPA
โปรเจกต์นี้มีสอง frontend:
apps/websiteapps/spa
ถ้าเพิ่มแค่ฝั่งเดียว อีกฝั่งจะไม่ render block นั้น
Summary Flow
Admin Page Builder
-> creates PageSection type CUSTOM
-> payload.componentId = BakeryCategories
-> payload.props = localized JSON
-> backend validates CustomSectionPayloadSchema
-> website/spa DynamicPageRenderer receives section
-> customRegistry finds BakeryCategories
-> BakeryCategories resolves language
-> UI renders cards