diff --git a/apps/backend/package.json b/apps/backend/package.json index d6aa78a..888856f 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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", diff --git a/apps/backend/src/routes/pages.ts b/apps/backend/src/routes/pages.ts index 1110da9..97b8a41 100644 --- a/apps/backend/src/routes/pages.ts +++ b/apps/backend/src/routes/pages.ts @@ -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}`; } diff --git a/apps/mcp-server/index.ts b/apps/mcp-server/index.ts index c8b6840..71e8cb5 100644 --- a/apps/mcp-server/index.ts +++ b/apps/mcp-server/index.ts @@ -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) }] }; } diff --git a/apps/spa/src/components/DynamicHeroArticle.tsx b/apps/spa/src/components/DynamicHeroArticle.tsx new file mode 100644 index 0000000..5aab534 --- /dev/null +++ b/apps/spa/src/components/DynamicHeroArticle.tsx @@ -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 ( +
+ {imageUrl && ( +
+ {title +
+
+ )} +
+ {category && ( + + {category} + + )} +

+ {title || 'Scent Guide Article'} +

+ {subtitle && ( +

+ {subtitle} +

+ )} + {(author || date) && ( +
+ {author && By {author}} + {author && date && •} + {date && {date}} +
+ )} +
+
+ ); +} diff --git a/apps/spa/src/components/DynamicPageRenderer.tsx b/apps/spa/src/components/DynamicPageRenderer.tsx index b82f00c..f7216ad 100644 --- a/apps/spa/src/components/DynamicPageRenderer.tsx +++ b/apps/spa/src/components/DynamicPageRenderer.tsx @@ -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 ; case 'EDITORIAL_CAROUSEL': return ; + case 'HERO_ARTICLE': + return ; + case 'SCENT_NOTES_BREAKDOWN': + return ; + case 'QUOTE_CALLOUT': + return ; + case 'PRODUCT_CAROUSEL': + return ; default: console.warn(`[DynamicPageRenderer] Unknown block type: ${section.type}`); return null; diff --git a/apps/spa/src/components/DynamicProductCarousel.tsx b/apps/spa/src/components/DynamicProductCarousel.tsx new file mode 100644 index 0000000..ba8bba3 --- /dev/null +++ b/apps/spa/src/components/DynamicProductCarousel.tsx @@ -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 ( +
+
+ {(title || description) && ( +
+ {title &&

{title}

} + {description &&

{description}

} +
+ )} + + {products.length === 0 ? ( +
+ No products to display. +
+ ) : ( +
+ + {products.map((p: any) => ( + + + + ))} + +
+ )} +
+
+ ); +} diff --git a/apps/spa/src/components/DynamicQuoteCallout.tsx b/apps/spa/src/components/DynamicQuoteCallout.tsx new file mode 100644 index 0000000..b6db8e2 --- /dev/null +++ b/apps/spa/src/components/DynamicQuoteCallout.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +interface DynamicQuoteCalloutProps { + payload: any; +} + +export default function DynamicQuoteCallout({ payload }: DynamicQuoteCalloutProps) { + const { quote, author, role } = payload || {}; + + return ( +
+
+ + + +
+ "{quote || 'Fragrance is the invisible, unforgettable, ultimate accessory of fashion.'}" +
+ {(author || role) && ( +
+ {author && {author}} + {role && {role}} +
+ )} +
+
+ ); +} diff --git a/apps/spa/src/components/DynamicScentNotesBreakdown.tsx b/apps/spa/src/components/DynamicScentNotesBreakdown.tsx new file mode 100644 index 0000000..0cb6f5e --- /dev/null +++ b/apps/spa/src/components/DynamicScentNotesBreakdown.tsx @@ -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 ( +
+
+

+ {title || 'Scent Notes'} +

+ {description && ( +

+ {description} +

+ )} + +
+
+

Top Notes

+

+ {topNotes || 'The initial, lighter smells of the fragrance.'} +

+
+ +
+

Heart Notes

+

+ {heartNotes || 'The core of the fragrance, emerging just after the top notes.'} +

+
+ +
+

Base Notes

+

+ {baseNotes || 'The deepest, richest scents that linger the longest.'} +

+
+
+
+
+ ); +} diff --git a/bun.lock b/bun.lock index bf29ac6..fe80666 100644 --- a/bun.lock +++ b/bun.lock @@ -45,6 +45,7 @@ "@elysiajs/jwt": "^1.4.1", "@elysiajs/static": "^1.4.7", "@elysiajs/swagger": "^1.2.1", + "@homaroma/types": "*", "@prisma/adapter-pg": "^7.6.0", "@prisma/client": "^7.6.0", "bcryptjs": "^3.0.3", @@ -137,6 +138,9 @@ "packages/types": { "name": "@homaroma/types", "version": "1.0.0", + "dependencies": { + "zod": "^4.4.3", + }, }, }, "trustedDependencies": [ @@ -1640,7 +1644,7 @@ "zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -1654,6 +1658,8 @@ "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "@modelcontextprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@prisma/dev/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.8.0", "", { "dependencies": { "@prisma/debug": "7.8.0" } }, "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw=="], @@ -1704,6 +1710,8 @@ "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "eslint-plugin-react-hooks/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -1754,6 +1762,8 @@ "@scalar/themes/@scalar/types/nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], + "@scalar/themes/@scalar/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], diff --git a/packages/types/index.ts b/packages/types/index.ts index 213d3ce..7aad7c4 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -277,4 +277,88 @@ export interface CustomerAddress { updatedAt?: string } +import { z } from "zod"; +export const HeroArticlePayloadSchema = z.object({ + title: z.string().min(1, "Title is required"), + subtitle: z.string().optional(), + author: z.string().optional(), + publishDate: z.string().optional(), // Or z.date() if preferred, but string works for ISO dates + backgroundImageUrl: z.string().url("Must be a valid URL").optional(), + overlayColor: z.string().optional(), +}); +export type HeroArticlePayload = z.infer; + +export const RichTextPayloadSchema = z.object({ + content: z.string().min(1, "Content is required"), +}); +export type RichTextPayload = z.infer; + +export const ScentNotesBreakdownPayloadSchema = z.object({ + title: z.string().optional(), + topNotes: z.array(z.string()), + middleNotes: z.array(z.string()), + baseNotes: z.array(z.string()), +}); +export type ScentNotesBreakdownPayload = z.infer; + +export const QuoteCalloutPayloadSchema = z.object({ + quote: z.string().min(1, "Quote is required"), + author: z.string().optional(), +}); +export type QuoteCalloutPayload = z.infer; + +export const ProductCarouselPayloadSchema = z.object({ + title: z.string().optional(), + collectionId: z.string().optional(), + limit: z.number().int().positive().optional(), + showPrice: z.boolean().optional(), + showAddToCart: z.boolean().optional(), +}); +export type ProductCarouselPayload = z.infer; + +export const ScentGuideSectionSchema = z.discriminatedUnion("type", [ + z.object({ + id: z.string().or(z.number()), + type: z.literal("hero_article"), + order: z.number(), + payload: HeroArticlePayloadSchema, + }), + z.object({ + id: z.string().or(z.number()), + type: z.literal("rich_text"), + order: z.number(), + payload: RichTextPayloadSchema, + }), + z.object({ + id: z.string().or(z.number()), + type: z.literal("scent_notes_breakdown"), + order: z.number(), + payload: ScentNotesBreakdownPayloadSchema, + }), + z.object({ + id: z.string().or(z.number()), + type: z.literal("quote_callout"), + order: z.number(), + payload: QuoteCalloutPayloadSchema, + }), + z.object({ + id: z.string().or(z.number()), + type: z.literal("product_carousel"), + order: z.number(), + payload: ProductCarouselPayloadSchema, + }), +]); + +export const ScentGuidePageSchema = z.object({ + pageId: z.string().or(z.number()), + title: z.string(), + slug: z.string(), + status: z.string().optional(), + metadata: z.object({ + seoTitle: z.string().optional(), + seoDescription: z.string().optional(), + }).optional(), + sections: z.array(ScentGuideSectionSchema), +}); +export type ScentGuidePage = z.infer; diff --git a/packages/types/package.json b/packages/types/package.json index 875cbee..fe148c5 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -3,5 +3,8 @@ "version": "1.0.0", "private": true, "main": "index.ts", - "types": "index.ts" + "types": "index.ts", + "dependencies": { + "zod": "^4.4.3" + } }