diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md new file mode 100644 index 0000000..9a523be --- /dev/null +++ b/ARCHITECTURE_DECISIONS.md @@ -0,0 +1,225 @@ +# Architecture Decisions — HomAroma Platform + +> บันทึกการตัดสินใจเชิงสถาปัตยกรรม (ADR) จากการประชุมทีม +> อัปเดตล่าสุด: 2026-07-12 + +--- + +## 1. Page Builder CMS — Section Types ที่มีอยู่ + +ระบบมี Block/Section ทั้งหมด **19 แบบ** กำหนดใน `packages/types/index.ts` → `PAGE_SECTION_TYPES` + +### กลุ่ม Headers & Banners +| Type | คำอธิบาย | +|---|---| +| `HERO` | Hero หลัก (image, text, 1 button) | +| `HERO_V2` | Hero อัปเกรด (2 buttons, alignment, ornament) | +| `BANNER` | แบนเนอร์รูปภาพ + link | +| `ANNOUNCEMENT_BAR` | แถบประกาศด้านบน (auto-slide, ปรับสีได้) | + +### กลุ่ม Products & Categories +| Type | คำอธิบาย | +|---|---| +| `PRODUCTS` | แสดงสินค้า (by brand/category/custom, grid/carousel) | +| `PRODUCT_CAROUSEL` | สไลด์สินค้าแนวนอน | +| `CATEGORIES` | แสดงหมวดหมู่สินค้า | + +### กลุ่ม Content & Editorials +| Type | คำอธิบาย | +|---|---| +| `TEXT` | ข้อความทั่วไป (จัดตำแหน่งได้) | +| `RICH_TEXT` | เนื้อหาจัดรูปแบบได้ | +| `EDITORIAL_TILE` | รูปภาพ + เนื้อหา (สลับซ้าย-ขวาได้) | +| `EDITORIAL_CAROUSEL` | สไลด์บทความ (standard / split screen) | +| `HERO_ARTICLE` | หัวบทความ (ผู้เขียน, วันที่, หมวดหมู่) | +| `QUOTE_CALLOUT` | โควทคำพูด / คำโปรย | + +### กลุ่ม Brand Features +| Type | คำอธิบาย | +|---|---| +| `SCENT_NOTES_BREAKDOWN` | แสดง Top/Heart/Base Notes ของกลิ่น | +| `USP_BAR` | จุดเด่นของร้าน (max 4 items) | +| `ACCORDION` | Q&A / FAQ แบบย่อ-ขยาย | + +### กลุ่ม Forms & Contacts +| Type | คำอธิบาย | +|---|---| +| `NEWSLETTER` | ฟอร์มรับอีเมล + privacy checkbox | +| `CONTACT_INFO` | ที่อยู่, อีเมล, LINE, เวลาทำการ | +| `CONTACT_FORM` | ฟอร์มส่งข้อความ | + +--- + +## 2. Multi-Client White-Label Strategy + +### หลักการสำคัญ + +> **CMS เก็บข้อมูล (Data + Semantics) — Renderer คือสิ่งที่เปลี่ยนตาม Client** + +ความแตกต่างระหว่าง Client มี 2 ระดับที่แก้ต่างกัน: + +| ระดับ | ตัวอย่าง | แก้ด้วย | +|---|---|---| +| **Visual Style** | สี, font, spacing ต่างกัน | Design Tokens / CSS Variables | +| **Layout Structure** | Card ตั้ง vs Card นอน (HTML ต่างกัน) | Component Variants ใน Renderer | + +> Theme Token ช่วยไม่ได้กับ Layout Structure เพราะ HTML shape ต่างกันทั้งหมด + +### Git Repository Strategy + +``` +[product-template] ← Source of Truth เสมอ + │ + ├── fork → [homaroma-client] + ├── fork → [client-a-repo] + │ └── good feature → cherry-pick กลับ product-template + └── fork → [client-b-repo] ← clone จาก product-template (ที่ updated แล้ว) +``` + +**กฎ:** Client B ไม่ได้ start จาก Client A แต่ start จาก **product-template ที่รับ feature ดีๆ กลับมาแล้ว** + +### Commit Discipline (สำคัญมาก) + +```bash +# ✅ แยก commit ให้ชัดเสมอ +git commit -m "feat(categories): add horizontal-card layout variant" # generic → กลับ master ได้ +git commit -m "feat(client-a): set default theme to warm-beige" # client-specific +git commit -m "fix(checkout): correct discount calculation" # generic → กลับ master ได้ + +# ❌ อย่าทำแบบนี้ +git commit -m "client A changes" # cherry-pick ยากมาก +``` + +### Decision: Feature กลับ master ได้หรือไม่? + +| ลักษณะ Feature | กลับ master? | +|---|---| +| Generic component / layout variant ใหม่ | ✅ ควร | +| Bug fix | ✅ เสมอ | +| Infrastructure / DevOps | ✅ เสมอ | +| Branding เฉพาะ client (สี, logo) | ❌ | +| Business logic เฉพาะ client | ❌ | +| Config ที่ structure generic แต่ค่าต่างกัน | ✅ structure กลับ, ค่าอยู่ที่ client | + +--- + +## 3. componentRegistry Pattern + +### เป้าหมาย +ให้ **Backend + Schema กลางไม่ต้องเปลี่ยน** เมื่อ Client ต้องการ Layout ที่ต่างออกไป + +### โครงสร้างที่ควรมี + +``` +apps/spa/src/ + config/ + componentRegistry.ts ← Client-specific (ไม่ขึ้น master) + themeConfig.ts ← Client-specific + components/ + dynamic/ + categories/ + VerticalCard.tsx ← Generic variant (ขึ้น master) + HorizontalCard.tsx ← Generic variant (ขึ้น master) +``` + +### การทำงาน + +```ts +// apps/spa/src/config/componentRegistry.ts +export const componentRegistry = { + CATEGORIES: DynamicCategoriesVertical, // HomAroma เลือก vertical + HERO: DynamicHeroV2, +} + +// DynamicPageRenderer.tsx — generic, ไม่มี hardcode switch-case +sections.map(section => { + const Component = componentRegistry[section.type] + return +}) +``` + +**ผลลัพธ์:** เพิ่ม Client ใหม่ = สร้าง `componentRegistry.ts` ใหม่ + component ที่ต้องการ ไม่แตะ Backend เลย + +--- + +## 4. CUSTOM Block — Developer Escape Hatch + +### เหตุผลที่ต้องมี + +ทุกครั้งที่ Dev เพิ่ม Section ใหม่แบบเดิม ต้องแก้ **4 จุดพร้อมกัน:** + +1. `packages/types/index.ts` → Schema + Type +2. `apps/backend/routes/pages.ts` → validation +3. `apps/spa/DynamicPageRenderer.tsx` → render +4. `apps/admin/PageBuilderPage.tsx` → form + +ถ้าเป็น client-specific section → ต้องแก้ Schema กลาง (ผิดหลักการ) + +### แนวทาง: CUSTOM Block Type + +```json +{ + "type": "CUSTOM", + "payload": { + "componentId": "BakeryCategories", + "props": { + "title": "หมวดหมู่ของเรา", + "layout": "horizontal" + } + } +} +``` + +### Dev Workflow ใหม่ (2 ขั้นตอน ทำแค่ Frontend) + +```ts +// Step 1: สร้าง Component ใดๆ ก็ได้ +// apps/spa/src/components/custom/BakeryCategories.tsx +export default function BakeryCategories({ title, layout }) { + return
...JSX ได้เลย ไม่มีข้อจำกัด...
+} + +// Step 2: Register ใน customRegistry +// apps/spa/src/config/componentRegistry.ts +export const customRegistry = { + 'BakeryCategories': BakeryCategories, +} +``` + +**Backend ไม่ต้องแก้เลย** + +### กฎการตัดสินใจ + +``` +ถาม: "Section นี้จะถูกใช้ใน project อื่นด้วยไหม?" + +ใช่ → สร้าง Block Type ใหม่ตาม flow เดิม (เพิ่มเข้า master) +ไม่ → ใช้ CUSTOM block + register ใน client customRegistry เท่านั้น +``` + +--- + +## 5. Backlog — สิ่งที่ต้อง Implement + +### ต้องทำ (ครั้งเดียว — foundation) +- [ ] เพิ่ม `'CUSTOM'` เข้า `PAGE_SECTION_TYPES` ใน `packages/types/index.ts` +- [ ] สร้าง `apps/spa/src/config/componentRegistry.ts` แยกออกมาจาก DynamicPageRenderer +- [ ] เพิ่ม `case 'CUSTOM'` ใน `apps/spa/src/components/DynamicPageRenderer.tsx` +- [ ] (Optional) เพิ่มใน `apps/website` ด้วย + +### ควรทำ (เพิ่ม variants) +- [ ] สร้าง `HorizontalCard.tsx` variant ใน `components/dynamic/categories/` +- [ ] สร้าง `VerticalCard.tsx` (refactor จากของเดิม) + +### Long-term +- [ ] `packages/ui/` — generic component library สำหรับ white-label + +--- + +## 6. Anti-patterns — สิ่งที่ไม่ควรทำ + +- ❌ **อย่าเพิ่ม UI layout ลงใน Schema กลาง** เช่น `layout: 'vertical' | 'horizontal'` ใน `CategoriesSectionPayloadSchema` — Schema จะพองและผูก UI กับ Data +- ❌ **อย่าให้ Admin ใส่ Raw HTML ตรงๆ** — XSS risk และ bypass design system +- ❌ **อย่า start Client B จาก Client A repo** — ใช้ product-template เสมอ +- ❌ **อย่า commit รวมกัน** เช่น "client A changes" — cherry-pick ยากมาก +- ❌ **อย่า hardcode switch-case ใน DynamicPageRenderer** — ใช้ componentRegistry แทน diff --git a/CUSTOM_BLOCK_FLOW_GUIDE.md b/CUSTOM_BLOCK_FLOW_GUIDE.md new file mode 100644 index 0000000..db4e639 --- /dev/null +++ b/CUSTOM_BLOCK_FLOW_GUIDE.md @@ -0,0 +1,515 @@ +# Custom Block Flow Guide + +เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ HomAroma Page Builder โดยใช้ `BakeryCategories` เป็นตัวอย่างจริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง + +## แนวคิดหลัก + +Custom Block คือ section ประเภท `CUSTOM` ที่เก็บชื่อ component และ props ไว้ใน CMS payload: + +```json +{ + "componentId": "BakeryCategories", + "props": { + "title": { + "th": "หมวดหมู่เบเกอรี่", + "en": "Bakery Categories" + } + } +} +``` + +Renderer จะอ่าน `componentId` แล้วไปหา component จาก `customRegistry` ของแต่ละ frontend: + +- `apps/website/src/config/customRegistry.ts` +- `apps/spa/src/config/customRegistry.ts` + +ดังนั้นการเพิ่ม Custom Block ใหม่ต้องทำให้ครบ 4 ชั้น: + +1. Shared type และ backend validation +2. Admin Page Builder form +3. Frontend registry +4. Frontend component render จริง + +## Step 1: ออกแบบ Payload + +เริ่มจากกำหนด JSON shape ก่อนเสมอ ห้ามเดา field ตอนเขียน component + +ตัวอย่าง payload ของ `BakeryCategories`: + +```json +{ + "componentId": "BakeryCategories", + "props": { + "title": { + "th": "หมวดหมู่เบเกอรี่", + "en": "Bakery Categories", + "ja": "ベーカリーカテゴリー", + "zh": "烘焙类别", + "vi": "Danh mục bánh nướng", + "ko": "베이커리 카테고리" + }, + "subtitle": { + "th": "เลือกซื้อขนมอบตามหมวดหมู่ที่คุณชอบ", + "en": "Explore fresh-baked favorites by collection.", + "ja": "お好みのカテゴリーから焼きたての商品をお選びください。", + "zh": "按类别浏览新鲜烘焙的精选产品。", + "vi": "Khám phá các món bánh nướng mới theo từng bộ sưu tập.", + "ko": "카테고리별로 갓 구운 인기 제품을 살펴보세요." + }, + "layout": "horizontal", + "items": [ + { + "title": { + "th": "ขนมปัง", + "en": "Breads", + "ja": "パン", + "zh": "面包", + "vi": "Bánh mì", + "ko": "빵" + }, + "description": { + "th": "ขนมปังอบสดใหม่ทุกวัน", + "en": "Daily loaves, sourdough, and soft sandwich breads.", + "ja": "毎日焼き上げる食パン、サワードウ、やわらかなサンドイッチブレッド。", + "zh": "每日新鲜出炉的面包、酸面包和柔软三明治面包。", + "vi": "Bánh mì ổ, sourdough và bánh mì sandwich mềm được nướng mỗi ngày.", + "ko": "매일 구운 식빵, 사워도우, 부드러운 샌드위치 브레드." + }, + "href": "/shop", + "imageUrl": "https://example.com/bread.jpg" + } + ] + } +} +``` + +กฎสำหรับ multilingual content: + +- ใช้ language keys ชุดเดียวกับระบบ: `th`, `en`, `ja`, `zh`, `vi`, `ko` +- field ที่เป็นข้อความควรรองรับ localized object เสมอ +- field ที่เป็น route หรือ URL เช่น `href`, `imageUrl` เป็น string ปกติ +- `imageUrl` เป็น optional ถ้าไม่ใส่ component ต้องมี fallback ที่หน้าไม่พัง + +## Step 2: เพิ่ม Shared Type + +ไฟล์หลัก: + +```text +packages/types/index.ts +``` + +ต้องมี schema สำหรับ `CUSTOM`: + +```ts +export const CustomSectionPayloadSchema = z.object({ + componentId: z.string().min(1), + props: z.record(z.string(), z.unknown()).optional().default({}), +}).strict(); + +export type CustomSectionPayload = z.infer; +``` + +และเพิ่ม `CUSTOM` เข้า `PAGE_SECTION_TYPES`: + +```ts +export const PAGE_SECTION_TYPES = [ + // existing section types... + 'CUSTOM' +] as const; +``` + +เหตุผล: + +- Admin, MCP, backend, frontend จะใช้ source of truth เดียวกัน +- Backend validate ได้ว่า `componentId` ต้องมีจริง +- `props` เปิดเป็น object เพื่อให้ custom block แต่ละตัวมี shape ของตัวเอง + +## Step 3: เพิ่ม Backend Validation + +ไฟล์หลัก: + +```text +apps/backend/src/routes/pages.ts +``` + +เพิ่ม import: + +```ts +import { CustomSectionPayloadSchema } from "@homaroma/types"; +``` + +เพิ่ม case ใน `validateSectionPayload`: + +```ts +case "CUSTOM": + schema = CustomSectionPayloadSchema; + break; +``` + +ผลลัพธ์ที่ต้องได้: + +- สร้าง section type `CUSTOM` ได้ +- update payload ของ custom section ได้ +- ถ้าไม่มี `componentId` backend ต้อง reject + +## Step 4: เพิ่ม Admin Page Builder + +ไฟล์หลัก: + +```text +apps/admin/src/pages/PageBuilderPage.tsx +apps/admin/src/components/page-builder/CustomBlockForm.tsx +``` + +ใน `PageBuilderPage.tsx` ต้องทำ 3 จุด: + +1. Import form: + +```ts +import CustomBlockForm from '../components/page-builder/CustomBlockForm'; +``` + +2. เพิ่ม default payload เมื่อเลือก `CUSTOM`: + +```ts +case 'CUSTOM': + defaultPayload = { + componentId: 'BakeryCategories', + props: { + title: { + th: 'หมวดหมู่เบเกอรี่', + en: 'Bakery Categories' + }, + layout: 'horizontal', + items: [] + } + }; + break; +``` + +3. เพิ่ม option และ render form: + +```tsx + +``` + +```tsx +{editingSection.type?.toUpperCase() === 'CUSTOM' && ( + +)} +``` + +`CustomBlockForm` มีหน้าที่รับ: + +- `Component ID` +- `Props JSON` + +สำหรับ `BakeryCategories` ให้ใส่: + +```text +Component ID: BakeryCategories +``` + +แล้วใส่ JSON ใน `Props JSON` + +## Step 5: เพิ่ม Website Registry + +ไฟล์หลัก: + +```text +apps/website/src/config/customRegistry.ts +``` + +ตัวอย่าง: + +```ts +import type { ComponentType } from 'react' +import type { CustomSectionPayload } from '@homaroma/types' +import BakeryCategories from '../components/custom/BakeryCategories' + +export type CustomBlockComponentProps = { + props: CustomSectionPayload['props'] + lang: string +} + +export type CustomBlockComponent = ComponentType + +export const customRegistry: Record = { + BakeryCategories, +} +``` + +กฎสำคัญ: + +- key ใน registry ต้องตรงกับ `componentId` ใน Admin +- ถ้า Admin ใส่ `BakeryCategories` แต่ registry ไม่มี key นี้ block จะไม่ render +- website ต้องส่ง `lang` เข้า custom component เพราะ route เป็น `/th`, `/en`, etc. + +## Step 6: เพิ่ม SPA Registry + +ไฟล์หลัก: + +```text +apps/spa/src/config/customRegistry.ts +``` + +ตัวอย่าง: + +```ts +import type { ComponentType } from 'react'; +import type { CustomSectionPayload } from '@homaroma/types'; +import BakeryCategories from '../components/custom/BakeryCategories'; + +export type CustomBlockComponentProps = { + props: CustomSectionPayload['props']; +}; + +export type CustomBlockComponent = ComponentType; + +export const customRegistry: Record = { + BakeryCategories, +}; +``` + +SPA ไม่ต้องรับ `lang` จาก registry เพราะ component ใช้ `useTranslation()` อ่าน `i18n.language` เอง + +## Step 7: เพิ่ม Renderer Case + +Website renderer: + +```text +apps/website/src/components/dynamic/DynamicPageRenderer.tsx +``` + +SPA renderer: + +```text +apps/spa/src/components/DynamicPageRenderer.tsx +``` + +Renderer ต้องมี case `CUSTOM` ที่ทำงานประมาณนี้: + +```tsx +case 'CUSTOM': { + const componentId = typeof payload.componentId === 'string' ? payload.componentId : '' + const props = toRecord(payload.props) + const CustomComponent = customRegistry[componentId] + + if (!CustomComponent) { + return null + } + + return ( + + + + ) +} +``` + +สำหรับ SPA จะไม่มี `lang={lang}`: + +```tsx + +``` + +กฎสำคัญ: + +- Renderer ไม่ควรรู้ logic เฉพาะของ `BakeryCategories` +- Renderer แค่ map `componentId` ไป component เท่านั้น +- ถ้า component ไม่เจอ ให้ return `null` และ warn เฉพาะ dev ได้ + +## Step 8: สร้าง Component จริง + +Website: + +```text +apps/website/src/components/custom/BakeryCategories.tsx +``` + +SPA: + +```text +apps/spa/src/components/custom/BakeryCategories.tsx +``` + +หน้าที่ของ component: + +- อ่าน `props.title` +- อ่าน `props.subtitle` +- อ่าน `props.layout` +- อ่าน `props.items` +- resolve localized text ตามภาษาปัจจุบัน +- render UI โดยใช้ design tokens/classes ของ app นั้น ๆ + +ตัวอย่าง field ที่ component รองรับ: + +```ts +type BakeryCategory = { + title: string | Record + description?: string | Record + href: string + imageUrl?: string +} +``` + +สำหรับ website ใช้: + +```ts +resolveLocalized(value, lang) +``` + +สำหรับ SPA ใช้: + +```ts +getLocalizedField(value, i18n.language) +``` + +## Step 9: อัปเดต MCP Guidance + +ไฟล์หลัก: + +```text +apps/mcp-server/section-toolkit.ts +``` + +เพิ่ม `CUSTOM` ใน guidance: + +```ts +CUSTOM: { + required: ["componentId"], + example: `{ "componentId": "BakeryCategories", "props": { "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "layout": "horizontal", "items": [{ "title": ${localizedStringExample}, "description": ${localizedStringExample}, "href": "/shop" }] } }` +} +``` + +เหตุผล: + +- AI/MCP tools จะรู้ว่า `CUSTOM` ใช้ได้ +- ผู้ใช้ tool จะเห็น minimal example ที่ถูกต้อง +- ลดโอกาสสร้าง payload ที่ backend reject + +## Step 10: ทดสอบ + +ควรตรวจอย่างน้อย: + +```bash +bun run --cwd apps/website build +bun run --cwd apps/spa build +bun run --cwd apps/admin build +``` + +Acceptance criteria: + +- Admin สร้าง section `CUSTOM` ได้ +- Backend รับ payload ที่มี `componentId` ได้ +- Website render `BakeryCategories` ได้ +- SPA render `BakeryCategories` ได้ +- เปลี่ยนภาษาแล้ว title/subtitle/items แสดงตาม language key +- ถ้า `imageUrl` ไม่มี ต้องไม่ทำให้หน้า error + +## Checklist สำหรับ Custom Block ตัวถัดไป + +ใช้ checklist นี้ทุกครั้งที่เพิ่ม custom block ใหม่: + +- ตั้งชื่อ componentId แล้ว เช่น `BakeryCategories` +- ออกแบบ props JSON ให้ชัดเจน +- ข้อความทุก field รองรับ localized object +- เพิ่ม component ใน `apps/website/src/components/custom` +- เพิ่ม component ใน `apps/spa/src/components/custom` +- register ใน `apps/website/src/config/customRegistry.ts` +- register ใน `apps/spa/src/config/customRegistry.ts` +- ถ้าต้องการ default ใน Admin ให้เพิ่มใน `PageBuilderPage.tsx` +- ถ้าต้องการให้ Admin กรอกง่ายขึ้น ให้ปรับ `CustomBlockForm.tsx` +- ถ้าต้องการให้ MCP ใช้งานได้ ให้เพิ่ม guidance ใน `section-toolkit.ts` +- build ทั้ง website/spa/admin ผ่าน + +## Common Mistakes + +1. `componentId` ไม่ตรงกับ registry key + +Admin ใส่: + +```json +{ "componentId": "BakeryCategory" } +``` + +แต่ registry มี: + +```ts +BakeryCategories +``` + +ผลคือ block ไม่แสดง + +2. ใส่ข้อความเป็นภาษาเดียว + +```json +{ "title": "Bakery Categories" } +``` + +แบบนี้ยัง render ได้ แต่ไม่เหมาะกับระบบหลายภาษา ควรใช้: + +```json +{ + "title": { + "th": "หมวดหมู่เบเกอรี่", + "en": "Bakery Categories", + "ja": "ベーカリーカテゴリー", + "zh": "烘焙类别", + "vi": "Danh mục bánh nướng", + "ko": "베이커리 카테고리" + } +} +``` + +3. ใส่ `items` เป็น object แทน array + +ผิด: + +```json +{ + "items": { + "title": { "th": "ขนมปัง" } + } +} +``` + +ถูก: + +```json +{ + "items": [ + { + "title": { "th": "ขนมปัง" }, + "href": "/shop" + } + ] +} +``` + +4. ลืมเพิ่มทั้ง website และ SPA + +โปรเจกต์นี้มีสอง frontend: + +- `apps/website` +- `apps/spa` + +ถ้าเพิ่มแค่ฝั่งเดียว อีกฝั่งจะไม่ render block นั้น + +## Summary Flow + +```text +Admin Page Builder + -> creates PageSection type CUSTOM + -> payload.componentId = BakeryCategories + -> payload.props = localized JSON + -> backend validates CustomSectionPayloadSchema + -> website/spa DynamicPageRenderer receives section + -> customRegistry finds BakeryCategories + -> BakeryCategories resolves language + -> UI renders cards +``` + diff --git a/apps/admin/src/components/page-builder/CustomBlockForm.tsx b/apps/admin/src/components/page-builder/CustomBlockForm.tsx new file mode 100644 index 0000000..c26b726 --- /dev/null +++ b/apps/admin/src/components/page-builder/CustomBlockForm.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'; +import { type PageSection } from '@homaroma/types'; + +interface CustomBlockFormProps { + editingSection: Partial; + setEditingSection: Dispatch | null>>; +} + +function formatProps(props: unknown) { + if (!props || typeof props !== 'object' || Array.isArray(props)) { + return '{}'; + } + + return JSON.stringify(props, null, 2); +} + +export default function CustomBlockForm({ + editingSection, + setEditingSection, +}: CustomBlockFormProps) { + const propsRef = useRef(null); + const [jsonError, setJsonError] = useState(''); + const propsFieldKey = `${editingSection.id ?? 'new'}:${editingSection.payload?.componentId ?? ''}`; + + useEffect(() => { + propsRef.current?.setCustomValidity(jsonError); + }, [jsonError]); + + const updateComponentId = (componentId: string) => { + setEditingSection((prev) => { + if (!prev) return null; + + return { + ...prev, + payload: { + ...(prev.payload || {}), + componentId, + }, + }; + }); + }; + + const updateProps = (value: string) => { + try { + const nextProps = JSON.parse(value); + if (!nextProps || typeof nextProps !== 'object' || Array.isArray(nextProps)) { + setJsonError('Props must be a JSON object.'); + return; + } + + setJsonError(''); + setEditingSection((prev) => { + if (!prev) return null; + + return { + ...prev, + payload: { + ...(prev.payload || {}), + props: nextProps, + }, + }; + }); + } catch { + setJsonError('Props must be valid JSON.'); + } + }; + + return ( +
+
+ + updateComponentId(e.target.value)} + className="admin-input w-full" + placeholder="BakeryCategories" + /> +
+ +
+ +