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

@@ -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 HeroV2Form from '../components/page-builder/HeroV2Form';
import UspBarForm from '../components/page-builder/UspBarForm';
import CustomBlockForm from '../components/page-builder/CustomBlockForm';
interface Brand {
id: number;
@@ -678,6 +679,56 @@ export default function PageBuilderPage() {
subtitle: 'We would love to hear from you.'
};
break;
case 'CUSTOM':
defaultPayload = {
componentId: 'BakeryCategories',
props: {
title: {
th: 'หมวดหมู่เบเกอรี่',
en: 'Bakery Categories'
},
subtitle: {
th: 'เลือกซื้อขนมอบตามหมวดหมู่ที่คุณชอบ',
en: 'Explore fresh-baked favorites by collection.'
},
layout: 'horizontal',
items: [
{
title: { th: 'ขนมปัง', en: 'Breads' },
description: {
th: 'ขนมปังอบสดใหม่ทุกวัน',
en: 'Daily loaves, sourdough, and soft sandwich breads.'
},
href: '/shop'
},
{
title: { th: 'พาสทรี', en: 'Pastries' },
description: {
th: 'พาสทรีเนยหอมกรอบสำหรับมื้อเช้าและของฝาก',
en: 'Layered butter pastries for breakfast and gifting.'
},
href: '/shop'
},
{
title: { th: 'เค้ก', en: 'Cakes' },
description: {
th: 'เค้กสำหรับวันพิเศษและช่วงเวลาพักผ่อน',
en: 'Celebration cakes and slices for quiet afternoons.'
},
href: '/shop'
},
{
title: { th: 'คุกกี้', en: 'Cookies' },
description: {
th: 'คุกกี้อบใหม่ กรอบนอกนุ่มใน',
en: 'Small-batch cookies with crisp edges and soft centers.'
},
href: '/shop'
}
]
}
};
break;
}
setEditingSection(prev => {
@@ -990,6 +1041,7 @@ export default function PageBuilderPage() {
<option value="CONTACT_FORM">CONTACT FORM (Message inputs)</option>
<option value="HERO_V2">HERO V2 (Split Layout Hero)</option>
<option value="USP_BAR">USP BAR (Features / Icons)</option>
<option value="CUSTOM">CUSTOM (Registered component)</option>
</select>
</div>
)}
@@ -1205,6 +1257,13 @@ export default function PageBuilderPage() {
/>
)}
{editingSection.type?.toUpperCase() === 'CUSTOM' && (
<CustomBlockForm
editingSection={editingSection}
setEditingSection={setEditingSection}
/>
)}
</div>
<div className="p-6 bg-night-950/50 border-t border-white/5 flex flex-col sm:flex-row gap-4 shrink-0">
<button
+5
View File
@@ -16,10 +16,12 @@ import {
ScentNotesBreakdownSectionPayloadSchema,
QuoteCalloutSectionPayloadSchema,
ProductCarouselSectionPayloadSchema,
AccordionSectionPayloadSchema,
ContactInfoSectionPayloadSchema,
ContactFormSectionPayloadSchema,
HeroV2SectionPayloadSchema,
UspBarSectionPayloadSchema,
CustomSectionPayloadSchema,
} from "@homaroma/types";
import { z } from "zod";
import { adminOnly } from "../middleware/auth";
@@ -607,6 +609,9 @@ function validateSectionPayload(
case "USP_BAR":
schema = UspBarSectionPayloadSchema;
break;
case "CUSTOM":
schema = CustomSectionPayloadSchema;
break;
default:
return {
success: false,
+5 -1
View File
@@ -90,6 +90,10 @@ const ADD_PAGE_SECTION_GUIDANCE: Record<string, { required: string[]; example: s
CONTACT_FORM: {
required: [],
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",
properties: {
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)" },
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" }
@@ -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 DynamicHeroV2 from './DynamicHeroV2';
import DynamicUspBar from './DynamicUspBar';
@@ -19,6 +19,7 @@ import AccordionSection from './sections/AccordionSection';
import ContactInfoSection from './sections/ContactInfoSection';
import ContactFormSection from './sections/ContactFormSection';
import ScrollReveal from './ScrollReveal';
import { customRegistry } from '../config/customRegistry';
interface DynamicPageRendererProps {
sections: PageSection[];
@@ -93,6 +94,21 @@ export default function DynamicPageRenderer({
return <ScrollReveal key={section.id}><ContactInfoSection payload={section.payload as ContactInfoSectionPayload & { title?: string }} /></ScrollReveal>;
case 'CONTACT_FORM':
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:
console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`);
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 { useLang } from '../../hooks/useLang'
import { useTranslation } from 'react-i18next'
import { customRegistry } from '../../config/customRegistry'
import { Swiper, SwiperSlide } from 'swiper/react'
import { Autoplay, Pagination, Navigation } from 'swiper/modules'
import 'swiper/css'
@@ -1127,6 +1128,24 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
<ContactFormSection payload={payload} lang={lang} />
</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:
if (process.env.NODE_ENV !== 'production') {
console.warn(`[DynamicPageRenderer] Unsupported section type: ${section.type}`)
@@ -1137,4 +1156,3 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
</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,
}