This commit is contained in:
Your Name committed 2026-10-01 15:42:52 +07:00
1 parent 2d39edbd57
commit 78b722e120
24 files changed
+2130 -65

No files matched your search

+110 -10
View File
@@ -32,6 +32,9 @@ import { API_BASE, type Category, type Product, type ProductDetailSection } from
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'
interface Brand {
@@ -39,10 +42,36 @@ interface Brand {
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',
@@ -64,10 +93,18 @@ const PRODUCT_NATIVE_SECTION_TYPES = new Set([
'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',
@@ -85,6 +122,8 @@ 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.',
@@ -138,6 +177,27 @@ const createDefaultProductDetailPayload = (type: string) => {
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' }
@@ -177,13 +237,14 @@ export default function ProductCmsPage() {
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 [mediaTargetField, setMediaTargetField] = useState<'imageUrl' | null>(null)
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
const [isProductPickerOpen, setIsProductPickerOpen] = useState(false)
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']
const selectedSection = selectedSectionId === 'new'
@@ -199,6 +260,7 @@ export default function ProductCmsPage() {
setSectionPayload(payload)
setSectionJson(JSON.stringify(payload, null, 2))
setSectionIsActive(true)
setSectionSaveError(null)
}
const startNewSection = (nextType: string) => {
@@ -212,6 +274,7 @@ export default function ProductCmsPage() {
setSectionPayload(payload)
setSectionJson(JSON.stringify(payload, null, 2))
setSectionIsActive(section.isActive)
setSectionSaveError(null)
}
const fetchDetailSections = async (productId: string | number) => {
@@ -285,11 +348,15 @@ export default function ProductCmsPage() {
syncNativeLocalizedPayload(field, value)
}
const openImagePicker = (field: 'imageUrl') => {
setMediaTargetField(field)
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
@@ -322,8 +389,13 @@ export default function ProductCmsPage() {
if (!id) return
setSectionsSaving(true)
setError(null)
setSectionSaveError(null)
try {
const payload = PRODUCT_NATIVE_SECTION_TYPES.has(sectionType) ? sectionPayload : JSON.parse(sectionJson || '{}')
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}`,
@@ -334,13 +406,15 @@ export default function ProductCmsPage() {
}
)
const data = await response.json().catch(() => null)
if (!response.ok) throw new Error(data?.message || 'Failed to save product detail section')
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) {
setError(err.message)
const message = err instanceof Error ? err.message : 'Failed to save product detail section'
setError(message)
setSectionSaveError(message)
} finally {
setSectionsSaving(false)
}
@@ -846,6 +920,26 @@ export default function ProductCmsPage() {
</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">
@@ -1193,7 +1287,7 @@ export default function ProductCmsPage() {
</div>
)}
{!['HERO', 'HERO_V2', 'RICH_TEXT', 'BANNER', 'EDITORIAL_TILE', 'QUOTE_CALLOUT', 'PRODUCT_CAROUSEL'].includes(sectionType) && (
{!['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
@@ -1207,6 +1301,12 @@ export default function ProductCmsPage() {
)}
<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"
@@ -1253,12 +1353,12 @@ export default function ProductCmsPage() {
isOpen={isMediaModalOpen}
onClose={() => {
setIsMediaModalOpen(false)
setMediaTargetField(null)
setOnSelectMedia(null)
}}
onSelect={(url) => {
if (mediaTargetField) updatePayloadValue(mediaTargetField, url)
if (onSelectMedia) onSelectMedia(url)
setIsMediaModalOpen(false)
setMediaTargetField(null)
setOnSelectMedia(null)
}}
/>