This commit is contained in:
Your Name committed 2026-06-27 23:19:50 -04:00
1 parent fb776f778f
commit 8f64d89f6f
13 files changed
+779 -295

No files matched your search

@@ -0,0 +1,165 @@
import React, { useState, useEffect } from 'react';
import { X, Search, Loader2 } from 'lucide-react';
import { API_BASE, type Product } from '@homaroma/types';
import { getAdminDisplayString } from '../../utils/localization';
interface ProductPickerModalProps {
selectedIds: number[];
onSave: (ids: number[]) => void;
onClose: () => void;
}
export default function ProductPickerModal({
selectedIds,
onSave,
onClose,
}: ProductPickerModalProps) {
const [search, setSearch] = useState('');
const [products, setProducts] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<Set<number>>(new Set(selectedIds));
useEffect(() => {
const fetchProducts = async () => {
setLoading(true);
try {
const url = `${API_BASE}/products?limit=20&search=${encodeURIComponent(search)}`;
const res = await fetch(url);
const data = await res.json();
if (data && data.data) {
setProducts(data.data);
} else if (data && data.products) {
setProducts(data.products);
} else if (Array.isArray(data)) {
setProducts(data);
} else {
setProducts([]);
}
} catch (err) {
console.error('Fetch products error:', err);
} finally {
setLoading(false);
}
};
const timer = setTimeout(() => {
fetchProducts();
}, 300); // debounce
return () => clearTimeout(timer);
}, [search]);
const toggleSelect = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSave = () => {
onSave(Array.from(selected));
onClose();
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-night-900/80 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative bg-slate-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col h-[80vh]">
<div className="p-4 border-b border-white/10 flex justify-between items-center bg-white/5">
<h2 className="text-xl font-bold text-white">Select Products</h2>
<button
type="button"
onClick={onClose}
className="p-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4 border-b border-white/10 bg-black/20">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Search products..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
}
}}
className="w-full bg-slate-800 border border-white/10 text-white rounded-xl pl-10 pr-4 py-2 text-sm focus:outline-none focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow transition-all"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{loading ? (
<div className="flex justify-center items-center py-10">
<Loader2 className="w-8 h-8 animate-spin text-cyan-glow" />
</div>
) : products.length > 0 ? (
products.map((product) => {
const isSelected = selected.has(product.id);
const name = getAdminDisplayString(product.name);
return (
<div
key={product.id}
onClick={() => toggleSelect(product.id)}
className={`flex items-center p-3 rounded-xl cursor-pointer border transition-all ${
isSelected
? 'bg-cyan-glow/10 border-cyan-glow/30'
: 'bg-white/5 border-transparent hover:bg-white/10 hover:border-white/20'
}`}
>
<div className="flex-1">
<p className="text-sm font-medium text-white">{name}</p>
{product.sku && <p className="text-xs text-slate-400">SKU: {product.sku}</p>}
</div>
<div className={`w-5 h-5 rounded border flex items-center justify-center ${
isSelected ? 'bg-cyan-glow border-cyan-glow text-black' : 'border-white/30 text-transparent'
}`}>
{isSelected && (
<svg viewBox="0 0 14 14" fill="none" className="w-3 h-3">
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
</div>
);
})
) : (
<div className="text-center py-10 text-slate-400">
No products found.
</div>
)}
</div>
<div className="p-4 border-t border-white/10 bg-black/20 flex justify-between items-center">
<span className="text-sm text-slate-400">
{selected.size} product{selected.size !== 1 ? 's' : ''} selected
</span>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-bold text-slate-300 hover:text-white transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
className="px-4 py-2 text-sm font-bold bg-cyan-glow hover:bg-cyan-glow/80 text-black rounded-xl transition-colors"
>
Save Selection
</button>
</div>
</div>
</div>
</div>
);
}
@@ -1,11 +1,18 @@
import React from 'react';
import React, { useState } from 'react';
import { type PageSection } from '@homaroma/types';
import ProductPickerModal from './ProductPickerModal';
import { getAdminDisplayString } from '../../utils/localization';
interface Brand {
id: number;
name: string;
}
interface Category {
id: number;
name: any;
}
interface ProductsFormProps {
editingSection: Partial<PageSection>;
setEditingSection: React.Dispatch<React.SetStateAction<Partial<PageSection> | null>>;
@@ -13,6 +20,7 @@ interface ProductsFormProps {
getLocalizedValue: (payload: any, field: string) => string;
updateLocalizedValue: (field: string, value: string) => void;
brands: Brand[];
categories?: Category[];
}
export default function ProductsForm({
@@ -21,7 +29,10 @@ export default function ProductsForm({
getLocalizedValue,
updateLocalizedValue,
brands,
categories = [],
}: ProductsFormProps) {
const [isPickerOpen, setIsPickerOpen] = useState(false);
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -50,16 +61,17 @@ export default function ProductsForm({
<select
value={editingSection.payload?.type || 'featured'}
onChange={(e) =>
setEditingSection({
...editingSection,
setEditingSection(prev => prev ? {
...prev,
payload: {
...(editingSection.payload || {}),
...(prev.payload || {}),
type: e.target.value,
// reset conditional properties
brandId: e.target.value === 'brand' ? brands[0]?.id : undefined,
productIds: e.target.value === 'custom' ? [] : undefined
productIds: e.target.value === 'custom' ? (prev.payload?.productIds || []) : undefined,
categoryId: e.target.value === 'featured' ? prev.payload?.categoryId : undefined
}
})
} : null)
}
className="admin-input w-full bg-slate-900 border-white/10"
>
@@ -78,10 +90,10 @@ export default function ProductsForm({
required
value={editingSection.payload?.limit || 3}
onChange={(e) =>
setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), limit: parseInt(e.target.value) }
})
setEditingSection(prev => prev ? {
...prev,
payload: { ...(prev.payload || {}), limit: e.target.value ? parseInt(e.target.value) : undefined }
} : null)
}
className="admin-input w-full"
/>
@@ -92,10 +104,10 @@ export default function ProductsForm({
<select
value={editingSection.payload?.layout || 'grid'}
onChange={(e) =>
setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), layout: e.target.value }
})
setEditingSection(prev => prev ? {
...prev,
payload: { ...(prev.payload || {}), layout: e.target.value }
} : null)
}
className="admin-input w-full bg-slate-900 border-white/10"
>
@@ -110,10 +122,10 @@ export default function ProductsForm({
type="checkbox"
checked={editingSection.payload?.enableQuickShop ?? false}
onChange={(e) =>
setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), enableQuickShop: e.target.checked }
})
setEditingSection(prev => prev ? {
...prev,
payload: { ...(prev.payload || {}), enableQuickShop: e.target.checked }
} : null)
}
className="accent-cyan-glow"
/>
@@ -122,16 +134,39 @@ export default function ProductsForm({
</div>
</div>
{editingSection.payload?.type === 'featured' && (
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Select Category</label>
<select
value={editingSection.payload?.categoryId || ''}
onChange={(e) =>
setEditingSection(prev => prev ? {
...prev,
payload: { ...(prev.payload || {}), categoryId: e.target.value ? parseInt(e.target.value) : undefined }
} : null)
}
className="admin-input w-full bg-slate-900 border-white/10"
>
<option value="">None (All products / fallback)</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{getAdminDisplayString(cat.name)}
</option>
))}
</select>
</div>
)}
{editingSection.payload?.type === 'brand' && (
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Select Brand</label>
<select
value={editingSection.payload?.brandId || ''}
onChange={(e) =>
setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), brandId: parseInt(e.target.value) }
})
setEditingSection(prev => prev ? {
...prev,
payload: { ...(prev.payload || {}), brandId: e.target.value ? parseInt(e.target.value) : undefined }
} : null)
}
className="admin-input w-full bg-slate-900 border-white/10"
>
@@ -146,26 +181,42 @@ export default function ProductsForm({
{editingSection.payload?.type === 'custom' && (
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Custom Product IDs (comma separated)</label>
<input
type="text"
required
placeholder="e.g. 151, 152, 153"
value={Array.isArray(editingSection.payload?.productIds) ? editingSection.payload.productIds.join(', ') : ''}
onChange={(e) => {
const ids = e.target.value
.split(',')
.map((id) => parseInt(id.trim()))
.filter((id) => !isNaN(id));
setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), productIds: ids }
});
}}
className="admin-input w-full"
/>
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Custom Products</label>
<div className="flex gap-2">
<input
type="text"
readOnly
value={Array.isArray(editingSection.payload?.productIds) ? `${editingSection.payload.productIds.length} product(s) selected` : '0 products selected'}
className="admin-input flex-1 bg-slate-900/50 cursor-not-allowed text-slate-400"
/>
<button
type="button"
onClick={() => setIsPickerOpen(true)}
className="px-4 py-2 bg-cyan-glow hover:bg-cyan-glow/80 text-black text-sm font-bold rounded-xl transition-colors whitespace-nowrap"
>
Select Products
</button>
</div>
{Array.isArray(editingSection.payload?.productIds) && editingSection.payload.productIds.length > 0 && (
<div className="text-xs text-slate-500">
IDs: {editingSection.payload.productIds.join(', ')}
</div>
)}
</div>
)}
{isPickerOpen && (
<ProductPickerModal
selectedIds={Array.isArray(editingSection.payload?.productIds) ? editingSection.payload.productIds : []}
onSave={(ids) => {
setEditingSection((prev) => prev ? {
...prev,
payload: { ...(prev.payload || {}), productIds: ids }
} : null);
}}
onClose={() => setIsPickerOpen(false)}
/>
)}
</div>
);
}
+2 -1
View File
@@ -706,7 +706,7 @@ export default function PageBuilderPage() {
});
if (res.ok) {
setEditingSection(null);
// setEditingSection(null); // Fix: Do not close editing panel on save
if (selectedPageId) fetchPageDetails(selectedPageId);
} else {
const errData = await res.json();
@@ -1070,6 +1070,7 @@ export default function PageBuilderPage() {
getLocalizedValue={getLocalizedValue}
updateLocalizedValue={updateLocalizedValue}
brands={brands}
categories={categories}
/>
)}
+2 -1
View File
@@ -24,7 +24,8 @@
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.0",
"elysia": "^1.2.25",
"pg": "^8.20.0"
"pg": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
+42 -219
View File
@@ -11,133 +11,18 @@ import {
NewsletterSectionPayloadSchema,
EditorialTileSectionPayloadSchema,
EditorialCarouselSectionPayloadSchema,
HeroArticlePayloadSchema,
RichTextPayloadSchema,
ScentNotesBreakdownPayloadSchema,
QuoteCalloutPayloadSchema,
ProductCarouselPayloadSchema,
HeroArticleSectionPayloadSchema,
RichTextSectionPayloadSchema,
ScentNotesBreakdownSectionPayloadSchema,
QuoteCalloutSectionPayloadSchema,
ProductCarouselSectionPayloadSchema,
AccordionSectionPayloadSchema,
ContactInfoSectionPayloadSchema,
ContactFormSectionPayloadSchema
} from "@homaroma/types";
import { z } from "zod";
import { adminOnly } from "../middleware/auth";
function isLocalizedStringValue(value: unknown): value is string | Record<string, string> {
if (typeof value === "string") return true;
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
return Object.values(value).every((entry) => typeof entry === "string");
}
function isLocalizedStringList(value: unknown): boolean {
return Array.isArray(value) && value.every((entry) => isLocalizedStringValue(entry));
}
function hasValidString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function hasValidNumber(value: unknown): boolean {
return typeof value === "number" && Number.isFinite(value);
}
function hasValidBoolean(value: unknown): boolean {
return typeof value === "boolean";
}
function validateHeroArticlePayload(payload: any): string | null {
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
if (!isLocalizedStringValue(payload.title)) return "title: Must be a localized string";
const localizedFields = ["subtitle", "author", "category"] as const;
for (const field of localizedFields) {
if (payload[field] !== undefined && !isLocalizedStringValue(payload[field])) {
return `${field}: Must be a localized string`;
}
}
const stringFields = ["publishDate", "date", "imageUrl", "backgroundImageUrl", "overlayColor"] as const;
for (const field of stringFields) {
if (payload[field] !== undefined && !hasValidString(payload[field])) {
return `${field}: Must be a string`;
}
}
return null;
}
function validateRichTextPayload(payload: any): string | null {
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
if (payload.title !== undefined && !isLocalizedStringValue(payload.title)) return "title: Must be a localized string";
if (!isLocalizedStringValue(payload.content)) return "content: Must be a localized string";
return null;
}
function validateScentNotesBreakdownPayload(payload: any): string | null {
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
if (payload.title !== undefined && !isLocalizedStringValue(payload.title)) return "title: Must be a localized string";
if (payload.description !== undefined && !isLocalizedStringValue(payload.description)) return "description: Must be a localized string";
const listFields = ["topNotes", "heartNotes", "middleNotes", "baseNotes"] as const;
for (const field of listFields) {
const value = payload[field];
if (value !== undefined && !(isLocalizedStringValue(value) || isLocalizedStringList(value))) {
return `${field}: Must be a localized string or localized string list`;
}
}
return null;
}
function validateQuoteCalloutPayload(payload: any): string | null {
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
if (!isLocalizedStringValue(payload.quote)) return "quote: Must be a localized string";
if (payload.author !== undefined && !isLocalizedStringValue(payload.author)) return "author: Must be a localized string";
if (payload.role !== undefined && !isLocalizedStringValue(payload.role)) return "role: Must be a localized string";
return null;
}
function validateProductCarouselPayload(payload: any): string | null {
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
if (payload.title !== undefined && !isLocalizedStringValue(payload.title)) return "title: Must be a localized string";
if (payload.description !== undefined && !isLocalizedStringValue(payload.description)) return "description: Must be a localized string";
if (payload.collectionId !== undefined && !hasValidString(payload.collectionId)) return "collectionId: Must be a string";
if (payload.limit !== undefined && (!hasValidNumber(payload.limit) || payload.limit <= 0)) return "limit: Must be a positive number";
if (payload.showPrice !== undefined && !hasValidBoolean(payload.showPrice)) return "showPrice: Must be a boolean";
if (payload.showAddToCart !== undefined && !hasValidBoolean(payload.showAddToCart)) return "showAddToCart: Must be a boolean";
return null;
}
function validateAccordionPayload(payload: any): string | null {
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
if (payload.title !== undefined && !isLocalizedStringValue(payload.title)) return "title: Must be a localized string";
if (!Array.isArray(payload.categories) || payload.categories.length === 0) return "categories: Must be a non-empty array";
for (const [catIndex, category] of payload.categories.entries()) {
if (!category || typeof category !== "object") return `categories.${catIndex}: Must be an object`;
if (!isLocalizedStringValue(category.name)) return `categories.${catIndex}.name: Must be a localized string`;
if (category.icon !== undefined && !hasValidString(category.icon)) return `categories.${catIndex}.icon: Must be a string`;
if (!Array.isArray(category.questions) || category.questions.length === 0) {
return `categories.${catIndex}.questions: Must be a non-empty array`;
}
for (const [qIndex, question] of category.questions.entries()) {
if (!question || typeof question !== "object") return `categories.${catIndex}.questions.${qIndex}: Must be an object`;
if (!isLocalizedStringValue(question.q)) return `categories.${catIndex}.questions.${qIndex}.q: Must be a localized string`;
if (!isLocalizedStringValue(question.a)) return `categories.${catIndex}.questions.${qIndex}.a: Must be a localized string`;
}
}
return null;
}
function validateContactInfoPayload(payload: any): string | null {
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
if (payload.title !== undefined && !isLocalizedStringValue(payload.title)) return "title: Must be a localized string";
for (const field of ["address", "email", "line", "hours"] as const) {
if (!isLocalizedStringValue(payload[field])) return `${field}: Must be a localized string`;
}
return null;
}
function validateContactFormPayload(payload: any): string | null {
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
if (payload.title !== undefined && !isLocalizedStringValue(payload.title)) return "title: Must be a localized string";
if (payload.subtitle !== undefined && !isLocalizedStringValue(payload.subtitle)) return "subtitle: Must be a localized string";
return null;
}
const RESERVED_SLUGS = [
"shop", "category", "products", "cart", "checkout", "account", "search",
"login", "admin", "wishlist", "track", "coupons", "scent-guide",
@@ -215,7 +100,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
if (payload.type === 'featured') {
products = await prisma.product.findMany({
where: { categoryId: 1 },
where: { categoryId: Number(payload.categoryId) || 1 },
include: productInclude,
take: limit
});
@@ -273,7 +158,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
if (payload.type === 'featured' || payload.collectionId === 'featured') {
products = await prisma.product.findMany({
where: { categoryId: 1 },
where: { categoryId: Number(payload.categoryId) || 1 },
include: productInclude,
take: limit
});
@@ -517,10 +402,10 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
const pageId = Number(slug);
const { type, order, payload, isActive } = body as any;
const validationError = validateSectionPayload(type, payload);
if (validationError) {
const validationResult = validateSectionPayload(type, payload);
if (!validationResult.success) {
set.status = 400;
return { error: validationError };
return { error: "Validation failed", details: validationResult.issues };
}
let finalOrder = order;
@@ -569,10 +454,10 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
}
validationType = existingSection.type;
}
const validationError = validateSectionPayload(validationType, payload);
if (validationError) {
const validationResult = validateSectionPayload(validationType, payload);
if (!validationResult.success) {
set.status = 400;
return { error: validationError };
return { error: "Validation failed", details: validationResult.issues };
}
}
@@ -637,100 +522,38 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
}
});
function validateSectionPayload(type: string, payload: any): string | null {
function validateSectionPayload(type: string, payload: any): { success: boolean; issues?: z.ZodIssue[] } {
if (!payload || typeof payload !== 'object') {
return "Payload must be a JSON object";
return { success: false, issues: [{ code: 'custom', message: "Payload must be a JSON object", path: [] }] as z.ZodIssue[] };
}
let schema: z.ZodTypeAny;
switch (type.toUpperCase()) {
case 'HERO': {
const res = HeroSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'CATEGORIES': {
const res = CategoriesSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'PRODUCTS': {
const res = ProductsSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'BANNER': {
const res = BannerSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'TEXT': {
const res = TextSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'ANNOUNCEMENT_BAR': {
const res = AnnouncementBarSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'NEWSLETTER': {
const res = NewsletterSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'EDITORIAL_TILE': {
const res = EditorialTileSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'EDITORIAL_CAROUSEL': {
const res = EditorialCarouselSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'HERO_ARTICLE': {
const error = validateHeroArticlePayload(payload);
if (error) return error;
break;
}
case 'RICH_TEXT': {
const error = validateRichTextPayload(payload);
if (error) return error;
break;
}
case 'SCENT_NOTES_BREAKDOWN': {
const error = validateScentNotesBreakdownPayload(payload);
if (error) return error;
break;
}
case 'QUOTE_CALLOUT': {
const error = validateQuoteCalloutPayload(payload);
if (error) return error;
break;
}
case 'PRODUCT_CAROUSEL': {
const error = validateProductCarouselPayload(payload);
if (error) return error;
break;
}
case 'ACCORDION': {
const error = validateAccordionPayload(payload);
if (error) return error;
break;
}
case 'CONTACT_INFO': {
const error = validateContactInfoPayload(payload);
if (error) return error;
break;
}
case 'CONTACT_FORM': {
const error = validateContactFormPayload(payload);
if (error) return error;
break;
}
case 'HERO': schema = HeroSectionPayloadSchema; break;
case 'CATEGORIES': schema = CategoriesSectionPayloadSchema; break;
case 'PRODUCTS': schema = ProductsSectionPayloadSchema; break;
case 'BANNER': schema = BannerSectionPayloadSchema; break;
case 'TEXT': schema = TextSectionPayloadSchema; break;
case 'ANNOUNCEMENT_BAR': schema = AnnouncementBarSectionPayloadSchema; break;
case 'NEWSLETTER': schema = NewsletterSectionPayloadSchema; break;
case 'EDITORIAL_TILE': schema = EditorialTileSectionPayloadSchema; break;
case 'EDITORIAL_CAROUSEL': schema = EditorialCarouselSectionPayloadSchema; break;
case 'HERO_ARTICLE': schema = HeroArticleSectionPayloadSchema; break;
case 'RICH_TEXT': schema = RichTextSectionPayloadSchema; break;
case 'SCENT_NOTES_BREAKDOWN': schema = ScentNotesBreakdownSectionPayloadSchema; break;
case 'QUOTE_CALLOUT': schema = QuoteCalloutSectionPayloadSchema; break;
case 'PRODUCT_CAROUSEL': schema = ProductCarouselSectionPayloadSchema; break;
case 'ACCORDION': schema = AccordionSectionPayloadSchema; break;
case 'CONTACT_INFO': schema = ContactInfoSectionPayloadSchema; break;
case 'CONTACT_FORM': schema = ContactFormSectionPayloadSchema; break;
default:
return `Unknown section type: ${type}`;
return { success: false, issues: [{ code: 'custom', message: `Unknown section type: ${type}`, path: [] }] as z.ZodIssue[] };
}
return null;
}
const res = schema.safeParse(payload);
if (!res.success) {
return { success: false, issues: res.error.issues };
}
return { success: true };
}
@@ -217,6 +217,7 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Featured Picks'
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const layout = typeof payload.layout === 'string' ? payload.layout : 'grid'
const products = getProducts(payload)
return (
@@ -232,9 +233,29 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
</Link>
</div>
{products.length ? (
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{products.map((product) => <ProductCard key={product.id} product={product} />)}
</div>
layout === 'carousel' ? (
<div className="relative -mx-4 px-4 sm:mx-0 sm:px-0">
<Swiper
slidesPerView={1.2}
spaceBetween={16}
breakpoints={{
640: { slidesPerView: 2.2, spaceBetween: 24 },
1024: { slidesPerView: 3, spaceBetween: 32 },
}}
className="w-full"
>
{products.map((product) => (
<SwiperSlide key={product.id} className="!h-auto pb-4">
<ProductCard product={product} />
</SwiperSlide>
))}
</Swiper>
</div>
) : (
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
{products.map((product) => <ProductCard key={product.id} product={product} />)}
</div>
)
) : (
<p className="ui-section-card bg-surface-card p-8 text-center text-muted-text">
{t('dynamic.emptyProducts', { lng: resolvedLang })}
+53 -1
View File
@@ -4,6 +4,9 @@
"workspaces": {
"": {
"name": "homaroma-monorepo",
"dependencies": {
"cheerio": "^1.2.0",
},
},
"apps/admin": {
"name": "admin",
@@ -53,6 +56,7 @@
"dotenv": "^17.4.0",
"elysia": "^1.2.25",
"pg": "^8.20.0",
"zod": "^4.4.3",
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
@@ -754,6 +758,8 @@
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
@@ -782,6 +788,10 @@
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
"cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
@@ -816,6 +826,10 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
@@ -874,8 +888,16 @@
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"dompurify": ["dompurify@3.4.10", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -894,8 +916,12 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="],
@@ -1090,6 +1116,8 @@
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="],
@@ -1098,7 +1126,7 @@
"i18next": ["i18next@26.3.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -1302,6 +1330,8 @@
"node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
@@ -1340,6 +1370,12 @@
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
@@ -1604,6 +1640,8 @@
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
@@ -1626,8 +1664,12 @@
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -1716,6 +1758,8 @@
"backend/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"c12/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"eslint-config-next/globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="],
@@ -1746,18 +1790,26 @@
"homaroma-website/react-i18next": ["react-i18next@15.7.4", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.4.0", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"is-bun-module/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"mysql2/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"sharp/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
+230
View File
@@ -0,0 +1,230 @@
import re
filePath = 'apps/backend/src/routes/pages.ts'
with open(filePath, 'r') as f:
content = f.read()
# 1. Update imports
old_imports = """import {
LocalizedStringSchema,
HeroSectionPayloadSchema,
CategoriesSectionPayloadSchema,
ProductsSectionPayloadSchema,
BannerSectionPayloadSchema,
TextSectionPayloadSchema,
AnnouncementBarSectionPayloadSchema,
NewsletterSectionPayloadSchema,
EditorialTileSectionPayloadSchema,
EditorialCarouselSectionPayloadSchema,
HeroArticlePayloadSchema,
RichTextPayloadSchema,
ScentNotesBreakdownPayloadSchema,
QuoteCalloutPayloadSchema,
ProductCarouselPayloadSchema,
AccordionSectionPayloadSchema,
ContactInfoSectionPayloadSchema,
ContactFormSectionPayloadSchema
} from "@homaroma/types";"""
new_imports = """import {
LocalizedStringSchema,
HeroSectionPayloadSchema,
CategoriesSectionPayloadSchema,
ProductsSectionPayloadSchema,
BannerSectionPayloadSchema,
TextSectionPayloadSchema,
AnnouncementBarSectionPayloadSchema,
NewsletterSectionPayloadSchema,
EditorialTileSectionPayloadSchema,
EditorialCarouselSectionPayloadSchema,
HeroArticleSectionPayloadSchema,
RichTextSectionPayloadSchema,
ScentNotesBreakdownSectionPayloadSchema,
QuoteCalloutSectionPayloadSchema,
ProductCarouselSectionPayloadSchema,
AccordionSectionPayloadSchema,
ContactInfoSectionPayloadSchema,
ContactFormSectionPayloadSchema
} from "@homaroma/types";
import { z } from "zod";"""
content = content.replace(old_imports, new_imports)
# 2. Remove manual validation functions
# From 'function isLocalizedStringValue' up to 'const RESERVED_SLUGS'
start_idx = content.find('function isLocalizedStringValue')
end_idx = content.find('const RESERVED_SLUGS')
if start_idx != -1 and end_idx != -1:
content = content[:start_idx] + content[end_idx:]
# 3. Refactor validateSectionPayload
old_validate_func = """function validateSectionPayload(type: string, payload: any): string | null {
if (!payload || typeof payload !== 'object') {
return "Payload must be a JSON object";
}
switch (type.toUpperCase()) {
case 'HERO': {
const res = HeroSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'CATEGORIES': {
const res = CategoriesSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'PRODUCTS': {
const res = ProductsSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'BANNER': {
const res = BannerSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'TEXT': {
const res = TextSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'ANNOUNCEMENT_BAR': {
const res = AnnouncementBarSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'NEWSLETTER': {
const res = NewsletterSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'EDITORIAL_TILE': {
const res = EditorialTileSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'EDITORIAL_CAROUSEL': {
const res = EditorialCarouselSectionPayloadSchema.safeParse(payload);
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
break;
}
case 'HERO_ARTICLE': {
const error = validateHeroArticlePayload(payload);
if (error) return error;
break;
}
case 'RICH_TEXT': {
const error = validateRichTextPayload(payload);
if (error) return error;
break;
}
case 'SCENT_NOTES_BREAKDOWN': {
const error = validateScentNotesBreakdownPayload(payload);
if (error) return error;
break;
}
case 'QUOTE_CALLOUT': {
const error = validateQuoteCalloutPayload(payload);
if (error) return error;
break;
}
case 'PRODUCT_CAROUSEL': {
const error = validateProductCarouselPayload(payload);
if (error) return error;
break;
}
case 'ACCORDION': {
const error = validateAccordionPayload(payload);
if (error) return error;
break;
}
case 'CONTACT_INFO': {
const error = validateContactInfoPayload(payload);
if (error) return error;
break;
}
case 'CONTACT_FORM': {
const error = validateContactFormPayload(payload);
if (error) return error;
break;
}
default:
return `Unknown section type: ${type}`;
}
return null;
}"""
new_validate_func = """function validateSectionPayload(type: string, payload: any): { success: boolean; issues?: z.ZodIssue[] } {
if (!payload || typeof payload !== 'object') {
return { success: false, issues: [{ code: 'custom', message: "Payload must be a JSON object", path: [] }] as z.ZodIssue[] };
}
let schema: z.ZodTypeAny;
switch (type.toUpperCase()) {
case 'HERO': schema = HeroSectionPayloadSchema; break;
case 'CATEGORIES': schema = CategoriesSectionPayloadSchema; break;
case 'PRODUCTS': schema = ProductsSectionPayloadSchema; break;
case 'BANNER': schema = BannerSectionPayloadSchema; break;
case 'TEXT': schema = TextSectionPayloadSchema; break;
case 'ANNOUNCEMENT_BAR': schema = AnnouncementBarSectionPayloadSchema; break;
case 'NEWSLETTER': schema = NewsletterSectionPayloadSchema; break;
case 'EDITORIAL_TILE': schema = EditorialTileSectionPayloadSchema; break;
case 'EDITORIAL_CAROUSEL': schema = EditorialCarouselSectionPayloadSchema; break;
case 'HERO_ARTICLE': schema = HeroArticleSectionPayloadSchema; break;
case 'RICH_TEXT': schema = RichTextSectionPayloadSchema; break;
case 'SCENT_NOTES_BREAKDOWN': schema = ScentNotesBreakdownSectionPayloadSchema; break;
case 'QUOTE_CALLOUT': schema = QuoteCalloutSectionPayloadSchema; break;
case 'PRODUCT_CAROUSEL': schema = ProductCarouselSectionPayloadSchema; break;
case 'ACCORDION': schema = AccordionSectionPayloadSchema; break;
case 'CONTACT_INFO': schema = ContactInfoSectionPayloadSchema; break;
case 'CONTACT_FORM': schema = ContactFormSectionPayloadSchema; break;
default:
return { success: false, issues: [{ code: 'custom', message: `Unknown section type: ${type}`, path: [] }] as z.ZodIssue[] };
}
const res = schema.safeParse(payload);
if (!res.success) {
return { success: false, issues: res.error.issues };
}
return { success: true };
}"""
# I need to use re.sub for validateSectionPayload in case of slight whitespace differences, but a simple find+replace is safer if exact.
# Let's just use re.sub with a regex that captures everything from `function validateSectionPayload(type: string, payload: any): string | null {` to the end of the file.
pattern = r'function validateSectionPayload\(type: string, payload: any\): string \| null \{[\s\S]*'
content = re.sub(pattern, new_validate_func, content)
# 4. Update error handling in POST and PUT
# POST
old_post_err = """ const validationError = validateSectionPayload(type, payload);
if (validationError) {
set.status = 400;
return { error: validationError };
}"""
new_post_err = """ const validationResult = validateSectionPayload(type, payload);
if (!validationResult.success) {
set.status = 400;
return { error: "Validation failed", details: validationResult.issues };
}"""
content = content.replace(old_post_err, new_post_err)
# PUT
old_put_err = """ const validationError = validateSectionPayload(validationType, payload);
if (validationError) {
set.status = 400;
return { error: validationError };
}"""
new_put_err = """ const validationResult = validateSectionPayload(validationType, payload);
if (!validationResult.success) {
set.status = 400;
return { error: "Validation failed", details: validationResult.issues };
}"""
content = content.replace(old_put_err, new_put_err)
with open(filePath, 'w') as f:
f.write(content)
+1
View File
@@ -0,0 +1 @@
<!DOCTYPE html><html lang="th" class="sarabun_388a5c3-module__3SHriq__variable kanit_f5fedee1-module__EPOHma__variable scroll-smooth"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/chunks/%5Broot-of-the-server%5D__0biig8i._.css" data-precedence="next_static/chunks/[root-of-the-server]__0biig8i._.css"/><link rel="stylesheet" href="/_next/static/chunks/07-k_swiper_swiper_00i-jcc.css" data-precedence="next_static/chunks/07-k_swiper_swiper_00i-jcc.css"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/%5Bturbopack%5D_browser_dev_hmr-client_hmr-client_ts_0wters9._.js"/><script src="/_next/static/chunks/0-d2_next_dist_compiled_next-devtools_index_0095.qm.js" async=""></script><script src="/_next/static/chunks/0-d2_next_dist_compiled_react-dom_04f_5_f._.js" async=""></script><script src="/_next/static/chunks/0-d2_next_dist_compiled_react-server-dom-turbopack_0ssyuat._.js" async=""></script><script src="/_next/static/chunks/0-d2_next_dist_compiled_0p9w5cq._.js" async=""></script><script src="/_next/static/chunks/0-d2_next_dist_client_0uxyif6._.js" async=""></script><script src="/_next/static/chunks/0-d2_next_dist_0tzo9q~._.js" async=""></script><script src="/_next/static/chunks/0~ve_%40swc_helpers_cjs_097dtp9._.js" async=""></script><script src="/_next/static/chunks/apps_website_0rqeker._.js" async=""></script><script src="/_next/static/chunks/turbopack-apps_website_0y.p17v._.js" async=""></script><script src="/_next/static/chunks/0-d2_next_dist_0ocmezb._.js" async=""></script><script src="/_next/static/chunks/0-d2_next_dist_client_components_builtin_not-found_03y66kz.js" async=""></script><script src="/_next/static/chunks/0-d2_next_dist_client_components_builtin_global-error_03y66kz.js" async=""></script><script src="/_next/static/chunks/_06n5idi._.js" async=""></script><script src="/_next/static/chunks/0-d2_next_0ik_n4d._.js" async=""></script><script src="/_next/static/chunks/0nx-_zod_v4_0lohmlh._.js" async=""></script><script src="/_next/static/chunks/node_modules__bun_0u-jy1s._.js" async=""></script><script src="/_next/static/chunks/apps_website_src_app_%5Blang%5D_layout_tsx_03y66kz._.js" async=""></script><script src="/_next/static/chunks/apps_website_src_components_08sg2.6._.js" async=""></script><script src="/_next/static/chunks/node_modules__bun_0u-0qmu._.js" async=""></script><script src="/_next/static/chunks/apps_website_src_app_%5Blang%5D_%5Bslug%5D_page_tsx_0085uc7._.js" async=""></script><meta name="next-size-adjust" content=""/><title>Mega 01 | HomAroma</title><meta name="description" content="Mega 01"/><link rel="canonical" href="http://localhost:3000/th/mega01"/><link rel="alternate" hrefLang="th" href="http://localhost:3000/th/mega01"/><link rel="alternate" hrefLang="en" href="http://localhost:3000/en/mega01"/><link rel="alternate" hrefLang="ja" href="http://localhost:3000/ja/mega01"/><link rel="alternate" hrefLang="zh" href="http://localhost:3000/zh/mega01"/><link rel="alternate" hrefLang="vi" href="http://localhost:3000/vi/mega01"/><link rel="alternate" hrefLang="ko" href="http://localhost:3000/ko/mega01"/><link rel="icon" href="/favicon.ico?favicon.0x3dzn~oxb6tn.ico" sizes="256x256" type="image/x-icon"/><script src="/_next/static/chunks/0-d2_next_dist_build_polyfills_polyfill-nomodule.js" noModule=""></script></head><body class="min-h-screen bg-surface-page text-text-main font-sans antialiased" style="--color-brand-primary:#FF6B6B;--color-brand-primary-hover:#FF4500;--color-brand-accent:#FFF200;--color-brand-warm:#FFA07A;--color-brand-cool:#E0FFFF;--color-brand-success:#98FB98;--color-brand-soft:#D8BFD8;--color-surface-page:#FFF0F5;--color-surface-nav-rest:#F9F8F5;--color-surface-card:#ffffff;--color-text-main:#333333;--color-muted-text:#666666;--color-line-primary:#00B900;--color-line-primary-hover:#009900;--shadow-card:0 10px 25px rgba(0,0,0,0.05);--shadow-card-hover:0 20px 40px rgba(0,0,0,0.1)"><div hidden=""><!--$--><!--/$--></div><nav data-scrolled="false" class="global-navbar fixed top-0 z-50 w-full border-b border-black/5 px-4 py-3.5 transition-all duration-300 md:px-6 md:py-4 bg-surface-page/80 backdrop-blur-xl md:bg-surface-nav-rest md:backdrop-blur-none"><div class="flex w-full items-center justify-between gap-4"><div class="flex shrink-0 items-center"><a class="ui-brand-wordmark" href="/th">HomAroma</a></div><div class="flex flex-1 justify-center"><div class="hidden space-x-7 text-sm font-medium uppercase tracking-wide md:flex"><div class="relative"><a class="flex items-center gap-1 border-b py-2 transition-colors border-transparent text-text-main hover:border-brand-primary hover:text-text-main" href="/th">ข่าวสาร</a></div><div class="relative"><a class="flex items-center gap-1 border-b py-2 transition-colors border-transparent text-text-main hover:border-brand-primary hover:text-text-main" href="/th/shop">ซื้อสินค้า<svg class=Line truncated
+3
View File
@@ -9,5 +9,8 @@
"dev": "npm run dev --workspaces --if-present",
"build": "npm run build --workspaces --if-present",
"test": "npm run test --workspaces --if-present"
},
"dependencies": {
"cheerio": "^1.2.0"
}
}
+46 -30
View File
@@ -271,6 +271,7 @@ export const ProductsSectionPayloadSchema = z.object({
title: LocalizedStringSchema,
subtitle: LocalizedStringSchema.optional(),
type: z.enum(['featured', 'brand', 'custom']),
categoryId: z.number().optional(),
brandId: z.number().optional(),
productIds: z.array(z.number()).optional(),
limit: z.number().optional(),
@@ -354,46 +355,59 @@ export interface CustomerAddress {
}
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(),
export const HeroArticleSectionPayloadSchema = z.object({
title: LocalizedStringSchema,
subtitle: LocalizedStringSchema.optional(),
author: LocalizedStringSchema.optional(),
category: LocalizedStringSchema.optional(),
publishDate: z.string().optional(),
date: z.string().optional(),
imageUrl: z.string().optional(),
backgroundImageUrl: z.string().optional(),
overlayColor: z.string().optional(),
});
export type HeroArticlePayload = z.infer<typeof HeroArticlePayloadSchema>;
export type HeroArticleSectionPayload = z.infer<typeof HeroArticleSectionPayloadSchema>;
export const RichTextPayloadSchema = z.object({
content: z.string().min(1, "Content is required"),
export const RichTextSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(),
content: LocalizedStringSchema,
});
export type RichTextPayload = z.infer<typeof RichTextPayloadSchema>;
export type RichTextSectionPayload = z.infer<typeof RichTextSectionPayloadSchema>;
export const ScentNotesBreakdownPayloadSchema = z.object({
export const ScentNotesBreakdownSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(),
description: LocalizedStringSchema.optional(),
topNotes: z.array(LocalizedStringSchema),
middleNotes: z.array(LocalizedStringSchema),
baseNotes: z.array(LocalizedStringSchema),
topNotes: z.union([LocalizedStringSchema, z.array(LocalizedStringSchema)]).optional(),
heartNotes: z.union([LocalizedStringSchema, z.array(LocalizedStringSchema)]).optional(),
middleNotes: z.union([LocalizedStringSchema, z.array(LocalizedStringSchema)]).optional(),
baseNotes: z.union([LocalizedStringSchema, z.array(LocalizedStringSchema)]).optional(),
});
export type ScentNotesBreakdownPayload = z.infer<typeof ScentNotesBreakdownPayloadSchema>;
export type ScentNotesBreakdownSectionPayload = z.infer<typeof ScentNotesBreakdownSectionPayloadSchema>;
export const QuoteCalloutPayloadSchema = z.object({
quote: z.string().min(1, "Quote is required"),
author: z.string().optional(),
export const QuoteCalloutSectionPayloadSchema = z.object({
quote: LocalizedStringSchema,
author: LocalizedStringSchema.optional(),
role: LocalizedStringSchema.optional(),
});
export type QuoteCalloutPayload = z.infer<typeof QuoteCalloutPayloadSchema>;
export type QuoteCalloutSectionPayload = z.infer<typeof QuoteCalloutSectionPayloadSchema>;
export const ProductCarouselPayloadSchema = z.object({
export const ProductCarouselSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(),
description: LocalizedStringSchema.optional(),
collectionId: z.string().optional(),
limit: z.number().int().positive().optional(),
limit: z.number().positive().optional(),
showPrice: z.boolean().optional(),
showAddToCart: z.boolean().optional(),
products: z.array(z.any()).optional(),
});
export type ProductCarouselPayload = z.infer<typeof ProductCarouselPayloadSchema>;
export type ProductCarouselSectionPayload = z.infer<typeof ProductCarouselSectionPayloadSchema>;
// Aliases for backwards compatibility in ScentGuideSectionSchema
export const HeroArticlePayloadSchema = HeroArticleSectionPayloadSchema;
export const RichTextPayloadSchema = RichTextSectionPayloadSchema;
export const ScentNotesBreakdownPayloadSchema = ScentNotesBreakdownSectionPayloadSchema;
export const QuoteCalloutPayloadSchema = QuoteCalloutSectionPayloadSchema;
export const ProductCarouselPayloadSchema = ProductCarouselSectionPayloadSchema;
export const ScentGuideSectionSchema = z.discriminatedUnion("type", [
z.object({
@@ -442,6 +456,7 @@ export const ScentGuidePageSchema = z.object({
export type ScentGuidePage = z.infer<typeof ScentGuidePageSchema>;
export const AccordionSectionPayloadSchema = z.object({
title: LocalizedStringSchema.optional(),
categories: z.array(
z.object({
name: LocalizedStringSchema,
@@ -451,23 +466,24 @@ export const AccordionSectionPayloadSchema = z.object({
q: LocalizedStringSchema,
a: LocalizedStringSchema,
})
),
).min(1),
})
),
).min(1),
});
export type AccordionSectionPayload = z.infer<typeof AccordionSectionPayloadSchema>;
export const ContactInfoSectionPayloadSchema = z.object({
address: z.string().min(1, "Address is required"),
email: z.string().min(1, "Email is required"),
line: z.string().min(1, "Line ID is required"),
hours: z.string().min(1, "Business hours are required"),
title: LocalizedStringSchema.optional(),
address: LocalizedStringSchema,
email: LocalizedStringSchema,
line: LocalizedStringSchema,
hours: LocalizedStringSchema,
});
export type ContactInfoSectionPayload = z.infer<typeof ContactInfoSectionPayloadSchema>;
export const ContactFormSectionPayloadSchema = z.object({
title: z.string().optional(),
subtitle: z.string().optional(),
title: LocalizedStringSchema.optional(),
subtitle: LocalizedStringSchema.optional(),
});
export type ContactFormSectionPayload = z.infer<typeof ContactFormSectionPayloadSchema>;
+31
View File
@@ -0,0 +1,31 @@
const cheerio = require('cheerio');
const fs = require('fs');
const html = fs.readFileSync('mega01.html', 'utf8');
const $ = cheerio.load(html);
console.log('--- Page Title ---');
console.log($('title').text());
console.log('\n--- Main Content Sections ---');
const sections = [];
$('section, .section, [data-section]').each((i, el) => {
sections.push({
index: i + 1,
id: $(el).attr('id') || 'no-id',
class: $(el).attr('class') || 'no-class',
text: $(el).text().replace(/\s+/g, ' ').substring(0, 500)
});
});
if (sections.length === 0) {
// If no semantic tags, maybe just print divs with specific classes
$('div').each((i, el) => {
if($(el).attr('class') && $(el).attr('class').includes('py-')) {
sections.push({
index: i + 1,
class: $(el).attr('class') || 'no-class',
text: $(el).text().replace(/\s+/g, ' ').substring(0, 500)
});
}
});
}
console.log(JSON.stringify(sections, null, 2));
+89
View File
@@ -0,0 +1,89 @@
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, 'apps/backend/src/routes/pages.ts');
let content = fs.readFileSync(filePath, 'utf8');
content = content.replace(/import \{[\s\S]*?\} from "@homaroma\/types";/, `import {
LocalizedStringSchema,
HeroSectionPayloadSchema,
CategoriesSectionPayloadSchema,
ProductsSectionPayloadSchema,
BannerSectionPayloadSchema,
TextSectionPayloadSchema,
AnnouncementBarSectionPayloadSchema,
NewsletterSectionPayloadSchema,
EditorialTileSectionPayloadSchema,
EditorialCarouselSectionPayloadSchema,
HeroArticleSectionPayloadSchema,
RichTextSectionPayloadSchema,
ScentNotesBreakdownSectionPayloadSchema,
QuoteCalloutSectionPayloadSchema,
ProductCarouselSectionPayloadSchema,
AccordionSectionPayloadSchema,
ContactInfoSectionPayloadSchema,
ContactFormSectionPayloadSchema
} from "@homaroma/types";
import { z } from "zod";`);
// Remove manual validation functions
content = content.replace(/function isLocalizedStringValue[\s\S]*?function validateContactFormPayload[^{]*\{[\s\S]*?return null;\n\}/, '');
// Refactor validateSectionPayload
content = content.replace(/function validateSectionPayload\(type: string, payload: any\): string \| null \{[\s\S]*?return null;\n\}/, `function validateSectionPayload(type: string, payload: any): { success: boolean; issues?: z.ZodIssue[] } {
if (!payload || typeof payload !== 'object') {
return { success: false, issues: [{ code: 'custom', message: "Payload must be a JSON object", path: [] }] };
}
let schema: z.ZodTypeAny;
switch (type.toUpperCase()) {
case 'HERO': schema = HeroSectionPayloadSchema; break;
case 'CATEGORIES': schema = CategoriesSectionPayloadSchema; break;
case 'PRODUCTS': schema = ProductsSectionPayloadSchema; break;
case 'BANNER': schema = BannerSectionPayloadSchema; break;
case 'TEXT': schema = TextSectionPayloadSchema; break;
case 'ANNOUNCEMENT_BAR': schema = AnnouncementBarSectionPayloadSchema; break;
case 'NEWSLETTER': schema = NewsletterSectionPayloadSchema; break;
case 'EDITORIAL_TILE': schema = EditorialTileSectionPayloadSchema; break;
case 'EDITORIAL_CAROUSEL': schema = EditorialCarouselSectionPayloadSchema; break;
case 'HERO_ARTICLE': schema = HeroArticleSectionPayloadSchema; break;
case 'RICH_TEXT': schema = RichTextSectionPayloadSchema; break;
case 'SCENT_NOTES_BREAKDOWN': schema = ScentNotesBreakdownSectionPayloadSchema; break;
case 'QUOTE_CALLOUT': schema = QuoteCalloutSectionPayloadSchema; break;
case 'PRODUCT_CAROUSEL': schema = ProductCarouselSectionPayloadSchema; break;
case 'ACCORDION': schema = AccordionSectionPayloadSchema; break;
case 'CONTACT_INFO': schema = ContactInfoSectionPayloadSchema; break;
case 'CONTACT_FORM': schema = ContactFormSectionPayloadSchema; break;
default:
return { success: false, issues: [{ code: 'custom', message: \`Unknown section type: \${type}\`, path: [] }] };
}
const res = schema.safeParse(payload);
if (!res.success) {
return { success: false, issues: res.error.issues };
}
return { success: true };
}`);
// Update error handling in POST /:slug/sections
content = content.replace(
/const validationError = validateSectionPayload\(type, payload\);\n\s*if \(validationError\) \{\n\s*set\.status = 400;\n\s*return \{ error: validationError \};\n\s*\}/g,
`const validationResult = validateSectionPayload(type, payload);
if (!validationResult.success) {
set.status = 400;
return { error: "Validation failed", details: validationResult.issues };
}`
);
// Update error handling in PUT /sections/:sectionId
content = content.replace(
/const validationError = validateSectionPayload\(validationType, payload\);\n\s*if \(validationError\) \{\n\s*set\.status = 400;\n\s*return \{ error: validationError \};\n\s*\}/g,
`const validationResult = validateSectionPayload(validationType, payload);
if (!validationResult.success) {
set.status = 400;
return { error: "Validation failed", details: validationResult.issues };
}`
);
fs.writeFileSync(filePath, content);