This commit is contained in:
Your Name committed 2026-10-01 06:59:38 +07:00
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, id: cat.id,
name: catBaseName || transMap['th'] || 'Unnamed Category', name: catBaseName || transMap['th'] || 'Unnamed Category',
imageUrl: cat.imageUrl || null, imageUrl: cat.imageUrl || null,
media: cat.media || [],
layoutType: cat.layoutType || 'split_showcase', layoutType: cat.layoutType || 'split_showcase',
translations: transMap, translations: transMap,
}; };
@@ -89,8 +90,9 @@ export default function CategoryBulkJsonModal({
const aiTranslatePrompt = useMemo(() => { const aiTranslatePrompt = useMemo(() => {
const sampleItems = exportData.slice(0, 5); 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). 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 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: Return ONLY a valid JSON Array conforming to this structure:
\`\`\`json \`\`\`json
@@ -101,6 +103,11 @@ ${JSON.stringify(
{ {
name: 'น้ำมันหอมระเหย', name: 'น้ำมันหอมระเหย',
imageUrl: '/images/categories/essential-oils.jpg', 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', layoutType: 'split_showcase',
translations: { translations: {
th: 'น้ำมันหอมระเหย', th: 'น้ำมันหอมระเหย',
@@ -116,6 +123,11 @@ ${JSON.stringify(
{ {
name: 'ก้านไม้หอมปรับอากาศ', name: 'ก้านไม้หอมปรับอากาศ',
imageUrl: '/images/categories/reed-diffusers.jpg', 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', layoutType: 'editorial_hero',
translations: { translations: {
th: 'ก้านไม้หอมปรับอากาศ', th: 'ก้านไม้หอมปรับอากาศ',
@@ -169,6 +181,7 @@ ${JSON.stringify(
id: typeof item.id === 'number' ? item.id : undefined, id: typeof item.id === 'number' ? item.id : undefined,
name: baseName, name: baseName,
imageUrl: item.imageUrl || null, imageUrl: item.imageUrl || null,
media: Array.isArray(item.media) ? item.media : [],
layoutType: typeof item.layoutType === 'string' ? item.layoutType : 'split_showcase', layoutType: typeof item.layoutType === 'string' ? item.layoutType : 'split_showcase',
translations: trans, 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>
)
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next' 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 { import {
Edit3, Edit3,
Package, Package,
@@ -237,7 +237,7 @@ export default function CategoriesPage() {
) : ( ) : (
filteredCategories.map((cat) => { filteredCategories.map((cat) => {
const fallbackName = typeof cat.name === 'string' ? cat.name : (cat.name?.en || cat.name?.th || '') 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 langsCount = getAvailableLangsCount(cat)
const thaiName = getThaiName(cat) const thaiName = getThaiName(cat)
@@ -339,7 +339,7 @@ export default function CategoriesPage() {
) : ( ) : (
filteredCategories.map((cat) => { filteredCategories.map((cat) => {
const fallbackName = typeof cat.name === 'string' ? cat.name : (cat.name?.en || cat.name?.th || '') 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 thaiName = getThaiName(cat)
const englishName = getEnglishName(cat) const englishName = getEnglishName(cat)
+51 -90
View File
@@ -1,13 +1,11 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next' 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 { import {
ArrowLeft, ArrowLeft,
Save, Save,
Trash2, Trash2,
Image as ImageIcon,
UploadCloud,
Loader2, Loader2,
ClipboardPaste, ClipboardPaste,
Globe, Globe,
@@ -28,6 +26,7 @@ import {
Grid3X3 Grid3X3
} from 'lucide-react' } from 'lucide-react'
import MediaLibraryModal from '../components/MediaLibraryModal' import MediaLibraryModal from '../components/MediaLibraryModal'
import CategoryMediaEditor from '../components/CategoryMediaEditor'
const ADDITIONAL_LANGS = [ const ADDITIONAL_LANGS = [
{ code: 'ja', label: 'Japanese (日本語)', placeholder: '例: アロマキャンドル' }, { code: 'ja', label: 'Japanese (日本語)', placeholder: '例: アロマキャンドル' },
@@ -78,12 +77,14 @@ export default function CategoryEditPage() {
lo: '', lo: '',
it: '', it: '',
}) })
const [imageUrl, setImageUrl] = useState<string>('') const [media, setMedia] = useState<CategoryMedia[]>([])
const [layoutType, setLayoutType] = useState<string>('split_showcase') const [layoutType, setLayoutType] = useState<string>('split_showcase')
const [categoryMeta, setCategoryMeta] = useState<Category | null>(null) const [categoryMeta, setCategoryMeta] = useState<Category | null>(null)
// Media Library Modal // Media Library Modal
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false) const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [activeMediaRole, setActiveMediaRole] = useState<CategoryMediaRole>('thumbnail')
const [uploadingRole, setUploadingRole] = useState<CategoryMediaRole | null>(null)
useEffect(() => { useEffect(() => {
if (isNew) return if (isNew) return
@@ -94,7 +95,9 @@ export default function CategoryEditPage() {
if (!res.ok) throw new Error('Category not found') if (!res.ok) throw new Error('Category not found')
const data = await res.json() const data = await res.json()
setCategoryMeta(data) 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') setLayoutType(data.layoutType || 'split_showcase')
if (data.name && typeof data.name === 'object') { if (data.name && typeof data.name === 'object') {
@@ -223,8 +226,17 @@ export default function CategoryEditPage() {
} }
// ── Image Upload Handlers ───────────────────────────────────────────────── // ── 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) setIsUploading(true)
setUploadingRole(role)
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
@@ -235,17 +247,18 @@ export default function CategoryEditPage() {
}) })
const data = await res.json() const data = await res.json()
if (data.url) { if (data.url) {
setImageUrl(data.url) setMediaUrl(role, data.url)
} }
} catch (err) { } catch (err) {
console.error('Upload error:', err) console.error('Upload error:', err)
alert('Failed to upload image') alert('Failed to upload image')
} finally { } finally {
setIsUploading(false) setIsUploading(false)
setUploadingRole(null)
} }
} }
const handlePasteFromClipboard = async () => { const handlePasteFromClipboard = async (role: CategoryMediaRole) => {
try { try {
const items = await navigator.clipboard.read() const items = await navigator.clipboard.read()
for (const item of items) { for (const item of items) {
@@ -253,7 +266,7 @@ export default function CategoryEditPage() {
for (const type of imageTypes) { for (const type of imageTypes) {
const blob = await item.getType(type) const blob = await item.getType(type)
const file = new File([blob], 'pasted-category-image.png', { type }) const file = new File([blob], 'pasted-category-image.png', { type })
handleUpload(file) void handleUpload(file, role)
return return
} }
} }
@@ -266,6 +279,12 @@ export default function CategoryEditPage() {
const handleSave = async (e: React.FormEvent) => { const handleSave = async (e: React.FormEvent) => {
e.preventDefault() 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()) { if (!name.th?.trim()) {
alert('Please enter Thai category name') alert('Please enter Thai category name')
return return
@@ -283,8 +302,17 @@ export default function CategoryEditPage() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: masterName, name: masterName,
imageUrl: imageUrl || null, imageUrl: thumbnail.url,
layoutType: layoutType || 'split_showcase' 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 */} {/* Right Column (1 Col): Image & Metadata */}
<div className="space-y-6"> <div className="space-y-6">
{/* Category Image Card */} <CategoryMediaEditor
<div className="admin-card p-6 space-y-5"> media={media}
<div className="flex items-center justify-between border-b border-white/5 pb-4"> layoutType={layoutType}
<h2 className="text-base font-bold text-white">Category Image</h2> displayName={displayName}
<button uploadingRole={uploadingRole}
type="button" onChange={setMedia}
onClick={handlePasteFromClipboard} onBrowse={(role) => {
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" setActiveMediaRole(role)
title="Paste image directly from clipboard" setIsMediaModalOpen(true)
> }}
<ClipboardPaste className="w-3 h-3" /> onPaste={(role) => void handlePasteFromClipboard(role)}
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}
/> />
<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 */} {/* Quick Info & Tips Card */}
<div className="admin-card p-6 space-y-4"> <div className="admin-card p-6 space-y-4">
@@ -950,7 +911,7 @@ export default function CategoryEditPage() {
isOpen={isMediaModalOpen} isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)} onClose={() => setIsMediaModalOpen(false)}
onSelect={(url) => { onSelect={(url) => {
setImageUrl(url) setMediaUrl(activeMediaRole, url)
setIsMediaModalOpen(false) setIsMediaModalOpen(false)
}} }}
/> />
@@ -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;
@@ -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;
+19
View File
@@ -35,6 +35,7 @@ model Category {
name String @unique name String @unique
imageUrl String? imageUrl String?
layoutType String @default("split_showcase") layoutType String @default("split_showcase")
media CategoryMedia[]
products Product[] products Product[]
attributes AttributeDefinition[] attributes AttributeDefinition[]
translations CategoryTranslation[] translations CategoryTranslation[]
@@ -45,6 +46,24 @@ model Category {
@@index([deletedAt]) @@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 { model CategoryTranslation {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
categoryId Int categoryId Int
+23 -2
View File
@@ -115,11 +115,11 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
// full catalog // full catalog
const categories = await prisma.category.findMany({ const categories = await prisma.category.findMany({
where: { deletedAt: null }, where: { deletedAt: null },
include: { translations: true }, include: { translations: true, media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } },
orderBy: { id: "asc" }, orderBy: { id: "asc" },
}); });
const attributes = await prisma.attributeDefinition.findMany({ const attributes = await prisma.attributeDefinition.findMany({
include: { translations: true }, include: { translations: true, media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } },
orderBy: { sortOrder: "asc" }, orderBy: { sortOrder: "asc" },
}); });
const brands = await prisma.productBrand.findMany({ const brands = await prisma.productBrand.findMany({
@@ -846,11 +846,13 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
where: { name: cat.name }, where: { name: cat.name },
update: { update: {
imageUrl: cat.imageUrl, imageUrl: cat.imageUrl,
layoutType: cat.layoutType || 'split_showcase',
deletedAt: null, deletedAt: null,
}, },
create: { create: {
name: cat.name, name: cat.name,
imageUrl: cat.imageUrl, imageUrl: cat.imageUrl,
layoutType: cat.layoutType || 'split_showcase',
}, },
}); });
if (cat.id) categoryMap.set(cat.id, savedCat.id); 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++; summary.categories++;
} }
+183 -16
View File
@@ -1,7 +1,89 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']; 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 */ /** Resolve category name based on lang */
function resolveCatName(cat: any, lang: string): any { function resolveCatName(cat: any, lang: string): any {
@@ -24,6 +106,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
orderBy: { name: "asc" }, orderBy: { name: "asc" },
include: { include: {
translations: true, translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: { _count: {
select: { products: { where: { isDeleted: false } } }, select: { products: { where: { isDeleted: false } } },
}, },
@@ -49,7 +132,9 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
for (const item of list) { for (const item of list) {
const baseName = item.name || item.translations?.th || item.translations?.en || 'Unnamed Category'; 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 || {}; const translations = item.translations || {};
let category: any = null; let category: any = null;
@@ -69,7 +154,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
if (category) { if (category) {
// Update existing // Update existing
const updateData: any = {}; 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.name) updateData.name = item.name;
if (item.layoutType) updateData.layoutType = item.layoutType; if (item.layoutType) updateData.layoutType = item.layoutType;
@@ -85,13 +170,19 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
category = await prisma.category.create({ category = await prisma.category.create({
data: { data: {
name: baseName, name: baseName,
imageUrl, imageUrl: media === undefined ? (imageUrl ?? null) : null,
layoutType: item.layoutType || 'split_showcase', layoutType: item.layoutType || 'split_showcase',
}, },
}); });
createdCount++; createdCount++;
} }
if (media !== undefined) {
await replaceCategoryMedia(category.id, media);
} else if (imageUrl !== undefined) {
await syncLegacyThumbnail(category.id, imageUrl);
}
// Upsert translations // Upsert translations
for (const lang of LANGS) { for (const lang of LANGS) {
let transName = typeof translations[lang] === 'string' let transName = typeof translations[lang] === 'string'
@@ -118,7 +209,11 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
const fullCategory = await prisma.category.findUnique({ const fullCategory = await prisma.category.findUnique({
where: { id: category.id }, 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); results.push(fullCategory);
@@ -145,6 +240,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
where: { id, deletedAt: null }, where: { id, deletedAt: null },
include: { include: {
translations: true, translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: { _count: {
select: { products: { where: { isDeleted: false } } }, select: { products: { where: { isDeleted: false } } },
}, },
@@ -173,11 +269,12 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
where: { id, deletedAt: null }, where: { id, deletedAt: null },
include: { include: {
translations: true, translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
products: { products: {
where: { isDeleted: false }, where: { isDeleted: false },
include: { include: {
brand: true, brand: true,
category: { include: { translations: true } }, category: { include: { translations: true, media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } } },
translations: true, translations: true,
variants: { orderBy: { price: "asc" as const } }, variants: { orderBy: { price: "asc" as const } },
images: { orderBy: { order: "asc" as const } }, images: { orderBy: { order: "asc" as const } },
@@ -201,6 +298,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
id: category.id, id: category.id,
name: resolveCatName(category, lang), name: resolveCatName(category, lang),
imageUrl: category.imageUrl, imageUrl: category.imageUrl,
media: category.media,
layoutType: category.layoutType || 'split_showcase', layoutType: category.layoutType || 'split_showcase',
_count: category._count, _count: category._count,
}, },
@@ -282,7 +380,11 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
const updated = await prisma.category.findUnique({ const updated = await prisma.category.findUnique({
where: { id }, 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 }; 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" }); if (isNaN(id)) return status(400, { message: "Invalid category ID" });
try { 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 }, where: { id, deletedAt: null },
data: { data: { imageUrl: body.imageUrl },
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; return category;
} catch (err: any) { } catch (err: any) {
@@ -315,18 +427,52 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
imageUrl: t.Union([t.String(), t.Null()]), 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 }) => { .post("/", async ({ body, status }) => {
try { 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: { data: {
name: body.name, name: body.name,
imageUrl: body.imageUrl, imageUrl: normalizedMedia === null ? (body.imageUrl ?? null) : null,
layoutType: body.layoutType || 'split_showcase', layoutType: body.layoutType || 'split_showcase',
// Auto-seed Thai translation // Auto-seed Thai translation
translations: { create: { langCode: 'th', name: body.name } }, 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) { } catch (err: any) {
if (err.code === "P2002") { if (err.code === "P2002") {
return status(400, { message: "Category name already exists" }); return status(400, { message: "Category name already exists" });
@@ -338,6 +484,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
name: t.String(), name: t.String(),
imageUrl: t.Optional(t.Union([t.String(), t.Null()])), imageUrl: t.Optional(t.Union([t.String(), t.Null()])),
layoutType: t.Optional(t.String()), layoutType: t.Optional(t.String()),
media: t.Optional(t.Array(categoryMediaInput, { maxItems: CATEGORY_MEDIA_ROLES.length })),
}) })
}) })
.put("/:id", async ({ params, body, status }) => { .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" }); if (isNaN(id)) return status(400, { message: "Invalid category ID" });
try { try {
const normalizedMedia = body.media === undefined ? null : normalizeCategoryMedia(body.media);
const updateData: any = {}; const updateData: any = {};
if (body.name !== undefined) updateData.name = body.name; 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; 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 }, where: { id, deletedAt: null },
data: updateData, 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) { } catch (err: any) {
if (err.code === "P2025") { if (err.code === "P2025") {
return status(404, { message: "Category not found" }); return status(404, { message: "Category not found" });
@@ -370,6 +536,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
name: t.Optional(t.String()), name: t.Optional(t.String()),
imageUrl: t.Optional(t.Union([t.String(), t.Null()])), imageUrl: t.Optional(t.Union([t.String(), t.Null()])),
layoutType: t.Optional(t.String()), layoutType: t.Optional(t.String()),
media: t.Optional(t.Array(categoryMediaInput, { maxItems: CATEGORY_MEDIA_ROLES.length })),
}) })
}) })
.delete("/:id", async ({ params, status }) => { .delete("/:id", async ({ params, status }) => {
+6 -1
View File
@@ -129,11 +129,16 @@ th, en, ja, zh, vi, ko, lo, it
LLM agents should use this workflow for category edits: LLM agents should use this workflow for category edits:
1. `list_categories` with `lang: "all"` to discover category IDs and current translations 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 3. Review the preview and product counts
4. Apply only after the intended change is clear 4. Apply only after the intended change is clear
5. Run `list_categories` again to verify 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: Backend category support currently covers:
- base `name` - base `name`
+70 -8
View File
@@ -104,11 +104,31 @@ function compactCategory(category: any) {
id: category.id, id: category.id,
name: category.name, name: category.name,
imageUrl: category.imageUrl ?? null, imageUrl: category.imageUrl ?? null,
media: category.media ?? [],
layoutType: category.layoutType ?? undefined,
productCount: category._count?.products ?? category.productCount ?? undefined, productCount: category._count?.products ?? category.productCount ?? undefined,
translations: category.translations ?? 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) { function compactProduct(product: any) {
if (!product) return null; if (!product) return null;
const images = Array.isArray(product.images) ? product.images : []; const images = Array.isArray(product.images) ? product.images : [];
@@ -211,12 +231,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
}, },
{ {
name: "create_category", 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: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
name: { type: "string", description: "Base category name. Use Thai or the primary display name." }, name: { type: "string", description: "Base category name. Use Thai or the primary display name." },
imageUrl: { type: ["string", "null"], description: "Optional image URL." }, 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: { translations: {
type: "object", type: "object",
description: "Optional locale map for category names. Supported locales: th, en, ja, zh, vi, ko, lo, it." 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", 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: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
id: { type: "number", description: "Category ID" }, id: { type: "number", description: "Category ID" },
name: { type: "string", description: "Optional base category name." }, name: { type: "string", description: "Optional base category name." },
imageUrl: { type: ["string", "null"], description: "Optional image URL. Pass null to remove." }, 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: { translations: {
type: "object", type: "object",
description: "Optional locale map for category names. Supported locales: th, en, ja, zh, vi, ko, lo, it." 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"] 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", 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: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@@ -1145,13 +1182,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
} }
case "create_category": { 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) { if (typeof name !== "string" || name.trim().length === 0) {
throw new Error("name must be a non-empty string."); throw new Error("name must be a non-empty string.");
} }
if (translations !== undefined) validateCategoryLocaleMap(translations); if (translations !== undefined) validateCategoryLocaleMap(translations);
const payload = { name: name.trim(), imageUrl }; const payload = { name: name.trim(), imageUrl, layoutType, media };
if (dryRun) { if (dryRun) {
return { return {
content: [{ content: [{
@@ -1164,7 +1201,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
create: payload, create: payload,
translations: translations ?? null, translations: translations ?? null,
backendSupport: { backendSupport: {
fields: ["name", "imageUrl", "translations"], fields: ["name", "imageUrl", "layoutType", "media", "translations"],
unsupportedFields: ["slug", "parentId", "sortOrder", "isActive"], unsupportedFields: ["slug", "parentId", "sortOrder", "isActive"],
}, },
}, },
@@ -1193,7 +1230,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}; };
} }
case "update_category": { 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)) { if (name !== undefined && (typeof name !== "string" || name.trim().length === 0)) {
throw new Error("name must be a non-empty string when provided."); throw new Error("name must be a non-empty string when provided.");
} }
@@ -1210,6 +1247,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const baseUpdate: any = {}; const baseUpdate: any = {};
if (name !== undefined) baseUpdate.name = name.trim(); if (name !== undefined) baseUpdate.name = name.trim();
if (imageUrl !== undefined) baseUpdate.imageUrl = imageUrl; if (imageUrl !== undefined) baseUpdate.imageUrl = imageUrl;
if (layoutType !== undefined) baseUpdate.layoutType = layoutType;
if (media !== undefined) baseUpdate.media = media;
if (dryRun) { if (dryRun) {
return { return {
@@ -1225,7 +1264,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
baseUpdate, baseUpdate,
translations: translations ?? null, translations: translations ?? null,
backendSupport: { backendSupport: {
fields: ["name", "imageUrl", "translations"], fields: ["name", "imageUrl", "layoutType", "media", "translations"],
unsupportedFields: ["slug", "parentId", "sortOrder", "isActive"], unsupportedFields: ["slug", "parentId", "sortOrder", "isActive"],
}, },
}, },
@@ -1392,6 +1431,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] 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": { case "update_product_brand_translation": {
const { id, lang, name } = params as any; const { id, lang, name } = params as any;
const res = await api.patch(`/products/brands/${id}/translations`, { const res = await api.patch(`/products/brands/${id}/translations`, {
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; 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 { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization'; 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" /> <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 <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" className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
alt={getLocalizedField(cat.name, lang)} alt={getLocalizedField(cat.name, lang)}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
+21 -8
View File
@@ -5,7 +5,7 @@ import Navbar from '../components/Navbar'
import Footer from '../components/Footer' import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard' import ProductCard from '../components/ProductCard'
import DynamicPageRenderer from '../components/DynamicPageRenderer' 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 { getLocalizedField } from '../utils/localization'
import { Sparkles, Layers, Grid3X3, ArrowRight } from 'lucide-react' 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 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 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' const layoutType = category?.layoutType || 'split_showcase'
// Determine Product Grid Class based on layout archetype // Determine Product Grid Class based on layout archetype
@@ -130,7 +136,7 @@ export default function CategoryPage() {
<Navbar categories={categories} /> <Navbar categories={categories} />
{/* Hero / Banner Area */} {/* Hero / Banner Area */}
{heroSections.length > 0 ? ( {heroSections.length > 0 && !(layoutType === 'editorial_hero' && hasArtDirectedHero) ? (
<div className="pt-20"> <div className="pt-20">
<DynamicPageRenderer <DynamicPageRenderer
sections={heroSections} sections={heroSections}
@@ -142,12 +148,19 @@ export default function CategoryPage() {
/* ── Archetype 2: Editorial Hero Layout ── */ /* ── 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"> <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 */} {/* Immersive Background Image with Dark Gradient Overlays */}
{categoryImageUrl && ( {heroDesktopUrl && (
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
<img <img
src={categoryImageUrl} src={heroDesktopUrl}
alt={categoryName} 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-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" /> <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> </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"> <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 <img
src={categoryImageUrl} src={categoryThumbnailUrl}
alt={categoryName} alt={categoryName}
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" 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' }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
@@ -4,7 +4,7 @@ import { notFound } from 'next/navigation'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { generateSeoMetadata } from '@/lib/seo' import { generateSeoMetadata } from '@/lib/seo'
import type { Category, Product } from '@homaroma/types' 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 DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard' import ProductCard from '@/components/ProductCard'
import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms' 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.') : (brandName ? `Shop ${brandName} category products.` : 'Shop category products.')
const category = result.ok ? result.data.category : undefined 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({ return generateSeoMetadata({
title: categoryName ? `${categoryName}` : 'Category', title: categoryName ? `${categoryName}` : 'Category',
description, description,
path: `/category/${id}`, path: `/category/${id}`,
lang, lang,
openGraph: categoryImage openGraph: socialImage
? { ? {
title: categoryName || undefined, title: categoryName || undefined,
description, description,
images: [categoryImage], images: [socialImage],
} }
: undefined, : undefined,
}) })
@@ -93,7 +96,13 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
const category = categoryProductsResult.data.category const category = categoryProductsResult.data.category
const products = categoryProductsResult.data.data ?? [] const products = categoryProductsResult.data.data ?? []
const categoryName = resolveLocalized(category.name, lang) 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' const layoutType = category.layoutType || 'split_showcase'
// Determine Product Grid Class based on layout archetype // 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 && ( {announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} /> <DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)} )}
{heroSections.length > 0 ? ( {heroSections.length > 0 && !(layoutType === 'editorial_hero' && hasArtDirectedHero) ? (
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} /> <DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
) : layoutType === 'editorial_hero' ? ( ) : layoutType === 'editorial_hero' ? (
/* ── Archetype 2: Editorial Hero Layout ── */ /* ── 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"> <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"> <div className="absolute inset-0 z-0">
<Image <Image
src={categoryImageUrl} src={heroDesktopUrl}
alt={categoryName} alt={categoryName}
fill fill
unoptimized 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 className="absolute inset-0 bg-gradient-to-t from-black via-black/70 to-black/40" />
</div> </div>
@@ -201,7 +221,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
</div> </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"> <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 <Image
src={categoryImageUrl} src={categoryThumbnailUrl}
alt={categoryName} alt={categoryName}
fill fill
unoptimized 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 { 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 { useState, useRef, useEffect, useMemo } from 'react'
import type { Category, PageSection, Product } from '@homaroma/types' import type { Category, PageSection, Product } from '@homaroma/types'
import { getCategoryImage } from '@homaroma/types' import { getCategoryImage, resolveCategoryMediaUrl } from '@homaroma/types'
import ProductCard from '../ProductCard' import ProductCard from '../ProductCard'
import { localizedFromRecord, resolveCategoryName, resolveLocalized, toRecord } from '../../lib/localization' import { localizedFromRecord, resolveCategoryName, resolveLocalized, toRecord } from '../../lib/localization'
import { useLang } from '../../hooks/useLang' 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" className="group relative h-72 overflow-hidden rounded-2xl shadow-card transition hover:-translate-y-1 hover:shadow-card-hover"
> >
<Image <Image
src={category.imageUrl || getCategoryImage(name)} src={resolveCategoryMediaUrl(category, 'card_portrait', getCategoryImage(name))}
alt={name} alt={name}
fill fill
unoptimized unoptimized
+69
View File
@@ -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.
+32
View File
@@ -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')
})
})
+51
View File
@@ -6,10 +6,33 @@ export interface Brand {
description?: string | null 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 { export interface Category {
id: number id: number
name: TranslatableString name: TranslatableString
imageUrl?: string | null imageUrl?: string | null
media?: CategoryMedia[]
layoutType?: 'split_showcase' | 'editorial_hero' | 'dense_catalog' | string | null layoutType?: 'split_showcase' | 'editorial_hero' | 'dense_catalog' | string | null
_count?: { products: number } _count?: { products: number }
translations?: { langCode: string; name: string }[] translations?: { langCode: string; name: string }[]
@@ -17,6 +40,34 @@ export interface Category {
updatedAt?: string 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 { export interface Variant {
id: number id: number