diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
new file mode 100644
index 0000000..9a523be
--- /dev/null
+++ b/ARCHITECTURE_DECISIONS.md
@@ -0,0 +1,225 @@
+# Architecture Decisions — HomAroma Platform
+
+> บันทึกการตัดสินใจเชิงสถาปัตยกรรม (ADR) จากการประชุมทีม
+> อัปเดตล่าสุด: 2026-07-12
+
+---
+
+## 1. Page Builder CMS — Section Types ที่มีอยู่
+
+ระบบมี Block/Section ทั้งหมด **19 แบบ** กำหนดใน `packages/types/index.ts` → `PAGE_SECTION_TYPES`
+
+### กลุ่ม Headers & Banners
+| Type | คำอธิบาย |
+|---|---|
+| `HERO` | Hero หลัก (image, text, 1 button) |
+| `HERO_V2` | Hero อัปเกรด (2 buttons, alignment, ornament) |
+| `BANNER` | แบนเนอร์รูปภาพ + link |
+| `ANNOUNCEMENT_BAR` | แถบประกาศด้านบน (auto-slide, ปรับสีได้) |
+
+### กลุ่ม Products & Categories
+| Type | คำอธิบาย |
+|---|---|
+| `PRODUCTS` | แสดงสินค้า (by brand/category/custom, grid/carousel) |
+| `PRODUCT_CAROUSEL` | สไลด์สินค้าแนวนอน |
+| `CATEGORIES` | แสดงหมวดหมู่สินค้า |
+
+### กลุ่ม Content & Editorials
+| Type | คำอธิบาย |
+|---|---|
+| `TEXT` | ข้อความทั่วไป (จัดตำแหน่งได้) |
+| `RICH_TEXT` | เนื้อหาจัดรูปแบบได้ |
+| `EDITORIAL_TILE` | รูปภาพ + เนื้อหา (สลับซ้าย-ขวาได้) |
+| `EDITORIAL_CAROUSEL` | สไลด์บทความ (standard / split screen) |
+| `HERO_ARTICLE` | หัวบทความ (ผู้เขียน, วันที่, หมวดหมู่) |
+| `QUOTE_CALLOUT` | โควทคำพูด / คำโปรย |
+
+### กลุ่ม Brand Features
+| Type | คำอธิบาย |
+|---|---|
+| `SCENT_NOTES_BREAKDOWN` | แสดง Top/Heart/Base Notes ของกลิ่น |
+| `USP_BAR` | จุดเด่นของร้าน (max 4 items) |
+| `ACCORDION` | Q&A / FAQ แบบย่อ-ขยาย |
+
+### กลุ่ม Forms & Contacts
+| Type | คำอธิบาย |
+|---|---|
+| `NEWSLETTER` | ฟอร์มรับอีเมล + privacy checkbox |
+| `CONTACT_INFO` | ที่อยู่, อีเมล, LINE, เวลาทำการ |
+| `CONTACT_FORM` | ฟอร์มส่งข้อความ |
+
+---
+
+## 2. Multi-Client White-Label Strategy
+
+### หลักการสำคัญ
+
+> **CMS เก็บข้อมูล (Data + Semantics) — Renderer คือสิ่งที่เปลี่ยนตาม Client**
+
+ความแตกต่างระหว่าง Client มี 2 ระดับที่แก้ต่างกัน:
+
+| ระดับ | ตัวอย่าง | แก้ด้วย |
+|---|---|---|
+| **Visual Style** | สี, font, spacing ต่างกัน | Design Tokens / CSS Variables |
+| **Layout Structure** | Card ตั้ง vs Card นอน (HTML ต่างกัน) | Component Variants ใน Renderer |
+
+> Theme Token ช่วยไม่ได้กับ Layout Structure เพราะ HTML shape ต่างกันทั้งหมด
+
+### Git Repository Strategy
+
+```
+[product-template] ← Source of Truth เสมอ
+ │
+ ├── fork → [homaroma-client]
+ ├── fork → [client-a-repo]
+ │ └── good feature → cherry-pick กลับ product-template
+ └── fork → [client-b-repo] ← clone จาก product-template (ที่ updated แล้ว)
+```
+
+**กฎ:** Client B ไม่ได้ start จาก Client A แต่ start จาก **product-template ที่รับ feature ดีๆ กลับมาแล้ว**
+
+### Commit Discipline (สำคัญมาก)
+
+```bash
+# ✅ แยก commit ให้ชัดเสมอ
+git commit -m "feat(categories): add horizontal-card layout variant" # generic → กลับ master ได้
+git commit -m "feat(client-a): set default theme to warm-beige" # client-specific
+git commit -m "fix(checkout): correct discount calculation" # generic → กลับ master ได้
+
+# ❌ อย่าทำแบบนี้
+git commit -m "client A changes" # cherry-pick ยากมาก
+```
+
+### Decision: Feature กลับ master ได้หรือไม่?
+
+| ลักษณะ Feature | กลับ master? |
+|---|---|
+| Generic component / layout variant ใหม่ | ✅ ควร |
+| Bug fix | ✅ เสมอ |
+| Infrastructure / DevOps | ✅ เสมอ |
+| Branding เฉพาะ client (สี, logo) | ❌ |
+| Business logic เฉพาะ client | ❌ |
+| Config ที่ structure generic แต่ค่าต่างกัน | ✅ structure กลับ, ค่าอยู่ที่ client |
+
+---
+
+## 3. componentRegistry Pattern
+
+### เป้าหมาย
+ให้ **Backend + Schema กลางไม่ต้องเปลี่ยน** เมื่อ Client ต้องการ Layout ที่ต่างออกไป
+
+### โครงสร้างที่ควรมี
+
+```
+apps/spa/src/
+ config/
+ componentRegistry.ts ← Client-specific (ไม่ขึ้น master)
+ themeConfig.ts ← Client-specific
+ components/
+ dynamic/
+ categories/
+ VerticalCard.tsx ← Generic variant (ขึ้น master)
+ HorizontalCard.tsx ← Generic variant (ขึ้น master)
+```
+
+### การทำงาน
+
+```ts
+// apps/spa/src/config/componentRegistry.ts
+export const componentRegistry = {
+ CATEGORIES: DynamicCategoriesVertical, // HomAroma เลือก vertical
+ HERO: DynamicHeroV2,
+}
+
+// DynamicPageRenderer.tsx — generic, ไม่มี hardcode switch-case
+sections.map(section => {
+ const Component = componentRegistry[section.type]
+ return
+})
+```
+
+**ผลลัพธ์:** เพิ่ม Client ใหม่ = สร้าง `componentRegistry.ts` ใหม่ + component ที่ต้องการ ไม่แตะ Backend เลย
+
+---
+
+## 4. CUSTOM Block — Developer Escape Hatch
+
+### เหตุผลที่ต้องมี
+
+ทุกครั้งที่ Dev เพิ่ม Section ใหม่แบบเดิม ต้องแก้ **4 จุดพร้อมกัน:**
+
+1. `packages/types/index.ts` → Schema + Type
+2. `apps/backend/routes/pages.ts` → validation
+3. `apps/spa/DynamicPageRenderer.tsx` → render
+4. `apps/admin/PageBuilderPage.tsx` → form
+
+ถ้าเป็น client-specific section → ต้องแก้ Schema กลาง (ผิดหลักการ)
+
+### แนวทาง: CUSTOM Block Type
+
+```json
+{
+ "type": "CUSTOM",
+ "payload": {
+ "componentId": "BakeryCategories",
+ "props": {
+ "title": "หมวดหมู่ของเรา",
+ "layout": "horizontal"
+ }
+ }
+}
+```
+
+### Dev Workflow ใหม่ (2 ขั้นตอน ทำแค่ Frontend)
+
+```ts
+// Step 1: สร้าง Component ใดๆ ก็ได้
+// apps/spa/src/components/custom/BakeryCategories.tsx
+export default function BakeryCategories({ title, layout }) {
+ return
...JSX ได้เลย ไม่มีข้อจำกัด...
+}
+
+// Step 2: Register ใน customRegistry
+// apps/spa/src/config/componentRegistry.ts
+export const customRegistry = {
+ 'BakeryCategories': BakeryCategories,
+}
+```
+
+**Backend ไม่ต้องแก้เลย**
+
+### กฎการตัดสินใจ
+
+```
+ถาม: "Section นี้จะถูกใช้ใน project อื่นด้วยไหม?"
+
+ใช่ → สร้าง Block Type ใหม่ตาม flow เดิม (เพิ่มเข้า master)
+ไม่ → ใช้ CUSTOM block + register ใน client customRegistry เท่านั้น
+```
+
+---
+
+## 5. Backlog — สิ่งที่ต้อง Implement
+
+### ต้องทำ (ครั้งเดียว — foundation)
+- [ ] เพิ่ม `'CUSTOM'` เข้า `PAGE_SECTION_TYPES` ใน `packages/types/index.ts`
+- [ ] สร้าง `apps/spa/src/config/componentRegistry.ts` แยกออกมาจาก DynamicPageRenderer
+- [ ] เพิ่ม `case 'CUSTOM'` ใน `apps/spa/src/components/DynamicPageRenderer.tsx`
+- [ ] (Optional) เพิ่มใน `apps/website` ด้วย
+
+### ควรทำ (เพิ่ม variants)
+- [ ] สร้าง `HorizontalCard.tsx` variant ใน `components/dynamic/categories/`
+- [ ] สร้าง `VerticalCard.tsx` (refactor จากของเดิม)
+
+### Long-term
+- [ ] `packages/ui/` — generic component library สำหรับ white-label
+
+---
+
+## 6. Anti-patterns — สิ่งที่ไม่ควรทำ
+
+- ❌ **อย่าเพิ่ม UI layout ลงใน Schema กลาง** เช่น `layout: 'vertical' | 'horizontal'` ใน `CategoriesSectionPayloadSchema` — Schema จะพองและผูก UI กับ Data
+- ❌ **อย่าให้ Admin ใส่ Raw HTML ตรงๆ** — XSS risk และ bypass design system
+- ❌ **อย่า start Client B จาก Client A repo** — ใช้ product-template เสมอ
+- ❌ **อย่า commit รวมกัน** เช่น "client A changes" — cherry-pick ยากมาก
+- ❌ **อย่า hardcode switch-case ใน DynamicPageRenderer** — ใช้ componentRegistry แทน
diff --git a/CUSTOM_BLOCK_FLOW_GUIDE.md b/CUSTOM_BLOCK_FLOW_GUIDE.md
new file mode 100644
index 0000000..db4e639
--- /dev/null
+++ b/CUSTOM_BLOCK_FLOW_GUIDE.md
@@ -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;
+```
+
+และเพิ่ม `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
+```
+
diff --git a/apps/admin/src/components/page-builder/CustomBlockForm.tsx b/apps/admin/src/components/page-builder/CustomBlockForm.tsx
new file mode 100644
index 0000000..c26b726
--- /dev/null
+++ b/apps/admin/src/components/page-builder/CustomBlockForm.tsx
@@ -0,0 +1,100 @@
+import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
+import { type PageSection } from '@homaroma/types';
+
+interface CustomBlockFormProps {
+ editingSection: Partial;
+ setEditingSection: Dispatch | null>>;
+}
+
+function formatProps(props: unknown) {
+ if (!props || typeof props !== 'object' || Array.isArray(props)) {
+ return '{}';
+ }
+
+ return JSON.stringify(props, null, 2);
+}
+
+export default function CustomBlockForm({
+ editingSection,
+ setEditingSection,
+}: CustomBlockFormProps) {
+ const propsRef = useRef(null);
+ const [jsonError, setJsonError] = useState('');
+ const propsFieldKey = `${editingSection.id ?? 'new'}:${editingSection.payload?.componentId ?? ''}`;
+
+ useEffect(() => {
+ propsRef.current?.setCustomValidity(jsonError);
+ }, [jsonError]);
+
+ const updateComponentId = (componentId: string) => {
+ setEditingSection((prev) => {
+ if (!prev) return null;
+
+ return {
+ ...prev,
+ payload: {
+ ...(prev.payload || {}),
+ componentId,
+ },
+ };
+ });
+ };
+
+ const updateProps = (value: string) => {
+ try {
+ const nextProps = JSON.parse(value);
+ if (!nextProps || typeof nextProps !== 'object' || Array.isArray(nextProps)) {
+ setJsonError('Props must be a JSON object.');
+ return;
+ }
+
+ setJsonError('');
+ setEditingSection((prev) => {
+ if (!prev) return null;
+
+ return {
+ ...prev,
+ payload: {
+ ...(prev.payload || {}),
+ props: nextProps,
+ },
+ };
+ });
+ } catch {
+ setJsonError('Props must be valid JSON.');
+ }
+ };
+
+ return (
+
+
+
+ updateComponentId(e.target.value)}
+ className="admin-input w-full"
+ placeholder="BakeryCategories"
+ />
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/pages/PageBuilderPage.tsx b/apps/admin/src/pages/PageBuilderPage.tsx
index f18c37f..5990464 100644
--- a/apps/admin/src/pages/PageBuilderPage.tsx
+++ b/apps/admin/src/pages/PageBuilderPage.tsx
@@ -38,6 +38,7 @@ import ContactInfoForm from '../components/page-builder/ContactInfoForm';
import ContactFormForm from '../components/page-builder/ContactFormForm';
import HeroV2Form from '../components/page-builder/HeroV2Form';
import UspBarForm from '../components/page-builder/UspBarForm';
+import CustomBlockForm from '../components/page-builder/CustomBlockForm';
interface Brand {
id: number;
@@ -678,6 +679,56 @@ export default function PageBuilderPage() {
subtitle: 'We would love to hear from you.'
};
break;
+ case 'CUSTOM':
+ defaultPayload = {
+ componentId: 'BakeryCategories',
+ props: {
+ title: {
+ th: 'หมวดหมู่เบเกอรี่',
+ en: 'Bakery Categories'
+ },
+ subtitle: {
+ th: 'เลือกซื้อขนมอบตามหมวดหมู่ที่คุณชอบ',
+ en: 'Explore fresh-baked favorites by collection.'
+ },
+ layout: 'horizontal',
+ items: [
+ {
+ title: { th: 'ขนมปัง', en: 'Breads' },
+ description: {
+ th: 'ขนมปังอบสดใหม่ทุกวัน',
+ en: 'Daily loaves, sourdough, and soft sandwich breads.'
+ },
+ href: '/shop'
+ },
+ {
+ title: { th: 'พาสทรี', en: 'Pastries' },
+ description: {
+ th: 'พาสทรีเนยหอมกรอบสำหรับมื้อเช้าและของฝาก',
+ en: 'Layered butter pastries for breakfast and gifting.'
+ },
+ href: '/shop'
+ },
+ {
+ title: { th: 'เค้ก', en: 'Cakes' },
+ description: {
+ th: 'เค้กสำหรับวันพิเศษและช่วงเวลาพักผ่อน',
+ en: 'Celebration cakes and slices for quiet afternoons.'
+ },
+ href: '/shop'
+ },
+ {
+ title: { th: 'คุกกี้', en: 'Cookies' },
+ description: {
+ th: 'คุกกี้อบใหม่ กรอบนอกนุ่มใน',
+ en: 'Small-batch cookies with crisp edges and soft centers.'
+ },
+ href: '/shop'
+ }
+ ]
+ }
+ };
+ break;
}
setEditingSection(prev => {
@@ -990,6 +1041,7 @@ export default function PageBuilderPage() {
+
)}
@@ -1205,6 +1257,13 @@ export default function PageBuilderPage() {
/>
)}
+ {editingSection.type?.toUpperCase() === 'CUSTOM' && (
+
+ )}
+
)
}
-
diff --git a/apps/website/src/config/customRegistry.ts b/apps/website/src/config/customRegistry.ts
new file mode 100644
index 0000000..b652880
--- /dev/null
+++ b/apps/website/src/config/customRegistry.ts
@@ -0,0 +1,14 @@
+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,
+}
diff --git a/packages/types/index.ts b/packages/types/index.ts
index 518a416..50e9fe2 100644
--- a/packages/types/index.ts
+++ b/packages/types/index.ts
@@ -586,11 +586,17 @@ export const UspBarSectionPayloadSchema = z.object({
});
export type UspBarSectionPayload = z.infer;
+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;
+
export const PAGE_SECTION_TYPES = [
'HERO', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE',
'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER',
'HERO_ARTICLE', 'RICH_TEXT', 'SCENT_NOTES_BREAKDOWN', 'QUOTE_CALLOUT',
'PRODUCT_CAROUSEL', 'ACCORDION', 'CONTACT_INFO', 'CONTACT_FORM',
- 'HERO_V2', 'USP_BAR'
+ 'HERO_V2', 'USP_BAR', 'CUSTOM'
] as const;
export type SectionType = typeof PAGE_SECTION_TYPES[number];