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

No files matched your search

+2
View File
@@ -7,6 +7,7 @@ import ProductBomPage from './pages/ProductBomPage'
import ProductCmsPage from './pages/ProductCmsPage'
import CategoriesPage from './pages/CategoriesPage'
import CategoryEditPage from './pages/CategoryEditPage'
import CategoryCmsPage from './pages/CategoryCmsPage'
import CustomersPage from './pages/CustomersPage'
import PageBuilderPage from './pages/PageBuilderPage'
import PageReorderPage from './pages/PageReorderPage'
@@ -107,6 +108,7 @@ function App() {
<Route path="/categories/new" element={<CategoryEditPage />} />
<Route path="/categories/:id" element={<CategoryEditPage />} />
<Route path="/categories/:id/edit" element={<CategoryEditPage />} />
<Route path="/categories/:id/cms" element={<CategoryCmsPage />} />
<Route path="/customers" element={<CustomersPage />} />
<Route path="/page-builder" element={<PageBuilderPage />} />
<Route path="/page-builder/:id/reorder" element={<PageReorderPage />} />
@@ -0,0 +1,155 @@
interface CategoryDividerBlockFormProps {
payload: Record<string, unknown>
onChange: (payload: Record<string, unknown>) => void
}
const WIDTH_OPTIONS = [
{ value: 'full', label: 'Full width' },
{ value: 'container', label: 'Container' },
{ value: 'narrow', label: 'Narrow' },
] as const
const STYLE_OPTIONS = [
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'dotted', label: 'Dotted' },
] as const
const SPACING_OPTIONS = [
{ value: 'none', label: 'None' },
{ value: 'sm', label: 'Small' },
{ value: 'md', label: 'Medium' },
{ value: 'lg', label: 'Large' },
{ value: 'xl', label: 'Extra large' },
] as const
const getString = (value: unknown, fallback: string) =>
typeof value === 'string' ? value : fallback
const getThickness = (value: unknown) =>
typeof value === 'number' && value >= 1 && value <= 4 ? value : 1
const getLineStyle = (value: unknown): 'solid' | 'dashed' | 'dotted' =>
value === 'dashed' || value === 'dotted' ? value : 'solid'
const DEFAULT_DIVIDER_COLOR = '#94a3b8'
const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
const toColorPickerValue = (color: string) => {
if (!HEX_COLOR_PATTERN.test(color)) return DEFAULT_DIVIDER_COLOR
const digits = color.slice(1)
if (digits.length === 3 || digits.length === 4) {
return `#${digits.slice(0, 3).split('').map((digit) => `${digit}${digit}`).join('')}`
}
return `#${digits.slice(0, 6)}`
}
export default function CategoryDividerBlockForm({ payload, onChange }: CategoryDividerBlockFormProps) {
const width = getString(payload.width, 'container')
const style = getLineStyle(payload.style)
const thickness = getThickness(payload.thickness)
const color = getString(payload.color, DEFAULT_DIVIDER_COLOR)
const isColorValid = HEX_COLOR_PATTERN.test(color)
const previewColor = isColorValid ? color : DEFAULT_DIVIDER_COLOR
const pickerColor = toColorPickerValue(color)
const updatePayload = (field: string, value: unknown) => onChange({ ...payload, [field]: value })
return (
<div className="space-y-6">
<div className="rounded-2xl border border-cyan-glow/10 bg-cyan-glow/[0.03] p-4">
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">Category Section Divider</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">
Add a visual separator between blocks on this category detail page. This form is scoped to Category CMS.
</p>
</div>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Width</span>
<select
value={width}
onChange={(event) => updatePayload('width', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{WIDTH_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Line style</span>
<select
value={style}
onChange={(event) => updatePayload('style', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{STYLE_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Thickness</span>
<select
value={thickness}
onChange={(event) => updatePayload('thickness', Number(event.target.value))}
className="admin-input w-full bg-slate-900 border-white/10"
>
{[1, 2, 3, 4].map((value) => <option key={value} value={value}>{value}px</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Spacing</span>
<select
value={getString(payload.spacing, 'md')}
onChange={(event) => updatePayload('spacing', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{SPACING_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Color</label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="flex items-center gap-3">
<input
type="color"
value={pickerColor}
onChange={(event) => updatePayload('color', event.target.value)}
className="h-11 w-14 cursor-pointer rounded-xl border border-white/10 bg-night-950 p-1"
aria-label="Divider color picker"
/>
<input
type="text"
value={color}
onChange={(event) => updatePayload('color', event.target.value)}
placeholder="#94a3b8"
className="admin-input w-36 uppercase"
/>
</div>
{!isColorValid && (
<p className="text-xs text-amber-400">Enter a valid hex color code (e.g. #94a3b8).</p>
)}
</div>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Live preview</label>
<div className="rounded-2xl border border-white/5 bg-night-950 p-6">
<div
style={{
width: width === 'full' ? '100%' : width === 'narrow' ? '40%' : '75%',
margin: '0 auto',
borderTopStyle: style,
borderTopWidth: `${thickness}px`,
borderTopColor: previewColor,
}}
/>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,277 @@
import {
ChevronDown,
ChevronUp,
ImagePlus,
Monitor,
Plus,
Smartphone,
Trash2,
} from 'lucide-react'
type CategoryHeroCarouselBlockFormProps = {
payload: Record<string, unknown>
activeLanguage: string
onChange: (payload: Record<string, unknown>) => void
onBrowseMedia: (onSelect: (url: string) => void) => void
}
type SlidePayload = Record<string, unknown>
const DESKTOP_ASPECT_RATIOS = ['21/6', '21/7', '21/9', '16/5', '16/6', '4/1', '5/1', 'auto'] as const
const MOBILE_ASPECT_RATIOS = ['16/9', '4/3', '1/1', '21/9', 'auto'] as const
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const createSlide = (): SlidePayload => ({
imageUrl: '',
mobileImageUrl: '',
linkUrl: '',
alt: {},
title: {},
subtitle: {},
buttonText: {},
focalPoint: { x: '50%', y: '50%' },
})
const getSlides = (payload: Record<string, unknown>): SlidePayload[] =>
Array.isArray(payload.slides) ? payload.slides.filter(isRecord) : []
const getLocalizedValue = (value: unknown, language: string) => {
if (typeof value === 'string') return value
if (!isRecord(value)) return ''
const localized = value[language]
return typeof localized === 'string' ? localized : ''
}
const updateLocalizedValue = (value: unknown, language: string, nextValue: string) => ({
...(typeof value === 'string' ? { th: value } : isRecord(value) ? value : {}),
[language]: nextValue,
})
const getString = (value: unknown, fallback = '') => typeof value === 'string' ? value : fallback
export default function CategoryHeroCarouselBlockForm({
payload,
activeLanguage,
onChange,
onBrowseMedia,
}: CategoryHeroCarouselBlockFormProps) {
const slides = getSlides(payload)
const updateRoot = (field: string, value: unknown) => {
onChange({ ...payload, [field]: value })
}
const updateSlide = (index: number, update: SlidePayload) => {
updateRoot('slides', slides.map((slide, slideIndex) => slideIndex === index ? { ...slide, ...update } : slide))
}
const addSlide = () => updateRoot('slides', [...slides, createSlide()])
const removeSlide = (index: number) => {
if (slides.length <= 1) return
updateRoot('slides', slides.filter((_, slideIndex) => slideIndex !== index))
}
const moveSlide = (index: number, direction: -1 | 1) => {
const targetIndex = index + direction
if (targetIndex < 0 || targetIndex >= slides.length) return
const reordered = [...slides]
const [movedSlide] = reordered.splice(index, 1)
reordered.splice(targetIndex, 0, movedSlide)
updateRoot('slides', reordered)
}
return (
<div className="space-y-6">
<div className="rounded-2xl border border-cyan-glow/10 bg-cyan-glow/[0.03] p-4">
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">Category Wide Promo Slider</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">This form is scoped to Category CMS and does not share UI code with Page CMS.</p>
</div>
<div className="grid grid-cols-1 gap-4 rounded-2xl border border-white/5 bg-night-950 p-4 md:grid-cols-2 xl:grid-cols-4">
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Desktop ratio</span>
<select
value={getString(payload.aspectRatio, '21/7')}
onChange={(event) => updateRoot('aspectRatio', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{DESKTOP_ASPECT_RATIOS.map((ratio) => <option key={ratio} value={ratio}>{ratio}</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Mobile ratio</span>
<select
value={getString(payload.mobileAspectRatio, '16/9')}
onChange={(event) => updateRoot('mobileAspectRatio', event.target.value)}
className="admin-input w-full bg-slate-900 border-white/10"
>
{MOBILE_ASPECT_RATIOS.map((ratio) => <option key={ratio} value={ratio}>{ratio}</option>)}
</select>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Interval (ms)</span>
<input
type="number"
min={1000}
step={500}
value={typeof payload.interval === 'number' ? payload.interval : 4500}
disabled={payload.autoplay === false}
onChange={(event) => updateRoot('interval', Number(event.target.value))}
className="admin-input w-full disabled:opacity-40"
/>
</label>
{([
['Autoplay', 'autoplay'],
['Show arrows', 'showArrows'],
['Show dots', 'showDots'],
] as const).map(([label, field]) => (
<label key={field} className="flex items-center gap-3 rounded-xl border border-white/5 bg-white/5 px-4 py-3">
<input
type="checkbox"
checked={payload[field] !== false}
onChange={(event) => updateRoot(field, event.target.checked)}
className="accent-cyan-glow"
/>
<span className="text-xs font-bold text-slate-300">{label}</span>
</label>
))}
</div>
<div className="space-y-4">
{slides.map((slide, slideIndex) => {
const focalPoint = isRecord(slide.focalPoint) ? slide.focalPoint : {}
const focalX = getString(focalPoint.x, '50%')
const focalY = getString(focalPoint.y, '50%')
return (
<article key={slideIndex} className="space-y-5 rounded-2xl border border-white/5 bg-white/[0.02] p-4 md:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="font-bold text-white">Slide {slideIndex + 1}</h4>
<p className="mt-1 text-xs text-slate-500">Desktop image is required. Mobile falls back to desktop.</p>
</div>
<div className="flex items-center gap-1">
<button type="button" onClick={() => moveSlide(slideIndex, -1)} disabled={slideIndex === 0} className="rounded-lg p-2 text-slate-500 hover:bg-white/5 hover:text-white disabled:opacity-30" aria-label={`Move slide ${slideIndex + 1} up`}>
<ChevronUp className="h-4 w-4" />
</button>
<button type="button" onClick={() => moveSlide(slideIndex, 1)} disabled={slideIndex === slides.length - 1} className="rounded-lg p-2 text-slate-500 hover:bg-white/5 hover:text-white disabled:opacity-30" aria-label={`Move slide ${slideIndex + 1} down`}>
<ChevronDown className="h-4 w-4" />
</button>
<button type="button" onClick={() => removeSlide(slideIndex)} disabled={slides.length === 1} className="rounded-lg p-2 text-slate-500 hover:bg-rose-500/10 hover:text-rose-400 disabled:opacity-30" aria-label={`Delete slide ${slideIndex + 1}`}>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{([
{ field: 'imageUrl', label: 'Desktop image *', icon: Monitor },
{ field: 'mobileImageUrl', label: 'Mobile image', icon: Smartphone },
] as const).map(({ field, label, icon: DeviceIcon }) => {
const imageUrl = getString(slide[field])
return (
<div key={field} className="space-y-3">
<div className="flex aspect-[16/7] items-center justify-center overflow-hidden rounded-xl border border-white/5 bg-night-950">
{imageUrl ? (
<img src={imageUrl} alt="" className="h-full w-full object-cover" style={{ objectPosition: `${focalX} ${focalY}` }} />
) : (
<DeviceIcon className="h-8 w-8 text-slate-700" />
)}
</div>
<div className="flex gap-2">
<input
type="url"
required={field === 'imageUrl'}
aria-required={field === 'imageUrl'}
value={imageUrl}
onChange={(event) => updateSlide(slideIndex, { [field]: event.target.value })}
placeholder={`${label} URL`}
className="admin-input min-w-0 flex-1"
/>
<button
type="button"
onClick={() => onBrowseMedia((url) => updateSlide(slideIndex, { [field]: url }))}
className="rounded-xl border border-cyan-glow/20 bg-cyan-glow/10 px-3 py-2.5 text-cyan-glow hover:bg-cyan-glow/20"
aria-label={`Choose ${label.toLowerCase()} from media library`}
>
<ImagePlus className="h-4 w-4" />
</button>
</div>
</div>
)
})}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{([
['title', 'Title'],
['subtitle', 'Subtitle'],
['buttonText', 'Button text'],
['alt', 'Image alt text'],
] as const).map(([field, label]) => (
<label key={field} className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">{label} ({activeLanguage.toUpperCase()})</span>
<input
type="text"
value={getLocalizedValue(slide[field], activeLanguage)}
onChange={(event) => updateSlide(slideIndex, { [field]: updateLocalizedValue(slide[field], activeLanguage, event.target.value) })}
className="admin-input w-full"
/>
</label>
))}
<label className="space-y-2 md:col-span-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Link URL</span>
<input
type="text"
value={getString(slide.linkUrl)}
onChange={(event) => updateSlide(slideIndex, { linkUrl: event.target.value })}
placeholder="/shop or https://..."
className="admin-input w-full"
/>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Focal point X</span>
<input
type="text"
value={focalX}
onChange={(event) => updateSlide(slideIndex, { focalPoint: { ...focalPoint, x: event.target.value } })}
placeholder="50%"
className="admin-input w-full"
/>
</label>
<label className="space-y-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Focal point Y</span>
<input
type="text"
value={focalY}
onChange={(event) => updateSlide(slideIndex, { focalPoint: { ...focalPoint, y: event.target.value } })}
placeholder="50%"
className="admin-input w-full"
/>
</label>
</div>
</article>
)
})}
</div>
{slides.length === 0 && (
<div className="rounded-2xl border-2 border-dashed border-white/5 py-10 text-center text-sm text-slate-600">
Add at least one slide before saving.
</div>
)}
<button
type="button"
onClick={addSlide}
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-bold text-slate-300 hover:bg-white/10"
>
<Plus className="h-4 w-4" />
Add Slide
</button>
</div>
)
}
@@ -0,0 +1,25 @@
const isRecord = (value: unknown): value is Record<string, unknown> => (
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
)
export const validateCategoryHeroCarouselPayload = (payload: unknown): string[] => {
if (!isRecord(payload) || !Array.isArray(payload.slides) || payload.slides.length === 0) {
return ['Add at least one slide.']
}
const issues = payload.slides.flatMap((slide, index) => {
if (!isRecord(slide) || typeof slide.imageUrl !== 'string' || slide.imageUrl.trim().length === 0) {
return [`Slide ${index + 1}: Desktop image is required.`]
}
return []
})
if (
payload.autoplay !== false
&& (typeof payload.interval !== 'number' || !Number.isFinite(payload.interval) || payload.interval < 1000)
) {
issues.push('Autoplay interval must be at least 1000 ms.')
}
return issues
}
+16 -1
View File
@@ -13,7 +13,8 @@ import {
Search,
Globe,
Layers,
FileJson
FileJson,
LayoutTemplate
} from 'lucide-react'
import CategoryBulkJsonModal from '../components/CategoryBulkJsonModal'
@@ -300,6 +301,13 @@ export default function CategoriesPage() {
{/* Actions */}
<td className="px-6 py-3.5 text-right">
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
<Link
to={`/categories/${cat.id}/cms`}
title="Category CMS Builder"
className="p-2 rounded-xl bg-night-800 border border-white/10 text-slate-400 hover:text-cyan-glow hover:border-cyan-glow/30 hover:bg-cyan-glow/10 transition-all cursor-pointer shadow-sm inline-flex"
>
<LayoutTemplate className="w-4 h-4" />
</Link>
<Link
to={`/categories/${cat.id}`}
title={t('common.edit', 'Edit Category')}
@@ -360,6 +368,13 @@ export default function CategoriesPage() {
{/* Actions on Card */}
<div className="flex gap-1.5 relative z-10" onClick={(e) => e.stopPropagation()}>
<Link
to={`/categories/${cat.id}/cms`}
title="Category CMS Builder"
className="p-2 rounded-xl bg-night-800 border border-white/10 text-slate-400 hover:text-cyan-glow hover:border-cyan-glow/30 hover:bg-cyan-glow/10 transition-all cursor-pointer inline-flex"
>
<LayoutTemplate className="w-4 h-4" />
</Link>
<Link
to={`/categories/${cat.id}`}
title={t('common.edit', 'Edit')}
+995
View File
@@ -0,0 +1,995 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import {
AlertCircle,
ArrowDown,
ArrowLeft,
ArrowUp,
CheckCircle2,
Edit3,
Eye,
EyeOff,
FileJson,
Grid3X3,
GripVertical,
Image as ImageIcon,
LayoutTemplate,
Loader2,
Lock,
Minus,
PanelTop,
Plus,
Save,
Shield,
Sparkles,
Trash2,
} from 'lucide-react'
import { API_BASE, type Category, type CategorySection } from '@homaroma/types'
import MediaLibraryModal from '../components/MediaLibraryModal'
import CategoryHeroCarouselBlockForm from '../components/category/CategoryHeroCarouselBlockForm'
import CategoryDividerBlockForm from '../components/category/CategoryDividerBlockForm'
import { validateCategoryHeroCarouselPayload } from '../components/category/categoryHeroCarouselValidation'
import { getAdminDisplayString } from '../utils/localization'
interface ApiValidationIssue {
path?: string
message?: string
}
const getApiErrorMessage = (data: unknown, fallback: string) => {
if (!data || typeof data !== 'object') return fallback
const response = data as { message?: unknown; issues?: unknown }
const message = typeof response.message === 'string' ? response.message : fallback
if (!Array.isArray(response.issues)) return message
const issueMessages = response.issues
.filter((issue): issue is ApiValidationIssue => Boolean(issue) && typeof issue === 'object')
.map((issue) => {
const detail = typeof issue.message === 'string' ? issue.message : ''
const path = typeof issue.path === 'string' ? issue.path : ''
return [path, detail].filter(Boolean).join(': ')
})
.filter(Boolean)
return issueMessages.length > 0 ? `${message} — ${issueMessages.join('; ')}` : message
}
// Exactly the blocks requested: Native blocks + Hero Promotional Slider + Divider
const CATEGORY_SECTION_TYPES = [
'CATEGORY_HEADER',
'CATALOG_GRID',
'USP_BAR',
'HERO_CAROUSEL',
'DIVIDER',
] as const
const CATEGORY_NATIVE_SECTION_TYPES = new Set([
'CATEGORY_HEADER',
'CATALOG_GRID',
'USP_BAR',
])
const CATEGORY_STRUCTURED_SECTION_TYPES = new Set([
...CATEGORY_NATIVE_SECTION_TYPES,
'HERO_CAROUSEL',
'DIVIDER',
])
const SECTION_LABELS: Record<string, string> = {
CATEGORY_HEADER: 'Category header',
CATALOG_GRID: 'Product catalog',
USP_BAR: 'USP benefits',
HERO_CAROUSEL: 'Hero carousel (Wide Promo Slider)',
DIVIDER: 'Divider',
}
const SECTION_HELP: Record<string, string> = {
CATEGORY_HEADER: 'Live category art-directed hero banner, title, description, and navigation pills.',
CATALOG_GRID: 'Product catalog grid, sorting toolbar, and facet filter sidebar.',
USP_BAR: 'Benefits and support USP blocks configured for this category.',
HERO_CAROUSEL: 'Category-specific wide promotional slider with responsive desktop and mobile artwork.',
DIVIDER: 'Configurable horizontal separator between category CMS blocks.',
}
const NATIVE_BLOCKS = [
{ type: 'CATEGORY_HEADER', label: 'Category header', icon: PanelTop },
{ type: 'CATALOG_GRID', label: 'Product catalog', icon: Grid3X3 },
{ type: 'USP_BAR', label: 'USP benefits', icon: Shield },
] as const
const createDefaultCategoryPayload = (type: string) => {
switch (type) {
case 'CATEGORY_HEADER':
return {
showBreadcrumbs: true,
showCategoryNav: true,
showHeading: true,
title: { th: '', en: '' },
subtitle: { th: '', en: '' },
layoutType: 'split_showcase',
}
case 'CATALOG_GRID':
return {
showFilters: true,
showToolbar: true,
itemsPerPage: 24,
}
case 'USP_BAR':
return {
showUsp: true,
}
case 'HERO_CAROUSEL':
return {
slides: [
{
imageUrl: '',
mobileImageUrl: '',
linkUrl: '',
alt: { th: '', en: '' },
title: { th: '', en: '' },
subtitle: { th: '', en: '' },
buttonText: { th: '', en: '' },
focalPoint: { x: '50%', y: '50%' },
},
],
autoplay: true,
interval: 4500,
aspectRatio: '21/7',
mobileAspectRatio: '16/9',
showArrows: true,
showDots: true,
}
case 'DIVIDER':
return { width: 'container', style: 'solid', thickness: 1, spacing: 'md', color: '#94a3b8' }
default:
return {}
}
}
export default function CategoryCmsPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [category, setCategory] = useState<Category | null>(null)
const [detailSections, setDetailSections] = useState<CategorySection[]>([])
const [loading, setLoading] = useState(true)
const [sectionsSaving, setSectionsSaving] = useState(false)
const [selectedSectionId, setSelectedSectionId] = useState<number | 'new'>('new')
const [sectionType, setSectionType] = useState<string>('HERO_CAROUSEL')
const [sectionPayload, setSectionPayload] = useState<Record<string, unknown>>(createDefaultCategoryPayload('HERO_CAROUSEL'))
const [sectionJson, setSectionJson] = useState<string>(JSON.stringify(createDefaultCategoryPayload('HERO_CAROUSEL'), null, 2))
const [sectionIsActive, setSectionIsActive] = useState(true)
const [sectionSaveError, setSectionSaveError] = useState<string | null>(null)
const [activeLang, setActiveLang] = useState('th')
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
const [isAddBlockModalOpen, setIsAddBlockModalOpen] = useState(false)
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']
const selectedSection = selectedSectionId === 'new'
? null
: detailSections.find((section) => section.id === selectedSectionId) || null
const nativeSectionCount = detailSections.filter((section) => CATEGORY_NATIVE_SECTION_TYPES.has(section.type)).length
const inactiveSectionCount = detailSections.filter((section) => !section.isActive).length
const resetDetailSectionEditor = (nextType = 'HERO_CAROUSEL') => {
const payload = createDefaultCategoryPayload(nextType)
setSelectedSectionId('new')
setSectionType(nextType)
setSectionPayload(payload)
setSectionJson(JSON.stringify(payload, null, 2))
setSectionIsActive(true)
setSectionSaveError(null)
}
const startNewSection = (nextType: string) => {
resetDetailSectionEditor(nextType)
}
const selectDetailSection = (section: CategorySection) => {
const payload = section.payload && typeof section.payload === 'object' && !Array.isArray(section.payload) ? section.payload : {}
setSelectedSectionId(section.id)
setSectionType(section.type)
setSectionPayload(payload)
setSectionJson(JSON.stringify(payload, null, 2))
setSectionIsActive(section.isActive)
setSectionSaveError(null)
}
const fetchDetailSections = async (catId: string | number) => {
const res = await fetch(`${API_BASE}/categories/${catId}/sections?includeInactive=true&lang=all`)
if (!res.ok) throw new Error('Failed to load category detail sections')
const data: CategorySection[] = await res.json()
setDetailSections(data)
if (data.length > 0) {
selectDetailSection(data[0])
} else {
resetDetailSectionEditor()
}
}
useEffect(() => {
const fetchData = async () => {
if (!id) return
setLoading(true)
setError(null)
try {
const categoryRes = await fetch(`${API_BASE}/categories/${id}?lang=all`)
if (!categoryRes.ok) throw new Error('Category not found')
const categoryData = await categoryRes.json()
setCategory(categoryData)
await fetchDetailSections(id)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
fetchData()
}, [id])
const syncNativePayload = (field: string, value: unknown) => {
setSectionPayload((prev) => {
const next = { ...prev, [field]: value }
setSectionJson(JSON.stringify(next, null, 2))
return next
})
}
const syncNativeLocalizedPayload = (field: string, value: string) => {
setSectionPayload((prev) => {
const current = prev[field]
const next = {
...prev,
[field]: {
...(current && typeof current === 'object' && !Array.isArray(current) ? current : {}),
[activeLang]: value,
},
}
setSectionJson(JSON.stringify(next, null, 2))
return next
})
}
const getSectionLocalizedValue = (field: string) => {
const value = sectionPayload[field]
if (typeof value === 'string') return value
if (value && typeof value === 'object' && !Array.isArray(value)) {
const localized = value as Record<string, string>
return localized[activeLang] || localized.th || localized.en || ''
}
return ''
}
const openMediaPicker = (onSelect: (url: string) => void) => {
setOnSelectMedia(() => onSelect)
setIsMediaModalOpen(true)
}
const handleSeedDetailSections = async () => {
if (!id) return
setSectionsSaving(true)
setError(null)
try {
const response = await fetch(`${API_BASE}/categories/${id}/sections/seed-default`, { method: 'POST' })
const data = await response.json().catch(() => null)
if (!response.ok) throw new Error(data?.message || 'Failed to seed category detail sections')
await fetchDetailSections(id)
setSuccess(true)
setTimeout(() => setSuccess(false), 2500)
} catch (err: any) {
setError(err.message)
} finally {
setSectionsSaving(false)
}
}
const handleSaveDetailSection = async () => {
if (!id) return
setSectionsSaving(true)
setError(null)
setSectionSaveError(null)
try {
const payload = CATEGORY_STRUCTURED_SECTION_TYPES.has(sectionType) ? sectionPayload : JSON.parse(sectionJson || '{}')
if (sectionType === 'HERO_CAROUSEL') {
const validationIssues = validateCategoryHeroCarouselPayload(payload)
if (validationIssues.length > 0) throw new Error(validationIssues.join(' '))
}
const isNewSection = selectedSectionId === 'new'
const response = await fetch(
isNewSection ? `${API_BASE}/categories/${id}/sections` : `${API_BASE}/categories/sections/${selectedSectionId}`,
{
method: isNewSection ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: sectionType, payload, isActive: sectionIsActive }),
}
)
const data = await response.json().catch(() => null)
if (!response.ok) throw new Error(getApiErrorMessage(data, 'Failed to save category detail section'))
await fetchDetailSections(id)
if (data?.section) selectDetailSection(data.section)
setSuccess(true)
setTimeout(() => setSuccess(false), 2500)
} catch (err: any) {
const message = err instanceof Error ? err.message : 'Failed to save category detail section'
setError(message)
setSectionSaveError(message)
} finally {
setSectionsSaving(false)
}
}
const handleDeleteDetailSection = async (section: CategorySection) => {
if (!id || section.isLocked) return
setSectionsSaving(true)
setError(null)
try {
const response = await fetch(`${API_BASE}/categories/sections/${section.id}`, { method: 'DELETE' })
const data = await response.json().catch(() => null)
if (!response.ok) throw new Error(data?.message || 'Failed to delete category detail section')
await fetchDetailSections(id)
} catch (err: any) {
setError(err.message)
} finally {
setSectionsSaving(false)
}
}
const handleToggleDetailSection = async (section: CategorySection) => {
if (!id || section.isLocked) return
setSelectedSectionId(section.id)
setSectionsSaving(true)
setError(null)
try {
const response = await fetch(`${API_BASE}/categories/sections/${section.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: section.type, payload: section.payload, isActive: !section.isActive }),
})
const data = await response.json().catch(() => null)
if (!response.ok) throw new Error(data?.message || 'Failed to update category detail section')
await fetchDetailSections(id)
} catch (err: any) {
setError(err.message)
} finally {
setSectionsSaving(false)
}
}
const handleMoveDetailSection = async (index: number, direction: -1 | 1) => {
if (!id) return
const targetIndex = index + direction
if (targetIndex < 0 || targetIndex >= detailSections.length) return
if (detailSections[index]?.isLocked || detailSections[targetIndex]?.isLocked) return
const reordered = [...detailSections]
const [moved] = reordered.splice(index, 1)
reordered.splice(targetIndex, 0, moved)
const sections = reordered.map((section, idx) => ({ id: section.id, order: idx + 1 }))
setDetailSections(reordered.map((section, idx) => ({ ...section, order: idx + 1 })))
setSectionsSaving(true)
setError(null)
try {
const response = await fetch(`${API_BASE}/categories/${id}/sections/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sections }),
})
const data = await response.json().catch(() => null)
if (!response.ok) throw new Error(data?.message || 'Failed to reorder category detail sections')
await fetchDetailSections(id)
} catch (err: any) {
setError(err.message)
} finally {
setSectionsSaving(false)
}
}
if (loading) {
return (
<div className="h-[60vh] flex flex-col items-center justify-center space-y-4">
<Loader2 className="w-10 h-10 text-cyan-glow animate-spin" />
<p className="text-slate-500 animate-pulse">Loading category CMS...</p>
</div>
)
}
if (error && !category) {
return (
<div className="admin-card text-center py-20 space-y-4">
<AlertCircle className="w-16 h-16 text-rose-500 mx-auto" />
<h2 className="text-2xl font-bold">Error</h2>
<p className="text-slate-500">{error}</p>
<Link to="/categories" className="btn-primary inline-flex items-center gap-2">
<ArrowLeft className="w-4 h-4" />
Back to Categories
</Link>
</div>
)
}
const categoryName = getAdminDisplayString(category?.name) || `Category ${id}`
return (
<div className="w-full space-y-6 animate-fade-in pb-12">
{/* Top Header Bar */}
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1">
<Link to="/categories" className="hover:text-cyan-glow transition-colors">Categories</Link>
<span>/</span>
<span className="text-white">Category CMS</span>
</div>
<h1 className="text-3xl font-bold tracking-tight">
Category CMS <span className="text-cyan-glow">{categoryName}</span>
</h1>
<p className="text-sm text-slate-500">Manage category detail sections without changing category data.</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => navigate(`/categories/${id}`)}
className="px-4 py-2.5 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 text-sm font-bold transition-all inline-flex items-center gap-2"
>
<Edit3 className="w-4 h-4" />
Edit Category
</button>
<button
type="button"
onClick={() => navigate('/categories')}
className="px-4 py-2.5 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 text-sm font-bold transition-all"
>
Back
</button>
</div>
</div>
{/* Notifications */}
{success && (
<div className="flex items-center gap-3 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-xl text-emerald-500 text-sm animate-fade-in">
<CheckCircle2 className="w-5 h-5 shrink-0" />
<p>Category CMS updated successfully.</p>
</div>
)}
{error && (
<div className="flex items-center gap-3 p-4 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-500 text-sm animate-fade-in">
<AlertCircle className="w-5 h-5 shrink-0" />
<p>{error}</p>
</div>
)}
{/* Top 4 Stats Cards */}
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Total Blocks</p>
<p className="mt-2 text-2xl font-bold text-white">{detailSections.length}</p>
</div>
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Native Blocks</p>
<p className="mt-2 text-2xl font-bold text-cyan-glow">{nativeSectionCount}</p>
</div>
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Hidden</p>
<p className="mt-2 text-2xl font-bold text-slate-300">{inactiveSectionCount}</p>
</div>
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Mode</p>
<p className="mt-2 text-sm font-bold text-white">{selectedSectionId === 'new' ? 'Create block' : 'Edit selected block'}</p>
</div>
</div>
{/* Main Page Builder Section */}
<section className="admin-card space-y-6">
{/* Section Header with Language Switcher and Add Block Button */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-2 text-cyan-glow">
<LayoutTemplate className="w-5 h-5" />
<div>
<h2 className="font-bold text-lg">Category Detail Page Builder</h2>
<p className="text-xs text-slate-500">Start with native blocks. Use content blocks only when this category needs extra storytelling.</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex bg-white/5 rounded-lg p-1">
{languages.map((lang) => (
<button
key={lang}
type="button"
onClick={() => setActiveLang(lang)}
className={`px-3 py-1.5 rounded-md text-xs font-bold uppercase tracking-widest transition-all ${activeLang === lang ? 'bg-cyan-glow text-night-950 shadow-lg' : 'text-slate-400 hover:text-white'}`}
>
{lang}
</button>
))}
</div>
{detailSections.length === 0 && (
<button
type="button"
onClick={handleSeedDetailSections}
disabled={sectionsSaving}
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-xs font-bold uppercase tracking-widest hover:bg-cyan-glow/20 transition-all disabled:opacity-50"
>
{sectionsSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
Seed Default
</button>
)}
<button
type="button"
onClick={() => setIsAddBlockModalOpen(true)}
className="flex items-center gap-2 px-3 py-2 rounded-xl border border-white/10 bg-white/5 text-xs font-bold uppercase tracking-widest text-slate-300 hover:bg-white/10 transition-all"
>
<Plus className="w-3.5 h-3.5" />
Add Block
</button>
</div>
</div>
{/* Native Category Blocks Quick Bar */}
<div className="rounded-2xl border border-cyan-glow/15 bg-cyan-glow/[0.03] p-4">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<p className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-cyan-glow">
<Sparkles className="h-4 w-4" />
Native Category Blocks
</p>
<p className="mt-1 text-xs text-slate-500">These blocks render live category data and are the recommended starting point.</p>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{NATIVE_BLOCKS.map(({ type, label, icon: Icon }) => (
<button
key={type}
type="button"
onClick={() => startNewSection(type)}
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-left text-xs font-bold transition-all ${
selectedSectionId === 'new' && sectionType === type
? 'border-cyan-glow/50 bg-cyan-glow/15 text-cyan-glow'
: 'border-white/10 bg-night-950/60 text-slate-300 hover:border-cyan-glow/30 hover:text-cyan-glow'
}`}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{label}</span>
</button>
))}
</div>
</div>
</div>
{/* 2-Column Section Order & Block Form */}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(300px,0.85fr)_minmax(0,1.15fr)]">
{/* Left Column: Section Order */}
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-bold text-white">Section Order</p>
<p className="text-xs text-slate-500">Move blocks to control the category detail page layout.</p>
</div>
<button
type="button"
onClick={() => resetDetailSectionEditor()}
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-bold uppercase tracking-widest text-slate-300 transition-all hover:bg-white/10"
>
<Plus className="h-3.5 w-3.5" />
Add
</button>
</div>
{detailSections.length === 0 && (
<div className="rounded-2xl border-2 border-dashed border-white/5 p-8 text-center">
<LayoutTemplate className="mx-auto h-10 w-10 text-slate-700" />
<p className="mt-3 text-sm font-semibold text-slate-500">No category CMS blocks yet.</p>
<p className="mt-1 text-xs text-slate-600">Seed the standard layout or choose a native block above.</p>
</div>
)}
{detailSections.map((section, index) => (
<div
key={section.id}
className={`rounded-2xl border p-4 transition-all ${selectedSectionId === section.id ? 'border-cyan-glow/40 bg-cyan-glow/5 shadow-[0_0_20px_rgba(102,252,241,0.06)]' : 'border-white/5 bg-white/[0.02] hover:border-white/10'}`}
>
<button type="button" onClick={() => selectDetailSection(section)} className="w-full text-left">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-night-950 text-slate-500">
<GripVertical className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-bold text-white">{SECTION_LABELS[section.type] || section.type}</p>
{section.isLocked && <Lock className="h-3.5 w-3.5 shrink-0 text-slate-500" />}
</div>
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-slate-500">
{SECTION_HELP[section.type] || 'Custom category detail section.'}
</p>
<p className="mt-2 text-[10px] uppercase tracking-widest text-slate-600">Order {section.order}</p>
</div>
<span className={`rounded-lg px-2 py-1 text-[10px] font-bold uppercase tracking-widest ${section.isActive ? 'bg-emerald-500/10 text-emerald-400' : 'bg-white/5 text-slate-500'}`}>
{section.isActive ? 'Active' : 'Hidden'}
</span>
</div>
</button>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => handleMoveDetailSection(index, -1)}
disabled={index === 0 || section.isLocked || detailSections[index - 1]?.isLocked || sectionsSaving}
className="p-2 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/10 disabled:opacity-30"
title={section.isLocked ? 'Locked section' : detailSections[index - 1]?.isLocked ? 'Cannot move above locked section' : 'Move up'}
>
<ArrowUp className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleMoveDetailSection(index, 1)}
disabled={index === detailSections.length - 1 || section.isLocked || detailSections[index + 1]?.isLocked || sectionsSaving}
className="p-2 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/10 disabled:opacity-30"
title={section.isLocked ? 'Locked section' : detailSections[index + 1]?.isLocked ? 'Cannot move below locked section' : 'Move down'}
>
<ArrowDown className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleToggleDetailSection(section)}
disabled={section.isLocked || sectionsSaving}
className="p-2 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/10 disabled:opacity-30"
title={section.isLocked ? 'Locked section' : section.isActive ? 'Hide section' : 'Show section'}
>
{section.isActive ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => handleDeleteDetailSection(section)}
disabled={section.isLocked || sectionsSaving}
className="ml-auto p-2 rounded-xl text-slate-600 hover:text-rose-500 hover:bg-rose-500/10 disabled:opacity-30"
title={section.isLocked ? 'Locked section' : 'Delete section'}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
{/* Right Column: Editing Block */}
<div className="space-y-4">
<div className="rounded-2xl border border-white/5 bg-white/[0.02] p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">
{selectedSectionId === 'new' ? 'New Block' : 'Editing Block'}
</p>
<h3 className="mt-2 text-2xl font-bold text-white">{SECTION_LABELS[sectionType] || sectionType}</h3>
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-slate-500">
{SECTION_HELP[sectionType] || 'Configure the section payload for this category detail page.'}
</p>
</div>
{selectedSection?.isLocked && (
<div className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-night-950 px-3 py-2 text-xs font-bold uppercase tracking-widest text-slate-400">
<Lock className="h-3.5 w-3.5" />
Locked
</div>
)}
</div>
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-[1fr_auto]">
<div className="space-y-2">
<label className="ml-1 text-[10px] font-bold uppercase tracking-widest text-slate-500">Block Type</label>
<select
value={sectionType}
onChange={(e) => {
const nextType = e.target.value
setSectionType(nextType)
const nextPayload = createDefaultCategoryPayload(nextType)
setSectionPayload(nextPayload)
setSectionJson(JSON.stringify(nextPayload, null, 2))
}}
disabled={selectedSectionId !== 'new' && detailSections.find((section) => section.id === selectedSectionId)?.isLocked}
className="w-full rounded-xl border border-white/10 bg-night-950 px-4 py-3 text-sm text-white outline-none transition-all focus:border-cyan-glow/40"
>
{CATEGORY_SECTION_TYPES.map((type) => (
<option key={type} value={type}>{SECTION_LABELS[type] || type}</option>
))}
</select>
</div>
<label className="mt-0 flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3 md:mt-6">
<input
type="checkbox"
checked={sectionIsActive}
onChange={(event) => setSectionIsActive(event.target.checked)}
className="accent-cyan-glow"
/>
<span className="whitespace-nowrap text-xs font-bold text-slate-300">Visible on category page</span>
</label>
</div>
</div>
{/* Block Form Container */}
<div className="rounded-2xl border border-white/5 bg-white/[0.02] p-5 space-y-5">
{CATEGORY_NATIVE_SECTION_TYPES.has(sectionType) ? (
<div className="space-y-5">
<div className="rounded-2xl border border-cyan-glow/10 bg-cyan-glow/[0.03] p-4">
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">Native Category Settings</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">These controls affect headers, navigation, and presentation. Live products come from this category.</p>
</div>
{sectionType === 'CATEGORY_HEADER' && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Title ({activeLang.toUpperCase()})</label>
<input
type="text"
value={getSectionLocalizedValue('title')}
onChange={(e) => syncNativeLocalizedPayload('title', e.target.value)}
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all"
placeholder="Leave blank to use category name"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Subtitle ({activeLang.toUpperCase()})</label>
<input
type="text"
value={getSectionLocalizedValue('subtitle')}
onChange={(e) => syncNativeLocalizedPayload('subtitle', e.target.value)}
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all"
placeholder="Optional tagline"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3">
<input
type="checkbox"
checked={sectionPayload.showBreadcrumbs !== false}
onChange={(event) => syncNativePayload('showBreadcrumbs', event.target.checked)}
className="accent-cyan-glow"
/>
<span className="text-xs font-bold text-slate-300">Show breadcrumbs</span>
</label>
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3">
<input
type="checkbox"
checked={sectionPayload.showCategoryNav !== false}
onChange={(event) => syncNativePayload('showCategoryNav', event.target.checked)}
className="accent-cyan-glow"
/>
<span className="text-xs font-bold text-slate-300">Show category pills</span>
</label>
</div>
</div>
)}
{sectionType === 'CATALOG_GRID' && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3">
<input
type="checkbox"
checked={sectionPayload.showFilters !== false}
onChange={(event) => syncNativePayload('showFilters', event.target.checked)}
className="accent-cyan-glow"
/>
<span className="text-xs font-bold text-slate-300">Show filter sidebar</span>
</label>
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3">
<input
type="checkbox"
checked={sectionPayload.showToolbar !== false}
onChange={(event) => syncNativePayload('showToolbar', event.target.checked)}
className="accent-cyan-glow"
/>
<span className="text-xs font-bold text-slate-300">Show sorting toolbar</span>
</label>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Items Per Page</label>
<input
type="number"
min={4}
max={100}
value={typeof sectionPayload.itemsPerPage === 'number' ? sectionPayload.itemsPerPage : 24}
onChange={(e) => syncNativePayload('itemsPerPage', parseInt(e.target.value, 10) || 24)}
className="w-40 px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all"
/>
</div>
</div>
)}
{sectionType === 'USP_BAR' && (
<div className="space-y-4">
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3">
<input
type="checkbox"
checked={sectionPayload.showUsp !== false}
onChange={(event) => syncNativePayload('showUsp', event.target.checked)}
className="accent-cyan-glow"
/>
<span className="text-xs font-bold text-slate-300">Show USP benefits bar</span>
</label>
<p className="text-xs text-slate-500">Renders the 3 USP benefit columns configured for this category.</p>
</div>
)}
</div>
) : (
<div className="space-y-5">
<div className="rounded-2xl border border-cyan-glow/10 bg-cyan-glow/[0.03] p-4">
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">
Category Content Block
</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">These forms follow the Page Builder block design, but stay scoped to this category.</p>
</div>
{sectionType === 'HERO_CAROUSEL' && (
<CategoryHeroCarouselBlockForm
payload={sectionPayload}
activeLanguage={activeLang}
onChange={(nextPayload) => {
setSectionPayload(nextPayload)
setSectionJson(JSON.stringify(nextPayload, null, 2))
}}
onBrowseMedia={openMediaPicker}
/>
)}
{sectionType === 'DIVIDER' && (
<CategoryDividerBlockForm
payload={sectionPayload}
onChange={(nextPayload) => {
setSectionPayload(nextPayload)
setSectionJson(JSON.stringify(nextPayload, null, 2))
}}
/>
)}
</div>
)}
{/* Bottom Save Bar */}
<div className="flex flex-col-reverse gap-3 border-t border-white/5 pt-5 sm:flex-row sm:justify-end">
{sectionSaveError && (
<div className="flex items-start gap-2 rounded-xl border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-400 sm:mr-auto sm:max-w-xl" role="alert">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>{sectionSaveError}</p>
</div>
)}
<button
type="button"
onClick={() => setSectionJson(JSON.stringify(sectionPayload, null, 2))}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-bold text-slate-300 transition-all hover:bg-white/10"
>
<FileJson className="h-4 w-4" />
Sync JSON
</button>
<button
type="button"
onClick={handleSaveDetailSection}
disabled={sectionsSaving}
className="btn-primary inline-flex items-center justify-center gap-2 px-6 py-2.5 text-sm font-bold disabled:opacity-50"
>
{sectionsSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4" />
Save Section
</>
)}
</button>
</div>
</div>
</div>
</div>
</section>
{/* Media Library Modal */}
<MediaLibraryModal
isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)}
onSelect={(url) => {
if (onSelectMedia) onSelectMedia(url)
setIsMediaModalOpen(false)
}}
/>
{/* Add Block Modal (Focused specifically on Hero Carousel and Divider) */}
{isAddBlockModalOpen && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="admin-card max-w-xl w-full p-6 border border-white/10 bg-night-950 rounded-2xl space-y-6">
<div className="flex items-center justify-between border-b border-white/10 pb-4">
<div>
<h3 className="text-lg font-bold text-white">Add Page Block</h3>
<p className="text-xs text-slate-400">Choose a section type to insert into this category detail page.</p>
</div>
<button
onClick={() => setIsAddBlockModalOpen(false)}
className="text-slate-400 hover:text-white"
>
✕
</button>
</div>
<div className="space-y-3">
<p className="text-xs font-bold uppercase tracking-widest text-cyan-glow">Content Blocks</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => {
startNewSection('HERO_CAROUSEL')
setIsAddBlockModalOpen(false)
}}
className="p-4 rounded-xl border border-white/5 bg-white/[0.03] hover:border-cyan-glow/50 hover:bg-cyan-glow/5 transition-all text-left flex items-start gap-3 group"
>
<div className="p-2.5 rounded-lg bg-cyan-glow/10 text-cyan-glow group-hover:bg-cyan-glow group-hover:text-black transition-colors shrink-0">
<ImageIcon className="w-5 h-5" />
</div>
<div>
<span className="text-sm font-bold text-white group-hover:text-cyan-glow transition-colors block">
Hero Carousel
</span>
<p className="text-xs text-slate-400 mt-1">
Wide promotional slider with responsive artwork.
</p>
</div>
</button>
<button
type="button"
onClick={() => {
startNewSection('DIVIDER')
setIsAddBlockModalOpen(false)
}}
className="p-4 rounded-xl border border-white/5 bg-white/[0.03] hover:border-cyan-glow/50 hover:bg-cyan-glow/5 transition-all text-left flex items-start gap-3 group"
>
<div className="p-2.5 rounded-lg bg-cyan-glow/10 text-cyan-glow group-hover:bg-cyan-glow group-hover:text-black transition-colors shrink-0">
<Minus className="w-5 h-5" />
</div>
<div>
<span className="text-sm font-bold text-white group-hover:text-cyan-glow transition-colors block">
Divider
</span>
<p className="text-xs text-slate-400 mt-1">
Visual line separator between category sections.
</p>
</div>
</button>
</div>
<p className="text-xs font-bold uppercase tracking-widest text-slate-400 pt-2">Native Category Blocks</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{NATIVE_BLOCKS.map(({ type, label, icon: Icon }) => (
<button
key={type}
type="button"
onClick={() => {
startNewSection(type)
setIsAddBlockModalOpen(false)
}}
className="p-3 rounded-xl border border-white/5 bg-white/[0.02] hover:border-cyan-glow/40 transition-all text-left flex items-center gap-2 group"
>
<Icon className="w-4 h-4 text-cyan-glow shrink-0" />
<span className="text-xs font-bold text-slate-300 group-hover:text-white truncate">
{label}
</span>
</button>
))}
</div>
</div>
</div>
</div>
)}
</div>
)
}
+10
View File
@@ -463,6 +463,16 @@ export default function CategoryEditPage() {
</button>
)}
{!isNew && (
<Link
to={`/categories/${id}/cms`}
className="px-4 py-2.5 rounded-xl bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/30 text-sm font-semibold transition-all cursor-pointer flex items-center gap-2"
>
<LayoutTemplate className="w-4 h-4" />
<span>CMS Blocks</span>
</Link>
)}
<Link
to="/categories"
className="px-5 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white text-sm font-semibold transition-all cursor-pointer"