Fix product image gallery and submenu navigation
This commit is contained in:
66
HANDOFF.md
66
HANDOFF.md
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user