x
This commit is contained in:
1 parent
305b593698
commit
2a9dd8ddec
13 files changed
+1231
-4
No files matched your search
@@ -0,0 +1,515 @@
|
||||
# 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<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
|
||||
}
|
||||
```
|
||||
|
||||
สำหรับ 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
|
||||
```
|
||||
|
||||
Reference in new issue
Block a user