=> (
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value)
+)
+
+export const validateCategoryHeroCarouselPayload = (payload: unknown): string[] => {
+ if (!isRecord(payload) || !Array.isArray(payload.slides) || payload.slides.length === 0) {
+ return ['Add at least one slide.']
+ }
+
+ const issues = payload.slides.flatMap((slide, index) => {
+ if (!isRecord(slide) || typeof slide.imageUrl !== 'string' || slide.imageUrl.trim().length === 0) {
+ return [`Slide ${index + 1}: Desktop image is required.`]
+ }
+ return []
+ })
+
+ if (
+ payload.autoplay !== false
+ && (typeof payload.interval !== 'number' || !Number.isFinite(payload.interval) || payload.interval < 1000)
+ ) {
+ issues.push('Autoplay interval must be at least 1000 ms.')
+ }
+
+ return issues
+}
diff --git a/apps/admin/src/pages/CategoriesPage.tsx b/apps/admin/src/pages/CategoriesPage.tsx
index 7b163d3..4907039 100644
--- a/apps/admin/src/pages/CategoriesPage.tsx
+++ b/apps/admin/src/pages/CategoriesPage.tsx
@@ -13,7 +13,8 @@ import {
Search,
Globe,
Layers,
- FileJson
+ FileJson,
+ LayoutTemplate
} from 'lucide-react'
import CategoryBulkJsonModal from '../components/CategoryBulkJsonModal'
@@ -300,6 +301,13 @@ export default function CategoriesPage() {
{/* Actions */}
e.stopPropagation()}>
+
+
+
e.stopPropagation()}>
+
+
+
{
+ if (!data || typeof data !== 'object') return fallback
+
+ const response = data as { message?: unknown; issues?: unknown }
+ const message = typeof response.message === 'string' ? response.message : fallback
+ if (!Array.isArray(response.issues)) return message
+
+ const issueMessages = response.issues
+ .filter((issue): issue is ApiValidationIssue => Boolean(issue) && typeof issue === 'object')
+ .map((issue) => {
+ const detail = typeof issue.message === 'string' ? issue.message : ''
+ const path = typeof issue.path === 'string' ? issue.path : ''
+ return [path, detail].filter(Boolean).join(': ')
+ })
+ .filter(Boolean)
+
+ return issueMessages.length > 0 ? `${message} — ${issueMessages.join('; ')}` : message
+}
+
+// Exactly the blocks requested: Native blocks + Hero Promotional Slider + Divider
+const CATEGORY_SECTION_TYPES = [
+ 'CATEGORY_HEADER',
+ 'CATALOG_GRID',
+ 'USP_BAR',
+ 'HERO_CAROUSEL',
+ 'DIVIDER',
+] as const
+
+const CATEGORY_NATIVE_SECTION_TYPES = new Set([
+ 'CATEGORY_HEADER',
+ 'CATALOG_GRID',
+ 'USP_BAR',
+])
+
+const CATEGORY_STRUCTURED_SECTION_TYPES = new Set([
+ ...CATEGORY_NATIVE_SECTION_TYPES,
+ 'HERO_CAROUSEL',
+ 'DIVIDER',
+])
+
+const SECTION_LABELS: Record = {
+ CATEGORY_HEADER: 'Category header',
+ CATALOG_GRID: 'Product catalog',
+ USP_BAR: 'USP benefits',
+ HERO_CAROUSEL: 'Hero carousel (Wide Promo Slider)',
+ DIVIDER: 'Divider',
+}
+
+const SECTION_HELP: Record = {
+ CATEGORY_HEADER: 'Live category art-directed hero banner, title, description, and navigation pills.',
+ CATALOG_GRID: 'Product catalog grid, sorting toolbar, and facet filter sidebar.',
+ USP_BAR: 'Benefits and support USP blocks configured for this category.',
+ HERO_CAROUSEL: 'Category-specific wide promotional slider with responsive desktop and mobile artwork.',
+ DIVIDER: 'Configurable horizontal separator between category CMS blocks.',
+}
+
+const NATIVE_BLOCKS = [
+ { type: 'CATEGORY_HEADER', label: 'Category header', icon: PanelTop },
+ { type: 'CATALOG_GRID', label: 'Product catalog', icon: Grid3X3 },
+ { type: 'USP_BAR', label: 'USP benefits', icon: Shield },
+] as const
+
+const createDefaultCategoryPayload = (type: string) => {
+ switch (type) {
+ case 'CATEGORY_HEADER':
+ return {
+ showBreadcrumbs: true,
+ showCategoryNav: true,
+ showHeading: true,
+ title: { th: '', en: '' },
+ subtitle: { th: '', en: '' },
+ layoutType: 'split_showcase',
+ }
+ case 'CATALOG_GRID':
+ return {
+ showFilters: true,
+ showToolbar: true,
+ itemsPerPage: 24,
+ }
+ case 'USP_BAR':
+ return {
+ showUsp: true,
+ }
+ case 'HERO_CAROUSEL':
+ return {
+ slides: [
+ {
+ imageUrl: '',
+ mobileImageUrl: '',
+ linkUrl: '',
+ alt: { th: '', en: '' },
+ title: { th: '', en: '' },
+ subtitle: { th: '', en: '' },
+ buttonText: { th: '', en: '' },
+ focalPoint: { x: '50%', y: '50%' },
+ },
+ ],
+ autoplay: true,
+ interval: 4500,
+ aspectRatio: '21/7',
+ mobileAspectRatio: '16/9',
+ showArrows: true,
+ showDots: true,
+ }
+ case 'DIVIDER':
+ return { width: 'container', style: 'solid', thickness: 1, spacing: 'md', color: '#94a3b8' }
+ default:
+ return {}
+ }
+}
+
+export default function CategoryCmsPage() {
+ const { id } = useParams<{ id: string }>()
+ const navigate = useNavigate()
+
+ const [category, setCategory] = useState(null)
+ const [detailSections, setDetailSections] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [sectionsSaving, setSectionsSaving] = useState(false)
+ const [selectedSectionId, setSelectedSectionId] = useState('new')
+ const [sectionType, setSectionType] = useState('HERO_CAROUSEL')
+ const [sectionPayload, setSectionPayload] = useState>(createDefaultCategoryPayload('HERO_CAROUSEL'))
+ const [sectionJson, setSectionJson] = useState(JSON.stringify(createDefaultCategoryPayload('HERO_CAROUSEL'), null, 2))
+ const [sectionIsActive, setSectionIsActive] = useState(true)
+ const [sectionSaveError, setSectionSaveError] = useState(null)
+ const [activeLang, setActiveLang] = useState('th')
+ const [error, setError] = useState(null)
+ const [success, setSuccess] = useState(false)
+ const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
+ const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
+ const [isAddBlockModalOpen, setIsAddBlockModalOpen] = useState(false)
+
+ const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']
+
+ const selectedSection = selectedSectionId === 'new'
+ ? null
+ : detailSections.find((section) => section.id === selectedSectionId) || null
+
+ const nativeSectionCount = detailSections.filter((section) => CATEGORY_NATIVE_SECTION_TYPES.has(section.type)).length
+ const inactiveSectionCount = detailSections.filter((section) => !section.isActive).length
+
+ const resetDetailSectionEditor = (nextType = 'HERO_CAROUSEL') => {
+ const payload = createDefaultCategoryPayload(nextType)
+ setSelectedSectionId('new')
+ setSectionType(nextType)
+ setSectionPayload(payload)
+ setSectionJson(JSON.stringify(payload, null, 2))
+ setSectionIsActive(true)
+ setSectionSaveError(null)
+ }
+
+ const startNewSection = (nextType: string) => {
+ resetDetailSectionEditor(nextType)
+ }
+
+ const selectDetailSection = (section: CategorySection) => {
+ const payload = section.payload && typeof section.payload === 'object' && !Array.isArray(section.payload) ? section.payload : {}
+ setSelectedSectionId(section.id)
+ setSectionType(section.type)
+ setSectionPayload(payload)
+ setSectionJson(JSON.stringify(payload, null, 2))
+ setSectionIsActive(section.isActive)
+ setSectionSaveError(null)
+ }
+
+ const fetchDetailSections = async (catId: string | number) => {
+ const res = await fetch(`${API_BASE}/categories/${catId}/sections?includeInactive=true&lang=all`)
+ if (!res.ok) throw new Error('Failed to load category detail sections')
+ const data: CategorySection[] = await res.json()
+ setDetailSections(data)
+ if (data.length > 0) {
+ selectDetailSection(data[0])
+ } else {
+ resetDetailSectionEditor()
+ }
+ }
+
+ useEffect(() => {
+ const fetchData = async () => {
+ if (!id) return
+ setLoading(true)
+ setError(null)
+ try {
+ const categoryRes = await fetch(`${API_BASE}/categories/${id}?lang=all`)
+ if (!categoryRes.ok) throw new Error('Category not found')
+ const categoryData = await categoryRes.json()
+ setCategory(categoryData)
+ await fetchDetailSections(id)
+ } catch (err: any) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchData()
+ }, [id])
+
+ const syncNativePayload = (field: string, value: unknown) => {
+ setSectionPayload((prev) => {
+ const next = { ...prev, [field]: value }
+ setSectionJson(JSON.stringify(next, null, 2))
+ return next
+ })
+ }
+
+ const syncNativeLocalizedPayload = (field: string, value: string) => {
+ setSectionPayload((prev) => {
+ const current = prev[field]
+ const next = {
+ ...prev,
+ [field]: {
+ ...(current && typeof current === 'object' && !Array.isArray(current) ? current : {}),
+ [activeLang]: value,
+ },
+ }
+ setSectionJson(JSON.stringify(next, null, 2))
+ return next
+ })
+ }
+
+ const getSectionLocalizedValue = (field: string) => {
+ const value = sectionPayload[field]
+ if (typeof value === 'string') return value
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ const localized = value as Record
+ return localized[activeLang] || localized.th || localized.en || ''
+ }
+ return ''
+ }
+
+ const openMediaPicker = (onSelect: (url: string) => void) => {
+ setOnSelectMedia(() => onSelect)
+ setIsMediaModalOpen(true)
+ }
+
+ const handleSeedDetailSections = async () => {
+ if (!id) return
+ setSectionsSaving(true)
+ setError(null)
+ try {
+ const response = await fetch(`${API_BASE}/categories/${id}/sections/seed-default`, { method: 'POST' })
+ const data = await response.json().catch(() => null)
+ if (!response.ok) throw new Error(data?.message || 'Failed to seed category detail sections')
+ await fetchDetailSections(id)
+ setSuccess(true)
+ setTimeout(() => setSuccess(false), 2500)
+ } catch (err: any) {
+ setError(err.message)
+ } finally {
+ setSectionsSaving(false)
+ }
+ }
+
+ const handleSaveDetailSection = async () => {
+ if (!id) return
+ setSectionsSaving(true)
+ setError(null)
+ setSectionSaveError(null)
+ try {
+ const payload = CATEGORY_STRUCTURED_SECTION_TYPES.has(sectionType) ? sectionPayload : JSON.parse(sectionJson || '{}')
+ if (sectionType === 'HERO_CAROUSEL') {
+ const validationIssues = validateCategoryHeroCarouselPayload(payload)
+ if (validationIssues.length > 0) throw new Error(validationIssues.join(' '))
+ }
+ const isNewSection = selectedSectionId === 'new'
+ const response = await fetch(
+ isNewSection ? `${API_BASE}/categories/${id}/sections` : `${API_BASE}/categories/sections/${selectedSectionId}`,
+ {
+ method: isNewSection ? 'POST' : 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ type: sectionType, payload, isActive: sectionIsActive }),
+ }
+ )
+ const data = await response.json().catch(() => null)
+ if (!response.ok) throw new Error(getApiErrorMessage(data, 'Failed to save category detail section'))
+ await fetchDetailSections(id)
+ if (data?.section) selectDetailSection(data.section)
+ setSuccess(true)
+ setTimeout(() => setSuccess(false), 2500)
+ } catch (err: any) {
+ const message = err instanceof Error ? err.message : 'Failed to save category detail section'
+ setError(message)
+ setSectionSaveError(message)
+ } finally {
+ setSectionsSaving(false)
+ }
+ }
+
+ const handleDeleteDetailSection = async (section: CategorySection) => {
+ if (!id || section.isLocked) return
+ setSectionsSaving(true)
+ setError(null)
+ try {
+ const response = await fetch(`${API_BASE}/categories/sections/${section.id}`, { method: 'DELETE' })
+ const data = await response.json().catch(() => null)
+ if (!response.ok) throw new Error(data?.message || 'Failed to delete category detail section')
+ await fetchDetailSections(id)
+ } catch (err: any) {
+ setError(err.message)
+ } finally {
+ setSectionsSaving(false)
+ }
+ }
+
+ const handleToggleDetailSection = async (section: CategorySection) => {
+ if (!id || section.isLocked) return
+ setSelectedSectionId(section.id)
+ setSectionsSaving(true)
+ setError(null)
+ try {
+ const response = await fetch(`${API_BASE}/categories/sections/${section.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ type: section.type, payload: section.payload, isActive: !section.isActive }),
+ })
+ const data = await response.json().catch(() => null)
+ if (!response.ok) throw new Error(data?.message || 'Failed to update category detail section')
+ await fetchDetailSections(id)
+ } catch (err: any) {
+ setError(err.message)
+ } finally {
+ setSectionsSaving(false)
+ }
+ }
+
+ const handleMoveDetailSection = async (index: number, direction: -1 | 1) => {
+ if (!id) return
+ const targetIndex = index + direction
+ if (targetIndex < 0 || targetIndex >= detailSections.length) return
+ if (detailSections[index]?.isLocked || detailSections[targetIndex]?.isLocked) return
+
+ const reordered = [...detailSections]
+ const [moved] = reordered.splice(index, 1)
+ reordered.splice(targetIndex, 0, moved)
+ const sections = reordered.map((section, idx) => ({ id: section.id, order: idx + 1 }))
+ setDetailSections(reordered.map((section, idx) => ({ ...section, order: idx + 1 })))
+
+ setSectionsSaving(true)
+ setError(null)
+ try {
+ const response = await fetch(`${API_BASE}/categories/${id}/sections/reorder`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ sections }),
+ })
+ const data = await response.json().catch(() => null)
+ if (!response.ok) throw new Error(data?.message || 'Failed to reorder category detail sections')
+ await fetchDetailSections(id)
+ } catch (err: any) {
+ setError(err.message)
+ } finally {
+ setSectionsSaving(false)
+ }
+ }
+
+ if (loading) {
+ return (
+
+
+ Loading category CMS...
+
+ )
+ }
+
+ if (error && !category) {
+ return (
+
+
+ Error
+ {error}
+
+
+ Back to Categories
+
+
+ )
+ }
+
+ const categoryName = getAdminDisplayString(category?.name) || `Category ${id}`
+
+ return (
+
+ {/* Top Header Bar */}
+
+
+
+ Categories
+ /
+ Category CMS
+
+
+ Category CMS {categoryName}
+
+ Manage category detail sections without changing category data.
+
+
+
+
+
+
+
+
+ {/* Notifications */}
+ {success && (
+
+
+ Category CMS updated successfully.
+
+ )}
+
+ {error && (
+
+ )}
+
+ {/* Top 4 Stats Cards */}
+
+
+ Total Blocks
+ {detailSections.length}
+
+
+ Native Blocks
+ {nativeSectionCount}
+
+
+ Hidden
+ {inactiveSectionCount}
+
+
+ Mode
+ {selectedSectionId === 'new' ? 'Create block' : 'Edit selected block'}
+
+
+
+ {/* Main Page Builder Section */}
+
+ {/* Section Header with Language Switcher and Add Block Button */}
+
+
+
+
+ Category Detail Page Builder
+ Start with native blocks. Use content blocks only when this category needs extra storytelling.
+
+
+
+
+ {languages.map((lang) => (
+
+ ))}
+
+ {detailSections.length === 0 && (
+
+ )}
+
+
+
+
+ {/* Native Category Blocks Quick Bar */}
+
+
+
+
+
+ Native Category Blocks
+
+ These blocks render live category data and are the recommended starting point.
+
+
+ {NATIVE_BLOCKS.map(({ type, label, icon: Icon }) => (
+
+ ))}
+
+
+
+
+ {/* 2-Column Section Order & Block Form */}
+
+ {/* Left Column: Section Order */}
+
+
+
+ Section Order
+ Move blocks to control the category detail page layout.
+
+
+
+
+ {detailSections.length === 0 && (
+
+
+ No category CMS blocks yet.
+ Seed the standard layout or choose a native block above.
+
+ )}
+
+ {detailSections.map((section, index) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {/* Right Column: Editing Block */}
+
+
+
+
+
+ {selectedSectionId === 'new' ? 'New Block' : 'Editing Block'}
+
+ {SECTION_LABELS[sectionType] || sectionType}
+
+ {SECTION_HELP[sectionType] || 'Configure the section payload for this category detail page.'}
+
+
+ {selectedSection?.isLocked && (
+
+
+ Locked
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Block Form Container */}
+
+ {CATEGORY_NATIVE_SECTION_TYPES.has(sectionType) ? (
+
+
+ Native Category Settings
+ These controls affect headers, navigation, and presentation. Live products come from this category.
+
+
+ {sectionType === 'CATEGORY_HEADER' && (
+
+ )}
+
+ {sectionType === 'CATALOG_GRID' && (
+
+ )}
+
+ {sectionType === 'USP_BAR' && (
+
+
+ Renders the 3 USP benefit columns configured for this category.
+
+ )}
+
+ ) : (
+
+
+
+ Category Content Block
+
+ These forms follow the Page Builder block design, but stay scoped to this category.
+
+
+ {sectionType === 'HERO_CAROUSEL' && (
+ {
+ setSectionPayload(nextPayload)
+ setSectionJson(JSON.stringify(nextPayload, null, 2))
+ }}
+ onBrowseMedia={openMediaPicker}
+ />
+ )}
+
+ {sectionType === 'DIVIDER' && (
+ {
+ setSectionPayload(nextPayload)
+ setSectionJson(JSON.stringify(nextPayload, null, 2))
+ }}
+ />
+ )}
+
+ )}
+
+ {/* Bottom Save Bar */}
+
+ {sectionSaveError && (
+
+ )}
+
+
+
+
+
+
+
+
+ {/* Media Library Modal */}
+ setIsMediaModalOpen(false)}
+ onSelect={(url) => {
+ if (onSelectMedia) onSelectMedia(url)
+ setIsMediaModalOpen(false)
+ }}
+ />
+
+ {/* Add Block Modal (Focused specifically on Hero Carousel and Divider) */}
+ {isAddBlockModalOpen && (
+
+
+
+
+ Add Page Block
+ Choose a section type to insert into this category detail page.
+
+
+
+
+
+ Content Blocks
+
+
+
+
+
+
+ Native Category Blocks
+
+ {NATIVE_BLOCKS.map(({ type, label, icon: Icon }) => (
+
+ ))}
+
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/admin/src/pages/CategoryEditPage.tsx b/apps/admin/src/pages/CategoryEditPage.tsx
index eff5ee9..2628ced 100644
--- a/apps/admin/src/pages/CategoryEditPage.tsx
+++ b/apps/admin/src/pages/CategoryEditPage.tsx
@@ -463,6 +463,16 @@ export default function CategoryEditPage() {
)}
+ {!isNew && (
+
+
+ CMS Blocks
+
+ )}
+
- app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute)
+ app.use(authRoute).use(categoriesRoute).use(categorySectionsRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute)
)
.listen({ port: PORT, hostname: "0.0.0.0" });
diff --git a/apps/backend/src/lib/categorySections.ts b/apps/backend/src/lib/categorySections.ts
new file mode 100644
index 0000000..af1587d
--- /dev/null
+++ b/apps/backend/src/lib/categorySections.ts
@@ -0,0 +1,228 @@
+import {
+ PAGE_SECTION_PAYLOAD_SCHEMAS,
+ CATEGORY_SECTION_TYPES,
+ type CategorySectionType,
+} from "@homaroma/types";
+import type { z } from "zod";
+
+export type CategorySectionValidationResult =
+ | { success: true; type: CategorySectionType; data: unknown }
+ | { success: false; issues: z.ZodIssue[] };
+
+export function defaultCategorySections(categoryId: number) {
+ return [
+ {
+ categoryId,
+ type: "CATEGORY_HEADER",
+ order: 1,
+ payload: {
+ showBreadcrumbs: true,
+ showCategoryNav: true,
+ title: { th: "", en: "" },
+ subtitle: { th: "", en: "" },
+ showHeading: true,
+ },
+ isActive: true,
+ isLocked: false,
+ },
+ {
+ categoryId,
+ type: "CATALOG_GRID",
+ order: 2,
+ payload: {
+ showFilters: true,
+ showToolbar: true,
+ itemsPerPage: 24,
+ },
+ isActive: true,
+ isLocked: false,
+ },
+ {
+ categoryId,
+ type: "USP_BAR",
+ order: 3,
+ payload: {
+ showUsp: true,
+ },
+ isActive: true,
+ isLocked: false,
+ },
+ ];
+}
+
+interface CategorySectionTransaction {
+ categorySection: {
+ count(args: any): Promise;
+ createMany(args: any): Promise;
+ findFirst(args: any): Promise<{ id?: number; order: number; type?: string } | null>;
+ update(args: any): Promise;
+ updateMany(args: any): Promise;
+ create(args: any): Promise;
+ };
+}
+
+interface CreateCategorySectionInput {
+ categoryId: number;
+ type: CategorySectionType;
+ payload: unknown;
+ order?: number;
+ isActive: boolean;
+ isLocked: boolean;
+}
+
+export async function createCategorySection(
+ tx: CategorySectionTransaction,
+ input: CreateCategorySectionInput,
+) {
+ const existingCount = await tx.categorySection.count({
+ where: { categoryId: input.categoryId },
+ });
+
+ if (existingCount === 0) {
+ await tx.categorySection.createMany({
+ data: defaultCategorySections(input.categoryId),
+ });
+
+ const seededSection = await tx.categorySection.findFirst({
+ where: { categoryId: input.categoryId, type: input.type },
+ });
+ if (seededSection?.id !== undefined) {
+ return await tx.categorySection.update({
+ where: { id: seededSection.id },
+ data: {
+ payload: input.payload,
+ isActive: input.isActive,
+ isLocked: input.isLocked,
+ ...(input.order !== undefined ? { order: input.order } : {}),
+ },
+ });
+ }
+ }
+
+ const lastSection = await tx.categorySection.findFirst({
+ where: { categoryId: input.categoryId },
+ orderBy: { order: "desc" },
+ });
+
+ return await tx.categorySection.create({
+ data: {
+ categoryId: input.categoryId,
+ type: input.type,
+ order: input.order ?? (lastSection?.order || 0) + 1,
+ payload: input.payload,
+ isActive: input.isActive,
+ isLocked: input.isLocked,
+ },
+ });
+}
+
+export function isCategorySectionType(
+ type: string,
+): type is CategorySectionType {
+ return (CATEGORY_SECTION_TYPES as readonly string[]).includes(type);
+}
+
+export function createDefaultCategorySectionPayload(type: string): Record {
+ const normalizedType = type.toUpperCase();
+ switch (normalizedType) {
+ case "CATEGORY_HEADER":
+ return {
+ showBreadcrumbs: true,
+ showCategoryNav: true,
+ title: { th: "", en: "" },
+ subtitle: { th: "", en: "" },
+ showHeading: true,
+ };
+ case "CATALOG_GRID":
+ return {
+ showFilters: true,
+ showToolbar: true,
+ itemsPerPage: 24,
+ };
+ case "USP_BAR":
+ return {
+ showUsp: true,
+ };
+ case "HERO_CAROUSEL":
+ return {
+ slides: [
+ {
+ imageUrl: "",
+ mobileImageUrl: "",
+ linkUrl: "",
+ alt: { th: "", en: "" },
+ title: { th: "", en: "" },
+ subtitle: { th: "", en: "" },
+ buttonText: { th: "", en: "" },
+ focalPoint: { x: "50%", y: "50%" },
+ },
+ ],
+ autoplay: true,
+ interval: 4500,
+ aspectRatio: "21/7",
+ mobileAspectRatio: "16/9",
+ showArrows: true,
+ showDots: true,
+ };
+ case "DIVIDER":
+ return {
+ width: "container",
+ style: "solid",
+ thickness: 1,
+ spacing: "md",
+ color: "#94a3b8",
+ };
+ default:
+ return {};
+ }
+}
+
+export function validateCategorySectionPayload(
+ type: string,
+ payload: unknown,
+): CategorySectionValidationResult {
+ const normalizedType = type.toUpperCase();
+
+ if (!isCategorySectionType(normalizedType)) {
+ return {
+ success: false,
+ issues: [
+ {
+ code: "custom",
+ message: `Unknown category section type: ${type}`,
+ path: [],
+ },
+ ] as z.ZodIssue[],
+ };
+ }
+
+ let finalPayload = payload;
+ if (!finalPayload || typeof finalPayload !== "object" || Array.isArray(finalPayload) || Object.keys(finalPayload).length === 0) {
+ finalPayload = createDefaultCategorySectionPayload(normalizedType);
+ }
+
+ if (normalizedType === "HERO_CAROUSEL" && finalPayload && typeof finalPayload === "object") {
+ const slides = (finalPayload as any).slides;
+ if (Array.isArray(slides)) {
+ finalPayload = {
+ ...finalPayload,
+ slides: slides.map((s: any) => ({
+ ...s,
+ imageUrl: (typeof s?.imageUrl === "string" && s.imageUrl.trim()) ? s.imageUrl.trim() : "/placeholder.svg",
+ })),
+ };
+ }
+ }
+
+ const schema = (PAGE_SECTION_PAYLOAD_SCHEMAS as any)[normalizedType];
+ if (!schema) {
+ return { success: true, type: normalizedType, data: finalPayload };
+ }
+
+ const result = schema.safeParse(finalPayload);
+ if (!result.success) {
+ return { success: false, issues: result.error.issues };
+ }
+
+ return { success: true, type: normalizedType, data: result.data };
+}
diff --git a/apps/backend/src/lib/prisma.ts b/apps/backend/src/lib/prisma.ts
index e2883ba..a6b632f 100644
--- a/apps/backend/src/lib/prisma.ts
+++ b/apps/backend/src/lib/prisma.ts
@@ -20,3 +20,4 @@ const prisma = new PrismaClient({
});
export default prisma;
+// Reloaded with CategorySection support
diff --git a/apps/backend/src/routes/categorySections.ts b/apps/backend/src/routes/categorySections.ts
new file mode 100644
index 0000000..e16b7ef
--- /dev/null
+++ b/apps/backend/src/routes/categorySections.ts
@@ -0,0 +1,219 @@
+import { Elysia, t } from "elysia";
+import type { Prisma } from "@prisma/client";
+import prisma from "../lib/prisma";
+import {
+ defaultCategorySections,
+ createCategorySection,
+ validateCategorySectionPayload,
+} from "../lib/categorySections";
+import { hydrateSectionPayload } from "./pages";
+
+function summarizeValidationIssues(issues: any[]) {
+ return issues.map((issue) => ({
+ path: issue.path?.join(".") || "(root)",
+ message: issue.message,
+ code: issue.code,
+ }));
+}
+
+export const categorySectionsRoute = new Elysia({ prefix: "/categories" })
+ // Get all sections for a category
+ .get("/:id/sections", async ({ params, query, status }) => {
+ const id = parseInt(params.id);
+ if (isNaN(id)) return status(400, { message: "Invalid category ID" });
+
+ const category = await prisma.category.findUnique({
+ where: { id, deletedAt: null },
+ select: { id: true },
+ });
+ if (!category) return status(404, { message: "Category not found" });
+
+ const includeInactive = query.includeInactive === "true";
+ const lang = query.lang || "th";
+ const sections = await prisma.categorySection.findMany({
+ where: {
+ categoryId: id,
+ ...(includeInactive ? {} : { isActive: true }),
+ },
+ orderBy: [{ order: "asc" }, { id: "asc" }],
+ });
+
+ return await Promise.all(
+ sections.map((section: any) => hydrateSectionPayload(section, lang))
+ );
+ })
+
+ // Seed default sections
+ .post("/:id/sections/seed-default", async ({ params, status }) => {
+ const id = parseInt(params.id);
+ if (isNaN(id)) return status(400, { message: "Invalid category ID" });
+
+ const category = await prisma.category.findUnique({
+ where: { id, deletedAt: null },
+ select: { id: true },
+ });
+ if (!category) return status(404, { message: "Category not found" });
+
+ const existingCount = await prisma.categorySection.count({
+ where: { categoryId: id },
+ });
+ if (existingCount > 0) {
+ return status(409, {
+ success: false,
+ message: "Category sections already exist",
+ });
+ }
+
+ await prisma.categorySection.createMany({
+ data: defaultCategorySections(id),
+ });
+
+ const sections = await prisma.categorySection.findMany({
+ where: { categoryId: id },
+ orderBy: [{ order: "asc" }, { id: "asc" }],
+ });
+
+ return { success: true, sections };
+ })
+
+ // Create a new section
+ .post("/:id/sections", async ({ params, body, status }) => {
+ const id = parseInt(params.id);
+ if (isNaN(id)) return status(400, { message: "Invalid category ID" });
+
+ const payload = body as any;
+ const type = String(payload.type || "").toUpperCase();
+ const sectionPayload = payload.payload || {};
+ const validationResult = validateCategorySectionPayload(type, sectionPayload);
+ if (!validationResult.success) {
+ return status(400, {
+ success: false,
+ message: "Invalid category section payload",
+ issues: summarizeValidationIssues(validationResult.issues || []),
+ });
+ }
+
+ const category = await prisma.category.findUnique({
+ where: { id, deletedAt: null },
+ select: { id: true },
+ });
+ if (!category) return status(404, { message: "Category not found" });
+
+ const section = await prisma.$transaction((tx: Prisma.TransactionClient) =>
+ createCategorySection(tx, {
+ categoryId: id,
+ type: validationResult.type,
+ payload: validationResult.data,
+ ...(Number.isFinite(Number(payload.order)) ? { order: Number(payload.order) } : {}),
+ isActive: payload.isActive !== false,
+ isLocked: Boolean(payload.isLocked),
+ })
+ );
+
+ return { success: true, section };
+ })
+
+ // Update a section
+ .put("/sections/:sectionId", async ({ params, body, status }) => {
+ const sectionId = parseInt(params.sectionId);
+ if (isNaN(sectionId)) return status(400, { message: "Invalid section ID" });
+
+ const existing = await prisma.categorySection.findUnique({
+ where: { id: sectionId },
+ });
+ if (!existing) return status(404, { message: "Category section not found" });
+
+ const payload = body as any;
+ const type = String(payload.type || existing.type).toUpperCase();
+ const sectionPayload = payload.payload === undefined ? existing.payload : payload.payload;
+ const validationResult = validateCategorySectionPayload(type, sectionPayload);
+ if (!validationResult.success) {
+ return status(400, {
+ success: false,
+ message: "Invalid category section payload",
+ issues: summarizeValidationIssues(validationResult.issues || []),
+ });
+ }
+
+ const validatedType = validationResult.type;
+ if (existing.isLocked) {
+ if (payload.type && validatedType !== existing.type) {
+ return status(409, {
+ success: false,
+ message: "Locked category sections cannot change type",
+ });
+ }
+ if (payload.isActive === false) {
+ return status(409, {
+ success: false,
+ message: "Locked category sections cannot be hidden",
+ });
+ }
+ if (payload.isLocked === false) {
+ return status(409, {
+ success: false,
+ message: "Locked category sections cannot be unlocked",
+ });
+ }
+ }
+
+ const section = await prisma.categorySection.update({
+ where: { id: sectionId },
+ data: {
+ type: validatedType,
+ payload: validationResult.data,
+ ...(payload.order !== undefined ? { order: Number(payload.order) } : {}),
+ ...(payload.isActive !== undefined ? { isActive: Boolean(payload.isActive) } : {}),
+ ...(payload.isLocked !== undefined ? { isLocked: Boolean(payload.isLocked) } : {}),
+ },
+ });
+
+ return { success: true, section };
+ })
+
+ // Reorder sections
+ .put("/:id/sections/reorder", async ({ params, body, status }) => {
+ const id = parseInt(params.id);
+ if (isNaN(id)) return status(400, { message: "Invalid category ID" });
+
+ const items = (body as any)?.sections;
+ if (!Array.isArray(items)) return status(400, { message: "sections must be an array" });
+
+ await prisma.$transaction(
+ items.map((item: any, index: number) =>
+ prisma.categorySection.updateMany({
+ where: { id: Number(item.id), categoryId: id },
+ data: {
+ order: Number.isFinite(Number(item.order)) ? Number(item.order) : index + 1,
+ },
+ })
+ )
+ );
+
+ const sections = await prisma.categorySection.findMany({
+ where: { categoryId: id },
+ orderBy: [{ order: "asc" }, { id: "asc" }],
+ });
+
+ return { success: true, sections };
+ })
+
+ // Delete a section
+ .delete("/sections/:sectionId", async ({ params, status }) => {
+ const sectionId = parseInt(params.sectionId);
+ if (isNaN(sectionId)) return status(400, { message: "Invalid section ID" });
+
+ const existing = await prisma.categorySection.findUnique({
+ where: { id: sectionId },
+ });
+ if (!existing) return status(404, { message: "Category section not found" });
+ if (existing.isLocked) {
+ return status(409, {
+ success: false,
+ message: "Locked category sections cannot be deleted",
+ });
+ }
+
+ await prisma.categorySection.delete({ where: { id: sectionId } });
+ return { success: true };
+ });
diff --git a/apps/website/src/app/[lang]/category/[id]/page.tsx b/apps/website/src/app/[lang]/category/[id]/page.tsx
index f6ac7b9..a79fde6 100644
--- a/apps/website/src/app/[lang]/category/[id]/page.tsx
+++ b/apps/website/src/app/[lang]/category/[id]/page.tsx
@@ -3,17 +3,30 @@ import Image from 'next/image'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { generateSeoMetadata } from '@/lib/seo'
-import type { Category, Product, AttributeDefinition } from '@homaroma/types'
+import type { Category, Product, AttributeDefinition, CategorySection, PageSection } from '@homaroma/types'
import { getCategoryImage, getCategoryMedia, resolveCategoryMediaUrl } from '@homaroma/types'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
import CategoryFeaturedSeries from '@/components/category/CategoryFeaturedSeries'
import CategoryCatalogContainer from '@/components/category/CategoryCatalogContainer'
import CategoryUspBar from '@/components/category/CategoryUspBar'
-import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
+import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult, getCategorySectionsResult, getStoreSettings } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { resolveLocalized } from '@/lib/localization'
+function toPageSection(section: CategorySection): PageSection {
+ return {
+ id: section.id,
+ pageId: 0,
+ type: section.type,
+ order: section.order,
+ payload: section.payload,
+ isActive: section.isActive,
+ createdAt: section.createdAt,
+ updatedAt: section.updatedAt,
+ }
+}
+
// lang is extracted from params
type CategoryProductsResponse = {
category?: Category
@@ -63,14 +76,18 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
export default async function CategoryPage({ params }: { params: Promise<{ id: string, lang: string }> }) {
const { id, lang } = await params
- const [categoryProductsResult, categoriesResult, cmsLayoutResult] = await Promise.all([
+ const [categoryProductsResult, categoriesResult, cmsLayoutResult, categorySectionsResult] = await Promise.all([
getCategoryProducts(id, lang),
getCategoriesResult(lang),
getCmsPageResult('category-layout', lang),
+ getCategorySectionsResult(id, lang),
])
const categories = categoriesResult.ok ? categoriesResult.data : []
const cmsSections = cmsLayoutResult.ok ? cmsLayoutResult.data.sections ?? [] : []
const { announcementSections, heroSections, bottomSections } = splitCmsLayoutSections(cmsSections)
+ const customSections = categorySectionsResult.ok && Array.isArray(categorySectionsResult.data)
+ ? categorySectionsResult.data.filter((s) => s.isActive)
+ : []
if (!cmsLayoutResult.ok && cmsLayoutResult.status !== 404) {
console.error(`[website-cms] category-layout unavailable: ${cmsLayoutResult.error}`)
@@ -122,195 +139,256 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
}
}
+ const renderHeaderSection = (payload?: Record) => {
+ const showPills = payload?.showCategoryNav !== false && category.filterConfig?.showCategoryNav !== false
+ const showBreadcrumb = payload?.showBreadcrumbs !== false
+
+ return (
+
+ {layoutType === 'none' ? null : layoutType === 'editorial_hero' ? (
+ /* ── Archetype 2: Editorial Hero Layout ── */
+
+ {heroDesktopUrl && (
+
+ )}
+
+
+ {showBreadcrumb && (
+
+ Home
+ /
+ Shop
+ /
+ {categoryName}
+
+ )}
+
+
+ Curated Aroma Collection
+
+
+
+ {categoryName}
+
+
+ {category._count && (
+
+ Discover {category._count.products} handcrafted scents and artisanal essences designed to elevate your living spaces.
+
+ )}
+
+
+ ) : layoutType === 'dense_catalog' ? (
+ /* ── Archetype 3: Dense Catalog Layout ── */
+
+
+
+ {showBreadcrumb && (
+
+ Home
+ /
+ Shop
+ /
+ {categoryName}
+
+ )}
+
+ {categoryName}
+ {category._count && (
+
+ {category._count.products} items
+
+ )}
+
+
+
+ High-Density Catalog View
+
+
+
+ ) : (
+ /* ── Archetype 1: Split Showcase Layout (Default) ── */
+
+
+
+ {showBreadcrumb && (
+
+ Home
+ /
+ Shop
+ /
+ {categoryName}
+
+ )}
+
+ {categoryName}
+ {category._count && {category._count.products} products available in this collection }
+
+
+ {categoryThumbnailUrl && (
+
+
+
+ )}
+
+
+ )}
+
+ {layoutType === 'none' && showBreadcrumb && (
+
+
+
+ )}
+
+ {showPills && (
+
+
+ {!categoriesResult.ok && (
+
+ Categories unavailable
+
+ )}
+ {categories.map((item) => {
+ const name = resolveLocalized(item.name, lang)
+ const isActive = item.id === category.id
+
+ return (
+
+ {name}
+
+ )
+ })}
+
+
+ )}
+
+ )
+ }
+
+ const renderCatalogGrid = () => (
+
+ {/* Featured Series Showcase Banner */}
+ {Boolean(category.featuredSeries?.enabled) && (
+
+ )}
+
+ {/* Catalog Container (Filter Sidebar + Toolbar + Grid) */}
+ {products.length === 0 ? (
+
+ No products in this category yet.
+ Back to Shop
+
+ ) : (
+
+ )}
+
+ )
+
+ const renderUspBar = () => (
+
+
+
+ )
+
return (
{announcementSections.length > 0 && (
)}
- {heroSections.length > 0 && !(layoutType === 'editorial_hero' && hasArtDirectedHero) ? (
-
- ) : layoutType === 'none' ? (
- /* ── Archetype 4: No Header (ปิดส่วนหัว Archetype) ── */
- null
- ) : layoutType === 'editorial_hero' ? (
- /* ── Archetype 2: Editorial Hero Layout ── */
-
- {heroDesktopUrl && (
-
- 0 ? (
+ /* ── Dynamic Category CMS Layout ── */
+
+ {customSections.map((sec, idx) => {
+ const secPayload = sec.payload && typeof sec.payload === 'object' && !Array.isArray(sec.payload)
+ ? (sec.payload as Record )
+ : undefined
+
+ if (sec.type === 'CATEGORY_HEADER') {
+ return {renderHeaderSection(secPayload)}
+ }
+ if (sec.type === 'CATALOG_GRID') {
+ return {renderCatalogGrid()}
+ }
+ if (sec.type === 'USP_BAR') {
+ return {renderUspBar()}
+ }
+
+ return (
+
-
-
-
+ )
+ })}
+
+ ) : (
+ /* ── Legacy Fallback Category Layout ── */
+ <>
+ {heroSections.length > 0 && !(layoutType === 'editorial_hero' && hasArtDirectedHero) ? (
+
+ ) : (
+ renderHeaderSection()
)}
-
-
- Home
- /
- Shop
- /
- {categoryName}
-
+ {renderCatalogGrid()}
+ {renderUspBar()}
-
- Curated Aroma Collection
-
-
-
- {categoryName}
-
-
- {category._count && (
-
- Discover {category._count.products} handcrafted scents and artisanal essences designed to elevate your living spaces.
-
- )}
-
-
- ) : layoutType === 'dense_catalog' ? (
- /* ── Archetype 3: Dense Catalog Layout ── */
-
-
-
-
- Home
- /
- Shop
- /
- {categoryName}
-
-
- {categoryName}
- {category._count && (
-
- {category._count.products} items
-
- )}
-
-
-
- High-Density Catalog View
-
-
-
- ) : (
- /* ── Archetype 1: Split Showcase Layout (Default) ── */
-
-
-
-
- Home
- /
- Shop
- /
- {categoryName}
-
-
- {categoryName}
- {category._count && {category._count.products} products available in this collection }
-
-
- {categoryThumbnailUrl && (
-
-
-
- )}
-
-
- )}
-
-
- {/* Minimal Breadcrumbs when Archetype Header is disabled */}
- {layoutType === 'none' && (
-
- )}
-
- {/* Category Pills Navigation (Per-category Toggle) */}
- {category.filterConfig?.showCategoryNav !== false && (
-
- {!categoriesResult.ok && (
-
- Categories unavailable
-
- )}
- {categories.map((item) => {
- const name = resolveLocalized(item.name, lang)
- const isActive = item.id === category.id
-
- return (
-
- {name}
-
- )
- })}
-
- )}
-
- {/* Featured Series Showcase Banner (Mockup 01 & 03) */}
- {Boolean(category.featuredSeries?.enabled) && (
-
- )}
-
- {/* Catalog Container (Filter Sidebar + Toolbar + Grid) */}
- {products.length === 0 ? (
-
- No products in this category yet.
- Back to Shop
-
- ) : (
-
- )}
-
- {/* Benefits & Support USP Blocks (Mockup 06) */}
-
-
- {bottomSections.length > 0 && (
-
+ {bottomSections.length > 0 && (
+
+ )}
+ >
)}
)
diff --git a/apps/website/src/lib/cms.ts b/apps/website/src/lib/cms.ts
index d1a136c..5d46b3f 100644
--- a/apps/website/src/lib/cms.ts
+++ b/apps/website/src/lib/cms.ts
@@ -1,4 +1,4 @@
-import type { Article, Category, Order, Page, Product, ProductDetailSection, StoreSettings } from '@homaroma/types'
+import type { Article, Category, CategorySection, Order, Page, Product, ProductDetailSection, StoreSettings } from '@homaroma/types'
import { API_BASE } from './api'
const isDev = process.env.NODE_ENV === 'development'
@@ -128,6 +128,10 @@ export async function getProductDetailSectionsResult(id: string | number, lang =
return await fetchJsonResult(`/products/${id}/detail-sections?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
}
+export async function getCategorySectionsResult(id: string | number, lang = 'all'): Promise> {
+ return await fetchJsonResult(`/categories/${id}/sections?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
+}
+
export async function getProductReviewsResult(id: string | number): Promise> {
return await fetchJsonResult(`/reviews/product/${id}`, { next: { revalidate: defaultRevalidate } })
}
diff --git a/packages/types/index.ts b/packages/types/index.ts
index 2c8f682..00ccf29 100644
--- a/packages/types/index.ts
+++ b/packages/types/index.ts
@@ -66,6 +66,7 @@ export interface Category {
featuredSeries?: CategoryFeaturedSeries | null
uspBenefits?: CategoryUspBenefit[] | null
filterConfig?: CategoryFilterConfig | null
+ sections?: CategorySection[]
availableFilters?: AttributeDefinition[]
_count?: { products: number }
translations?: { langCode: string; name: string }[]
@@ -584,6 +585,18 @@ export interface ProductDetailSection {
updatedAt?: string
}
+export interface CategorySection {
+ id: number
+ categoryId: number
+ type: string
+ order: number
+ payload: any
+ isActive: boolean
+ isLocked: boolean
+ createdAt?: string
+ updatedAt?: string
+}
+
export const LocalizedStringSchema = z.union([z.string(), z.record(z.string(), z.string())]);
export const HeroSectionPayloadSchema = z.object({
@@ -1291,3 +1304,23 @@ export const PRODUCT_DETAIL_SECTION_TYPES = [
'CUSTOM',
] as const;
export type ProductDetailSectionType = typeof PRODUCT_DETAIL_SECTION_TYPES[number];
+
+export const CATEGORY_SECTION_TYPES = [
+ 'CATEGORY_HEADER',
+ 'CATALOG_GRID',
+ 'USP_BAR',
+ 'HERO',
+ 'HERO_V2',
+ 'HERO_VIDEO',
+ 'HERO_CAROUSEL',
+ 'DIVIDER',
+ 'RICH_TEXT',
+ 'TEXT',
+ 'BANNER',
+ 'PRODUCT_CAROUSEL',
+ 'EDITORIAL_TILE',
+ 'FEATURED_TILES',
+ 'QUOTE_CALLOUT',
+ 'CUSTOM',
+] as const;
+export type CategorySectionType = typeof CATEGORY_SECTION_TYPES[number];
|