This commit is contained in:
Your Name committed 2026-06-27 23:19:51 -04:00
1 parent 1a13b3624a
commit 4454b860c1
9 files changed
+381 -105

No files matched your search

@@ -34,13 +34,85 @@ export default function AccordionForm({
/> />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Color</label>
<div className="flex gap-3">
<input
type="color"
value={editingSection.payload?.backgroundColor || '#FFFFFF'}
onChange={(e) => setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), backgroundColor: e.target.value }
})}
className="w-10 h-10 rounded cursor-pointer p-1 bg-night-900 border border-white/10"
/>
<input
type="text"
value={editingSection.payload?.backgroundColor || '#FFFFFF'}
onChange={(e) => setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), backgroundColor: e.target.value }
})}
className="admin-input flex-1 font-mono text-xs"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Text Color</label>
<div className="flex gap-3">
<input
type="color"
value={editingSection.payload?.textColor || '#000000'}
onChange={(e) => setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), textColor: e.target.value }
})}
className="w-10 h-10 rounded cursor-pointer p-1 bg-night-900 border border-white/10"
/>
<input
type="text"
value={editingSection.payload?.textColor || '#000000'}
onChange={(e) => setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), textColor: e.target.value }
})}
className="admin-input flex-1 font-mono text-xs"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Icon Color</label>
<div className="flex gap-3">
<input
type="color"
value={editingSection.payload?.iconColor || '#000000'}
onChange={(e) => setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), iconColor: e.target.value }
})}
className="w-10 h-10 rounded cursor-pointer p-1 bg-night-900 border border-white/10"
/>
<input
type="text"
value={editingSection.payload?.iconColor || '#000000'}
onChange={(e) => setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), iconColor: e.target.value }
})}
className="admin-input flex-1 font-mono text-xs"
/>
</div>
</div>
</div>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Categories & Questions</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Categories & Questions</label>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
const categories = [...(editingSection.payload?.categories || []), { icon: '✨', name: { th: 'หมวดหมู่ใหม่' }, questions: [] }]; const categories = [...(editingSection.payload?.categories || []), { id: crypto.randomUUID(), icon: '✨', name: { th: 'หมวดหมู่ใหม่' }, questions: [] }];
setEditingSection({ ...editingSection, payload: { ...(editingSection.payload || {}), categories } }); setEditingSection({ ...editingSection, payload: { ...(editingSection.payload || {}), categories } });
}} }}
className="text-xs text-cyan-glow hover:underline flex items-center gap-1" className="text-xs text-cyan-glow hover:underline flex items-center gap-1"
@@ -124,7 +196,7 @@ export default function AccordionForm({
type="button" type="button"
onClick={() => { onClick={() => {
const categories = [...editingSection.payload.categories]; const categories = [...editingSection.payload.categories];
categories[cIdx].questions = [...(categories[cIdx].questions || []), { q: { th: 'คำถามใหม่' }, a: { th: 'คำตอบใหม่' } }]; categories[cIdx].questions = [...(categories[cIdx].questions || []), { id: crypto.randomUUID(), q: { th: 'คำถามใหม่' }, a: { th: 'คำตอบใหม่' } }];
setEditingSection({ ...editingSection, payload: { ...(editingSection.payload || {}), categories } }); setEditingSection({ ...editingSection, payload: { ...(editingSection.payload || {}), categories } });
}} }}
className="text-[10px] text-cyan-glow/80 hover:text-cyan-glow uppercase tracking-widest font-bold flex items-center gap-1" className="text-[10px] text-cyan-glow/80 hover:text-cyan-glow uppercase tracking-widest font-bold flex items-center gap-1"
@@ -43,49 +43,45 @@ export default function EditorialCarouselForm({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Color</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Color</label>
<div className="flex gap-3"> <select
<input value={editingSection.payload?.backgroundColor || 'bg'}
type="color" onChange={(e) => setEditingSection({
value={editingSection.payload?.backgroundColor || '#0F172A'} ...editingSection,
onChange={(e) => setEditingSection({ payload: { ...(editingSection.payload || {}), backgroundColor: e.target.value }
...editingSection, })}
payload: { ...(editingSection.payload || {}), backgroundColor: e.target.value } className="admin-input w-full"
})} >
className="w-10 h-10 rounded cursor-pointer p-1 bg-night-900 border border-white/10" <option value="bg">Page Background (bg)</option>
/> <option value="black">Main Text (black)</option>
<input <option value="pink">Primary Brand (pink)</option>
type="text" <option value="yellow">Accent Brand (yellow)</option>
value={editingSection.payload?.backgroundColor || '#0F172A'} <option value="orange">Warm Brand (orange)</option>
onChange={(e) => setEditingSection({ <option value="red">Hover Red (red)</option>
...editingSection, <option value="cyan">Cool Brand (cyan)</option>
payload: { ...(editingSection.payload || {}), backgroundColor: e.target.value } <option value="green">Success Brand (green)</option>
})} <option value="purple">Soft Brand (purple)</option>
className="admin-input flex-1 font-mono text-xs" </select>
/>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Text Color</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Text Color</label>
<div className="flex gap-3"> <select
<input value={editingSection.payload?.textColor || 'black'}
type="color" onChange={(e) => setEditingSection({
value={editingSection.payload?.textColor || '#FFFFFF'} ...editingSection,
onChange={(e) => setEditingSection({ payload: { ...(editingSection.payload || {}), textColor: e.target.value }
...editingSection, })}
payload: { ...(editingSection.payload || {}), textColor: e.target.value } className="admin-input w-full"
})} >
className="w-10 h-10 rounded cursor-pointer p-1 bg-night-900 border border-white/10" <option value="black">Main Text (black)</option>
/> <option value="bg">Page Background (bg)</option>
<input <option value="pink">Primary Brand (pink)</option>
type="text" <option value="yellow">Accent Brand (yellow)</option>
value={editingSection.payload?.textColor || '#FFFFFF'} <option value="orange">Warm Brand (orange)</option>
onChange={(e) => setEditingSection({ <option value="red">Hover Red (red)</option>
...editingSection, <option value="cyan">Cool Brand (cyan)</option>
payload: { ...(editingSection.payload || {}), textColor: e.target.value } <option value="green">Success Brand (green)</option>
})} <option value="purple">Soft Brand (purple)</option>
className="admin-input flex-1 font-mono text-xs" </select>
/>
</div>
</div> </div>
</div> </div>
+133 -2
View File
@@ -1,3 +1,4 @@
import crypto from "crypto";
import type { AxiosInstance } from "axios"; import type { AxiosInstance } from "axios";
import { PAGE_SECTION_TYPES } from "@homaroma/types"; import { PAGE_SECTION_TYPES } from "@homaroma/types";
@@ -116,9 +117,12 @@ const SECTION_TOOL_CONFIGS = [
properties: { properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" }, sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` }, title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` },
backgroundColor: { type: "string", description: "Plain string representing a valid CSS color for the background" },
textColor: { type: "string", description: "Plain string representing a valid CSS color for text" },
iconColor: { type: "string", description: "Plain string representing a valid CSS color for icons" },
categories: { categories: {
type: "array", type: "array",
description: "Accordion categories", description: "Accordion categories. IMPORTANT: Providing this will completely overwrite the existing categories array. Ensure you provide all categories.",
items: { items: {
type: "object", type: "object",
properties: { properties: {
@@ -141,7 +145,7 @@ const SECTION_TOOL_CONFIGS = [
} }
} }
}, },
required: ["sectionId", "categories"] required: ["sectionId"]
}, },
{ {
name: "update_contact_info", name: "update_contact_info",
@@ -298,6 +302,74 @@ export const SECTION_TOOL_DEFINITIONS = [
required: config.required required: config.required
} }
})), })),
{
name: "add_accordion_category",
description: "Add a category to an ACCORDION section.",
inputSchema: {
type: "object",
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
name: { ...localizedStringSchema, description: "Localized category name" },
icon: { type: "string", description: "Optional plain string for icon/emoji" },
questions: {
type: "array",
description: "Optional array of questions to add",
items: {
type: "object",
properties: {
q: { ...localizedStringSchema, description: "Localized question text" },
a: { ...localizedStringSchema, description: "Localized answer text" }
},
required: ["q", "a"]
}
}
},
required: ["sectionId", "name"]
}
},
{
name: "update_accordion_category",
description: "Update a category in an ACCORDION section.",
inputSchema: {
type: "object",
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
categoryId: { type: "string", description: "ID of the category to update" },
name: { ...localizedStringSchema, description: "Optional localized category name" },
icon: { type: "string", description: "Optional plain string for icon/emoji" }
},
required: ["sectionId", "categoryId"]
}
},
{
name: "add_accordion_question",
description: "Add a question to a category in an ACCORDION section.",
inputSchema: {
type: "object",
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
categoryId: { type: "string", description: "ID of the category" },
question: { ...localizedStringSchema, description: "Localized question text" },
answer: { ...localizedStringSchema, description: "Localized answer text" }
},
required: ["sectionId", "categoryId", "question", "answer"]
}
},
{
name: "update_accordion_question",
description: "Update a question in an ACCORDION section.",
inputSchema: {
type: "object",
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
categoryId: { type: "string", description: "ID of the category" },
questionId: { type: "string", description: "ID of the question item to update" },
question: { ...localizedStringSchema, description: "Optional localized question text" },
answer: { ...localizedStringSchema, description: "Optional localized answer text" }
},
required: ["sectionId", "categoryIndex", "itemIndex"]
}
},
{ {
name: "delete_page_section", name: "delete_page_section",
description: "Delete a block section from a dynamic page layout", description: "Delete a block section from a dynamic page layout",
@@ -395,6 +467,65 @@ export async function handleSectionTool(api: AxiosInstance, name: string, params
const res = await api.delete(`/pages/sections/${params.sectionId}`); const res = await api.delete(`/pages/sections/${params.sectionId}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
} }
case "add_accordion_category":
case "update_accordion_category":
case "add_accordion_question":
case "update_accordion_question": {
const existingRes = await api.get(`/pages/sections/${params.sectionId}?lang=all`);
const payload = existingRes.data?.payload || {};
payload.categories = payload.categories || [];
if (name === "add_accordion_category") {
const questions = params.questions?.map((q: any) => ({
id: crypto.randomUUID(),
q: q.q,
a: q.a
})) || [];
payload.categories.push({
id: crypto.randomUUID(),
name: params.name,
icon: params.icon,
questions
});
} else if (name === "update_accordion_category") {
const cat = payload.categories.find((c: any) => c.id === params.categoryId);
if (!cat) throw new Error(`Category with id ${params.categoryId} not found`);
if (params.name) {
cat.name = typeof params.name === "object" && typeof cat.name === "object"
? { ...cat.name, ...params.name }
: params.name;
}
if (params.icon !== undefined) cat.icon = params.icon;
} else if (name === "add_accordion_question") {
const cat = payload.categories.find((c: any) => c.id === params.categoryId);
if (!cat) throw new Error(`Category with id ${params.categoryId} not found`);
cat.questions = cat.questions || [];
cat.questions.push({ id: crypto.randomUUID(), q: params.question, a: params.answer });
} else if (name === "update_accordion_question") {
const cat = payload.categories.find((c: any) => c.id === params.categoryId);
if (!cat) throw new Error(`Category with id ${params.categoryId} not found`);
cat.questions = cat.questions || [];
const item = cat.questions.find((q: any) => q.id === params.questionId);
if (!item) throw new Error(`Question with id ${params.questionId} not found`);
if (params.question) {
item.q = typeof params.question === "object" && typeof item.q === "object"
? { ...item.q, ...params.question }
: params.question;
}
if (params.answer) {
item.a = typeof params.answer === "object" && typeof item.a === "object"
? { ...item.a, ...params.answer }
: params.answer;
}
}
await api.put(`/pages/sections/${params.sectionId}`, {
type: "ACCORDION",
payload
});
return { content: [{ type: "text", text: "Success message" }] };
}
default: default:
return null; return null;
} }
@@ -1,5 +1,5 @@
import { Swiper, SwiperSlide } from 'swiper/react'; import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Pagination } from 'swiper/modules'; import { Autoplay, Pagination, Navigation } from 'swiper/modules';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { EditorialCarouselSectionPayload } from '@homaroma/types'; import type { EditorialCarouselSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -8,6 +8,7 @@ import { getLocalizedField } from '../utils/localization';
// Import Swiper styles // Import Swiper styles
import 'swiper/css'; import 'swiper/css';
import 'swiper/css/pagination'; import 'swiper/css/pagination';
import 'swiper/css/navigation';
interface DynamicEditorialCarouselProps { interface DynamicEditorialCarouselProps {
payload: EditorialCarouselSectionPayload; payload: EditorialCarouselSectionPayload;
@@ -45,8 +46,35 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
}); });
}; };
const bgMap: Record<string, string> = {
bg: 'bg-pop-bg',
black: 'bg-pop-black',
pink: 'bg-pop-pink',
yellow: 'bg-pop-yellow',
orange: 'bg-pop-orange',
red: 'bg-pop-red',
cyan: 'bg-pop-cyan',
green: 'bg-pop-green',
purple: 'bg-pop-purple',
};
const textMap: Record<string, string> = {
bg: 'text-pop-bg',
black: 'text-pop-black',
pink: 'text-pop-pink',
yellow: 'text-pop-yellow',
orange: 'text-pop-orange',
red: 'text-pop-red',
cyan: 'text-pop-cyan',
green: 'text-pop-green',
purple: 'text-pop-purple',
};
const bgClass = payload.backgroundColor && bgMap[payload.backgroundColor] ? bgMap[payload.backgroundColor] : 'bg-pop-bg';
const textClass = payload.textColor && textMap[payload.textColor] ? textMap[payload.textColor] : 'text-pop-black';
return ( return (
<section className="overflow-hidden py-16 md:py-24" style={{ backgroundColor: payload.backgroundColor, color: payload.textColor }}> <section className={`overflow-hidden py-16 md:py-24 ${bgClass} ${textClass}`}>
<div className="ui-section-inner space-y-12"> <div className="ui-section-inner space-y-12">
<div className="space-y-3 text-center"> <div className="space-y-3 text-center">
<h2 className="font-serif text-3xl font-light tracking-wide text-pop-pink md:text-5xl"> <h2 className="font-serif text-3xl font-light tracking-wide text-pop-pink md:text-5xl">
@@ -57,7 +85,7 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
<div className="relative select-none pb-12"> <div className="relative select-none pb-12">
<Swiper <Swiper
modules={[Autoplay, Pagination]} modules={[Autoplay, Pagination, Navigation]}
spaceBetween={30} spaceBetween={30}
slidesPerView={1} slidesPerView={1}
breakpoints={{ breakpoints={{
@@ -65,6 +93,7 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
1024: { slidesPerView: 3 } 1024: { slidesPerView: 3 }
}} }}
pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-pop-pink' }} pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-pop-pink' }}
navigation={true}
autoplay={{ delay: 5000, disableOnInteraction: false }} autoplay={{ delay: 5000, disableOnInteraction: false }}
className="w-full" className="w-full"
> >
@@ -19,17 +19,22 @@ export default function AccordionSection({ payload }: AccordionSectionProps) {
}; };
return ( return (
<section className="ui-section-band w-full bg-pop-bg px-4 md:px-8"> <section
className="ui-section-band w-full px-4 md:px-8"
style={{ backgroundColor: payload.backgroundColor, color: payload.textColor }}
>
<div className="ui-section-inner"> <div className="ui-section-inner">
{title && ( {title && (
<h2 className="mb-10 text-center font-serif text-3xl text-pop-black md:text-4xl"> <h2 className="mb-10 text-center font-serif text-3xl md:text-4xl">
{title} {title}
</h2> </h2>
)} )}
{payload.categories.map((category, catIndex) => ( {payload.categories.map((category, catIndex) => (
<div key={catIndex} className="mb-12 last:mb-0"> <div key={catIndex} className="mb-12 last:mb-0">
<h3 className="mb-6 flex items-center gap-3 font-serif text-2xl text-pop-black"> <h3 className="mb-6 flex items-center gap-3 font-serif text-2xl">
{category.icon && <span className="text-pop-pink">{category.icon}</span>} {category.icon && (
<span style={{ color: payload.iconColor }}>{category.icon}</span>
)}
{getLocalizedField(category.name, lang)} {getLocalizedField(category.name, lang)}
</h3> </h3>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -43,15 +48,15 @@ export default function AccordionSection({ payload }: AccordionSectionProps) {
> >
<button <button
type="button" type="button"
className={`flex w-full items-center justify-between p-5 text-left focus:outline-none focus:ring-2 focus:ring-pop-pink/50 hover:bg-pop-black/5 transition-colors ${ className={`flex w-full items-center justify-between p-5 text-left focus:outline-none focus:ring-2 focus:ring-pop-pink/50 hover:bg-black/5 transition-colors ${
isOpen ? 'bg-pop-black/5' : '' isOpen ? 'bg-black/5' : ''
}`} }`}
onClick={() => toggleAccordion(indexKey)} onClick={() => toggleAccordion(indexKey)}
> >
<span className="pr-4 text-lg font-medium text-pop-black"> <span className="pr-4 text-lg font-medium">
{getLocalizedField(item.q, lang)} {getLocalizedField(item.q, lang)}
</span> </span>
<span className="flex-shrink-0 text-pop-pink"> <span className="flex-shrink-0" style={{ color: payload.iconColor }}>
{isOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />} {isOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</span> </span>
</button> </button>
@@ -60,7 +65,7 @@ export default function AccordionSection({ payload }: AccordionSectionProps) {
isOpen ? 'max-h-96 pb-5 opacity-100' : 'max-h-0 opacity-0' isOpen ? 'max-h-96 pb-5 opacity-100' : 'max-h-0 opacity-0'
}`} }`}
> >
<p className="text-pop-black/80 leading-relaxed whitespace-pre-wrap"> <p className="opacity-80 leading-relaxed whitespace-pre-wrap">
{getLocalizedField(item.a, lang)} {getLocalizedField(item.a, lang)}
</p> </p>
</div> </div>
+1 -1
View File
@@ -119,7 +119,7 @@
} }
.ui-button-secondary { .ui-button-secondary {
@apply inline-flex items-center justify-center rounded-full bg-pop-bg px-6 py-3 text-xs font-bold uppercase tracking-widest text-pop-black transition-colors hover:bg-pop-black/5; @apply inline-flex items-center justify-center rounded-full bg-pop-bg px-6 py-3 text-xs font-bold uppercase tracking-widest text-pop-black transition-colors hover:bg-pop-pink hover:text-pop-bg;
} }
.ui-input-shell { .ui-input-shell {
@@ -290,9 +290,9 @@ function BannerSection({ payload, lang }: { payload: Record<string, unknown>, la
<Image src={imageUrl} alt={title || 'Banner'} fill unoptimized className="absolute inset-0 h-full w-full object-cover" /> <Image src={imageUrl} alt={title || 'Banner'} fill unoptimized className="absolute inset-0 h-full w-full object-cover" />
<div className="ui-banner-overlay" /> <div className="ui-banner-overlay" />
<div className="ui-banner-content"> <div className="ui-banner-content">
{title && <h2 className="text-3xl font-semibold md:text-5xl">{title}</h2>} {title && <h2 className="text-3xl font-semibold text-brand-primary drop-shadow-md md:text-5xl">{title}</h2>}
{description && <p className="text-surface-card">{description}</p>} {description && <p className="text-surface-page/90 drop-shadow-sm">{description}</p>}
{buttonText && <span className="inline-flex rounded-full bg-surface-card px-6 py-3 font-semibold text-text-main">{buttonText}</span>} {buttonText && <span className="inline-flex cursor-pointer rounded-full bg-surface-card px-6 py-3 font-semibold text-text-main transition-colors hover:bg-brand-primary hover:text-surface-card">{buttonText}</span>}
</div> </div>
</div> </div>
) )
@@ -399,56 +399,82 @@ function EditorialCarouselSection({ payload, lang }: { payload: Record<string, u
const slides = Array.isArray(payload.slides) ? payload.slides.map(toRecord) : [] const slides = Array.isArray(payload.slides) ? payload.slides.map(toRecord) : []
const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined
const textColor = typeof payload.textColor === 'string' ? payload.textColor : undefined const textColor = typeof payload.textColor === 'string' ? payload.textColor : undefined
const bgClass = backgroundColor ? `bg-pop-${backgroundColor}` : 'bg-surface-page'
const textClass = textColor ? `text-pop-${textColor}` : 'text-text-main'
const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollBy = (offset: number) => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollBy({ left: offset, behavior: 'smooth' })
}
}
if (!slides.length) return null if (!slides.length) return null
return ( return (
<section className="ui-section-dark py-16 md:py-24" style={{ backgroundColor, color: textColor }}> <section className={`px-6 py-16 md:py-24 ${bgClass} ${textClass}`}>
<div className="mx-auto max-w-7xl space-y-12"> <div className="mx-auto max-w-7xl space-y-12">
{title && ( {title && (
<div className="mx-auto max-w-3xl space-y-4 text-center"> <div className="mx-auto max-w-3xl space-y-4 text-center">
<h2 className="text-4xl font-semibold text-surface-card md:text-5xl" style={{ color: textColor }}>{renderAccentTitle(title)}</h2> <h2 className={`text-4xl font-semibold md:text-5xl ${textClass}`}>{renderAccentTitle(title)}</h2>
<div className="mx-auto h-px w-16 bg-brand-accent" /> <div className="mx-auto h-px w-16 bg-brand-accent" />
</div> </div>
)} )}
<div className="flex overflow-x-auto snap-x snap-mandatory hide-scrollbar gap-6 pb-8"> <div className="relative">
{slides.map((slide, index) => { <button
const slideTitle = localizedFromRecord(slide, 'title', resolvedLang) onClick={() => scrollBy(-400)}
const description = localizedFromRecord(slide, 'description', resolvedLang) className="hidden md:flex bg-surface-card rounded-full shadow-card hover:text-brand-primary h-10 w-10 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -left-5"
const category = localizedFromRecord(slide, 'category', resolvedLang) aria-label="Scroll left"
const buttonText = localizedFromRecord(slide, 'buttonText', resolvedLang) >
const buttonLink = typeof slide.buttonLink === 'string' ? slide.buttonLink : '' <ChevronLeft className="h-6 w-6 text-brand-primary" />
const imageUrl = typeof slide.imageUrl === 'string' ? slide.imageUrl : '' </button>
<div ref={scrollContainerRef} className="flex overflow-x-auto snap-x snap-mandatory hide-scrollbar gap-6 pb-8">
{slides.map((slide, index) => {
const slideTitle = localizedFromRecord(slide, 'title', resolvedLang)
const description = localizedFromRecord(slide, 'description', resolvedLang)
const category = localizedFromRecord(slide, 'category', resolvedLang)
const buttonText = localizedFromRecord(slide, 'buttonText', resolvedLang)
const buttonLink = typeof slide.buttonLink === 'string' ? slide.buttonLink : ''
const imageUrl = typeof slide.imageUrl === 'string' ? slide.imageUrl : ''
return ( return (
<article key={index} className="ui-section-dark-card group w-[85vw] md:w-[350px] flex-none snap-start"> <article key={index} className="ui-card overflow-hidden group w-[85vw] md:w-[350px] flex-none snap-start">
{imageUrl ? ( {imageUrl ? (
<div className="relative aspect-[4/3] overflow-hidden"> <div className="relative aspect-[4/3] overflow-hidden">
<Image <Image
src={imageUrl} src={imageUrl}
alt={slideTitle || 'Editorial slide'} alt={slideTitle || 'Editorial slide'}
fill fill
unoptimized unoptimized
className="h-full w-full object-cover transition duration-700 group-hover:scale-105" className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
/> />
</div>
) : null}
<div className="space-y-4 p-6 flex flex-col flex-1 justify-between">
<div>
{category && <p className="text-[10px] font-bold uppercase tracking-[0.24em] text-brand-accent mb-2">{category}</p>}
{slideTitle && <h3 className="ui-section-dark-card-title text-xl mb-3">{renderAccentTitle(slideTitle)}</h3>}
{description && <p className="ui-section-dark-card-copy text-sm line-clamp-3">{description}</p>}
</div>
{buttonText && buttonLink && (
<div className="pt-2">
<Link href={buttonLink} className="ui-section-dark-link inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest transition-colors group/link">
{buttonText} <span className="group-hover/link:translate-x-1 transition-transform">&rarr;</span>
</Link>
</div> </div>
)} ) : null}
</div> <div className="space-y-4 p-6 flex flex-col flex-1 justify-between">
</article> <div>
) {category && <span className="text-[10px] font-bold uppercase tracking-[0.24em] text-brand-primary block">{category}</span>}
})} {slideTitle && <h3 className="text-xl font-bold text-text-main mt-6 group-hover:text-brand-primary transition-colors">{renderAccentTitle(slideTitle)}</h3>}
{description && <p className="text-base text-muted-text mt-3 whitespace-pre-wrap">{description}</p>}
</div>
{buttonText && buttonLink && (
<div className="pt-2">
<Link href={buttonLink} className="ui-section-dark-link inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest transition-colors group/link">
{buttonText} <span className="group-hover/link:translate-x-1 transition-transform">&rarr;</span>
</Link>
</div>
)}
</div>
</article>
)
})}
</div>
<button
onClick={() => scrollBy(400)}
className="hidden md:flex bg-surface-card rounded-full shadow-card hover:text-brand-primary h-10 w-10 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -right-5"
aria-label="Scroll right"
>
<ChevronRight className="h-6 w-6 text-brand-primary" />
</button>
</div> </div>
</div> </div>
</section> </section>
@@ -560,9 +586,12 @@ function AccordionSection({ payload, lang }: { payload: Record<string, unknown>,
const categories = Array.isArray(payload.categories) ? payload.categories.map(toRecord) : [] const categories = Array.isArray(payload.categories) ? payload.categories.map(toRecord) : []
if (!categories.length) return null if (!categories.length) return null
const sectionTitle = localizedFromRecord(payload, 'title', resolvedLang) const sectionTitle = localizedFromRecord(payload, 'title', resolvedLang)
const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined
const textColor = typeof payload.textColor === 'string' ? payload.textColor : undefined
const iconColor = typeof payload.iconColor === 'string' ? payload.iconColor : undefined
return ( return (
<section className="bg-surface-page ui-section-band"> <section className="ui-section-band" style={{ backgroundColor, color: textColor }}>
<div className="mx-auto max-w-4xl space-y-10"> <div className="mx-auto max-w-4xl space-y-10">
{sectionTitle && <h2 className="ui-section-title">{sectionTitle}</h2>} {sectionTitle && <h2 className="ui-section-title">{sectionTitle}</h2>}
{categories.map((category, categoryIndex) => { {categories.map((category, categoryIndex) => {
@@ -573,8 +602,8 @@ function AccordionSection({ payload, lang }: { payload: Record<string, unknown>,
return ( return (
<div key={categoryIndex} className="space-y-5"> <div key={categoryIndex} className="space-y-5">
{name && ( {name && (
<h2 className="flex items-center gap-3 text-2xl font-semibold text-text-main"> <h2 className="flex items-center gap-3 text-2xl font-semibold">
{icon && <span className="text-brand-primary">{icon}</span>} {icon && <span style={{ color: iconColor }}>{icon}</span>}
{name} {name}
</h2> </h2>
)} )}
@@ -586,7 +615,7 @@ function AccordionSection({ payload, lang }: { payload: Record<string, unknown>,
return ( return (
<details key={itemIndex} className="group rounded-2xl bg-surface-card p-5 shadow-card"> <details key={itemIndex} className="group rounded-2xl bg-surface-card p-5 shadow-card">
<summary className="cursor-pointer list-none text-lg font-semibold text-text-main marker:hidden"> <summary className="cursor-pointer list-none text-lg font-semibold marker:hidden">
{question} {question}
</summary> </summary>
<p className="mt-4 whitespace-pre-wrap leading-relaxed text-muted-text">{answer}</p> <p className="mt-4 whitespace-pre-wrap leading-relaxed text-muted-text">{answer}</p>
+9
View File
@@ -0,0 +1,9 @@
const fs = require('fs');
const path = 'apps/website/src/components/dynamic/DynamicPageRenderer.tsx';
let content = fs.readFileSync(path, 'utf8');
content = content.replace(/className="ui-section-dark-card group/g, 'className="ui-card group');
content = content.replace(/className="ui-section-dark-card-title mt-6 text-xl"/g, 'className="text-xl font-bold text-text-main mt-6"');
content = content.replace(/className="ui-section-dark-card-copy mt-3 text-base"/g, 'className="text-base text-muted-text mt-3 whitespace-pre-wrap"');
fs.writeFileSync(path, content);
+7 -2
View File
@@ -462,18 +462,23 @@ export type ScentGuidePage = z.infer<typeof ScentGuidePageSchema>;
export const AccordionSectionPayloadSchema = z.object({ export const AccordionSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(), title: LocalizedStringSchema.optional(),
backgroundColor: z.string().optional(),
textColor: z.string().optional(),
iconColor: z.string().optional(),
categories: z.array( categories: z.array(
z.object({ z.object({
id: z.string().optional(),
name: LocalizedStringSchema, name: LocalizedStringSchema,
icon: z.string().optional(), icon: z.string().optional(),
questions: z.array( questions: z.array(
z.object({ z.object({
id: z.string().optional(),
q: LocalizedStringSchema, q: LocalizedStringSchema,
a: LocalizedStringSchema, a: LocalizedStringSchema,
}) })
).min(1), ),
}) })
).min(1), ).optional(),
}); });
export type AccordionSectionPayload = z.infer<typeof AccordionSectionPayloadSchema>; export type AccordionSectionPayload = z.infer<typeof AccordionSectionPayloadSchema>;