Fix product image gallery and submenu navigation

This commit is contained in:
2026-06-01 08:16:16 +07:00
parent 2cfff02b69
commit 4e28ccdbd1
8 changed files with 174 additions and 45 deletions

View File

@ -8,6 +8,12 @@ Latest verified commit: `f090ba7`
This codebase has recent updates around auth/onboarding, help/privacy pages, dashboard search, product creation/edit/review/detail, admin review/detail, stock/price editing, seller in-review listing, backend request logging, expired-session redirects, product keyword limits, sanitized backend error display, AddProductRequest validation, and 10 MB upload limits.
Latest local verification before the newest push:
```bash
npm run build
```
The latest build was verified successfully with:
```bash
@ -20,6 +26,60 @@ Latest TypeScript verification after the validation/upload changes:
npx tsc --noEmit
```
## Latest Codex Changes After `2cfff02`
### Product edit image payload preservation
File:
- `src/app/(dashboard)/products/[productId]/edit/page.tsx`
Behavior:
- Edit-product save keeps `imageId` as the main image field.
- If the main image already exists inside `productImages`, the save payload preserves it at the same sequence.
- If the main image is replaced, the old main image entry inside `productImages` is replaced with the new `imageId` at the same sequence.
- The edit gallery UI hides duplicate thumbnails where `form.imageId` is also present in `form.productImages`, but the underlying array is preserved for payload sequence.
Expected image payload shape:
```json
{
"imageId": "imageBaru.jpg",
"productImages": [
{ "imageId": "gallery-0.jpg", "sequence": 1 },
{ "imageId": "imageBaru.jpg", "sequence": 2 },
{ "imageId": "gallery-2.jpg", "sequence": 3 }
]
}
```
### Product detail image gallery
Files:
- `src/app/(dashboard)/products/[productId]/detail/page.tsx`
- `src/components/product-variant-showcase.tsx`
- `src/lib/product-variants.ts`
Behavior:
- Product detail image thumbnails are deduplicated between root `imageId` and `productImages`.
- Thumbnail strip is horizontally scrollable when there are many images.
- Clicking a thumbnail updates the large preview image.
- The active thumbnail receives a visible selected state.
### Product submenu click lock fix
Files:
- `src/app/(dashboard)/layout.tsx`
- `src/app/(dashboard)/products/page.tsx`
- `src/components/product-submenu-nav.tsx`
Behavior:
- Product submenu items now use Next `<Link>` instead of manual `button` + `router.push`.
- `All Product` now navigates to `/products?tab=all`, matching the query-based pattern used by the other submenu tabs.
- Product API already normalizes `tab=all` to the all-product backend endpoint.
- Sidebar z-index was raised above product modals so submenu clicks are not blocked by stale overlays.
- Product list clears action-menu and modal state when tab/page changes.
- Delete/unpublish/stock-price-load errors now close the related modal after showing the error, preventing a full-screen overlay from trapping sidebar clicks.
## Latest Codex Changes After `76fb4f3`
### Product create preorder and warranty submit rules
@ -602,13 +662,13 @@ pm2 save
sudo -iu inadev pm2 status
```
Current expected deployed commit after latest push:
Current expected deployed commit after latest push is the latest `origin/main` commit from this handoff update. Verify short hash with:
```bash
2f64282
git rev-parse --short HEAD
```
Verify on server:
Verify full commit on server:
```bash
git rev-parse HEAD

View File

@ -118,7 +118,7 @@ export default function DashboardLayout({
</header>
{/* Sidebar */}
<aside className="fixed left-0 top-0 h-full w-72 bg-surface-container-lowest flex flex-col py-6 border-r border-surface-container z-40">
<aside className="fixed left-0 top-0 z-[60] h-full w-72 bg-surface-container-lowest flex flex-col py-6 border-r border-surface-container">
{/* Sidebar Header */}
<div className="mt-12 px-6 mb-6">
<div className="flex items-center gap-3">

View File

@ -304,7 +304,7 @@ function ProductDetailPageInner() {
const features = Array.isArray(product.productFeatures) ? product.productFeatures.filter(Boolean) : [];
const productInfos = Array.isArray(product.productInformations) ? product.productInformations.filter((i: { paramName: string; paramValue: string }) => i.paramName && i.paramValue) : [];
const categoryInfos = Array.isArray(product.categoryInformations) ? product.categoryInformations.filter((i: { paramName: string; paramValue: string }) => i.paramName && i.paramValue) : [];
const allImages: Array<{ id?: string; url: string }> = [
const rawImages: Array<{ id?: string; url: string }> = [
...(product.imageId || product.image
? [{ id: product.imageId, url: resolveImageUrl(product.imageId, product.image) }]
: []),
@ -321,6 +321,13 @@ function ProductDetailPageInner() {
.filter((img) => isNonEmptyString(img.url))
: []),
];
const seenImages = new Set<string>();
const allImages = rawImages.filter((img) => {
const key = img.id || img.url;
if (!key || seenImages.has(key)) return false;
seenImages.add(key);
return true;
});
const isReviewProduct = isReview || product.state === "REVIEW";
return (

View File

@ -1337,6 +1337,14 @@ function EditProductPageInner() {
function buildPayload(state?: "DRAFT" | "REVIEW") {
if (!form) return null;
const resolvedState = state ?? "DRAFT";
const productImageIds = form.productImages.filter(Boolean);
const hasMainImageInProductImages = Boolean(
form.imageId && productImageIds.includes(form.imageId)
);
const productImagesWithMain = [
...(hasMainImageInProductImages ? [] : [form.imageId]),
...productImageIds,
].filter(Boolean);
const base = {
subCategory: form.subCategoryId ? { id: form.subCategoryId } : undefined,
name: form.name,
@ -1345,9 +1353,9 @@ function EditProductPageInner() {
preOrderDay: form.isPreOrder ? toNum(form.preOrderDay) : null,
isNew: form.isNew,
isEligibleToExport: form.isEligibleToExport,
imageId: form.imageId || undefined,
imageId: form.imageId || null,
productFiles: form.productFiles.map((file) => file.id).filter(Boolean),
productImages: form.productImages.filter(Boolean).map((imageId, i) => ({ imageId, sequence: i + 1 })),
productImages: productImagesWithMain.map((imageId, i) => ({ imageId, sequence: i + 1 })),
productKeyWords: form.keywords.filter(Boolean).slice(0, 3),
productFeatures: form.features.filter(Boolean).slice(0, 5),
productModels: form.models.map((m) => ({
@ -1529,6 +1537,11 @@ function EditProductPageInner() {
}
// ── Render ──
const visibleGalleryImages = form.productImages
.map((imageId, index) => ({ imageId, index }))
.filter((item) => !item.imageId || item.imageId !== form.imageId);
const visibleImageSlotCount = 1 + visibleGalleryImages.length;
return (
<div className="w-full px-6 md:px-10 py-8 pb-36 space-y-8">
{/* Page header */}
@ -1729,30 +1742,43 @@ function EditProductPageInner() {
<div className="xl:col-span-5 bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm p-8 space-y-4">
<div className="flex items-center justify-between">
<label className="text-[11px] font-black uppercase tracking-[0.18em] text-outline block">{e.visualIdentity}</label>
<span className="text-[10px] font-bold text-outline">{1 + form.productImages.length} / {MAX_IMAGES}</span>
<span className="text-[10px] font-bold text-outline">{visibleImageSlotCount} / {MAX_IMAGES}</span>
</div>
<div className="space-y-2">
<ImageSlotUpload
fileId={form.imageId}
label={e.mainImage}
onUploaded={(id) => update({ imageId: id })}
onRemove={() => update({ imageId: "" })}
onUploaded={(id) => {
const previousMainImageId = form.imageId;
const updatedProductImages = previousMainImageId
? form.productImages.map((imageId) =>
imageId === previousMainImageId ? id : imageId
)
: form.productImages;
update({ imageId: id, productImages: updatedProductImages });
}}
onRemove={() =>
update({
imageId: "",
productImages: form.productImages.filter((imageId) => imageId !== form.imageId),
})
}
/>
{form.productImages.map((imgId, i) => (
{visibleGalleryImages.map((item, visibleIndex) => (
<ImageSlotUpload
key={i}
fileId={imgId}
label={`${e.gallery} ${i + 1}`}
key={item.index}
fileId={item.imageId}
label={`${e.gallery} ${visibleIndex + 1}`}
onUploaded={(id) => {
const updated = [...form.productImages];
updated[i] = id;
updated[item.index] = id;
update({ productImages: updated });
}}
onRemove={() => update({ productImages: form.productImages.filter((_, xi) => xi !== i) })}
onRemove={() => update({ productImages: form.productImages.filter((_, xi) => xi !== item.index) })}
/>
))}
</div>
{1 + form.productImages.length < MAX_IMAGES && (
{visibleImageSlotCount < MAX_IMAGES && (
<button type="button"
onClick={() => update({ productImages: [...form.productImages, ""] })}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl border-2 border-dashed border-outline-variant/40 text-on-surface-variant hover:border-primary hover:text-primary transition-colors text-sm font-bold">

View File

@ -794,6 +794,14 @@ function ProductsPageInner() {
useEffect(() => {
setOpenActionMenuId(null);
setActionMenuPosition(null);
setDeleteTarget(null);
setUnpublishTarget(null);
setStockPriceTarget(null);
setDeleting(false);
setUnpublishing(false);
setPublishingId(null);
setRestoringId(null);
}, [tab, page]);
useEffect(() => {
@ -966,11 +974,18 @@ function ProductsPageInner() {
const query = searchParams.toString();
const url = `/api/products/${deleteTarget.id}${query ? `?${query}` : ""}`;
await fetch(url, { method: "DELETE", headers: { "x-auth-token": getToken() } });
const res = await fetch(url, { method: "DELETE", headers: { "x-auth-token": getToken() } });
const result = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(result?.responseDesc || p.deleteDialog.errorGeneric);
}
setDeleteTarget(null);
window.location.reload();
} catch {
alert(p.deleteDialog.errorGeneric);
} catch (err) {
alert(err instanceof Error ? err.message : p.deleteDialog.errorGeneric);
setDeleteTarget(null);
} finally {
setDeleting(false);
}
@ -994,6 +1009,7 @@ function ProductsPageInner() {
window.location.reload();
} catch (err) {
alert(err instanceof Error ? err.message : p.unpublishDialog.errorGeneric);
setUnpublishTarget(null);
} finally {
setUnpublishing(false);
}
@ -1104,17 +1120,9 @@ function ProductsPageInner() {
: current
);
} catch (err) {
alert(err instanceof Error ? err.message : p.stockPriceDialog.loadError);
setStockPriceTarget((current) =>
current && current.product.id === product.id
? {
...current,
loading: false,
error:
err instanceof Error
? err.message
: p.stockPriceDialog.loadError,
}
: current
current && current.product.id === product.id ? null : current
);
}
}

View File

@ -1,10 +1,11 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense } from "react";
const productSubmenu = [
{ label: "All Product", href: "/products" },
{ label: "All Product", href: "/products?tab=all" },
{ label: "Draft", href: "/products?tab=draft" },
{ label: "In Review", href: "/products?tab=in-review" },
{ label: "International Market", href: "/products?tab=international-market" },
@ -16,7 +17,6 @@ const productSubmenu = [
function ProductSubmenuNavInner() {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const rawTab = searchParams.get("tab") ?? "";
const currentTab = rawTab === "all" ? "" : rawTab;
@ -29,16 +29,15 @@ function ProductSubmenuNavInner() {
const submenuTab = new URLSearchParams(
submenu.href.split("?")[1] || ""
).get("tab") ?? "";
const isAllProduct = submenu.href === "/products";
const isAllProduct = submenuTab === "all";
const isSubmenuActive =
pathname === "/products" &&
(isAllProduct ? currentTab === "" : submenuTab === currentTab);
return (
<button
<Link
key={submenu.href}
type="button"
onClick={() => router.push(submenu.href)}
href={submenu.href}
className={`relative z-10 flex w-full items-center py-2 pl-3 text-left text-sm font-semibold transition-all rounded-r-xl pointer-events-auto ${
isSubmenuActive
? "bg-white text-primary border-l-4 border-primary shadow-sm rounded-l-none"
@ -46,7 +45,7 @@ function ProductSubmenuNavInner() {
}`}
>
{submenu.label}
</button>
</Link>
);
})}
</div>

View File

@ -96,16 +96,25 @@ export function ProductVariantShowcase({
const productImages = getAllProductImageRefs(product);
const [selectedModelIndex, setSelectedModelIndex] = useState(0);
const [selectedMeasurementIndex, setSelectedMeasurementIndex] = useState(0);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const selectedModel = models[selectedModelIndex] || null;
const measurements = selectedModel ? getModelMeasurements(selectedModel) : [];
const hasMeasurements = selectedModel ? modelHasMeasurements(selectedModel) : false;
const selectedMeasurement = hasMeasurements ? measurements[selectedMeasurementIndex] || measurements[0] || null : null;
const selectedProductImage =
productImages.length > 0
? productImages[Math.min(selectedImageIndex, productImages.length - 1)]
: null;
const selectedImageId =
selectedProductImage?.id ||
(selectedModel?.imageId as string | undefined) ||
productImages[0]?.id ||
null;
const selectedImageUrl = imgUrl(selectedImageId, selectedModel?.image || productImages[0]?.url);
const selectedImageUrl = imgUrl(
selectedImageId,
selectedProductImage?.url || selectedModel?.image || productImages[0]?.url
);
const selectedPrice = selectedMeasurement
? formatMoney(selectedMeasurement.price, selectedMeasurement.currency || selectedModel?.currency)
@ -185,15 +194,23 @@ export function ProductVariantShowcase({
)}
</div>
{productImages.length > 1 ? (
<div className="grid grid-cols-4 gap-2">
{productImages.slice(0, 4).map((imageRef, index) => {
<div className="flex gap-2 overflow-x-auto pb-1">
{productImages.map((imageRef, index) => {
const image = imgUrl(imageRef.id, imageRef.url);
if (!image) return null;
const active = index === Math.min(selectedImageIndex, productImages.length - 1);
return (
<div key={`${imageRef.id || imageRef.url}-${index}`} className="aspect-square overflow-hidden rounded-xl border border-surface-container bg-surface-container-low">
<button
key={`${imageRef.id || imageRef.url}-${index}`}
type="button"
onClick={() => setSelectedImageIndex(index)}
className={`h-20 w-20 flex-shrink-0 overflow-hidden rounded-xl border bg-surface-container-low transition-all ${
active ? "border-primary ring-2 ring-primary/20" : "border-surface-container hover:border-primary/40"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image} alt={`gallery-${index + 1}`} className="h-full w-full object-cover" />
</div>
</button>
);
})}
</div>

View File

@ -118,10 +118,15 @@ export function getSortedProductImages(product: VariantProductLike) {
}
export function getAllProductImageIds(product: VariantProductLike) {
const seen = new Set<string>();
return [
...(product.imageId ? [product.imageId] : []),
...getSortedProductImages(product),
];
].filter((imageId) => {
if (seen.has(imageId)) return false;
seen.add(imageId);
return true;
});
}
export function getSortedProductImageRefs(product: VariantProductLike) {
@ -136,10 +141,17 @@ export function getSortedProductImageRefs(product: VariantProductLike) {
}
export function getAllProductImageRefs(product: VariantProductLike) {
const seen = new Set<string>();
return [
...(product.imageId || product.image ? [{ id: product.imageId || null, url: product.image || null }] : []),
...getSortedProductImageRefs(product),
];
].filter((item) => {
const key = item.id || item.url;
if (!key) return false;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function getModelMeasurements(model: VariantModelLike) {