This commit is contained in:
Your Name committed 2026-07-24 20:08:21 -04:00
1 parent ac08f4490b
commit f8da2cceaf
8 files changed
+427 -2

No files matched your search

@@ -196,3 +196,87 @@ export function ArticleReferencesForm({ editingSection, setEditingSection, editi
</div>
);
}
export function ArticleTableForm({ editingSection, setEditingSection, editingLang, getLocalizedValue, updateLocalizedValue, updatePayloadValue }: BaseProps) {
const payload = editingSection.payload || {};
const columns = Array.isArray(payload.columns) ? payload.columns as Record<string, string>[] : [];
const rows = Array.isArray(payload.rows) ? payload.rows as Record<string, unknown>[] : [];
const updateColumn = (index: number, value: string) => {
const next = [...columns];
next[index] = { ...(next[index] || {}), [editingLang]: value };
updatePayloadValue('columns', next);
};
const setColumnCount = (count: number) => {
const nextColumns = Array.from({ length: count }, (_, index) => columns[index] || { [editingLang]: `Column ${index + 1}` });
const nextRows = rows.map((row) => ({
...row,
cells: Array.from({ length: count }, (_, index) => (Array.isArray(row.cells) ? row.cells[index] : undefined) || { [editingLang]: '' }),
}));
setEditingSection((previous) => previous ? { ...previous, payload: { ...(previous.payload || {}), columns: nextColumns, rows: nextRows } } : null);
};
return (
<div className="space-y-6">
<label className="block space-y-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Title</span><input value={getLocalizedValue(payload, 'title')} onChange={(event) => updateLocalizedValue('title', event.target.value)} className="admin-input w-full" /></label>
<label className="block space-y-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Columns</span><input type="number" min={2} max={6} value={columns.length || 2} onChange={(event) => setColumnCount(Math.max(2, Math.min(6, Number(event.target.value))))} className="admin-input w-full" /></label>
<div className="grid gap-3 md:grid-cols-2">
{columns.map((column, index) => <input key={index} required value={column[editingLang] || ''} onChange={(event) => updateColumn(index, event.target.value)} className="admin-input w-full" placeholder={`Column ${index + 1}`} />)}
</div>
{rows.map((row, rowIndex) => {
const cells = Array.isArray(row.cells) ? row.cells as Record<string, string>[] : [];
return (
<div key={rowIndex} className="space-y-3 border-t border-white/10 pt-5">
<input required value={localizedValue(row, 'label', editingLang)} onChange={(event) => updateItemLocalized(setEditingSection, 'rows', rowIndex, 'label', editingLang, event.target.value)} className="admin-input w-full" placeholder="Row label" />
<div className="grid gap-3 md:grid-cols-2">
{columns.map((_, cellIndex) => (
<input key={cellIndex} required value={cells[cellIndex]?.[editingLang] || ''} onChange={(event) => updateArrayItem(setEditingSection, 'rows', rowIndex, (record) => {
const nextCells = Array.isArray(record.cells) ? [...record.cells] as Record<string, string>[] : [];
nextCells[cellIndex] = { ...(nextCells[cellIndex] || {}), [editingLang]: event.target.value };
return { ...record, cells: nextCells };
})} className="admin-input w-full" placeholder={`Cell ${cellIndex + 1}`} />
))}
</div>
<button type="button" onClick={() => removeArrayItem(setEditingSection, 'rows', rowIndex)} className="inline-flex items-center gap-2 text-sm text-red-400"><Trash2 size={16} /> Remove row</button>
</div>
);
})}
<button type="button" disabled={rows.length >= 20} onClick={() => updatePayloadValue('rows', [...rows, { label: { [editingLang]: '' }, cells: columns.map(() => ({ [editingLang]: '' })) }])} className="inline-flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2 text-sm text-slate-200 disabled:opacity-40"><Plus size={16} /> Add row</button>
<label className="block space-y-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Note</span><input value={getLocalizedValue(payload, 'note')} onChange={(event) => updateLocalizedValue('note', event.target.value)} className="admin-input w-full" /></label>
</div>
);
}
export function ArticleDownloadForm({ editingSection, setEditingSection, editingLang, getLocalizedValue, updateLocalizedValue, updatePayloadValue }: BaseProps) {
const payload = editingSection.payload || {};
const files = Array.isArray(payload.files) ? payload.files as Record<string, unknown>[] : [];
return (
<div className="space-y-6">
<label className="block space-y-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Title</span><input value={getLocalizedValue(payload, 'title')} onChange={(event) => updateLocalizedValue('title', event.target.value)} className="admin-input w-full" /></label>
<label className="block space-y-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Description</span><textarea rows={3} value={getLocalizedValue(payload, 'description')} onChange={(event) => updateLocalizedValue('description', event.target.value)} className="admin-input w-full" /></label>
{files.map((file, index) => (
<div key={index} className="grid gap-3 border-t border-white/10 pt-5 md:grid-cols-2">
<input required value={localizedValue(file, 'title', editingLang)} onChange={(event) => updateItemLocalized(setEditingSection, 'files', index, 'title', editingLang, event.target.value)} className="admin-input w-full" placeholder="Document title" />
<input type="url" required value={typeof file.url === 'string' ? file.url : ''} onChange={(event) => updateArrayItem(setEditingSection, 'files', index, (record) => ({ ...record, url: event.target.value }))} className="admin-input w-full" placeholder="https://..." />
<input value={typeof file.fileType === 'string' ? file.fileType : ''} onChange={(event) => updateArrayItem(setEditingSection, 'files', index, (record) => ({ ...record, fileType: event.target.value }))} className="admin-input w-full" placeholder="PDF" />
<input value={typeof file.fileSize === 'string' ? file.fileSize : ''} onChange={(event) => updateArrayItem(setEditingSection, 'files', index, (record) => ({ ...record, fileSize: event.target.value }))} className="admin-input w-full" placeholder="2.4 MB" />
<button type="button" onClick={() => removeArrayItem(setEditingSection, 'files', index)} className="inline-flex items-center gap-2 text-sm text-red-400"><Trash2 size={16} /> Remove file</button>
</div>
))}
<button type="button" disabled={files.length >= 10} onClick={() => updatePayloadValue('files', [...files, { title: { [editingLang]: '' }, url: '' }])} className="inline-flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2 text-sm text-slate-200 disabled:opacity-40"><Plus size={16} /> Add file</button>
</div>
);
}
export function ArticleCtaForm({ editingSection, getLocalizedValue, updateLocalizedValue, updatePayloadValue }: BaseProps) {
const payload = editingSection.payload || {};
return (
<div className="grid gap-6 md:grid-cols-2">
<label className="space-y-2 md:col-span-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Title</span><input required value={getLocalizedValue(payload, 'title')} onChange={(event) => updateLocalizedValue('title', event.target.value)} className="admin-input w-full" /></label>
<label className="space-y-2 md:col-span-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Description</span><textarea rows={3} value={getLocalizedValue(payload, 'description')} onChange={(event) => updateLocalizedValue('description', event.target.value)} className="admin-input w-full" /></label>
<label className="space-y-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Primary text</span><input required value={getLocalizedValue(payload, 'primaryText')} onChange={(event) => updateLocalizedValue('primaryText', event.target.value)} className="admin-input w-full" /></label>
<label className="space-y-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Primary link</span><input required value={typeof payload.primaryLink === 'string' ? payload.primaryLink : ''} onChange={(event) => updatePayloadValue('primaryLink', event.target.value)} className="admin-input w-full" placeholder="/contact" /></label>
<label className="space-y-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Secondary text</span><input value={getLocalizedValue(payload, 'secondaryText')} onChange={(event) => updateLocalizedValue('secondaryText', event.target.value)} className="admin-input w-full" /></label>
<label className="space-y-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Secondary link</span><input value={typeof payload.secondaryLink === 'string' ? payload.secondaryLink : ''} onChange={(event) => updatePayloadValue('secondaryLink', event.target.value)} className="admin-input w-full" /></label>
<label className="space-y-2 md:col-span-2"><span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Alignment</span><select value={typeof payload.alignment === 'string' ? payload.alignment : 'center'} onChange={(event) => updatePayloadValue('alignment', event.target.value)} className="admin-input w-full"><option value="left">Left</option><option value="center">Center</option></select></label>
</div>
);
}
+52
View File
@@ -36,6 +36,9 @@ import {
ArticleGalleryForm,
ArticleHeaderForm,
ArticleReferencesForm,
ArticleTableForm,
ArticleDownloadForm,
ArticleCtaForm,
ArticleVideoForm,
} from '../components/page-builder/ArticleContentForms';
import ScentNotesBreakdownForm from '../components/page-builder/ScentNotesBreakdownForm';
@@ -678,6 +681,19 @@ export default function PageBuilderPage() {
case 'ARTICLE_REFERENCES':
defaultPayload = { title: 'References', items: [{ title: 'Source title' }] };
break;
case 'ARTICLE_TABLE':
defaultPayload = {
title: 'Technical comparison',
columns: ['Property', 'Value'],
rows: [{ label: 'Example', cells: ['Example property', 'Example value'] }],
};
break;
case 'ARTICLE_DOWNLOAD':
defaultPayload = { title: 'Downloads', files: [{ title: 'Document', url: 'https://example.com/document.pdf', fileType: 'PDF' }] };
break;
case 'ARTICLE_CTA':
defaultPayload = { title: 'Need more information?', primaryText: 'Contact us', primaryLink: '/contact', alignment: 'center' };
break;
case 'SCENT_NOTES_BREAKDOWN':
defaultPayload = {
title: 'Scent Notes',
@@ -1126,6 +1142,9 @@ export default function PageBuilderPage() {
<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="ARTICLE_TABLE">ARTICLE TABLE (Technical comparison)</option>
<option value="ARTICLE_DOWNLOAD">ARTICLE DOWNLOAD (Documents)</option>
<option value="ARTICLE_CTA">ARTICLE CTA (Next action)</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>
@@ -1362,6 +1381,39 @@ export default function PageBuilderPage() {
/>
)}
{editingSection.type?.toUpperCase() === 'ARTICLE_TABLE' && (
<ArticleTableForm
editingSection={editingSection}
setEditingSection={setEditingSection}
editingLang={editingLang}
getLocalizedValue={getLocalizedValue}
updateLocalizedValue={updateLocalizedValue}
updatePayloadValue={updatePayloadValue}
/>
)}
{editingSection.type?.toUpperCase() === 'ARTICLE_DOWNLOAD' && (
<ArticleDownloadForm
editingSection={editingSection}
setEditingSection={setEditingSection}
editingLang={editingLang}
getLocalizedValue={getLocalizedValue}
updateLocalizedValue={updateLocalizedValue}
updatePayloadValue={updatePayloadValue}
/>
)}
{editingSection.type?.toUpperCase() === 'ARTICLE_CTA' && (
<ArticleCtaForm
editingSection={editingSection}
setEditingSection={setEditingSection}
editingLang={editingLang}
getLocalizedValue={getLocalizedValue}
updateLocalizedValue={updateLocalizedValue}
updatePayloadValue={updatePayloadValue}
/>
)}
{editingSection.type?.toUpperCase() === 'SCENT_NOTES_BREAKDOWN' && (
<ScentNotesBreakdownForm
editingSection={editingSection}
+12
View File
@@ -19,6 +19,9 @@ import {
ArticleFactsSectionPayloadSchema,
ArticleVideoSectionPayloadSchema,
ArticleReferencesSectionPayloadSchema,
ArticleTableSectionPayloadSchema,
ArticleDownloadSectionPayloadSchema,
ArticleCtaSectionPayloadSchema,
ScentNotesBreakdownSectionPayloadSchema,
QuoteCalloutSectionPayloadSchema,
ProductCarouselSectionPayloadSchema,
@@ -795,6 +798,15 @@ export function validateSectionPayload(
case "ARTICLE_REFERENCES":
schema = ArticleReferencesSectionPayloadSchema;
break;
case "ARTICLE_TABLE":
schema = ArticleTableSectionPayloadSchema;
break;
case "ARTICLE_DOWNLOAD":
schema = ArticleDownloadSectionPayloadSchema;
break;
case "ARTICLE_CTA":
schema = ArticleCtaSectionPayloadSchema;
break;
case "SCENT_NOTES_BREAKDOWN":
schema = ScentNotesBreakdownSectionPayloadSchema;
break;
+25
View File
@@ -23,6 +23,9 @@ const websiteBlockTypes = [
"ARTICLE_FACTS",
"ARTICLE_VIDEO",
"ARTICLE_REFERENCES",
"ARTICLE_TABLE",
"ARTICLE_DOWNLOAD",
"ARTICLE_CTA",
"SCENT_NOTES_BREAKDOWN",
"QUOTE_CALLOUT",
"PRODUCT_CAROUSEL",
@@ -105,6 +108,28 @@ describe("Page Builder block contracts", () => {
expect(result.validationIssues.some((issue) => issue.path === "videoUrl")).toBe(true);
});
test("ARTICLE_TABLE requires every row to match the column count", () => {
const result = validatePayloadAgainstMetadata("ARTICLE_TABLE", {
columns: [{ th: "A" }, { th: "B" }],
rows: [{ label: { th: "Row" }, cells: [{ th: "Only one" }] }],
});
expect(result.valid).toBe(false);
expect(result.validationIssues.some((issue) => issue.path === "rows.0.cells")).toBe(true);
});
test("ARTICLE_CTA requires secondaryLink with secondaryText", () => {
const result = validatePayloadAgainstMetadata("ARTICLE_CTA", {
title: { th: "ติดต่อเรา" },
primaryText: { th: "สอบถาม" },
primaryLink: "/contact",
secondaryText: { th: "สินค้า" },
});
expect(result.valid).toBe(false);
expect(result.validationIssues.some((issue) => issue.path === "secondaryLink")).toBe(true);
});
test("create_page_with_section rejects invalid payloads without calling backend", async () => {
let postCalls = 0;
const api = {
+64
View File
@@ -24,6 +24,9 @@ const WEBSITE_PAGE_SECTION_TYPE_LIST = [
"ARTICLE_FACTS",
"ARTICLE_VIDEO",
"ARTICLE_REFERENCES",
"ARTICLE_TABLE",
"ARTICLE_DOWNLOAD",
"ARTICLE_CTA",
"SCENT_NOTES_BREAKDOWN",
"QUOTE_CALLOUT",
"PRODUCT_CAROUSEL",
@@ -418,6 +421,55 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
items: [{ title: { th: "เอกสารข้อมูลทางเทคนิค", en: "Technical documentation" }, note: { th: "ข้อมูลจากฝ่ายวิศวกรรม", en: "Engineering source material" } }],
},
},
ARTICLE_TABLE: {
type: "ARTICLE_TABLE",
requiredFields: ["columns", "rows", "rows[].label", "rows[].cells"],
optionalFields: ["title", "note"],
renderedFields: ["title", "columns", "rows", "note"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["Use 2 to 6 columns and 1 to 20 rows. Every row must contain exactly one cell per column."],
minimalPayload: {
columns: [{ th: "หัวข้อ", en: "Item" }, { th: "รายละเอียด", en: "Details" }],
rows: [{ label: localizedObjectExample, cells: [localizedObjectExample, localizedObjectExample] }],
},
recommendedPayload: {
title: { th: "ข้อมูลเปรียบเทียบ", en: "Comparison" },
columns: [{ th: "คุณสมบัติ", en: "Property" }, { th: "Fine Blanking", en: "Fine blanking" }],
rows: [{ label: { th: "ผิวตัด", en: "Cut surface" }, cells: [{ th: "ผิวตัด", en: "Cut surface" }, { th: "เรียบและสม่ำเสมอ", en: "Smooth and consistent" }] }],
},
},
ARTICLE_DOWNLOAD: {
type: "ARTICLE_DOWNLOAD",
requiredFields: ["files", "files[].title", "files[].url"],
optionalFields: ["title", "description", "files[].fileType", "files[].fileSize"],
renderedFields: ["title", "description", "files"],
storedOnlyFields: [],
discoveryToolsNeeded: ["Use verified HTTPS document URLs from the CMS media library."],
warnings: ["Provide 1 to 10 files. File URLs must use HTTPS."],
minimalPayload: { files: [{ title: localizedObjectExample, url: "https://example.com/document.pdf" }] },
recommendedPayload: {
title: { th: "ดาวน์โหลดเอกสาร", en: "Downloads" },
files: [{ title: { th: "ข้อมูลผลิตภัณฑ์", en: "Product specification" }, url: "https://example.com/spec.pdf", fileType: "PDF" }],
},
},
ARTICLE_CTA: {
type: "ARTICLE_CTA",
requiredFields: ["title", "primaryText", "primaryLink"],
optionalFields: ["description", "secondaryText", "secondaryLink", "alignment"],
renderedFields: ["title", "description", "primaryText", "primaryLink", "secondaryText", "secondaryLink", "alignment"],
storedOnlyFields: [],
discoveryToolsNeeded: [],
warnings: ["Links must be website-relative paths or HTTPS URLs. secondaryLink is required with secondaryText."],
minimalPayload: { title: localizedObjectExample, primaryText: localizedObjectExample, primaryLink: "/contact" },
recommendedPayload: {
title: { th: "ต้องการข้อมูลเพิ่มเติม?", en: "Need more information?" },
description: { th: "ติดต่อทีมงานเพื่อสอบถามรายละเอียดผลิตภัณฑ์", en: "Contact our team for product details." },
primaryText: { th: "ติดต่อเรา", en: "Contact us" },
primaryLink: "/contact",
alignment: "center",
},
},
SCENT_NOTES_BREAKDOWN: {
type: "SCENT_NOTES_BREAKDOWN",
requiredFields: [],
@@ -667,6 +719,18 @@ const ADD_PAGE_SECTION_GUIDANCE: Record<string, { required: string[]; example: s
required: ["items", "items[].title"],
example: `{ "items": [{ "title": ${localizedStringExample}, "url": "https://example.com/source" }] }`
},
ARTICLE_TABLE: {
required: ["columns", "rows", "rows[].label", "rows[].cells"],
example: `{ "columns": [${localizedStringExample}, ${localizedStringExample}], "rows": [{ "label": ${localizedStringExample}, "cells": [${localizedStringExample}, ${localizedStringExample}] }] }`
},
ARTICLE_DOWNLOAD: {
required: ["files", "files[].title", "files[].url"],
example: `{ "files": [{ "title": ${localizedStringExample}, "url": "https://example.com/document.pdf", "fileType": "PDF" }] }`
},
ARTICLE_CTA: {
required: ["title", "primaryText", "primaryLink"],
example: `{ "title": ${localizedStringExample}, "primaryText": ${localizedStringExample}, "primaryLink": "/contact", "alignment": "center" }`
},
SCENT_NOTES_BREAKDOWN: {
required: [],
example: `{ "title": ${localizedStringExample}, "topNotes": ${localizedStringListExample} }`
@@ -2,7 +2,7 @@
import Image from 'next/image'
import Link from '../LocalizedLink'
import { Clock, Mail, MapPin, MessageCircle, Send, ChevronLeft, ChevronRight, Leaf, Droplet, Sparkles, Gift, CheckCircle, ShieldCheck, Heart } from 'lucide-react'
import { Clock, Mail, MapPin, MessageCircle, Send, ChevronLeft, ChevronRight, Leaf, Droplet, Sparkles, Gift, CheckCircle, ShieldCheck, Heart, Download, FileText } from 'lucide-react'
import { useState, useRef } from 'react'
import type { Category, PageSection, Product } from '@homaroma/types'
import { getCategoryImage } from '@homaroma/types'
@@ -736,6 +736,128 @@ function ArticleReferencesSection({ payload, lang }: { payload: Record<string, u
)
}
function ArticleTableSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const note = localizedFromRecord(payload, 'note', resolvedLang)
const columns = Array.isArray(payload.columns) ? payload.columns : []
const rows = Array.isArray(payload.rows) ? payload.rows : []
if (columns.length < 2 || rows.length === 0) 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="overflow-x-auto rounded-lg border border-line-primary/20">
<table className="w-full min-w-[42rem] border-collapse text-left">
<thead className="bg-surface-card">
<tr>
<th className="px-5 py-4 text-sm font-semibold text-text-main" scope="col" />
{columns.map((column, index) => (
<th key={index} className="px-5 py-4 text-sm font-semibold text-text-main" scope="col">
{localizedValue(column, resolvedLang)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => {
const record = toRecord(row)
const cells = Array.isArray(record.cells) ? record.cells : []
return (
<tr key={rowIndex} className="border-t border-line-primary/20">
<th className="px-5 py-4 font-semibold text-text-main" scope="row">
{localizedFromRecord(record, 'label', resolvedLang)}
</th>
{cells.map((cell, cellIndex) => (
<td key={cellIndex} className="px-5 py-4 leading-7 text-muted-text">
{localizedValue(cell, resolvedLang)}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
{note && <p className="mt-4 text-sm leading-6 text-muted-text">{note}</p>}
</div>
</section>
)
}
function ArticleDownloadSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang)
const files = Array.isArray(payload.files) ? payload.files : []
if (files.length === 0) return null
return (
<section className="bg-surface-card ui-section-band">
<div className="mx-auto max-w-5xl">
{title && <h2 className="text-3xl font-semibold text-text-main md:text-4xl">{title}</h2>}
{description && <p className="mt-4 max-w-3xl leading-7 text-muted-text">{description}</p>}
<div className="mt-8 divide-y divide-line-primary/20 border-y border-line-primary/20">
{files.map((file, index) => {
const record = toRecord(file)
const fileTitle = localizedFromRecord(record, 'title', resolvedLang)
const url = typeof record.url === 'string' ? record.url : ''
const meta = [record.fileType, record.fileSize].filter((value) => typeof value === 'string' && value).join(' · ')
if (!url) return null
return (
<a key={index} href={url} target="_blank" rel="noreferrer" className="flex items-center gap-4 py-5 text-text-main transition-opacity hover:opacity-70">
<FileText className="h-6 w-6 shrink-0 text-brand-primary" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block font-semibold">{fileTitle}</span>
{meta && <span className="mt-1 block text-sm text-muted-text">{meta}</span>}
</span>
<Download className="h-5 w-5 shrink-0" aria-hidden="true" />
</a>
)
})}
</div>
</div>
</section>
)
}
function ArticleCtaSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang)
const primaryText = localizedFromRecord(payload, 'primaryText', resolvedLang)
const secondaryText = localizedFromRecord(payload, 'secondaryText', resolvedLang)
const primaryLink = typeof payload.primaryLink === 'string' ? payload.primaryLink : ''
const secondaryLink = typeof payload.secondaryLink === 'string' ? payload.secondaryLink : ''
const centered = payload.alignment !== 'left'
if (!title || !primaryText || !primaryLink) return null
const primaryClass = "inline-flex min-h-12 items-center justify-center rounded-lg bg-brand-primary px-6 py-3 font-semibold text-surface-card transition-opacity hover:opacity-85"
const secondaryClass = "inline-flex min-h-12 items-center justify-center rounded-lg border border-line-primary px-6 py-3 font-semibold text-text-main transition-colors hover:bg-surface-page"
return (
<section className="bg-surface-card ui-section-band">
<div className={`mx-auto max-w-5xl ${centered ? 'text-center' : 'text-left'}`}>
<h2 className="text-3xl font-semibold text-text-main md:text-4xl">{title}</h2>
{description && <p className={`mt-4 max-w-2xl leading-7 text-muted-text ${centered ? 'mx-auto' : ''}`}>{description}</p>}
<div className={`mt-8 flex flex-wrap gap-3 ${centered ? 'justify-center' : 'justify-start'}`}>
{primaryLink.startsWith('/') ? <Link href={primaryLink} className={primaryClass}>{primaryText}</Link> : <a href={primaryLink} className={primaryClass}>{primaryText}</a>}
{secondaryText && secondaryLink && (
secondaryLink.startsWith('/') ? <Link href={secondaryLink} className={secondaryClass}>{secondaryText}</Link> : <a href={secondaryLink} className={secondaryClass}>{secondaryText}</a>
)}
</div>
</div>
</section>
)
}
function NewsletterSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
@@ -1389,6 +1511,12 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
return <ArticleVideoSection key={section.id} payload={payload} lang={lang} />
case 'ARTICLE_REFERENCES':
return <ArticleReferencesSection key={section.id} payload={payload} lang={lang} />
case 'ARTICLE_TABLE':
return <ArticleTableSection key={section.id} payload={payload} lang={lang} />
case 'ARTICLE_DOWNLOAD':
return <ArticleDownloadSection key={section.id} payload={payload} lang={lang} />
case 'ARTICLE_CTA':
return <ArticleCtaSection key={section.id} payload={payload} lang={lang} />
case 'SCENT_NOTES_BREAKDOWN':
return (
<ScrollReveal key={section.id}>