x
This commit is contained in:
1 parent
e509e3f0a2
commit
0d4df55d93
7 files changed
+526
-3
No files matched your search
@@ -103,6 +103,15 @@ Use one string only for URLs, slugs, IDs, and booleans/numbers as appropriate.
|
||||
- `width` accepts `narrow`, `standard`, or `wide`
|
||||
- `background` accepts `default` or `muted`
|
||||
|
||||
### Article composition blocks
|
||||
|
||||
- `ARTICLE_HEADER`: article title, subtitle, category, author, date, and optional cover image
|
||||
- `ARTICLE_GALLERY`: 2–8 images with required localized alt text; layouts are `grid` or `featured`
|
||||
- `ARTICLE_FACTS`: 1–8 concise facts or key takeaways
|
||||
- `ARTICLE_VIDEO`: HTTPS YouTube, Vimeo, or direct video URL; embed HTML is not accepted
|
||||
- `ARTICLE_REFERENCES`: 1–20 references with optional verified HTTPS URLs and notes
|
||||
- Reuse `QUOTE_CALLOUT`, `ACCORDION`, and `PRODUCT_CAROUSEL` instead of creating duplicate article-only variants
|
||||
|
||||
### `SCENT_NOTES_BREAKDOWN`
|
||||
|
||||
- `title` and `description` are localized
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
+61
-1
@@ -464,6 +464,60 @@ export const ArticleMediaSectionPayloadSchema = z.object({
|
||||
});
|
||||
export type ArticleMediaSectionPayload = z.infer<typeof ArticleMediaSectionPayloadSchema>;
|
||||
|
||||
export const ArticleHeaderSectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema,
|
||||
subtitle: LocalizedStringSchema.optional(),
|
||||
category: LocalizedStringSchema.optional(),
|
||||
author: LocalizedStringSchema.optional(),
|
||||
publishDate: z.string().optional(),
|
||||
imageUrl: z.string().min(1).optional(),
|
||||
imageAlt: LocalizedStringSchema.optional(),
|
||||
}).strict().superRefine((payload, ctx) => {
|
||||
if (payload.imageUrl && !payload.imageAlt) {
|
||||
ctx.addIssue({ code: 'custom', path: ['imageAlt'], message: 'imageAlt is required when imageUrl is provided' });
|
||||
}
|
||||
});
|
||||
export type ArticleHeaderSectionPayload = z.infer<typeof ArticleHeaderSectionPayloadSchema>;
|
||||
|
||||
export const ArticleGallerySectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema.optional(),
|
||||
layout: z.enum(['grid', 'featured']).optional().default('grid'),
|
||||
images: z.array(z.object({
|
||||
imageUrl: z.string().min(1),
|
||||
alt: LocalizedStringSchema,
|
||||
caption: LocalizedStringSchema.optional(),
|
||||
}).strict()).min(2).max(8),
|
||||
}).strict();
|
||||
export type ArticleGallerySectionPayload = z.infer<typeof ArticleGallerySectionPayloadSchema>;
|
||||
|
||||
export const ArticleFactsSectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema.optional(),
|
||||
items: z.array(z.object({
|
||||
title: LocalizedStringSchema,
|
||||
description: LocalizedStringSchema.optional(),
|
||||
}).strict()).min(1).max(8),
|
||||
}).strict();
|
||||
export type ArticleFactsSectionPayload = z.infer<typeof ArticleFactsSectionPayloadSchema>;
|
||||
|
||||
export const ArticleVideoSectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema.optional(),
|
||||
videoUrl: z.string().url().startsWith('https://'),
|
||||
caption: LocalizedStringSchema.optional(),
|
||||
posterUrl: z.string().min(1).optional(),
|
||||
aspectRatio: z.enum(['16:9', '4:3', '1:1']).optional().default('16:9'),
|
||||
}).strict();
|
||||
export type ArticleVideoSectionPayload = z.infer<typeof ArticleVideoSectionPayloadSchema>;
|
||||
|
||||
export const ArticleReferencesSectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema.optional(),
|
||||
items: z.array(z.object({
|
||||
title: LocalizedStringSchema,
|
||||
url: z.string().url().startsWith('https://').optional(),
|
||||
note: LocalizedStringSchema.optional(),
|
||||
}).strict()).min(1).max(20),
|
||||
}).strict();
|
||||
export type ArticleReferencesSectionPayload = z.infer<typeof ArticleReferencesSectionPayloadSchema>;
|
||||
|
||||
export const ScentNotesBreakdownSectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema.optional(),
|
||||
description: LocalizedStringSchema.optional(),
|
||||
@@ -642,7 +696,8 @@ export type CustomSectionPayload = z.infer<typeof CustomSectionPayloadSchema>;
|
||||
export const PAGE_SECTION_TYPES = [
|
||||
'HERO', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE',
|
||||
'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER',
|
||||
'HERO_ARTICLE', 'RICH_TEXT', 'ARTICLE_MEDIA', 'SCENT_NOTES_BREAKDOWN', 'QUOTE_CALLOUT',
|
||||
'HERO_ARTICLE', 'RICH_TEXT', 'ARTICLE_HEADER', 'ARTICLE_MEDIA', 'ARTICLE_GALLERY',
|
||||
'ARTICLE_FACTS', 'ARTICLE_VIDEO', 'ARTICLE_REFERENCES', 'SCENT_NOTES_BREAKDOWN', 'QUOTE_CALLOUT',
|
||||
'PRODUCT_CAROUSEL', 'ACCORDION', 'CONTACT_INFO', 'CONTACT_FORM',
|
||||
'HERO_V2', 'USP_BAR', 'PRODUCT_MAIN', 'PRODUCT_DETAILS', 'PRODUCT_SPECS',
|
||||
'PRODUCT_REVIEWS', 'RELATED_PRODUCTS', 'CUSTOM'
|
||||
@@ -661,7 +716,12 @@ export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
|
||||
BANNER: BannerSectionPayloadSchema,
|
||||
HERO_ARTICLE: HeroArticleSectionPayloadSchema,
|
||||
RICH_TEXT: RichTextSectionPayloadSchema,
|
||||
ARTICLE_HEADER: ArticleHeaderSectionPayloadSchema,
|
||||
ARTICLE_MEDIA: ArticleMediaSectionPayloadSchema,
|
||||
ARTICLE_GALLERY: ArticleGallerySectionPayloadSchema,
|
||||
ARTICLE_FACTS: ArticleFactsSectionPayloadSchema,
|
||||
ARTICLE_VIDEO: ArticleVideoSectionPayloadSchema,
|
||||
ARTICLE_REFERENCES: ArticleReferencesSectionPayloadSchema,
|
||||
SCENT_NOTES_BREAKDOWN: ScentNotesBreakdownSectionPayloadSchema,
|
||||
QUOTE_CALLOUT: QuoteCalloutSectionPayloadSchema,
|
||||
PRODUCT_CAROUSEL: ProductCarouselSectionPayloadSchema,
|
||||
|
||||
Reference in new issue
Block a user