x
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
Reference in new issue
Block a user