This commit is contained in:
Your Name committed 2026-10-01 06:59:38 +07:00
1 parent 0ceee3dbff
commit 2d39edbd57
18 files changed
+804 -153

No files matched your search

@@ -75,6 +75,7 @@ export default function CategoryBulkJsonModal({
id: cat.id,
name: catBaseName || transMap['th'] || 'Unnamed Category',
imageUrl: cat.imageUrl || null,
media: cat.media || [],
layoutType: cat.layoutType || 'split_showcase',
translations: transMap,
};
@@ -89,8 +90,9 @@ export default function CategoryBulkJsonModal({
const aiTranslatePrompt = useMemo(() => {
const sampleItems = exportData.slice(0, 5);
return `Please translate and optimize our E-commerce Category Names into all 8 supported languages (th, en, ja, zh, vi, ko, lo, it).
Ensure the tone is elegant, accurate for luxury aromatherapy & lifestyle products.
Preserve the tenant's catalog terminology and brand tone.
Allowed layoutType values: "split_showcase" (default), "editorial_hero", "dense_catalog".
Allowed media roles: "thumbnail", "hero_desktop", "hero_mobile", "card_portrait", "social_share".
Return ONLY a valid JSON Array conforming to this structure:
\`\`\`json
@@ -101,6 +103,11 @@ ${JSON.stringify(
{
name: 'น้ำมันหอมระเหย',
imageUrl: '/images/categories/essential-oils.jpg',
media: [
{ role: 'thumbnail', url: '/images/categories/essential-oils-square.jpg' },
{ role: 'hero_desktop', url: '/images/categories/essential-oils-wide.jpg' },
{ role: 'hero_mobile', url: '/images/categories/essential-oils-mobile.jpg' },
],
layoutType: 'split_showcase',
translations: {
th: 'น้ำมันหอมระเหย',
@@ -116,6 +123,11 @@ ${JSON.stringify(
{
name: 'ก้านไม้หอมปรับอากาศ',
imageUrl: '/images/categories/reed-diffusers.jpg',
media: [
{ role: 'thumbnail', url: '/images/categories/reed-diffusers-square.jpg' },
{ role: 'hero_desktop', url: '/images/categories/reed-diffusers-wide.jpg' },
{ role: 'hero_mobile', url: '/images/categories/reed-diffusers-mobile.jpg' },
],
layoutType: 'editorial_hero',
translations: {
th: 'ก้านไม้หอมปรับอากาศ',
@@ -169,6 +181,7 @@ ${JSON.stringify(
id: typeof item.id === 'number' ? item.id : undefined,
name: baseName,
imageUrl: item.imageUrl || null,
media: Array.isArray(item.media) ? item.media : [],
layoutType: typeof item.layoutType === 'string' ? item.layoutType : 'split_showcase',
translations: trans,
};
@@ -0,0 +1,168 @@
import type { Dispatch, SetStateAction } from 'react'
import { AlertCircle, ClipboardPaste, Image as ImageIcon, Loader2, Trash2 } from 'lucide-react'
import type { CategoryMedia, CategoryMediaRole } from '@homaroma/types'
import FocalPointPicker from './page-builder/FocalPointPicker'
type CategoryMediaEditorProps = {
media: CategoryMedia[]
layoutType: string
displayName: string
uploadingRole: CategoryMediaRole | null
onChange: Dispatch<SetStateAction<CategoryMedia[]>>
onBrowse: (role: CategoryMediaRole) => void
onPaste: (role: CategoryMediaRole) => void
}
const MEDIA_SLOTS: Array<{
role: CategoryMediaRole
label: string
ratio: string
dimensions: string
previewClass: string
expectedRatio: number
}> = [
{ role: 'thumbnail', label: 'Thumbnail', ratio: '1:1', dimensions: '1200 × 1200 px', previewClass: 'aspect-square', expectedRatio: 1 },
{ role: 'hero_desktop', label: 'Desktop Hero', ratio: '21:9', dimensions: '2400 × 1028 px', previewClass: 'aspect-[21/9]', expectedRatio: 21 / 9 },
{ role: 'hero_mobile', label: 'Mobile Hero', ratio: '4:5', dimensions: '1200 × 1500 px', previewClass: 'aspect-[4/5]', expectedRatio: 4 / 5 },
{ role: 'card_portrait', label: 'Portrait Card', ratio: '4:5', dimensions: '1200 × 1500 px', previewClass: 'aspect-[4/5]', expectedRatio: 4 / 5 },
{ role: 'social_share', label: 'Social Share', ratio: '1.91:1', dimensions: '1200 × 630 px', previewClass: 'aspect-[1.91/1]', expectedRatio: 1200 / 630 },
]
function isRecommendedForLayout(role: CategoryMediaRole, layoutType: string) {
return layoutType === 'editorial_hero' && (role === 'hero_desktop' || role === 'hero_mobile')
}
export default function CategoryMediaEditor({
media,
layoutType,
displayName,
uploadingRole,
onChange,
onBrowse,
onPaste,
}: CategoryMediaEditorProps) {
const findMedia = (role: CategoryMediaRole) => media.find((item) => item.role === role)
const updateMedia = (role: CategoryMediaRole, update: Partial<CategoryMedia>) => {
onChange((current) => {
const existing = current.find((item) => item.role === role)
if (existing) return current.map((item) => item.role === role ? { ...item, ...update } : item)
if (!update.url) return current
return [...current, { role, url: update.url, focalX: 0.5, focalY: 0.5, sortOrder: current.length, ...update }]
})
}
const removeMedia = (role: CategoryMediaRole) => {
onChange((current) => current.filter((item) => item.role !== role))
}
return (
<div className="admin-card space-y-5 p-6">
<div className="border-b border-white/5 pb-4">
<h2 className="text-base font-bold text-white">Category Media</h2>
<p className="mt-1 text-xs text-slate-400">Art-directed assets for cards, responsive heroes, and social sharing.</p>
</div>
<div className="space-y-5">
{MEDIA_SLOTS.map((slot) => {
const item = findMedia(slot.role)
const required = slot.role === 'thumbnail'
const recommended = isRecommendedForLayout(slot.role, layoutType)
const isUploading = uploadingRole === slot.role
const actualRatio = item?.width && item?.height ? item.width / item.height : null
const ratioMismatch = actualRatio !== null && Math.abs(actualRatio - slot.expectedRatio) / slot.expectedRatio > 0.08
return (
<section key={slot.role} className="space-y-3 rounded-2xl border border-white/10 bg-white/[0.02] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold text-white">{slot.label}</h3>
{(required || recommended) && (
<span className="rounded-md border border-cyan-glow/20 bg-cyan-glow/10 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-cyan-glow">
{required ? 'Required' : 'Recommended'}
</span>
)}
</div>
<p className="mt-1 text-[11px] text-slate-500">{slot.ratio} · {slot.dimensions}</p>
</div>
<button
type="button"
onClick={() => onPaste(slot.role)}
className="inline-flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-[10px] font-semibold text-slate-300 transition hover:bg-white/10 hover:text-white"
>
<ClipboardPaste className="h-3 w-3" />
Paste
</button>
</div>
<button
type="button"
onClick={() => onBrowse(slot.role)}
className={`relative flex w-full items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-white/10 bg-night-950/60 transition hover:border-cyan-glow/40 ${slot.previewClass}`}
>
{item?.url ? (
<img
src={item.url}
alt={`${displayName || 'Category'} ${slot.label}`}
className="absolute inset-0 h-full w-full object-cover"
style={{ objectPosition: `${(item.focalX ?? 0.5) * 100}% ${(item.focalY ?? 0.5) * 100}%` }}
onLoad={(event) => {
const { naturalWidth, naturalHeight } = event.currentTarget
if (naturalWidth !== item.width || naturalHeight !== item.height) {
updateMedia(slot.role, { width: naturalWidth, height: naturalHeight })
}
}}
/>
) : (
<div className="space-y-2 p-5 text-center">
<ImageIcon className="mx-auto h-6 w-6 text-slate-500" />
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400">Select {slot.label}</p>
</div>
)}
{isUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-night-950/80">
<Loader2 className="h-6 w-6 animate-spin text-cyan-glow" />
</div>
)}
</button>
{item?.url ? (
<div className="space-y-3">
<p className={`text-[11px] ${ratioMismatch ? 'text-amber-400' : 'text-slate-500'}`}>
Actual: {item.width && item.height ? `${item.width} × ${item.height} px` : 'detecting dimensions…'}
{ratioMismatch ? ` · recommended ${slot.ratio}` : ''}
</p>
<FocalPointPicker
imageUrl={item.url}
focalPoint={{
x: `${(item.focalX ?? 0.5) * 100}%`,
y: `${(item.focalY ?? 0.5) * 100}%`,
}}
onChange={(point) => updateMedia(slot.role, {
focalX: Number.parseFloat(point.x) / 100,
focalY: Number.parseFloat(point.y) / 100,
})}
/>
<button
type="button"
onClick={() => removeMedia(slot.role)}
className="inline-flex items-center gap-1 text-xs font-medium text-rose-400 hover:underline"
>
<Trash2 className="h-3.5 w-3.5" />
Remove asset
</button>
</div>
) : (required || recommended) ? (
<div className="flex items-start gap-2 rounded-xl border border-amber-500/10 bg-amber-500/5 p-3 text-xs text-amber-400">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{required ? 'This asset is required before saving.' : 'This layout will use the documented fallback until this asset is supplied.'}</span>
</div>
) : null}
</section>
)
})}
</div>
</div>
)
}
+3 -3
View File
@@ -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)
+52 -91
View File
@@ -1,13 +1,11 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { API_BASE, type Category } from '@homaroma/types'
import { API_BASE, type Category, type CategoryMedia, type CategoryMediaRole } from '@homaroma/types'
import {
ArrowLeft,
Save,
Trash2,
Image as ImageIcon,
UploadCloud,
Loader2,
ClipboardPaste,
Globe,
@@ -28,6 +26,7 @@ import {
Grid3X3
} from 'lucide-react'
import MediaLibraryModal from '../components/MediaLibraryModal'
import CategoryMediaEditor from '../components/CategoryMediaEditor'
const ADDITIONAL_LANGS = [
{ code: 'ja', label: 'Japanese (日本語)', placeholder: '例: アロマキャンドル' },
@@ -78,12 +77,14 @@ export default function CategoryEditPage() {
lo: '',
it: '',
})
const [imageUrl, setImageUrl] = useState<string>('')
const [media, setMedia] = useState<CategoryMedia[]>([])
const [layoutType, setLayoutType] = useState<string>('split_showcase')
const [categoryMeta, setCategoryMeta] = useState<Category | null>(null)
// Media Library Modal
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [activeMediaRole, setActiveMediaRole] = useState<CategoryMediaRole>('thumbnail')
const [uploadingRole, setUploadingRole] = useState<CategoryMediaRole | null>(null)
useEffect(() => {
if (isNew) return
@@ -94,7 +95,9 @@ export default function CategoryEditPage() {
if (!res.ok) throw new Error('Category not found')
const data = await res.json()
setCategoryMeta(data)
setImageUrl(data.imageUrl || '')
setMedia(Array.isArray(data.media) && data.media.length > 0
? data.media
: (data.imageUrl ? [{ role: 'thumbnail', url: data.imageUrl, focalX: 0.5, focalY: 0.5 }] : []))
setLayoutType(data.layoutType || 'split_showcase')
if (data.name && typeof data.name === 'object') {
@@ -223,8 +226,17 @@ export default function CategoryEditPage() {
}
// ── Image Upload Handlers ─────────────────────────────────────────────────
const handleUpload = async (file: File) => {
const setMediaUrl = (role: CategoryMediaRole, url: string) => {
setMedia((current) => {
const existing = current.find((item) => item.role === role)
if (existing) return current.map((item) => item.role === role ? { ...item, url } : item)
return [...current, { role, url, focalX: 0.5, focalY: 0.5, sortOrder: current.length }]
})
}
const handleUpload = async (file: File, role: CategoryMediaRole) => {
setIsUploading(true)
setUploadingRole(role)
const formData = new FormData()
formData.append('file', file)
@@ -235,17 +247,18 @@ export default function CategoryEditPage() {
})
const data = await res.json()
if (data.url) {
setImageUrl(data.url)
setMediaUrl(role, data.url)
}
} catch (err) {
console.error('Upload error:', err)
alert('Failed to upload image')
} finally {
setIsUploading(false)
setUploadingRole(null)
}
}
const handlePasteFromClipboard = async () => {
const handlePasteFromClipboard = async (role: CategoryMediaRole) => {
try {
const items = await navigator.clipboard.read()
for (const item of items) {
@@ -253,7 +266,7 @@ export default function CategoryEditPage() {
for (const type of imageTypes) {
const blob = await item.getType(type)
const file = new File([blob], 'pasted-category-image.png', { type })
handleUpload(file)
void handleUpload(file, role)
return
}
}
@@ -266,6 +279,12 @@ export default function CategoryEditPage() {
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
const thumbnail = media.find((item) => item.role === 'thumbnail' && item.url.trim())
if (!thumbnail) {
alert('A 1:1 category thumbnail is required before saving.')
return
}
if (!name.th?.trim()) {
alert('Please enter Thai category name')
return
@@ -283,8 +302,17 @@ export default function CategoryEditPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: masterName,
imageUrl: imageUrl || null,
layoutType: layoutType || 'split_showcase'
imageUrl: thumbnail.url,
layoutType: layoutType || 'split_showcase',
media: media.map(({ role, url, width, height, focalX, focalY, sortOrder }) => ({
role,
url,
width: width ?? null,
height: height ?? null,
focalX: focalX ?? 0.5,
focalY: focalY ?? 0.5,
sortOrder: sortOrder ?? 0,
})),
})
})
@@ -838,85 +866,18 @@ export default function CategoryEditPage() {
{/* Right Column (1 Col): Image & Metadata */}
<div className="space-y-6">
{/* Category Image Card */}
<div className="admin-card p-6 space-y-5">
<div className="flex items-center justify-between border-b border-white/5 pb-4">
<h2 className="text-base font-bold text-white">Category Image</h2>
<button
type="button"
onClick={handlePasteFromClipboard}
className="flex items-center gap-1 px-2.5 py-1 rounded-lg bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-wider transition-all border border-cyan-glow/20 cursor-pointer"
title="Paste image directly from clipboard"
>
<ClipboardPaste className="w-3 h-3" />
Paste Image
</button>
</div>
{/* Thumbnail Preview Area */}
<div
className="relative w-full aspect-square rounded-2xl overflow-hidden border-2 border-dashed border-white/10 bg-night-950/60 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/40 transition-all shadow-inner"
onClick={() => setIsMediaModalOpen(true)}
>
{imageUrl ? (
<>
<img
src={imageUrl}
className="w-full h-full object-cover"
alt={displayName}
/>
<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>
<CategoryMediaEditor
media={media}
layoutType={layoutType}
displayName={displayName}
uploadingRole={uploadingRole}
onChange={setMedia}
onBrowse={(role) => {
setActiveMediaRole(role)
setIsMediaModalOpen(true)
}}
onPaste={(role) => void handlePasteFromClipboard(role)}
/>
{/* Quick Info & Tips Card */}
<div className="admin-card p-6 space-y-4">
@@ -950,7 +911,7 @@ export default function CategoryEditPage() {
isOpen={isMediaModalOpen}
onClose={() => setIsMediaModalOpen(false)}
onSelect={(url) => {
setImageUrl(url)
setMediaUrl(activeMediaRole, url)
setIsMediaModalOpen(false)
}}
/>
@@ -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
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
+23 -2
View File
@@ -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++;
}
+192 -25
View File
@@ -1,7 +1,89 @@
import { Elysia, t } from "elysia";
import type { Prisma } from "@prisma/client";
import prisma from "../lib/prisma";
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'];
const CATEGORY_MEDIA_ROLES = ['thumbnail', 'hero_desktop', 'hero_mobile', 'card_portrait', 'social_share'] as const;
type CategoryMediaRole = typeof CATEGORY_MEDIA_ROLES[number];
type CategoryMediaInputData = {
role: CategoryMediaRole;
url: string;
width?: number | null;
height?: number | null;
focalX?: number;
focalY?: number;
sortOrder?: number;
};
const categoryMediaInput = t.Object({
role: t.Union([
t.Literal('thumbnail'),
t.Literal('hero_desktop'),
t.Literal('hero_mobile'),
t.Literal('card_portrait'),
t.Literal('social_share'),
]),
url: t.String({ minLength: 1 }),
width: t.Optional(t.Union([t.Number({ minimum: 1 }), t.Null()])),
height: t.Optional(t.Union([t.Number({ minimum: 1 }), t.Null()])),
focalX: t.Optional(t.Number({ minimum: 0, maximum: 1 })),
focalY: t.Optional(t.Number({ minimum: 0, maximum: 1 })),
sortOrder: t.Optional(t.Number({ minimum: 0 })),
});
function normalizeCategoryMedia(items: CategoryMediaInputData[]) {
const seen = new Set<string>();
return items.map((item, index) => {
if (!CATEGORY_MEDIA_ROLES.includes(item.role)) throw new Error(`Unsupported category media role: ${item.role}`);
if (typeof item.url !== 'string' || item.url.trim().length === 0) throw new Error(`Category media ${item.role} requires a URL`);
if (seen.has(item.role)) throw new Error(`Duplicate category media role: ${item.role}`);
seen.add(item.role);
return {
role: item.role,
url: item.url.trim(),
width: item.width ?? null,
height: item.height ?? null,
focalX: item.focalX ?? 0.5,
focalY: item.focalY ?? 0.5,
sortOrder: item.sortOrder ?? index,
};
});
}
async function replaceCategoryMediaWithClient(
tx: Prisma.TransactionClient,
categoryId: number,
media: ReturnType<typeof normalizeCategoryMedia>,
) {
const thumbnailUrl = media.find((item) => item.role === 'thumbnail')?.url ?? null;
await tx.categoryMedia.deleteMany({ where: { categoryId } });
if (media.length > 0) {
await tx.categoryMedia.createMany({
data: media.map((item) => ({ ...item, categoryId })),
});
}
await tx.category.update({ where: { id: categoryId }, data: { imageUrl: thumbnailUrl } });
}
async function replaceCategoryMedia(categoryId: number, items: CategoryMediaInputData[]) {
const media = normalizeCategoryMedia(items);
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await replaceCategoryMediaWithClient(tx, categoryId, media);
});
}
async function syncLegacyThumbnail(categoryId: number, imageUrl: string | null) {
if (imageUrl) {
await prisma.categoryMedia.upsert({
where: { categoryId_role: { categoryId, role: 'thumbnail' } },
update: { url: imageUrl },
create: { categoryId, role: 'thumbnail', url: imageUrl },
});
} else {
await prisma.categoryMedia.deleteMany({ where: { categoryId, role: 'thumbnail' } });
}
}
/** Resolve category name based on lang */
function resolveCatName(cat: any, lang: string): any {
@@ -24,6 +106,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
orderBy: { name: "asc" },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: {
select: { products: { where: { isDeleted: false } } },
},
@@ -49,7 +132,9 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
for (const item of list) {
const baseName = item.name || item.translations?.th || item.translations?.en || 'Unnamed Category';
const imageUrl = item.imageUrl || null;
const hasLegacyImage = Object.prototype.hasOwnProperty.call(item, 'imageUrl');
const imageUrl = hasLegacyImage ? (item.imageUrl || null) : undefined;
const media = Array.isArray(item.media) ? item.media : undefined;
const translations = item.translations || {};
let category: any = null;
@@ -69,7 +154,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
if (category) {
// Update existing
const updateData: any = {};
if (imageUrl !== undefined) updateData.imageUrl = imageUrl;
if (imageUrl !== undefined && media === undefined) updateData.imageUrl = imageUrl;
if (item.name) updateData.name = item.name;
if (item.layoutType) updateData.layoutType = item.layoutType;
@@ -85,13 +170,19 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
category = await prisma.category.create({
data: {
name: baseName,
imageUrl,
imageUrl: media === undefined ? (imageUrl ?? null) : null,
layoutType: item.layoutType || 'split_showcase',
},
});
createdCount++;
}
if (media !== undefined) {
await replaceCategoryMedia(category.id, media);
} else if (imageUrl !== undefined) {
await syncLegacyThumbnail(category.id, imageUrl);
}
// Upsert translations
for (const lang of LANGS) {
let transName = typeof translations[lang] === 'string'
@@ -118,7 +209,11 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
const fullCategory = await prisma.category.findUnique({
where: { id: category.id },
include: { translations: true, _count: { select: { products: { where: { isDeleted: false } } } } },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: { select: { products: { where: { isDeleted: false } } } },
},
});
results.push(fullCategory);
@@ -145,6 +240,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
where: { id, deletedAt: null },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: {
select: { products: { where: { isDeleted: false } } },
},
@@ -173,11 +269,12 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
where: { id, deletedAt: null },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
products: {
where: { isDeleted: false },
include: {
brand: true,
category: { include: { translations: true } },
category: { include: { translations: true, media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] } } },
translations: true,
variants: { orderBy: { price: "asc" as const } },
images: { orderBy: { order: "asc" as const } },
@@ -201,6 +298,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
id: category.id,
name: resolveCatName(category, lang),
imageUrl: category.imageUrl,
media: category.media,
layoutType: category.layoutType || 'split_showcase',
_count: category._count,
},
@@ -282,7 +380,11 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
const updated = await prisma.category.findUnique({
where: { id },
include: { translations: true, _count: { select: { products: { where: { isDeleted: false } } } } },
include: {
translations: true,
media: { orderBy: [{ sortOrder: "asc" }, { id: "asc" }] },
_count: { select: { products: { where: { isDeleted: false } } } },
},
});
return { success: true, translations: results, category: updated };
@@ -296,11 +398,21 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
try {
const category = await prisma.category.update({
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 }) => {
+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:
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`
+70 -8
View File
@@ -104,11 +104,31 @@ function compactCategory(category: any) {
id: category.id,
name: category.name,
imageUrl: category.imageUrl ?? null,
media: category.media ?? [],
layoutType: category.layoutType ?? undefined,
productCount: category._count?.products ?? category.productCount ?? undefined,
translations: category.translations ?? undefined,
};
}
const categoryMediaSchema = {
type: "object",
properties: {
role: {
type: "string",
enum: ["thumbnail", "hero_desktop", "hero_mobile", "card_portrait", "social_share"],
description: "Semantic image usage; do not encode a layout name in this field."
},
url: { type: "string", description: "Image URL or website-relative media path." },
width: { type: ["number", "null"], description: "Optional intrinsic width in pixels." },
height: { type: ["number", "null"], description: "Optional intrinsic height in pixels." },
focalX: { type: "number", minimum: 0, maximum: 1, description: "Horizontal focal point from 0 to 1." },
focalY: { type: "number", minimum: 0, maximum: 1, description: "Vertical focal point from 0 to 1." },
sortOrder: { type: "number", minimum: 0 }
},
required: ["role", "url"]
};
function compactProduct(product: any) {
if (!product) return null;
const images = Array.isArray(product.images) ? product.images : [];
@@ -211,12 +231,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
},
{
name: "create_category",
description: "Create a product category. Backend supports base name, imageUrl, and translations. It does not support slug, hierarchy, sort order, or active state for categories.",
description: "Create a product category with semantic responsive media and translations. imageUrl remains a legacy thumbnail fallback.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Base category name. Use Thai or the primary display name." },
imageUrl: { type: ["string", "null"], description: "Optional image URL." },
layoutType: { type: "string", enum: ["split_showcase", "editorial_hero", "dense_catalog"] },
media: { type: "array", items: categoryMediaSchema, maxItems: 5 },
translations: {
type: "object",
description: "Optional locale map for category names. Supported locales: th, en, ja, zh, vi, ko, lo, it."
@@ -228,13 +250,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
},
{
name: "update_category",
description: "Update category base name, image URL, and/or translations. Use dryRun first. Backend supports name and imageUrl only for base category fields.",
description: "Update category base fields, layout, semantic responsive media, and/or translations. Use dryRun first.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Category ID" },
name: { type: "string", description: "Optional base category name." },
imageUrl: { type: ["string", "null"], description: "Optional image URL. Pass null to remove." },
layoutType: { type: "string", enum: ["split_showcase", "editorial_hero", "dense_catalog"] },
media: { type: "array", items: categoryMediaSchema, maxItems: 5 },
translations: {
type: "object",
description: "Optional locale map for category names. Supported locales: th, en, ja, zh, vi, ko, lo, it."
@@ -324,9 +348,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
required: ["id", "translations"]
}
},
{
name: "update_category_media",
description: "Replace the complete semantic media set for a category. Omitted roles are removed. Use dryRun first.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Category ID" },
media: { type: "array", items: categoryMediaSchema, maxItems: 5 },
dryRun: { type: "boolean", description: "Preview only. No media is changed." }
},
required: ["id", "media"]
}
},
{
name: "update_category_image",
description: "Update the image URL for a specific category.",
description: "Legacy compatibility tool. Update the category thumbnail only. Prefer update_category_media for responsive assets.",
inputSchema: {
type: "object",
properties: {
@@ -1145,13 +1182,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "create_category": {
const { name, imageUrl = null, translations, dryRun } = params as any;
const { name, imageUrl = null, layoutType, media, translations, dryRun } = params as any;
if (typeof name !== "string" || name.trim().length === 0) {
throw new Error("name must be a non-empty string.");
}
if (translations !== undefined) validateCategoryLocaleMap(translations);
const payload = { name: name.trim(), imageUrl };
const payload = { name: name.trim(), imageUrl, layoutType, media };
if (dryRun) {
return {
content: [{
@@ -1164,7 +1201,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
create: payload,
translations: translations ?? null,
backendSupport: {
fields: ["name", "imageUrl", "translations"],
fields: ["name", "imageUrl", "layoutType", "media", "translations"],
unsupportedFields: ["slug", "parentId", "sortOrder", "isActive"],
},
},
@@ -1193,7 +1230,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
};
}
case "update_category": {
const { id, name, imageUrl, translations, dryRun } = params as any;
const { id, name, imageUrl, layoutType, media, translations, dryRun } = params as any;
if (name !== undefined && (typeof name !== "string" || name.trim().length === 0)) {
throw new Error("name must be a non-empty string when provided.");
}
@@ -1210,6 +1247,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const baseUpdate: any = {};
if (name !== undefined) baseUpdate.name = name.trim();
if (imageUrl !== undefined) baseUpdate.imageUrl = imageUrl;
if (layoutType !== undefined) baseUpdate.layoutType = layoutType;
if (media !== undefined) baseUpdate.media = media;
if (dryRun) {
return {
@@ -1225,7 +1264,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
baseUpdate,
translations: translations ?? null,
backendSupport: {
fields: ["name", "imageUrl", "translations"],
fields: ["name", "imageUrl", "layoutType", "media", "translations"],
unsupportedFields: ["slug", "parentId", "sortOrder", "isActive"],
},
},
@@ -1392,6 +1431,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }]
};
}
case "update_category_media": {
const { id, media, dryRun } = params as any;
if (!Array.isArray(media)) throw new Error("media must be an array.");
if (dryRun) {
const before = await api.get(`/categories/${id}?lang=all`);
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
dryRun: true,
operation: "update_category_media",
categoryId: id,
before: compactCategory(before.data),
media,
note: "This is a full replacement; omitted roles will be removed."
}, null, 2)
}]
};
}
const res = await api.put(`/categories/${id}/media`, { media });
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "update_product_brand_translation": {
const { id, lang, name } = params as any;
const res = await api.patch(`/products/brands/${id}/translations`, {
@@ -1,6 +1,6 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { getCategoryImage, type Category, type CategoriesSectionPayload } from '@homaroma/types';
import { getCategoryImage, resolveCategoryMediaUrl, type Category, type CategoriesSectionPayload } from '@homaroma/types';
import { useTranslation } from 'react-i18next';
import { getLocalizedField } from '../utils/localization';
@@ -53,7 +53,7 @@ export default function DynamicCategories({ payload, categories, loading }: Dyna
>
<div className="absolute inset-0 bg-gradient-to-t from-pop-bg/90 via-pop-bg/40 to-transparent z-10 opacity-80 group-hover:opacity-100 transition-opacity" />
<img
src={cat.imageUrl || getCategoryImage(getLocalizedField(cat.name, lang))}
src={resolveCategoryMediaUrl(cat, 'card_portrait', getCategoryImage(getLocalizedField(cat.name, lang)))}
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
alt={getLocalizedField(cat.name, lang)}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
+21 -8
View File
@@ -5,7 +5,7 @@ import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
import { API_BASE, getCategoryImage, type Category, type Product, type PageSection } from '../types'
import { API_BASE, getCategoryImage, getCategoryMedia, resolveCategoryMediaUrl, type Category, type Product, type PageSection } from '../types'
import { getLocalizedField } from '../utils/localization'
import { Sparkles, Layers, Grid3X3, ArrowRight } from 'lucide-react'
@@ -101,7 +101,13 @@ export default function CategoryPage() {
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER')
const categoryName = getLocalizedField(category?.name, lang)
const categoryImageUrl = category?.imageUrl || (categoryName ? getCategoryImage(categoryName) : '')
const fallbackImageUrl = categoryName ? getCategoryImage(categoryName) : ''
const categoryThumbnailUrl = resolveCategoryMediaUrl(category, 'thumbnail', fallbackImageUrl)
const heroDesktopUrl = resolveCategoryMediaUrl(category, 'hero_desktop', fallbackImageUrl)
const heroMobileUrl = resolveCategoryMediaUrl(category, 'hero_mobile', heroDesktopUrl)
const heroDesktopMedia = getCategoryMedia(category, 'hero_desktop')
const heroMobileMedia = getCategoryMedia(category, 'hero_mobile')
const hasArtDirectedHero = Boolean(category?.media?.some((item) => item.role === 'hero_desktop' || item.role === 'hero_mobile'))
const layoutType = category?.layoutType || 'split_showcase'
// Determine Product Grid Class based on layout archetype
@@ -130,7 +136,7 @@ export default function CategoryPage() {
<Navbar categories={categories} />
{/* Hero / Banner Area */}
{heroSections.length > 0 ? (
{heroSections.length > 0 && !(layoutType === 'editorial_hero' && hasArtDirectedHero) ? (
<div className="pt-20">
<DynamicPageRenderer
sections={heroSections}
@@ -142,12 +148,19 @@ export default function CategoryPage() {
/* ── Archetype 2: Editorial Hero Layout ── */
<section className="relative overflow-hidden min-h-[380px] md:min-h-[460px] flex items-center justify-center border-b border-white/10 bg-night-950">
{/* Immersive Background Image with Dark Gradient Overlays */}
{categoryImageUrl && (
{heroDesktopUrl && (
<div className="absolute inset-0 z-0">
<img
src={categoryImageUrl}
src={heroDesktopUrl}
alt={categoryName}
className="w-full h-full object-cover object-center filter brightness-[0.45] saturate-125 scale-105 transform transition-transform duration-1000"
className="hidden h-full w-full object-cover filter brightness-[0.45] saturate-125 scale-105 transform transition-transform duration-1000 md:block"
style={{ objectPosition: `${(heroDesktopMedia?.focalX ?? 0.5) * 100}% ${(heroDesktopMedia?.focalY ?? 0.5) * 100}%` }}
/>
<img
src={heroMobileUrl}
alt={categoryName}
className="h-full w-full object-cover filter brightness-[0.45] saturate-125 scale-105 transform transition-transform duration-1000 md:hidden"
style={{ objectPosition: `${(heroMobileMedia?.focalX ?? 0.5) * 100}% ${(heroMobileMedia?.focalY ?? 0.5) * 100}%` }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-night-950 via-night-950/70 to-night-950/40" />
<div className="absolute inset-0 bg-radial-gradient from-transparent via-night-950/40 to-night-950/90" />
@@ -257,10 +270,10 @@ export default function CategoryPage() {
)}
</div>
{categoryImageUrl && (
{categoryThumbnailUrl && (
<div className="relative w-48 h-48 md:w-64 md:h-64 shrink-0 rounded-3xl overflow-hidden shadow-2xl border border-white/10 bg-night-950 group">
<img
src={categoryImageUrl}
src={categoryThumbnailUrl}
alt={categoryName}
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
@@ -4,7 +4,7 @@ import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { generateSeoMetadata } from '@/lib/seo'
import type { Category, Product } from '@homaroma/types'
import { getCategoryImage } from '@homaroma/types'
import { getCategoryImage, getCategoryMedia, resolveCategoryMediaUrl } from '@homaroma/types'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
@@ -37,18 +37,21 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
: (brandName ? `Shop ${brandName} category products.` : 'Shop category products.')
const category = result.ok ? result.data.category : undefined
const categoryImage = category?.imageUrl || (categoryName ? getCategoryImage(categoryName) : undefined) || settings?.defaultOgImage
const categoryImage = category
? resolveCategoryMediaUrl(category, 'social_share', categoryName ? getCategoryImage(categoryName) : '')
: undefined
const socialImage = categoryImage || settings?.defaultOgImage
return generateSeoMetadata({
title: categoryName ? `${categoryName}` : 'Category',
description,
path: `/category/${id}`,
lang,
openGraph: categoryImage
openGraph: socialImage
? {
title: categoryName || undefined,
description,
images: [categoryImage],
images: [socialImage],
}
: undefined,
})
@@ -93,7 +96,13 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
const category = categoryProductsResult.data.category
const products = categoryProductsResult.data.data ?? []
const categoryName = resolveLocalized(category.name, lang)
const categoryImageUrl = category.imageUrl || getCategoryImage(categoryName)
const fallbackImageUrl = getCategoryImage(categoryName)
const categoryThumbnailUrl = resolveCategoryMediaUrl(category, 'thumbnail', fallbackImageUrl)
const heroDesktopUrl = resolveCategoryMediaUrl(category, 'hero_desktop', fallbackImageUrl)
const heroMobileUrl = resolveCategoryMediaUrl(category, 'hero_mobile', heroDesktopUrl)
const heroDesktopMedia = getCategoryMedia(category, 'hero_desktop')
const heroMobileMedia = getCategoryMedia(category, 'hero_mobile')
const hasArtDirectedHero = Boolean(category.media?.some((item) => item.role === 'hero_desktop' || item.role === 'hero_mobile'))
const layoutType = category.layoutType || 'split_showcase'
// Determine Product Grid Class based on layout archetype
@@ -114,19 +123,30 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
{announcementSections.length > 0 && (
<DynamicPageRenderer sections={announcementSections} categories={categories} lang={lang} />
)}
{heroSections.length > 0 ? (
{heroSections.length > 0 && !(layoutType === 'editorial_hero' && hasArtDirectedHero) ? (
<DynamicPageRenderer sections={heroSections} categories={categories} lang={lang} />
) : layoutType === 'editorial_hero' ? (
/* ── Archetype 2: Editorial Hero Layout ── */
<section className="relative overflow-hidden min-h-[380px] md:min-h-[460px] flex items-center justify-center border-b border-line-primary/10 bg-black">
{categoryImageUrl && (
{heroDesktopUrl && (
<div className="absolute inset-0 z-0">
<Image
src={categoryImageUrl}
src={heroDesktopUrl}
alt={categoryName}
fill
unoptimized
className="object-cover object-center filter brightness-[0.45] saturate-125 scale-105"
sizes="(min-width: 768px) 100vw, 1px"
className="hidden object-cover filter brightness-[0.45] saturate-125 scale-105 md:block"
style={{ objectPosition: `${(heroDesktopMedia?.focalX ?? 0.5) * 100}% ${(heroDesktopMedia?.focalY ?? 0.5) * 100}%` }}
/>
<Image
src={heroMobileUrl}
alt={categoryName}
fill
unoptimized
sizes="(max-width: 767px) 100vw, 1px"
className="object-cover filter brightness-[0.45] saturate-125 scale-105 md:hidden"
style={{ objectPosition: `${(heroMobileMedia?.focalX ?? 0.5) * 100}% ${(heroMobileMedia?.focalY ?? 0.5) * 100}%` }}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/70 to-black/40" />
</div>
@@ -201,7 +221,7 @@ export default async function CategoryPage({ params }: { params: Promise<{ id: s
</div>
<div className="relative w-48 h-48 md:w-72 md:h-72 shrink-0 rounded-3xl overflow-hidden shadow-lg border border-line-primary/5">
<Image
src={categoryImageUrl}
src={categoryThumbnailUrl}
alt={categoryName}
fill
unoptimized
@@ -5,7 +5,7 @@ import Link from '../LocalizedLink'
import { Clock, Mail, MapPin, MessageCircle, Send, ChevronLeft, ChevronRight, Leaf, Droplet, Sparkles, Gift, CheckCircle, ShieldCheck, Heart, Download, FileText } from 'lucide-react'
import { useState, useRef, useEffect, useMemo } from 'react'
import type { Category, PageSection, Product } from '@homaroma/types'
import { getCategoryImage } from '@homaroma/types'
import { getCategoryImage, resolveCategoryMediaUrl } from '@homaroma/types'
import ProductCard from '../ProductCard'
import { localizedFromRecord, resolveCategoryName, resolveLocalized, toRecord } from '../../lib/localization'
import { useLang } from '../../hooks/useLang'
@@ -1003,7 +1003,7 @@ function CategoriesSection({
className="group relative h-72 overflow-hidden rounded-2xl shadow-card transition hover:-translate-y-1 hover:shadow-card-hover"
>
<Image
src={category.imageUrl || getCategoryImage(name)}
src={resolveCategoryMediaUrl(category, 'card_portrait', getCategoryImage(name))}
alt={name}
fill
unoptimized