From 46f09ab145e0398500710ed50751e17d2cea7311 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 3 Oct 2026 15:09:45 +0700 Subject: [PATCH] x --- apps/admin/src/App.tsx | 2 + .../category/CategoryDividerBlockForm.tsx | 155 +++ .../CategoryHeroCarouselBlockForm.tsx | 277 +++++ .../categoryHeroCarouselValidation.ts | 25 + apps/admin/src/pages/CategoriesPage.tsx | 17 +- apps/admin/src/pages/CategoryCmsPage.tsx | 995 ++++++++++++++++++ apps/admin/src/pages/CategoryEditPage.tsx | 10 + .../migration.sql | 23 + apps/backend/prisma/schema.prisma | 17 + apps/backend/src/index.ts | 3 +- apps/backend/src/lib/categorySections.ts | 228 ++++ apps/backend/src/lib/prisma.ts | 1 + apps/backend/src/routes/categorySections.ts | 219 ++++ .../src/app/[lang]/category/[id]/page.tsx | 444 ++++---- apps/website/src/lib/cms.ts | 6 +- packages/types/index.ts | 33 + 16 files changed, 2269 insertions(+), 186 deletions(-) create mode 100644 apps/admin/src/components/category/CategoryDividerBlockForm.tsx create mode 100644 apps/admin/src/components/category/CategoryHeroCarouselBlockForm.tsx create mode 100644 apps/admin/src/components/category/categoryHeroCarouselValidation.ts create mode 100644 apps/admin/src/pages/CategoryCmsPage.tsx create mode 100644 apps/backend/prisma/migrations/20261003072334_add_category_sections/migration.sql create mode 100644 apps/backend/src/lib/categorySections.ts create mode 100644 apps/backend/src/routes/categorySections.ts diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index c0fc6d4..32eccdd 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -7,6 +7,7 @@ import ProductBomPage from './pages/ProductBomPage' import ProductCmsPage from './pages/ProductCmsPage' import CategoriesPage from './pages/CategoriesPage' import CategoryEditPage from './pages/CategoryEditPage' +import CategoryCmsPage from './pages/CategoryCmsPage' import CustomersPage from './pages/CustomersPage' import PageBuilderPage from './pages/PageBuilderPage' import PageReorderPage from './pages/PageReorderPage' @@ -107,6 +108,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin/src/components/category/CategoryDividerBlockForm.tsx b/apps/admin/src/components/category/CategoryDividerBlockForm.tsx new file mode 100644 index 0000000..0874ab9 --- /dev/null +++ b/apps/admin/src/components/category/CategoryDividerBlockForm.tsx @@ -0,0 +1,155 @@ +interface CategoryDividerBlockFormProps { + payload: Record + onChange: (payload: Record) => void +} + +const WIDTH_OPTIONS = [ + { value: 'full', label: 'Full width' }, + { value: 'container', label: 'Container' }, + { value: 'narrow', label: 'Narrow' }, +] as const + +const STYLE_OPTIONS = [ + { value: 'solid', label: 'Solid' }, + { value: 'dashed', label: 'Dashed' }, + { value: 'dotted', label: 'Dotted' }, +] as const + +const SPACING_OPTIONS = [ + { value: 'none', label: 'None' }, + { value: 'sm', label: 'Small' }, + { value: 'md', label: 'Medium' }, + { value: 'lg', label: 'Large' }, + { value: 'xl', label: 'Extra large' }, +] as const + +const getString = (value: unknown, fallback: string) => + typeof value === 'string' ? value : fallback + +const getThickness = (value: unknown) => + typeof value === 'number' && value >= 1 && value <= 4 ? value : 1 + +const getLineStyle = (value: unknown): 'solid' | 'dashed' | 'dotted' => + value === 'dashed' || value === 'dotted' ? value : 'solid' + +const DEFAULT_DIVIDER_COLOR = '#94a3b8' +const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i + +const toColorPickerValue = (color: string) => { + if (!HEX_COLOR_PATTERN.test(color)) return DEFAULT_DIVIDER_COLOR + + const digits = color.slice(1) + if (digits.length === 3 || digits.length === 4) { + return `#${digits.slice(0, 3).split('').map((digit) => `${digit}${digit}`).join('')}` + } + + return `#${digits.slice(0, 6)}` +} + +export default function CategoryDividerBlockForm({ payload, onChange }: CategoryDividerBlockFormProps) { + const width = getString(payload.width, 'container') + const style = getLineStyle(payload.style) + const thickness = getThickness(payload.thickness) + const color = getString(payload.color, DEFAULT_DIVIDER_COLOR) + const isColorValid = HEX_COLOR_PATTERN.test(color) + const previewColor = isColorValid ? color : DEFAULT_DIVIDER_COLOR + const pickerColor = toColorPickerValue(color) + const updatePayload = (field: string, value: unknown) => onChange({ ...payload, [field]: value }) + + return ( +
+
+

Category Section Divider

+

+ Add a visual separator between blocks on this category detail page. This form is scoped to Category CMS. +

+
+ +
+ + + + + + + + +
+ +
+
+ updatePayload('color', event.target.value)} + className="h-11 w-14 cursor-pointer rounded-xl border border-white/10 bg-night-950 p-1" + aria-label="Divider color picker" + /> + updatePayload('color', event.target.value)} + placeholder="#94a3b8" + className="admin-input w-36 uppercase" + /> +
+ {!isColorValid && ( +

Enter a valid hex color code (e.g. #94a3b8).

+ )} +
+
+ +
+ +
+
+
+
+
+
+ ) +} diff --git a/apps/admin/src/components/category/CategoryHeroCarouselBlockForm.tsx b/apps/admin/src/components/category/CategoryHeroCarouselBlockForm.tsx new file mode 100644 index 0000000..991a624 --- /dev/null +++ b/apps/admin/src/components/category/CategoryHeroCarouselBlockForm.tsx @@ -0,0 +1,277 @@ +import { + ChevronDown, + ChevronUp, + ImagePlus, + Monitor, + Plus, + Smartphone, + Trash2, +} from 'lucide-react' + +type CategoryHeroCarouselBlockFormProps = { + payload: Record + activeLanguage: string + onChange: (payload: Record) => void + onBrowseMedia: (onSelect: (url: string) => void) => void +} + +type SlidePayload = Record + +const DESKTOP_ASPECT_RATIOS = ['21/6', '21/7', '21/9', '16/5', '16/6', '4/1', '5/1', 'auto'] as const +const MOBILE_ASPECT_RATIOS = ['16/9', '4/3', '1/1', '21/9', 'auto'] as const + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const createSlide = (): SlidePayload => ({ + imageUrl: '', + mobileImageUrl: '', + linkUrl: '', + alt: {}, + title: {}, + subtitle: {}, + buttonText: {}, + focalPoint: { x: '50%', y: '50%' }, +}) + +const getSlides = (payload: Record): SlidePayload[] => + Array.isArray(payload.slides) ? payload.slides.filter(isRecord) : [] + +const getLocalizedValue = (value: unknown, language: string) => { + if (typeof value === 'string') return value + if (!isRecord(value)) return '' + const localized = value[language] + return typeof localized === 'string' ? localized : '' +} + +const updateLocalizedValue = (value: unknown, language: string, nextValue: string) => ({ + ...(typeof value === 'string' ? { th: value } : isRecord(value) ? value : {}), + [language]: nextValue, +}) + +const getString = (value: unknown, fallback = '') => typeof value === 'string' ? value : fallback + +export default function CategoryHeroCarouselBlockForm({ + payload, + activeLanguage, + onChange, + onBrowseMedia, +}: CategoryHeroCarouselBlockFormProps) { + const slides = getSlides(payload) + + const updateRoot = (field: string, value: unknown) => { + onChange({ ...payload, [field]: value }) + } + + const updateSlide = (index: number, update: SlidePayload) => { + updateRoot('slides', slides.map((slide, slideIndex) => slideIndex === index ? { ...slide, ...update } : slide)) + } + + const addSlide = () => updateRoot('slides', [...slides, createSlide()]) + + const removeSlide = (index: number) => { + if (slides.length <= 1) return + updateRoot('slides', slides.filter((_, slideIndex) => slideIndex !== index)) + } + + const moveSlide = (index: number, direction: -1 | 1) => { + const targetIndex = index + direction + if (targetIndex < 0 || targetIndex >= slides.length) return + const reordered = [...slides] + const [movedSlide] = reordered.splice(index, 1) + reordered.splice(targetIndex, 0, movedSlide) + updateRoot('slides', reordered) + } + + return ( +
+
+

Category Wide Promo Slider

+

This form is scoped to Category CMS and does not share UI code with Page CMS.

+
+ +
+ + + + {([ + ['Autoplay', 'autoplay'], + ['Show arrows', 'showArrows'], + ['Show dots', 'showDots'], + ] as const).map(([label, field]) => ( + + ))} +
+ +
+ {slides.map((slide, slideIndex) => { + const focalPoint = isRecord(slide.focalPoint) ? slide.focalPoint : {} + const focalX = getString(focalPoint.x, '50%') + const focalY = getString(focalPoint.y, '50%') + + return ( +
+
+
+

Slide {slideIndex + 1}

+

Desktop image is required. Mobile falls back to desktop.

+
+
+ + + +
+
+ +
+ {([ + { field: 'imageUrl', label: 'Desktop image *', icon: Monitor }, + { field: 'mobileImageUrl', label: 'Mobile image', icon: Smartphone }, + ] as const).map(({ field, label, icon: DeviceIcon }) => { + const imageUrl = getString(slide[field]) + return ( +
+
+ {imageUrl ? ( + + ) : ( + + )} +
+
+ updateSlide(slideIndex, { [field]: event.target.value })} + placeholder={`${label} URL`} + className="admin-input min-w-0 flex-1" + /> + +
+
+ ) + })} +
+ +
+ {([ + ['title', 'Title'], + ['subtitle', 'Subtitle'], + ['buttonText', 'Button text'], + ['alt', 'Image alt text'], + ] as const).map(([field, label]) => ( + + ))} + + + +
+
+ ) + })} +
+ + {slides.length === 0 && ( +
+ Add at least one slide before saving. +
+ )} + + +
+ ) +} diff --git a/apps/admin/src/components/category/categoryHeroCarouselValidation.ts b/apps/admin/src/components/category/categoryHeroCarouselValidation.ts new file mode 100644 index 0000000..438fe00 --- /dev/null +++ b/apps/admin/src/components/category/categoryHeroCarouselValidation.ts @@ -0,0 +1,25 @@ +const isRecord = (value: unknown): value is Record => ( + 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 && ( +
+ +

{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' && ( +
+
+
+ + syncNativeLocalizedPayload('title', e.target.value)} + className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all" + placeholder="Leave blank to use category name" + /> +
+
+ + syncNativeLocalizedPayload('subtitle', e.target.value)} + className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all" + placeholder="Optional tagline" + /> +
+
+ +
+ + +
+
+ )} + + {sectionType === 'CATALOG_GRID' && ( +
+
+ + +
+ +
+ + syncNativePayload('itemsPerPage', parseInt(e.target.value, 10) || 24)} + className="w-40 px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all" + /> +
+
+ )} + + {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 && ( +
+ +

{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 && ( +
+ {categoryName} + {categoryName} +
+
+ )} + +
+ {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 && ( +
+ {categoryName} +
+ )} +
+
+ )} + + {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 && ( -
- {categoryName} 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 ( + - {categoryName} -
-
+ ) + })} +
+ ) : ( + /* ── 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 && ( -
- {categoryName} -
- )} -
-
- )} - -
- {/* 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];