x
This commit is contained in:
1 parent
0ceee3dbff
commit
2d39edbd57
18 files changed
+794
-143
No files matched your search
@@ -75,6 +75,7 @@ export default function CategoryBulkJsonModal({
|
||||
id: cat.id,
|
||||
name: catBaseName || transMap['th'] || 'Unnamed Category',
|
||||
imageUrl: cat.imageUrl || null,
|
||||
media: cat.media || [],
|
||||
layoutType: cat.layoutType || 'split_showcase',
|
||||
translations: transMap,
|
||||
};
|
||||
@@ -89,8 +90,9 @@ export default function CategoryBulkJsonModal({
|
||||
const aiTranslatePrompt = useMemo(() => {
|
||||
const sampleItems = exportData.slice(0, 5);
|
||||
return `Please translate and optimize our E-commerce Category Names into all 8 supported languages (th, en, ja, zh, vi, ko, lo, it).
|
||||
Ensure the tone is elegant, accurate for luxury aromatherapy & lifestyle products.
|
||||
Preserve the tenant's catalog terminology and brand tone.
|
||||
Allowed layoutType values: "split_showcase" (default), "editorial_hero", "dense_catalog".
|
||||
Allowed media roles: "thumbnail", "hero_desktop", "hero_mobile", "card_portrait", "social_share".
|
||||
|
||||
Return ONLY a valid JSON Array conforming to this structure:
|
||||
\`\`\`json
|
||||
@@ -101,6 +103,11 @@ ${JSON.stringify(
|
||||
{
|
||||
name: 'น้ำมันหอมระเหย',
|
||||
imageUrl: '/images/categories/essential-oils.jpg',
|
||||
media: [
|
||||
{ role: 'thumbnail', url: '/images/categories/essential-oils-square.jpg' },
|
||||
{ role: 'hero_desktop', url: '/images/categories/essential-oils-wide.jpg' },
|
||||
{ role: 'hero_mobile', url: '/images/categories/essential-oils-mobile.jpg' },
|
||||
],
|
||||
layoutType: 'split_showcase',
|
||||
translations: {
|
||||
th: 'น้ำมันหอมระเหย',
|
||||
@@ -116,6 +123,11 @@ ${JSON.stringify(
|
||||
{
|
||||
name: 'ก้านไม้หอมปรับอากาศ',
|
||||
imageUrl: '/images/categories/reed-diffusers.jpg',
|
||||
media: [
|
||||
{ role: 'thumbnail', url: '/images/categories/reed-diffusers-square.jpg' },
|
||||
{ role: 'hero_desktop', url: '/images/categories/reed-diffusers-wide.jpg' },
|
||||
{ role: 'hero_mobile', url: '/images/categories/reed-diffusers-mobile.jpg' },
|
||||
],
|
||||
layoutType: 'editorial_hero',
|
||||
translations: {
|
||||
th: 'ก้านไม้หอมปรับอากาศ',
|
||||
@@ -169,6 +181,7 @@ ${JSON.stringify(
|
||||
id: typeof item.id === 'number' ? item.id : undefined,
|
||||
name: baseName,
|
||||
imageUrl: item.imageUrl || null,
|
||||
media: Array.isArray(item.media) ? item.media : [],
|
||||
layoutType: typeof item.layoutType === 'string' ? item.layoutType : 'split_showcase',
|
||||
translations: trans,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { AlertCircle, ClipboardPaste, Image as ImageIcon, Loader2, Trash2 } from 'lucide-react'
|
||||
import type { CategoryMedia, CategoryMediaRole } from '@homaroma/types'
|
||||
import FocalPointPicker from './page-builder/FocalPointPicker'
|
||||
|
||||
type CategoryMediaEditorProps = {
|
||||
media: CategoryMedia[]
|
||||
layoutType: string
|
||||
displayName: string
|
||||
uploadingRole: CategoryMediaRole | null
|
||||
onChange: Dispatch<SetStateAction<CategoryMedia[]>>
|
||||
onBrowse: (role: CategoryMediaRole) => void
|
||||
onPaste: (role: CategoryMediaRole) => void
|
||||
}
|
||||
|
||||
const MEDIA_SLOTS: Array<{
|
||||
role: CategoryMediaRole
|
||||
label: string
|
||||
ratio: string
|
||||
dimensions: string
|
||||
previewClass: string
|
||||
expectedRatio: number
|
||||
}> = [
|
||||
{ role: 'thumbnail', label: 'Thumbnail', ratio: '1:1', dimensions: '1200 × 1200 px', previewClass: 'aspect-square', expectedRatio: 1 },
|
||||
{ role: 'hero_desktop', label: 'Desktop Hero', ratio: '21:9', dimensions: '2400 × 1028 px', previewClass: 'aspect-[21/9]', expectedRatio: 21 / 9 },
|
||||
{ role: 'hero_mobile', label: 'Mobile Hero', ratio: '4:5', dimensions: '1200 × 1500 px', previewClass: 'aspect-[4/5]', expectedRatio: 4 / 5 },
|
||||
{ role: 'card_portrait', label: 'Portrait Card', ratio: '4:5', dimensions: '1200 × 1500 px', previewClass: 'aspect-[4/5]', expectedRatio: 4 / 5 },
|
||||
{ role: 'social_share', label: 'Social Share', ratio: '1.91:1', dimensions: '1200 × 630 px', previewClass: 'aspect-[1.91/1]', expectedRatio: 1200 / 630 },
|
||||
]
|
||||
|
||||
function isRecommendedForLayout(role: CategoryMediaRole, layoutType: string) {
|
||||
return layoutType === 'editorial_hero' && (role === 'hero_desktop' || role === 'hero_mobile')
|
||||
}
|
||||
|
||||
export default function CategoryMediaEditor({
|
||||
media,
|
||||
layoutType,
|
||||
displayName,
|
||||
uploadingRole,
|
||||
onChange,
|
||||
onBrowse,
|
||||
onPaste,
|
||||
}: CategoryMediaEditorProps) {
|
||||
const findMedia = (role: CategoryMediaRole) => media.find((item) => item.role === role)
|
||||
|
||||
const updateMedia = (role: CategoryMediaRole, update: Partial<CategoryMedia>) => {
|
||||
onChange((current) => {
|
||||
const existing = current.find((item) => item.role === role)
|
||||
if (existing) return current.map((item) => item.role === role ? { ...item, ...update } : item)
|
||||
if (!update.url) return current
|
||||
return [...current, { role, url: update.url, focalX: 0.5, focalY: 0.5, sortOrder: current.length, ...update }]
|
||||
})
|
||||
}
|
||||
|
||||
const removeMedia = (role: CategoryMediaRole) => {
|
||||
onChange((current) => current.filter((item) => item.role !== role))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-card space-y-5 p-6">
|
||||
<div className="border-b border-white/5 pb-4">
|
||||
<h2 className="text-base font-bold text-white">Category Media</h2>
|
||||
<p className="mt-1 text-xs text-slate-400">Art-directed assets for cards, responsive heroes, and social sharing.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{MEDIA_SLOTS.map((slot) => {
|
||||
const item = findMedia(slot.role)
|
||||
const required = slot.role === 'thumbnail'
|
||||
const recommended = isRecommendedForLayout(slot.role, layoutType)
|
||||
const isUploading = uploadingRole === slot.role
|
||||
const actualRatio = item?.width && item?.height ? item.width / item.height : null
|
||||
const ratioMismatch = actualRatio !== null && Math.abs(actualRatio - slot.expectedRatio) / slot.expectedRatio > 0.08
|
||||
|
||||
return (
|
||||
<section key={slot.role} className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.02] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-bold text-white">{slot.label}</h3>
|
||||
{(required || recommended) && (
|
||||
<span className="rounded-md border border-cyan-glow/20 bg-cyan-glow/10 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-cyan-glow">
|
||||
{required ? 'Required' : 'Recommended'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-slate-500">{slot.ratio} · {slot.dimensions}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPaste(slot.role)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-[10px] font-semibold text-slate-300 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<ClipboardPaste className="h-3 w-3" />
|
||||
Paste
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBrowse(slot.role)}
|
||||
className={`relative flex w-full items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-white/10 bg-night-950/60 transition hover:border-cyan-glow/40 ${slot.previewClass}`}
|
||||
>
|
||||
{item?.url ? (
|
||||
<img
|
||||
src={item.url}
|
||||
alt={`${displayName || 'Category'} ${slot.label}`}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
style={{ objectPosition: `${(item.focalX ?? 0.5) * 100}% ${(item.focalY ?? 0.5) * 100}%` }}
|
||||
onLoad={(event) => {
|
||||
const { naturalWidth, naturalHeight } = event.currentTarget
|
||||
if (naturalWidth !== item.width || naturalHeight !== item.height) {
|
||||
updateMedia(slot.role, { width: naturalWidth, height: naturalHeight })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2 p-5 text-center">
|
||||
<ImageIcon className="mx-auto h-6 w-6 text-slate-500" />
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400">Select {slot.label}</p>
|
||||
</div>
|
||||
)}
|
||||
{isUploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-night-950/80">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-cyan-glow" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{item?.url ? (
|
||||
<div className="space-y-3">
|
||||
<p className={`text-[11px] ${ratioMismatch ? 'text-amber-400' : 'text-slate-500'}`}>
|
||||
Actual: {item.width && item.height ? `${item.width} × ${item.height} px` : 'detecting dimensions…'}
|
||||
{ratioMismatch ? ` · recommended ${slot.ratio}` : ''}
|
||||
</p>
|
||||
<FocalPointPicker
|
||||
imageUrl={item.url}
|
||||
focalPoint={{
|
||||
x: `${(item.focalX ?? 0.5) * 100}%`,
|
||||
y: `${(item.focalY ?? 0.5) * 100}%`,
|
||||
}}
|
||||
onChange={(point) => updateMedia(slot.role, {
|
||||
focalX: Number.parseFloat(point.x) / 100,
|
||||
focalY: Number.parseFloat(point.y) / 100,
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMedia(slot.role)}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-rose-400 hover:underline"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Remove asset
|
||||
</button>
|
||||
</div>
|
||||
) : (required || recommended) ? (
|
||||
<div className="flex items-start gap-2 rounded-xl border border-amber-500/10 bg-amber-500/5 p-3 text-xs text-amber-400">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{required ? 'This asset is required before saving.' : 'This layout will use the documented fallback until this asset is supplied.'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { API_BASE, getCategoryImage, type Category } from '@homaroma/types'
|
||||
import { API_BASE, getCategoryImage, resolveCategoryMediaUrl, type Category } from '@homaroma/types'
|
||||
import {
|
||||
Edit3,
|
||||
Package,
|
||||
@@ -237,7 +237,7 @@ export default function CategoriesPage() {
|
||||
) : (
|
||||
filteredCategories.map((cat) => {
|
||||
const fallbackName = typeof cat.name === 'string' ? cat.name : (cat.name?.en || cat.name?.th || '')
|
||||
const imageUrl = cat.imageUrl || getCategoryImage(fallbackName)
|
||||
const imageUrl = resolveCategoryMediaUrl(cat, 'thumbnail', getCategoryImage(fallbackName))
|
||||
const langsCount = getAvailableLangsCount(cat)
|
||||
const thaiName = getThaiName(cat)
|
||||
|
||||
@@ -339,7 +339,7 @@ export default function CategoriesPage() {
|
||||
) : (
|
||||
filteredCategories.map((cat) => {
|
||||
const fallbackName = typeof cat.name === 'string' ? cat.name : (cat.name?.en || cat.name?.th || '')
|
||||
const imageUrl = cat.imageUrl || getCategoryImage(fallbackName)
|
||||
const imageUrl = resolveCategoryMediaUrl(cat, 'thumbnail', getCategoryImage(fallbackName))
|
||||
const thaiName = getThaiName(cat)
|
||||
const englishName = getEnglishName(cat)
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { API_BASE, type Category } from '@homaroma/types'
|
||||
import { API_BASE, type Category, type CategoryMedia, type CategoryMediaRole } from '@homaroma/types'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Trash2,
|
||||
Image as ImageIcon,
|
||||
UploadCloud,
|
||||
Loader2,
|
||||
ClipboardPaste,
|
||||
Globe,
|
||||
@@ -28,6 +26,7 @@ import {
|
||||
Grid3X3
|
||||
} from 'lucide-react'
|
||||
import MediaLibraryModal from '../components/MediaLibraryModal'
|
||||
import CategoryMediaEditor from '../components/CategoryMediaEditor'
|
||||
|
||||
const ADDITIONAL_LANGS = [
|
||||
{ code: 'ja', label: 'Japanese (日本語)', placeholder: '例: アロマキャンドル' },
|
||||
@@ -78,12 +77,14 @@ export default function CategoryEditPage() {
|
||||
lo: '',
|
||||
it: '',
|
||||
})
|
||||
const [imageUrl, setImageUrl] = useState<string>('')
|
||||
const [media, setMedia] = useState<CategoryMedia[]>([])
|
||||
const [layoutType, setLayoutType] = useState<string>('split_showcase')
|
||||
const [categoryMeta, setCategoryMeta] = useState<Category | null>(null)
|
||||
|
||||
// Media Library Modal
|
||||
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
|
||||
const [activeMediaRole, setActiveMediaRole] = useState<CategoryMediaRole>('thumbnail')
|
||||
const [uploadingRole, setUploadingRole] = useState<CategoryMediaRole | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) return
|
||||
@@ -94,7 +95,9 @@ export default function CategoryEditPage() {
|
||||
if (!res.ok) throw new Error('Category not found')
|
||||
const data = await res.json()
|
||||
setCategoryMeta(data)
|
||||
setImageUrl(data.imageUrl || '')
|
||||
setMedia(Array.isArray(data.media) && data.media.length > 0
|
||||
? data.media
|
||||
: (data.imageUrl ? [{ role: 'thumbnail', url: data.imageUrl, focalX: 0.5, focalY: 0.5 }] : []))
|
||||
setLayoutType(data.layoutType || 'split_showcase')
|
||||
|
||||
if (data.name && typeof data.name === 'object') {
|
||||
@@ -223,8 +226,17 @@ export default function CategoryEditPage() {
|
||||
}
|
||||
|
||||
// ── Image Upload Handlers ─────────────────────────────────────────────────
|
||||
const handleUpload = async (file: File) => {
|
||||
const setMediaUrl = (role: CategoryMediaRole, url: string) => {
|
||||
setMedia((current) => {
|
||||
const existing = current.find((item) => item.role === role)
|
||||
if (existing) return current.map((item) => item.role === role ? { ...item, url } : item)
|
||||
return [...current, { role, url, focalX: 0.5, focalY: 0.5, sortOrder: current.length }]
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpload = async (file: File, role: CategoryMediaRole) => {
|
||||
setIsUploading(true)
|
||||
setUploadingRole(role)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
@@ -235,17 +247,18 @@ export default function CategoryEditPage() {
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
setImageUrl(data.url)
|
||||
setMediaUrl(role, data.url)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err)
|
||||
alert('Failed to upload image')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
setUploadingRole(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasteFromClipboard = async () => {
|
||||
const handlePasteFromClipboard = async (role: CategoryMediaRole) => {
|
||||
try {
|
||||
const items = await navigator.clipboard.read()
|
||||
for (const item of items) {
|
||||
@@ -253,7 +266,7 @@ export default function CategoryEditPage() {
|
||||
for (const type of imageTypes) {
|
||||
const blob = await item.getType(type)
|
||||
const file = new File([blob], 'pasted-category-image.png', { type })
|
||||
handleUpload(file)
|
||||
void handleUpload(file, role)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -266,6 +279,12 @@ export default function CategoryEditPage() {
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const thumbnail = media.find((item) => item.role === 'thumbnail' && item.url.trim())
|
||||
if (!thumbnail) {
|
||||
alert('A 1:1 category thumbnail is required before saving.')
|
||||
return
|
||||
}
|
||||
if (!name.th?.trim()) {
|
||||
alert('Please enter Thai category name')
|
||||
return
|
||||
@@ -283,8 +302,17 @@ export default function CategoryEditPage() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: masterName,
|
||||
imageUrl: imageUrl || null,
|
||||
layoutType: layoutType || 'split_showcase'
|
||||
imageUrl: thumbnail.url,
|
||||
layoutType: layoutType || 'split_showcase',
|
||||
media: media.map(({ role, url, width, height, focalX, focalY, sortOrder }) => ({
|
||||
role,
|
||||
url,
|
||||
width: width ?? null,
|
||||
height: height ?? null,
|
||||
focalX: focalX ?? 0.5,
|
||||
focalY: focalY ?? 0.5,
|
||||
sortOrder: sortOrder ?? 0,
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -838,85 +866,18 @@ export default function CategoryEditPage() {
|
||||
{/* Right Column (1 Col): Image & Metadata */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Category Image Card */}
|
||||
<div className="admin-card p-6 space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-white/5 pb-4">
|
||||
<h2 className="text-base font-bold text-white">Category Image</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePasteFromClipboard}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-lg bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-wider transition-all border border-cyan-glow/20 cursor-pointer"
|
||||
title="Paste image directly from clipboard"
|
||||
>
|
||||
<ClipboardPaste className="w-3 h-3" />
|
||||
Paste Image
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail Preview Area */}
|
||||
<div
|
||||
className="relative w-full aspect-square rounded-2xl overflow-hidden border-2 border-dashed border-white/10 bg-night-950/60 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/40 transition-all shadow-inner"
|
||||
onClick={() => setIsMediaModalOpen(true)}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<>
|
||||
<img
|
||||
src={imageUrl}
|
||||
className="w-full h-full object-cover"
|
||||
alt={displayName}
|
||||
<CategoryMediaEditor
|
||||
media={media}
|
||||
layoutType={layoutType}
|
||||
displayName={displayName}
|
||||
uploadingRole={uploadingRole}
|
||||
onChange={setMedia}
|
||||
onBrowse={(role) => {
|
||||
setActiveMediaRole(role)
|
||||
setIsMediaModalOpen(true)
|
||||
}}
|
||||
onPaste={(role) => void handlePasteFromClipboard(role)}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-2 backdrop-blur-xs text-white">
|
||||
<UploadCloud className="w-8 h-8 text-cyan-glow" />
|
||||
<span className="text-xs font-bold">Change Image</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center space-y-2 p-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-white/5 flex items-center justify-center mx-auto text-slate-500 group-hover:text-cyan-glow transition-colors">
|
||||
<ImageIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 font-bold uppercase tracking-wider">Select Image</p>
|
||||
<p className="text-[10px] text-slate-600">Click to browse Media Library</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className="absolute inset-0 bg-night-950/80 flex flex-col items-center justify-center gap-2">
|
||||
<Loader2 className="w-8 h-8 text-cyan-glow animate-spin" />
|
||||
<span className="text-xs text-slate-300 font-medium">Uploading image...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Actions & Helpers */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMediaModalOpen(true)}
|
||||
className="text-cyan-glow hover:underline cursor-pointer font-medium"
|
||||
>
|
||||
Browse Media Library
|
||||
</button>
|
||||
{imageUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImageUrl('')}
|
||||
className="text-rose-400 hover:underline cursor-pointer"
|
||||
>
|
||||
Remove Image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!imageUrl && (
|
||||
<div className="p-3 rounded-xl bg-amber-500/5 border border-amber-500/10 text-amber-400 text-xs flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>If no image is uploaded, a default placeholder graphic will be used.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Info & Tips Card */}
|
||||
<div className="admin-card p-6 space-y-4">
|
||||
@@ -950,7 +911,7 @@ export default function CategoryEditPage() {
|
||||
isOpen={isMediaModalOpen}
|
||||
onClose={() => setIsMediaModalOpen(false)}
|
||||
onSelect={(url) => {
|
||||
setImageUrl(url)
|
||||
setMediaUrl(activeMediaRole, url)
|
||||
setIsMediaModalOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "CategoryMedia" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"categoryId" INTEGER NOT NULL,
|
||||
"role" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"width" INTEGER,
|
||||
"height" INTEGER,
|
||||
"focalX" DOUBLE PRECISION NOT NULL DEFAULT 0.5,
|
||||
"focalY" DOUBLE PRECISION NOT NULL DEFAULT 0.5,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CategoryMedia_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CategoryMedia_categoryId_sortOrder_idx" ON "CategoryMedia"("categoryId", "sortOrder");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CategoryMedia_categoryId_role_key" ON "CategoryMedia"("categoryId", "role");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CategoryMedia" ADD CONSTRAINT "CategoryMedia_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
-- Preserve every existing Category.imageUrl as the initial semantic thumbnail.
|
||||
-- The legacy column remains available while API and frontend consumers migrate.
|
||||
INSERT INTO "CategoryMedia" (
|
||||
"categoryId",
|
||||
"role",
|
||||
"url",
|
||||
"focalX",
|
||||
"focalY",
|
||||
"sortOrder",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
)
|
||||
SELECT
|
||||
"id",
|
||||
'thumbnail',
|
||||
"imageUrl",
|
||||
0.5,
|
||||
0.5,
|
||||
0,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM "Category"
|
||||
WHERE "imageUrl" IS NOT NULL
|
||||
AND BTRIM("imageUrl") <> ''
|
||||
ON CONFLICT ("categoryId", "role") DO NOTHING;
|
||||
@@ -35,6 +35,7 @@ model Category {
|
||||
name String @unique
|
||||
imageUrl String?
|
||||
layoutType String @default("split_showcase")
|
||||
media CategoryMedia[]
|
||||
products Product[]
|
||||
attributes AttributeDefinition[]
|
||||
translations CategoryTranslation[]
|
||||
@@ -45,6 +46,24 @@ model Category {
|
||||
@@index([deletedAt])
|
||||
}
|
||||
|
||||
model CategoryMedia {
|
||||
id Int @id @default(autoincrement())
|
||||
categoryId Int
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
role String
|
||||
url String
|
||||
width Int?
|
||||
height Int?
|
||||
focalX Float @default(0.5)
|
||||
focalY Float @default(0.5)
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([categoryId, role])
|
||||
@@index([categoryId, sortOrder])
|
||||
}
|
||||
|
||||
model CategoryTranslation {
|
||||
id Int @id @default(autoincrement())
|
||||
categoryId Int
|
||||
|
||||
@@ -115,11 +115,11 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
|
||||
// full catalog
|
||||
const categories = await prisma.category.findMany({
|
||||
where: { deletedAt: null },
|
||||
include: { translations: true },
|
||||
include: { translations: true, media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
const attributes = await prisma.attributeDefinition.findMany({
|
||||
include: { translations: true },
|
||||
include: { translations: true, media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
});
|
||||
const brands = await prisma.productBrand.findMany({
|
||||
@@ -846,11 +846,13 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
|
||||
where: { name: cat.name },
|
||||
update: {
|
||||
imageUrl: cat.imageUrl,
|
||||
layoutType: cat.layoutType || 'split_showcase',
|
||||
deletedAt: null,
|
||||
},
|
||||
create: {
|
||||
name: cat.name,
|
||||
imageUrl: cat.imageUrl,
|
||||
layoutType: cat.layoutType || 'split_showcase',
|
||||
},
|
||||
});
|
||||
if (cat.id) categoryMap.set(cat.id, savedCat.id);
|
||||
@@ -871,6 +873,25 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const categoryMedia = Array.isArray(cat.media)
|
||||
? cat.media
|
||||
: (cat.imageUrl ? [{ role: 'thumbnail', url: cat.imageUrl }] : []);
|
||||
await prisma.categoryMedia.deleteMany({ where: { categoryId: savedCat.id } });
|
||||
if (categoryMedia.length > 0) {
|
||||
await prisma.categoryMedia.createMany({
|
||||
data: categoryMedia.map((item: any, index: number) => ({
|
||||
categoryId: savedCat.id,
|
||||
role: item.role,
|
||||
url: item.url,
|
||||
width: item.width ?? null,
|
||||
height: item.height ?? null,
|
||||
focalX: item.focalX ?? 0.5,
|
||||
focalY: item.focalY ?? 0.5,
|
||||
sortOrder: item.sortOrder ?? index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
summary.categories++;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,89 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'];
|
||||
const CATEGORY_MEDIA_ROLES = ['thumbnail', 'hero_desktop', 'hero_mobile', 'card_portrait', 'social_share'] as const;
|
||||
type CategoryMediaRole = typeof CATEGORY_MEDIA_ROLES[number];
|
||||
type CategoryMediaInputData = {
|
||||
role: CategoryMediaRole;
|
||||
url: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number;
|
||||
focalY?: number;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
const categoryMediaInput = t.Object({
|
||||
role: t.Union([
|
||||
t.Literal('thumbnail'),
|
||||
t.Literal('hero_desktop'),
|
||||
t.Literal('hero_mobile'),
|
||||
t.Literal('card_portrait'),
|
||||
t.Literal('social_share'),
|
||||
]),
|
||||
url: t.String({ minLength: 1 }),
|
||||
width: t.Optional(t.Union([t.Number({ minimum: 1 }), t.Null()])),
|
||||
height: t.Optional(t.Union([t.Number({ minimum: 1 }), t.Null()])),
|
||||
focalX: t.Optional(t.Number({ minimum: 0, maximum: 1 })),
|
||||
focalY: t.Optional(t.Number({ minimum: 0, maximum: 1 })),
|
||||
sortOrder: t.Optional(t.Number({ minimum: 0 })),
|
||||
});
|
||||
|
||||
function normalizeCategoryMedia(items: CategoryMediaInputData[]) {
|
||||
const seen = new Set<string>();
|
||||
return items.map((item, index) => {
|
||||
if (!CATEGORY_MEDIA_ROLES.includes(item.role)) throw new Error(`Unsupported category media role: ${item.role}`);
|
||||
if (typeof item.url !== 'string' || item.url.trim().length === 0) throw new Error(`Category media ${item.role} requires a URL`);
|
||||
if (seen.has(item.role)) throw new Error(`Duplicate category media role: ${item.role}`);
|
||||
seen.add(item.role);
|
||||
return {
|
||||
role: item.role,
|
||||
url: item.url.trim(),
|
||||
width: item.width ?? null,
|
||||
height: item.height ?? null,
|
||||
focalX: item.focalX ?? 0.5,
|
||||
focalY: item.focalY ?? 0.5,
|
||||
sortOrder: item.sortOrder ?? index,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function replaceCategoryMediaWithClient(
|
||||
tx: Prisma.TransactionClient,
|
||||
categoryId: number,
|
||||
media: ReturnType<typeof normalizeCategoryMedia>,
|
||||
) {
|
||||
const thumbnailUrl = media.find((item) => item.role === 'thumbnail')?.url ?? null;
|
||||
|
||||
await tx.categoryMedia.deleteMany({ where: { categoryId } });
|
||||
if (media.length > 0) {
|
||||
await tx.categoryMedia.createMany({
|
||||
data: media.map((item) => ({ ...item, categoryId })),
|
||||
});
|
||||
}
|
||||
await tx.category.update({ where: { id: categoryId }, data: { imageUrl: thumbnailUrl } });
|
||||
}
|
||||
|
||||
async function replaceCategoryMedia(categoryId: number, items: CategoryMediaInputData[]) {
|
||||
const media = normalizeCategoryMedia(items);
|
||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await replaceCategoryMediaWithClient(tx, categoryId, media);
|
||||
});
|
||||
}
|
||||
|
||||
async function syncLegacyThumbnail(categoryId: number, imageUrl: string | null) {
|
||||
if (imageUrl) {
|
||||
await prisma.categoryMedia.upsert({
|
||||
where: { categoryId_role: { categoryId, role: 'thumbnail' } },
|
||||
update: { url: imageUrl },
|
||||
create: { categoryId, role: 'thumbnail', url: imageUrl },
|
||||
});
|
||||
} else {
|
||||
await prisma.categoryMedia.deleteMany({ where: { categoryId, role: 'thumbnail' } });
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve category name based on lang */
|
||||
function resolveCatName(cat: any, lang: string): any {
|
||||
@@ -24,6 +106,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
translations: true,
|
||||
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
|
||||
_count: {
|
||||
select: { products: { where: { isDeleted: false } } },
|
||||
},
|
||||
@@ -49,7 +132,9 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
|
||||
for (const item of list) {
|
||||
const baseName = item.name || item.translations?.th || item.translations?.en || 'Unnamed Category';
|
||||
const imageUrl = item.imageUrl || null;
|
||||
const hasLegacyImage = Object.prototype.hasOwnProperty.call(item, 'imageUrl');
|
||||
const imageUrl = hasLegacyImage ? (item.imageUrl || null) : undefined;
|
||||
const media = Array.isArray(item.media) ? item.media : undefined;
|
||||
const translations = item.translations || {};
|
||||
|
||||
let category: any = null;
|
||||
@@ -69,7 +154,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
if (category) {
|
||||
// Update existing
|
||||
const updateData: any = {};
|
||||
if (imageUrl !== undefined) updateData.imageUrl = imageUrl;
|
||||
if (imageUrl !== undefined && media === undefined) updateData.imageUrl = imageUrl;
|
||||
if (item.name) updateData.name = item.name;
|
||||
if (item.layoutType) updateData.layoutType = item.layoutType;
|
||||
|
||||
@@ -85,13 +170,19 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
category = await prisma.category.create({
|
||||
data: {
|
||||
name: baseName,
|
||||
imageUrl,
|
||||
imageUrl: media === undefined ? (imageUrl ?? null) : null,
|
||||
layoutType: item.layoutType || 'split_showcase',
|
||||
},
|
||||
});
|
||||
createdCount++;
|
||||
}
|
||||
|
||||
if (media !== undefined) {
|
||||
await replaceCategoryMedia(category.id, media);
|
||||
} else if (imageUrl !== undefined) {
|
||||
await syncLegacyThumbnail(category.id, imageUrl);
|
||||
}
|
||||
|
||||
// Upsert translations
|
||||
for (const lang of LANGS) {
|
||||
let transName = typeof translations[lang] === 'string'
|
||||
@@ -118,7 +209,11 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
|
||||
const fullCategory = await prisma.category.findUnique({
|
||||
where: { id: category.id },
|
||||
include: { translations: true, _count: { select: { products: { where: { isDeleted: false } } } } },
|
||||
include: {
|
||||
translations: true,
|
||||
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
|
||||
_count: { select: { products: { where: { isDeleted: false } } } },
|
||||
},
|
||||
});
|
||||
|
||||
results.push(fullCategory);
|
||||
@@ -145,6 +240,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
where: { id, deletedAt: null },
|
||||
include: {
|
||||
translations: true,
|
||||
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
|
||||
_count: {
|
||||
select: { products: { where: { isDeleted: false } } },
|
||||
},
|
||||
@@ -173,11 +269,12 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
where: { id, deletedAt: null },
|
||||
include: {
|
||||
translations: true,
|
||||
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
|
||||
products: {
|
||||
where: { isDeleted: false },
|
||||
include: {
|
||||
brand: true,
|
||||
category: { include: { translations: true } },
|
||||
category: { include: { translations: true, media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } } },
|
||||
translations: true,
|
||||
variants: { orderBy: { price: "asc" as const } },
|
||||
images: { orderBy: { order: "asc" as const } },
|
||||
@@ -201,6 +298,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
id: category.id,
|
||||
name: resolveCatName(category, lang),
|
||||
imageUrl: category.imageUrl,
|
||||
media: category.media,
|
||||
layoutType: category.layoutType || 'split_showcase',
|
||||
_count: category._count,
|
||||
},
|
||||
@@ -282,7 +380,11 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
|
||||
const updated = await prisma.category.findUnique({
|
||||
where: { id },
|
||||
include: { translations: true, _count: { select: { products: { where: { isDeleted: false } } } } },
|
||||
include: {
|
||||
translations: true,
|
||||
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
|
||||
_count: { select: { products: { where: { isDeleted: false } } } },
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, translations: results, category: updated };
|
||||
@@ -296,11 +398,21 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
|
||||
|
||||
try {
|
||||
const category = await prisma.category.update({
|
||||
const category = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
const updated = await tx.category.update({
|
||||
where: { id, deletedAt: null },
|
||||
data: {
|
||||
imageUrl: body.imageUrl,
|
||||
},
|
||||
data: { imageUrl: body.imageUrl },
|
||||
});
|
||||
if (body.imageUrl) {
|
||||
await tx.categoryMedia.upsert({
|
||||
where: { categoryId_role: { categoryId: id, role: 'thumbnail' } },
|
||||
update: { url: body.imageUrl },
|
||||
create: { categoryId: id, role: 'thumbnail', url: body.imageUrl },
|
||||
});
|
||||
} else {
|
||||
await tx.categoryMedia.deleteMany({ where: { categoryId: id, role: 'thumbnail' } });
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
return category;
|
||||
} catch (err: any) {
|
||||
@@ -315,18 +427,52 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
imageUrl: t.Union([t.String(), t.Null()]),
|
||||
})
|
||||
})
|
||||
.put("/:id/media", async ({ params, body, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
|
||||
|
||||
const category = await prisma.category.findFirst({ where: { id, deletedAt: null } });
|
||||
if (!category) return status(404, { message: "Category not found" });
|
||||
|
||||
try {
|
||||
await replaceCategoryMedia(id, body.media);
|
||||
const updated = await prisma.category.findUnique({
|
||||
where: { id },
|
||||
include: { media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } },
|
||||
});
|
||||
return { success: true, category: updated };
|
||||
} catch (err: any) {
|
||||
return status(400, { message: err?.message || "Failed to update category media" });
|
||||
}
|
||||
}, {
|
||||
body: t.Object({ media: t.Array(categoryMediaInput, { maxItems: CATEGORY_MEDIA_ROLES.length }) }),
|
||||
})
|
||||
.post("/", async ({ body, status }) => {
|
||||
try {
|
||||
const category = await prisma.category.create({
|
||||
const normalizedMedia = body.media === undefined ? null : normalizeCategoryMedia(body.media);
|
||||
const category = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
const created = await tx.category.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
imageUrl: body.imageUrl,
|
||||
imageUrl: normalizedMedia === null ? (body.imageUrl ?? null) : null,
|
||||
layoutType: body.layoutType || 'split_showcase',
|
||||
// Auto-seed Thai translation
|
||||
translations: { create: { langCode: 'th', name: body.name } },
|
||||
},
|
||||
});
|
||||
return category;
|
||||
if (normalizedMedia !== null) {
|
||||
await replaceCategoryMediaWithClient(tx, created.id, normalizedMedia);
|
||||
} else if (body.imageUrl) {
|
||||
await tx.categoryMedia.create({
|
||||
data: { categoryId: created.id, role: 'thumbnail', url: body.imageUrl },
|
||||
});
|
||||
}
|
||||
return created;
|
||||
});
|
||||
return prisma.category.findUnique({
|
||||
where: { id: category.id },
|
||||
include: { media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } },
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.code === "P2002") {
|
||||
return status(400, { message: "Category name already exists" });
|
||||
@@ -338,6 +484,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
name: t.String(),
|
||||
imageUrl: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
layoutType: t.Optional(t.String()),
|
||||
media: t.Optional(t.Array(categoryMediaInput, { maxItems: CATEGORY_MEDIA_ROLES.length })),
|
||||
})
|
||||
})
|
||||
.put("/:id", async ({ params, body, status }) => {
|
||||
@@ -345,16 +492,35 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
|
||||
|
||||
try {
|
||||
const normalizedMedia = body.media === undefined ? null : normalizeCategoryMedia(body.media);
|
||||
const updateData: any = {};
|
||||
if (body.name !== undefined) updateData.name = body.name;
|
||||
if (body.imageUrl !== undefined) updateData.imageUrl = body.imageUrl;
|
||||
if (body.imageUrl !== undefined && normalizedMedia === null) updateData.imageUrl = body.imageUrl;
|
||||
if (body.layoutType !== undefined) updateData.layoutType = body.layoutType;
|
||||
|
||||
const category = await prisma.category.update({
|
||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await tx.category.update({
|
||||
where: { id, deletedAt: null },
|
||||
data: updateData,
|
||||
});
|
||||
return category;
|
||||
if (normalizedMedia !== null) {
|
||||
await replaceCategoryMediaWithClient(tx, id, normalizedMedia);
|
||||
} else if (body.imageUrl !== undefined) {
|
||||
if (body.imageUrl) {
|
||||
await tx.categoryMedia.upsert({
|
||||
where: { categoryId_role: { categoryId: id, role: 'thumbnail' } },
|
||||
update: { url: body.imageUrl },
|
||||
create: { categoryId: id, role: 'thumbnail', url: body.imageUrl },
|
||||
});
|
||||
} else {
|
||||
await tx.categoryMedia.deleteMany({ where: { categoryId: id, role: 'thumbnail' } });
|
||||
}
|
||||
}
|
||||
});
|
||||
return prisma.category.findUnique({
|
||||
where: { id },
|
||||
include: { media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } },
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.code === "P2025") {
|
||||
return status(404, { message: "Category not found" });
|
||||
@@ -370,6 +536,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
name: t.Optional(t.String()),
|
||||
imageUrl: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
layoutType: t.Optional(t.String()),
|
||||
media: t.Optional(t.Array(categoryMediaInput, { maxItems: CATEGORY_MEDIA_ROLES.length })),
|
||||
})
|
||||
})
|
||||
.delete("/:id", async ({ params, status }) => {
|
||||
|
||||
@@ -129,11 +129,16 @@ th, en, ja, zh, vi, ko, lo, it
|
||||
LLM agents should use this workflow for category edits:
|
||||
|
||||
1. `list_categories` with `lang: "all"` to discover category IDs and current translations
|
||||
2. `create_category`, `update_category`, `update_category_translations`, or `delete_category` with `dryRun: true`
|
||||
2. `create_category`, `update_category`, `update_category_media`, `update_category_translations`, or `delete_category` with `dryRun: true`
|
||||
3. Review the preview and product counts
|
||||
4. Apply only after the intended change is clear
|
||||
5. Run `list_categories` again to verify
|
||||
|
||||
Category media uses semantic roles: `thumbnail`, `hero_desktop`, `hero_mobile`,
|
||||
`card_portrait`, and `social_share`. `update_category_media` replaces the complete
|
||||
media set, so omitted roles are removed. `update_category_image` remains available
|
||||
only as a legacy thumbnail compatibility tool.
|
||||
|
||||
Backend category support currently covers:
|
||||
|
||||
- base `name`
|
||||
|
||||
@@ -104,11 +104,31 @@ function compactCategory(category: any) {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
imageUrl: category.imageUrl ?? null,
|
||||
media: category.media ?? [],
|
||||
layoutType: category.layoutType ?? undefined,
|
||||
productCount: category._count?.products ?? category.productCount ?? undefined,
|
||||
translations: category.translations ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const categoryMediaSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
role: {
|
||||
type: "string",
|
||||
enum: ["thumbnail", "hero_desktop", "hero_mobile", "card_portrait", "social_share"],
|
||||
description: "Semantic image usage; do not encode a layout name in this field."
|
||||
},
|
||||
url: { type: "string", description: "Image URL or website-relative media path." },
|
||||
width: { type: ["number", "null"], description: "Optional intrinsic width in pixels." },
|
||||
height: { type: ["number", "null"], description: "Optional intrinsic height in pixels." },
|
||||
focalX: { type: "number", minimum: 0, maximum: 1, description: "Horizontal focal point from 0 to 1." },
|
||||
focalY: { type: "number", minimum: 0, maximum: 1, description: "Vertical focal point from 0 to 1." },
|
||||
sortOrder: { type: "number", minimum: 0 }
|
||||
},
|
||||
required: ["role", "url"]
|
||||
};
|
||||
|
||||
function compactProduct(product: any) {
|
||||
if (!product) return null;
|
||||
const images = Array.isArray(product.images) ? product.images : [];
|
||||
@@ -211,12 +231,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
},
|
||||
{
|
||||
name: "create_category",
|
||||
description: "Create a product category. Backend supports base name, imageUrl, and translations. It does not support slug, hierarchy, sort order, or active state for categories.",
|
||||
description: "Create a product category with semantic responsive media and translations. imageUrl remains a legacy thumbnail fallback.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Base category name. Use Thai or the primary display name." },
|
||||
imageUrl: { type: ["string", "null"], description: "Optional image URL." },
|
||||
layoutType: { type: "string", enum: ["split_showcase", "editorial_hero", "dense_catalog"] },
|
||||
media: { type: "array", items: categoryMediaSchema, maxItems: 5 },
|
||||
translations: {
|
||||
type: "object",
|
||||
description: "Optional locale map for category names. Supported locales: th, en, ja, zh, vi, ko, lo, it."
|
||||
@@ -228,13 +250,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
},
|
||||
{
|
||||
name: "update_category",
|
||||
description: "Update category base name, image URL, and/or translations. Use dryRun first. Backend supports name and imageUrl only for base category fields.",
|
||||
description: "Update category base fields, layout, semantic responsive media, and/or translations. Use dryRun first.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", description: "Category ID" },
|
||||
name: { type: "string", description: "Optional base category name." },
|
||||
imageUrl: { type: ["string", "null"], description: "Optional image URL. Pass null to remove." },
|
||||
layoutType: { type: "string", enum: ["split_showcase", "editorial_hero", "dense_catalog"] },
|
||||
media: { type: "array", items: categoryMediaSchema, maxItems: 5 },
|
||||
translations: {
|
||||
type: "object",
|
||||
description: "Optional locale map for category names. Supported locales: th, en, ja, zh, vi, ko, lo, it."
|
||||
@@ -324,9 +348,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
required: ["id", "translations"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_category_media",
|
||||
description: "Replace the complete semantic media set for a category. Omitted roles are removed. Use dryRun first.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", description: "Category ID" },
|
||||
media: { type: "array", items: categoryMediaSchema, maxItems: 5 },
|
||||
dryRun: { type: "boolean", description: "Preview only. No media is changed." }
|
||||
},
|
||||
required: ["id", "media"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_category_image",
|
||||
description: "Update the image URL for a specific category.",
|
||||
description: "Legacy compatibility tool. Update the category thumbnail only. Prefer update_category_media for responsive assets.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -1145,13 +1182,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "create_category": {
|
||||
const { name, imageUrl = null, translations, dryRun } = params as any;
|
||||
const { name, imageUrl = null, layoutType, media, translations, dryRun } = params as any;
|
||||
if (typeof name !== "string" || name.trim().length === 0) {
|
||||
throw new Error("name must be a non-empty string.");
|
||||
}
|
||||
if (translations !== undefined) validateCategoryLocaleMap(translations);
|
||||
|
||||
const payload = { name: name.trim(), imageUrl };
|
||||
const payload = { name: name.trim(), imageUrl, layoutType, media };
|
||||
if (dryRun) {
|
||||
return {
|
||||
content: [{
|
||||
@@ -1164,7 +1201,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
create: payload,
|
||||
translations: translations ?? null,
|
||||
backendSupport: {
|
||||
fields: ["name", "imageUrl", "translations"],
|
||||
fields: ["name", "imageUrl", "layoutType", "media", "translations"],
|
||||
unsupportedFields: ["slug", "parentId", "sortOrder", "isActive"],
|
||||
},
|
||||
},
|
||||
@@ -1193,7 +1230,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
};
|
||||
}
|
||||
case "update_category": {
|
||||
const { id, name, imageUrl, translations, dryRun } = params as any;
|
||||
const { id, name, imageUrl, layoutType, media, translations, dryRun } = params as any;
|
||||
if (name !== undefined && (typeof name !== "string" || name.trim().length === 0)) {
|
||||
throw new Error("name must be a non-empty string when provided.");
|
||||
}
|
||||
@@ -1210,6 +1247,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const baseUpdate: any = {};
|
||||
if (name !== undefined) baseUpdate.name = name.trim();
|
||||
if (imageUrl !== undefined) baseUpdate.imageUrl = imageUrl;
|
||||
if (layoutType !== undefined) baseUpdate.layoutType = layoutType;
|
||||
if (media !== undefined) baseUpdate.media = media;
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
@@ -1225,7 +1264,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
baseUpdate,
|
||||
translations: translations ?? null,
|
||||
backendSupport: {
|
||||
fields: ["name", "imageUrl", "translations"],
|
||||
fields: ["name", "imageUrl", "layoutType", "media", "translations"],
|
||||
unsupportedFields: ["slug", "parentId", "sortOrder", "isActive"],
|
||||
},
|
||||
},
|
||||
@@ -1392,6 +1431,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }]
|
||||
};
|
||||
}
|
||||
case "update_category_media": {
|
||||
const { id, media, dryRun } = params as any;
|
||||
if (!Array.isArray(media)) throw new Error("media must be an array.");
|
||||
if (dryRun) {
|
||||
const before = await api.get(`/categories/${id}?lang=all`);
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
dryRun: true,
|
||||
operation: "update_category_media",
|
||||
categoryId: id,
|
||||
before: compactCategory(before.data),
|
||||
media,
|
||||
note: "This is a full replacement; omitted roles will be removed."
|
||||
}, null, 2)
|
||||
}]
|
||||
};
|
||||
}
|
||||
const res = await api.put(`/categories/${id}/media`, { media });
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "update_product_brand_translation": {
|
||||
const { id, lang, name } = params as any;
|
||||
const res = await api.patch(`/products/brands/${id}/translations`, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getCategoryImage, type Category, type CategoriesSectionPayload } from '@homaroma/types';
|
||||
import { getCategoryImage, resolveCategoryMediaUrl, type Category, type CategoriesSectionPayload } from '@homaroma/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function DynamicCategories({ payload, categories, loading }: Dyna
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-pop-bg/90 via-pop-bg/40 to-transparent z-10 opacity-80 group-hover:opacity-100 transition-opacity" />
|
||||
<img
|
||||
src={cat.imageUrl || getCategoryImage(getLocalizedField(cat.name, lang))}
|
||||
src={resolveCategoryMediaUrl(cat, 'card_portrait', getCategoryImage(getLocalizedField(cat.name, lang)))}
|
||||
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
alt={getLocalizedField(cat.name, lang)}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
|
||||
@@ -5,7 +5,7 @@ import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import ProductCard from '../components/ProductCard'
|
||||
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||
import { API_BASE, getCategoryImage, type Category, type Product, type PageSection } from '../types'
|
||||
import { API_BASE, getCategoryImage, getCategoryMedia, resolveCategoryMediaUrl, type Category, type Product, type PageSection } from '../types'
|
||||
import { getLocalizedField } from '../utils/localization'
|
||||
import { Sparkles, Layers, Grid3X3, ArrowRight } from 'lucide-react'
|
||||
|
||||
@@ -101,7 +101,13 @@ export default function CategoryPage() {
|
||||
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER')
|
||||
|
||||
const categoryName = getLocalizedField(category?.name, lang)
|
||||
const categoryImageUrl = category?.imageUrl || (categoryName ? getCategoryImage(categoryName) : '')
|
||||
const fallbackImageUrl = categoryName ? getCategoryImage(categoryName) : ''
|
||||
const categoryThumbnailUrl = resolveCategoryMediaUrl(category, 'thumbnail', fallbackImageUrl)
|
||||
const heroDesktopUrl = resolveCategoryMediaUrl(category, 'hero_desktop', fallbackImageUrl)
|
||||
const heroMobileUrl = resolveCategoryMediaUrl(category, 'hero_mobile', heroDesktopUrl)
|
||||
const heroDesktopMedia = getCategoryMedia(category, 'hero_desktop')
|
||||
const heroMobileMedia = getCategoryMedia(category, 'hero_mobile')
|
||||
const hasArtDirectedHero = Boolean(category?.media?.some((item) => item.role === 'hero_desktop' || item.role === 'hero_mobile'))
|
||||
const layoutType = category?.layoutType || 'split_showcase'
|
||||
|
||||
// Determine Product Grid Class based on layout archetype
|
||||
@@ -130,7 +136,7 @@ export default function CategoryPage() {
|
||||
<Navbar categories={categories} />
|
||||
|
||||
{/* Hero / Banner Area */}
|
||||
{heroSections.length > 0 ? (
|
||||
{heroSections.length > 0 && !(layoutType === 'editorial_hero' && hasArtDirectedHero) ? (
|
||||
<div className="pt-20">
|
||||
<DynamicPageRenderer
|
||||
sections={heroSections}
|
||||
@@ -142,12 +148,19 @@ export default function CategoryPage() {
|
||||
/* ── Archetype 2: Editorial Hero Layout ── */
|
||||
<section className="relative overflow-hidden min-h-[380px] md:min-h-[460px] flex items-center justify-center border-b border-white/10 bg-night-950">
|
||||
{/* Immersive Background Image with Dark Gradient Overlays */}
|
||||
{categoryImageUrl && (
|
||||
{heroDesktopUrl && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img
|
||||
src={categoryImageUrl}
|
||||
src={heroDesktopUrl}
|
||||
alt={categoryName}
|
||||
className="w-full h-full object-cover object-center filter brightness-[0.45] saturate-125 scale-105 transform transition-transform duration-1000"
|
||||
className="hidden h-full w-full object-cover filter brightness-[0.45] saturate-125 scale-105 transform transition-transform duration-1000 md:block"
|
||||
style={{ objectPosition: `${(heroDesktopMedia?.focalX ?? 0.5) * 100}% ${(heroDesktopMedia?.focalY ?? 0.5) * 100}%` }}
|
||||
/>
|
||||
<img
|
||||
src={heroMobileUrl}
|
||||
alt={categoryName}
|
||||
className="h-full w-full object-cover filter brightness-[0.45] saturate-125 scale-105 transform transition-transform duration-1000 md:hidden"
|
||||
style={{ objectPosition: `${(heroMobileMedia?.focalX ?? 0.5) * 100}% ${(heroMobileMedia?.focalY ?? 0.5) * 100}%` }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-night-950 via-night-950/70 to-night-950/40" />
|
||||
<div className="absolute inset-0 bg-radial-gradient from-transparent via-night-950/40 to-night-950/90" />
|
||||
@@ -257,10 +270,10 @@ export default function CategoryPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{categoryImageUrl && (
|
||||
{categoryThumbnailUrl && (
|
||||
<div className="relative w-48 h-48 md:w-64 md:h-64 shrink-0 rounded-3xl overflow-hidden shadow-2xl border border-white/10 bg-night-950 group">
|
||||
<img
|
||||
src={categoryImageUrl}
|
||||
src={categoryThumbnailUrl}
|
||||
alt={categoryName}
|
||||
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
import { generateSeoMetadata } from '@/lib/seo'
|
||||
import type { Category, Product } from '@homaroma/types'
|
||||
import { getCategoryImage } from '@homaroma/types'
|
||||
import { getCategoryImage, getCategoryMedia, resolveCategoryMediaUrl } from '@homaroma/types'
|
||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||
import ProductCard from '@/components/ProductCard'
|
||||
import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
|
||||
@@ -37,18 +37,21 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
|
||||
: (brandName ? `Shop ${brandName} category products.` : 'Shop category products.')
|
||||
|
||||
const category = result.ok ? result.data.category : undefined
|
||||
const categoryImage = category?.imageUrl || (categoryName ? getCategoryImage(categoryName) : undefined) || settings?.defaultOgImage
|
||||
const categoryImage = category
|
||||
? resolveCategoryMediaUrl(category, 'social_share', categoryName ? getCategoryImage(categoryName) : '')
|
||||
: undefined
|
||||
const socialImage = categoryImage || settings?.defaultOgImage
|
||||
|
||||
return generateSeoMetadata({
|
||||
title: categoryName ? `${categoryName}` : 'Category',
|
||||
description,
|
||||
path: `/category/${id}`,
|
||||
lang,
|
||||
openGraph: categoryImage
|
||||
openGraph: socialImage
|
||||
? {
|
||||
title: categoryName || undefined,
|
||||
description,
|
||||
images: [categoryImage],
|
||||
images: [socialImage],
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
@@ -93,7 +96,13 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
|
||||
const category = categoryProductsResult.data.category
|
||||
const products = categoryProductsResult.data.data ?? []
|
||||
const categoryName = resolveLocalized(category.name, lang)
|
||||
const categoryImageUrl = category.imageUrl || getCategoryImage(categoryName)
|
||||
const fallbackImageUrl = getCategoryImage(categoryName)
|
||||
const categoryThumbnailUrl = resolveCategoryMediaUrl(category, 'thumbnail', fallbackImageUrl)
|
||||
const heroDesktopUrl = resolveCategoryMediaUrl(category, 'hero_desktop', fallbackImageUrl)
|
||||
const heroMobileUrl = resolveCategoryMediaUrl(category, 'hero_mobile', heroDesktopUrl)
|
||||
const heroDesktopMedia = getCategoryMedia(category, 'hero_desktop')
|
||||
const heroMobileMedia = getCategoryMedia(category, 'hero_mobile')
|
||||
const hasArtDirectedHero = Boolean(category.media?.some((item) => item.role === 'hero_desktop' || item.role === 'hero_mobile'))
|
||||
const layoutType = category.layoutType || 'split_showcase'
|
||||
|
||||
// Determine Product Grid Class based on layout archetype
|
||||
@@ -114,19 +123,30 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
|
||||
{announcementSections.length > 0 && (
|
||||
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
|
||||
)}
|
||||
{heroSections.length > 0 ? (
|
||||
{heroSections.length > 0 && !(layoutType === 'editorial_hero' && hasArtDirectedHero) ? (
|
||||
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
|
||||
) : layoutType === 'editorial_hero' ? (
|
||||
/* ── Archetype 2: Editorial Hero Layout ── */
|
||||
<section className="relative overflow-hidden min-h-[380px] md:min-h-[460px] flex items-center justify-center border-b border-line-primary/10 bg-black">
|
||||
{categoryImageUrl && (
|
||||
{heroDesktopUrl && (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src={categoryImageUrl}
|
||||
src={heroDesktopUrl}
|
||||
alt={categoryName}
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover object-center filter brightness-[0.45] saturate-125 scale-105"
|
||||
sizes="(min-width: 768px) 100vw, 1px"
|
||||
className="hidden object-cover filter brightness-[0.45] saturate-125 scale-105 md:block"
|
||||
style={{ objectPosition: `${(heroDesktopMedia?.focalX ?? 0.5) * 100}% ${(heroDesktopMedia?.focalY ?? 0.5) * 100}%` }}
|
||||
/>
|
||||
<Image
|
||||
src={heroMobileUrl}
|
||||
alt={categoryName}
|
||||
fill
|
||||
unoptimized
|
||||
sizes="(max-width: 767px) 100vw, 1px"
|
||||
className="object-cover filter brightness-[0.45] saturate-125 scale-105 md:hidden"
|
||||
style={{ objectPosition: `${(heroMobileMedia?.focalX ?? 0.5) * 100}% ${(heroMobileMedia?.focalY ?? 0.5) * 100}%` }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/70 to-black/40" />
|
||||
</div>
|
||||
@@ -201,7 +221,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
|
||||
</div>
|
||||
<div className="relative w-48 h-48 md:w-72 md:h-72 shrink-0 rounded-3xl overflow-hidden shadow-lg border border-line-primary/5">
|
||||
<Image
|
||||
src={categoryImageUrl}
|
||||
src={categoryThumbnailUrl}
|
||||
alt={categoryName}
|
||||
fill
|
||||
unoptimized
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from '../LocalizedLink'
|
||||
import { Clock, Mail, MapPin, MessageCircle, Send, ChevronLeft, ChevronRight, Leaf, Droplet, Sparkles, Gift, CheckCircle, ShieldCheck, Heart, Download, FileText } from 'lucide-react'
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import type { Category, PageSection, Product } from '@homaroma/types'
|
||||
import { getCategoryImage } from '@homaroma/types'
|
||||
import { getCategoryImage, resolveCategoryMediaUrl } from '@homaroma/types'
|
||||
import ProductCard from '../ProductCard'
|
||||
import { localizedFromRecord, resolveCategoryName, resolveLocalized, toRecord } from '../../lib/localization'
|
||||
import { useLang } from '../../hooks/useLang'
|
||||
@@ -1003,7 +1003,7 @@ function CategoriesSection({
|
||||
className="group relative h-72 overflow-hidden rounded-2xl shadow-card transition hover:-translate-y-1 hover:shadow-card-hover"
|
||||
>
|
||||
<Image
|
||||
src={category.imageUrl || getCategoryImage(name)}
|
||||
src={resolveCategoryMediaUrl(category, 'card_portrait', getCategoryImage(name))}
|
||||
alt={name}
|
||||
fill
|
||||
unoptimized
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# DBK Platform Category Media Guide
|
||||
|
||||
Categories support art-directed media for multiple storefront layouts. Media is
|
||||
stored by semantic usage rather than by a layout name, allowing future layouts to
|
||||
reuse the same contract.
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Recommended ratio | Recommended source size | Primary usage |
|
||||
| --- | ---: | ---: | --- |
|
||||
| `thumbnail` | 1:1 | 1200 × 1200 px | Category lists, square showcases, product fallback |
|
||||
| `hero_desktop` | 21:9 | 2400 × 1028 px | Wide desktop hero |
|
||||
| `hero_mobile` | 4:5 | 1200 × 1500 px | Mobile and LIFF hero |
|
||||
| `card_portrait` | 4:5 | 1200 × 1500 px | Editorial and collection cards |
|
||||
| `social_share` | 1.91:1 | 1200 × 630 px | Open Graph and social sharing |
|
||||
|
||||
`thumbnail` is required for every category. `editorial_hero` should also provide
|
||||
both hero roles. Other roles are optional and use deterministic fallbacks.
|
||||
|
||||
## Fallback order
|
||||
|
||||
```text
|
||||
thumbnail → thumbnail → legacy imageUrl → application fallback
|
||||
hero desktop → hero_desktop → thumbnail → legacy imageUrl
|
||||
hero mobile → hero_mobile → hero_desktop → thumbnail → legacy imageUrl
|
||||
card → card_portrait → thumbnail → legacy imageUrl
|
||||
social → social_share → hero_desktop → thumbnail → legacy imageUrl
|
||||
```
|
||||
|
||||
## API payload
|
||||
|
||||
```json
|
||||
{
|
||||
"media": [
|
||||
{
|
||||
"role": "thumbnail",
|
||||
"url": "/uploads/category-square.webp",
|
||||
"width": 1200,
|
||||
"height": 1200,
|
||||
"focalX": 0.5,
|
||||
"focalY": 0.5,
|
||||
"sortOrder": 0
|
||||
},
|
||||
{
|
||||
"role": "hero_desktop",
|
||||
"url": "/uploads/category-wide.webp",
|
||||
"width": 2400,
|
||||
"height": 1028,
|
||||
"focalX": 0.35,
|
||||
"focalY": 0.5,
|
||||
"sortOrder": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`PUT /categories/:id/media` performs a complete replacement. Each role may occur
|
||||
only once. Width and height are optional metadata; focal coordinates are normalized
|
||||
from `0` to `1`.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
`Category.imageUrl` remains during migration and mirrors the `thumbnail` role.
|
||||
Legacy clients may continue reading or updating it. New clients should read
|
||||
`Category.media` and resolve the semantic role appropriate to their UI.
|
||||
|
||||
For `editorial_hero`, an explicitly supplied category hero takes precedence over
|
||||
the generic `category-layout` CMS hero. Categories without art-directed hero media
|
||||
retain the existing CMS fallback behavior.
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { getCategoryMedia, resolveCategoryMediaUrl, type Category } from './index'
|
||||
|
||||
const category: Category = {
|
||||
id: 1,
|
||||
name: 'Example',
|
||||
imageUrl: '/legacy.jpg',
|
||||
media: [
|
||||
{ role: 'thumbnail', url: '/square.jpg' },
|
||||
{ role: 'hero_desktop', url: '/wide.jpg', focalX: 0.25, focalY: 0.75 },
|
||||
],
|
||||
}
|
||||
|
||||
describe('category media resolver', () => {
|
||||
test('selects the exact semantic role', () => {
|
||||
expect(resolveCategoryMediaUrl(category, 'hero_desktop')).toBe('/wide.jpg')
|
||||
expect(getCategoryMedia(category, 'hero_desktop')?.focalX).toBe(0.25)
|
||||
})
|
||||
|
||||
test('uses the documented role fallback order', () => {
|
||||
expect(resolveCategoryMediaUrl(category, 'hero_mobile')).toBe('/wide.jpg')
|
||||
expect(resolveCategoryMediaUrl(category, 'card_portrait')).toBe('/square.jpg')
|
||||
})
|
||||
|
||||
test('keeps the legacy imageUrl fallback for migrated clients', () => {
|
||||
expect(resolveCategoryMediaUrl({ imageUrl: '/legacy.jpg' }, 'thumbnail')).toBe('/legacy.jpg')
|
||||
})
|
||||
|
||||
test('uses the caller fallback when no media exists', () => {
|
||||
expect(resolveCategoryMediaUrl({}, 'social_share', '/default.jpg')).toBe('/default.jpg')
|
||||
})
|
||||
})
|
||||
@@ -6,10 +6,33 @@ export interface Brand {
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export const CATEGORY_MEDIA_ROLES = [
|
||||
'thumbnail',
|
||||
'hero_desktop',
|
||||
'hero_mobile',
|
||||
'card_portrait',
|
||||
'social_share',
|
||||
] as const
|
||||
|
||||
export type CategoryMediaRole = typeof CATEGORY_MEDIA_ROLES[number]
|
||||
|
||||
export interface CategoryMedia {
|
||||
id?: number
|
||||
categoryId?: number
|
||||
role: CategoryMediaRole
|
||||
url: string
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
focalX?: number | null
|
||||
focalY?: number | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number
|
||||
name: TranslatableString
|
||||
imageUrl?: string | null
|
||||
media?: CategoryMedia[]
|
||||
layoutType?: 'split_showcase' | 'editorial_hero' | 'dense_catalog' | string | null
|
||||
_count?: { products: number }
|
||||
translations?: { langCode: string; name: string }[]
|
||||
@@ -17,6 +40,34 @@ export interface Category {
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
const CATEGORY_MEDIA_FALLBACKS: Record<CategoryMediaRole, CategoryMediaRole[]> = {
|
||||
thumbnail: ['thumbnail'],
|
||||
hero_desktop: ['hero_desktop', 'thumbnail'],
|
||||
hero_mobile: ['hero_mobile', 'hero_desktop', 'thumbnail'],
|
||||
card_portrait: ['card_portrait', 'thumbnail'],
|
||||
social_share: ['social_share', 'hero_desktop', 'thumbnail'],
|
||||
}
|
||||
|
||||
export function getCategoryMedia(
|
||||
category: Pick<Category, 'media'> | null | undefined,
|
||||
role: CategoryMediaRole,
|
||||
): CategoryMedia | undefined {
|
||||
const media = Array.isArray(category?.media) ? category.media : []
|
||||
for (const candidateRole of CATEGORY_MEDIA_FALLBACKS[role]) {
|
||||
const candidate = media.find((item) => item.role === candidateRole && item.url?.trim())
|
||||
if (candidate) return candidate
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function resolveCategoryMediaUrl(
|
||||
category: Pick<Category, 'media' | 'imageUrl'> | null | undefined,
|
||||
role: CategoryMediaRole,
|
||||
fallbackUrl = '',
|
||||
): string {
|
||||
return getCategoryMedia(category, role)?.url || category?.imageUrl || fallbackUrl
|
||||
}
|
||||
|
||||
|
||||
export interface Variant {
|
||||
id: number
|
||||
|
||||
Reference in new issue
Block a user