This commit is contained in:
Your Name committed 2026-10-03 15:09:45 +07:00
1 parent b8a255821b
commit 46f09ab145
16 files changed
+2106 -23

No files matched your search

+2
View File
@@ -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() {
<Route path="/categories/new" element={<CategoryEditPage />} />
<Route path="/categories/:id" element={<CategoryEditPage />} />
<Route path="/categories/:id/edit" element={<CategoryEditPage />} />
<Route path="/categories/:id/cms" element={<CategoryCmsPage />} />
<Route path="/customers" element={<CustomersPage />} />
<Route path="/page-builder" element={<PageBuilderPage />} />
<Route path="/page-builder/:id/reorder" element={<PageReorderPage />} />
@@ -0,0 +1,155 @@
interface CategoryDividerBlockFormProps {
payload: Record<string, unknown>
onChange: (payload: Record<string, unknown>) => 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 (
<div className="space-y-6">
<div className="rounded-2xl border border-cyan-glow/10 bg-cyan-glow/[0.03] p-4">
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">Category Section Divider</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">
Add a visual separator between blocks on this category detail page. This form is scoped to Category CMS.
</p>
</div>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Width</span>
<select
value={width}
onChange={(event) => updatePayload('width', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{WIDTH_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Line style</span>
<select
value={style}
onChange={(event) => updatePayload('style', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{STYLE_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Thickness</span>
<select
value={thickness}
onChange={(event) => updatePayload('thickness', Number(event.target.value))}
className="admin-input w-full bg-slate-900 border-white/10"
>
{[1, 2, 3, 4].map((value) => <option key={value} value={value}>{value}px</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Spacing</span>
<select
value={getString(payload.spacing, 'md')}
onChange={(event) => updatePayload('spacing', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{SPACING_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Color</label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex items-center gap-3">
<input
type="color"
value={pickerColor}
onChange={(event) => 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"
/>
<input
type="text"
value={color}
onChange={(event) => updatePayload('color', event.target.value)}
placeholder="#94a3b8"
className="admin-input w-36 uppercase"
/>
</div>
{!isColorValid && (
<p className="text-xs text-amber-400">Enter a valid hex color code (e.g. #94a3b8).</p>
)}
</div>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Live preview</label>
<div className="rounded-2xl border border-white/5 bg-night-950 p-6">
<div
style={{
width: width === 'full' ? '100%' : width === 'narrow' ? '40%' : '75%',
margin: '0 auto',
borderTopStyle: style,
borderTopWidth: `${thickness}px`,
borderTopColor: previewColor,
}}
/>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,277 @@
import {
ChevronDown,
ChevronUp,
ImagePlus,
Monitor,
Plus,
Smartphone,
Trash2,
} from 'lucide-react'
type CategoryHeroCarouselBlockFormProps = {
payload: Record<string, unknown>
activeLanguage: string
onChange: (payload: Record<string, unknown>) => void
onBrowseMedia: (onSelect: (url: string) => void) => void
}
type SlidePayload = Record<string, unknown>
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<string, unknown> =>
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<string, unknown>): 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 (
<div className="space-y-6">
<div className="rounded-2xl border border-cyan-glow/10 bg-cyan-glow/[0.03] p-4">
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">Category Wide Promo Slider</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">This form is scoped to Category CMS and does not share UI code with Page CMS.</p>
</div>
<div className="grid grid-cols-1 gap-4 rounded-2xl border border-white/5 bg-night-950 p-4 md:grid-cols-2 xl:grid-cols-4">
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Desktop ratio</span>
<select
value={getString(payload.aspectRatio, '21/7')}
onChange={(event) => updateRoot('aspectRatio', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{DESKTOP_ASPECT_RATIOS.map((ratio) => <option key={ratio} value={ratio}>{ratio}</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Mobile ratio</span>
<select
value={getString(payload.mobileAspectRatio, '16/9')}
onChange={(event) => updateRoot('mobileAspectRatio', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{MOBILE_ASPECT_RATIOS.map((ratio) => <option key={ratio} value={ratio}>{ratio}</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Interval (ms)</span>
<input
type="number"
min={1000}
step={500}
value={typeof payload.interval === 'number' ? payload.interval : 4500}
disabled={payload.autoplay === false}
onChange={(event) => updateRoot('interval', Number(event.target.value))}
className="admin-input w-full disabled:opacity-40"
/>
</label>
{([
['Autoplay', 'autoplay'],
['Show arrows', 'showArrows'],
['Show dots', 'showDots'],
] as const).map(([label, field]) => (
<label key={field} className="flex items-center gap-3 rounded-xl border border-white/5 bg-white/5 px-4 py-3">
<input
type="checkbox"
checked={payload[field] !== false}
onChange={(event) => updateRoot(field, event.target.checked)}
className="accent-cyan-glow"
/>
<span className="text-xs font-bold text-slate-300">{label}</span>
</label>
))}
</div>
<div className="space-y-4">
{slides.map((slide, slideIndex) => {
const focalPoint = isRecord(slide.focalPoint) ? slide.focalPoint : {}
const focalX = getString(focalPoint.x, '50%')
const focalY = getString(focalPoint.y, '50%')
return (
<article key={slideIndex} className="space-y-5 rounded-2xl border border-white/5 bg-white/[0.02] p-4 md:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="font-bold text-white">Slide {slideIndex + 1}</h4>
<p className="mt-1 text-xs text-slate-500">Desktop image is required. Mobile falls back to desktop.</p>
</div>
<div className="flex items-center gap-1">
<button type="button" onClick={() => moveSlide(slideIndex, -1)} disabled={slideIndex === 0} className="rounded-lg p-2 text-slate-500 hover:bg-white/5 hover:text-white disabled:opacity-30" aria-label={`Move slide ${slideIndex + 1} up`}>
<ChevronUp className="h-4 w-4" />
</button>
<button type="button" onClick={() => moveSlide(slideIndex, 1)} disabled={slideIndex === slides.length - 1} className="rounded-lg p-2 text-slate-500 hover:bg-white/5 hover:text-white disabled:opacity-30" aria-label={`Move slide ${slideIndex + 1} down`}>
<ChevronDown className="h-4 w-4" />
</button>
<button type="button" onClick={() => removeSlide(slideIndex)} disabled={slides.length === 1} className="rounded-lg p-2 text-slate-500 hover:bg-rose-500/10 hover:text-rose-400 disabled:opacity-30" aria-label={`Delete slide ${slideIndex + 1}`}>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{([
{ 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 (
<div key={field} className="space-y-3">
<div className="flex aspect-[16/7] items-center justify-center overflow-hidden rounded-xl border border-white/5 bg-night-950">
{imageUrl ? (
<img src={imageUrl} alt="" className="h-full w-full object-cover" style={{ objectPosition: `${focalX} ${focalY}` }} />
) : (
<DeviceIcon className="h-8 w-8 text-slate-700" />
)}
</div>
<div className="flex gap-2">
<input
type="url"
required={field === 'imageUrl'}
aria-required={field === 'imageUrl'}
value={imageUrl}
onChange={(event) => updateSlide(slideIndex, { [field]: event.target.value })}
placeholder={`${label} URL`}
className="admin-input min-w-0 flex-1"
/>
<button
type="button"
onClick={() => onBrowseMedia((url) => updateSlide(slideIndex, { [field]: url }))}
className="rounded-xl border border-cyan-glow/20 bg-cyan-glow/10 px-3 py-2.5 text-cyan-glow hover:bg-cyan-glow/20"
aria-label={`Choose ${label.toLowerCase()} from media library`}
>
<ImagePlus className="h-4 w-4" />
</button>
</div>
</div>
)
})}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{([
['title', 'Title'],
['subtitle', 'Subtitle'],
['buttonText', 'Button text'],
['alt', 'Image alt text'],
] as const).map(([field, label]) => (
<label key={field} className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">{label} ({activeLanguage.toUpperCase()})</span>
<input
type="text"
value={getLocalizedValue(slide[field], activeLanguage)}
onChange={(event) => updateSlide(slideIndex, { [field]: updateLocalizedValue(slide[field], activeLanguage, event.target.value) })}
className="admin-input w-full"
/>
</label>
))}
<label className="space-y-2 md:col-span-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Link URL</span>
<input
type="text"
value={getString(slide.linkUrl)}
onChange={(event) => updateSlide(slideIndex, { linkUrl: event.target.value })}
placeholder="/shop or https://..."
className="admin-input w-full"
/>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Focal point X</span>
<input
type="text"
value={focalX}
onChange={(event) => updateSlide(slideIndex, { focalPoint: { ...focalPoint, x: event.target.value } })}
placeholder="50%"
className="admin-input w-full"
/>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Focal point Y</span>
<input
type="text"
value={focalY}
onChange={(event) => updateSlide(slideIndex, { focalPoint: { ...focalPoint, y: event.target.value } })}
placeholder="50%"
className="admin-input w-full"
/>
</label>
</div>
</article>
)
})}
</div>
{slides.length === 0 && (
<div className="rounded-2xl border-2 border-dashed border-white/5 py-10 text-center text-sm text-slate-600">
Add at least one slide before saving.
</div>
)}
<button
type="button"
onClick={addSlide}
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-bold text-slate-300 hover:bg-white/10"
>
<Plus className="h-4 w-4" />
Add Slide
</button>
</div>
)
}
@@ -0,0 +1,25 @@
const isRecord = (value: unknown): value is Record<string, unknown> => (
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
}
+16 -1
View File
@@ -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 */}
<td className="px-6 py-3.5 text-right">
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
<Link
to={`/categories/${cat.id}/cms`}
title="Category CMS Builder"
className="p-2 rounded-xl bg-night-800 border border-white/10 text-slate-400 hover:text-cyan-glow hover:border-cyan-glow/30 hover:bg-cyan-glow/10 transition-all cursor-pointer shadow-sm inline-flex"
>
<LayoutTemplate className="w-4 h-4" />
</Link>
<Link
to={`/categories/${cat.id}`}
title={t('common.edit', 'Edit Category')}
@@ -360,6 +368,13 @@ export default function CategoriesPage() {
{/* Actions on Card */}
<div className="flex gap-1.5 relative z-10" onClick={(e) => e.stopPropagation()}>
<Link
to={`/categories/${cat.id}/cms`}
title="Category CMS Builder"
className="p-2 rounded-xl bg-night-800 border border-white/10 text-slate-400 hover:text-cyan-glow hover:border-cyan-glow/30 hover:bg-cyan-glow/10 transition-all cursor-pointer inline-flex"
>
<LayoutTemplate className="w-4 h-4" />
</Link>
<Link
to={`/categories/${cat.id}`}
title={t('common.edit', 'Edit')}
+995
View File
@@ -0,0 +1,995 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import {
AlertCircle,
ArrowDown,
ArrowLeft,
ArrowUp,
CheckCircle2,
Edit3,
Eye,
EyeOff,
FileJson,
Grid3X3,
GripVertical,
Image as ImageIcon,
LayoutTemplate,
Loader2,
Lock,
Minus,
PanelTop,
Plus,
Save,
Shield,
Sparkles,
Trash2,
} from 'lucide-react'
import { API_BASE, type Category, type CategorySection } from '@homaroma/types'
import MediaLibraryModal from '../components/MediaLibraryModal'
import CategoryHeroCarouselBlockForm from '../components/category/CategoryHeroCarouselBlockForm'
import CategoryDividerBlockForm from '../components/category/CategoryDividerBlockForm'
import { validateCategoryHeroCarouselPayload } from '../components/category/categoryHeroCarouselValidation'
import { getAdminDisplayString } from '../utils/localization'
interface ApiValidationIssue {
path?: string
message?: string
}
const getApiErrorMessage = (data: unknown, fallback: string) => {
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<string, string> = {
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<string, string> = {
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<Category | null>(null)
const [detailSections, setDetailSections] = useState<CategorySection[]>([])
const [loading, setLoading] = useState(true)
const [sectionsSaving, setSectionsSaving] = useState(false)
const [selectedSectionId, setSelectedSectionId] = useState<number | 'new'>('new')
const [sectionType, setSectionType] = useState<string>('HERO_CAROUSEL')
const [sectionPayload, setSectionPayload] = useState<Record<string, unknown>>(createDefaultCategoryPayload('HERO_CAROUSEL'))
const [sectionJson, setSectionJson] = useState<string>(JSON.stringify(createDefaultCategoryPayload('HERO_CAROUSEL'), null, 2))
const [sectionIsActive, setSectionIsActive] = useState(true)
const [sectionSaveError, setSectionSaveError] = useState<string | null>(null)
const [activeLang, setActiveLang] = useState('th')
const [error, setError] = useState<string | null>(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<string, string>
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 (
<div className="h-[60vh] flex flex-col items-center justify-center space-y-4">
<Loader2 className="w-10 h-10 text-cyan-glow animate-spin" />
<p className="text-slate-500 animate-pulse">Loading category CMS...</p>
</div>
)
}
if (error && !category) {
return (
<div className="admin-card text-center py-20 space-y-4">
<AlertCircle className="w-16 h-16 text-rose-500 mx-auto" />
<h2 className="text-2xl font-bold">Error</h2>
<p className="text-slate-500">{error}</p>
<Link to="/categories" className="btn-primary inline-flex items-center gap-2">
<ArrowLeft className="w-4 h-4" />
Back to Categories
</Link>
</div>
)
}
const categoryName = getAdminDisplayString(category?.name) || `Category ${id}`
return (
<div className="w-full space-y-6 animate-fade-in pb-12">
{/* Top Header Bar */}
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1">
<Link to="/categories" className="hover:text-cyan-glow transition-colors">Categories</Link>
<span>/</span>
<span className="text-white">Category CMS</span>
</div>
<h1 className="text-3xl font-bold tracking-tight">
Category CMS <span className="text-cyan-glow">{categoryName}</span>
</h1>
<p className="text-sm text-slate-500">Manage category detail sections without changing category data.</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => navigate(`/categories/${id}`)}
className="px-4 py-2.5 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 text-sm font-bold transition-all inline-flex items-center gap-2"
>
<Edit3 className="w-4 h-4" />
Edit Category
</button>
<button
type="button"
onClick={() => navigate('/categories')}
className="px-4 py-2.5 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 text-sm font-bold transition-all"
>
Back
</button>
</div>
</div>
{/* Notifications */}
{success && (
<div className="flex items-center gap-3 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-xl text-emerald-500 text-sm animate-fade-in">
<CheckCircle2 className="w-5 h-5 shrink-0" />
<p>Category CMS updated successfully.</p>
</div>
)}
{error && (
<div className="flex items-center gap-3 p-4 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-500 text-sm animate-fade-in">
<AlertCircle className="w-5 h-5 shrink-0" />
<p>{error}</p>
</div>
)}
{/* Top 4 Stats Cards */}
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Total Blocks</p>
<p className="mt-2 text-2xl font-bold text-white">{detailSections.length}</p>
</div>
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Native Blocks</p>
<p className="mt-2 text-2xl font-bold text-cyan-glow">{nativeSectionCount}</p>
</div>
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Hidden</p>
<p className="mt-2 text-2xl font-bold text-slate-300">{inactiveSectionCount}</p>
</div>
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Mode</p>
<p className="mt-2 text-sm font-bold text-white">{selectedSectionId === 'new' ? 'Create block' : 'Edit selected block'}</p>
</div>
</div>
{/* Main Page Builder Section */}
<section className="admin-card space-y-6">
{/* Section Header with Language Switcher and Add Block Button */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-2 text-cyan-glow">
<LayoutTemplate className="w-5 h-5" />
<div>
<h2 className="font-bold text-lg">Category Detail Page Builder</h2>
<p className="text-xs text-slate-500">Start with native blocks. Use content blocks only when this category needs extra storytelling.</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex bg-white/5 rounded-lg p-1">
{languages.map((lang) => (
<button
key={lang}
type="button"
onClick={() => setActiveLang(lang)}
className={`px-3 py-1.5 rounded-md text-xs font-bold uppercase tracking-widest transition-all ${activeLang === lang ? 'bg-cyan-glow text-night-950 shadow-lg' : 'text-slate-400 hover:text-white'}`}
>
{lang}
</button>
))}
</div>
{detailSections.length === 0 && (
<button
type="button"
onClick={handleSeedDetailSections}
disabled={sectionsSaving}
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-xs font-bold uppercase tracking-widest hover:bg-cyan-glow/20 transition-all disabled:opacity-50"
>
{sectionsSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
Seed Default
</button>
)}
<button
type="button"
onClick={() => setIsAddBlockModalOpen(true)}
className="flex items-center gap-2 px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-xs font-bold uppercase tracking-widest text-slate-300 hover:bg-white/10 transition-all"
>
<Plus className="w-3.5 h-3.5" />
Add Block
</button>
</div>
</div>
{/* Native Category Blocks Quick Bar */}
<div className="rounded-2xl border border-cyan-glow/15 bg-cyan-glow/[0.03] p-4">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<p className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-cyan-glow">
<Sparkles className="h-4 w-4" />
Native Category Blocks
</p>
<p className="mt-1 text-xs text-slate-500">These blocks render live category data and are the recommended starting point.</p>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{NATIVE_BLOCKS.map(({ type, label, icon: Icon }) => (
<button
key={type}
type="button"
onClick={() => startNewSection(type)}
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-left text-xs font-bold transition-all ${
selectedSectionId === 'new' && sectionType === type
? 'border-cyan-glow/50 bg-cyan-glow/15 text-cyan-glow'
: 'border-white/10 bg-night-950/60 text-slate-300 hover:border-cyan-glow/30 hover:text-cyan-glow'
}`}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{label}</span>
</button>
))}
</div>
</div>
</div>
{/* 2-Column Section Order & Block Form */}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(300px,0.85fr)_minmax(0,1.15fr)]">
{/* Left Column: Section Order */}
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-bold text-white">Section Order</p>
<p className="text-xs text-slate-500">Move blocks to control the category detail page layout.</p>
</div>
<button
type="button"
onClick={() => resetDetailSectionEditor()}
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-bold uppercase tracking-widest text-slate-300 transition-all hover:bg-white/10"
>
<Plus className="h-3.5 w-3.5" />
Add
</button>
</div>
{detailSections.length === 0 && (
<div className="rounded-2xl border-2 border-dashed border-white/5 p-8 text-center">
<LayoutTemplate className="mx-auto h-10 w-10 text-slate-700" />
<p className="mt-3 text-sm font-semibold text-slate-500">No category CMS blocks yet.</p>
<p className="mt-1 text-xs text-slate-600">Seed the standard layout or choose a native block above.</p>
</div>
)}
{detailSections.map((section, index) => (
<div
key={section.id}
className={`rounded-2xl border p-4 transition-all ${selectedSectionId === section.id ? 'border-cyan-glow/40 bg-cyan-glow/5 shadow-[0_0_20px_rgba(102,252,241,0.06)]' : 'border-white/5 bg-white/[0.02] hover:border-white/10'}`}
>
<button type="button" onClick={() => selectDetailSection(section)} className="w-full text-left">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-night-950 text-slate-500">
<GripVertical className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-bold text-white">{SECTION_LABELS[section.type] || section.type}</p>
{section.isLocked && <Lock className="h-3.5 w-3.5 shrink-0 text-slate-500" />}
</div>
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-slate-500">
{SECTION_HELP[section.type] || 'Custom category detail section.'}
</p>
<p className="mt-2 text-[10px] uppercase tracking-widest text-slate-600">Order {section.order}</p>
</div>
<span className={`rounded-lg px-2 py-1 text-[10px] font-bold uppercase tracking-widest ${section.isActive ? 'bg-emerald-500/10 text-emerald-400' : 'bg-white/5 text-slate-500'}`}>
{section.isActive ? 'Active' : 'Hidden'}
</span>
</div>
</button>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => handleMoveDetailSection(index, -1)}
disabled={index === 0 || section.isLocked || detailSections[index - 1]?.isLocked || sectionsSaving}
className="p-2 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/10 disabled:opacity-30"
title={section.isLocked ? 'Locked section' : detailSections[index - 1]?.isLocked ? 'Cannot move above locked section' : 'Move up'}
>
<ArrowUp className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleMoveDetailSection(index, 1)}
disabled={index === detailSections.length - 1 || section.isLocked || detailSections[index + 1]?.isLocked || sectionsSaving}
className="p-2 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/10 disabled:opacity-30"
title={section.isLocked ? 'Locked section' : detailSections[index + 1]?.isLocked ? 'Cannot move below locked section' : 'Move down'}
>
<ArrowDown className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleToggleDetailSection(section)}
disabled={section.isLocked || sectionsSaving}
className="p-2 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/10 disabled:opacity-30"
title={section.isLocked ? 'Locked section' : section.isActive ? 'Hide section' : 'Show section'}
>
{section.isActive ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => handleDeleteDetailSection(section)}
disabled={section.isLocked || sectionsSaving}
className="ml-auto p-2 rounded-xl text-slate-600 hover:text-rose-500 hover:bg-rose-500/10 disabled:opacity-30"
title={section.isLocked ? 'Locked section' : 'Delete section'}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
{/* Right Column: Editing Block */}
<div className="space-y-4">
<div className="rounded-2xl border border-white/5 bg-white/[0.02] p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">
{selectedSectionId === 'new' ? 'New Block' : 'Editing Block'}
</p>
<h3 className="mt-2 text-2xl font-bold text-white">{SECTION_LABELS[sectionType] || sectionType}</h3>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-500">
{SECTION_HELP[sectionType] || 'Configure the section payload for this category detail page.'}
</p>
</div>
{selectedSection?.isLocked && (
<div className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-night-950 px-3 py-2 text-xs font-bold uppercase tracking-widest text-slate-400">
<Lock className="h-3.5 w-3.5" />
Locked
</div>
)}
</div>
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-[1fr_auto]">
<div className="space-y-2">
<label className="ml-1 text-[10px] font-bold uppercase tracking-widest text-slate-500">Block Type</label>
<select
value={sectionType}
onChange={(e) => {
const nextType = e.target.value
setSectionType(nextType)
const nextPayload = createDefaultCategoryPayload(nextType)
setSectionPayload(nextPayload)
setSectionJson(JSON.stringify(nextPayload, null, 2))
}}
disabled={selectedSectionId !== 'new' && detailSections.find((section) => section.id === selectedSectionId)?.isLocked}
className="w-full rounded-xl border border-white/10 bg-night-950 px-4 py-3 text-sm text-white outline-none transition-all focus:border-cyan-glow/40"
>
{CATEGORY_SECTION_TYPES.map((type) => (
<option key={type} value={type}>{SECTION_LABELS[type] || type}</option>
))}
</select>
</div>
<label className="mt-0 flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3 md:mt-6">
<input
type="checkbox"
checked={sectionIsActive}
onChange={(event) => setSectionIsActive(event.target.checked)}
className="accent-cyan-glow"
/>
<span className="whitespace-nowrap text-xs font-bold text-slate-300">Visible on category page</span>
</label>
</div>
</div>
{/* Block Form Container */}
<div className="rounded-2xl border border-white/5 bg-white/[0.02] p-5 space-y-5">
{CATEGORY_NATIVE_SECTION_TYPES.has(sectionType) ? (
<div className="space-y-5">
<div className="rounded-2xl border border-cyan-glow/10 bg-cyan-glow/[0.03] p-4">
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">Native Category Settings</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">These controls affect headers, navigation, and presentation. Live products come from this category.</p>
</div>
{sectionType === 'CATEGORY_HEADER' && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Title ({activeLang.toUpperCase()})</label>
<input
type="text"
value={getSectionLocalizedValue('title')}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Subtitle ({activeLang.toUpperCase()})</label>
<input
type="text"
value={getSectionLocalizedValue('subtitle')}
onChange={(e) => 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"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3">
<input
type="checkbox"
checked={sectionPayload.showBreadcrumbs !== false}
onChange={(event) => syncNativePayload('showBreadcrumbs', event.target.checked)}
className="accent-cyan-glow"
/>
<span className="text-xs font-bold text-slate-300">Show breadcrumbs</span>
</label>
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3">
<input
type="checkbox"
checked={sectionPayload.showCategoryNav !== false}
onChange={(event) => syncNativePayload('showCategoryNav', event.target.checked)}
className="accent-cyan-glow"
/>
<span className="text-xs font-bold text-slate-300">Show category pills</span>
</label>
</div>
</div>
)}
{sectionType === 'CATALOG_GRID' && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3">
<input
type="checkbox"
checked={sectionPayload.showFilters !== false}
onChange={(event) => syncNativePayload('showFilters', event.target.checked)}
className="accent-cyan-glow"
/>
<span className="text-xs font-bold text-slate-300">Show filter sidebar</span>
</label>
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3">
<input
type="checkbox"
checked={sectionPayload.showToolbar !== false}
onChange={(event) => syncNativePayload('showToolbar', event.target.checked)}
className="accent-cyan-glow"
/>
<span className="text-xs font-bold text-slate-300">Show sorting toolbar</span>
</label>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Items Per Page</label>
<input
type="number"
min={4}
max={100}
value={typeof sectionPayload.itemsPerPage === 'number' ? sectionPayload.itemsPerPage : 24}
onChange={(e) => 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"
/>
</div>
</div>
)}
{sectionType === 'USP_BAR' && (
<div className="space-y-4">
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3">
<input
type="checkbox"
checked={sectionPayload.showUsp !== false}
onChange={(event) => syncNativePayload('showUsp', event.target.checked)}
className="accent-cyan-glow"
/>
<span className="text-xs font-bold text-slate-300">Show USP benefits bar</span>
</label>
<p className="text-xs text-slate-500">Renders the 3 USP benefit columns configured for this category.</p>
</div>
)}
</div>
) : (
<div className="space-y-5">
<div className="rounded-2xl border border-cyan-glow/10 bg-cyan-glow/[0.03] p-4">
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">
Category Content Block
</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">These forms follow the Page Builder block design, but stay scoped to this category.</p>
</div>
{sectionType === 'HERO_CAROUSEL' && (
<CategoryHeroCarouselBlockForm
payload={sectionPayload}
activeLanguage={activeLang}
onChange={(nextPayload) => {
setSectionPayload(nextPayload)
setSectionJson(JSON.stringify(nextPayload, null, 2))
}}
onBrowseMedia={openMediaPicker}
/>
)}
{sectionType === 'DIVIDER' && (
<CategoryDividerBlockForm
payload={sectionPayload}
onChange={(nextPayload) => {
setSectionPayload(nextPayload)
setSectionJson(JSON.stringify(nextPayload, null, 2))
}}
/>
)}
</div>
)}
{/* Bottom Save Bar */}
<div className="flex flex-col-reverse gap-3 border-t border-white/5 pt-5 sm:flex-row sm:justify-end">
{sectionSaveError && (
<div className="flex items-start gap-2 rounded-xl border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-400 sm:mr-auto sm:max-w-xl" role="alert">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>{sectionSaveError}</p>
</div>
)}
<button
type="button"
onClick={() => setSectionJson(JSON.stringify(sectionPayload, null, 2))}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-bold text-slate-300 transition-all hover:bg-white/10"
>
<FileJson className="h-4 w-4" />
Sync JSON
</button>
<button
type="button"
onClick={handleSaveDetailSection}
disabled={sectionsSaving}
className="btn-primary inline-flex items-center justify-center gap-2 px-6 py-2.5 text-sm font-bold disabled:opacity-50"
>
{sectionsSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4" />
Save Section
</>
)}
</button>
</div>
</div>
</div>
</div>
</section>
{/* Media Library Modal */}
<MediaLibraryModal
isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)}
onSelect={(url) => {
if (onSelectMedia) onSelectMedia(url)
setIsMediaModalOpen(false)
}}
/>
{/* Add Block Modal (Focused specifically on Hero Carousel and Divider) */}
{isAddBlockModalOpen && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="admin-card max-w-xl w-full p-6 border border-white/10 bg-night-950 rounded-2xl space-y-6">
<div className="flex items-center justify-between border-b border-white/10 pb-4">
<div>
<h3 className="text-lg font-bold text-white">Add Page Block</h3>
<p className="text-xs text-slate-400">Choose a section type to insert into this category detail page.</p>
</div>
<button
onClick={() => setIsAddBlockModalOpen(false)}
className="text-slate-400 hover:text-white"
>
✕
</button>
</div>
<div className="space-y-3">
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">Content Blocks</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => {
startNewSection('HERO_CAROUSEL')
setIsAddBlockModalOpen(false)
}}
className="p-4 rounded-xl border border-white/5 bg-white/[0.03] hover:border-cyan-glow/50 hover:bg-cyan-glow/5 transition-all text-left flex items-start gap-3 group"
>
<div className="p-2.5 rounded-lg bg-cyan-glow/10 text-cyan-glow group-hover:bg-cyan-glow group-hover:text-black transition-colors shrink-0">
<ImageIcon className="w-5 h-5" />
</div>
<div>
<span className="text-sm font-bold text-white group-hover:text-cyan-glow transition-colors block">
Hero Carousel
</span>
<p className="text-xs text-slate-400 mt-1">
Wide promotional slider with responsive artwork.
</p>
</div>
</button>
<button
type="button"
onClick={() => {
startNewSection('DIVIDER')
setIsAddBlockModalOpen(false)
}}
className="p-4 rounded-xl border border-white/5 bg-white/[0.03] hover:border-cyan-glow/50 hover:bg-cyan-glow/5 transition-all text-left flex items-start gap-3 group"
>
<div className="p-2.5 rounded-lg bg-cyan-glow/10 text-cyan-glow group-hover:bg-cyan-glow group-hover:text-black transition-colors shrink-0">
<Minus className="w-5 h-5" />
</div>
<div>
<span className="text-sm font-bold text-white group-hover:text-cyan-glow transition-colors block">
Divider
</span>
<p className="text-xs text-slate-400 mt-1">
Visual line separator between category sections.
</p>
</div>
</button>
</div>
<p className="text-xs font-bold uppercase tracking-widest text-slate-400 pt-2">Native Category Blocks</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{NATIVE_BLOCKS.map(({ type, label, icon: Icon }) => (
<button
key={type}
type="button"
onClick={() => {
startNewSection(type)
setIsAddBlockModalOpen(false)
}}
className="p-3 rounded-xl border border-white/5 bg-white/[0.02] hover:border-cyan-glow/40 transition-all text-left flex items-center gap-2 group"
>
<Icon className="w-4 h-4 text-cyan-glow shrink-0" />
<span className="text-xs font-bold text-slate-300 group-hover:text-white truncate">
{label}
</span>
</button>
))}
</div>
</div>
</div>
</div>
)}
</div>
)
}
+10
View File
@@ -463,6 +463,16 @@ export default function CategoryEditPage() {
</button>
)}
{!isNew && (
<Link
to={`/categories/${id}/cms`}
className="px-4 py-2.5 rounded-xl bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/30 text-sm font-semibold transition-all cursor-pointer flex items-center gap-2"
>
<LayoutTemplate className="w-4 h-4" />
<span>CMS Blocks</span>
</Link>
)}
<Link
to="/categories"
className="px-5 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white text-sm font-semibold transition-all cursor-pointer"
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "CategorySection" (
"id" SERIAL NOT NULL,
"categoryId" INTEGER NOT NULL,
"type" TEXT NOT NULL,
"order" INTEGER NOT NULL,
"payload" JSONB NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isLocked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CategorySection_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CategorySection_categoryId_order_idx" ON "CategorySection"("categoryId", "order");
-- CreateIndex
CREATE INDEX "CategorySection_categoryId_isActive_idx" ON "CategorySection"("categoryId", "isActive");
-- AddForeignKey
ALTER TABLE "CategorySection" ADD CONSTRAINT "CategorySection_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+17
View File
@@ -42,6 +42,7 @@ model Category {
products Product[]
attributes AttributeDefinition[]
translations CategoryTranslation[]
sections CategorySection[]
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -767,6 +768,22 @@ model ProductDetailSection {
@@index([productId, isActive])
}
model CategorySection {
id Int @id @default(autoincrement())
categoryId Int
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
type String
order Int
payload Json
isActive Boolean @default(true)
isLocked Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([categoryId, order])
@@index([categoryId, isActive])
}
model NewsletterSubscription {
id Int @id @default(autoincrement())
email String @unique
+2 -1
View File
@@ -33,6 +33,7 @@ import { businessAnalyticsRoute } from "./routes/businessAnalytics";
import { toolsRoute } from "./routes/tools";
import { crmRoute } from "./routes/crm";
import { brandKnowledgeRoute } from "./routes/brandKnowledge";
import { categorySectionsRoute } from "./routes/categorySections";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
@@ -140,7 +141,7 @@ const app = new Elysia()
return file;
})
.group("/api", (app) =>
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" });
+228
View File
@@ -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<number>;
createMany(args: any): Promise<unknown>;
findFirst(args: any): Promise<{ id?: number; order: number; type?: string } | null>;
update(args: any): Promise<any>;
updateMany(args: any): Promise<unknown>;
create(args: any): Promise<any>;
};
}
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<string, unknown> {
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 };
}
+1
View File
@@ -20,3 +20,4 @@ const prisma = new PrismaClient({
});
export default prisma;
// Reloaded with CategorySection support
+219
View File
@@ -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 };
});
@@ -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,17 +139,13 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
}
}
const renderHeaderSection = (payload?: Record<string, unknown>) => {
const showPills = payload?.showCategoryNav !== false && category.filterConfig?.showCategoryNav !== false
const showBreadcrumb = payload?.showBreadcrumbs !== false
return (
<div className="min-h-screen bg-surface-page text-text-main">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
{heroSections.length > 0 && !(layoutType === 'editorial_hero' && hasArtDirectedHero) ? (
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
) : layoutType === 'none' ? (
/* ── Archetype 4: No Header (ปิดส่วนหัว Archetype) ── */
null
) : layoutType === 'editorial_hero' ? (
<div className="w-full">
{layoutType === 'none' ? null : layoutType === 'editorial_hero' ? (
/* ── Archetype 2: Editorial Hero Layout ── */
<section className="relative overflow-hidden min-h-[380px] md:min-h-[460px] flex items-center justify-center border-b border-line-primary/10 bg-black">
{heroDesktopUrl && (
@@ -160,6 +173,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
)}
<div className="relative z-10 mx-auto max-w-4xl px-6 pt-32 pb-16 text-center space-y-5">
{showBreadcrumb && (
<div className="flex items-center justify-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-300">
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
<span>/</span>
@@ -167,6 +181,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
<span>/</span>
<span className="text-brand-primary font-bold">{categoryName}</span>
</div>
)}
<div className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full text-[11px] font-bold uppercase tracking-widest bg-white/10 text-white border border-white/20 backdrop-blur-md">
<span>Curated Aroma Collection</span>
@@ -188,6 +203,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
<section className="relative overflow-hidden bg-surface-card border-b border-line-primary/10 pt-28 pb-4 md:pt-32 md:pb-5">
<div className="mx-auto max-w-7xl px-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="space-y-1.5">
{showBreadcrumb && (
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-text">
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
<span>/</span>
@@ -195,6 +211,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
<span>/</span>
<span className="text-text-main font-bold">{categoryName}</span>
</div>
)}
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-text-main sm:text-3xl tracking-tight">{categoryName}</h1>
{category._count && (
@@ -214,6 +231,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
<section className="relative overflow-hidden bg-surface-card border-b border-line-primary/10">
<div className="relative mx-auto max-w-7xl px-6 pt-32 pb-12 md:pt-32 md:pb-20 flex flex-col-reverse md:flex-row items-center gap-10 md:gap-16">
<div className="flex-1 text-center md:text-left space-y-4">
{showBreadcrumb && (
<div className="flex items-center justify-center md:justify-start gap-2 text-xs font-semibold uppercase tracking-widest text-muted-text">
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
<span>/</span>
@@ -221,6 +239,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
<span>/</span>
<span className="text-text-main font-bold">{categoryName}</span>
</div>
)}
<div>
<h1 className="text-4xl font-semibold text-text-main md:text-6xl">{categoryName}</h1>
{category._count && <p className="mt-3 text-muted-text text-sm">{category._count.products} products available in this collection</p>}
@@ -241,9 +260,8 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
</section>
)}
<div className={`mx-auto max-w-7xl space-y-5 px-6 pb-12 ${layoutType === 'none' ? 'pt-24 md:pt-28' : 'pt-3'}`}>
{/* Minimal Breadcrumbs when Archetype Header is disabled */}
{layoutType === 'none' && (
{layoutType === 'none' && showBreadcrumb && (
<div className="mx-auto max-w-7xl px-6 pt-24 md:pt-28">
<nav aria-label="Breadcrumb" className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-text">
<Link href="/" className="transition hover:text-brand-primary">Home</Link>
<span>/</span>
@@ -251,10 +269,11 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
<span>/</span>
<span className="text-text-main font-bold">{categoryName}</span>
</nav>
</div>
)}
{/* Category Pills Navigation (Per-category Toggle) */}
{category.filterConfig?.showCategoryNav !== false && (
{showPills && (
<div className="mx-auto max-w-7xl px-6 pt-4">
<div className={`flex flex-wrap gap-2.5 ${layoutType === 'editorial_hero' ? 'justify-center' : 'justify-start'}`}>
{!categoriesResult.ok && (
<span className="rounded-full border border-rose-100 bg-surface-card px-4 py-1.5 text-xs font-bold uppercase tracking-widest text-rose-500">
@@ -279,9 +298,15 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
)
})}
</div>
</div>
)}
</div>
)
}
{/* Featured Series Showcase Banner (Mockup 01 & 03) */}
const renderCatalogGrid = () => (
<div className="mx-auto max-w-7xl space-y-5 px-6 pb-12">
{/* Featured Series Showcase Banner */}
{Boolean(category.featuredSeries?.enabled) && (
<CategoryFeaturedSeries
featured={category.featuredSeries!}
@@ -305,13 +330,66 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
lang={lang}
/>
)}
</div>
)
{/* Benefits & Support USP Blocks (Mockup 06) */}
const renderUspBar = () => (
<div className="mx-auto max-w-7xl px-6 pb-12">
<CategoryUspBar benefits={category.uspBenefits} />
</div>
)
return (
<div className="min-h-screen bg-surface-page text-text-main">
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
{customSections.length > 0 ? (
/* ── Dynamic Category CMS Layout ── */
<div className="space-y-6">
{customSections.map((sec, idx) => {
const secPayload = sec.payload && typeof sec.payload === 'object' && !Array.isArray(sec.payload)
? (sec.payload as Record<string, unknown>)
: undefined
if (sec.type === 'CATEGORY_HEADER') {
return <div key={sec.id || idx}>{renderHeaderSection(secPayload)}</div>
}
if (sec.type === 'CATALOG_GRID') {
return <div key={sec.id || idx}>{renderCatalogGrid()}</div>
}
if (sec.type === 'USP_BAR') {
return <div key={sec.id || idx}>{renderUspBar()}</div>
}
return (
<DynamicPageRenderer
key={sec.id || idx}
sections={[toPageSection(sec)]}
categories={categories}
lang={lang}
/>
)
})}
</div>
) : (
/* ── Legacy Fallback Category Layout ── */
<>
{heroSections.length > 0 && !(layoutType === 'editorial_hero' && hasArtDirectedHero) ? (
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
) : (
renderHeaderSection()
)}
{renderCatalogGrid()}
{renderUspBar()}
{bottomSections.length > 0 && (
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
)}
</>
)}
</div>
)
}
+5 -1
View File
@@ -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<ProductDetailSection[]>(`/products/${id}/detail-sections?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
}
export async function getCategorySectionsResult(id: string | number, lang = 'all'): Promise<ApiResult<CategorySection[]>> {
return await fetchJsonResult<CategorySection[]>(`/categories/${id}/sections?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
}
export async function getProductReviewsResult(id: string | number): Promise<ApiResult<ProductReview[]>> {
return await fetchJsonResult<ProductReview[]>(`/reviews/product/${id}`, { next: { revalidate: defaultRevalidate } })
}
+33
View File
@@ -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];