x
This commit is contained in:
1 parent
75770ca122
commit
f258e3e176
16 files changed
+1203
-344
No files matched your search
@@ -7,6 +7,7 @@ interface MediaLibraryModalProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelect: (url: string) => void;
|
onSelect: (url: string) => void;
|
||||||
|
mediaType?: 'all' | 'image' | 'video';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaItem {
|
interface MediaItem {
|
||||||
@@ -16,7 +17,7 @@ interface MediaItem {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLibraryModalProps) {
|
export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType = 'all' }: MediaLibraryModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [media, setMedia] = useState<MediaItem[]>([]);
|
const [media, setMedia] = useState<MediaItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -46,6 +47,16 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
|
|||||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
if (mediaType === 'image' && !file.type.startsWith('image/')) {
|
||||||
|
alert('Please select an image file.');
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mediaType === 'video' && !file.type.startsWith('video/')) {
|
||||||
|
alert('Please select a video file.');
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
@@ -82,6 +93,16 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
|
|||||||
return fn.endsWith('.mp4') || fn.endsWith('.webm') || fn.endsWith('.mov') || fn.endsWith('.ogg');
|
return fn.endsWith('.mp4') || fn.endsWith('.webm') || fn.endsWith('.mov') || fn.endsWith('.ogg');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const visibleMedia = media.filter((item) => {
|
||||||
|
if (mediaType === 'all') return true;
|
||||||
|
return mediaType === 'video' ? isVideoFile(item) : !isVideoFile(item);
|
||||||
|
});
|
||||||
|
const acceptedFileTypes = mediaType === 'image'
|
||||||
|
? 'image/*'
|
||||||
|
: mediaType === 'video'
|
||||||
|
? 'video/*'
|
||||||
|
: 'image/*,video/*';
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -96,7 +117,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-slate-400 mt-1">{t('mediaLibrary.modalSubtitle')}</p>
|
<p className="text-sm text-slate-400 mt-1">{t('mediaLibrary.modalSubtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors text-slate-400 hover:text-white">
|
<button type="button" onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors text-slate-400 hover:text-white">
|
||||||
<X className="w-6 h-6" />
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,10 +130,10 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
|
|||||||
) : (
|
) : (
|
||||||
<><Upload className="w-4 h-4" /> {t('mediaLibrary.btnUploadNewFile')}</>
|
<><Upload className="w-4 h-4" /> {t('mediaLibrary.btnUploadNewFile')}</>
|
||||||
)}
|
)}
|
||||||
<input type="file" className="hidden" accept="image/*,video/*" onChange={handleUpload} disabled={uploading} />
|
<input type="file" className="hidden" accept={acceptedFileTypes} onChange={handleUpload} disabled={uploading} />
|
||||||
</label>
|
</label>
|
||||||
<div className="text-sm text-slate-500 font-mono">
|
<div className="text-sm text-slate-500 font-mono">
|
||||||
{t('mediaLibrary.itemsFound', { count: media.length })}
|
{t('mediaLibrary.itemsFound', { count: visibleMedia.length })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -125,7 +146,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
|
||||||
{media.map((item, idx) => {
|
{visibleMedia.map((item, idx) => {
|
||||||
const isVid = isVideoFile(item);
|
const isVid = isVideoFile(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -164,7 +185,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{media.length === 0 && !loading && (
|
{visibleMedia.length === 0 && !loading && (
|
||||||
<div className="col-span-full flex flex-col items-center justify-center py-20 text-slate-500">
|
<div className="col-span-full flex flex-col items-center justify-center py-20 text-slate-500">
|
||||||
<ImageIcon className="w-16 h-16 mb-4 opacity-50" />
|
<ImageIcon className="w-16 h-16 mb-4 opacity-50" />
|
||||||
<p className="text-lg font-bold">{t('mediaLibrary.noMediaFound')}</p>
|
<p className="text-lg font-bold">{t('mediaLibrary.noMediaFound')}</p>
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import {
|
|||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
X
|
X,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
Monitor,
|
||||||
|
Smartphone,
|
||||||
|
RotateCcw
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { API_BASE, type AttributeDefinition, type Category, type Product, type ProductAttribute, type ProductImage, type Variant } from '@homaroma/types'
|
import { API_BASE, type AttributeDefinition, type Category, type Product, type ProductAttribute, type ProductImage, type Variant } from '@homaroma/types'
|
||||||
import MDEditor from '@uiw/react-md-editor'
|
import MDEditor from '@uiw/react-md-editor'
|
||||||
@@ -32,6 +37,75 @@ type EditableAttribute = {
|
|||||||
translations: Record<string, string>
|
translations: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EditableProductImage = Partial<ProductImage> & {
|
||||||
|
mobileUrl?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageAspectPreviewProps = {
|
||||||
|
url?: string | null
|
||||||
|
label: string
|
||||||
|
expectedRatio: number
|
||||||
|
expectedLabel: string
|
||||||
|
frameClassName: string
|
||||||
|
fallbackUrl?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageAspectPreview({
|
||||||
|
url,
|
||||||
|
label,
|
||||||
|
expectedRatio,
|
||||||
|
expectedLabel,
|
||||||
|
frameClassName,
|
||||||
|
fallbackUrl,
|
||||||
|
}: ImageAspectPreviewProps) {
|
||||||
|
const [loadedImage, setLoadedImage] = useState<{ url: string; width: number; height: number } | null>(null)
|
||||||
|
const [failedUrl, setFailedUrl] = useState<string | null>(null)
|
||||||
|
const displayUrl = url || fallbackUrl || ''
|
||||||
|
const isFallback = !url && Boolean(fallbackUrl)
|
||||||
|
const dimensions = loadedImage?.url === displayUrl ? loadedImage : null
|
||||||
|
const failed = failedUrl === displayUrl
|
||||||
|
const actualRatio = dimensions ? dimensions.width / dimensions.height : null
|
||||||
|
const ratioMismatch = actualRatio !== null && Math.abs(actualRatio - expectedRatio) > 0.04
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className={`relative overflow-hidden rounded-xl border border-white/10 bg-night-950 ${frameClassName}`}>
|
||||||
|
{displayUrl && !failed ? (
|
||||||
|
<img
|
||||||
|
src={displayUrl.replace('/public/uploads/', '/uploads/')}
|
||||||
|
alt={`${label} preview`}
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
onLoad={(event) => {
|
||||||
|
const image = event.currentTarget
|
||||||
|
setLoadedImage({ url: displayUrl, width: image.naturalWidth, height: image.naturalHeight })
|
||||||
|
}}
|
||||||
|
onError={() => setFailedUrl(displayUrl)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-3 text-center text-slate-600">
|
||||||
|
<ImageIcon className="h-6 w-6" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
{failed ? 'Preview unavailable' : `No ${label} image`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isFallback && displayUrl && !failed && (
|
||||||
|
<div className="absolute inset-x-2 bottom-2 flex items-center justify-center gap-1 rounded-lg border border-white/10 bg-night-950/90 px-2 py-1 text-[9px] font-bold uppercase tracking-widest text-slate-300">
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
Desktop fallback
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{dimensions && (
|
||||||
|
<p className={`text-[10px] ${ratioMismatch ? 'text-amber-400' : 'text-slate-500'}`}>
|
||||||
|
{dimensions.width} × {dimensions.height}px
|
||||||
|
{ratioMismatch ? ` · Recommended aspect ratio is ${expectedLabel}` : ` · ${expectedLabel} ratio`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProductEditPage() {
|
export default function ProductEditPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -52,7 +126,7 @@ export default function ProductEditPage() {
|
|||||||
const [details, setDetails] = useState<Record<string, string>>({})
|
const [details, setDetails] = useState<Record<string, string>>({})
|
||||||
const [options, setOptions] = useState<{name: Record<string, string>, values: Record<string, string>[]}[]>([])
|
const [options, setOptions] = useState<{name: Record<string, string>, values: Record<string, string>[]}[]>([])
|
||||||
const [variants, setVariants] = useState<Partial<Variant>[]>([])
|
const [variants, setVariants] = useState<Partial<Variant>[]>([])
|
||||||
const [images, setImages] = useState<Partial<ProductImage>[]>([])
|
const [images, setImages] = useState<EditableProductImage[]>([])
|
||||||
const [availableAttributes, setAvailableAttributes] = useState<AttributeDefinition[]>([])
|
const [availableAttributes, setAvailableAttributes] = useState<AttributeDefinition[]>([])
|
||||||
const [attributes, setAttributes] = useState<EditableAttribute[]>([])
|
const [attributes, setAttributes] = useState<EditableAttribute[]>([])
|
||||||
const [selectedDefinitionId, setSelectedDefinitionId] = useState<number>(0)
|
const [selectedDefinitionId, setSelectedDefinitionId] = useState<number>(0)
|
||||||
@@ -75,11 +149,6 @@ export default function ProductEditPage() {
|
|||||||
|
|
||||||
// removed quillRef
|
// removed quillRef
|
||||||
|
|
||||||
const fixUrl = (url: string) => {
|
|
||||||
if (!url) return ''
|
|
||||||
return url.replace('/public/uploads/', '/uploads/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const openMediaModal = (onSelect: (url: string) => void) => {
|
const openMediaModal = (onSelect: (url: string) => void) => {
|
||||||
setOnSelectMedia(() => onSelect)
|
setOnSelectMedia(() => onSelect)
|
||||||
setIsMediaModalOpen(true)
|
setIsMediaModalOpen(true)
|
||||||
@@ -211,13 +280,27 @@ export default function ProductEditPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveImage = (index: number) => {
|
const handleRemoveImage = (index: number) => {
|
||||||
setImages(images.filter((_, i) => i !== index))
|
setImages((current) => current
|
||||||
|
.filter((_, imageIndex) => imageIndex !== index)
|
||||||
|
.map((image, order) => ({ ...image, order })))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateImage = (index: number, value: string) => {
|
const handleUpdateImage = (index: number, field: 'url' | 'mobileUrl', value: string) => {
|
||||||
const updated = [...images]
|
setImages((current) => current.map((image, imageIndex) => (
|
||||||
updated[index] = { ...updated[index], url: value }
|
imageIndex === index ? { ...image, [field]: value } : image
|
||||||
setImages(updated)
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMoveImage = (index: number, direction: -1 | 1) => {
|
||||||
|
const nextIndex = index + direction
|
||||||
|
if (nextIndex < 0 || nextIndex >= images.length) return
|
||||||
|
|
||||||
|
setImages((current) => {
|
||||||
|
const reordered = [...current]
|
||||||
|
const [moved] = reordered.splice(index, 1)
|
||||||
|
reordered.splice(nextIndex, 0, moved)
|
||||||
|
return reordered.map((image, order) => ({ ...image, order }))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachedDefinitionIds = new Set(attributes.map((attribute) => attribute.attributeDefinitionId))
|
const attachedDefinitionIds = new Set(attributes.map((attribute) => attribute.attributeDefinitionId))
|
||||||
@@ -287,6 +370,10 @@ export default function ProductEditPage() {
|
|||||||
setSuccess(false)
|
setSuccess(false)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (images.some((image) => !image.url?.trim() && image.mobileUrl?.trim())) {
|
||||||
|
throw new Error('Each mobile image needs a Desktop / Tablet image in the same gallery item.')
|
||||||
|
}
|
||||||
|
|
||||||
const isNew = !id
|
const isNew = !id
|
||||||
const method = isNew ? 'POST' : 'PUT'
|
const method = isNew ? 'POST' : 'PUT'
|
||||||
const url = isNew ? `${API_BASE}/products` : `${API_BASE}/products/${id}`
|
const url = isNew ? `${API_BASE}/products` : `${API_BASE}/products/${id}`
|
||||||
@@ -313,7 +400,8 @@ export default function ProductEditPage() {
|
|||||||
images: images.filter(img => img.url).map((img, idx) => ({
|
images: images.filter(img => img.url).map((img, idx) => ({
|
||||||
...(img.id ? { id: img.id } : {}),
|
...(img.id ? { id: img.id } : {}),
|
||||||
url: img.url,
|
url: img.url,
|
||||||
order: img.order ?? (typeof img.order === 'number' ? img.order : idx)
|
mobileUrl: img.mobileUrl?.trim() || null,
|
||||||
|
order: idx
|
||||||
}))
|
}))
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -650,59 +738,202 @@ export default function ProductEditPage() {
|
|||||||
|
|
||||||
{/* Image Management */}
|
{/* Image Management */}
|
||||||
<section className="admin-card space-y-6">
|
<section className="admin-card space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex items-center gap-2 text-cyan-glow">
|
<div className="space-y-1">
|
||||||
<ImageIcon className="w-5 h-5" />
|
<div className="flex items-center gap-2 text-cyan-glow">
|
||||||
<h2 className="font-bold text-lg">Images Gallery</h2>
|
<ImageIcon className="w-5 h-5" />
|
||||||
</div>
|
<h2 className="font-bold text-lg">Responsive Images Gallery</h2>
|
||||||
<div className="flex gap-2">
|
</div>
|
||||||
<button
|
<p className="max-w-2xl text-sm text-slate-500">
|
||||||
onClick={() => openMediaModal((url) => {
|
Each gallery item keeps its Desktop / Tablet and Mobile assets together when reordered or removed.
|
||||||
setImages(prev => [...prev, { url, order: prev.length }])
|
</p>
|
||||||
})}
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-widest hover:bg-cyan-glow/20 transition-all"
|
|
||||||
>
|
|
||||||
<ImagePlus className="w-3.5 h-3.5" />
|
|
||||||
Add / Browse Image
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openMediaModal((url) => {
|
||||||
|
setImages(prev => [...prev, { url, mobileUrl: null, order: prev.length }])
|
||||||
|
})}
|
||||||
|
className="flex shrink-0 items-center justify-center gap-2 rounded-lg border border-cyan-glow/20 bg-cyan-glow/10 px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-cyan-glow transition-all hover:bg-cyan-glow/20"
|
||||||
|
>
|
||||||
|
<ImagePlus className="w-3.5 h-3.5" />
|
||||||
|
Add Gallery Item
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
{images.map((img, idx) => (
|
{images.map((img, idx) => (
|
||||||
<div key={idx} className="flex gap-4 p-4 bg-white/[0.02] border border-white/5 rounded-2xl group animate-fade-in items-center">
|
<div key={img.id ?? `new-image-${idx}`} className="animate-fade-in space-y-5 rounded-2xl border border-white/5 bg-white/[0.02] p-4 sm:p-5">
|
||||||
<div className="w-16 h-16 rounded-lg overflow-hidden bg-night-950 border border-white/5 flex-shrink-0">
|
<div className="flex items-center justify-between gap-4 border-b border-white/5 pb-4">
|
||||||
{img.url ? (
|
<div>
|
||||||
<img src={fixUrl(img.url)} alt="" className="w-full h-full object-cover" />
|
<p className="text-xs font-bold uppercase tracking-widest text-white">Gallery item {idx + 1}</p>
|
||||||
) : (
|
<p className="mt-1 text-[11px] text-slate-500">Both responsive assets move together.</p>
|
||||||
<div className="w-full h-full flex items-center justify-center text-slate-700">
|
</div>
|
||||||
<ImageIcon className="w-6 h-6" />
|
<div className="flex items-center gap-1">
|
||||||
</div>
|
<button
|
||||||
)}
|
type="button"
|
||||||
</div>
|
onClick={() => handleMoveImage(idx, -1)}
|
||||||
<div className="flex-1 space-y-2">
|
disabled={idx === 0}
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Image {idx + 1} URL</label>
|
aria-label={`Move gallery item ${idx + 1} up`}
|
||||||
<div className="relative">
|
title="Move up"
|
||||||
<input
|
className="rounded-lg p-2 text-slate-400 transition-colors hover:bg-white/5 hover:text-cyan-glow disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
type="text"
|
>
|
||||||
value={img.url}
|
<ArrowUp className="h-4 w-4" />
|
||||||
onChange={(e) => handleUpdateImage(idx, e.target.value)}
|
</button>
|
||||||
placeholder="https://..."
|
<button
|
||||||
className="w-full pl-4 pr-10 py-2.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
|
type="button"
|
||||||
/>
|
onClick={() => handleMoveImage(idx, 1)}
|
||||||
{img.url && (
|
disabled={idx === images.length - 1}
|
||||||
<a href={img.url} target="_blank" rel="noreferrer" className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-cyan-glow transition-colors">
|
aria-label={`Move gallery item ${idx + 1} down`}
|
||||||
<ExternalLink className="w-4 h-4" />
|
title="Move down"
|
||||||
</a>
|
className="rounded-lg p-2 text-slate-400 transition-colors hover:bg-white/5 hover:text-cyan-glow disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
)}
|
>
|
||||||
</div>
|
<ArrowDown className="h-4 w-4" />
|
||||||
</div>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveImage(idx)}
|
type="button"
|
||||||
className="p-2.5 rounded-xl text-slate-600 hover:text-rose-500 hover:bg-rose-500/10 transition-all"
|
onClick={() => handleRemoveImage(idx)}
|
||||||
>
|
aria-label={`Remove gallery item ${idx + 1}`}
|
||||||
<Trash2 className="w-5 h-5" />
|
title="Remove image pair"
|
||||||
</button>
|
className="rounded-lg p-2 text-slate-500 transition-all hover:bg-rose-500/10 hover:text-rose-500"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 xl:grid-cols-2">
|
||||||
|
<div className="space-y-4 rounded-2xl border border-white/5 bg-night-950/40 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="rounded-lg border border-cyan-glow/20 bg-cyan-glow/10 p-2 text-cyan-glow">
|
||||||
|
<Monitor className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-white">Desktop / Tablet · 3:4</h3>
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">Recommended 1200 × 1600px</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[9rem_minmax(0,1fr)]">
|
||||||
|
<ImageAspectPreview
|
||||||
|
url={img.url}
|
||||||
|
label="Desktop"
|
||||||
|
expectedRatio={3 / 4}
|
||||||
|
expectedLabel="3:4"
|
||||||
|
frameClassName="aspect-[3/4] w-full"
|
||||||
|
/>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="ml-1 text-[10px] font-bold uppercase tracking-widest text-slate-500">Desktop image URL</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={img.url || ''}
|
||||||
|
onChange={(e) => handleUpdateImage(idx, 'url', e.target.value)}
|
||||||
|
placeholder="https://... or /uploads/..."
|
||||||
|
className="w-full rounded-xl border border-white/10 bg-night-950 py-2.5 pl-4 pr-10 text-sm text-white shadow-inner outline-none transition-all focus:border-cyan-glow/30"
|
||||||
|
/>
|
||||||
|
{img.url && (
|
||||||
|
<a href={img.url} target="_blank" rel="noreferrer" aria-label="Open desktop image" className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 transition-colors hover:text-cyan-glow">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openMediaModal((url) => handleUpdateImage(idx, 'url', url))}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-cyan-glow/20 bg-cyan-glow/10 px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-cyan-glow transition-colors hover:bg-cyan-glow/20"
|
||||||
|
>
|
||||||
|
<ImagePlus className="h-3.5 w-3.5" />
|
||||||
|
{img.url ? 'Replace / Browse' : 'Add / Browse'}
|
||||||
|
</button>
|
||||||
|
{img.url && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUpdateImage(idx, 'url', '')}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-rose-400 transition-colors hover:bg-rose-500/20"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!img.url && img.mobileUrl && (
|
||||||
|
<p className="text-[11px] text-amber-400">Desktop / Tablet image is required before this pair can be saved.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-2xl border border-white/5 bg-night-950/40 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="rounded-lg border border-cyan-glow/20 bg-cyan-glow/10 p-2 text-cyan-glow">
|
||||||
|
<Smartphone className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-white">Mobile · 1:1</h3>
|
||||||
|
<p className="mt-1 text-[11px] text-slate-500">Recommended 1200 × 1200px · Optional</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[9rem_minmax(0,1fr)]">
|
||||||
|
<ImageAspectPreview
|
||||||
|
url={img.mobileUrl}
|
||||||
|
fallbackUrl={img.url}
|
||||||
|
label="Mobile"
|
||||||
|
expectedRatio={1}
|
||||||
|
expectedLabel="1:1"
|
||||||
|
frameClassName="aspect-square w-full"
|
||||||
|
/>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="ml-1 text-[10px] font-bold uppercase tracking-widest text-slate-500">Mobile image URL</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={img.mobileUrl || ''}
|
||||||
|
onChange={(e) => handleUpdateImage(idx, 'mobileUrl', e.target.value)}
|
||||||
|
placeholder="Optional — uses Desktop image when empty"
|
||||||
|
className="w-full rounded-xl border border-white/10 bg-night-950 py-2.5 pl-4 pr-10 text-sm text-white shadow-inner outline-none transition-all focus:border-cyan-glow/30"
|
||||||
|
/>
|
||||||
|
{img.mobileUrl && (
|
||||||
|
<a href={img.mobileUrl} target="_blank" rel="noreferrer" aria-label="Open mobile image" className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 transition-colors hover:text-cyan-glow">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openMediaModal((url) => handleUpdateImage(idx, 'mobileUrl', url))}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-cyan-glow/20 bg-cyan-glow/10 px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-cyan-glow transition-colors hover:bg-cyan-glow/20"
|
||||||
|
>
|
||||||
|
<ImagePlus className="h-3.5 w-3.5" />
|
||||||
|
{img.mobileUrl ? 'Replace / Browse' : 'Add / Browse'}
|
||||||
|
</button>
|
||||||
|
{img.mobileUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUpdateImage(idx, 'mobileUrl', '')}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-rose-400 transition-colors hover:bg-rose-500/20"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!img.mobileUrl && img.url && (
|
||||||
|
<p className="flex items-center gap-1.5 text-[11px] text-slate-400">
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
Mobile storefront will fall back to the Desktop image.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -711,7 +942,7 @@ export default function ProductEditPage() {
|
|||||||
<div className="w-12 h-12 bg-white/5 rounded-full flex items-center justify-center mx-auto text-slate-600">
|
<div className="w-12 h-12 bg-white/5 rounded-full flex items-center justify-center mx-auto text-slate-600">
|
||||||
<ImageIcon className="w-6 h-6" />
|
<ImageIcon className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-600 italic text-sm">No product images added yet. Default category image will be used.</p>
|
<p className="text-slate-600 italic text-sm">No gallery items added yet. The storefront will use its existing fallback image.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -926,6 +1157,7 @@ export default function ProductEditPage() {
|
|||||||
<MediaLibraryModal
|
<MediaLibraryModal
|
||||||
isOpen={isMediaModalOpen}
|
isOpen={isMediaModalOpen}
|
||||||
onClose={() => setIsMediaModalOpen(false)}
|
onClose={() => setIsMediaModalOpen(false)}
|
||||||
|
mediaType="image"
|
||||||
onSelect={(url) => {
|
onSelect={(url) => {
|
||||||
if (onSelectMedia) onSelectMedia(url)
|
if (onSelectMedia) onSelectMedia(url)
|
||||||
}}
|
}}
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ProductImage" ADD COLUMN "mobileUrl" TEXT;
|
||||||
@@ -118,6 +118,7 @@ model ProductTranslation {
|
|||||||
model ProductImage {
|
model ProductImage {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
url String
|
url String
|
||||||
|
mobileUrl String?
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
productId Int
|
productId Int
|
||||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
assertProductImageIdsBelongToProduct,
|
||||||
|
normalizeProductImageInput,
|
||||||
|
normalizeProductImagesInput,
|
||||||
|
ProductImageConflictError,
|
||||||
|
ProductImagePayloadError,
|
||||||
|
} from './productImages'
|
||||||
|
|
||||||
|
describe('product image request normalization', () => {
|
||||||
|
test('normalizes mixed legacy and paired image arrays', () => {
|
||||||
|
expect(normalizeProductImagesInput([
|
||||||
|
'/uploads/products/legacy.webp',
|
||||||
|
{
|
||||||
|
url: '/uploads/products/desktop.webp',
|
||||||
|
mobileUrl: '/uploads/products/mobile.webp',
|
||||||
|
order: 4,
|
||||||
|
},
|
||||||
|
])).toEqual([
|
||||||
|
{ url: '/uploads/products/legacy.webp' },
|
||||||
|
{
|
||||||
|
url: '/uploads/products/desktop.webp',
|
||||||
|
mobileUrl: '/uploads/products/mobile.webp',
|
||||||
|
order: 4,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves omitted mobile URLs and normalizes empty values to null', () => {
|
||||||
|
expect(normalizeProductImageInput({ url: '/desktop.webp' }).mobileUrl).toBeUndefined()
|
||||||
|
expect(normalizeProductImageInput({ url: '/desktop.webp', mobileUrl: '' }).mobileUrl).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns indexed validation detail for invalid URL types', () => {
|
||||||
|
try {
|
||||||
|
normalizeProductImagesInput([{ url: '/desktop.webp' }, { url: false }])
|
||||||
|
throw new Error('Expected normalization to fail')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ProductImagePayloadError)
|
||||||
|
expect((error as ProductImagePayloadError).issues[0]?.path).toBe('images.1.url')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects duplicate and foreign image IDs before destructive synchronization', () => {
|
||||||
|
expect(() => assertProductImageIdsBelongToProduct([1, 2], [1, 1])).toThrow(ProductImageConflictError)
|
||||||
|
expect(() => assertProductImageIdsBelongToProduct([1, 2], [1, 3])).toThrow(ProductImageConflictError)
|
||||||
|
expect(() => assertProductImageIdsBelongToProduct([1, 2], [2, 1])).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
ProductImageInputSchema,
|
||||||
|
type NormalizedProductImageInput,
|
||||||
|
} from '@homaroma/types'
|
||||||
|
|
||||||
|
export class ProductImagePayloadError extends Error {
|
||||||
|
readonly code = 'INVALID_PRODUCT_IMAGES'
|
||||||
|
readonly issues: Array<{ path: string; message: string }>
|
||||||
|
|
||||||
|
constructor(index: number, issues: Array<{ path: PropertyKey[]; message: string; errors?: unknown }>) {
|
||||||
|
super(`Invalid product image at index ${index}`)
|
||||||
|
this.name = 'ProductImagePayloadError'
|
||||||
|
const flattenedIssues = flattenValidationIssues(issues)
|
||||||
|
const detailedIssues = flattenedIssues.some((issue) => issue.path.length > 0)
|
||||||
|
? flattenedIssues.filter((issue) => issue.path.length > 0)
|
||||||
|
: flattenedIssues
|
||||||
|
this.issues = detailedIssues.map((issue) => ({
|
||||||
|
path: ['images', index, ...issue.path].join('.'),
|
||||||
|
message: issue.message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProductImageConflictError extends Error {
|
||||||
|
readonly code = 'PRODUCT_IMAGE_CONFLICT'
|
||||||
|
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ProductImageConflictError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenValidationIssues(
|
||||||
|
issues: Array<{ path: PropertyKey[]; message: string; errors?: unknown }>,
|
||||||
|
): Array<{ path: PropertyKey[]; message: string }> {
|
||||||
|
return issues.flatMap((issue) => {
|
||||||
|
if (!Array.isArray(issue.errors)) {
|
||||||
|
return [{ path: issue.path, message: issue.message }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = issue.errors.flatMap((branch) =>
|
||||||
|
Array.isArray(branch)
|
||||||
|
? flattenValidationIssues(branch as Array<{ path: PropertyKey[]; message: string; errors?: unknown }>)
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
return nested.length > 0 ? nested : [{ path: issue.path, message: issue.message }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProductImageInput(
|
||||||
|
input: unknown,
|
||||||
|
index = 0,
|
||||||
|
): NormalizedProductImageInput {
|
||||||
|
const result = ProductImageInputSchema.safeParse(input)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new ProductImagePayloadError(index, result.error.issues)
|
||||||
|
}
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProductImagesInput(input: unknown): NormalizedProductImageInput[] {
|
||||||
|
if (!Array.isArray(input)) {
|
||||||
|
throw new ProductImagePayloadError(0, [{
|
||||||
|
path: [],
|
||||||
|
message: 'Product images must be an array',
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
return input.map((image, index) => normalizeProductImageInput(image, index))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertProductImageIdsBelongToProduct(
|
||||||
|
existingIds: number[],
|
||||||
|
incomingIds: number[],
|
||||||
|
) {
|
||||||
|
if (new Set(incomingIds).size !== incomingIds.length) {
|
||||||
|
throw new ProductImageConflictError('Product image IDs must be unique')
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = new Set(existingIds)
|
||||||
|
const unknownIds = incomingIds.filter((id) => !existing.has(id))
|
||||||
|
if (unknownIds.length > 0) {
|
||||||
|
throw new ProductImageConflictError(`Product images changed or do not belong to this product: ${unknownIds.join(', ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productImageErrorResponse(error: ProductImagePayloadError) {
|
||||||
|
return {
|
||||||
|
success: false as const,
|
||||||
|
message: 'Invalid product images',
|
||||||
|
issues: error.issues,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
normalizeProductTranslationCreate,
|
||||||
|
normalizeProductTranslationUpdate,
|
||||||
|
ProductTranslationPayloadError,
|
||||||
|
syncProductTranslations,
|
||||||
|
} from './productTranslations'
|
||||||
|
|
||||||
|
describe('product translation update normalization', () => {
|
||||||
|
test('normalizes multilingual objects and syncs the Thai legacy fields', () => {
|
||||||
|
expect(normalizeProductTranslationUpdate({
|
||||||
|
name: { th: 'ชื่อสินค้า', en: 'Product name' },
|
||||||
|
description: { th: 'คำอธิบาย', ja: '商品説明' },
|
||||||
|
})).toEqual({
|
||||||
|
base: { name: 'ชื่อสินค้า', description: 'คำอธิบาย' },
|
||||||
|
byLanguage: {
|
||||||
|
th: { name: 'ชื่อสินค้า', description: 'คำอธิบาย' },
|
||||||
|
en: { name: 'Product name' },
|
||||||
|
ja: { description: '商品説明' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('treats strings as Thai values', () => {
|
||||||
|
expect(normalizeProductTranslationUpdate({
|
||||||
|
name: 'ชื่อใหม่',
|
||||||
|
details: 'รายละเอียดใหม่',
|
||||||
|
})).toEqual({
|
||||||
|
base: { name: 'ชื่อใหม่', details: 'รายละเอียดใหม่' },
|
||||||
|
byLanguage: {
|
||||||
|
th: { name: 'ชื่อใหม่', details: 'รายละเอียดใหม่' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves omitted fields and locales', () => {
|
||||||
|
expect(normalizeProductTranslationUpdate({ description: { en: 'Updated only' } })).toEqual({
|
||||||
|
base: {},
|
||||||
|
byLanguage: { en: { description: 'Updated only' } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preserves explicit empty strings as intentional updates', () => {
|
||||||
|
expect(normalizeProductTranslationUpdate({
|
||||||
|
name: { th: '' },
|
||||||
|
description: '',
|
||||||
|
details: { en: '' },
|
||||||
|
})).toEqual({
|
||||||
|
base: { name: '', description: '' },
|
||||||
|
byLanguage: {
|
||||||
|
th: { name: '', description: '' },
|
||||||
|
en: { details: '' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['__proto__', JSON.parse('{"name":{"__proto__":"polluted"}}')],
|
||||||
|
['constructor', { name: { constructor: 'polluted' } }],
|
||||||
|
['fr', { name: { fr: 'Nom du produit' } }],
|
||||||
|
])('rejects unsupported or unsafe locale key %s', (locale, payload) => {
|
||||||
|
expect(() => normalizeProductTranslationUpdate(payload)).toThrow(ProductTranslationPayloadError)
|
||||||
|
try {
|
||||||
|
normalizeProductTranslationUpdate(payload)
|
||||||
|
throw new Error('Expected translation normalization to fail')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ProductTranslationPayloadError)
|
||||||
|
expect((error as ProductTranslationPayloadError).code).toBe('INVALID_PRODUCT_TRANSLATIONS')
|
||||||
|
expect((error as ProductTranslationPayloadError).issues[0]?.path).toBe(`name.${locale}`)
|
||||||
|
}
|
||||||
|
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
||||||
|
expect((Object.prototype as Record<string, unknown>).name).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('product translation transaction synchronization', () => {
|
||||||
|
test('writes every locale through the supplied transaction client', async () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const tx = {
|
||||||
|
productTranslation: {
|
||||||
|
upsert: async (args: unknown) => {
|
||||||
|
calls.push(args)
|
||||||
|
return args
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const plan = normalizeProductTranslationUpdate({
|
||||||
|
name: { th: 'ชื่อใหม่', en: 'New name' },
|
||||||
|
description: { en: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await syncProductTranslations(tx as never, 2, plan, 'ชื่อเดิม')
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(2)
|
||||||
|
expect(calls).toContainEqual({
|
||||||
|
where: { productId_langCode: { productId: 2, langCode: 'th' } },
|
||||||
|
update: { name: 'ชื่อใหม่' },
|
||||||
|
create: {
|
||||||
|
productId: 2,
|
||||||
|
langCode: 'th',
|
||||||
|
name: 'ชื่อใหม่',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(calls).toContainEqual({
|
||||||
|
where: { productId_langCode: { productId: 2, langCode: 'en' } },
|
||||||
|
update: { name: 'New name', description: '' },
|
||||||
|
create: {
|
||||||
|
productId: 2,
|
||||||
|
langCode: 'en',
|
||||||
|
name: 'New name',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses the current product name when creating a description-only locale', async () => {
|
||||||
|
let call: any
|
||||||
|
const tx = {
|
||||||
|
productTranslation: {
|
||||||
|
upsert: async (args: unknown) => {
|
||||||
|
call = args
|
||||||
|
return args
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncProductTranslations(
|
||||||
|
tx as never,
|
||||||
|
2,
|
||||||
|
normalizeProductTranslationUpdate({ description: { ja: '説明' } }),
|
||||||
|
'Fallback name',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(call.create.name).toBe('Fallback name')
|
||||||
|
expect(call.update).toEqual({ description: '説明' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('product translation create normalization', () => {
|
||||||
|
test('preserves nested translations and lets direct localized fields take precedence', () => {
|
||||||
|
expect(normalizeProductTranslationCreate({
|
||||||
|
name: { th: 'ชื่อไทย', en: 'Direct English' },
|
||||||
|
description: { th: 'คำอธิบายไทย' },
|
||||||
|
translations: {
|
||||||
|
en: { name: 'Nested English', description: 'Nested description' },
|
||||||
|
ja: { name: '日本語名' },
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
base: { name: 'ชื่อไทย', description: 'คำอธิบายไทย', details: '' },
|
||||||
|
byLanguage: {
|
||||||
|
en: { name: 'Direct English', description: 'Nested description', details: '' },
|
||||||
|
ja: { name: '日本語名', description: 'คำอธิบายไทย', details: '' },
|
||||||
|
th: { name: 'ชื่อไทย', description: 'คำอธิบายไทย', details: '' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates the legacy Thai fallback row when no translations are supplied', () => {
|
||||||
|
expect(normalizeProductTranslationCreate({ name: 'New product' })).toEqual({
|
||||||
|
base: { name: 'New product', description: '', details: '' },
|
||||||
|
byLanguage: {
|
||||||
|
th: { name: 'New product', description: '', details: '' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not add a Thai row for string fields when nested translations select other locales', () => {
|
||||||
|
expect(normalizeProductTranslationCreate({
|
||||||
|
name: 'Fallback product',
|
||||||
|
translations: { en: { name: 'English product' } },
|
||||||
|
})).toEqual({
|
||||||
|
base: { name: 'Fallback product', description: '', details: '' },
|
||||||
|
byLanguage: {
|
||||||
|
en: { name: 'English product', description: '', details: '' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each(['__proto__', 'constructor', 'fr'])(
|
||||||
|
'rejects unsafe or unsupported nested translation locale %s',
|
||||||
|
(locale) => {
|
||||||
|
const payload = JSON.parse(`{"translations":{"${locale}":{"name":"unsafe"}}}`)
|
||||||
|
expect(() => normalizeProductTranslationCreate(payload)).toThrow(ProductTranslationPayloadError)
|
||||||
|
expect((Object.prototype as Record<string, unknown>).name).toBeUndefined()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
|
const TRANSLATABLE_FIELDS = ['name', 'description', 'details'] as const
|
||||||
|
export const PRODUCT_TRANSLATION_LOCALES = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'] as const
|
||||||
|
const PRODUCT_TRANSLATION_LOCALE_SET = new Set<string>(PRODUCT_TRANSLATION_LOCALES)
|
||||||
|
|
||||||
|
type TranslatableField = (typeof TRANSLATABLE_FIELDS)[number]
|
||||||
|
type TranslationValues = Partial<Record<TranslatableField, string>>
|
||||||
|
|
||||||
|
interface ProductTranslationCreatePlan {
|
||||||
|
base: Required<TranslationValues>
|
||||||
|
byLanguage: Record<string, Required<TranslationValues>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductTranslationUpdatePlan {
|
||||||
|
base: TranslationValues
|
||||||
|
byLanguage: Record<string, TranslationValues>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProductTranslationPayloadError extends Error {
|
||||||
|
readonly code = 'INVALID_PRODUCT_TRANSLATIONS'
|
||||||
|
readonly issues: Array<{ path: string; message: string }>
|
||||||
|
|
||||||
|
constructor(path: string, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ProductTranslationPayloadError'
|
||||||
|
this.issues = [{ path, message }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSupportedLocale(fieldPath: string, langCode: string) {
|
||||||
|
if (!PRODUCT_TRANSLATION_LOCALE_SET.has(langCode)) {
|
||||||
|
throw new ProductTranslationPayloadError(
|
||||||
|
`${fieldPath}.${langCode}`,
|
||||||
|
`Unsupported product translation locale: ${langCode}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyBaseValue(rawValue: unknown, fallback: string) {
|
||||||
|
if (typeof rawValue === 'string') return rawValue
|
||||||
|
if (!isRecord(rawValue)) return fallback
|
||||||
|
const thai = typeof rawValue.th === 'string' ? rawValue.th : ''
|
||||||
|
const english = typeof rawValue.en === 'string' ? rawValue.en : ''
|
||||||
|
return thai || english || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes the two request shapes supported by product writes:
|
||||||
|
* - a string updates Thai
|
||||||
|
* - an object updates only the explicitly supplied locales
|
||||||
|
*
|
||||||
|
* Empty strings are intentional values. Missing fields/locales are excluded so
|
||||||
|
* partial updates never erase translations that were not sent by the caller.
|
||||||
|
*/
|
||||||
|
export function normalizeProductTranslationUpdate(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): ProductTranslationUpdatePlan {
|
||||||
|
const plan: ProductTranslationUpdatePlan = {
|
||||||
|
base: {},
|
||||||
|
byLanguage: Object.create(null) as Record<string, TranslationValues>,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of TRANSLATABLE_FIELDS) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(payload, field)) continue
|
||||||
|
|
||||||
|
const rawValue = payload[field]
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
plan.byLanguage.th ??= {}
|
||||||
|
plan.byLanguage.th[field] = rawValue
|
||||||
|
plan.base[field] = rawValue
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(rawValue)) continue
|
||||||
|
for (const [langCode, localizedValue] of Object.entries(rawValue)) {
|
||||||
|
assertSupportedLocale(field, langCode)
|
||||||
|
if (typeof localizedValue !== 'string') continue
|
||||||
|
plan.byLanguage[langCode] ??= {}
|
||||||
|
plan.byLanguage[langCode][field] = localizedValue
|
||||||
|
if (langCode === 'th') plan.base[field] = localizedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalizes the legacy create payload, including its optional nested
|
||||||
|
* `translations` object. Direct name/description/details values retain
|
||||||
|
* precedence, matching the previous create behavior. */
|
||||||
|
export function normalizeProductTranslationCreate(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): ProductTranslationCreatePlan {
|
||||||
|
const direct = normalizeProductTranslationUpdate(payload)
|
||||||
|
const base = {
|
||||||
|
name: legacyBaseValue(payload.name, 'New Product'),
|
||||||
|
description: legacyBaseValue(payload.description, ''),
|
||||||
|
details: legacyBaseValue(payload.details, ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = Object.create(null) as Record<string, TranslationValues>
|
||||||
|
if (isRecord(payload.translations)) {
|
||||||
|
for (const [langCode, rawTranslation] of Object.entries(payload.translations)) {
|
||||||
|
assertSupportedLocale('translations', langCode)
|
||||||
|
if (!isRecord(rawTranslation)) continue
|
||||||
|
const values: TranslationValues = {}
|
||||||
|
for (const field of TRANSLATABLE_FIELDS) {
|
||||||
|
if (typeof rawTranslation[field] === 'string') values[field] = rawTranslation[field]
|
||||||
|
}
|
||||||
|
if (Object.keys(values).length > 0) pending[langCode] = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [langCode, values] of Object.entries(direct.byLanguage)) {
|
||||||
|
const objectLocalizedValues: TranslationValues = {}
|
||||||
|
for (const field of TRANSLATABLE_FIELDS) {
|
||||||
|
if (isRecord(payload[field]) && values[field] !== undefined) {
|
||||||
|
objectLocalizedValues[field] = values[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(objectLocalizedValues).length > 0) {
|
||||||
|
pending[langCode] = { ...(pending[langCode] ?? {}), ...objectLocalizedValues }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(pending).length === 0) pending.th = { ...base }
|
||||||
|
|
||||||
|
const byLanguage = Object.create(null) as Record<string, Required<TranslationValues>>
|
||||||
|
for (const [langCode, values] of Object.entries(pending)) {
|
||||||
|
byLanguage[langCode] = {
|
||||||
|
name: values.name || base.name,
|
||||||
|
description: values.description || base.description,
|
||||||
|
details: values.details || base.details,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { base, byLanguage }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productTranslationErrorResponse(error: ProductTranslationPayloadError) {
|
||||||
|
return {
|
||||||
|
success: false as const,
|
||||||
|
code: error.code,
|
||||||
|
message: 'Invalid product translations',
|
||||||
|
issues: error.issues,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductTranslationWriter = Pick<Prisma.TransactionClient, 'productTranslation'>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a normalized plan through the caller's transaction client. Keeping
|
||||||
|
* this helper transaction-client-only prevents product and translation writes
|
||||||
|
* from accidentally being split across independent requests/transactions.
|
||||||
|
*/
|
||||||
|
export async function syncProductTranslations(
|
||||||
|
tx: ProductTranslationWriter,
|
||||||
|
productId: number,
|
||||||
|
plan: ProductTranslationUpdatePlan,
|
||||||
|
fallbackName: string | null | undefined,
|
||||||
|
) {
|
||||||
|
for (const [langCode, values] of Object.entries(plan.byLanguage)) {
|
||||||
|
await tx.productTranslation.upsert({
|
||||||
|
where: { productId_langCode: { productId, langCode } },
|
||||||
|
update: values,
|
||||||
|
create: {
|
||||||
|
productId,
|
||||||
|
langCode,
|
||||||
|
name: values.name ?? fallbackName ?? '',
|
||||||
|
...(values.description !== undefined ? { description: values.description } : {}),
|
||||||
|
...(values.details !== undefined ? { details: values.details } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,11 @@ import { adminOnly } from "../middleware/auth";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
|
import {
|
||||||
|
normalizeProductImagesInput,
|
||||||
|
productImageErrorResponse,
|
||||||
|
ProductImagePayloadError,
|
||||||
|
} from "../lib/productImages";
|
||||||
|
|
||||||
// ─── Helper: stream JSON as file download ────────────────────────────────────
|
// ─── Helper: stream JSON as file download ────────────────────────────────────
|
||||||
|
|
||||||
@@ -828,6 +833,18 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
|
|||||||
return { error: "Invalid catalog payload: Expected object with categories, attributes, brands, or products" };
|
return { error: "Invalid catalog payload: Expected object with categories, attributes, brands, or products" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate every product image payload before the restore performs any writes.
|
||||||
|
// This keeps malformed paired-image data from causing a partially restored catalog.
|
||||||
|
const products = Array.isArray(payload.products)
|
||||||
|
? payload.products
|
||||||
|
: (Array.isArray(payload) && payload[0]?.variants ? payload : []);
|
||||||
|
const normalizedImagesByProduct = new Map<any, ReturnType<typeof normalizeProductImagesInput>>();
|
||||||
|
for (const product of products) {
|
||||||
|
if (product?.images !== undefined) {
|
||||||
|
normalizedImagesByProduct.set(product, normalizeProductImagesInput(product.images));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
categories: 0,
|
categories: 0,
|
||||||
attributes: 0,
|
attributes: 0,
|
||||||
@@ -1010,10 +1027,6 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Products
|
// 4. Products
|
||||||
const products = Array.isArray(payload.products)
|
|
||||||
? payload.products
|
|
||||||
: (Array.isArray(payload) && payload[0]?.variants ? payload : []);
|
|
||||||
|
|
||||||
for (const prod of products) {
|
for (const prod of products) {
|
||||||
if (!prod.name) continue;
|
if (!prod.name) continue;
|
||||||
const catId = prod.categoryId ? (categoryMap.get(prod.categoryId) || prod.categoryId) : defaultCatId;
|
const catId = prod.categoryId ? (categoryMap.get(prod.categoryId) || prod.categoryId) : defaultCatId;
|
||||||
@@ -1088,20 +1101,19 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Images
|
// Images
|
||||||
if (Array.isArray(prod.images) && prod.images.length > 0) {
|
if (prod.images !== undefined) {
|
||||||
|
const images = normalizedImagesByProduct.get(prod)!;
|
||||||
await prisma.productImage.deleteMany({ where: { productId: savedProduct.id } });
|
await prisma.productImage.deleteMany({ where: { productId: savedProduct.id } });
|
||||||
for (let i = 0; i < prod.images.length; i++) {
|
for (let i = 0; i < images.length; i++) {
|
||||||
const img = prod.images[i];
|
const img = images[i]!;
|
||||||
const url = typeof img === "string" ? img : img.url;
|
await prisma.productImage.create({
|
||||||
if (url) {
|
data: {
|
||||||
await prisma.productImage.create({
|
productId: savedProduct.id,
|
||||||
data: {
|
url: img.url,
|
||||||
productId: savedProduct.id,
|
mobileUrl: img.mobileUrl ?? null,
|
||||||
url,
|
order: img.order ?? i,
|
||||||
order: typeof img === "object" && img.order !== undefined ? img.order : i,
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1142,6 +1154,10 @@ export const backupRoute = new Elysia({ prefix: "/backup" })
|
|||||||
summary,
|
summary,
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err instanceof ProductImagePayloadError) {
|
||||||
|
set.status = 400;
|
||||||
|
return productImageErrorResponse(err);
|
||||||
|
}
|
||||||
console.error("[Backup] Catalog restore error:", err);
|
console.error("[Backup] Catalog restore error:", err);
|
||||||
set.status = 500;
|
set.status = 500;
|
||||||
return { error: "Catalog restore failed", detail: err?.message };
|
return { error: "Catalog restore failed", detail: err?.message };
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ import {
|
|||||||
validateProductDetailSectionPayload,
|
validateProductDetailSectionPayload,
|
||||||
} from "../lib/productDetailSections";
|
} from "../lib/productDetailSections";
|
||||||
import { hydrateSectionPayload } from "./pages";
|
import { hydrateSectionPayload } from "./pages";
|
||||||
|
import {
|
||||||
|
assertProductImageIdsBelongToProduct,
|
||||||
|
normalizeProductImageInput,
|
||||||
|
normalizeProductImagesInput,
|
||||||
|
productImageErrorResponse,
|
||||||
|
ProductImageConflictError,
|
||||||
|
ProductImagePayloadError,
|
||||||
|
} from "../lib/productImages";
|
||||||
|
import {
|
||||||
|
normalizeProductTranslationCreate,
|
||||||
|
normalizeProductTranslationUpdate,
|
||||||
|
productTranslationErrorResponse,
|
||||||
|
ProductTranslationPayloadError,
|
||||||
|
syncProductTranslations,
|
||||||
|
} from "../lib/productTranslations";
|
||||||
|
|
||||||
function summarizeValidationIssues(issues: any[]) {
|
function summarizeValidationIssues(issues: any[]) {
|
||||||
return issues.map((issue) => ({
|
return issues.map((issue) => ({
|
||||||
@@ -72,11 +87,11 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Normalize Name, Description, Details
|
// 3. Normalize Name, Description, Details and translation rows
|
||||||
const rawName = payload.name;
|
const translationPlan = normalizeProductTranslationCreate(payload);
|
||||||
const pName = typeof rawName === 'string' ? rawName : (rawName?.th || rawName?.en || "New Product");
|
const pName = translationPlan.base.name;
|
||||||
const pDesc = typeof payload.description === 'string' ? payload.description : (payload.description?.th || payload.description?.en || "");
|
const pDesc = translationPlan.base.description;
|
||||||
const pDetails = typeof payload.details === 'string' ? payload.details : (payload.details?.th || payload.details?.en || "");
|
const pDetails = translationPlan.base.details;
|
||||||
|
|
||||||
// 4. Resolve Variants and Options
|
// 4. Resolve Variants and Options
|
||||||
let variants = Array.isArray(payload.variants) ? payload.variants : [];
|
let variants = Array.isArray(payload.variants) ? payload.variants : [];
|
||||||
@@ -110,46 +125,14 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 6. Handle Translations
|
// 6. Handle Translations
|
||||||
const transObj = payload.translations && typeof payload.translations === 'object' ? { ...payload.translations } : {};
|
for (const [lang, trans] of Object.entries(translationPlan.byLanguage)) {
|
||||||
if (typeof rawName === 'object' && rawName !== null) {
|
|
||||||
for (const [lang, val] of Object.entries(rawName)) {
|
|
||||||
if (!transObj[lang]) transObj[lang] = {};
|
|
||||||
transObj[lang].name = val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof payload.description === 'object' && payload.description !== null) {
|
|
||||||
for (const [lang, val] of Object.entries(payload.description)) {
|
|
||||||
if (!transObj[lang]) transObj[lang] = {};
|
|
||||||
transObj[lang].description = val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof payload.details === 'object' && payload.details !== null) {
|
|
||||||
for (const [lang, val] of Object.entries(payload.details)) {
|
|
||||||
if (!transObj[lang]) transObj[lang] = {};
|
|
||||||
transObj[lang].details = val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(transObj).length > 0) {
|
|
||||||
for (const [lang, trans] of Object.entries(transObj)) {
|
|
||||||
await tx.productTranslation.create({
|
|
||||||
data: {
|
|
||||||
productId: prod.id,
|
|
||||||
langCode: lang,
|
|
||||||
name: (trans as any).name || pName,
|
|
||||||
description: (trans as any).description || pDesc,
|
|
||||||
details: (trans as any).details || pDetails,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await tx.productTranslation.create({
|
await tx.productTranslation.create({
|
||||||
data: {
|
data: {
|
||||||
productId: prod.id,
|
productId: prod.id,
|
||||||
langCode: 'th',
|
langCode: lang,
|
||||||
name: pName,
|
name: trans.name,
|
||||||
description: pDesc,
|
description: trans.description,
|
||||||
details: pDetails,
|
details: trans.details,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -171,18 +154,17 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. Create Images (support both ["http..."] and [{ url: "http..." }])
|
// 8. Create Images (support both ["http..."] and [{ url: "http..." }])
|
||||||
if (payload.images && Array.isArray(payload.images)) {
|
if (payload.images !== undefined) {
|
||||||
for (const [idx, img] of payload.images.entries()) {
|
const images = normalizeProductImagesInput(payload.images);
|
||||||
const url = typeof img === 'string' ? img : img?.url;
|
for (const [idx, img] of images.entries()) {
|
||||||
if (url) {
|
await tx.productImage.create({
|
||||||
await tx.productImage.create({
|
data: {
|
||||||
data: {
|
productId: prod.id,
|
||||||
productId: prod.id,
|
url: img.url,
|
||||||
url,
|
mobileUrl: img.mobileUrl ?? null,
|
||||||
order: typeof img === 'object' && typeof img?.order === 'number' ? img.order : idx,
|
order: img.order ?? idx,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,10 +651,15 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
const id = parseInt(params.id);
|
const id = parseInt(params.id);
|
||||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||||
|
|
||||||
const { categoryId, description, details, variants, images } = body as any;
|
const payload = body as any;
|
||||||
let options = (body as any).options;
|
const { categoryId, variants, images } = payload;
|
||||||
|
let options = payload.options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const normalizedImages = images === undefined
|
||||||
|
? undefined
|
||||||
|
: normalizeProductImagesInput(images);
|
||||||
|
const translationPlan = normalizeProductTranslationUpdate(payload);
|
||||||
if (variants && Array.isArray(variants)) {
|
if (variants && Array.isArray(variants)) {
|
||||||
// If options is not passed but variants are, we might need to fetch existing options
|
// If options is not passed but variants are, we might need to fetch existing options
|
||||||
if (options === undefined) {
|
if (options === undefined) {
|
||||||
@@ -683,7 +670,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
}
|
}
|
||||||
const updatedProduct = await prisma.$transaction(async (tx) => {
|
const updatedProduct = await prisma.$transaction(async (tx) => {
|
||||||
// 1. Update basic product info
|
// 1. Update basic product info
|
||||||
const updateData: any = {};
|
const updateData: any = { ...translationPlan.base };
|
||||||
if (categoryId !== undefined) {
|
if (categoryId !== undefined) {
|
||||||
await assertActiveCategory(categoryId, tx);
|
await assertActiveCategory(categoryId, tx);
|
||||||
updateData.categoryId = categoryId;
|
updateData.categoryId = categoryId;
|
||||||
@@ -694,6 +681,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
where: { id },
|
where: { id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
});
|
});
|
||||||
|
await syncProductTranslations(tx, id, translationPlan, prod.name);
|
||||||
|
|
||||||
// 2. Sync Variants
|
// 2. Sync Variants
|
||||||
if (variants && Array.isArray(variants)) {
|
if (variants && Array.isArray(variants)) {
|
||||||
@@ -757,9 +745,10 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Sync Images
|
// 3. Sync Images
|
||||||
if (images && Array.isArray(images)) {
|
if (normalizedImages) {
|
||||||
const existingImages = await tx.productImage.findMany({ where: { productId: id } });
|
const existingImages = await tx.productImage.findMany({ where: { productId: id } });
|
||||||
const incomingImgIds = images.map((img: any) => img.id).filter(Boolean);
|
const incomingImgIds = normalizedImages.map((img) => img.id).filter((imageId): imageId is number => imageId !== undefined);
|
||||||
|
assertProductImageIdsBelongToProduct(existingImages.map((image) => image.id), incomingImgIds);
|
||||||
|
|
||||||
const imagesToDelete = existingImages.filter(ei => !incomingImgIds.includes(ei.id));
|
const imagesToDelete = existingImages.filter(ei => !incomingImgIds.includes(ei.id));
|
||||||
if (imagesToDelete.length > 0) {
|
if (imagesToDelete.length > 0) {
|
||||||
@@ -768,7 +757,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [idx, img] of images.entries()) {
|
for (const [idx, img] of normalizedImages.entries()) {
|
||||||
if (img.id) {
|
if (img.id) {
|
||||||
const exists = existingImages.find(ei => ei.id === img.id);
|
const exists = existingImages.find(ei => ei.id === img.id);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
@@ -776,7 +765,8 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
where: { id: img.id },
|
where: { id: img.id },
|
||||||
data: {
|
data: {
|
||||||
url: img.url,
|
url: img.url,
|
||||||
order: img.order ?? (typeof img.order === 'number' ? img.order : idx),
|
mobileUrl: img.mobileUrl,
|
||||||
|
order: img.order ?? idx,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -785,7 +775,8 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
data: {
|
data: {
|
||||||
productId: id,
|
productId: id,
|
||||||
url: img.url,
|
url: img.url,
|
||||||
order: img.order ?? (typeof img.order === 'number' ? img.order : idx),
|
mobileUrl: img.mobileUrl,
|
||||||
|
order: img.order ?? idx,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -797,6 +788,15 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
|
|
||||||
return { success: true, product: updatedProduct };
|
return { success: true, product: updatedProduct };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err instanceof ProductTranslationPayloadError) {
|
||||||
|
return status(400, productTranslationErrorResponse(err));
|
||||||
|
}
|
||||||
|
if (err instanceof ProductImageConflictError) {
|
||||||
|
return status(409, { success: false, code: err.code, message: err.message });
|
||||||
|
}
|
||||||
|
if (err instanceof ProductImagePayloadError) {
|
||||||
|
return status(400, productImageErrorResponse(err));
|
||||||
|
}
|
||||||
if (err?.code === "CATEGORY_NOT_FOUND") {
|
if (err?.code === "CATEGORY_NOT_FOUND") {
|
||||||
return status(400, { success: false, message: "Category not found" });
|
return status(400, { success: false, message: "Category not found" });
|
||||||
}
|
}
|
||||||
@@ -817,10 +817,8 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
async ({ params, body, status }) => {
|
async ({ params, body, status }) => {
|
||||||
const id = parseInt(params.id);
|
const id = parseInt(params.id);
|
||||||
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
|
||||||
const { url } = body as any;
|
|
||||||
if (!url) return status(400, { message: "URL is required" });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const image = normalizeProductImageInput(body);
|
||||||
const product = await prisma.product.findUnique({
|
const product = await prisma.product.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { images: true }
|
include: { images: true }
|
||||||
@@ -830,20 +828,25 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
const newImage = await prisma.productImage.create({
|
const newImage = await prisma.productImage.create({
|
||||||
data: {
|
data: {
|
||||||
productId: id,
|
productId: id,
|
||||||
url,
|
url: image.url,
|
||||||
|
mobileUrl: image.mobileUrl ?? null,
|
||||||
order: product.images.length
|
order: product.images.length
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, image: newImage };
|
return { success: true, image: newImage };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err instanceof ProductImagePayloadError) {
|
||||||
|
return status(400, productImageErrorResponse(err));
|
||||||
|
}
|
||||||
console.error("Add product image error:", err);
|
console.error("Add product image error:", err);
|
||||||
return status(500, { success: false, message: err?.message || "Failed to add product image" });
|
return status(500, { success: false, message: err?.message || "Failed to add product image" });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
url: t.String()
|
url: t.String({ minLength: 1 }),
|
||||||
|
mobileUrl: t.Optional(t.Union([t.String(), t.Null()]))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -858,6 +861,12 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
|
|
||||||
return { success: true, product };
|
return { success: true, product };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err instanceof ProductTranslationPayloadError) {
|
||||||
|
return status(400, productTranslationErrorResponse(err));
|
||||||
|
}
|
||||||
|
if (err instanceof ProductImagePayloadError) {
|
||||||
|
return status(400, productImageErrorResponse(err));
|
||||||
|
}
|
||||||
if (err?.code === "CATEGORY_NOT_FOUND") {
|
if (err?.code === "CATEGORY_NOT_FOUND") {
|
||||||
return status(400, { success: false, message: "Category not found" });
|
return status(400, { success: false, message: "Category not found" });
|
||||||
}
|
}
|
||||||
@@ -893,6 +902,12 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
|
|
||||||
return { success: true, count: createdProducts.length, products: createdProducts };
|
return { success: true, count: createdProducts.length, products: createdProducts };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err instanceof ProductTranslationPayloadError) {
|
||||||
|
return status(400, productTranslationErrorResponse(err));
|
||||||
|
}
|
||||||
|
if (err instanceof ProductImagePayloadError) {
|
||||||
|
return status(400, productImageErrorResponse(err));
|
||||||
|
}
|
||||||
if (err?.code === "CATEGORY_NOT_FOUND") {
|
if (err?.code === "CATEGORY_NOT_FOUND") {
|
||||||
return status(400, { success: false, message: "Category not found" });
|
return status(400, { success: false, message: "Category not found" });
|
||||||
}
|
}
|
||||||
@@ -916,64 +931,26 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = body as any;
|
const payload = body as any;
|
||||||
const prodData: any = {};
|
const translationPlan = normalizeProductTranslationUpdate(payload);
|
||||||
|
const prodData: any = { ...translationPlan.base };
|
||||||
if (payload.categoryId !== undefined) prodData.categoryId = payload.categoryId;
|
if (payload.categoryId !== undefined) prodData.categoryId = payload.categoryId;
|
||||||
if (payload.brandId !== undefined) prodData.brandId = payload.brandId;
|
if (payload.brandId !== undefined) prodData.brandId = payload.brandId;
|
||||||
if (payload.options !== undefined) prodData.options = payload.options;
|
if (payload.options !== undefined) prodData.options = payload.options;
|
||||||
|
|
||||||
const prod = await prisma.product.update({
|
const prod = await prisma.$transaction(async (tx) => {
|
||||||
where: { id },
|
const updated = await tx.product.update({
|
||||||
data: prodData,
|
where: { id },
|
||||||
|
data: prodData,
|
||||||
|
});
|
||||||
|
await syncProductTranslations(tx, id, translationPlan, updated.name);
|
||||||
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update translations
|
|
||||||
// If name, description, details are strings, treat as 'th'
|
|
||||||
// If they are objects, iterate over them
|
|
||||||
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'];
|
|
||||||
for (const lang of languages) {
|
|
||||||
const updateData: any = {};
|
|
||||||
let shouldUpdate = false;
|
|
||||||
|
|
||||||
if (payload.name) {
|
|
||||||
const val = typeof payload.name === 'string' ? (lang === 'th' ? payload.name : null) : payload.name[lang];
|
|
||||||
if (val !== undefined && val !== null) { updateData.name = val; shouldUpdate = true; }
|
|
||||||
}
|
|
||||||
if (payload.description) {
|
|
||||||
const val = typeof payload.description === 'string' ? (lang === 'th' ? payload.description : null) : payload.description[lang];
|
|
||||||
if (val !== undefined && val !== null) { updateData.description = val; shouldUpdate = true; }
|
|
||||||
}
|
|
||||||
if (payload.details) {
|
|
||||||
const val = typeof payload.details === 'string' ? (lang === 'th' ? payload.details : null) : payload.details[lang];
|
|
||||||
if (val !== undefined && val !== null) { updateData.details = val; shouldUpdate = true; }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldUpdate) {
|
|
||||||
// First check if translation exists to create or update
|
|
||||||
const existing = await prisma.productTranslation.findUnique({
|
|
||||||
where: { productId_langCode: { productId: id, langCode: lang } }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await prisma.productTranslation.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: updateData
|
|
||||||
});
|
|
||||||
} else if (updateData.name) { // name is required for creation
|
|
||||||
await prisma.productTranslation.create({
|
|
||||||
data: {
|
|
||||||
productId: id,
|
|
||||||
langCode: lang,
|
|
||||||
name: updateData.name,
|
|
||||||
description: updateData.description,
|
|
||||||
details: updateData.details
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, product: prod };
|
return { success: true, product: prod };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err instanceof ProductTranslationPayloadError) {
|
||||||
|
return status(400, productTranslationErrorResponse(err));
|
||||||
|
}
|
||||||
console.error("Patch product error:", err);
|
console.error("Patch product error:", err);
|
||||||
return status(500, { success: false, message: err?.message || "Failed to patch product" });
|
return status(500, { success: false, message: err?.message || "Failed to patch product" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
|
||||||
import type { ProductImage } from '../types'
|
import type { ProductImage } from '../types'
|
||||||
|
|
||||||
interface ProductImageGalleryProps {
|
interface ProductImageGalleryProps {
|
||||||
@@ -6,59 +6,81 @@ interface ProductImageGalleryProps {
|
|||||||
fallback: string
|
fallback: string
|
||||||
alt?: string
|
alt?: string
|
||||||
badgeText?: string
|
badgeText?: string
|
||||||
|
stretchDesktop?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductImageGallery({ images, fallback, alt = 'Product Image', badgeText }: ProductImageGalleryProps) {
|
interface ResponsiveProductImageProps {
|
||||||
|
image: ProductImage
|
||||||
|
fallback: string
|
||||||
|
alt: string
|
||||||
|
className: string
|
||||||
|
loading?: 'eager' | 'lazy'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResponsiveProductImage({ image, fallback, alt, className, loading = 'lazy' }: ResponsiveProductImageProps) {
|
||||||
|
const handleError = (event: SyntheticEvent<HTMLImageElement>) => {
|
||||||
|
const picture = event.currentTarget.parentElement
|
||||||
|
picture?.querySelectorAll('source').forEach((source) => source.remove())
|
||||||
|
event.currentTarget.src = fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<picture className="block h-full w-full">
|
||||||
|
{image.mobileUrl && <source media="(max-width: 767px)" srcSet={image.mobileUrl} />}
|
||||||
|
<img src={image.url} alt={alt} loading={loading} className={className} onError={handleError} />
|
||||||
|
</picture>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductImageGallery({
|
||||||
|
images,
|
||||||
|
fallback,
|
||||||
|
alt = 'Product Image',
|
||||||
|
badgeText,
|
||||||
|
stretchDesktop = false,
|
||||||
|
}: ProductImageGalleryProps) {
|
||||||
const [currentIndex, setCurrentIndex] = useState(0)
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const isScrollingRef = useRef(false)
|
const sortedImages = images?.length
|
||||||
|
? [...images].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
: [{ id: -1, url: fallback, mobileUrl: null, order: 0 }]
|
||||||
|
const imageCount = sortedImages.length
|
||||||
|
const visibleIndex = Math.min(currentIndex, Math.max(imageCount - 1, 0))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const onScrollEnd = () => {
|
const onScrollEnd = () => {
|
||||||
if (isScrollingRef.current) return
|
|
||||||
const width = el.clientWidth
|
const width = el.clientWidth
|
||||||
if (width > 0) {
|
if (width > 0) {
|
||||||
const newIndex = Math.round(el.scrollLeft / width)
|
const newIndex = Math.round(el.scrollLeft / width)
|
||||||
if (newIndex >= 0 && newIndex < (images?.length || 0)) {
|
if (newIndex >= 0 && newIndex < imageCount) setCurrentIndex(newIndex)
|
||||||
setCurrentIndex(newIndex)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
el.addEventListener('scrollend', onScrollEnd)
|
el.addEventListener('scrollend', onScrollEnd)
|
||||||
return () => el.removeEventListener('scrollend', onScrollEnd)
|
return () => el.removeEventListener('scrollend', onScrollEnd)
|
||||||
}, [images?.length])
|
}, [imageCount])
|
||||||
|
|
||||||
const handleThumbnailClick = (idx: number) => {
|
const handleThumbnailClick = (idx: number) => {
|
||||||
setCurrentIndex(idx)
|
setCurrentIndex(idx)
|
||||||
if (scrollRef.current) {
|
const scroller = scrollRef.current
|
||||||
const child = scrollRef.current.children[idx] as HTMLElement
|
const child = scroller?.children[idx] as HTMLElement | undefined
|
||||||
if (child) {
|
if (scroller && child) scroller.scrollTo({ left: child.offsetLeft, behavior: 'smooth' })
|
||||||
isScrollingRef.current = true
|
|
||||||
scrollRef.current.scrollTo({ left: child.offsetLeft, behavior: 'smooth' })
|
|
||||||
setTimeout(() => { isScrollingRef.current = false }, 600)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort images by order if they exist, else just use the array
|
const frameClass = stretchDesktop
|
||||||
const sortedImages = images?.length ? [...images].sort((a, b) => (a.order || 0) - (b.order || 0)) : []
|
? 'relative aspect-square w-full overflow-hidden rounded-3xl bg-pop-bg shadow-sm border border-pop-black/10 md:aspect-auto md:h-full md:min-h-0'
|
||||||
|
: 'relative aspect-square w-full overflow-hidden rounded-3xl bg-pop-bg shadow-sm border border-pop-black/10'
|
||||||
|
const mainImageClass = stretchDesktop
|
||||||
|
? 'h-full w-full object-cover md:object-contain'
|
||||||
|
: 'h-full w-full object-cover'
|
||||||
|
|
||||||
if (sortedImages.length === 0) return null
|
if (imageCount === 1) {
|
||||||
|
|
||||||
// If there's only one image, don't show thumbnails
|
|
||||||
if (sortedImages.length === 1) {
|
|
||||||
return (
|
return (
|
||||||
<div className="relative aspect-square w-full flex-1 overflow-hidden rounded-3xl bg-pop-bg shadow-sm border border-pop-black/10 flex items-center justify-center">
|
<div className={frameClass}>
|
||||||
<img
|
<ResponsiveProductImage image={sortedImages[0]} fallback={fallback} alt={alt} loading="eager" className={mainImageClass} />
|
||||||
src={sortedImages[0].url}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
alt={alt}
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).src = fallback }}
|
|
||||||
/>
|
|
||||||
{badgeText && (
|
{badgeText && (
|
||||||
<div className="absolute top-6 left-6 bg-pop-bg backdrop-blur-sm px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest text-pop-pink shadow-sm pointer-events-none z-10">
|
<div className="pointer-events-none absolute left-6 top-6 z-10 rounded-full bg-pop-bg px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest text-pop-pink shadow-sm backdrop-blur-sm">
|
||||||
{badgeText}
|
{badgeText}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -67,59 +89,40 @@ export default function ProductImageGallery({ images, fallback, alt = 'Product I
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col-reverse md:flex-row items-start gap-4 w-full">
|
<div className={`flex w-full flex-col-reverse gap-4 md:flex-row ${stretchDesktop ? 'md:h-full md:min-h-0 md:items-stretch' : 'items-start'}`}>
|
||||||
{/* Thumbnails */}
|
<div className={`hide-scrollbar flex w-full flex-none snap-x snap-mandatory flex-row gap-2 overflow-x-auto pb-2 md:w-20 md:snap-y md:flex-col md:gap-3 md:overflow-y-auto md:pb-0 md:pr-2 lg:w-24 ${stretchDesktop ? 'md:h-full md:min-h-0' : ''}`}>
|
||||||
<div className="flex flex-row md:flex-col gap-2 md:gap-3 overflow-x-auto md:overflow-y-auto w-full md:w-20 lg:w-24 flex-none hide-scrollbar snap-x md:snap-y snap-mandatory pb-2 md:pb-0 md:pr-2">
|
|
||||||
{sortedImages.map((image, idx) => (
|
{sortedImages.map((image, idx) => (
|
||||||
<button
|
<button
|
||||||
key={image.id || idx}
|
key={image.id || `${image.url}-${idx}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleThumbnailClick(idx)}
|
onClick={() => handleThumbnailClick(idx)}
|
||||||
className={`relative aspect-square w-14 md:w-full flex-none overflow-hidden rounded-xl md:rounded-2xl border-2 transition-all snap-center ${
|
aria-label={`View ${alt} image ${idx + 1}`}
|
||||||
currentIndex === idx ? 'border-pop-pink shadow-md' : 'border-transparent opacity-60 hover:opacity-100'
|
aria-current={visibleIndex === idx ? 'true' : undefined}
|
||||||
}`}
|
className={`relative aspect-square w-14 flex-none snap-center overflow-hidden rounded-xl border-2 transition-all md:w-full md:rounded-2xl ${visibleIndex === idx ? 'border-pop-pink shadow-md' : 'border-transparent opacity-60 hover:opacity-100'}`}
|
||||||
>
|
>
|
||||||
<img
|
<ResponsiveProductImage image={image} fallback={fallback} alt={`${alt} thumbnail ${idx + 1}`} className="h-full w-full object-cover" />
|
||||||
src={image.url}
|
|
||||||
alt={`${alt} thumbnail ${idx + 1}`}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).src = fallback }}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Image */}
|
<div className={`${frameClass} flex-1`}>
|
||||||
<div className="relative aspect-square w-full flex-1 overflow-hidden rounded-3xl bg-pop-bg shadow-sm border border-pop-black/10 flex items-center justify-center">
|
<div ref={scrollRef} className="hide-scrollbar flex h-full w-full snap-x snap-mandatory overflow-x-auto">
|
||||||
<div
|
{sortedImages.map((image, idx) => (
|
||||||
ref={scrollRef}
|
<div key={image.id || `${image.url}-${idx}`} className="relative block h-full w-full flex-none snap-center snap-always">
|
||||||
className="flex w-full h-full overflow-x-auto snap-x snap-mandatory hide-scrollbar"
|
<ResponsiveProductImage image={image} fallback={fallback} alt={`${alt} ${idx + 1}`} loading={idx === 0 ? 'eager' : 'lazy'} className={mainImageClass} />
|
||||||
>
|
|
||||||
{sortedImages.map((img, idx) => (
|
|
||||||
<div key={idx} className="relative w-full h-full flex-none snap-center snap-always block">
|
|
||||||
<img
|
|
||||||
src={img.url}
|
|
||||||
alt={`${alt} ${idx + 1}`}
|
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
|
||||||
onError={(e) => { (e.target as HTMLImageElement).src = fallback }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{badgeText && (
|
{badgeText && (
|
||||||
<div className="absolute top-6 left-6 bg-pop-bg backdrop-blur-sm px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest text-pop-pink shadow-sm pointer-events-none z-10">
|
<div className="pointer-events-none absolute left-6 top-6 z-10 rounded-full bg-pop-bg px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest text-pop-pink shadow-sm backdrop-blur-sm">
|
||||||
{badgeText}
|
{badgeText}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Dots */}
|
<div className="pointer-events-none absolute bottom-4 left-0 right-0 z-10 flex justify-center gap-2 md:hidden">
|
||||||
<div className="md:hidden absolute bottom-4 left-0 right-0 flex justify-center gap-2 z-10 pointer-events-none">
|
{sortedImages.map((image, idx) => (
|
||||||
{sortedImages.map((_, idx) => (
|
<div key={image.id || `${image.url}-${idx}`} className={`h-1.5 w-1.5 rounded-full shadow-[0_1px_2px_rgba(0,0,0,0.3)] transition-all ${visibleIndex === idx ? 'scale-125 bg-white' : 'bg-white/50'}`} />
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className={`w-1.5 h-1.5 rounded-full shadow-[0_1px_2px_rgba(0,0,0,0.3)] transition-all ${currentIndex === idx ? 'bg-white scale-125' : 'bg-white/50'}`}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -248,14 +248,15 @@ export default function ProductDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Product Detail */}
|
{/* Product Detail */}
|
||||||
<div className="grid md:grid-cols-2 gap-8 md:gap-16 items-start">
|
<div className="grid items-start gap-8 md:grid-cols-2 md:items-stretch md:gap-16">
|
||||||
{/* Image Gallery */}
|
{/* Image Gallery */}
|
||||||
<div className="w-full">
|
<div className="w-full md:h-full">
|
||||||
<ProductImageGallery
|
<ProductImageGallery
|
||||||
images={product.images || []}
|
images={product.images || []}
|
||||||
fallback={fallbackImage}
|
fallback={fallbackImage}
|
||||||
alt={displayName}
|
alt={displayName}
|
||||||
badgeText={categoryName}
|
badgeText={categoryName}
|
||||||
|
stretchDesktop
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Image from 'next/image'
|
|||||||
import { generateSeoMetadata } from '@/lib/seo'
|
import { generateSeoMetadata } from '@/lib/seo'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import { getCategoryImage, type PageSection, type Product, type ProductDetailSection } from '@homaroma/types'
|
import { getCategoryImage, type PageSection, type Product, type ProductDetailSection, type ProductImage } from '@homaroma/types'
|
||||||
import AddToCartSection from '@/components/AddToCartSection'
|
import AddToCartSection from '@/components/AddToCartSection'
|
||||||
import ProductGallery from '@/components/ProductGallery'
|
import ProductGallery from '@/components/ProductGallery'
|
||||||
import ProductHeroCarousel from '@/components/ProductHeroCarousel'
|
import ProductHeroCarousel from '@/components/ProductHeroCarousel'
|
||||||
@@ -104,8 +104,10 @@ function ProductMainSection({
|
|||||||
const purchaseCardSticky = payload.purchaseCardSticky !== false
|
const purchaseCardSticky = payload.purchaseCardSticky !== false
|
||||||
const showBreadcrumbs = payload.showBreadcrumbs !== false
|
const showBreadcrumbs = payload.showBreadcrumbs !== false
|
||||||
const showBrandMeta = payload.showBrandMeta !== false
|
const showBrandMeta = payload.showBrandMeta !== false
|
||||||
const images = product.images?.map(img => img.url).filter(Boolean) || []
|
const images = product.images?.filter((image) => Boolean(image.url)) || []
|
||||||
const galleryImages = images.length > 0 ? images : [imageUrl]
|
const galleryImages: ProductImage[] = images.length > 0
|
||||||
|
? images
|
||||||
|
: [{ id: -1, url: imageUrl, mobileUrl: null, order: 0 }]
|
||||||
|
|
||||||
if (layoutMode === 'sticky_gallery') {
|
if (layoutMode === 'sticky_gallery') {
|
||||||
return (
|
return (
|
||||||
@@ -131,10 +133,10 @@ function ProductMainSection({
|
|||||||
|
|
||||||
<div className="hidden space-y-6 lg:block">
|
<div className="hidden space-y-6 lg:block">
|
||||||
{galleryBehavior === 'stacked' ? (
|
{galleryBehavior === 'stacked' ? (
|
||||||
galleryImages.map((src, index) => (
|
galleryImages.map((image, index) => (
|
||||||
<div key={`${src}-${index}`} className="relative min-h-[78vh] overflow-hidden rounded-3xl bg-surface-card shadow-card">
|
<div key={image.id || `${image.url}-${index}`} className="relative min-h-[78vh] overflow-hidden rounded-3xl bg-surface-card shadow-card">
|
||||||
<Image
|
<Image
|
||||||
src={src}
|
src={image.url}
|
||||||
alt={`${productName} ${index + 1}`}
|
alt={`${productName} ${index + 1}`}
|
||||||
fill
|
fill
|
||||||
priority={index === 0}
|
priority={index === 0}
|
||||||
@@ -179,11 +181,12 @@ function ProductMainSection({
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="grid gap-10 md:grid-cols-2 md:gap-16">
|
<section className="grid gap-10 md:grid-cols-2 md:items-stretch md:gap-16">
|
||||||
<ProductGallery
|
<ProductGallery
|
||||||
images={galleryImages}
|
images={galleryImages}
|
||||||
productName={productName}
|
productName={productName}
|
||||||
categoryName={categoryName}
|
categoryName={categoryName}
|
||||||
|
stretchDesktop
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddToCartSection product={product} showBrandMeta={showBrandMeta} />
|
<AddToCartSection product={product} showBrandMeta={showBrandMeta} />
|
||||||
|
|||||||
@@ -1,63 +1,97 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import Image from 'next/image'
|
import { getImageProps } from 'next/image'
|
||||||
|
import type { ProductImage } from '@homaroma/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
productName: string
|
productName: string
|
||||||
categoryName?: string
|
categoryName?: string
|
||||||
images: string[]
|
images: ProductImage[]
|
||||||
|
stretchDesktop?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductGallery({ productName, categoryName, images }: Props) {
|
interface ResponsiveProductImageProps {
|
||||||
|
image: ProductImage
|
||||||
|
alt: string
|
||||||
|
className: string
|
||||||
|
fetchPriority?: 'high' | 'low' | 'auto'
|
||||||
|
sizes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResponsiveProductImage({ image, alt, className, fetchPriority, sizes }: ResponsiveProductImageProps) {
|
||||||
|
const desktop = getImageProps({
|
||||||
|
src: image.url,
|
||||||
|
alt,
|
||||||
|
width: 1200,
|
||||||
|
height: 1600,
|
||||||
|
sizes,
|
||||||
|
fetchPriority,
|
||||||
|
}).props
|
||||||
|
const mobile = image.mobileUrl
|
||||||
|
? getImageProps({
|
||||||
|
src: image.mobileUrl,
|
||||||
|
alt,
|
||||||
|
width: 1200,
|
||||||
|
height: 1200,
|
||||||
|
sizes,
|
||||||
|
fetchPriority,
|
||||||
|
}).props
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<picture className="block h-full w-full">
|
||||||
|
{mobile && (
|
||||||
|
<source media="(max-width: 767px)" srcSet={mobile.srcSet ?? mobile.src} sizes={mobile.sizes} />
|
||||||
|
)}
|
||||||
|
<img {...desktop} alt={alt} className={className} />
|
||||||
|
</picture>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductGallery({ productName, categoryName, images, stretchDesktop = false }: Props) {
|
||||||
const [activeIndex, setActiveIndex] = useState(0)
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const isScrollingRef = useRef(false)
|
const sortedImages = [...images].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
const imageCount = sortedImages.length
|
||||||
|
const visibleIndex = Math.min(activeIndex, Math.max(imageCount - 1, 0))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const onScrollEnd = () => {
|
const onScrollEnd = () => {
|
||||||
if (isScrollingRef.current) return
|
|
||||||
const width = el.clientWidth
|
const width = el.clientWidth
|
||||||
if (width > 0) {
|
if (width > 0) {
|
||||||
const newIndex = Math.round(el.scrollLeft / width)
|
const newIndex = Math.round(el.scrollLeft / width)
|
||||||
if (newIndex >= 0 && newIndex < images.length) {
|
if (newIndex >= 0 && newIndex < imageCount) setActiveIndex(newIndex)
|
||||||
setActiveIndex(newIndex)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
el.addEventListener('scrollend', onScrollEnd)
|
el.addEventListener('scrollend', onScrollEnd)
|
||||||
return () => el.removeEventListener('scrollend', onScrollEnd)
|
return () => el.removeEventListener('scrollend', onScrollEnd)
|
||||||
}, [images.length])
|
}, [imageCount])
|
||||||
|
|
||||||
const handleThumbnailClick = (idx: number) => {
|
const handleThumbnailClick = (idx: number) => {
|
||||||
setActiveIndex(idx)
|
setActiveIndex(idx)
|
||||||
if (scrollRef.current) {
|
const scroller = scrollRef.current
|
||||||
const child = scrollRef.current.children[idx] as HTMLElement
|
const child = scroller?.children[idx] as HTMLElement | undefined
|
||||||
if (child) {
|
if (scroller && child) scroller.scrollTo({ left: child.offsetLeft, behavior: 'smooth' })
|
||||||
isScrollingRef.current = true
|
|
||||||
scrollRef.current.scrollTo({ left: child.offsetLeft, behavior: 'smooth' })
|
|
||||||
setTimeout(() => { isScrollingRef.current = false }, 600)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (images.length === 0) return null
|
if (imageCount === 0) return null
|
||||||
|
|
||||||
// If there's only one image, don't show thumbnails
|
const frameClass = stretchDesktop
|
||||||
if (images.length === 1) {
|
? 'relative aspect-square w-full overflow-hidden rounded-3xl bg-surface-card shadow-card md:aspect-auto md:h-full md:min-h-0'
|
||||||
|
: 'relative aspect-square w-full overflow-hidden rounded-3xl bg-surface-card shadow-card'
|
||||||
|
const mainImageClass = stretchDesktop
|
||||||
|
? 'h-full w-full object-cover md:object-contain'
|
||||||
|
: 'h-full w-full object-cover'
|
||||||
|
|
||||||
|
if (imageCount === 1) {
|
||||||
return (
|
return (
|
||||||
<div className="relative aspect-square w-full overflow-hidden rounded-3xl bg-surface-card shadow-card">
|
<div className={frameClass}>
|
||||||
<Image
|
<ResponsiveProductImage image={sortedImages[0]} alt={productName} fetchPriority="high" sizes="(min-width: 768px) 50vw, 100vw" className={mainImageClass} />
|
||||||
src={images[0]}
|
|
||||||
alt={productName}
|
|
||||||
fill
|
|
||||||
priority={true}
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
{categoryName && (
|
{categoryName && (
|
||||||
<div className="absolute left-5 top-5 rounded-full bg-surface-card/90 px-4 py-2 text-xs font-bold uppercase tracking-widest text-text-main shadow-sm pointer-events-none">
|
<div className="pointer-events-none absolute left-5 top-5 rounded-full bg-surface-card/90 px-4 py-2 text-xs font-bold uppercase tracking-widest text-text-main shadow-sm">
|
||||||
{categoryName}
|
{categoryName}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -66,61 +100,40 @@ export default function ProductGallery({ productName, categoryName, images }: Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col-reverse md:flex-row items-start gap-4 w-full">
|
<div className={`flex w-full flex-col-reverse gap-4 md:flex-row ${stretchDesktop ? 'md:h-full md:min-h-0 md:items-stretch' : 'items-start'}`}>
|
||||||
{/* Thumbnails */}
|
<div className={`hide-scrollbar flex w-full flex-none snap-x snap-mandatory flex-row gap-2 overflow-x-auto pb-2 md:w-20 md:snap-y md:flex-col md:gap-3 md:overflow-y-auto md:pb-0 md:pr-2 lg:w-24 ${stretchDesktop ? 'md:h-full md:min-h-0' : ''}`}>
|
||||||
<div className="flex flex-row md:flex-col gap-2 md:gap-3 overflow-x-auto md:overflow-y-auto w-full md:w-20 lg:w-24 flex-none hide-scrollbar snap-x md:snap-y snap-mandatory pb-2 md:pb-0 md:pr-2">
|
{sortedImages.map((image, idx) => (
|
||||||
{images.map((img, idx) => (
|
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={image.id || `${image.url}-${idx}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleThumbnailClick(idx)}
|
onClick={() => handleThumbnailClick(idx)}
|
||||||
className={`relative aspect-square w-14 md:w-full flex-none overflow-hidden rounded-xl md:rounded-2xl border-2 transition-all snap-center ${
|
aria-label={`View ${productName} image ${idx + 1}`}
|
||||||
activeIndex === idx ? 'border-brand-primary opacity-100' : 'border-transparent opacity-60 hover:opacity-100'
|
aria-current={visibleIndex === idx ? 'true' : undefined}
|
||||||
}`}
|
className={`relative aspect-square w-14 flex-none snap-center overflow-hidden rounded-xl border-2 transition-all md:w-full md:rounded-2xl ${visibleIndex === idx ? 'border-brand-primary opacity-100' : 'border-transparent opacity-60 hover:opacity-100'}`}
|
||||||
>
|
>
|
||||||
<Image
|
<ResponsiveProductImage image={image} alt={`${productName} thumbnail ${idx + 1}`} sizes="96px" className="h-full w-full object-cover" />
|
||||||
src={img}
|
|
||||||
alt={`${productName} thumbnail ${idx + 1}`}
|
|
||||||
fill
|
|
||||||
unoptimized
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Image */}
|
<div className={`${frameClass} flex-1`}>
|
||||||
<div className="relative aspect-square w-full flex-1 overflow-hidden rounded-3xl bg-surface-card shadow-card">
|
<div ref={scrollRef} className="hide-scrollbar flex h-full w-full snap-x snap-mandatory overflow-x-auto">
|
||||||
<div
|
{sortedImages.map((image, idx) => (
|
||||||
ref={scrollRef}
|
<div key={image.id || `${image.url}-${idx}`} className="relative block h-full w-full flex-none snap-center snap-always">
|
||||||
className="flex w-full h-full overflow-x-auto snap-x snap-mandatory hide-scrollbar"
|
<ResponsiveProductImage image={image} alt={`${productName} ${idx + 1}`} fetchPriority={idx === 0 ? 'high' : 'auto'} sizes="(min-width: 768px) 46vw, 100vw" className={mainImageClass} />
|
||||||
>
|
|
||||||
{images.map((img, idx) => (
|
|
||||||
<div key={idx} className="relative w-full h-full flex-none snap-center snap-always block">
|
|
||||||
<Image
|
|
||||||
src={img}
|
|
||||||
alt={`${productName} ${idx + 1}`}
|
|
||||||
fill
|
|
||||||
priority={idx === 0}
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{categoryName && (
|
{categoryName && (
|
||||||
<div className="absolute left-5 top-5 rounded-full bg-surface-card/90 px-4 py-2 text-xs font-bold uppercase tracking-widest text-text-main shadow-sm pointer-events-none z-10">
|
<div className="pointer-events-none absolute left-5 top-5 z-10 rounded-full bg-surface-card/90 px-4 py-2 text-xs font-bold uppercase tracking-widest text-text-main shadow-sm">
|
||||||
{categoryName}
|
{categoryName}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Dots */}
|
<div className="pointer-events-none absolute bottom-4 left-0 right-0 z-10 flex justify-center gap-2 md:hidden">
|
||||||
<div className="md:hidden absolute bottom-4 left-0 right-0 flex justify-center gap-2 z-10 pointer-events-none">
|
{sortedImages.map((image, idx) => (
|
||||||
{images.map((_, idx) => (
|
<div key={image.id || `${image.url}-${idx}`} className={`h-1.5 w-1.5 rounded-full shadow-[0_1px_2px_rgba(0,0,0,0.3)] transition-all ${visibleIndex === idx ? 'scale-125 bg-white' : 'bg-white/50'}`} />
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className={`w-1.5 h-1.5 rounded-full shadow-[0_1px_2px_rgba(0,0,0,0.3)] transition-all ${activeIndex === idx ? 'bg-white scale-125' : 'bg-white/50'}`}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,9 +98,45 @@ export interface BomItem {
|
|||||||
export interface ProductImage {
|
export interface ProductImage {
|
||||||
id: number
|
id: number
|
||||||
url: string
|
url: string
|
||||||
|
mobileUrl?: string | null
|
||||||
order: number
|
order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OptionalProductImageMobileUrlSchema = z
|
||||||
|
.union([z.string(), z.null()])
|
||||||
|
.optional()
|
||||||
|
.transform((value) => {
|
||||||
|
if (value === undefined) return undefined
|
||||||
|
if (value === null) return null
|
||||||
|
const normalized = value.trim()
|
||||||
|
return normalized.length > 0 ? normalized : null
|
||||||
|
})
|
||||||
|
.refine((value) => value === undefined || value === null || !/^data:video\//i.test(value), 'Product image URL must reference an image')
|
||||||
|
.refine((value) => value === undefined || value === null || !/\.(?:mp4|webm|mov|ogv|ogg)(?:[?#]|$)/i.test(value), 'Product image URL must reference an image')
|
||||||
|
|
||||||
|
const ProductImageUrlSchema = z.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, 'Product image URL is required')
|
||||||
|
.refine((value) => !/^data:video\//i.test(value), 'Product image URL must reference an image')
|
||||||
|
.refine((value) => !/\.(?:mp4|webm|mov|ogv|ogg)(?:[?#]|$)/i.test(value), 'Product image URL must reference an image')
|
||||||
|
|
||||||
|
export const ProductImageObjectInputSchema = z.object({
|
||||||
|
id: z.number().int().positive().optional(),
|
||||||
|
url: ProductImageUrlSchema,
|
||||||
|
mobileUrl: OptionalProductImageMobileUrlSchema,
|
||||||
|
order: z.number().int().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ProductImageInputSchema = z.union([
|
||||||
|
ProductImageUrlSchema.transform((url) => ({
|
||||||
|
url,
|
||||||
|
})),
|
||||||
|
ProductImageObjectInputSchema,
|
||||||
|
])
|
||||||
|
|
||||||
|
export type ProductImageInput = z.input<typeof ProductImageInputSchema>
|
||||||
|
export type NormalizedProductImageInput = z.output<typeof ProductImageInputSchema>
|
||||||
|
|
||||||
export type TranslatableString = string | Record<string, string>;
|
export type TranslatableString = string | Record<string, string>;
|
||||||
|
|
||||||
export interface AttributeDefinitionTranslation {
|
export interface AttributeDefinitionTranslation {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { ProductImageInputSchema } from './index'
|
||||||
|
|
||||||
|
describe('product image input contract', () => {
|
||||||
|
test('preserves the legacy string input shape', () => {
|
||||||
|
expect(ProductImageInputSchema.parse('/uploads/products/desktop.webp')).toEqual({
|
||||||
|
url: '/uploads/products/desktop.webp',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepts a paired desktop and mobile image', () => {
|
||||||
|
expect(ProductImageInputSchema.parse({
|
||||||
|
id: 12,
|
||||||
|
url: '/uploads/products/desktop.webp',
|
||||||
|
mobileUrl: '/uploads/products/mobile.webp',
|
||||||
|
order: 2,
|
||||||
|
})).toEqual({
|
||||||
|
id: 12,
|
||||||
|
url: '/uploads/products/desktop.webp',
|
||||||
|
mobileUrl: '/uploads/products/mobile.webp',
|
||||||
|
order: 2,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('normalizes an empty mobile image URL to null', () => {
|
||||||
|
expect(ProductImageInputSchema.parse({
|
||||||
|
url: '/uploads/products/desktop.webp',
|
||||||
|
mobileUrl: ' ',
|
||||||
|
}).mobileUrl).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects non-string image URLs without rejecting relative upload paths', () => {
|
||||||
|
expect(ProductImageInputSchema.safeParse({ url: 123 }).success).toBe(false)
|
||||||
|
expect(ProductImageInputSchema.safeParse({
|
||||||
|
url: '/uploads/products/desktop.webp',
|
||||||
|
mobileUrl: { path: '/uploads/products/mobile.webp' },
|
||||||
|
}).success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects known video URLs in desktop and mobile image slots', () => {
|
||||||
|
expect(ProductImageInputSchema.safeParse('/uploads/product.mp4').success).toBe(false)
|
||||||
|
expect(ProductImageInputSchema.safeParse({
|
||||||
|
url: '/uploads/desktop.webp',
|
||||||
|
mobileUrl: '/uploads/mobile.webm?version=2',
|
||||||
|
}).success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user