This commit is contained in:
Your Name committed 2026-07-24 19:39:38 -04:00
1 parent e509e3f0a2
commit 0d4df55d93
7 files changed
+526 -3

No files matched your search

+101 -1
View File
@@ -31,6 +31,13 @@ import TextForm from '../components/page-builder/TextForm';
import HeroArticleForm from '../components/page-builder/HeroArticleForm';
import RichTextForm from '../components/page-builder/RichTextForm';
import ArticleMediaForm from '../components/page-builder/ArticleMediaForm';
import {
ArticleFactsForm,
ArticleGalleryForm,
ArticleHeaderForm,
ArticleReferencesForm,
ArticleVideoForm,
} from '../components/page-builder/ArticleContentForms';
import ScentNotesBreakdownForm from '../components/page-builder/ScentNotesBreakdownForm';
import QuoteCalloutForm from '../components/page-builder/QuoteCalloutForm';
import ProductCarouselForm from '../components/page-builder/ProductCarouselForm';
@@ -71,7 +78,7 @@ export default function PageBuilderPage() {
const [brands, setBrands] = useState<Brand[]>([]);
const [loading, setLoading] = useState(true);
const [editingSection, setEditingSection] = useState<Partial<PageSection> | null>(null);
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide'; index?: number } | null>(null);
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide' | 'gallery'; index?: number } | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [isSavingPageSettings, setIsSavingPageSettings] = useState(false);
const [isReorderModalOpen, setIsReorderModalOpen] = useState(false);
@@ -87,6 +94,14 @@ export default function PageBuilderPage() {
currentSlides[idx] = { ...currentSlides[idx], imageUrl: url };
return { ...prev, payload: { ...(prev.payload || {}), slides: currentSlides } };
});
} else if (uploadTarget?.type === 'gallery' && typeof uploadTarget.index === 'number') {
const idx = uploadTarget.index;
setEditingSection(prev => {
if (!prev) return null;
const images = [...(prev.payload?.images || [])];
images[idx] = { ...(images[idx] || {}), imageUrl: url };
return { ...prev, payload: { ...(prev.payload || {}), images } };
});
} else {
setEditingSection(prev => {
if (!prev) return null;
@@ -642,6 +657,27 @@ export default function PageBuilderPage() {
background: 'default',
};
break;
case 'ARTICLE_HEADER':
defaultPayload = { title: 'Article title', subtitle: 'A concise introduction to the story.' };
break;
case 'ARTICLE_GALLERY':
defaultPayload = {
layout: 'grid',
images: [
{ imageUrl: '/images/hero.png', alt: 'Gallery image one' },
{ imageUrl: '/images/oil_ozone.png', alt: 'Gallery image two' },
],
};
break;
case 'ARTICLE_FACTS':
defaultPayload = { title: 'Key facts', items: [{ title: 'Important fact', description: 'Supporting detail.' }] };
break;
case 'ARTICLE_VIDEO':
defaultPayload = { videoUrl: 'https://www.youtube.com/watch?v=VIDEO_ID', aspectRatio: '16:9' };
break;
case 'ARTICLE_REFERENCES':
defaultPayload = { title: 'References', items: [{ title: 'Source title' }] };
break;
case 'SCENT_NOTES_BREAKDOWN':
defaultPayload = {
title: 'Scent Notes',
@@ -1085,6 +1121,11 @@ export default function PageBuilderPage() {
<option value="HERO_ARTICLE">HERO ARTICLE (Editorial hero)</option>
<option value="RICH_TEXT">RICH TEXT (Markdown / HTML block)</option>
<option value="ARTICLE_MEDIA">ARTICLE MEDIA (Paragraph with image)</option>
<option value="ARTICLE_HEADER">ARTICLE HEADER (Title and cover)</option>
<option value="ARTICLE_GALLERY">ARTICLE GALLERY (Image collection)</option>
<option value="ARTICLE_FACTS">ARTICLE FACTS (Key takeaways)</option>
<option value="ARTICLE_VIDEO">ARTICLE VIDEO (YouTube, Vimeo, or file)</option>
<option value="ARTICLE_REFERENCES">ARTICLE REFERENCES (Sources)</option>
<option value="SCENT_NOTES_BREAKDOWN">SCENT NOTES (Fragrance layers)</option>
<option value="QUOTE_CALLOUT">QUOTE CALLOUT (Highlighted quote)</option>
<option value="PRODUCT_CAROUSEL">PRODUCT CAROUSEL (Featured products)</option>
@@ -1262,6 +1303,65 @@ export default function PageBuilderPage() {
/>
)}
{editingSection.type?.toUpperCase() === 'ARTICLE_HEADER' && (
<ArticleHeaderForm
editingSection={editingSection}
setEditingSection={setEditingSection}
editingLang={editingLang}
getLocalizedValue={getLocalizedValue}
updateLocalizedValue={updateLocalizedValue}
updatePayloadValue={updatePayloadValue}
setUploadTarget={setUploadTarget}
setIsMediaModalOpen={setIsMediaModalOpen}
/>
)}
{editingSection.type?.toUpperCase() === 'ARTICLE_GALLERY' && (
<ArticleGalleryForm
editingSection={editingSection}
setEditingSection={setEditingSection}
editingLang={editingLang}
getLocalizedValue={getLocalizedValue}
updateLocalizedValue={updateLocalizedValue}
updatePayloadValue={updatePayloadValue}
setUploadTarget={setUploadTarget}
setIsMediaModalOpen={setIsMediaModalOpen}
/>
)}
{editingSection.type?.toUpperCase() === 'ARTICLE_FACTS' && (
<ArticleFactsForm
editingSection={editingSection}
setEditingSection={setEditingSection}
editingLang={editingLang}
getLocalizedValue={getLocalizedValue}
updateLocalizedValue={updateLocalizedValue}
updatePayloadValue={updatePayloadValue}
/>
)}
{editingSection.type?.toUpperCase() === 'ARTICLE_VIDEO' && (
<ArticleVideoForm
editingSection={editingSection}
setEditingSection={setEditingSection}
editingLang={editingLang}
getLocalizedValue={getLocalizedValue}
updateLocalizedValue={updateLocalizedValue}
updatePayloadValue={updatePayloadValue}
/>
)}
{editingSection.type?.toUpperCase() === 'ARTICLE_REFERENCES' && (
<ArticleReferencesForm
editingSection={editingSection}
setEditingSection={setEditingSection}
editingLang={editingLang}
getLocalizedValue={getLocalizedValue}
updateLocalizedValue={updateLocalizedValue}
updatePayloadValue={updatePayloadValue}
/>
)}
{editingSection.type?.toUpperCase() === 'SCENT_NOTES_BREAKDOWN' && (
<ScentNotesBreakdownForm
editingSection={editingSection}
+20
View File
@@ -13,7 +13,12 @@ import {
EditorialCarouselSectionPayloadSchema,
HeroArticleSectionPayloadSchema,
RichTextSectionPayloadSchema,
ArticleHeaderSectionPayloadSchema,
ArticleMediaSectionPayloadSchema,
ArticleGallerySectionPayloadSchema,
ArticleFactsSectionPayloadSchema,
ArticleVideoSectionPayloadSchema,
ArticleReferencesSectionPayloadSchema,
ScentNotesBreakdownSectionPayloadSchema,
QuoteCalloutSectionPayloadSchema,
ProductCarouselSectionPayloadSchema,
@@ -772,9 +777,24 @@ export function validateSectionPayload(
case "RICH_TEXT":
schema = RichTextSectionPayloadSchema;
break;
case "ARTICLE_HEADER":
schema = ArticleHeaderSectionPayloadSchema;
break;
case "ARTICLE_MEDIA":
schema = ArticleMediaSectionPayloadSchema;
break;
case "ARTICLE_GALLERY":
schema = ArticleGallerySectionPayloadSchema;
break;
case "ARTICLE_FACTS":
schema = ArticleFactsSectionPayloadSchema;
break;
case "ARTICLE_VIDEO":
schema = ArticleVideoSectionPayloadSchema;
break;
case "ARTICLE_REFERENCES":
schema = ArticleReferencesSectionPayloadSchema;
break;
case "SCENT_NOTES_BREAKDOWN":
schema = ScentNotesBreakdownSectionPayloadSchema;
break;
+23
View File
@@ -17,7 +17,12 @@ const websiteBlockTypes = [
"BANNER",
"HERO_ARTICLE",
"RICH_TEXT",
"ARTICLE_HEADER",
"ARTICLE_MEDIA",
"ARTICLE_GALLERY",
"ARTICLE_FACTS",
"ARTICLE_VIDEO",
"ARTICLE_REFERENCES",
"SCENT_NOTES_BREAKDOWN",
"QUOTE_CALLOUT",
"PRODUCT_CAROUSEL",
@@ -82,6 +87,24 @@ describe("Page Builder block contracts", () => {
});
});
test("ARTICLE_GALLERY rejects fewer than two images", () => {
const result = validatePayloadAgainstMetadata("ARTICLE_GALLERY", {
images: [{ imageUrl: "/uploads/one.jpg", alt: { th: "ภาพเดียว" } }],
});
expect(result.valid).toBe(false);
expect(result.validationIssues.some((issue) => issue.path === "images")).toBe(true);
});
test("ARTICLE_VIDEO rejects non-HTTPS URLs", () => {
const result = validatePayloadAgainstMetadata("ARTICLE_VIDEO", {
videoUrl: "http://example.com/video.mp4",
});
expect(result.valid).toBe(false);
expect(result.validationIssues.some((issue) => issue.path === "videoUrl")).toBe(true);
});
test("create_page_with_section rejects invalid payloads without calling backend", async () => {
let postCalls = 0;
const api = {
+113 -1
View File
@@ -18,7 +18,12 @@ const WEBSITE_PAGE_SECTION_TYPE_LIST = [
"BANNER",
"HERO_ARTICLE",
"RICH_TEXT",
"ARTICLE_HEADER",
"ARTICLE_MEDIA",
"ARTICLE_GALLERY",
"ARTICLE_FACTS",
"ARTICLE_VIDEO",
"ARTICLE_REFERENCES",
"SCENT_NOTES_BREAKDOWN",
"QUOTE_CALLOUT",
"PRODUCT_CAROUSEL",
@@ -326,6 +331,93 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
background: "default",
},
},
ARTICLE_HEADER: {
type: "ARTICLE_HEADER",
requiredFields: ["title"],
optionalFields: ["subtitle", "category", "author", "publishDate", "imageUrl", "imageAlt"],
renderedFields: ["title", "subtitle", "category", "author", "publishDate", "imageUrl", "imageAlt"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["imageAlt is required whenever imageUrl is provided."],
minimalPayload: { title: localizedObjectExample },
recommendedPayload: {
title: { th: "เทคโนโลยีที่อยู่เบื้องหลังทุกการขับเคลื่อน", en: "Technology behind every ride" },
subtitle: { th: "ทำความรู้จักกระบวนการผลิตที่ให้ความสำคัญกับความแม่นยำ", en: "A closer look at precision manufacturing." },
category: { th: "เทคโนโลยี", en: "Technology" },
author: { th: "ทีมงาน DDC", en: "DDC Team" },
publishDate: "2026-07-24",
},
},
ARTICLE_GALLERY: {
type: "ARTICLE_GALLERY",
requiredFields: ["images", "images[].imageUrl", "images[].alt"],
optionalFields: ["title", "layout", "images[].caption"],
renderedFields: ["title", "layout", "images"],
storedOnlyFields: [],
discoveryToolsNeeded: ["Use real CMS media URLs; do not invent image URLs."],
warnings: ["Provide 2 to 8 images. Every image requires localized alt text."],
minimalPayload: {
images: [
{ imageUrl: imageUrlExample, alt: localizedObjectExample },
{ imageUrl: imageUrlExample, alt: localizedObjectExample },
],
},
recommendedPayload: {
title: { th: "ภาพจากกระบวนการผลิต", en: "Inside the production process" },
layout: "grid",
images: [
{ imageUrl: imageUrlExample, alt: { th: "ขั้นตอนการผลิตชิ้นงาน", en: "Component production" } },
{ imageUrl: imageUrlExample, alt: { th: "การตรวจสอบคุณภาพ", en: "Quality inspection" } },
],
},
},
ARTICLE_FACTS: {
type: "ARTICLE_FACTS",
requiredFields: ["items", "items[].title"],
optionalFields: ["title", "items[].description"],
renderedFields: ["title", "items"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["Provide 1 to 8 concise facts."],
minimalPayload: { items: [{ title: localizedObjectExample }] },
recommendedPayload: {
title: { th: "ข้อมูลสำคัญ", en: "Key facts" },
items: [
{ title: { th: "ผิวตัดสม่ำเสมอ", en: "Consistent cut surface" }, description: { th: "ช่วยลดขั้นตอนการแต่งผิว", en: "Reduces secondary finishing." } },
{ title: { th: "รูปทรงแม่นยำ", en: "Precise geometry" }, description: { th: "ช่วยให้การขบกับโซ่ต่อเนื่อง", en: "Supports consistent chain engagement." } },
],
},
},
ARTICLE_VIDEO: {
type: "ARTICLE_VIDEO",
requiredFields: ["videoUrl"],
optionalFields: ["title", "caption", "posterUrl", "aspectRatio"],
renderedFields: ["title", "videoUrl", "caption", "posterUrl", "aspectRatio"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["Use a full HTTPS YouTube, Vimeo, or direct video URL. Do not send embed HTML."],
minimalPayload: { videoUrl: "https://www.youtube.com/watch?v=VIDEO_ID" },
recommendedPayload: {
title: { th: "ชมกระบวนการผลิต", en: "Watch the production process" },
videoUrl: "https://www.youtube.com/watch?v=VIDEO_ID",
caption: { th: "เบื้องหลังการผลิตที่แม่นยำ", en: "Behind precision manufacturing" },
aspectRatio: "16:9",
},
},
ARTICLE_REFERENCES: {
type: "ARTICLE_REFERENCES",
requiredFields: ["items", "items[].title"],
optionalFields: ["title", "items[].url", "items[].note"],
renderedFields: ["title", "items"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["Use verified source URLs only. A reference may omit url for offline sources."],
minimalPayload: { items: [{ title: localizedObjectExample }] },
recommendedPayload: {
title: { th: "แหล่งข้อมูล", en: "References" },
items: [{ title: { th: "เอกสารข้อมูลทางเทคนิค", en: "Technical documentation" }, note: { th: "ข้อมูลจากฝ่ายวิศวกรรม", en: "Engineering source material" } }],
},
},
SCENT_NOTES_BREAKDOWN: {
type: "SCENT_NOTES_BREAKDOWN",
requiredFields: [],
@@ -555,6 +647,26 @@ const ADD_PAGE_SECTION_GUIDANCE: Record<string, { required: string[]; example: s
required: ["body"],
example: `{ "heading": ${localizedStringExample}, "body": ${localizedStringExample}, "imageUrl": "https://example.com/article.jpg", "imageAlt": ${localizedStringExample}, "imagePosition": "right" }`
},
ARTICLE_HEADER: {
required: ["title"],
example: `{ "title": ${localizedStringExample}, "subtitle": ${localizedStringExample}, "author": ${localizedStringExample} }`
},
ARTICLE_GALLERY: {
required: ["images", "images[].imageUrl", "images[].alt"],
example: `{ "images": [{ "imageUrl": "https://example.com/one.jpg", "alt": ${localizedStringExample} }, { "imageUrl": "https://example.com/two.jpg", "alt": ${localizedStringExample} }] }`
},
ARTICLE_FACTS: {
required: ["items", "items[].title"],
example: `{ "title": ${localizedStringExample}, "items": [{ "title": ${localizedStringExample}, "description": ${localizedStringExample} }] }`
},
ARTICLE_VIDEO: {
required: ["videoUrl"],
example: `{ "videoUrl": "https://www.youtube.com/watch?v=VIDEO_ID", "title": ${localizedStringExample}, "aspectRatio": "16:9" }`
},
ARTICLE_REFERENCES: {
required: ["items", "items[].title"],
example: `{ "items": [{ "title": ${localizedStringExample}, "url": "https://example.com/source" }] }`
},
SCENT_NOTES_BREAKDOWN: {
required: [],
example: `{ "title": ${localizedStringExample}, "topNotes": ${localizedStringListExample} }`
@@ -1100,7 +1212,7 @@ export const SECTION_TOOL_DEFINITIONS = [
},
{
name: "add_page_section",
description: `Add a new website-renderable block to an existing dynamic CMS page. Use this instead of create_page_with_section when the page or slug already exists. Call get_dynamic_page_detail first to obtain pageId, and get_page_block_schema before composing payload.\n\n${buildAddPageSectionDescription()}\n\nRender target guard: generic CMS pages are rendered by apps/website DynamicPageRenderer. PRODUCT_MAIN, PRODUCT_DETAILS, PRODUCT_SPECS, PRODUCT_REVIEWS, and RELATED_PRODUCTS are product detail native blocks and are rejected by default here. Use product detail CMS tools for those blocks.`,
description: "Add one website-renderable block to an existing dynamic CMS page. Call get_dynamic_page_detail to obtain pageId, then get_page_block_schema for the selected type before composing payload. Use product detail CMS tools for product-native blocks.",
inputSchema: {
type: "object",
properties: {
@@ -469,6 +469,42 @@ function TextSection({ payload, lang }: { payload: Record<string, unknown>, lang
)
}
function ArticleHeaderSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const category = localizedFromRecord(payload, 'category', resolvedLang)
const author = localizedFromRecord(payload, 'author', resolvedLang)
const imageAlt = localizedFromRecord(payload, 'imageAlt', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : ''
const publishDate = typeof payload.publishDate === 'string' ? payload.publishDate : ''
if (!title) return null
return (
<header className="bg-surface-page ui-section-band">
<div className="mx-auto max-w-6xl">
<div className="mx-auto max-w-4xl text-center">
{category && <p className="mb-4 text-sm font-semibold uppercase text-brand-primary">{category}</p>}
<h1 className="text-4xl font-semibold leading-tight text-text-main md:text-6xl">{title}</h1>
{subtitle && <p className="mx-auto mt-6 max-w-3xl text-lg leading-8 text-muted-text md:text-xl">{subtitle}</p>}
{(author || publishDate) && (
<p className="mt-6 text-sm text-muted-text">
{[author, publishDate].filter(Boolean).join(' · ')}
</p>
)}
</div>
{imageUrl && (
<div className="relative mt-10 aspect-[16/9] w-full overflow-hidden rounded-lg bg-surface-card">
<Image src={imageUrl} alt={imageAlt || title} fill unoptimized priority sizes="100vw" className="object-cover" />
</div>
)}
</div>
</header>
)
}
function ArticleMediaSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
@@ -547,6 +583,159 @@ function ArticleMediaSection({ payload, lang }: { payload: Record<string, unknow
)
}
function ArticleGallerySection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const images = Array.isArray(payload.images) ? payload.images : []
const featured = payload.layout === 'featured'
if (images.length < 2) return null
return (
<section className="bg-surface-page ui-section-band">
<div className="mx-auto max-w-7xl">
{title && <h2 className="mb-8 text-3xl font-semibold text-text-main md:text-4xl">{title}</h2>}
<div className={featured ? 'grid gap-4 md:grid-cols-2' : 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'}>
{images.map((image, index) => {
const item = toRecord(image)
const imageUrl = typeof item.imageUrl === 'string' ? item.imageUrl : ''
const alt = localizedFromRecord(item, 'alt', resolvedLang)
const caption = localizedFromRecord(item, 'caption', resolvedLang)
if (!imageUrl) return null
return (
<figure key={`${imageUrl}-${index}`} className={featured && index === 0 ? 'md:row-span-2' : undefined}>
<div className={`relative w-full overflow-hidden rounded-lg bg-surface-card ${featured && index === 0 ? 'aspect-[4/5] md:h-full md:min-h-[32rem]' : 'aspect-[4/3]'}`}>
<Image src={imageUrl} alt={alt} fill unoptimized sizes="(max-width: 768px) 100vw, 50vw" className="object-cover" />
</div>
{caption && <figcaption className="mt-3 text-sm leading-6 text-muted-text">{caption}</figcaption>}
</figure>
)
})}
</div>
</div>
</section>
)
}
function ArticleFactsSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const items = Array.isArray(payload.items) ? payload.items : []
if (items.length === 0) return null
return (
<section className="bg-surface-card ui-section-band">
<div className="mx-auto max-w-6xl">
{title && <h2 className="mb-8 text-3xl font-semibold text-text-main md:text-4xl">{title}</h2>}
<dl className="grid gap-x-10 gap-y-8 md:grid-cols-2">
{items.map((item, index) => {
const record = toRecord(item)
const itemTitle = localizedFromRecord(record, 'title', resolvedLang)
const description = localizedFromRecord(record, 'description', resolvedLang)
return (
<div key={index} className="border-t border-line-primary/20 pt-5">
<dt className="text-lg font-semibold text-text-main">{itemTitle}</dt>
{description && <dd className="mt-2 leading-7 text-muted-text">{description}</dd>}
</div>
)
})}
</dl>
</div>
</section>
)
}
function getVideoEmbedUrl(videoUrl: string) {
try {
const url = new URL(videoUrl)
if (url.hostname === 'youtu.be') return `https://www.youtube.com/embed/${url.pathname.slice(1)}`
if (url.hostname === 'youtube.com' || url.hostname.endsWith('.youtube.com')) {
const id = url.searchParams.get('v')
return id ? `https://www.youtube.com/embed/${id}` : ''
}
if (url.hostname === 'vimeo.com' || url.hostname.endsWith('.vimeo.com')) {
const id = url.pathname.split('/').filter(Boolean).pop()
return id ? `https://player.vimeo.com/video/${id}` : ''
}
} catch {
return ''
}
return ''
}
function ArticleVideoSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const caption = localizedFromRecord(payload, 'caption', resolvedLang)
const videoUrl = typeof payload.videoUrl === 'string' ? payload.videoUrl : ''
const posterUrl = typeof payload.posterUrl === 'string' ? payload.posterUrl : undefined
const embedUrl = getVideoEmbedUrl(videoUrl)
const aspectClass = payload.aspectRatio === '4:3' ? 'aspect-[4/3]' : payload.aspectRatio === '1:1' ? 'aspect-square' : 'aspect-video'
if (!videoUrl) return null
return (
<section className="bg-surface-page ui-section-band">
<div className="mx-auto max-w-6xl">
{title && <h2 className="mb-8 text-3xl font-semibold text-text-main md:text-4xl">{title}</h2>}
<div className={`${aspectClass} overflow-hidden rounded-lg bg-text-main`}>
{embedUrl ? (
<iframe
src={embedUrl}
title={title || caption || 'Article video'}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
) : (
<video src={videoUrl} poster={posterUrl} controls preload="metadata" className="h-full w-full object-contain" />
)}
</div>
{caption && <p className="mt-3 text-sm leading-6 text-muted-text">{caption}</p>}
</div>
</section>
)
}
function ArticleReferencesSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const items = Array.isArray(payload.items) ? payload.items : []
if (items.length === 0) return null
return (
<section className="bg-surface-page ui-section-band">
<div className="mx-auto max-w-4xl">
<h2 className="mb-6 text-2xl font-semibold text-text-main">{title || 'References'}</h2>
<ol className="list-decimal space-y-4 pl-5 text-muted-text">
{items.map((item, index) => {
const record = toRecord(item)
const itemTitle = localizedFromRecord(record, 'title', resolvedLang)
const note = localizedFromRecord(record, 'note', resolvedLang)
const url = typeof record.url === 'string' ? record.url : ''
return (
<li key={index} className="pl-2">
{url ? (
<a href={url} target="_blank" rel="noreferrer" className="font-medium text-text-main underline decoration-line-primary underline-offset-4">
{itemTitle}
</a>
) : <span className="font-medium text-text-main">{itemTitle}</span>}
{note && <p className="mt-1 text-sm leading-6">{note}</p>}
</li>
)
})}
</ol>
</div>
</section>
)
}
function NewsletterSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
@@ -1184,12 +1373,22 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
<TextSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'ARTICLE_HEADER':
return <ArticleHeaderSection key={section.id} payload={payload} lang={lang} />
case 'ARTICLE_MEDIA':
return (
<ScrollReveal key={section.id}>
<ArticleMediaSection payload={payload} lang={lang} />
</ScrollReveal>
)
case 'ARTICLE_GALLERY':
return <ArticleGallerySection key={section.id} payload={payload} lang={lang} />
case 'ARTICLE_FACTS':
return <ArticleFactsSection key={section.id} payload={payload} lang={lang} />
case 'ARTICLE_VIDEO':
return <ArticleVideoSection key={section.id} payload={payload} lang={lang} />
case 'ARTICLE_REFERENCES':
return <ArticleReferencesSection key={section.id} payload={payload} lang={lang} />
case 'SCENT_NOTES_BREAKDOWN':
return (
<ScrollReveal key={section.id}>