Skip to content

Commit 6ac709d

Browse files
(SP:2) [SHOP] harden admin photo submission and PDP review fixes
1 parent cc158ff commit 6ac709d

5 files changed

Lines changed: 294 additions & 66 deletions

File tree

frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,36 @@ function isSerializableUiPhoto(photo: UiPhoto): photo is SerializableUiPhoto {
171171
return false;
172172
}
173173

174+
export const LEGACY_PHOTO_MIGRATION_REQUIRED_MESSAGE =
175+
'Legacy product photos must be migrated before adding or reordering gallery photos.';
176+
177+
class PhotoPlanSubmissionError extends Error {
178+
constructor(message: string) {
179+
super(message);
180+
this.name = 'PhotoPlanSubmissionError';
181+
}
182+
}
183+
184+
export function getPhotoPlanSubmissionError(photos: UiPhoto[]): string | null {
185+
const hasLegacyPhotos = photos.some(photo => photo.source === 'legacy');
186+
const hasNonLegacyPhotos = photos.some(photo => photo.source !== 'legacy');
187+
188+
if (hasLegacyPhotos && hasNonLegacyPhotos) {
189+
return LEGACY_PHOTO_MIGRATION_REQUIRED_MESSAGE;
190+
}
191+
192+
return null;
193+
}
194+
174195
export function buildPhotoPlanSubmission(photos: UiPhoto[]): {
175196
photoPlan?: AdminProductPhotoPlan;
176197
newPhotos: Array<UiPhoto & { source: 'new'; uploadId: string; file: File }>;
177198
} {
199+
const submissionError = getPhotoPlanSubmissionError(photos);
200+
if (submissionError) {
201+
throw new PhotoPlanSubmissionError(submissionError);
202+
}
203+
178204
const serializablePhotos = photos.filter(isSerializableUiPhoto);
179205

180206
if (serializablePhotos.length === 0) {
@@ -200,6 +226,35 @@ export function buildPhotoPlanSubmission(photos: UiPhoto[]): {
200226
return { photoPlan, newPhotos };
201227
}
202228

229+
function getBlobPreviewUrls(photos: UiPhoto[]): Set<string> {
230+
return new Set(
231+
photos
232+
.filter(
233+
photo => photo.source === 'new' && photo.previewUrl.startsWith('blob:')
234+
)
235+
.map(photo => photo.previewUrl)
236+
);
237+
}
238+
239+
export function revokeSupersededPhotoPreviewUrls(
240+
previousPhotos: UiPhoto[],
241+
nextPhotos: UiPhoto[]
242+
) {
243+
const nextBlobPreviewUrls = getBlobPreviewUrls(nextPhotos);
244+
245+
previousPhotos.forEach(photo => {
246+
if (photo.source !== 'new' || !photo.previewUrl.startsWith('blob:')) {
247+
return;
248+
}
249+
250+
if (nextBlobPreviewUrls.has(photo.previewUrl)) {
251+
return;
252+
}
253+
254+
URL.revokeObjectURL(photo.previewUrl);
255+
});
256+
}
257+
203258
export function ensureUiPhotos(fromInitial: {
204259
images?: ProductImage[];
205260
imageUrl?: string;
@@ -310,6 +365,20 @@ export function ProductForm({
310365
Partial<Record<CurrencyCode, string>>
311366
>({});
312367

368+
function replacePhotos(
369+
nextOrUpdater: UiPhoto[] | ((prev: UiPhoto[]) => UiPhoto[])
370+
) {
371+
setPhotos(prev => {
372+
const next =
373+
typeof nextOrUpdater === 'function'
374+
? nextOrUpdater(prev)
375+
: nextOrUpdater;
376+
revokeSupersededPhotoPreviewUrls(prev, next);
377+
photosRef.current = next;
378+
return next;
379+
});
380+
}
381+
313382
useEffect(() => {
314383
if (mode !== 'edit') {
315384
hydratedKeyRef.current = null;
@@ -357,7 +426,7 @@ export function ProductForm({
357426
setDescription(initialValues.description ?? '');
358427
setIsActive(initialValues.isActive ?? true);
359428
setIsFeatured(initialValues.isFeatured ?? false);
360-
setPhotos(
429+
replacePhotos(
361430
ensureUiPhotos({
362431
images: initialValues.images,
363432
imageUrl: initialValues.imageUrl,
@@ -435,13 +504,13 @@ export function ProductForm({
435504
file,
436505
}));
437506

438-
setPhotos(prev => normalizeUiPhotos([...prev, ...nextPhotos]));
507+
replacePhotos(prev => normalizeUiPhotos([...prev, ...nextPhotos]));
439508
setImageError(null);
440509
event.target.value = '';
441510
};
442511

443512
const setPrimaryPhoto = (key: string) => {
444-
setPhotos(prev =>
513+
replacePhotos(prev =>
445514
prev.map(photo => ({
446515
...photo,
447516
isPrimary: photo.key === key,
@@ -451,7 +520,7 @@ export function ProductForm({
451520
};
452521

453522
const movePhoto = (key: string, direction: -1 | 1) => {
454-
setPhotos(prev => {
523+
replacePhotos(prev => {
455524
const index = prev.findIndex(photo => photo.key === key);
456525
if (index < 0) return prev;
457526

@@ -466,14 +535,9 @@ export function ProductForm({
466535
};
467536

468537
const removePhoto = (key: string) => {
469-
setPhotos(prev => {
470-
const removed = prev.find(photo => photo.key === key);
471-
if (removed?.source === 'new' && removed.previewUrl.startsWith('blob:')) {
472-
URL.revokeObjectURL(removed.previewUrl);
473-
}
474-
475-
return normalizeUiPhotos(prev.filter(photo => photo.key !== key));
476-
});
538+
replacePhotos(prev =>
539+
normalizeUiPhotos(prev.filter(photo => photo.key !== key))
540+
);
477541
};
478542

479543
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
@@ -554,7 +618,24 @@ export function ProductForm({
554618
formData.append('isActive', isActive ? 'true' : 'false');
555619
formData.append('isFeatured', isFeatured ? 'true' : 'false');
556620

557-
const { photoPlan, newPhotos } = buildPhotoPlanSubmission(photos);
621+
const photoSubmission = (() => {
622+
try {
623+
return buildPhotoPlanSubmission(photos);
624+
} catch (photoPlanError) {
625+
if (photoPlanError instanceof PhotoPlanSubmissionError) {
626+
setImageError(photoPlanError.message);
627+
return null;
628+
}
629+
630+
throw photoPlanError;
631+
}
632+
})();
633+
634+
if (!photoSubmission) {
635+
return;
636+
}
637+
638+
const { photoPlan, newPhotos } = photoSubmission;
558639

559640
if (photoPlan?.length) {
560641
formData.append('photoPlan', JSON.stringify(photoPlan));

frontend/lib/shop/data.ts

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,27 @@ export interface ProductPageDisplayProduct {
6161
badge: ProductBadge;
6262
}
6363

64+
type AvailableProductPageViewModelInput = {
65+
kind: 'available';
66+
commerceProduct: ShopProduct;
67+
};
68+
69+
type UnavailableProductPageViewModelInput = {
70+
kind: 'unavailable';
71+
product: ProductPageDisplayProduct;
72+
commerceProduct: null;
73+
};
74+
6475
export type ProductPageData =
65-
| {
66-
kind: 'available';
67-
product: ProductPageDisplayProduct;
68-
commerceProduct: ShopProduct;
69-
}
70-
| {
71-
kind: 'unavailable';
76+
| (AvailableProductPageViewModelInput & {
7277
product: ProductPageDisplayProduct;
73-
commerceProduct: null;
74-
}
78+
})
79+
| UnavailableProductPageViewModelInput
80+
| { kind: 'not_found' };
81+
82+
type ProductPageViewModelInput =
83+
| AvailableProductPageViewModelInput
84+
| UnavailableProductPageViewModelInput
7585
| { kind: 'not_found' };
7686

7787
export async function getProductPageData(
@@ -83,23 +93,16 @@ export async function getProductPageData(
8393
const dbProduct = await getPublicProductBySlug(slug, currency);
8494
if (dbProduct) {
8595
const mapped = mapToShopProduct(dbProduct);
86-
if (mapped) {
87-
return toProductPageViewModel({
88-
kind: 'available',
89-
product: toProductPageDisplayProduct({
90-
id: mapped.id,
91-
slug: mapped.slug,
92-
name: mapped.name,
93-
image: mapped.image,
94-
images: mapped.images,
95-
primaryImage: mapped.primaryImage,
96-
description: mapped.description,
97-
badge: mapped.badge ?? 'NONE',
98-
}),
99-
commerceProduct: mapped,
100-
});
96+
if (!mapped) {
97+
throw new Error(
98+
`Invalid shop product data for PDP: slug=${slug} productId=${dbProduct.id}`
99+
);
101100
}
102-
return { kind: 'not_found' };
101+
102+
return toProductPageViewModel({
103+
kind: 'available',
104+
commerceProduct: mapped,
105+
});
103106
}
104107

105108
const base = await getPublicProductBaseBySlug(slug);
@@ -186,7 +189,9 @@ function toProductPageDisplayProduct(input: {
186189
};
187190
}
188191

189-
export function toProductPageViewModel(data: ProductPageData): ProductPageData {
192+
export function toProductPageViewModel(
193+
data: ProductPageViewModelInput
194+
): ProductPageData {
190195
if (data.kind === 'not_found') return data;
191196

192197
if (data.kind === 'available') {

frontend/lib/tests/shop/admin-product-photo-management.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,9 @@ describe.sequential('admin product photo management', () => {
247247
imageUrl: 'https://example.com/p2.png',
248248
imagePublicId: 'products/p2',
249249
});
250-
expect(cloudinaryMocks.destroyProductImage).toHaveBeenCalledWith(
250+
expect(cloudinaryMocks.destroyProductImage).toHaveBeenCalledTimes(1);
251+
expect(cloudinaryMocks.destroyProductImage).toHaveBeenNthCalledWith(
252+
1,
251253
primaryImage.imagePublicId
252254
);
253255
});

frontend/lib/tests/shop/product-gallery-view-model.test.ts

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
const shopQueryMocks = vi.hoisted(() => ({
4+
getPublicProductBySlug: vi.fn(),
5+
getPublicProductBaseBySlug: vi.fn(),
6+
getActiveProductsPage: vi.fn(),
7+
getFeaturedProducts: vi.fn(),
8+
}));
9+
10+
vi.mock('@/db/queries/shop/products', () => shopQueryMocks);
211

312
import {
413
getProductGalleryImages,
14+
getProductPageData,
515
toProductPageViewModel,
616
} from '@/lib/shop/data';
717

@@ -87,30 +97,6 @@ describe('product gallery view model', () => {
8797
it('normalizes PDP display and commerce data into separate concrete branches', () => {
8898
const viewModel = toProductPageViewModel({
8999
kind: 'available',
90-
product: {
91-
id: 'product-1',
92-
slug: 'product-1',
93-
name: 'Product 1',
94-
image: 'https://example.com/primary.png',
95-
images: [
96-
{
97-
id: 'img-primary',
98-
url: 'https://example.com/primary.png',
99-
publicId: 'products/primary',
100-
sortOrder: 0,
101-
isPrimary: true,
102-
},
103-
],
104-
primaryImage: {
105-
id: 'img-primary',
106-
url: 'https://example.com/primary.png',
107-
publicId: 'products/primary',
108-
sortOrder: 0,
109-
isPrimary: true,
110-
},
111-
description: 'desc',
112-
badge: 'SALE',
113-
},
114100
commerceProduct: {
115101
id: 'product-1',
116102
slug: 'product-1',
@@ -152,4 +138,35 @@ describe('product gallery view model', () => {
152138
expect(viewModel.commerceProduct.price).toBe(5000);
153139
expect(viewModel.commerceProduct.currency).toBe('USD');
154140
});
141+
142+
it('throws a descriptive error when a found PDP product cannot be mapped safely', async () => {
143+
shopQueryMocks.getPublicProductBySlug.mockResolvedValueOnce({
144+
id: 'db-product-1',
145+
slug: 'broken-product',
146+
title: 'Broken product',
147+
description: null,
148+
imageUrl: '',
149+
imagePublicId: undefined,
150+
price: 'not-a-number',
151+
originalPrice: null,
152+
currency: 'USD',
153+
isActive: true,
154+
isFeatured: false,
155+
stock: 2,
156+
sku: null,
157+
category: null,
158+
type: null,
159+
colors: [],
160+
sizes: [],
161+
badge: 'NONE',
162+
images: [],
163+
primaryImage: undefined,
164+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
165+
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
166+
});
167+
168+
await expect(getProductPageData('broken-product', 'en')).rejects.toThrow(
169+
'Invalid shop product data for PDP: slug=broken-product productId=db-product-1'
170+
);
171+
});
155172
});

0 commit comments

Comments
 (0)