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