Files
homaroma/CUSTOM_BLOCK_FLOW_GUIDE.md
T
2026-07-12 19:19:53 -04:00

518 lines
14 KiB
Markdown

# 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",
"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<typeof CustomSectionPayloadSchema>;
```
และเพิ่ม `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
<option value="CUSTOM">CUSTOM (Registered component)</option>
```
```tsx
{editingSection.type?.toUpperCase() === 'CUSTOM' && (
<CustomBlockForm
editingSection={editingSection}
setEditingSection={setEditingSection}
/>
)}
```
`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<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
ไฟล์หลัก:
```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<CustomBlockComponentProps>;
export const customRegistry: Record<string, CustomBlockComponent> = {
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 (
<ScrollReveal key={section.id}>
<CustomComponent props={props} lang={lang} />
</ScrollReveal>
)
}
```
สำหรับ SPA จะไม่มี `lang={lang}`:
```tsx
<CustomComponent props={payload.props ?? {}} />
```
กฎสำคัญ:
- 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<string, string>
description?: string | Record<string, string>
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 ผ่าน
## 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
```