# Custom Block Flow Guide เอกสารนี้อธิบาย 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", "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 ปกติ - `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 } ``` สำหรับ 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" }] } }` } ``` เหตุผล: - 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 ผ่าน ## 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 ```