diff --git a/apps/admin/src/components/CategoryBulkJsonModal.tsx b/apps/admin/src/components/CategoryBulkJsonModal.tsx index 63b1169..2be3beb 100644 --- a/apps/admin/src/components/CategoryBulkJsonModal.tsx +++ b/apps/admin/src/components/CategoryBulkJsonModal.tsx @@ -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, }; diff --git a/apps/admin/src/components/CategoryMediaEditor.tsx b/apps/admin/src/components/CategoryMediaEditor.tsx new file mode 100644 index 0000000..4d934cb --- /dev/null +++ b/apps/admin/src/components/CategoryMediaEditor.tsx @@ -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> + 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) => { + 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 ( +
+
+

Category Media

+

Art-directed assets for cards, responsive heroes, and social sharing.

+
+ +
+ {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 ( +
+
+
+
+

{slot.label}

+ {(required || recommended) && ( + + {required ? 'Required' : 'Recommended'} + + )} +
+

{slot.ratio} · {slot.dimensions}

+
+ +
+ + + + {item?.url ? ( +
+

+ Actual: {item.width && item.height ? `${item.width} × ${item.height} px` : 'detecting dimensions…'} + {ratioMismatch ? ` · recommended ${slot.ratio}` : ''} +

+ updateMedia(slot.role, { + focalX: Number.parseFloat(point.x) / 100, + focalY: Number.parseFloat(point.y) / 100, + })} + /> + +
+ ) : (required || recommended) ? ( +
+ + {required ? 'This asset is required before saving.' : 'This layout will use the documented fallback until this asset is supplied.'} +
+ ) : null} +
+ ) + })} +
+
+ ) +} diff --git a/apps/admin/src/pages/CategoriesPage.tsx b/apps/admin/src/pages/CategoriesPage.tsx index 3b10922..7b163d3 100644 --- a/apps/admin/src/pages/CategoriesPage.tsx +++ b/apps/admin/src/pages/CategoriesPage.tsx @@ -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) diff --git a/apps/admin/src/pages/CategoryEditPage.tsx b/apps/admin/src/pages/CategoryEditPage.tsx index 9608af7..b47e65e 100644 --- a/apps/admin/src/pages/CategoryEditPage.tsx +++ b/apps/admin/src/pages/CategoryEditPage.tsx @@ -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('') + const [media, setMedia] = useState([]) const [layoutType, setLayoutType] = useState('split_showcase') const [categoryMeta, setCategoryMeta] = useState(null) // Media Library Modal const [isMediaModalOpen, setIsMediaModalOpen] = useState(false) + const [activeMediaRole, setActiveMediaRole] = useState('thumbnail') + const [uploadingRole, setUploadingRole] = useState(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 */}
- {/* Category Image Card */} -
-
-

Category Image

- -
- - {/* Thumbnail Preview Area */} -
setIsMediaModalOpen(true)} - > - {imageUrl ? ( - <> - {displayName} -
- - Change Image -
- - ) : ( -
-
- -
-

Select Image

-

Click to browse Media Library

-
- )} - - {isUploading && ( -
- - Uploading image... -
- )} -
- - {/* Image Actions & Helpers */} -
-
- - {imageUrl && ( - - )} -
- - {!imageUrl && ( -
- - If no image is uploaded, a default placeholder graphic will be used. -
- )} -
-
+ { + setActiveMediaRole(role) + setIsMediaModalOpen(true) + }} + onPaste={(role) => void handlePasteFromClipboard(role)} + /> {/* Quick Info & Tips Card */}
@@ -950,7 +911,7 @@ export default function CategoryEditPage() { isOpen={isMediaModalOpen} onClose={() => setIsMediaModalOpen(false)} onSelect={(url) => { - setImageUrl(url) + setMediaUrl(activeMediaRole, url) setIsMediaModalOpen(false) }} /> diff --git a/apps/backend/prisma/migrations/20260930230551_add_category_media_variants/migration.sql b/apps/backend/prisma/migrations/20260930230551_add_category_media_variants/migration.sql new file mode 100644 index 0000000..aa26ac4 --- /dev/null +++ b/apps/backend/prisma/migrations/20260930230551_add_category_media_variants/migration.sql @@ -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; diff --git a/apps/backend/prisma/migrations/20260930231000_backfill_category_media_thumbnail/migration.sql b/apps/backend/prisma/migrations/20260930231000_backfill_category_media_thumbnail/migration.sql new file mode 100644 index 0000000..084809a --- /dev/null +++ b/apps/backend/prisma/migrations/20260930231000_backfill_category_media_thumbnail/migration.sql @@ -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; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 3fa3ecd..774c236 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -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 diff --git a/apps/backend/src/routes/backup.ts b/apps/backend/src/routes/backup.ts index ffaf353..bfaca16 100644 --- a/apps/backend/src/routes/backup.ts +++ b/apps/backend/src/routes/backup.ts @@ -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++; } diff --git a/apps/backend/src/routes/categories.ts b/apps/backend/src/routes/categories.ts index 3cd4898..2c195d2 100644 --- a/apps/backend/src/routes/categories.ts +++ b/apps/backend/src/routes/categories.ts @@ -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(); + 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, +) { + 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({ - where: { id, deletedAt: null }, - data: { - imageUrl: body.imageUrl, - }, + const category = await prisma.$transaction(async (tx: Prisma.TransactionClient) => { + const updated = await tx.category.update({ + where: { id, deletedAt: null }, + 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({ - data: { - name: body.name, - imageUrl: body.imageUrl, - layoutType: body.layoutType || 'split_showcase', - // Auto-seed Thai translation - translations: { create: { langCode: 'th', name: body.name } }, - }, + 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: normalizedMedia === null ? (body.imageUrl ?? null) : null, + layoutType: body.layoutType || 'split_showcase', + // Auto-seed Thai translation + translations: { create: { langCode: 'th', name: body.name } }, + }, + }); + 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" }] } }, }); - return category; } 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({ - where: { id, deletedAt: null }, - data: updateData, + await prisma.$transaction(async (tx: Prisma.TransactionClient) => { + await tx.category.update({ + where: { id, deletedAt: null }, + data: updateData, + }); + 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" }] } }, }); - return category; } 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 }) => { diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 6fbafe1..7c8ccd3 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -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` diff --git a/apps/mcp-server/index.ts b/apps/mcp-server/index.ts index 1064016..8199722 100644 --- a/apps/mcp-server/index.ts +++ b/apps/mcp-server/index.ts @@ -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`, { diff --git a/apps/spa/src/components/DynamicCategories.tsx b/apps/spa/src/components/DynamicCategories.tsx index 0a35492..578bc17 100644 --- a/apps/spa/src/components/DynamicCategories.tsx +++ b/apps/spa/src/components/DynamicCategories.tsx @@ -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 >
{getLocalizedField(cat.name, { (e.target as HTMLImageElement).style.display = 'none' }} diff --git a/apps/spa/src/pages/CategoryPage.tsx b/apps/spa/src/pages/CategoryPage.tsx index 7cce180..e1c082b 100644 --- a/apps/spa/src/pages/CategoryPage.tsx +++ b/apps/spa/src/pages/CategoryPage.tsx @@ -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() { {/* Hero / Banner Area */} - {heroSections.length > 0 ? ( + {heroSections.length > 0 && !(layoutType === 'editorial_hero' && hasArtDirectedHero) ? (
{/* Immersive Background Image with Dark Gradient Overlays */} - {categoryImageUrl && ( + {heroDesktopUrl && (
{categoryName} + {categoryName}
@@ -257,10 +270,10 @@ export default function CategoryPage() { )}
- {categoryImageUrl && ( + {categoryThumbnailUrl && (
{categoryName} { (e.target as HTMLImageElement).style.display = 'none' }} diff --git a/apps/website/src/app/[lang]/category/[id]/page.tsx b/apps/website/src/app/[lang]/category/[id]/page.tsx index 3ec2220..22154bd 100644 --- a/apps/website/src/app/[lang]/category/[id]/page.tsx +++ b/apps/website/src/app/[lang]/category/[id]/page.tsx @@ -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 && ( )} - {heroSections.length > 0 ? ( + {heroSections.length > 0 && !(layoutType === 'editorial_hero' && hasArtDirectedHero) ? ( ) : layoutType === 'editorial_hero' ? ( /* ── Archetype 2: Editorial Hero Layout ── */
- {categoryImageUrl && ( + {heroDesktopUrl && (
{categoryName} + {categoryName}
@@ -201,7 +221,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
{categoryName} {name} { + 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') + }) +}) diff --git a/packages/types/index.ts b/packages/types/index.ts index 57b5463..dc8482d 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -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 = { + 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 | 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 | null | undefined, + role: CategoryMediaRole, + fallbackUrl = '', +): string { + return getCategoryMedia(category, role)?.url || category?.imageUrl || fallbackUrl +} + export interface Variant { id: number