1425 lines
78 KiB
TypeScript
1425 lines
78 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
|
import {
|
|
AlertCircle,
|
|
ArrowDown,
|
|
ArrowLeft,
|
|
ArrowUp,
|
|
CheckCircle2,
|
|
Download,
|
|
Edit3,
|
|
Eye,
|
|
EyeOff,
|
|
FileJson,
|
|
GripVertical,
|
|
LayoutTemplate,
|
|
Link as LinkIcon,
|
|
ListChecks,
|
|
Loader2,
|
|
Lock,
|
|
MessageSquare,
|
|
PanelTop,
|
|
Plus,
|
|
Rows3,
|
|
Save,
|
|
ShoppingBag,
|
|
Sparkles,
|
|
Star,
|
|
Trash2,
|
|
Upload,
|
|
UploadCloud,
|
|
Image as ImageIcon,
|
|
} from 'lucide-react'
|
|
import { API_BASE, type Category, type Product, type ProductDetailSection } from '@homaroma/types'
|
|
import MediaLibraryModal from '../components/MediaLibraryModal'
|
|
import FocalPointPicker from '../components/page-builder/FocalPointPicker'
|
|
import ProductPickerModal from '../components/page-builder/ProductPickerModal'
|
|
import ProductHeroCarouselBlockForm from '../components/products/ProductHeroCarouselBlockForm'
|
|
import ProductDividerBlockForm from '../components/products/ProductDividerBlockForm'
|
|
import { validateProductHeroCarouselPayload } from '../components/products/productHeroCarouselValidation'
|
|
import { getAdminDisplayString } from '../utils/localization'
|
|
import BlockTranslationModal, { isTranslatableBlock } from '../components/page-builder/BlockTranslationModal'
|
|
|
|
interface Brand {
|
|
id: number
|
|
name: string
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
const PRODUCT_DETAIL_SECTION_TYPES = [
|
|
'PRODUCT_MAIN',
|
|
'HERO',
|
|
'HERO_V2',
|
|
'HERO_CAROUSEL',
|
|
'DIVIDER',
|
|
'PRODUCT_DETAILS',
|
|
'PRODUCT_SPECS',
|
|
'PRODUCT_REVIEWS',
|
|
'RELATED_PRODUCTS',
|
|
'RICH_TEXT',
|
|
'TEXT',
|
|
'BANNER',
|
|
'PRODUCT_CAROUSEL',
|
|
'EDITORIAL_TILE',
|
|
'QUOTE_CALLOUT',
|
|
'CUSTOM',
|
|
] as const
|
|
|
|
const PRODUCT_NATIVE_SECTION_TYPES = new Set([
|
|
'PRODUCT_MAIN',
|
|
'PRODUCT_DETAILS',
|
|
'PRODUCT_SPECS',
|
|
'PRODUCT_REVIEWS',
|
|
'RELATED_PRODUCTS',
|
|
])
|
|
|
|
const PRODUCT_STRUCTURED_SECTION_TYPES = new Set([
|
|
...PRODUCT_NATIVE_SECTION_TYPES,
|
|
'HERO_CAROUSEL',
|
|
'DIVIDER',
|
|
])
|
|
|
|
const SECTION_LABELS: Record<string, string> = {
|
|
PRODUCT_MAIN: 'Product main',
|
|
HERO: 'Hero',
|
|
HERO_V2: 'Hero V2',
|
|
HERO_CAROUSEL: 'Hero carousel (Wide Promo Slider)',
|
|
DIVIDER: 'Divider',
|
|
PRODUCT_DETAILS: 'Product details',
|
|
PRODUCT_SPECS: 'Product specs',
|
|
PRODUCT_REVIEWS: 'Product reviews',
|
|
RELATED_PRODUCTS: 'Related products',
|
|
RICH_TEXT: 'Rich text',
|
|
TEXT: 'Text',
|
|
BANNER: 'Banner',
|
|
PRODUCT_CAROUSEL: 'Product carousel',
|
|
EDITORIAL_TILE: 'Editorial tile',
|
|
QUOTE_CALLOUT: 'Quote callout',
|
|
CUSTOM: 'Custom block',
|
|
}
|
|
|
|
const SECTION_HELP: Record<string, string> = {
|
|
PRODUCT_MAIN: 'Live gallery, purchase panel, price, variants, and add-to-cart controls.',
|
|
HERO: 'Full-width Page Builder hero with image, headline, copy, and CTA.',
|
|
HERO_V2: 'Advanced Page Builder hero with alignment, ornament, and primary/secondary CTAs.',
|
|
HERO_CAROUSEL: 'Product-specific wide promotional slider with responsive desktop and mobile artwork.',
|
|
DIVIDER: 'Configurable horizontal separator between product CMS blocks.',
|
|
PRODUCT_DETAILS: 'Long product detail content from the product record.',
|
|
PRODUCT_SPECS: 'Product attributes and specification table.',
|
|
PRODUCT_REVIEWS: 'Published customer reviews for this product.',
|
|
RELATED_PRODUCTS: 'Products from the same category, excluding this product.',
|
|
RICH_TEXT: 'Editorial content block with rich HTML or markdown content.',
|
|
TEXT: 'Simple text block for short product-specific copy.',
|
|
BANNER: 'Image banner with optional link and localized title.',
|
|
PRODUCT_CAROUSEL: 'Curated product carousel inside this product detail page.',
|
|
EDITORIAL_TILE: 'Image and copy block for storytelling.',
|
|
QUOTE_CALLOUT: 'Short highlighted quote or testimonial-style block.',
|
|
CUSTOM: 'Advanced custom renderer payload.',
|
|
}
|
|
|
|
const NATIVE_BLOCKS = [
|
|
{ type: 'PRODUCT_MAIN', icon: ShoppingBag },
|
|
{ type: 'PRODUCT_DETAILS', icon: PanelTop },
|
|
{ type: 'PRODUCT_SPECS', icon: ListChecks },
|
|
{ type: 'PRODUCT_REVIEWS', icon: MessageSquare },
|
|
{ type: 'RELATED_PRODUCTS', icon: Rows3 },
|
|
] as const
|
|
|
|
const createDefaultProductDetailPayload = (type: string) => {
|
|
switch (type) {
|
|
case 'PRODUCT_DETAILS':
|
|
return { title: { th: 'รายละเอียดสินค้า', en: 'Product Details' }, showHeading: true }
|
|
case 'PRODUCT_SPECS':
|
|
return { title: { th: 'ข้อมูลสินค้า', en: 'Specifications' }, showHeading: true }
|
|
case 'PRODUCT_REVIEWS':
|
|
return { title: { th: 'รีวิว', en: 'Reviews' }, showHeading: true }
|
|
case 'RELATED_PRODUCTS':
|
|
return { title: { th: 'สินค้าที่เกี่ยวข้อง', en: 'Related Products' }, showHeading: true, showViewAllLink: true }
|
|
case 'HERO':
|
|
return {
|
|
tagline: { th: '', en: '' },
|
|
title: { th: '', en: '' },
|
|
description: { th: '', en: '' },
|
|
imageUrl: '',
|
|
buttonText: { th: '', en: '' },
|
|
buttonLink: '/shop',
|
|
}
|
|
case 'HERO_V2':
|
|
return {
|
|
tagline: { th: '', en: '' },
|
|
title: { th: '', en: '' },
|
|
description: { th: '', en: '' },
|
|
imageUrl: '',
|
|
contentAlignment: 'left',
|
|
showOrnament: true,
|
|
primaryButtonText: { th: '', en: '' },
|
|
primaryButtonLink: '/shop',
|
|
secondaryButtonText: { th: '', en: '' },
|
|
secondaryButtonLink: '',
|
|
}
|
|
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' }
|
|
case 'RICH_TEXT':
|
|
case 'TEXT':
|
|
return { title: { th: '', en: '' }, content: { th: '', en: '' }, textAlign: 'left' }
|
|
case 'BANNER':
|
|
return { imageUrl: '', linkUrl: '', title: { th: '', en: '' } }
|
|
case 'PRODUCT_CAROUSEL':
|
|
return { title: { th: '', en: '' }, type: 'custom', productIds: [], limit: 4, showPrice: true, showAddToCart: true }
|
|
case 'EDITORIAL_TILE':
|
|
return { title: { th: '', en: '' }, description: { th: '', en: '' }, imageUrl: '', imagePosition: 'left' }
|
|
case 'QUOTE_CALLOUT':
|
|
return { quote: { th: '', en: '' } }
|
|
case 'CUSTOM':
|
|
return { component: 'ProductDetailCustomBlock', props: {} }
|
|
case 'PRODUCT_MAIN':
|
|
return {
|
|
layoutMode: 'standard',
|
|
galleryBehavior: 'carousel',
|
|
purchaseCardSticky: true,
|
|
showStickyPurchaseBarAfterMain: true,
|
|
showBreadcrumbs: true,
|
|
showBrandMeta: true,
|
|
}
|
|
default:
|
|
return {}
|
|
}
|
|
}
|
|
|
|
export default function ProductCmsPage() {
|
|
const { id } = useParams<{ id: string }>()
|
|
const navigate = useNavigate()
|
|
const [product, setProduct] = useState<Product | null>(null)
|
|
const [detailSections, setDetailSections] = useState<ProductDetailSection[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [sectionsSaving, setSectionsSaving] = useState(false)
|
|
const [selectedSectionId, setSelectedSectionId] = useState<number | 'new'>('new')
|
|
const [sectionType, setSectionType] = useState<string>('PRODUCT_DETAILS')
|
|
const [sectionPayload, setSectionPayload] = useState<Record<string, unknown>>(createDefaultProductDetailPayload('PRODUCT_DETAILS'))
|
|
const [sectionJson, setSectionJson] = useState<string>(JSON.stringify(createDefaultProductDetailPayload('PRODUCT_DETAILS'), 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 [categories, setCategories] = useState<Category[]>([])
|
|
const [brands, setBrands] = useState<Brand[]>([])
|
|
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
|
|
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
|
|
const [isProductPickerOpen, setIsProductPickerOpen] = useState(false)
|
|
const [isTranslationModalOpen, setIsTranslationModalOpen] = useState(false)
|
|
const [translationModalTab, setTranslationModalTab] = useState<'export' | 'import'>('export')
|
|
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) => PRODUCT_NATIVE_SECTION_TYPES.has(section.type)).length
|
|
const inactiveSectionCount = detailSections.filter((section) => !section.isActive).length
|
|
|
|
const resetDetailSectionEditor = (nextType = 'PRODUCT_DETAILS') => {
|
|
const payload = createDefaultProductDetailPayload(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: ProductDetailSection) => {
|
|
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 (productId: string | number) => {
|
|
const res = await fetch(`${API_BASE}/products/${productId}/detail-sections?includeInactive=true&lang=all`)
|
|
if (!res.ok) throw new Error('Failed to load product detail sections')
|
|
const data: ProductDetailSection[] = 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 productRes = await fetch(`${API_BASE}/products/${id}?lang=all`)
|
|
if (!productRes.ok) throw new Error('Product not found')
|
|
const [productData, categoriesRes, brandsRes] = await Promise.all([
|
|
productRes.json(),
|
|
fetch(`${API_BASE}/categories?lang=all`),
|
|
fetch(`${API_BASE}/products/brands`),
|
|
])
|
|
setProduct(productData)
|
|
if (categoriesRes.ok) setCategories(await categoriesRes.json())
|
|
if (brandsRes.ok) setBrands(await brandsRes.json())
|
|
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 updatePayloadValue = (field: string, value: unknown) => {
|
|
setSectionPayload((prev) => {
|
|
const next = { ...prev, [field]: value }
|
|
setSectionJson(JSON.stringify(next, null, 2))
|
|
return next
|
|
})
|
|
}
|
|
|
|
const updateLocalizedValue = (field: string, value: string) => {
|
|
syncNativeLocalizedPayload(field, value)
|
|
}
|
|
|
|
const openMediaPicker = (onSelect: (url: string) => void) => {
|
|
setOnSelectMedia(() => onSelect)
|
|
setIsMediaModalOpen(true)
|
|
}
|
|
|
|
const openImagePicker = (field: 'imageUrl') => {
|
|
openMediaPicker((url) => updatePayloadValue(field, url))
|
|
}
|
|
|
|
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 handleSeedDetailSections = async () => {
|
|
if (!id) return
|
|
setSectionsSaving(true)
|
|
setError(null)
|
|
try {
|
|
const response = await fetch(`${API_BASE}/products/${id}/detail-sections/seed-default`, { method: 'POST' })
|
|
const data = await response.json().catch(() => null)
|
|
if (!response.ok) throw new Error(data?.message || 'Failed to seed product 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 = PRODUCT_STRUCTURED_SECTION_TYPES.has(sectionType) ? sectionPayload : JSON.parse(sectionJson || '{}')
|
|
if (sectionType === 'HERO_CAROUSEL') {
|
|
const validationIssues = validateProductHeroCarouselPayload(payload)
|
|
if (validationIssues.length > 0) throw new Error(validationIssues.join(' '))
|
|
}
|
|
const isNewSection = selectedSectionId === 'new'
|
|
const response = await fetch(
|
|
isNewSection ? `${API_BASE}/products/${id}/detail-sections` : `${API_BASE}/products/detail-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 product 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 product detail section'
|
|
setError(message)
|
|
setSectionSaveError(message)
|
|
} finally {
|
|
setSectionsSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleDeleteDetailSection = async (section: ProductDetailSection) => {
|
|
if (!id || section.isLocked) return
|
|
setSectionsSaving(true)
|
|
setError(null)
|
|
try {
|
|
const response = await fetch(`${API_BASE}/products/detail-sections/${section.id}`, { method: 'DELETE' })
|
|
const data = await response.json().catch(() => null)
|
|
if (!response.ok) throw new Error(data?.message || 'Failed to delete product detail section')
|
|
await fetchDetailSections(id)
|
|
} catch (err: any) {
|
|
setError(err.message)
|
|
} finally {
|
|
setSectionsSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleToggleDetailSection = async (section: ProductDetailSection) => {
|
|
if (!id || section.isLocked) return
|
|
setSelectedSectionId(section.id)
|
|
setSectionsSaving(true)
|
|
setError(null)
|
|
try {
|
|
const response = await fetch(`${API_BASE}/products/detail-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 product 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}/products/${id}/detail-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 product 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 product CMS...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error && !product) {
|
|
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="/products" className="btn-primary inline-flex items-center gap-2">
|
|
<ArrowLeft className="w-4 h-4" />
|
|
Back to Products
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const productName = typeof product?.name === 'string' ? product.name : product?.name?.th || product?.name?.en || product?.brand?.name || `Product ${id}`
|
|
|
|
return (
|
|
<div className="w-full space-y-6 animate-fade-in">
|
|
<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="/products" className="hover:text-cyan-glow transition-colors">Products</Link>
|
|
<span>/</span>
|
|
<span className="text-white">Product CMS</span>
|
|
</div>
|
|
<h1 className="text-3xl font-bold tracking-tight">
|
|
Product CMS <span className="text-cyan-glow">{productName}</span>
|
|
</h1>
|
|
<p className="text-sm text-slate-500">Manage product detail sections without changing product data.</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate(`/products/${id}/edit`)}
|
|
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 Product
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate('/products')}
|
|
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>
|
|
|
|
{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>Product 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>
|
|
)}
|
|
|
|
<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>
|
|
|
|
<section className="admin-card space-y-6">
|
|
<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">Product Detail Page Builder</h2>
|
|
<p className="text-xs text-slate-500">Start with native blocks. Use content blocks only when this product 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={() => resetDetailSectionEditor()}
|
|
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>
|
|
|
|
<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 Product Blocks
|
|
</p>
|
|
<p className="mt-1 text-xs text-slate-500">These blocks render live product data and are the recommended starting point.</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-5">
|
|
{NATIVE_BLOCKS.map(({ type, 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">{SECTION_LABELS[type]}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(300px,0.85fr)_minmax(0,1.15fr)]">
|
|
<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 product 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 product 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 product 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>
|
|
|
|
<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 product detail page.'}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{isTranslatableBlock(sectionType) && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setTranslationModalTab('export')
|
|
setIsTranslationModalOpen(true)
|
|
}}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/20 text-xs font-bold transition-all shadow-sm cursor-pointer"
|
|
title="ส่งออกข้อความทุกภาษาของบล็อกนี้เพื่อนำไปแปลด้วย LLM"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
<span>Export ภาษา</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setTranslationModalTab('import')
|
|
setIsTranslationModalOpen(true)
|
|
}}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-pop-pink/10 hover:bg-pop-pink/20 text-pop-pink border border-pop-pink/20 text-xs font-bold transition-all shadow-sm cursor-pointer"
|
|
title="วาง JSON ผลลัพธ์จาก LLM เพื่ออัปเดตทุกภาษาพร้อมกัน"
|
|
>
|
|
<Upload className="w-3.5 h-3.5" />
|
|
<span>Import ภาษา</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
{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>
|
|
|
|
<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 = createDefaultProductDetailPayload(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"
|
|
>
|
|
{PRODUCT_DETAIL_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 product page</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-white/5 bg-white/[0.02] p-5 space-y-5">
|
|
{PRODUCT_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 Settings</p>
|
|
<p className="mt-1 text-xs leading-relaxed text-slate-500">These controls affect headings and presentation. Product data still comes from the product record.</p>
|
|
</div>
|
|
<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 default"
|
|
/>
|
|
</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"
|
|
/>
|
|
</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.showHeading !== false}
|
|
onChange={(event) => syncNativePayload('showHeading', event.target.checked)}
|
|
className="accent-cyan-glow"
|
|
/>
|
|
<span className="text-xs font-bold text-slate-300">Show heading</span>
|
|
</label>
|
|
{sectionType === 'RELATED_PRODUCTS' && (
|
|
<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.showViewAllLink !== false}
|
|
onChange={(event) => syncNativePayload('showViewAllLink', event.target.checked)}
|
|
className="accent-cyan-glow"
|
|
/>
|
|
<span className="text-xs font-bold text-slate-300">Show view all link</span>
|
|
</label>
|
|
)}
|
|
{sectionType === 'PRODUCT_MAIN' && (
|
|
<div className="md:col-span-2 space-y-5 rounded-2xl border border-white/5 bg-night-950 p-4">
|
|
<div className="flex items-start gap-3">
|
|
<ShoppingBag className="mt-0.5 h-4 w-4 text-cyan-glow" />
|
|
<div>
|
|
<p className="text-xs font-bold uppercase tracking-widest text-slate-300">Product Main Behavior</p>
|
|
<p className="mt-1 text-xs leading-relaxed text-slate-500">Product data is managed on the Edit Product page. These controls only change the purchase experience.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<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">Layout Mode</label>
|
|
<select
|
|
value={typeof sectionPayload.layoutMode === 'string' ? sectionPayload.layoutMode : 'standard'}
|
|
onChange={(event) => syncNativePayload('layoutMode', event.target.value)}
|
|
className="admin-input w-full bg-slate-900 border-white/10"
|
|
>
|
|
<option value="standard">Standard</option>
|
|
<option value="sticky_gallery">Sticky Gallery</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Gallery Behavior</label>
|
|
<select
|
|
value={typeof sectionPayload.galleryBehavior === 'string' ? sectionPayload.galleryBehavior : 'carousel'}
|
|
onChange={(event) => syncNativePayload('galleryBehavior', event.target.value)}
|
|
className="admin-input w-full bg-slate-900 border-white/10"
|
|
>
|
|
<option value="carousel">Carousel</option>
|
|
<option value="stacked">Stacked Scroll Gallery</option>
|
|
</select>
|
|
</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-white/5 px-4 py-3">
|
|
<input type="checkbox" checked={sectionPayload.purchaseCardSticky !== false} onChange={(event) => syncNativePayload('purchaseCardSticky', event.target.checked)} className="accent-cyan-glow" />
|
|
<span className="text-xs font-bold text-slate-300">Sticky purchase card</span>
|
|
</label>
|
|
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-white/5 px-4 py-3">
|
|
<input type="checkbox" checked={sectionPayload.showStickyPurchaseBarAfterMain !== false} onChange={(event) => syncNativePayload('showStickyPurchaseBarAfterMain', event.target.checked)} className="accent-cyan-glow" />
|
|
<span className="text-xs font-bold text-slate-300">Sticky top purchase bar after main</span>
|
|
</label>
|
|
<label className="flex items-center gap-3 rounded-xl border border-white/5 bg-white/5 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-white/5 px-4 py-3">
|
|
<input type="checkbox" checked={sectionPayload.showBrandMeta !== false} onChange={(event) => syncNativePayload('showBrandMeta', event.target.checked)} className="accent-cyan-glow" />
|
|
<span className="text-xs font-bold text-slate-300">Show brand/category meta</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{sectionType === 'PRODUCT_REVIEWS' && (
|
|
<div className="flex items-center gap-3 rounded-xl border border-white/5 bg-night-950 px-4 py-3 text-xs font-bold text-slate-400">
|
|
<Star className="h-4 w-4 text-cyan-glow" />
|
|
Reviews are pulled from published product reviews.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div className="rounded-2xl border border-white/5 bg-night-950 p-4">
|
|
<p className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-slate-400">
|
|
<FileJson className="h-4 w-4" />
|
|
Product 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 product.</p>
|
|
</div>
|
|
{sectionType === 'HERO_CAROUSEL' && (
|
|
<ProductHeroCarouselBlockForm
|
|
payload={sectionPayload}
|
|
activeLanguage={activeLang}
|
|
onChange={(nextPayload) => {
|
|
setSectionPayload(nextPayload)
|
|
setSectionJson(JSON.stringify(nextPayload, null, 2))
|
|
}}
|
|
onBrowseMedia={openMediaPicker}
|
|
/>
|
|
)}
|
|
{sectionType === 'DIVIDER' && (
|
|
<ProductDividerBlockForm
|
|
payload={sectionPayload}
|
|
onChange={(nextPayload) => {
|
|
setSectionPayload(nextPayload)
|
|
setSectionJson(JSON.stringify(nextPayload, null, 2))
|
|
}}
|
|
/>
|
|
)}
|
|
{sectionType === 'HERO' && (
|
|
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
|
<div className="md:col-span-2 space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Hero Image</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => openImagePicker('imageUrl')}
|
|
className="relative h-44 w-full overflow-hidden rounded-2xl border-2 border-dashed border-white/10 bg-night-950/50 transition-all hover:border-cyan-glow/30"
|
|
>
|
|
{typeof sectionPayload.imageUrl === 'string' && sectionPayload.imageUrl ? (
|
|
<>
|
|
<img src={sectionPayload.imageUrl} className="h-full w-full object-cover" alt="Hero" />
|
|
<div className="absolute inset-0 flex items-center justify-center bg-night-950/40 opacity-0 transition-opacity hover:opacity-100">
|
|
<UploadCloud className="h-10 w-10 text-white" />
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex h-full flex-col items-center justify-center gap-2 text-slate-600">
|
|
<ImageIcon className="h-10 w-10" />
|
|
<span className="text-xs font-bold uppercase tracking-widest">Select Image</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{typeof sectionPayload.imageUrl === 'string' && sectionPayload.imageUrl && (
|
|
<div className="md:col-span-2">
|
|
<FocalPointPicker
|
|
imageUrl={sectionPayload.imageUrl}
|
|
focalPoint={sectionPayload.focalPoint as { x: string; y: string } | undefined}
|
|
onChange={(focalPoint) => updatePayloadValue('focalPoint', focalPoint)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Tagline ({activeLang.toUpperCase()})</label>
|
|
<input type="text" value={getSectionLocalizedValue('tagline')} onChange={(event) => updateLocalizedValue('tagline', event.target.value)} className="admin-input w-full" />
|
|
</div>
|
|
<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={(event) => updateLocalizedValue('title', event.target.value)} className="admin-input w-full" />
|
|
<p className="text-[9px] text-slate-500 font-medium">Use [brackets] for accent serif text.</p>
|
|
</div>
|
|
<div className="md:col-span-2 space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Description ({activeLang.toUpperCase()})</label>
|
|
<textarea rows={4} value={getSectionLocalizedValue('description')} onChange={(event) => updateLocalizedValue('description', event.target.value)} className="admin-input w-full resize-none" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Button Text ({activeLang.toUpperCase()})</label>
|
|
<input type="text" value={getSectionLocalizedValue('buttonText')} onChange={(event) => updateLocalizedValue('buttonText', event.target.value)} className="admin-input w-full" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Button Link</label>
|
|
<input type="text" value={typeof sectionPayload.buttonLink === 'string' ? sectionPayload.buttonLink : ''} onChange={(event) => updatePayloadValue('buttonLink', event.target.value)} className="admin-input w-full" placeholder="/shop or https://..." />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{sectionType === 'HERO_V2' && (
|
|
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
|
<div className="md:col-span-2 space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Hero Image</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => openImagePicker('imageUrl')}
|
|
className="relative h-44 w-full overflow-hidden rounded-2xl border-2 border-dashed border-white/10 bg-night-950/50 transition-all hover:border-cyan-glow/30"
|
|
>
|
|
{typeof sectionPayload.imageUrl === 'string' && sectionPayload.imageUrl ? (
|
|
<>
|
|
<img src={sectionPayload.imageUrl} className="h-full w-full object-cover" alt="Hero V2" />
|
|
<div className="absolute inset-0 flex items-center justify-center bg-night-950/40 opacity-0 transition-opacity hover:opacity-100">
|
|
<UploadCloud className="h-10 w-10 text-white" />
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex h-full flex-col items-center justify-center gap-2 text-slate-600">
|
|
<ImageIcon className="h-10 w-10" />
|
|
<span className="text-xs font-bold uppercase tracking-widest">Select Image</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{typeof sectionPayload.imageUrl === 'string' && sectionPayload.imageUrl && (
|
|
<div className="md:col-span-2">
|
|
<FocalPointPicker
|
|
imageUrl={sectionPayload.imageUrl}
|
|
focalPoint={sectionPayload.focalPoint as { x: string; y: string } | undefined}
|
|
onChange={(focalPoint) => updatePayloadValue('focalPoint', focalPoint)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Alignment</label>
|
|
<select value={typeof sectionPayload.contentAlignment === 'string' ? sectionPayload.contentAlignment : 'left'} onChange={(event) => updatePayloadValue('contentAlignment', event.target.value)} className="admin-input w-full bg-slate-900 border-white/10">
|
|
<option value="left">Left</option>
|
|
<option value="center">Center</option>
|
|
<option value="right">Right</option>
|
|
</select>
|
|
</div>
|
|
<label className="mt-0 flex items-center gap-3 rounded-xl border border-white/5 bg-white/5 px-4 py-3 md:mt-6">
|
|
<input type="checkbox" checked={sectionPayload.showOrnament !== false} onChange={(event) => updatePayloadValue('showOrnament', event.target.checked)} className="accent-cyan-glow" />
|
|
<span className="text-xs font-bold text-slate-300">Show ornament</span>
|
|
</label>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Tagline ({activeLang.toUpperCase()})</label>
|
|
<input type="text" value={getSectionLocalizedValue('tagline')} onChange={(event) => updateLocalizedValue('tagline', event.target.value)} className="admin-input w-full" />
|
|
</div>
|
|
<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={(event) => updateLocalizedValue('title', event.target.value)} className="admin-input w-full" />
|
|
<p className="text-[9px] text-slate-500 font-medium">Use [brackets] for accent serif text.</p>
|
|
</div>
|
|
<div className="md:col-span-2 space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Description ({activeLang.toUpperCase()})</label>
|
|
<textarea rows={4} value={getSectionLocalizedValue('description')} onChange={(event) => updateLocalizedValue('description', event.target.value)} className="admin-input w-full resize-none" />
|
|
</div>
|
|
|
|
<div className="space-y-3 rounded-2xl border border-white/5 bg-slate-800/30 p-4">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-cyan-glow">Primary Button</label>
|
|
<input type="text" value={getSectionLocalizedValue('primaryButtonText')} onChange={(event) => updateLocalizedValue('primaryButtonText', event.target.value)} className="admin-input w-full" placeholder={`Text (${activeLang.toUpperCase()})`} />
|
|
<input type="text" value={typeof sectionPayload.primaryButtonLink === 'string' ? sectionPayload.primaryButtonLink : ''} onChange={(event) => updatePayloadValue('primaryButtonLink', event.target.value)} className="admin-input w-full" placeholder="/shop or https://..." />
|
|
</div>
|
|
<div className="space-y-3 rounded-2xl border border-white/5 bg-slate-800/30 p-4">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Secondary Button</label>
|
|
<input type="text" value={getSectionLocalizedValue('secondaryButtonText')} onChange={(event) => updateLocalizedValue('secondaryButtonText', event.target.value)} className="admin-input w-full" placeholder={`Text (${activeLang.toUpperCase()})`} />
|
|
<input type="text" value={typeof sectionPayload.secondaryButtonLink === 'string' ? sectionPayload.secondaryButtonLink : ''} onChange={(event) => updatePayloadValue('secondaryButtonLink', event.target.value)} className="admin-input w-full" placeholder="/shop or https://..." />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{sectionType === 'RICH_TEXT' && (
|
|
<div className="space-y-5">
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Section Title ({activeLang.toUpperCase()})</label>
|
|
<input
|
|
type="text"
|
|
value={getSectionLocalizedValue('title')}
|
|
onChange={(event) => updateLocalizedValue('title', event.target.value)}
|
|
className="admin-input w-full"
|
|
placeholder="Optional title above the content"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Content ({activeLang.toUpperCase()})</label>
|
|
<textarea
|
|
rows={10}
|
|
value={getSectionLocalizedValue('content')}
|
|
onChange={(event) => updateLocalizedValue('content', event.target.value)}
|
|
className="admin-input w-full resize-y"
|
|
placeholder="<p>Write product-specific HTML or plain text here...</p>"
|
|
/>
|
|
<p className="text-[9px] text-slate-500 font-medium">HTML is allowed. Use the language tabs to translate each version.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{sectionType === 'BANNER' && (
|
|
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
|
<div className="md:col-span-2 space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Banner Image</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => openImagePicker('imageUrl')}
|
|
className="relative h-44 w-full overflow-hidden rounded-2xl border-2 border-dashed border-white/10 bg-night-950/50 transition-all hover:border-cyan-glow/30"
|
|
>
|
|
{typeof sectionPayload.imageUrl === 'string' && sectionPayload.imageUrl ? (
|
|
<>
|
|
<img src={sectionPayload.imageUrl} className="h-full w-full object-cover" alt="Banner" />
|
|
<div className="absolute inset-0 flex items-center justify-center bg-night-950/40 opacity-0 transition-opacity hover:opacity-100">
|
|
<UploadCloud className="h-10 w-10 text-white" />
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex h-full flex-col items-center justify-center gap-2 text-slate-600">
|
|
<ImageIcon className="h-10 w-10" />
|
|
<span className="text-xs font-bold uppercase tracking-widest">Select Image</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
</div>
|
|
<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={(event) => updateLocalizedValue('title', event.target.value)} className="admin-input w-full" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Button Text ({activeLang.toUpperCase()})</label>
|
|
<input type="text" value={getSectionLocalizedValue('buttonText')} onChange={(event) => updateLocalizedValue('buttonText', event.target.value)} className="admin-input w-full" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Link URL</label>
|
|
<input type="text" value={typeof sectionPayload.linkUrl === 'string' ? sectionPayload.linkUrl : ''} onChange={(event) => updatePayloadValue('linkUrl', event.target.value)} className="admin-input w-full" placeholder="/shop or https://..." />
|
|
</div>
|
|
<div className="md:col-span-2 space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Description ({activeLang.toUpperCase()})</label>
|
|
<textarea rows={3} value={getSectionLocalizedValue('description')} onChange={(event) => updateLocalizedValue('description', event.target.value)} className="admin-input w-full resize-none" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{sectionType === 'EDITORIAL_TILE' && (
|
|
<div className="space-y-5">
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Editorial Image</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => openImagePicker('imageUrl')}
|
|
className="relative h-44 w-full overflow-hidden rounded-2xl border-2 border-dashed border-white/10 bg-night-950/50 transition-all hover:border-cyan-glow/30"
|
|
>
|
|
{typeof sectionPayload.imageUrl === 'string' && sectionPayload.imageUrl ? (
|
|
<>
|
|
<img src={sectionPayload.imageUrl} className="h-full w-full object-cover" alt="Editorial" />
|
|
<div className="absolute inset-0 flex items-center justify-center bg-night-950/40 opacity-0 transition-opacity hover:opacity-100">
|
|
<UploadCloud className="h-10 w-10 text-white" />
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex h-full flex-col items-center justify-center gap-2 text-slate-600">
|
|
<ImageIcon className="h-10 w-10" />
|
|
<span className="text-xs font-bold uppercase tracking-widest">Select Image</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-5 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={(event) => updateLocalizedValue('title', event.target.value)} className="admin-input w-full" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Image Position</label>
|
|
<select value={typeof sectionPayload.imagePosition === 'string' ? sectionPayload.imagePosition : 'left'} onChange={(event) => updatePayloadValue('imagePosition', event.target.value)} className="admin-input w-full bg-slate-900 border-white/10">
|
|
<option value="left">Left</option>
|
|
<option value="right">Right</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Button Text ({activeLang.toUpperCase()})</label>
|
|
<input type="text" value={getSectionLocalizedValue('buttonText')} onChange={(event) => updateLocalizedValue('buttonText', event.target.value)} className="admin-input w-full" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Button Link</label>
|
|
<input type="text" value={typeof sectionPayload.buttonLink === 'string' ? sectionPayload.buttonLink : ''} onChange={(event) => updatePayloadValue('buttonLink', event.target.value)} className="admin-input w-full" />
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Description ({activeLang.toUpperCase()})</label>
|
|
<textarea rows={4} value={getSectionLocalizedValue('description')} onChange={(event) => updateLocalizedValue('description', event.target.value)} className="admin-input w-full resize-none" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{sectionType === 'QUOTE_CALLOUT' && (
|
|
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
|
<div className="md:col-span-2 space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Quote ({activeLang.toUpperCase()})</label>
|
|
<textarea rows={5} value={getSectionLocalizedValue('quote')} onChange={(event) => updateLocalizedValue('quote', event.target.value)} className="admin-input w-full resize-y" placeholder="The quote to highlight..." />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Author ({activeLang.toUpperCase()})</label>
|
|
<input type="text" value={getSectionLocalizedValue('author')} onChange={(event) => updateLocalizedValue('author', event.target.value)} className="admin-input w-full" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Role ({activeLang.toUpperCase()})</label>
|
|
<input type="text" value={getSectionLocalizedValue('role')} onChange={(event) => updateLocalizedValue('role', event.target.value)} className="admin-input w-full" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{sectionType === 'PRODUCT_CAROUSEL' && (
|
|
<div className="space-y-5">
|
|
<div className="grid grid-cols-1 gap-5 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={(event) => updateLocalizedValue('title', event.target.value)} className="admin-input w-full" placeholder="Carousel heading" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Description ({activeLang.toUpperCase()})</label>
|
|
<input type="text" value={getSectionLocalizedValue('description')} onChange={(event) => updateLocalizedValue('description', event.target.value)} className="admin-input w-full" placeholder="Optional supporting line" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Products Source</label>
|
|
<select value={typeof sectionPayload.type === 'string' ? sectionPayload.type : 'custom'} onChange={(event) => updatePayloadValue('type', event.target.value)} className="admin-input w-full bg-slate-900 border-white/10">
|
|
<option value="featured">By Category</option>
|
|
<option value="brand">By Brand</option>
|
|
<option value="custom">Custom Products</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Display Limit</label>
|
|
<input type="number" min={1} value={typeof sectionPayload.limit === 'number' ? sectionPayload.limit : 4} onChange={(event) => updatePayloadValue('limit', parseInt(event.target.value, 10) || 1)} className="admin-input w-full" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
<label className="flex items-center gap-3 p-3 border border-white/5 bg-white/5 rounded-xl cursor-pointer select-none">
|
|
<input type="checkbox" checked={sectionPayload.showPrice !== false} onChange={(event) => updatePayloadValue('showPrice', event.target.checked)} className="accent-cyan-glow" />
|
|
<span className="text-xs font-bold text-slate-300">Show Price</span>
|
|
</label>
|
|
<label className="flex items-center gap-3 p-3 border border-white/5 bg-white/5 rounded-xl cursor-pointer select-none">
|
|
<input type="checkbox" checked={sectionPayload.showAddToCart !== false} onChange={(event) => updatePayloadValue('showAddToCart', event.target.checked)} className="accent-cyan-glow" />
|
|
<span className="text-xs font-bold text-slate-300">Show Add to Cart</span>
|
|
</label>
|
|
</div>
|
|
|
|
{sectionPayload.type === 'featured' && (
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Select Category</label>
|
|
<select value={typeof sectionPayload.categoryId === 'number' ? sectionPayload.categoryId : ''} onChange={(event) => updatePayloadValue('categoryId', event.target.value ? parseInt(event.target.value, 10) : undefined)} className="admin-input w-full bg-slate-900 border-white/10">
|
|
<option value="">None</option>
|
|
{categories.map((category) => (
|
|
<option key={category.id} value={category.id}>{getAdminDisplayString(category.name)}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{sectionPayload.type === 'brand' && (
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Select Brand</label>
|
|
<select value={typeof sectionPayload.brandId === 'number' ? sectionPayload.brandId : ''} onChange={(event) => updatePayloadValue('brandId', event.target.value ? parseInt(event.target.value, 10) : undefined)} className="admin-input w-full bg-slate-900 border-white/10">
|
|
<option value="">None</option>
|
|
{brands.map((brand) => (
|
|
<option key={brand.id} value={brand.id}>{brand.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{sectionPayload.type === 'custom' && (
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Custom Products</label>
|
|
<div className="flex gap-2">
|
|
<input readOnly value={Array.isArray(sectionPayload.productIds) ? `${sectionPayload.productIds.length} product(s) selected` : '0 products selected'} className="admin-input flex-1 bg-slate-900/50 cursor-not-allowed text-slate-400" />
|
|
<button type="button" onClick={() => setIsProductPickerOpen(true)} className="px-4 py-2 bg-cyan-glow hover:bg-cyan-glow/80 text-black text-sm font-bold rounded-xl transition-colors whitespace-nowrap">Select Products</button>
|
|
</div>
|
|
{Array.isArray(sectionPayload.productIds) && sectionPayload.productIds.length > 0 && (
|
|
<p className="text-xs text-slate-500">IDs: {sectionPayload.productIds.join(', ')}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!['HERO', 'HERO_V2', 'HERO_CAROUSEL', 'DIVIDER', 'RICH_TEXT', 'BANNER', 'EDITORIAL_TILE', 'QUOTE_CALLOUT', 'PRODUCT_CAROUSEL'].includes(sectionType) && (
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Payload JSON</label>
|
|
<textarea
|
|
value={sectionJson}
|
|
onChange={(e) => setSectionJson(e.target.value)}
|
|
className="w-full min-h-[260px] 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 shadow-inner font-mono"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<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>
|
|
)}
|
|
{PRODUCT_NATIVE_SECTION_TYPES.has(sectionType) && (
|
|
<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={() => resetDetailSectionEditor()}
|
|
className="px-4 py-2.5 rounded-xl border border-white/10 bg-white/5 text-sm font-bold text-slate-300 hover:bg-white/10 transition-all"
|
|
>
|
|
Reset
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSaveDetailSection}
|
|
disabled={sectionsSaving}
|
|
className="btn-primary py-2.5 px-6 flex items-center justify-center gap-2"
|
|
>
|
|
{sectionsSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
{selectedSectionId === 'new' ? 'Create Block' : 'Save Block'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-white/5 bg-night-950/70 p-5">
|
|
<p className="flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-slate-400">
|
|
<LinkIcon className="h-4 w-4" />
|
|
Rendering Contract
|
|
</p>
|
|
<p className="mt-2 text-xs leading-relaxed text-slate-500">
|
|
Product native blocks render from live product data. This page only controls block order, visibility, headings, and additional content payloads.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<MediaLibraryModal
|
|
isOpen={isMediaModalOpen}
|
|
onClose={() => {
|
|
setIsMediaModalOpen(false)
|
|
setOnSelectMedia(null)
|
|
}}
|
|
onSelect={(url) => {
|
|
if (onSelectMedia) onSelectMedia(url)
|
|
setIsMediaModalOpen(false)
|
|
setOnSelectMedia(null)
|
|
}}
|
|
/>
|
|
|
|
{isProductPickerOpen && (
|
|
<ProductPickerModal
|
|
selectedIds={Array.isArray(sectionPayload.productIds) ? sectionPayload.productIds.filter((id): id is number => typeof id === 'number') : []}
|
|
onSave={(ids) => updatePayloadValue('productIds', ids)}
|
|
onClose={() => setIsProductPickerOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
<BlockTranslationModal
|
|
isOpen={isTranslationModalOpen}
|
|
onClose={() => setIsTranslationModalOpen(false)}
|
|
section={{
|
|
id: typeof selectedSectionId === 'number' ? selectedSectionId : undefined,
|
|
type: sectionType,
|
|
payload: sectionPayload,
|
|
}}
|
|
initialTab={translationModalTab}
|
|
onApplyTranslations={(updatedPayload) => {
|
|
setSectionPayload(updatedPayload)
|
|
setSectionJson(JSON.stringify(updatedPayload, null, 2))
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|