x
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user