x
This commit is contained in:
1 parent
ac08f4490b
commit
f8da2cceaf
8 files changed
+427
-2
No files matched your search
@@ -110,6 +110,9 @@ Use one string only for URLs, slugs, IDs, and booleans/numbers as appropriate.
|
||||
- `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
|
||||
- `ARTICLE_TABLE`: responsive technical comparison with 2–6 columns
|
||||
- `ARTICLE_DOWNLOAD`: 1–10 verified HTTPS documents
|
||||
- `ARTICLE_CTA`: one primary action and an optional secondary action
|
||||
- Reuse `QUOTE_CALLOUT`, `ACCORDION`, and `PRODUCT_CAROUSEL` instead of creating duplicate article-only variants
|
||||
|
||||
### `SCENT_NOTES_BREAKDOWN`
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
+58
-1
@@ -518,6 +518,59 @@ export const ArticleReferencesSectionPayloadSchema = z.object({
|
||||
}).strict();
|
||||
export type ArticleReferencesSectionPayload = z.infer<typeof ArticleReferencesSectionPayloadSchema>;
|
||||
|
||||
export const ArticleTableSectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema.optional(),
|
||||
columns: z.array(LocalizedStringSchema).min(2).max(6),
|
||||
rows: z.array(z.object({
|
||||
label: LocalizedStringSchema,
|
||||
cells: z.array(LocalizedStringSchema).min(2).max(6),
|
||||
}).strict()).min(1).max(20),
|
||||
note: LocalizedStringSchema.optional(),
|
||||
}).strict().superRefine((payload, ctx) => {
|
||||
payload.rows.forEach((row, index) => {
|
||||
if (row.cells.length !== payload.columns.length) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['rows', index, 'cells'],
|
||||
message: `Row must contain exactly ${payload.columns.length} cells`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
export type ArticleTableSectionPayload = z.infer<typeof ArticleTableSectionPayloadSchema>;
|
||||
|
||||
export const ArticleDownloadSectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema.optional(),
|
||||
description: LocalizedStringSchema.optional(),
|
||||
files: z.array(z.object({
|
||||
title: LocalizedStringSchema,
|
||||
url: z.string().url().startsWith('https://'),
|
||||
fileType: z.string().optional(),
|
||||
fileSize: z.string().optional(),
|
||||
}).strict()).min(1).max(10),
|
||||
}).strict();
|
||||
export type ArticleDownloadSectionPayload = z.infer<typeof ArticleDownloadSectionPayloadSchema>;
|
||||
|
||||
const ArticleLinkSchema = z.string().refine(
|
||||
(value) => value.startsWith('/') || value.startsWith('https://'),
|
||||
'Link must be a website-relative path or an HTTPS URL',
|
||||
);
|
||||
|
||||
export const ArticleCtaSectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema,
|
||||
description: LocalizedStringSchema.optional(),
|
||||
primaryText: LocalizedStringSchema,
|
||||
primaryLink: ArticleLinkSchema,
|
||||
secondaryText: LocalizedStringSchema.optional(),
|
||||
secondaryLink: ArticleLinkSchema.optional(),
|
||||
alignment: z.enum(['left', 'center']).optional().default('center'),
|
||||
}).strict().superRefine((payload, ctx) => {
|
||||
if (payload.secondaryText && !payload.secondaryLink) {
|
||||
ctx.addIssue({ code: 'custom', path: ['secondaryLink'], message: 'secondaryLink is required when secondaryText is provided' });
|
||||
}
|
||||
});
|
||||
export type ArticleCtaSectionPayload = z.infer<typeof ArticleCtaSectionPayloadSchema>;
|
||||
|
||||
export const ScentNotesBreakdownSectionPayloadSchema = z.object({
|
||||
title: LocalizedStringSchema.optional(),
|
||||
description: LocalizedStringSchema.optional(),
|
||||
@@ -697,7 +750,8 @@ export const PAGE_SECTION_TYPES = [
|
||||
'HERO', 'ANNOUNCEMENT_BAR', 'NEWSLETTER', 'EDITORIAL_TILE',
|
||||
'EDITORIAL_CAROUSEL', 'PRODUCTS', 'CATEGORIES', 'TEXT', 'BANNER',
|
||||
'HERO_ARTICLE', 'RICH_TEXT', 'ARTICLE_HEADER', 'ARTICLE_MEDIA', 'ARTICLE_GALLERY',
|
||||
'ARTICLE_FACTS', 'ARTICLE_VIDEO', 'ARTICLE_REFERENCES', 'SCENT_NOTES_BREAKDOWN', 'QUOTE_CALLOUT',
|
||||
'ARTICLE_FACTS', 'ARTICLE_VIDEO', 'ARTICLE_REFERENCES', 'ARTICLE_TABLE', 'ARTICLE_DOWNLOAD',
|
||||
'ARTICLE_CTA', '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'
|
||||
@@ -722,6 +776,9 @@ export const PAGE_SECTION_PAYLOAD_SCHEMAS: Record<SectionType, z.ZodType> = {
|
||||
ARTICLE_FACTS: ArticleFactsSectionPayloadSchema,
|
||||
ARTICLE_VIDEO: ArticleVideoSectionPayloadSchema,
|
||||
ARTICLE_REFERENCES: ArticleReferencesSectionPayloadSchema,
|
||||
ARTICLE_TABLE: ArticleTableSectionPayloadSchema,
|
||||
ARTICLE_DOWNLOAD: ArticleDownloadSectionPayloadSchema,
|
||||
ARTICLE_CTA: ArticleCtaSectionPayloadSchema,
|
||||
SCENT_NOTES_BREAKDOWN: ScentNotesBreakdownSectionPayloadSchema,
|
||||
QUOTE_CALLOUT: QuoteCalloutSectionPayloadSchema,
|
||||
PRODUCT_CAROUSEL: ProductCarouselSectionPayloadSchema,
|
||||
|
||||
Reference in new issue
Block a user