Files
homaroma/CUSTOM_BLOCK_FLOW_GUIDE.md
T
2026-07-13 20:04:23 -04:00

18 KiB

Custom Block Flow Guide

เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ HomAroma Page Builder โดยใช้ BakeryCategories เป็นตัวอย่างจริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง

แนวคิดหลัก

Custom Block คือ section ประเภท CUSTOM ที่เก็บชื่อ component และ props ไว้ใน CMS payload:

{
  "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:

{
  "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

ไฟล์หลัก:

packages/types/index.ts

ต้องมี schema สำหรับ CUSTOM:

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:

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

ไฟล์หลัก:

apps/backend/src/routes/pages.ts

เพิ่ม import:

import { CustomSectionPayloadSchema } from "@homaroma/types";

เพิ่ม case ใน validateSectionPayload:

case "CUSTOM":
  schema = CustomSectionPayloadSchema;
  break;

ผลลัพธ์ที่ต้องได้:

  • สร้าง section type CUSTOM ได้
  • update payload ของ custom section ได้
  • ถ้าไม่มี componentId backend ต้อง reject

Step 4: เพิ่ม Admin Page Builder

ไฟล์หลัก:

apps/admin/src/pages/PageBuilderPage.tsx
apps/admin/src/components/page-builder/CustomBlockForm.tsx

ใน PageBuilderPage.tsx ต้องทำ 3 จุด:

  1. Import form:
import CustomBlockForm from '../components/page-builder/CustomBlockForm';
  1. เพิ่ม default payload เมื่อเลือก CUSTOM:
case 'CUSTOM':
  defaultPayload = {
    componentId: 'BakeryCategories',
    props: {
      title: {
        th: 'หมวดหมู่เบเกอรี่',
        en: 'Bakery Categories'
      },
      layout: 'horizontal',
      items: []
    }
  };
  break;
  1. เพิ่ม option และ render form:
<option value="CUSTOM">CUSTOM (Registered component)</option>
{editingSection.type?.toUpperCase() === 'CUSTOM' && (
  <CustomBlockForm
    editingSection={editingSection}
    setEditingSection={setEditingSection}
  />
)}

CustomBlockForm มีหน้าที่รับ:

  • Component ID
  • Props JSON

สำหรับ BakeryCategories ให้ใส่:

Component ID: BakeryCategories

แล้วใส่ JSON ใน Props JSON

Step 5: เพิ่ม Website Registry

ไฟล์หลัก:

apps/website/src/config/customRegistry.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

ไฟล์หลัก:

apps/spa/src/config/customRegistry.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:

apps/website/src/components/dynamic/DynamicPageRenderer.tsx

SPA renderer:

apps/spa/src/components/DynamicPageRenderer.tsx

Renderer ต้องมี case CUSTOM ที่ทำงานประมาณนี้:

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}:

<CustomComponent props={payload.props ?? {}} />

กฎสำคัญ:

  • Renderer ไม่ควรรู้ logic เฉพาะของ BakeryCategories
  • Renderer แค่ map componentId ไป component เท่านั้น
  • ถ้า component ไม่เจอ ให้ return null และ warn เฉพาะ dev ได้

Step 8: สร้าง Component จริง

Website:

apps/website/src/components/custom/BakeryCategories.tsx

SPA:

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 รองรับ:

type BakeryCategory = {
  title: string | Record<string, string>
  description?: string | Record<string, string>
  href: string
  imageUrl?: string
  icon?: string
}

สำหรับ website ใช้:

resolveLocalized(value, lang)

สำหรับ SPA ใช้:

getLocalizedField(value, i18n.language)

Step 9: อัปเดต MCP Guidance

ไฟล์หลัก:

apps/mcp-server/section-toolkit.ts

เพิ่ม CUSTOM ใน guidance:

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: ทดสอบ

ควรตรวจอย่างน้อย:

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:

ข้อความซ้าย + รูปภาพ 3 ช่องกลาง + feature icons ด้านขวา

ใช้ component id:

StoryFeatureMosaic

ตัวอย่าง payload:

{
  "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 ที่รองรับตอนนี้:

leaf, cake, fresh, heart, care, sparkles, premium, check

Common Mistakes

  1. componentId ไม่ตรงกับ registry key

Admin ใส่:

{ "componentId": "BakeryCategory" }

แต่ registry มี:

BakeryCategories

ผลคือ block ไม่แสดง

  1. ใส่ข้อความเป็นภาษาเดียว
{ "title": "Bakery Categories" }

แบบนี้ยัง render ได้ แต่ไม่เหมาะกับระบบหลายภาษา ควรใช้:

{
  "title": {
    "th": "หมวดหมู่เบเกอรี่",
    "en": "Bakery Categories",
    "ja": "ベーカリーカテゴリー",
    "zh": "烘焙类别",
    "vi": "Danh mục bánh nướng",
    "ko": "베이커리 카테고리"
  }
}
  1. ใส่ items เป็น object แทน array

ผิด:

{
  "items": {
    "title": { "th": "ขนมปัง" }
  }
}

ถูก:

{
  "items": [
    {
      "title": { "th": "ขนมปัง" },
      "href": "/shop"
    }
  ]
}
  1. ลืมเพิ่มทั้ง website และ SPA

โปรเจกต์นี้มีสอง frontend:

  • apps/website
  • apps/spa

ถ้าเพิ่มแค่ฝั่งเดียว อีกฝั่งจะไม่ render block นั้น

Summary Flow

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