x
This commit is contained in:
1 parent
b8a255821b
commit
46f09ab145
16 files changed
+2269
-186
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -20,3 +20,4 @@ const prisma = new PrismaClient({
|
||||
});
|
||||
|
||||
export default prisma;
|
||||
// Reloaded with CategorySection support
|
||||
@@ -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,195 +139,256 @@ 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="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 && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src={heroDesktopUrl}
|
||||
alt={categoryName}
|
||||
fill
|
||||
unoptimized
|
||||
sizes="(min-width: 768px) 100vw, 1px"
|
||||
className="hidden object-cover filter brightness-[0.45] saturate-125 scale-105 md:block"
|
||||
style={{ objectPosition: `${(heroDesktopMedia?.focalX ?? 0.5) * 100}% ${(heroDesktopMedia?.focalY ?? 0.5) * 100}%` }}
|
||||
/>
|
||||
<Image
|
||||
src={heroMobileUrl}
|
||||
alt={categoryName}
|
||||
fill
|
||||
unoptimized
|
||||
sizes="(max-width: 767px) 100vw, 1px"
|
||||
className="object-cover filter brightness-[0.45] saturate-125 scale-105 md:hidden"
|
||||
style={{ objectPosition: `${(heroMobileMedia?.focalX ?? 0.5) * 100}% ${(heroMobileMedia?.focalY ?? 0.5) * 100}%` }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/70 to-black/40" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-serif font-bold text-white md:text-6xl tracking-tight leading-tight">
|
||||
{categoryName}
|
||||
</h1>
|
||||
|
||||
{category._count && (
|
||||
<p className="text-slate-300 text-sm md:text-base max-w-xl mx-auto font-light leading-relaxed">
|
||||
Discover {category._count.products} handcrafted scents and artisanal essences designed to elevate your living spaces.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : layoutType === 'dense_catalog' ? (
|
||||
/* ── Archetype 3: Dense Catalog Layout ── */
|
||||
<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>
|
||||
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
|
||||
<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 && (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-mono font-bold bg-brand-primary/10 text-brand-primary border border-brand-primary/20">
|
||||
{category._count.products} items
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-text">
|
||||
High-Density Catalog View
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
/* ── Archetype 1: Split Showcase Layout (Default) ── */
|
||||
<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>
|
||||
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
|
||||
<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>}
|
||||
</div>
|
||||
</div>
|
||||
{categoryThumbnailUrl && (
|
||||
<div className="relative w-48 h-48 md:w-72 md:h-72 shrink-0 rounded-3xl overflow-hidden shadow-lg border border-line-primary/5">
|
||||
<Image
|
||||
src={categoryThumbnailUrl}
|
||||
alt={categoryName}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
|
||||
<span>/</span>
|
||||
<span className="text-text-main font-bold">{categoryName}</span>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
Categories unavailable
|
||||
</span>
|
||||
)}
|
||||
{categories.map((item) => {
|
||||
const name = resolveLocalized(item.name, lang)
|
||||
const isActive = item.id === category.id
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/category/${item.id}`}
|
||||
className={`rounded-full px-4 py-1.5 text-xs font-bold uppercase tracking-wider transition-all ${isActive
|
||||
? 'bg-text-main text-surface-card shadow-sm'
|
||||
: 'border border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary'
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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!}
|
||||
categoryName={categoryName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Catalog Container (Filter Sidebar + Toolbar + Grid) */}
|
||||
{products.length === 0 ? (
|
||||
<div className="ui-panel px-6 py-20 text-center">
|
||||
<p className="text-lg font-semibold text-text-main">No products in this category yet.</p>
|
||||
<Link href="/shop" className="mt-4 inline-flex font-semibold text-brand-primary">Back to Shop</Link>
|
||||
</div>
|
||||
) : (
|
||||
<CategoryCatalogContainer
|
||||
products={products}
|
||||
categoryName={categoryName}
|
||||
filterConfig={category.filterConfig}
|
||||
availableFilters={categoryProductsResult.data?.availableFilters || category.availableFilters || []}
|
||||
layoutGridClass={getProductGridClass()}
|
||||
lang={lang}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
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} />
|
||||
)}
|
||||
{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' ? (
|
||||
/* ── 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 && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src={heroDesktopUrl}
|
||||
alt={categoryName}
|
||||
fill
|
||||
unoptimized
|
||||
sizes="(min-width: 768px) 100vw, 1px"
|
||||
className="hidden object-cover filter brightness-[0.45] saturate-125 scale-105 md:block"
|
||||
style={{ objectPosition: `${(heroDesktopMedia?.focalX ?? 0.5) * 100}% ${(heroDesktopMedia?.focalY ?? 0.5) * 100}%` }}
|
||||
|
||||
{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}
|
||||
/>
|
||||
<Image
|
||||
src={heroMobileUrl}
|
||||
alt={categoryName}
|
||||
fill
|
||||
unoptimized
|
||||
sizes="(max-width: 767px) 100vw, 1px"
|
||||
className="object-cover filter brightness-[0.45] saturate-125 scale-105 md:hidden"
|
||||
style={{ objectPosition: `${(heroMobileMedia?.focalX ?? 0.5) * 100}% ${(heroMobileMedia?.focalY ?? 0.5) * 100}%` }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/70 to-black/40" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* ── Legacy Fallback Category Layout ── */
|
||||
<>
|
||||
{heroSections.length > 0 && !(layoutType === 'editorial_hero' && hasArtDirectedHero) ? (
|
||||
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
|
||||
) : (
|
||||
renderHeaderSection()
|
||||
)}
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-4xl px-6 pt-32 pb-16 text-center space-y-5">
|
||||
<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>
|
||||
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
|
||||
<span>/</span>
|
||||
<span className="text-brand-primary font-bold">{categoryName}</span>
|
||||
</div>
|
||||
{renderCatalogGrid()}
|
||||
{renderUspBar()}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-serif font-bold text-white md:text-6xl tracking-tight leading-tight">
|
||||
{categoryName}
|
||||
</h1>
|
||||
|
||||
{category._count && (
|
||||
<p className="text-slate-300 text-sm md:text-base max-w-xl mx-auto font-light leading-relaxed">
|
||||
Discover {category._count.products} handcrafted scents and artisanal essences designed to elevate your living spaces.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : layoutType === 'dense_catalog' ? (
|
||||
/* ── Archetype 3: Dense Catalog Layout ── */
|
||||
<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">
|
||||
<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>
|
||||
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
|
||||
<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 && (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-mono font-bold bg-brand-primary/10 text-brand-primary border border-brand-primary/20">
|
||||
{category._count.products} items
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-text">
|
||||
High-Density Catalog View
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
/* ── Archetype 1: Split Showcase Layout (Default) ── */
|
||||
<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">
|
||||
<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>
|
||||
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
|
||||
<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>}
|
||||
</div>
|
||||
</div>
|
||||
{categoryThumbnailUrl && (
|
||||
<div className="relative w-48 h-48 md:w-72 md:h-72 shrink-0 rounded-3xl overflow-hidden shadow-lg border border-line-primary/5">
|
||||
<Image
|
||||
src={categoryThumbnailUrl}
|
||||
alt={categoryName}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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' && (
|
||||
<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>
|
||||
<Link href="/shop" className="transition hover:text-brand-primary">Shop</Link>
|
||||
<span>/</span>
|
||||
<span className="text-text-main font-bold">{categoryName}</span>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Category Pills Navigation (Per-category Toggle) */}
|
||||
{category.filterConfig?.showCategoryNav !== false && (
|
||||
<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">
|
||||
Categories unavailable
|
||||
</span>
|
||||
)}
|
||||
{categories.map((item) => {
|
||||
const name = resolveLocalized(item.name, lang)
|
||||
const isActive = item.id === category.id
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/category/${item.id}`}
|
||||
className={`rounded-full px-4 py-1.5 text-xs font-bold uppercase tracking-wider transition-all ${isActive
|
||||
? 'bg-text-main text-surface-card shadow-sm'
|
||||
: 'border border-line-primary/20 bg-surface-card text-muted-text hover:border-brand-primary hover:text-brand-primary'
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Featured Series Showcase Banner (Mockup 01 & 03) */}
|
||||
{Boolean(category.featuredSeries?.enabled) && (
|
||||
<CategoryFeaturedSeries
|
||||
featured={category.featuredSeries!}
|
||||
categoryName={categoryName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Catalog Container (Filter Sidebar + Toolbar + Grid) */}
|
||||
{products.length === 0 ? (
|
||||
<div className="ui-panel px-6 py-20 text-center">
|
||||
<p className="text-lg font-semibold text-text-main">No products in this category yet.</p>
|
||||
<Link href="/shop" className="mt-4 inline-flex font-semibold text-brand-primary">Back to Shop</Link>
|
||||
</div>
|
||||
) : (
|
||||
<CategoryCatalogContainer
|
||||
products={products}
|
||||
categoryName={categoryName}
|
||||
filterConfig={category.filterConfig}
|
||||
availableFilters={categoryProductsResult.data?.availableFilters || category.availableFilters || []}
|
||||
layoutGridClass={getProductGridClass()}
|
||||
lang={lang}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Benefits & Support USP Blocks (Mockup 06) */}
|
||||
<CategoryUspBar benefits={category.uspBenefits} />
|
||||
</div>
|
||||
{bottomSections.length > 0 && (
|
||||
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
|
||||
{bottomSections.length > 0 && (
|
||||
<DynamicPageRenderer sections={bottomSections} categories={categories} lang={lang} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
Reference in new issue
Block a user