This commit is contained in:
Your Name committed 2026-06-08 07:15:52 +07:00
1 parent c1c3a43d7d
commit a5f6e3171b
11 files changed
+351 -13

No files matched your search

+1
View File
@@ -12,6 +12,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@homaroma/types": "*",
"@elysiajs/cors": "^1.2.2",
"@elysiajs/jwt": "^1.4.1",
"@elysiajs/static": "^1.4.7",
+56 -6
View File
@@ -1,5 +1,12 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import {
HeroArticlePayloadSchema,
RichTextPayloadSchema,
ScentNotesBreakdownPayloadSchema,
QuoteCalloutPayloadSchema,
ProductCarouselPayloadSchema
} from "@homaroma/types";
export const pagesRoute = new Elysia({ prefix: "/pages" })
.get("/", async () => {
@@ -113,15 +120,22 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
.post("/", async ({ body, set }) => {
try {
const data = body as any;
const { id, slug, title, isPublished } = data;
const { id, slug, title, isPublished, status, metadata } = data;
let finalIsPublished = true;
if (status !== undefined) {
finalIsPublished = status === "published";
} else if (isPublished !== undefined) {
finalIsPublished = isPublished;
}
if (id) {
return await prisma.page.update({
where: { id: Number(id) },
data: { slug, title, isPublished: isPublished !== false }
data: { slug, title, isPublished: finalIsPublished }
});
} else {
return await prisma.page.create({
data: { slug, title, isPublished: isPublished !== false }
data: { slug, title, isPublished: finalIsPublished }
});
}
} catch (err) {
@@ -134,7 +148,9 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
id: t.Optional(t.Number()),
slug: t.String(),
title: t.String(),
isPublished: t.Optional(t.Boolean())
isPublished: t.Optional(t.Boolean()),
status: t.Optional(t.String()),
metadata: t.Optional(t.Any())
})
})
.delete("/:slug", async ({ params: { slug }, set }) => {
@@ -170,11 +186,20 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
return { error: validationError };
}
let finalOrder = order;
if (finalOrder === undefined) {
const lastSection = await prisma.pageSection.findFirst({
where: { pageId },
orderBy: { order: "desc" }
});
finalOrder = lastSection ? lastSection.order + 1 : 1;
}
return await prisma.pageSection.create({
data: {
pageId,
type: type.toUpperCase(),
order,
order: finalOrder,
payload,
isActive: isActive !== false
}
@@ -187,7 +212,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
}, {
body: t.Object({
type: t.String(),
order: t.Number(),
order: t.Optional(t.Number()),
payload: t.Any(),
isActive: t.Optional(t.Boolean())
})
@@ -373,6 +398,31 @@ function validateSectionPayload(type: string, payload: any): string | null {
if (typeof slide.category !== 'string') return `Slide #${idx+1} must contain a category string`;
}
break;
case 'HERO_ARTICLE': {
const res = HeroArticlePayloadSchema.safeParse(payload);
if (!res.success) return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'RICH_TEXT': {
const res = RichTextPayloadSchema.safeParse(payload);
if (!res.success) return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'SCENT_NOTES_BREAKDOWN': {
const res = ScentNotesBreakdownPayloadSchema.safeParse(payload);
if (!res.success) return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'QUOTE_CALLOUT': {
const res = QuoteCalloutPayloadSchema.safeParse(payload);
if (!res.success) return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'PRODUCT_CAROUSEL': {
const res = ProductCarouselPayloadSchema.safeParse(payload);
if (!res.success) return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
default:
return `Unknown section type: ${type}`;
}
+9 -5
View File
@@ -213,6 +213,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
title: { type: "string", description: "Title of the page" },
slug: { type: "string", description: "Unique slug identifier (e.g. about-us)" },
isPublished: { type: "boolean", description: "Publish status" },
status: { type: "string", description: "Publish status (e.g. 'published' or 'draft')" },
metadata: { type: "object", description: "SEO metadata" },
id: { type: "number", description: "Numeric page ID (if updating existing page)" }
},
required: ["title", "slug"]
@@ -225,12 +227,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
type: "object",
properties: {
pageId: { type: "number", description: "The numeric ID of the page" },
type: { type: "string", description: "Block type (e.g. HERO, ANNOUNCEMENT_BAR, NEWSLETTER, EDITORIAL_TILE, EDITORIAL_CAROUSEL, PRODUCTS, CATEGORIES, TEXT, BANNER)" },
type: { type: "string", description: "Block type (e.g. HERO, ANNOUNCEMENT_BAR, NEWSLETTER, EDITORIAL_TILE, EDITORIAL_CAROUSEL, PRODUCTS, CATEGORIES, TEXT, BANNER, HERO_ARTICLE, RICH_TEXT, SCENT_NOTES_BREAKDOWN, QUOTE_CALLOUT, PRODUCT_CAROUSEL)" },
order: { type: "number", description: "Display order sequence (starts at 1)" },
payload: { type: "object", description: "The JSON payload matching the section type rules" },
isActive: { type: "boolean", description: "Initial active state" }
},
required: ["pageId", "type", "order", "payload"]
required: ["pageId", "type", "payload"]
}
},
{
@@ -386,8 +388,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const payload: any = {
title: params.title,
slug: params.slug,
isPublished: params.isPublished !== false
};
if (params.isPublished !== undefined) payload.isPublished = params.isPublished;
if (params.status !== undefined) payload.status = params.status;
if (params.metadata !== undefined) payload.metadata = params.metadata;
if (params.id) {
payload.id = params.id;
}
@@ -395,12 +399,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "add_page_section": {
const payload = {
const payload: any = {
type: params.type,
order: params.order,
payload: params.payload,
isActive: params.isActive !== false
};
if (params.order !== undefined) payload.order = params.order;
const res = await api.post(`/pages/${params.pageId}/sections`, payload);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
@@ -0,0 +1,42 @@
import React from 'react';
interface DynamicHeroArticleProps {
payload: any;
}
export default function DynamicHeroArticle({ payload }: DynamicHeroArticleProps) {
const { title, subtitle, imageUrl, author, date, category } = payload || {};
return (
<section className="relative w-full min-h-[60vh] md:min-h-[80vh] flex items-center justify-center bg-spa-sage overflow-hidden">
{imageUrl && (
<div className="absolute inset-0 z-0">
<img src={imageUrl} alt={title || 'Article Hero'} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40" />
</div>
)}
<div className="relative z-10 text-center max-w-4xl px-6 text-white">
{category && (
<span className="text-sm uppercase tracking-widest font-bold mb-4 block text-spa-gold">
{category}
</span>
)}
<h1 className="text-4xl md:text-6xl font-serif mb-6 leading-tight">
{title || 'Scent Guide Article'}
</h1>
{subtitle && (
<p className="text-lg md:text-xl font-light opacity-90 max-w-2xl mx-auto mb-8">
{subtitle}
</p>
)}
{(author || date) && (
<div className="text-sm font-medium tracking-wide uppercase opacity-80 flex items-center justify-center gap-4">
{author && <span>By {author}</span>}
{author && date && <span>•</span>}
{date && <span>{date}</span>}
</div>
)}
</div>
</section>
);
}
@@ -8,6 +8,10 @@ import DynamicAnnouncementBar from './DynamicAnnouncementBar';
import DynamicNewsletter from './DynamicNewsletter';
import DynamicEditorialTile from './DynamicEditorialTile';
import DynamicEditorialCarousel from './DynamicEditorialCarousel';
import DynamicHeroArticle from './DynamicHeroArticle';
import DynamicScentNotesBreakdown from './DynamicScentNotesBreakdown';
import DynamicQuoteCallout from './DynamicQuoteCallout';
import DynamicProductCarousel from './DynamicProductCarousel';
interface DynamicPageRendererProps {
sections: PageSection[];
@@ -51,6 +55,14 @@ export default function DynamicPageRenderer({
return <DynamicEditorialTile key={section.id} payload={section.payload} />;
case 'EDITORIAL_CAROUSEL':
return <DynamicEditorialCarousel key={section.id} payload={section.payload} />;
case 'HERO_ARTICLE':
return <DynamicHeroArticle key={section.id} payload={section.payload} />;
case 'SCENT_NOTES_BREAKDOWN':
return <DynamicScentNotesBreakdown key={section.id} payload={section.payload} />;
case 'QUOTE_CALLOUT':
return <DynamicQuoteCallout key={section.id} payload={section.payload} />;
case 'PRODUCT_CAROUSEL':
return <DynamicProductCarousel key={section.id} payload={section.payload} />;
default:
console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`);
return null;
@@ -0,0 +1,57 @@
import React from 'react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Pagination, Navigation } from 'swiper/modules';
import ProductCard from './ProductCard';
import 'swiper/css';
import 'swiper/css/pagination';
import 'swiper/css/navigation';
interface DynamicProductCarouselProps {
payload: any;
}
export default function DynamicProductCarousel({ payload }: DynamicProductCarouselProps) {
const { title, description, products = [] } = payload || {};
return (
<section className="py-20 bg-spa-sage/30 px-6">
<div className="max-w-7xl mx-auto">
{(title || description) && (
<div className="text-center mb-12">
{title && <h2 className="text-3xl md:text-5xl font-serif text-spa-forest mb-4">{title}</h2>}
{description && <p className="text-lg text-spa-stone/70 max-w-2xl mx-auto">{description}</p>}
</div>
)}
{products.length === 0 ? (
<div className="text-center py-12 text-spa-stone/50 italic">
No products to display.
</div>
) : (
<div className="relative pb-12">
<Swiper
modules={[Autoplay, Pagination, Navigation]}
spaceBetween={32}
slidesPerView={1}
breakpoints={{
640: { slidesPerView: 2 },
1024: { slidesPerView: 3 },
1280: { slidesPerView: 4 }
}}
pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-spa-forest' }}
navigation
autoplay={{ delay: 5000, disableOnInteraction: false }}
className="w-full !px-4"
>
{products.map((p: any) => (
<SwiperSlide key={p.id} className="h-auto">
<ProductCard product={p} enableQuickShop={true} />
</SwiperSlide>
))}
</Swiper>
</div>
)}
</div>
</section>
);
}
@@ -0,0 +1,28 @@
import React from 'react';
interface DynamicQuoteCalloutProps {
payload: any;
}
export default function DynamicQuoteCallout({ payload }: DynamicQuoteCalloutProps) {
const { quote, author, role } = payload || {};
return (
<section className="py-24 px-6 bg-white">
<div className="max-w-4xl mx-auto text-center">
<svg className="w-12 h-12 mx-auto text-spa-gold/40 mb-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
</svg>
<blockquote className="text-2xl md:text-4xl font-serif text-spa-forest italic leading-relaxed mb-8">
"{quote || 'Fragrance is the invisible, unforgettable, ultimate accessory of fashion.'}"
</blockquote>
{(author || role) && (
<div className="flex flex-col items-center">
{author && <span className="font-bold uppercase tracking-widest text-spa-stone">{author}</span>}
{role && <span className="text-sm text-spa-stone/60 mt-1 uppercase tracking-wide">{role}</span>}
</div>
)}
</div>
</section>
);
}
@@ -0,0 +1,47 @@
import React from 'react';
interface DynamicScentNotesBreakdownProps {
payload: any;
}
export default function DynamicScentNotesBreakdown({ payload }: DynamicScentNotesBreakdownProps) {
const { title, description, topNotes, heartNotes, baseNotes } = payload || {};
return (
<section className="py-20 px-6 bg-spa-light">
<div className="max-w-5xl mx-auto text-center">
<h2 className="text-3xl md:text-5xl font-serif text-spa-forest mb-6">
{title || 'Scent Notes'}
</h2>
{description && (
<p className="text-lg text-spa-stone/80 max-w-2xl mx-auto mb-16">
{description}
</p>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="p-8 bg-white rounded-2xl shadow-sm border border-spa-stone/10">
<h3 className="text-xl font-bold uppercase tracking-widest text-spa-forest mb-4">Top Notes</h3>
<p className="text-spa-stone font-light leading-relaxed">
{topNotes || 'The initial, lighter smells of the fragrance.'}
</p>
</div>
<div className="p-8 bg-white rounded-2xl shadow-sm border border-spa-stone/10">
<h3 className="text-xl font-bold uppercase tracking-widest text-spa-forest mb-4">Heart Notes</h3>
<p className="text-spa-stone font-light leading-relaxed">
{heartNotes || 'The core of the fragrance, emerging just after the top notes.'}
</p>
</div>
<div className="p-8 bg-white rounded-2xl shadow-sm border border-spa-stone/10">
<h3 className="text-xl font-bold uppercase tracking-widest text-spa-forest mb-4">Base Notes</h3>
<p className="text-spa-stone font-light leading-relaxed">
{baseNotes || 'The deepest, richest scents that linger the longest.'}
</p>
</div>
</div>
</div>
</section>
);
}