Skip to content

Commit cc158ff

Browse files
(SP: 3) [SHOP] fix post-review storefront photo flow, PDP fallback, gallery tests, and migration SQL consistency
1 parent b550c39 commit cc158ff

19 files changed

Lines changed: 655 additions & 88 deletions

frontend/app/[locale]/admin/shop/products/[id]/edit/page.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Metadata } from 'next';
22
import { notFound } from 'next/navigation';
33
import { z } from 'zod';
44

5+
import { ProductNotFoundError } from '@/lib/errors/products';
56
import { issueCsrfToken } from '@/lib/security/csrf';
67
import { getAdminProductByIdWithPrices } from '@/lib/services/products';
78
import type { CurrencyCode } from '@/lib/shop/currency';
@@ -38,8 +39,12 @@ export default async function EditProductPage({
3839
let product;
3940
try {
4041
product = await getAdminProductByIdWithPrices(parsed.data.id);
41-
} catch {
42-
notFound();
42+
} catch (error) {
43+
if (error instanceof ProductNotFoundError) {
44+
notFound();
45+
}
46+
47+
throw error;
4348
}
4449

4550
const prices = product.prices;

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

Lines changed: 68 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
66
import { useRouter } from '@/i18n/routing';
77
import { CATEGORIES, COLORS, PRODUCT_TYPES, SIZES } from '@/lib/config/catalog';
88
import { logError } from '@/lib/logging';
9+
import type { AdminProductPhotoPlan } from '@/lib/validation/shop';
910
import type { ProductAdminInput, ProductImage } from '@/lib/validation/shop';
1011

1112
const localSlugify = (input: string): string => {
@@ -46,9 +47,9 @@ type UiPriceRow = {
4647
originalPrice: string;
4748
};
4849

49-
type UiPhoto = {
50+
export type UiPhoto = {
5051
key: string;
51-
source: 'existing' | 'new';
52+
source: 'existing' | 'legacy' | 'new';
5253
imageId?: string;
5354
uploadId?: string;
5455
previewUrl: string;
@@ -152,7 +153,54 @@ function normalizeUiPhotos(photos: UiPhoto[]): UiPhoto[] {
152153
}));
153154
}
154155

155-
function ensureUiPhotos(fromInitial: {
156+
type SerializableUiPhoto =
157+
| (UiPhoto & { source: 'existing'; imageId: string })
158+
| (UiPhoto & { source: 'new'; uploadId: string; file?: File });
159+
160+
function isSerializableUiPhoto(photo: UiPhoto): photo is SerializableUiPhoto {
161+
if (photo.source === 'existing') {
162+
return typeof photo.imageId === 'string' && photo.imageId.trim().length > 0;
163+
}
164+
165+
if (photo.source === 'new') {
166+
return (
167+
typeof photo.uploadId === 'string' && photo.uploadId.trim().length > 0
168+
);
169+
}
170+
171+
return false;
172+
}
173+
174+
export function buildPhotoPlanSubmission(photos: UiPhoto[]): {
175+
photoPlan?: AdminProductPhotoPlan;
176+
newPhotos: Array<UiPhoto & { source: 'new'; uploadId: string; file: File }>;
177+
} {
178+
const serializablePhotos = photos.filter(isSerializableUiPhoto);
179+
180+
if (serializablePhotos.length === 0) {
181+
return { photoPlan: undefined, newPhotos: [] };
182+
}
183+
184+
const primaryIndex = serializablePhotos.findIndex(photo => photo.isPrimary);
185+
const effectivePrimaryIndex = primaryIndex >= 0 ? primaryIndex : 0;
186+
187+
const photoPlan = serializablePhotos.map((photo, index) => ({
188+
imageId: photo.source === 'existing' ? photo.imageId : undefined,
189+
uploadId: photo.source === 'new' ? photo.uploadId : undefined,
190+
isPrimary: index === effectivePrimaryIndex,
191+
}));
192+
193+
const newPhotos = serializablePhotos.filter(
194+
(
195+
photo
196+
): photo is UiPhoto & { source: 'new'; uploadId: string; file: File } =>
197+
photo.source === 'new' && Boolean(photo.file)
198+
);
199+
200+
return { photoPlan, newPhotos };
201+
}
202+
203+
export function ensureUiPhotos(fromInitial: {
156204
images?: ProductImage[];
157205
imageUrl?: string;
158206
}): UiPhoto[] {
@@ -177,8 +225,7 @@ function ensureUiPhotos(fromInitial: {
177225
return [
178226
{
179227
key: 'legacy-image',
180-
source: 'existing',
181-
imageId: 'legacy-image',
228+
source: 'legacy',
182229
previewUrl: fromInitial.imageUrl,
183230
isPrimary: true,
184231
},
@@ -507,29 +554,18 @@ export function ProductForm({
507554
formData.append('isActive', isActive ? 'true' : 'false');
508555
formData.append('isFeatured', isFeatured ? 'true' : 'false');
509556

510-
const photoPlan = photos.map(photo => ({
511-
imageId: photo.source === 'existing' ? photo.imageId : undefined,
512-
uploadId: photo.source === 'new' ? photo.uploadId : undefined,
513-
isPrimary: photo.isPrimary,
514-
}));
515-
516-
const newPhotos = photos.filter(
517-
(
518-
photo
519-
): photo is UiPhoto & { source: 'new'; uploadId: string; file: File } =>
520-
photo.source === 'new' &&
521-
Boolean(photo.uploadId) &&
522-
Boolean(photo.file)
523-
);
557+
const { photoPlan, newPhotos } = buildPhotoPlanSubmission(photos);
524558

525-
formData.append('photoPlan', JSON.stringify(photoPlan));
526-
formData.append(
527-
'newImageUploadIds',
528-
JSON.stringify(newPhotos.map(photo => photo.uploadId))
529-
);
530-
newPhotos.forEach(photo => {
531-
formData.append('newImages', photo.file);
532-
});
559+
if (photoPlan?.length) {
560+
formData.append('photoPlan', JSON.stringify(photoPlan));
561+
formData.append(
562+
'newImageUploadIds',
563+
JSON.stringify(newPhotos.map(photo => photo.uploadId))
564+
);
565+
newPhotos.forEach(photo => {
566+
formData.append('newImages', photo.file);
567+
});
568+
}
533569

534570
if (!csrfToken) {
535571
setError('Security token missing. Refresh the page and retry.');
@@ -1127,7 +1163,11 @@ export function ProductForm({
11271163
</span>
11281164
) : null}
11291165
<span className="text-muted-foreground text-xs">
1130-
{photo.source === 'existing' ? 'Saved' : 'New upload'}
1166+
{photo.source === 'existing'
1167+
? 'Saved'
1168+
: photo.source === 'legacy'
1169+
? 'Legacy image'
1170+
: 'New upload'}
11311171
</span>
11321172
</div>
11331173

frontend/app/[locale]/shop/products/[slug]/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export default async function ProductPage({
7171

7272
<div className="mt-8 grid gap-8 lg:grid-cols-2 lg:gap-16">
7373
<ProductGallery
74+
key={product.id}
7475
productName={product.name}
7576
images={galleryImages}
7677
badgeLabel={badgeLabel}

frontend/app/api/shop/admin/products/[id]/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -405,9 +405,6 @@ export async function PATCH(
405405
}
406406

407407
const parsed = parseAdminProductForm(formData, { mode: 'update' });
408-
const parsedPhotos = parseAdminProductPhotosForm(formData, {
409-
mode: 'update',
410-
});
411408
if (!parsed.ok) {
412409
const issuesCount = getIssuesCount(parsed.error);
413410

@@ -428,6 +425,9 @@ export async function PATCH(
428425
{ status: 400 }
429426
);
430427
}
428+
const parsedPhotos = parseAdminProductPhotosForm(formData, {
429+
mode: 'update',
430+
});
431431
if (!parsedPhotos.ok) {
432432
logWarn('admin_product_update_invalid_photos', {
433433
...baseMeta,

frontend/app/api/shop/admin/products/route.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
AdminUnauthorizedError,
1313
requireAdminApi,
1414
} from '@/lib/auth/admin';
15-
import { destroyProductImage } from '@/lib/cloudinary';
1615
import { logError, logWarn } from '@/lib/logging';
1716
import { requireAdminCsrf } from '@/lib/security/admin-csrf';
1817
import { guardBrowserSameOrigin } from '@/lib/security/origin';
@@ -354,10 +353,8 @@ export async function POST(request: NextRequest) {
354353
durationMs: Date.now() - startedAtMs,
355354
});
356355

357-
let rollbackDeleted = false;
358356
try {
359357
await deleteProduct(inserted.id);
360-
rollbackDeleted = true;
361358
} catch (rollbackError) {
362359
logError(
363360
'admin_product_create_audit_rollback_failed',
@@ -372,25 +369,6 @@ export async function POST(request: NextRequest) {
372369
);
373370
}
374371

375-
try {
376-
if (rollbackDeleted && inserted.imagePublicId) {
377-
await destroyProductImage(inserted.imagePublicId);
378-
}
379-
} catch (imgError) {
380-
logError(
381-
'admin_product_create_audit_rollback_image_failed',
382-
imgError,
383-
{
384-
...baseMeta,
385-
code: 'AUDIT_ROLLBACK_IMAGE_FAILED',
386-
productId: inserted.id,
387-
slug: inserted.slug,
388-
imagePublicId: inserted.imagePublicId ?? null,
389-
durationMs: Date.now() - startedAtMs,
390-
}
391-
);
392-
}
393-
394372
throw auditError;
395373
}
396374

frontend/components/shop/ProductCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export function ProductCard({ product }: ProductCardProps) {
101101
className="text-muted-foreground mt-2 min-h-4 text-xs leading-4"
102102
{...(availabilityState === 'available_to_order'
103103
? { 'aria-label': t('availability.availableToOrder') }
104-
: { role: 'status' })}
104+
: {})}
105105
>
106106
{availabilityState === 'available_to_order'
107107
? t('availability.availableToOrder')

frontend/components/shop/ProductGallery.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import Image from 'next/image';
4+
import type { KeyboardEvent } from 'react';
45
import { useState } from 'react';
56

67
import { SHOP_FOCUS } from '@/lib/shop/ui-classes';
@@ -52,6 +53,22 @@ export function ProductGallery({
5253
const [selectedIndex, setSelectedIndex] = useState(0);
5354
const selectedImage = galleryImages[selectedIndex] ?? galleryImages[0];
5455

56+
const handleThumbnailKeyDown = (
57+
event: KeyboardEvent<HTMLButtonElement>,
58+
index: number
59+
) => {
60+
if (
61+
event.key !== ' ' &&
62+
event.key !== 'Space' &&
63+
event.key !== 'Spacebar'
64+
) {
65+
return;
66+
}
67+
68+
event.preventDefault();
69+
setSelectedIndex(index);
70+
};
71+
5572
return (
5673
<div className="space-y-4" aria-label="Product gallery">
5774
<div className="bg-muted relative aspect-square overflow-hidden rounded-lg">
@@ -86,6 +103,7 @@ export function ProductGallery({
86103
key={image.id}
87104
type="button"
88105
onClick={() => setSelectedIndex(index)}
106+
onKeyDown={event => handleThumbnailKeyDown(event, index)}
89107
aria-label={`Show ${productName} photo ${index + 1}`}
90108
aria-pressed={isSelected}
91109
className={cn(

frontend/db/queries/shop/products.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,37 @@ export type PublicProductBaseRow = Pick<
6161
| 'updatedAt'
6262
>;
6363

64+
type PublicProductBaseResolvedRow = Omit<
65+
PublicProductBaseRow,
66+
'imageUrl' | 'imagePublicId'
67+
> &
68+
ReturnType<typeof resolveProductImages>;
69+
6470
export async function getPublicProductBaseBySlug(
6571
slug: string
66-
): Promise<PublicProductBaseRow | null> {
72+
): Promise<PublicProductBaseResolvedRow | null> {
6773
const rows = await db
6874
.select(publicProductBaseSelect)
6975
.from(products)
7076
.where(and(eq(products.slug, slug), eq(products.isActive, true)))
7177
.limit(1);
7278

73-
return rows[0] ?? null;
79+
const row = rows[0];
80+
if (!row) return null;
81+
82+
const imagesByProductId = await getProductImagesByProductIds([row.id]);
83+
const resolvedImages = resolveProductImages(
84+
row,
85+
imagesByProductId.get(row.id)
86+
);
87+
88+
return {
89+
...row,
90+
imageUrl: resolvedImages.imageUrl,
91+
imagePublicId: resolvedImages.imagePublicId,
92+
images: resolvedImages.images,
93+
primaryImage: resolvedImages.primaryImage,
94+
};
7495
}
7596

7697
const publicProductSelect = {

frontend/drizzle/0034_melodic_pet_avengers.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
CREATE TABLE IF NOT EXISTS "ai_learned_terms" (
1+
CREATE TABLE IF NOT EXISTS "public"."ai_learned_terms" (
22
"id" text PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
33
"user_id" text NOT NULL,
44
"term" text NOT NULL,
@@ -20,7 +20,7 @@ BEGIN
2020
WHERE conname = 'ai_learned_terms_user_id_users_id_fk'
2121
AND conrelid = 'public.ai_learned_terms'::regclass
2222
) THEN
23-
ALTER TABLE "ai_learned_terms"
23+
ALTER TABLE "public"."ai_learned_terms"
2424
ADD CONSTRAINT "ai_learned_terms_user_id_users_id_fk"
2525
FOREIGN KEY ("user_id")
2626
REFERENCES "public"."users"("id")

frontend/lib/services/products/photo-plan.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,27 @@ function photoPayloadError(
3838
return error;
3939
}
4040

41+
function assertUniqueUploadIds(
42+
uploads: ProductImageUploadInput[],
43+
mode: ResolvePhotoPlanOptions['mode']
44+
) {
45+
const seen = new Set<string>();
46+
47+
for (const upload of uploads) {
48+
if (seen.has(upload.uploadId)) {
49+
throw photoPayloadError(
50+
'Uploaded photo payload contains duplicate upload ids.',
51+
{
52+
uploadId: upload.uploadId,
53+
mode,
54+
}
55+
);
56+
}
57+
58+
seen.add(upload.uploadId);
59+
}
60+
}
61+
4162
export function resolvePhotoPlan({
4263
mode,
4364
photoPlan,
@@ -48,6 +69,8 @@ export function resolvePhotoPlan({
4869
throw photoPayloadError('At least one product photo is required.');
4970
}
5071

72+
assertUniqueUploadIds(uploads, mode);
73+
5174
const existingById = new Map(existingImages.map(image => [image.id, image]));
5275
const uploadsById = new Map(uploads.map(upload => [upload.uploadId, upload]));
5376

0 commit comments

Comments
 (0)