x
This commit is contained in:
1 parent
305b593698
commit
2a9dd8ddec
13 files changed
+1231
-4
No files matched your search
@@ -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 แทน
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
Reference in new issue
Block a user