518 lines
14 KiB
Markdown
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
|
|
```
|