This commit is contained in:
Your Name committed 2026-07-12 19:10:19 -04:00
1 parent 305b593698
commit 2a9dd8ddec
13 files changed
+1231 -4

No files matched your search

+225
View File
@@ -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 <Component key={section.id} payload={section.payload} />
})
```
**ผลลัพธ์:** เพิ่ม 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 <div>...JSX ได้เลย ไม่มีข้อจำกัด...</div>
}
// 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 แทน
+515
View File
@@ -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
```
@@ -0,0 +1,100 @@
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { type PageSection } from '@homaroma/types';
interface CustomBlockFormProps {
editingSection: Partial<PageSection>;
setEditingSection: Dispatch<SetStateAction<Partial<PageSection> | 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<HTMLTextAreaElement | null>(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 (
<div className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Component ID</label>
<input
type="text"
required
value={editingSection.payload?.componentId || ''}
onChange={(e) => updateComponentId(e.target.value)}
className="admin-input w-full"
placeholder="BakeryCategories"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Props JSON</label>
<textarea
key={propsFieldKey}
ref={propsRef}
rows={12}
required
defaultValue={formatProps(editingSection.payload?.props)}
onChange={(e) => updateProps(e.target.value)}
className="admin-input w-full resize-y font-mono text-sm"
placeholder={'{\n "title": { "th": "หมวดหมู่เบเกอรี่", "en": "Bakery Categories" },\n "subtitle": { "th": "เลือกซื้อขนมอบตามหมวดหมู่ที่คุณชอบ", "en": "Explore fresh-baked favorites by collection." },\n "layout": "horizontal",\n "items": []\n}'}
/>
{jsonError && (
<p className="text-xs font-semibold text-red-300">{jsonError}</p>
)}
</div>
</div>
);
}
+59
View File
@@ -38,6 +38,7 @@ import ContactInfoForm from '../components/page-builder/ContactInfoForm';
import ContactFormForm from '../components/page-builder/ContactFormForm'; import ContactFormForm from '../components/page-builder/ContactFormForm';
import HeroV2Form from '../components/page-builder/HeroV2Form'; import HeroV2Form from '../components/page-builder/HeroV2Form';
import UspBarForm from '../components/page-builder/UspBarForm'; import UspBarForm from '../components/page-builder/UspBarForm';
import CustomBlockForm from '../components/page-builder/CustomBlockForm';
interface Brand { interface Brand {
id: number; id: number;
@@ -678,6 +679,56 @@ export default function PageBuilderPage() {
subtitle: 'We would love to hear from you.' subtitle: 'We would love to hear from you.'
}; };
break; 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 => { setEditingSection(prev => {
@@ -990,6 +1041,7 @@ export default function PageBuilderPage() {
<option value="CONTACT_FORM">CONTACT FORM (Message inputs)</option> <option value="CONTACT_FORM">CONTACT FORM (Message inputs)</option>
<option value="HERO_V2">HERO V2 (Split Layout Hero)</option> <option value="HERO_V2">HERO V2 (Split Layout Hero)</option>
<option value="USP_BAR">USP BAR (Features / Icons)</option> <option value="USP_BAR">USP BAR (Features / Icons)</option>
<option value="CUSTOM">CUSTOM (Registered component)</option>
</select> </select>
</div> </div>
)} )}
@@ -1205,6 +1257,13 @@ export default function PageBuilderPage() {
/> />
)} )}
{editingSection.type?.toUpperCase() === 'CUSTOM' && (
<CustomBlockForm
editingSection={editingSection}
setEditingSection={setEditingSection}
/>
)}
</div> </div>
<div className="p-6 bg-night-950/50 border-t border-white/5 flex flex-col sm:flex-row gap-4 shrink-0"> <div className="p-6 bg-night-950/50 border-t border-white/5 flex flex-col sm:flex-row gap-4 shrink-0">
<button <button
+5
View File
@@ -16,10 +16,12 @@ import {
ScentNotesBreakdownSectionPayloadSchema, ScentNotesBreakdownSectionPayloadSchema,
QuoteCalloutSectionPayloadSchema, QuoteCalloutSectionPayloadSchema,
ProductCarouselSectionPayloadSchema, ProductCarouselSectionPayloadSchema,
AccordionSectionPayloadSchema,
ContactInfoSectionPayloadSchema, ContactInfoSectionPayloadSchema,
ContactFormSectionPayloadSchema, ContactFormSectionPayloadSchema,
HeroV2SectionPayloadSchema, HeroV2SectionPayloadSchema,
UspBarSectionPayloadSchema, UspBarSectionPayloadSchema,
CustomSectionPayloadSchema,
} from "@homaroma/types"; } from "@homaroma/types";
import { z } from "zod"; import { z } from "zod";
import { adminOnly } from "../middleware/auth"; import { adminOnly } from "../middleware/auth";
@@ -607,6 +609,9 @@ function validateSectionPayload(
case "USP_BAR": case "USP_BAR":
schema = UspBarSectionPayloadSchema; schema = UspBarSectionPayloadSchema;
break; break;
case "CUSTOM":
schema = CustomSectionPayloadSchema;
break;
default: default:
return { return {
success: false, success: false,
+5 -1
View File
@@ -90,6 +90,10 @@ const ADD_PAGE_SECTION_GUIDANCE: Record<string, { required: string[]; example: s
CONTACT_FORM: { CONTACT_FORM: {
required: [], required: [],
example: `{ "title": ${localizedStringExample}, "subtitle": ${localizedStringExample} }` example: `{ "title": ${localizedStringExample}, "subtitle": ${localizedStringExample} }`
},
CUSTOM: {
required: ["componentId"],
example: `{ "componentId": "BakeryCategories", "props": { "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "layout": "horizontal", "items": [{ "title": ${localizedStringExample}, "description": ${localizedStringExample}, "href": "/shop" }] } }`
} }
}; };
@@ -383,7 +387,7 @@ export const SECTION_TOOL_DEFINITIONS = [
type: "object", type: "object",
properties: { properties: {
pageId: { type: "number", description: "The numeric ID of the page" }, pageId: { type: "number", description: "The numeric ID of the page" },
type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Block type (e.g. HERO, ANNOUNCEMENT_BAR, NEWSLETTER, EDITORIAL_TILE, EDITORIAL_CAROUSEL, PRODUCTS, CATEGORIES, TEXT, BANNER, HERO_ARTICLE, RICH_TEXT, SCENT_NOTES_BREAKDOWN, QUOTE_CALLOUT, PRODUCT_CAROUSEL)" }, type: { type: "string", enum: [...PAGE_SECTION_TYPES], description: "Block type (e.g. HERO, ANNOUNCEMENT_BAR, NEWSLETTER, EDITORIAL_TILE, EDITORIAL_CAROUSEL, PRODUCTS, CATEGORIES, TEXT, BANNER, HERO_ARTICLE, RICH_TEXT, SCENT_NOTES_BREAKDOWN, QUOTE_CALLOUT, PRODUCT_CAROUSEL, CUSTOM)" },
order: { type: "number", description: "Display order sequence (starts at 1)" }, order: { type: "number", description: "Display order sequence (starts at 1)" },
payload: { type: "object", description: "The JSON payload matching the exact schema for the chosen type. See the tool description for required fields and minimal valid examples per type." }, payload: { type: "object", description: "The JSON payload matching the exact schema for the chosen type. See the tool description for required fields and minimal valid examples per type." },
isActive: { type: "boolean", description: "Initial active state" } isActive: { type: "boolean", description: "Initial active state" }
@@ -1,4 +1,4 @@
import type { PageSection, Category, AccordionSectionPayload, ContactInfoSectionPayload, ContactFormSectionPayload } from '@homaroma/types'; import type { PageSection, Category, AccordionSectionPayload, ContactInfoSectionPayload, ContactFormSectionPayload, CustomSectionPayload } from '@homaroma/types';
import DynamicHero from './DynamicHero'; import DynamicHero from './DynamicHero';
import DynamicHeroV2 from './DynamicHeroV2'; import DynamicHeroV2 from './DynamicHeroV2';
import DynamicUspBar from './DynamicUspBar'; import DynamicUspBar from './DynamicUspBar';
@@ -19,6 +19,7 @@ import AccordionSection from './sections/AccordionSection';
import ContactInfoSection from './sections/ContactInfoSection'; import ContactInfoSection from './sections/ContactInfoSection';
import ContactFormSection from './sections/ContactFormSection'; import ContactFormSection from './sections/ContactFormSection';
import ScrollReveal from './ScrollReveal'; import ScrollReveal from './ScrollReveal';
import { customRegistry } from '../config/customRegistry';
interface DynamicPageRendererProps { interface DynamicPageRendererProps {
sections: PageSection[]; sections: PageSection[];
@@ -93,6 +94,21 @@ export default function DynamicPageRenderer({
return <ScrollReveal key={section.id}><ContactInfoSection payload={section.payload as ContactInfoSectionPayload & { title?: string }} /></ScrollReveal>; return <ScrollReveal key={section.id}><ContactInfoSection payload={section.payload as ContactInfoSectionPayload & { title?: string }} /></ScrollReveal>;
case 'CONTACT_FORM': case 'CONTACT_FORM':
return <ScrollReveal key={section.id}><ContactFormSection payload={section.payload as ContactFormSectionPayload} /></ScrollReveal>; return <ScrollReveal key={section.id}><ContactFormSection payload={section.payload as ContactFormSectionPayload} /></ScrollReveal>;
case 'CUSTOM': {
const payload = section.payload as CustomSectionPayload;
const CustomComponent = customRegistry[payload.componentId];
if (!CustomComponent) {
console.warn(`[DynamicPageRenderer] Unknown custom component: ${payload.componentId}`);
return null;
}
return (
<ScrollReveal key={section.id}>
<CustomComponent props={payload.props ?? {}} />
</ScrollReveal>
);
}
default: default:
console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`); console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`);
return null; return null;
@@ -0,0 +1,127 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import type { CustomBlockComponentProps } from '../../config/customRegistry';
import { getLocalizedField } from '../../utils/localization';
type LocalizedValue = string | Record<string, string>;
type BakeryCategory = {
title: LocalizedValue;
description?: LocalizedValue;
href: string;
imageUrl?: string;
};
type ResolvedBakeryCategory = {
title: string;
description: string;
href: string;
imageUrl?: string;
};
const defaultItems: BakeryCategory[] = [
{
title: 'Breads',
description: 'Daily loaves, sourdough, and soft sandwich breads.',
href: '/shop',
},
{
title: 'Pastries',
description: 'Layered butter pastries for breakfast and gifting.',
href: '/shop',
},
{
title: 'Cakes',
description: 'Celebration cakes and slices for quiet afternoons.',
href: '/shop',
},
{
title: 'Cookies',
description: 'Small-batch cookies with crisp edges and soft centers.',
href: '/shop',
},
];
function readString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value : fallback;
}
function readLocalized(value: unknown, lang: string, fallback: string) {
const text = getLocalizedField(value, lang);
return text.trim() ? text : fallback;
}
function readItems(value: unknown, lang: string): ResolvedBakeryCategory[] {
const sourceItems = Array.isArray(value) ? value : defaultItems;
const items = sourceItems.reduce<ResolvedBakeryCategory[]>((acc, item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return acc;
const record = item as Record<string, unknown>;
const title = readLocalized(record.title, lang, '');
if (!title) return acc;
acc.push({
title,
description: readLocalized(record.description, lang, ''),
href: readString(record.href, '/shop'),
imageUrl: typeof record.imageUrl === 'string' ? record.imageUrl : undefined,
});
return acc;
}, []);
return items.length > 0 ? items : readItems(defaultItems, lang);
}
export default function BakeryCategories({ props }: CustomBlockComponentProps) {
const { i18n } = useTranslation();
const lang = i18n.language;
const title = readLocalized(props?.title, lang, 'Bakery Categories');
const subtitle = readLocalized(props?.subtitle, lang, 'Explore fresh-baked favorites by collection.');
const layout = readString(props?.layout, 'grid');
const items = readItems(props?.items, lang);
const gridClass = layout === 'horizontal'
? 'grid-cols-1 md:grid-cols-4'
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4';
return (
<section className="ui-section-band bg-pop-bg">
<div className="ui-section-inner space-y-12">
<div className="mx-auto max-w-2xl space-y-4 text-center">
<h2 className="text-4xl font-bold tracking-tight text-pop-black md:text-5xl">
{title}
</h2>
<p className="ui-section-subtitle">{subtitle}</p>
</div>
<div className={`grid gap-5 ${gridClass}`}>
{items.map((item) => (
<Link
key={item.title}
to={item.href}
className="ui-shell-card ui-shell-card-hover group overflow-hidden"
>
<div className="relative flex aspect-[4/3] items-center justify-center overflow-hidden bg-pop-black/5">
{item.imageUrl ? (
<img
src={item.imageUrl}
alt={item.title}
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
/>
) : (
<span className="font-serif text-6xl font-semibold text-pop-black/20">
{item.title.slice(0, 1)}
</span>
)}
</div>
<div className="space-y-3 p-6">
<h3 className="text-xl font-semibold text-pop-black">{item.title}</h3>
<p className="text-sm leading-relaxed text-pop-black/70">{item.description}</p>
</div>
</Link>
))}
</div>
</div>
</section>
);
}
+13
View File
@@ -0,0 +1,13 @@
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,
};
@@ -0,0 +1,125 @@
import Image from 'next/image'
import Link from '../LocalizedLink'
import type { CustomBlockComponentProps } from '../../config/customRegistry'
import { resolveLocalized } from '../../lib/localization'
type LocalizedValue = string | Record<string, string>
type BakeryCategory = {
title: LocalizedValue
description?: LocalizedValue
href: string
imageUrl?: string
}
type ResolvedBakeryCategory = {
title: string
description: string
href: string
imageUrl?: string
}
const defaultItems: BakeryCategory[] = [
{
title: 'Breads',
description: 'Daily loaves, sourdough, and soft sandwich breads.',
href: '/shop',
},
{
title: 'Pastries',
description: 'Layered butter pastries for breakfast and gifting.',
href: '/shop',
},
{
title: 'Cakes',
description: 'Celebration cakes and slices for quiet afternoons.',
href: '/shop',
},
{
title: 'Cookies',
description: 'Small-batch cookies with crisp edges and soft centers.',
href: '/shop',
},
]
function readString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value : fallback
}
function readLocalized(value: unknown, lang: string, fallback: string) {
const text = resolveLocalized(value as LocalizedValue | null | undefined, lang)
return text.trim() ? text : fallback
}
function readItems(value: unknown, lang: string): ResolvedBakeryCategory[] {
const sourceItems = Array.isArray(value) ? value : defaultItems
const items = sourceItems.reduce<ResolvedBakeryCategory[]>((acc, item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return acc
const record = item as Record<string, unknown>
const title = readLocalized(record.title, lang, '')
if (!title) return acc
acc.push({
title,
description: readLocalized(record.description, lang, ''),
href: readString(record.href, '/shop'),
imageUrl: typeof record.imageUrl === 'string' ? record.imageUrl : undefined,
})
return acc
}, [])
return items.length > 0 ? items : readItems(defaultItems, lang)
}
export default function BakeryCategories({ props, lang }: CustomBlockComponentProps) {
const title = readLocalized(props?.title, lang, 'Bakery Categories')
const subtitle = readLocalized(props?.subtitle, lang, 'Explore fresh-baked favorites by collection.')
const layout = readString(props?.layout, 'grid')
const items = readItems(props?.items, lang)
const gridClass = layout === 'horizontal'
? 'grid-cols-1 md:grid-cols-4'
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4'
return (
<section className="ui-section-band bg-surface-page">
<div className="ui-section-shell space-y-12">
<div className="mx-auto max-w-2xl space-y-4 text-center">
<h2 className="ui-section-title">{title}</h2>
<p className="ui-section-subtitle">{subtitle}</p>
</div>
<div className={`grid gap-5 ${gridClass}`}>
{items.map((item) => (
<Link
key={item.title}
href={item.href}
className="group overflow-hidden rounded-2xl border border-line-primary/20 bg-surface-card shadow-card transition hover:-translate-y-1 hover:shadow-card-hover"
>
<div className="relative flex aspect-[4/3] items-center justify-center overflow-hidden bg-surface-muted">
{item.imageUrl ? (
<Image
src={item.imageUrl}
alt={item.title}
fill
unoptimized
className="object-cover transition duration-700 group-hover:scale-105"
/>
) : (
<span className="font-serif text-6xl font-semibold text-text-main/20">
{item.title.slice(0, 1)}
</span>
)}
</div>
<div className="space-y-3 p-6">
<h3 className="text-xl font-semibold text-text-main">{item.title}</h3>
<p className="text-sm leading-relaxed text-muted-text">{item.description}</p>
</div>
</Link>
))}
</div>
</div>
</section>
)
}
@@ -10,6 +10,7 @@ import ProductCard from '../ProductCard'
import { localizedFromRecord, resolveCategoryName, resolveLocalized, toRecord } from '../../lib/localization' import { localizedFromRecord, resolveCategoryName, resolveLocalized, toRecord } from '../../lib/localization'
import { useLang } from '../../hooks/useLang' import { useLang } from '../../hooks/useLang'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { customRegistry } from '../../config/customRegistry'
import { Swiper, SwiperSlide } from 'swiper/react' import { Swiper, SwiperSlide } from 'swiper/react'
import { Autoplay, Pagination, Navigation } from 'swiper/modules' import { Autoplay, Pagination, Navigation } from 'swiper/modules'
import 'swiper/css' import 'swiper/css'
@@ -1127,6 +1128,24 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
<ContactFormSection payload={payload} lang={lang} /> <ContactFormSection payload={payload} lang={lang} />
</ScrollReveal> </ScrollReveal>
) )
case 'CUSTOM': {
const componentId = typeof payload.componentId === 'string' ? payload.componentId : ''
const props = toRecord(payload.props)
const CustomComponent = customRegistry[componentId]
if (!CustomComponent) {
if (process.env.NODE_ENV !== 'production') {
console.warn(`[DynamicPageRenderer] Unknown custom component: ${componentId}`)
}
return null
}
return (
<ScrollReveal key={section.id}>
<CustomComponent props={props} lang={lang} />
</ScrollReveal>
)
}
default: default:
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
console.warn(`[DynamicPageRenderer] Unsupported section type: ${section.type}`) console.warn(`[DynamicPageRenderer] Unsupported section type: ${section.type}`)
@@ -1137,4 +1156,3 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
</div> </div>
) )
} }
+14
View File
@@ -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<CustomBlockComponentProps>
export const customRegistry: Record<string, CustomBlockComponent> = {
BakeryCategories,
}
+7 -1
View File
@@ -586,11 +586,17 @@ export const UspBarSectionPayloadSchema = z.object({
}); });
export type UspBarSectionPayload = z.infer<typeof UspBarSectionPayloadSchema>; export type UspBarSectionPayload = z.infer<typeof UspBarSectionPayloadSchema>;
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>;
export const PAGE_SECTION_TYPES = [ export const PAGE_SECTION_TYPES = [
'HERO', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE', 'HERO', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE',
'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER', 'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER',
'HERO_ARTICLE', 'RICH_TEXT', 'SCENT_NOTES_BREAKDOWN', 'QUOTE_CALLOUT', 'HERO_ARTICLE', 'RICH_TEXT', 'SCENT_NOTES_BREAKDOWN', 'QUOTE_CALLOUT',
'PRODUCT_CAROUSEL', 'ACCORDION', 'CONTACT_INFO', 'CONTACT_FORM', 'PRODUCT_CAROUSEL', 'ACCORDION', 'CONTACT_INFO', 'CONTACT_FORM',
'HERO_V2', 'USP_BAR' 'HERO_V2', 'USP_BAR', 'CUSTOM'
] as const; ] as const;
export type SectionType = typeof PAGE_SECTION_TYPES[number]; export type SectionType = typeof PAGE_SECTION_TYPES[number];