x
This commit is contained in:
1 parent
2d39edbd57
commit
78b722e120
24 files changed
+2130
-65
No files matched your search
@@ -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)
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
Reference in new issue
Block a user