# Custom Block Flow Guide > **Platform scope:** This guide describes shared CMS block workflows. Content examples belong to their named tenant. เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ HomAroma Page Builder โดยใช้ `BakeryCategories` เป็นตัวอย่างจริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง ## แนวคิดหลัก Custom Block คือ section ประเภท `CUSTOM` ที่เก็บชื่อ component และ props ไว้ใน CMS payload: ```json { "componentId": "BakeryCategories", "props": { "title": { "th": "หมวดหมู่เบเกอรี่", "en": "Bakery Categories" } } } ``` Renderer จะอ่าน `componentId` แล้วไปหา component จาก `customRegistry` ของแต่ละ frontend: - `apps/website/src/config/customRegistry.ts` - `apps/spa/src/config/customRegistry.ts` ดังนั้นการเพิ่ม Custom Block ใหม่ต้องทำให้ครบ 4 ชั้น: 1. Shared type และ backend validation 2. Admin Page Builder form 3. Frontend registry 4. Frontend component render จริง ## Step 1: ออกแบบ Payload เริ่มจากกำหนด JSON shape ก่อนเสมอ ห้ามเดา field ตอนเขียน component ตัวอย่าง payload ของ `BakeryCategories`: ```json { "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`, `bread` - `imageUrl` เป็น optional ถ้าไม่ใส่ component ต้องมี fallback ที่หน้าไม่พัง ## Step 2: เพิ่ม Shared Type ไฟล์หลัก: ```text packages/types/index.ts ``` ต้องมี schema สำหรับ `CUSTOM`: ```ts 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; ``` และเพิ่ม `CUSTOM` เข้า `PAGE_SECTION_TYPES`: ```ts 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 ไฟล์หลัก: ```text apps/backend/src/routes/pages.ts ``` เพิ่ม import: ```ts import { CustomSectionPayloadSchema } from "@homaroma/types"; ``` เพิ่ม case ใน `validateSectionPayload`: ```ts case "CUSTOM": schema = CustomSectionPayloadSchema; break; ``` ผลลัพธ์ที่ต้องได้: - สร้าง section type `CUSTOM` ได้ - update payload ของ custom section ได้ - ถ้าไม่มี `componentId` backend ต้อง reject ## Step 4: เพิ่ม Admin Page Builder ไฟล์หลัก: ```text apps/admin/src/pages/PageBuilderPage.tsx apps/admin/src/components/page-builder/CustomBlockForm.tsx ``` ใน `PageBuilderPage.tsx` ต้องทำ 3 จุด: 1. Import form: ```ts import CustomBlockForm from '../components/page-builder/CustomBlockForm'; ``` 2. เพิ่ม default payload เมื่อเลือก `CUSTOM`: ```ts case 'CUSTOM': defaultPayload = { componentId: 'BakeryCategories', props: { title: { th: 'หมวดหมู่เบเกอรี่', en: 'Bakery Categories' }, layout: 'horizontal', items: [] } }; break; ``` 3. เพิ่ม option และ render form: ```tsx ``` ```tsx {editingSection.type?.toUpperCase() === 'CUSTOM' && ( )} ``` `CustomBlockForm` มีหน้าที่รับ: - `Component ID` - `Props JSON` สำหรับ `BakeryCategories` ให้ใส่: ```text Component ID: BakeryCategories ``` แล้วใส่ JSON ใน `Props JSON` ## Step 5: เพิ่ม Website Registry ไฟล์หลัก: ```text apps/website/src/config/customRegistry.ts ``` ตัวอย่าง: ```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 export const customRegistry: Record = { BakeryCategories, } ``` กฎสำคัญ: - key ใน registry ต้องตรงกับ `componentId` ใน Admin - ถ้า Admin ใส่ `BakeryCategories` แต่ registry ไม่มี key นี้ block จะไม่ render - website ต้องส่ง `lang` เข้า custom component เพราะ route เป็น `/th`, `/en`, etc. ## Step 6: เพิ่ม SPA Registry ไฟล์หลัก: ```text apps/spa/src/config/customRegistry.ts ``` ตัวอย่าง: ```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; export const customRegistry: Record = { BakeryCategories, }; ``` SPA ไม่ต้องรับ `lang` จาก registry เพราะ component ใช้ `useTranslation()` อ่าน `i18n.language` เอง ## Step 7: เพิ่ม Renderer Case Website renderer: ```text apps/website/src/components/dynamic/DynamicPageRenderer.tsx ``` SPA renderer: ```text apps/spa/src/components/DynamicPageRenderer.tsx ``` Renderer ต้องมี case `CUSTOM` ที่ทำงานประมาณนี้: ```tsx case 'CUSTOM': { const componentId = typeof payload.componentId === 'string' ? payload.componentId : '' const props = toRecord(payload.props) const CustomComponent = customRegistry[componentId] if (!CustomComponent) { return null } return ( ) } ``` สำหรับ SPA จะไม่มี `lang={lang}`: ```tsx ``` กฎสำคัญ: - Renderer ไม่ควรรู้ logic เฉพาะของ `BakeryCategories` - Renderer แค่ map `componentId` ไป component เท่านั้น - ถ้า component ไม่เจอ ให้ return `null` และ warn เฉพาะ dev ได้ ## Step 8: สร้าง Component จริง Website: ```text apps/website/src/components/custom/BakeryCategories.tsx ``` SPA: ```text 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 รองรับ: ```ts type BakeryCategory = { title: string | Record description?: string | Record href: string imageUrl?: string icon?: string } ``` สำหรับ website ใช้: ```ts resolveLocalized(value, lang) ``` สำหรับ SPA ใช้: ```ts getLocalizedField(value, i18n.language) ``` ## Step 9: อัปเดต MCP Guidance ไฟล์หลัก: ```text apps/mcp-server/section-toolkit.ts ``` เพิ่ม `CUSTOM` ใน guidance: ```ts 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: ทดสอบ ควรตรวจอย่างน้อย: ```bash 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 ผ่าน ## Example: StoryFeatureMosaic `StoryFeatureMosaic` เป็น custom block สำหรับ layout แบบ story section: ```text ข้อความซ้าย + รูปภาพ 3 ช่องกลาง + feature icons ด้านขวา ``` ใช้ component id: ```text StoryFeatureMosaic ``` ตัวอย่าง payload: ```json { "componentId": "StoryFeatureMosaic", "props": { "title": { "th": "เรื่องราวของปั้นคำหอม", "en": "The Story of Pun-Kham-Hom", "ja": "Pun-Kham-Homの物語", "zh": "Pun-Kham-Hom 的故事", "vi": "Câu chuyện của Pun-Kham-Hom", "ko": "Pun-Kham-Hom 이야기" }, "subtitle": { "th": "จากความหอม...สู่ความสุขในทุกคำ", "en": "From fragrance to happiness in every bite", "ja": "香りから、一口ごとの幸せへ", "zh": "从香气到每一口的幸福", "vi": "Từ hương thơm đến niềm vui trong từng miếng bánh", "ko": "향기로움에서 한 입의 행복까지" }, "description": { "th": "เราเริ่มต้นจากความรักในขนมไทยและเบเกอรี่ คัดสรรวัตถุดิบคุณภาพดี ปรุงด้วยความพิถีพิถัน ผสานภูมิปัญญาไทยเข้ากับความร่วมสมัย เพื่อส่งต่อความหอมและความสุขในทุกช่วงเวลาที่ได้แบ่งปัน", "en": "We began with a love for Thai desserts and bakery, carefully selecting quality ingredients and blending Thai craft with a modern touch.", "ja": "私たちはタイ菓子とベーカリーへの愛から始まり、厳選した素材と丁寧な手仕事で、伝統と現代らしさを重ねています。", "zh": "我们从对泰式甜点与烘焙的热爱出发,精选优质食材,以细致工艺融合泰式智慧与现代风格。", "vi": "Chúng tôi bắt đầu từ tình yêu dành cho bánh Thái và bánh nướng, chọn lọc nguyên liệu chất lượng và kết hợp tinh hoa Thái với phong cách hiện đại.", "ko": "태국 디저트와 베이커리에 대한 애정에서 시작해 좋은 재료와 정성스러운 손길로 전통과 현대적인 감각을 담았습니다." }, "buttonText": { "th": "อ่านเพิ่มเติม", "en": "Read More", "ja": "もっと読む", "zh": "了解更多", "vi": "Đọc thêm", "ko": "더 보기" }, "buttonHref": "/about", "images": [ { "imageUrl": "https://example.com/story-dessert.jpg", "alt": { "th": "การทำขนมไทย", "en": "Thai dessert preparation" } }, { "imageUrl": "https://example.com/story-bakery.jpg", "alt": { "th": "ขนมปังอบใหม่", "en": "Fresh bakery" } }, { "imageUrl": "https://example.com/story-team.jpg", "alt": { "th": "ทีมงานปั้นคำหอม", "en": "Pun-Kham-Hom team" } } ], "features": [ { "icon": "leaf", "title": { "th": "คัดสรรวัตถุดิบคุณภาพดี", "en": "Quality ingredients" } }, { "icon": "cake", "title": { "th": "ทำสดใหม่ทุกวัน", "en": "Fresh daily" } }, { "icon": "heart", "title": { "th": "ใส่ใจในทุกรายละเอียด", "en": "Made with care" } } ] } } ``` Icon ที่รองรับตอนนี้: ```text leaf, cake, fresh, heart, care, sparkles, premium, check ``` ## Common Mistakes 1. `componentId` ไม่ตรงกับ registry key Admin ใส่: ```json { "componentId": "BakeryCategory" } ``` แต่ registry มี: ```ts BakeryCategories ``` ผลคือ block ไม่แสดง 2. ใส่ข้อความเป็นภาษาเดียว ```json { "title": "Bakery Categories" } ``` แบบนี้ยัง render ได้ แต่ไม่เหมาะกับระบบหลายภาษา ควรใช้: ```json { "title": { "th": "หมวดหมู่เบเกอรี่", "en": "Bakery Categories", "ja": "ベーカリーカテゴリー", "zh": "烘焙类别", "vi": "Danh mục bánh nướng", "ko": "베이커리 카테고리" } } ``` 3. ใส่ `items` เป็น object แทน array ผิด: ```json { "items": { "title": { "th": "ขนมปัง" } } } ``` ถูก: ```json { "items": [ { "title": { "th": "ขนมปัง" }, "href": "/shop" } ] } ``` 4. ลืมเพิ่มทั้ง website และ SPA โปรเจกต์นี้มีสอง frontend: - `apps/website` - `apps/spa` ถ้าเพิ่มแค่ฝั่งเดียว อีกฝั่งจะไม่ render block นั้น ## Summary Flow ```text 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 ```