From f258e3e176cc98a515298cd757253f84c5c43198 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 2 Oct 2026 07:10:25 +0700 Subject: [PATCH] x --- .../src/components/MediaLibraryModal.tsx | 33 +- apps/admin/src/pages/ProductEditPage.tsx | 358 +++++++++++++++--- .../migration.sql | 2 + apps/backend/prisma/schema.prisma | 1 + apps/backend/src/lib/productImages.test.ts | 49 +++ apps/backend/src/lib/productImages.ts | 92 +++++ .../src/lib/productTranslations.test.ts | 187 +++++++++ apps/backend/src/lib/productTranslations.ts | 179 +++++++++ apps/backend/src/routes/backup.ts | 50 ++- apps/backend/src/routes/products.ts | 211 +++++------ .../src/components/ProductImageGallery.tsx | 137 +++---- apps/spa/src/pages/ProductDetailPage.tsx | 5 +- .../src/app/[lang]/products/[slug]/page.tsx | 17 +- .../website/src/components/ProductGallery.tsx | 151 ++++---- packages/types/index.ts | 36 ++ packages/types/product-image.test.ts | 47 +++ 16 files changed, 1207 insertions(+), 348 deletions(-) create mode 100644 apps/backend/prisma/migrations/20261001231506_add_product_image_mobile_url/migration.sql create mode 100644 apps/backend/src/lib/productImages.test.ts create mode 100644 apps/backend/src/lib/productImages.ts create mode 100644 apps/backend/src/lib/productTranslations.test.ts create mode 100644 apps/backend/src/lib/productTranslations.ts create mode 100644 packages/types/product-image.test.ts diff --git a/apps/admin/src/components/MediaLibraryModal.tsx b/apps/admin/src/components/MediaLibraryModal.tsx index 76705c5..2e3dab0 100644 --- a/apps/admin/src/components/MediaLibraryModal.tsx +++ b/apps/admin/src/components/MediaLibraryModal.tsx @@ -7,6 +7,7 @@ interface MediaLibraryModalProps { isOpen: boolean; onClose: () => void; onSelect: (url: string) => void; + mediaType?: 'all' | 'image' | 'video'; } interface MediaItem { @@ -16,7 +17,7 @@ interface MediaItem { mimeType?: string; } -export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLibraryModalProps) { +export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType = 'all' }: MediaLibraryModalProps) { const { t } = useTranslation(); const [media, setMedia] = useState([]); const [loading, setLoading] = useState(false); @@ -46,6 +47,16 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi const handleUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; 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(); 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'); }; + 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; return ( @@ -96,7 +117,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi

{t('mediaLibrary.modalSubtitle')}

- @@ -109,10 +130,10 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi ) : ( <> {t('mediaLibrary.btnUploadNewFile')} )} - +
- {t('mediaLibrary.itemsFound', { count: media.length })} + {t('mediaLibrary.itemsFound', { count: visibleMedia.length })}
@@ -125,7 +146,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi ) : (
- {media.map((item, idx) => { + {visibleMedia.map((item, idx) => { const isVid = isVideoFile(item); return ( @@ -164,7 +185,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect }: MediaLi
); })} - {media.length === 0 && !loading && ( + {visibleMedia.length === 0 && !loading && (

{t('mediaLibrary.noMediaFound')}

diff --git a/apps/admin/src/pages/ProductEditPage.tsx b/apps/admin/src/pages/ProductEditPage.tsx index d874f16..d11d6fe 100644 --- a/apps/admin/src/pages/ProductEditPage.tsx +++ b/apps/admin/src/pages/ProductEditPage.tsx @@ -14,7 +14,12 @@ import { Image as ImageIcon, ImagePlus, ExternalLink, - X + X, + ArrowUp, + ArrowDown, + Monitor, + Smartphone, + RotateCcw } from 'lucide-react' 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' @@ -32,6 +37,75 @@ type EditableAttribute = { translations: Record } +type EditableProductImage = Partial & { + 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(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 ( +
+
+ {displayUrl && !failed ? ( + {`${label} { + const image = event.currentTarget + setLoadedImage({ url: displayUrl, width: image.naturalWidth, height: image.naturalHeight }) + }} + onError={() => setFailedUrl(displayUrl)} + /> + ) : ( +
+ + + {failed ? 'Preview unavailable' : `No ${label} image`} + +
+ )} + {isFallback && displayUrl && !failed && ( +
+ + Desktop fallback +
+ )} +
+ {dimensions && ( +

+ {dimensions.width} × {dimensions.height}px + {ratioMismatch ? ` · Recommended aspect ratio is ${expectedLabel}` : ` · ${expectedLabel} ratio`} +

+ )} +
+ ) +} + export default function ProductEditPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() @@ -52,7 +126,7 @@ export default function ProductEditPage() { const [details, setDetails] = useState>({}) const [options, setOptions] = useState<{name: Record, values: Record[]}[]>([]) const [variants, setVariants] = useState[]>([]) - const [images, setImages] = useState[]>([]) + const [images, setImages] = useState([]) const [availableAttributes, setAvailableAttributes] = useState([]) const [attributes, setAttributes] = useState([]) const [selectedDefinitionId, setSelectedDefinitionId] = useState(0) @@ -75,11 +149,6 @@ export default function ProductEditPage() { // removed quillRef - const fixUrl = (url: string) => { - if (!url) return '' - return url.replace('/public/uploads/', '/uploads/') - } - const openMediaModal = (onSelect: (url: string) => void) => { setOnSelectMedia(() => onSelect) setIsMediaModalOpen(true) @@ -211,13 +280,27 @@ export default function ProductEditPage() { } 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 updated = [...images] - updated[index] = { ...updated[index], url: value } - setImages(updated) + const handleUpdateImage = (index: number, field: 'url' | 'mobileUrl', value: string) => { + setImages((current) => current.map((image, imageIndex) => ( + imageIndex === index ? { ...image, [field]: value } : image + ))) + } + + 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)) @@ -287,6 +370,10 @@ export default function ProductEditPage() { setSuccess(false) 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 method = isNew ? 'POST' : 'PUT' 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) => ({ ...(img.id ? { id: img.id } : {}), 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 */}
-
-
- -

Images Gallery

-
-
- +
+
+
+ +

Responsive Images Gallery

+
+

+ Each gallery item keeps its Desktop / Tablet and Mobile assets together when reordered or removed. +

+
-
+
{images.map((img, idx) => ( -
-
- {img.url ? ( - - ) : ( -
- -
- )} -
-
- -
- handleUpdateImage(idx, e.target.value)} - placeholder="https://..." - 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" - /> - {img.url && ( - - - - )} -
-
- +
+
+
+

Gallery item {idx + 1}

+

Both responsive assets move together.

+
+
+ + + +
+
+ +
+
+
+
+ +
+
+

Desktop / Tablet · 3:4

+

Recommended 1200 × 1600px

+
+
+ +
+ +
+
+ +
+ 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 && ( + + + + )} +
+
+
+ + {img.url && ( + + )} +
+ {!img.url && img.mobileUrl && ( +

Desktop / Tablet image is required before this pair can be saved.

+ )} +
+
+
+ +
+
+
+ +
+
+

Mobile · 1:1

+

Recommended 1200 × 1200px · Optional

+
+
+ +
+ +
+
+ +
+ 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 && ( + + + + )} +
+
+
+ + {img.mobileUrl && ( + + )} +
+ {!img.mobileUrl && img.url && ( +

+ + Mobile storefront will fall back to the Desktop image. +

+ )} +
+
+
+
))} @@ -711,7 +942,7 @@ export default function ProductEditPage() {
-

No product images added yet. Default category image will be used.

+

No gallery items added yet. The storefront will use its existing fallback image.

)}
@@ -926,6 +1157,7 @@ export default function ProductEditPage() { setIsMediaModalOpen(false)} + mediaType="image" onSelect={(url) => { if (onSelectMedia) onSelectMedia(url) }} diff --git a/apps/backend/prisma/migrations/20261001231506_add_product_image_mobile_url/migration.sql b/apps/backend/prisma/migrations/20261001231506_add_product_image_mobile_url/migration.sql new file mode 100644 index 0000000..0edefa7 --- /dev/null +++ b/apps/backend/prisma/migrations/20261001231506_add_product_image_mobile_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ProductImage" ADD COLUMN "mobileUrl" TEXT; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index d7733e2..7dc7844 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -118,6 +118,7 @@ model ProductTranslation { model ProductImage { id Int @id @default(autoincrement()) url String + mobileUrl String? order Int @default(0) productId Int product Product @relation(fields: [productId], references: [id], onDelete: Cascade) diff --git a/apps/backend/src/lib/productImages.test.ts b/apps/backend/src/lib/productImages.test.ts new file mode 100644 index 0000000..c7c88a1 --- /dev/null +++ b/apps/backend/src/lib/productImages.test.ts @@ -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() + }) +}) diff --git a/apps/backend/src/lib/productImages.ts b/apps/backend/src/lib/productImages.ts new file mode 100644 index 0000000..8722d19 --- /dev/null +++ b/apps/backend/src/lib/productImages.ts @@ -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, + } +} diff --git a/apps/backend/src/lib/productTranslations.test.ts b/apps/backend/src/lib/productTranslations.test.ts new file mode 100644 index 0000000..4e2b0a2 --- /dev/null +++ b/apps/backend/src/lib/productTranslations.test.ts @@ -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).polluted).toBeUndefined() + expect((Object.prototype as Record).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).name).toBeUndefined() + }, + ) +}) diff --git a/apps/backend/src/lib/productTranslations.ts b/apps/backend/src/lib/productTranslations.ts new file mode 100644 index 0000000..bbfa2b5 --- /dev/null +++ b/apps/backend/src/lib/productTranslations.ts @@ -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(PRODUCT_TRANSLATION_LOCALES) + +type TranslatableField = (typeof TRANSLATABLE_FIELDS)[number] +type TranslationValues = Partial> + +interface ProductTranslationCreatePlan { + base: Required + byLanguage: Record> +} + +export interface ProductTranslationUpdatePlan { + base: TranslationValues + byLanguage: Record +} + +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 { + 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, +): ProductTranslationUpdatePlan { + const plan: ProductTranslationUpdatePlan = { + base: {}, + byLanguage: Object.create(null) as Record, + } + + 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, +): 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 + 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> + 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 + +/** + * 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 } : {}), + }, + }) + } +} diff --git a/apps/backend/src/routes/backup.ts b/apps/backend/src/routes/backup.ts index bfaca16..d106430 100644 --- a/apps/backend/src/routes/backup.ts +++ b/apps/backend/src/routes/backup.ts @@ -4,6 +4,11 @@ import { adminOnly } from "../middleware/auth"; import path from "path"; import fs from "fs"; import os from "os"; +import { + normalizeProductImagesInput, + productImageErrorResponse, + ProductImagePayloadError, +} from "../lib/productImages"; // ─── 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" }; } + // 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>(); + for (const product of products) { + if (product?.images !== undefined) { + normalizedImagesByProduct.set(product, normalizeProductImagesInput(product.images)); + } + } + const summary = { categories: 0, attributes: 0, @@ -1010,10 +1027,6 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) } // 4. Products - const products = Array.isArray(payload.products) - ? payload.products - : (Array.isArray(payload) && payload[0]?.variants ? payload : []); - for (const prod of products) { if (!prod.name) continue; const catId = prod.categoryId ? (categoryMap.get(prod.categoryId) || prod.categoryId) : defaultCatId; @@ -1088,20 +1101,19 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) } // 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 } }); - for (let i = 0; i < prod.images.length; i++) { - const img = prod.images[i]; - const url = typeof img === "string" ? img : img.url; - if (url) { - await prisma.productImage.create({ - data: { - productId: savedProduct.id, - url, - order: typeof img === "object" && img.order !== undefined ? img.order : i, - }, - }); - } + for (let i = 0; i < images.length; i++) { + const img = images[i]!; + await prisma.productImage.create({ + data: { + productId: savedProduct.id, + url: img.url, + mobileUrl: img.mobileUrl ?? null, + order: img.order ?? i, + }, + }); } } @@ -1142,6 +1154,10 @@ export const backupRoute = new Elysia({ prefix: "/backup" }) summary, }; } catch (err: any) { + if (err instanceof ProductImagePayloadError) { + set.status = 400; + return productImageErrorResponse(err); + } console.error("[Backup] Catalog restore error:", err); set.status = 500; return { error: "Catalog restore failed", detail: err?.message }; diff --git a/apps/backend/src/routes/products.ts b/apps/backend/src/routes/products.ts index 9e827d7..c3ba8e6 100644 --- a/apps/backend/src/routes/products.ts +++ b/apps/backend/src/routes/products.ts @@ -9,6 +9,21 @@ import { validateProductDetailSectionPayload, } from "../lib/productDetailSections"; 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[]) { return issues.map((issue) => ({ @@ -72,11 +87,11 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an } } - // 3. Normalize Name, Description, Details - const rawName = payload.name; - const pName = typeof rawName === 'string' ? rawName : (rawName?.th || rawName?.en || "New Product"); - const pDesc = typeof payload.description === 'string' ? payload.description : (payload.description?.th || payload.description?.en || ""); - const pDetails = typeof payload.details === 'string' ? payload.details : (payload.details?.th || payload.details?.en || ""); + // 3. Normalize Name, Description, Details and translation rows + const translationPlan = normalizeProductTranslationCreate(payload); + const pName = translationPlan.base.name; + const pDesc = translationPlan.base.description; + const pDetails = translationPlan.base.details; // 4. Resolve Variants and Options let variants = Array.isArray(payload.variants) ? payload.variants : []; @@ -110,46 +125,14 @@ async function createSingleProductTx(tx: any, payload: any, validateVariants: an }); // 6. Handle Translations - const transObj = payload.translations && typeof payload.translations === 'object' ? { ...payload.translations } : {}; - 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 { + for (const [lang, trans] of Object.entries(translationPlan.byLanguage)) { await tx.productTranslation.create({ data: { productId: prod.id, - langCode: 'th', - name: pName, - description: pDesc, - details: pDetails, + langCode: lang, + name: trans.name, + description: trans.description, + 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..." }]) - if (payload.images && Array.isArray(payload.images)) { - for (const [idx, img] of payload.images.entries()) { - const url = typeof img === 'string' ? img : img?.url; - if (url) { - await tx.productImage.create({ - data: { - productId: prod.id, - url, - order: typeof img === 'object' && typeof img?.order === 'number' ? img.order : idx, - } - }); - } + if (payload.images !== undefined) { + const images = normalizeProductImagesInput(payload.images); + for (const [idx, img] of images.entries()) { + await tx.productImage.create({ + data: { + productId: prod.id, + url: img.url, + mobileUrl: img.mobileUrl ?? null, + order: img.order ?? idx, + } + }); } } @@ -669,10 +651,15 @@ export const productsRoute = new Elysia({ prefix: "/products" }) const id = parseInt(params.id); if (isNaN(id)) return status(400, { message: "Invalid product ID" }); - const { categoryId, description, details, variants, images } = body as any; - let options = (body as any).options; + const payload = body as any; + const { categoryId, variants, images } = payload; + let options = payload.options; try { + const normalizedImages = images === undefined + ? undefined + : normalizeProductImagesInput(images); + const translationPlan = normalizeProductTranslationUpdate(payload); if (variants && Array.isArray(variants)) { // If options is not passed but variants are, we might need to fetch existing options if (options === undefined) { @@ -683,7 +670,7 @@ export const productsRoute = new Elysia({ prefix: "/products" }) } const updatedProduct = await prisma.$transaction(async (tx) => { // 1. Update basic product info - const updateData: any = {}; + const updateData: any = { ...translationPlan.base }; if (categoryId !== undefined) { await assertActiveCategory(categoryId, tx); updateData.categoryId = categoryId; @@ -694,6 +681,7 @@ export const productsRoute = new Elysia({ prefix: "/products" }) where: { id }, data: updateData, }); + await syncProductTranslations(tx, id, translationPlan, prod.name); // 2. Sync Variants if (variants && Array.isArray(variants)) { @@ -757,9 +745,10 @@ export const productsRoute = new Elysia({ prefix: "/products" }) } // 3. Sync Images - if (images && Array.isArray(images)) { + if (normalizedImages) { 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)); 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) { const exists = existingImages.find(ei => ei.id === img.id); if (exists) { @@ -776,7 +765,8 @@ export const productsRoute = new Elysia({ prefix: "/products" }) where: { id: img.id }, data: { 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: { productId: id, 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 }; } 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") { return status(400, { success: false, message: "Category not found" }); } @@ -817,10 +817,8 @@ export const productsRoute = new Elysia({ prefix: "/products" }) async ({ params, body, status }) => { const id = parseInt(params.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 { + const image = normalizeProductImageInput(body); const product = await prisma.product.findUnique({ where: { id }, include: { images: true } @@ -830,20 +828,25 @@ export const productsRoute = new Elysia({ prefix: "/products" }) const newImage = await prisma.productImage.create({ data: { productId: id, - url, + url: image.url, + mobileUrl: image.mobileUrl ?? null, order: product.images.length } }); return { success: true, image: newImage }; } catch (err: any) { + if (err instanceof ProductImagePayloadError) { + return status(400, productImageErrorResponse(err)); + } console.error("Add product image error:", err); return status(500, { success: false, message: err?.message || "Failed to add product image" }); } }, { 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 }; } 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") { 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 }; } 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") { return status(400, { success: false, message: "Category not found" }); } @@ -916,64 +931,26 @@ export const productsRoute = new Elysia({ prefix: "/products" }) try { 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.brandId !== undefined) prodData.brandId = payload.brandId; if (payload.options !== undefined) prodData.options = payload.options; - const prod = await prisma.product.update({ - where: { id }, - data: prodData, + const prod = await prisma.$transaction(async (tx) => { + const updated = await tx.product.update({ + 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 }; } catch (err: any) { + if (err instanceof ProductTranslationPayloadError) { + return status(400, productTranslationErrorResponse(err)); + } console.error("Patch product error:", err); return status(500, { success: false, message: err?.message || "Failed to patch product" }); } diff --git a/apps/spa/src/components/ProductImageGallery.tsx b/apps/spa/src/components/ProductImageGallery.tsx index 073b040..f81d1c5 100644 --- a/apps/spa/src/components/ProductImageGallery.tsx +++ b/apps/spa/src/components/ProductImageGallery.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useEffect, useRef, useState, type SyntheticEvent } from 'react' import type { ProductImage } from '../types' interface ProductImageGalleryProps { @@ -6,59 +6,81 @@ interface ProductImageGalleryProps { fallback: string alt?: 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) => { + const picture = event.currentTarget.parentElement + picture?.querySelectorAll('source').forEach((source) => source.remove()) + event.currentTarget.src = fallback + } + + return ( + + {image.mobileUrl && } + {alt} + + ) +} + +export default function ProductImageGallery({ + images, + fallback, + alt = 'Product Image', + badgeText, + stretchDesktop = false, +}: ProductImageGalleryProps) { const [currentIndex, setCurrentIndex] = useState(0) const scrollRef = useRef(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(() => { const el = scrollRef.current if (!el) return const onScrollEnd = () => { - if (isScrollingRef.current) return const width = el.clientWidth if (width > 0) { const newIndex = Math.round(el.scrollLeft / width) - if (newIndex >= 0 && newIndex < (images?.length || 0)) { - setCurrentIndex(newIndex) - } + if (newIndex >= 0 && newIndex < imageCount) setCurrentIndex(newIndex) } } el.addEventListener('scrollend', onScrollEnd) return () => el.removeEventListener('scrollend', onScrollEnd) - }, [images?.length]) + }, [imageCount]) const handleThumbnailClick = (idx: number) => { setCurrentIndex(idx) - if (scrollRef.current) { - const child = scrollRef.current.children[idx] as HTMLElement - if (child) { - isScrollingRef.current = true - scrollRef.current.scrollTo({ left: child.offsetLeft, behavior: 'smooth' }) - setTimeout(() => { isScrollingRef.current = false }, 600) - } - } + const scroller = scrollRef.current + const child = scroller?.children[idx] as HTMLElement | undefined + if (scroller && child) scroller.scrollTo({ left: child.offsetLeft, behavior: 'smooth' }) } - // Sort images by order if they exist, else just use the array - const sortedImages = images?.length ? [...images].sort((a, b) => (a.order || 0) - (b.order || 0)) : [] - - if (sortedImages.length === 0) return null + const frameClass = stretchDesktop + ? '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 there's only one image, don't show thumbnails - if (sortedImages.length === 1) { + if (imageCount === 1) { return ( -
- {alt} { (e.target as HTMLImageElement).src = fallback }} - /> +
+ {badgeText && ( -
+
{badgeText}
)} @@ -67,59 +89,40 @@ export default function ProductImageGallery({ images, fallback, alt = 'Product I } return ( -
- {/* Thumbnails */} -
+
+
{sortedImages.map((image, idx) => ( ))}
- {/* Main Image */} -
-
- {sortedImages.map((img, idx) => ( -
- {`${alt} { (e.target as HTMLImageElement).src = fallback }} - /> +
+
+ {sortedImages.map((image, idx) => ( +
+
))}
- + {badgeText && ( -
+
{badgeText}
)} - - {/* Mobile Dots */} -
- {sortedImages.map((_, idx) => ( -
+ +
+ {sortedImages.map((image, idx) => ( +
))}
diff --git a/apps/spa/src/pages/ProductDetailPage.tsx b/apps/spa/src/pages/ProductDetailPage.tsx index d01c58e..c4063b7 100644 --- a/apps/spa/src/pages/ProductDetailPage.tsx +++ b/apps/spa/src/pages/ProductDetailPage.tsx @@ -248,14 +248,15 @@ export default function ProductDetailPage() {
{/* Product Detail */} -
+
{/* Image Gallery */} -
+
diff --git a/apps/website/src/app/[lang]/products/[slug]/page.tsx b/apps/website/src/app/[lang]/products/[slug]/page.tsx index 2b48169..c5d48b2 100644 --- a/apps/website/src/app/[lang]/products/[slug]/page.tsx +++ b/apps/website/src/app/[lang]/products/[slug]/page.tsx @@ -5,7 +5,7 @@ import Image from 'next/image' import { generateSeoMetadata } from '@/lib/seo' import ReactMarkdown from 'react-markdown' 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 ProductGallery from '@/components/ProductGallery' import ProductHeroCarousel from '@/components/ProductHeroCarousel' @@ -104,8 +104,10 @@ function ProductMainSection({ const purchaseCardSticky = payload.purchaseCardSticky !== false const showBreadcrumbs = payload.showBreadcrumbs !== false const showBrandMeta = payload.showBrandMeta !== false - const images = product.images?.map(img => img.url).filter(Boolean) || [] - const galleryImages = images.length > 0 ? images : [imageUrl] + const images = product.images?.filter((image) => Boolean(image.url)) || [] + const galleryImages: ProductImage[] = images.length > 0 + ? images + : [{ id: -1, url: imageUrl, mobileUrl: null, order: 0 }] if (layoutMode === 'sticky_gallery') { return ( @@ -131,10 +133,10 @@ function ProductMainSection({
{galleryBehavior === 'stacked' ? ( - galleryImages.map((src, index) => ( -
+ galleryImages.map((image, index) => ( +
{`${productName} )} -
+
diff --git a/apps/website/src/components/ProductGallery.tsx b/apps/website/src/components/ProductGallery.tsx index 7de39d3..8d49589 100644 --- a/apps/website/src/components/ProductGallery.tsx +++ b/apps/website/src/components/ProductGallery.tsx @@ -1,63 +1,97 @@ 'use client' import { useState, useRef, useEffect } from 'react' -import Image from 'next/image' +import { getImageProps } from 'next/image' +import type { ProductImage } from '@homaroma/types' interface Props { productName: 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 ( + + {mobile && ( + + )} + {alt} + + ) +} + +export default function ProductGallery({ productName, categoryName, images, stretchDesktop = false }: Props) { const [activeIndex, setActiveIndex] = useState(0) const scrollRef = useRef(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(() => { const el = scrollRef.current if (!el) return const onScrollEnd = () => { - if (isScrollingRef.current) return const width = el.clientWidth if (width > 0) { const newIndex = Math.round(el.scrollLeft / width) - if (newIndex >= 0 && newIndex < images.length) { - setActiveIndex(newIndex) - } + if (newIndex >= 0 && newIndex < imageCount) setActiveIndex(newIndex) } } el.addEventListener('scrollend', onScrollEnd) return () => el.removeEventListener('scrollend', onScrollEnd) - }, [images.length]) + }, [imageCount]) const handleThumbnailClick = (idx: number) => { setActiveIndex(idx) - if (scrollRef.current) { - const child = scrollRef.current.children[idx] as HTMLElement - if (child) { - isScrollingRef.current = true - scrollRef.current.scrollTo({ left: child.offsetLeft, behavior: 'smooth' }) - setTimeout(() => { isScrollingRef.current = false }, 600) - } - } + const scroller = scrollRef.current + const child = scroller?.children[idx] as HTMLElement | undefined + if (scroller && child) scroller.scrollTo({ left: child.offsetLeft, behavior: 'smooth' }) } - if (images.length === 0) return null + if (imageCount === 0) return null - // If there's only one image, don't show thumbnails - if (images.length === 1) { + const frameClass = stretchDesktop + ? '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 ( -
- {productName} +
+ {categoryName && ( -
+
{categoryName}
)} @@ -66,61 +100,40 @@ export default function ProductGallery({ productName, categoryName, images }: Pr } return ( -
- {/* Thumbnails */} -
- {images.map((img, idx) => ( +
+
+ {sortedImages.map((image, idx) => ( ))}
- {/* Main Image */} -
-
- {images.map((img, idx) => ( -
- {`${productName} +
+
+ {sortedImages.map((image, idx) => ( +
+
))}
- + {categoryName && ( -
+
{categoryName}
)} - - {/* Mobile Dots */} -
- {images.map((_, idx) => ( -
+ +
+ {sortedImages.map((image, idx) => ( +
))}
diff --git a/packages/types/index.ts b/packages/types/index.ts index 346d5ae..9b320c0 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -98,9 +98,45 @@ export interface BomItem { export interface ProductImage { id: number url: string + mobileUrl?: string | null 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 +export type NormalizedProductImageInput = z.output + export type TranslatableString = string | Record; export interface AttributeDefinitionTranslation { diff --git a/packages/types/product-image.test.ts b/packages/types/product-image.test.ts new file mode 100644 index 0000000..111e2bf --- /dev/null +++ b/packages/types/product-image.test.ts @@ -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) + }) +})