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"
|
||||
|
||||
Reference in new issue
Block a user