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.
|
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:
|
The latest build was verified successfully with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -20,6 +26,60 @@ Latest TypeScript verification after the validation/upload changes:
|
|||||||
npx tsc --noEmit
|
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`
|
## Latest Codex Changes After `76fb4f3`
|
||||||
|
|
||||||
### Product create preorder and warranty submit rules
|
### Product create preorder and warranty submit rules
|
||||||
@ -602,13 +662,13 @@ pm2 save
|
|||||||
sudo -iu inadev pm2 status
|
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
|
```bash
|
||||||
2f64282
|
git rev-parse --short HEAD
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify on server:
|
Verify full commit on server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git rev-parse HEAD
|
git rev-parse HEAD
|
||||||
|
|||||||
@ -118,7 +118,7 @@ export default function DashboardLayout({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* 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 */}
|
{/* Sidebar Header */}
|
||||||
<div className="mt-12 px-6 mb-6">
|
<div className="mt-12 px-6 mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@ -304,7 +304,7 @@ function ProductDetailPageInner() {
|
|||||||
const features = Array.isArray(product.productFeatures) ? product.productFeatures.filter(Boolean) : [];
|
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 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 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
|
...(product.imageId || product.image
|
||||||
? [{ id: product.imageId, url: resolveImageUrl(product.imageId, product.image) }]
|
? [{ id: product.imageId, url: resolveImageUrl(product.imageId, product.image) }]
|
||||||
: []),
|
: []),
|
||||||
@ -321,6 +321,13 @@ function ProductDetailPageInner() {
|
|||||||
.filter((img) => isNonEmptyString(img.url))
|
.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";
|
const isReviewProduct = isReview || product.state === "REVIEW";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1337,6 +1337,14 @@ function EditProductPageInner() {
|
|||||||
function buildPayload(state?: "DRAFT" | "REVIEW") {
|
function buildPayload(state?: "DRAFT" | "REVIEW") {
|
||||||
if (!form) return null;
|
if (!form) return null;
|
||||||
const resolvedState = state ?? "DRAFT";
|
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 = {
|
const base = {
|
||||||
subCategory: form.subCategoryId ? { id: form.subCategoryId } : undefined,
|
subCategory: form.subCategoryId ? { id: form.subCategoryId } : undefined,
|
||||||
name: form.name,
|
name: form.name,
|
||||||
@ -1345,9 +1353,9 @@ function EditProductPageInner() {
|
|||||||
preOrderDay: form.isPreOrder ? toNum(form.preOrderDay) : null,
|
preOrderDay: form.isPreOrder ? toNum(form.preOrderDay) : null,
|
||||||
isNew: form.isNew,
|
isNew: form.isNew,
|
||||||
isEligibleToExport: form.isEligibleToExport,
|
isEligibleToExport: form.isEligibleToExport,
|
||||||
imageId: form.imageId || undefined,
|
imageId: form.imageId || null,
|
||||||
productFiles: form.productFiles.map((file) => file.id).filter(Boolean),
|
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),
|
productKeyWords: form.keywords.filter(Boolean).slice(0, 3),
|
||||||
productFeatures: form.features.filter(Boolean).slice(0, 5),
|
productFeatures: form.features.filter(Boolean).slice(0, 5),
|
||||||
productModels: form.models.map((m) => ({
|
productModels: form.models.map((m) => ({
|
||||||
@ -1529,6 +1537,11 @@ function EditProductPageInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Render ──
|
// ── Render ──
|
||||||
|
const visibleGalleryImages = form.productImages
|
||||||
|
.map((imageId, index) => ({ imageId, index }))
|
||||||
|
.filter((item) => !item.imageId || item.imageId !== form.imageId);
|
||||||
|
const visibleImageSlotCount = 1 + visibleGalleryImages.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-6 md:px-10 py-8 pb-36 space-y-8">
|
<div className="w-full px-6 md:px-10 py-8 pb-36 space-y-8">
|
||||||
{/* Page header */}
|
{/* 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="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">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-[11px] font-black uppercase tracking-[0.18em] text-outline block">{e.visualIdentity}</label>
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<ImageSlotUpload
|
<ImageSlotUpload
|
||||||
fileId={form.imageId}
|
fileId={form.imageId}
|
||||||
label={e.mainImage}
|
label={e.mainImage}
|
||||||
onUploaded={(id) => update({ imageId: id })}
|
onUploaded={(id) => {
|
||||||
onRemove={() => update({ imageId: "" })}
|
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
|
<ImageSlotUpload
|
||||||
key={i}
|
key={item.index}
|
||||||
fileId={imgId}
|
fileId={item.imageId}
|
||||||
label={`${e.gallery} ${i + 1}`}
|
label={`${e.gallery} ${visibleIndex + 1}`}
|
||||||
onUploaded={(id) => {
|
onUploaded={(id) => {
|
||||||
const updated = [...form.productImages];
|
const updated = [...form.productImages];
|
||||||
updated[i] = id;
|
updated[item.index] = id;
|
||||||
update({ productImages: updated });
|
update({ productImages: updated });
|
||||||
}}
|
}}
|
||||||
onRemove={() => update({ productImages: form.productImages.filter((_, xi) => xi !== i) })}
|
onRemove={() => update({ productImages: form.productImages.filter((_, xi) => xi !== item.index) })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{1 + form.productImages.length < MAX_IMAGES && (
|
{visibleImageSlotCount < MAX_IMAGES && (
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onClick={() => update({ productImages: [...form.productImages, ""] })}
|
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">
|
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(() => {
|
useEffect(() => {
|
||||||
setOpenActionMenuId(null);
|
setOpenActionMenuId(null);
|
||||||
|
setActionMenuPosition(null);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setUnpublishTarget(null);
|
||||||
|
setStockPriceTarget(null);
|
||||||
|
setDeleting(false);
|
||||||
|
setUnpublishing(false);
|
||||||
|
setPublishingId(null);
|
||||||
|
setRestoringId(null);
|
||||||
}, [tab, page]);
|
}, [tab, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -966,11 +974,18 @@ function ProductsPageInner() {
|
|||||||
|
|
||||||
const query = searchParams.toString();
|
const query = searchParams.toString();
|
||||||
const url = `/api/products/${deleteTarget.id}${query ? `?${query}` : ""}`;
|
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);
|
setDeleteTarget(null);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch {
|
} catch (err) {
|
||||||
alert(p.deleteDialog.errorGeneric);
|
alert(err instanceof Error ? err.message : p.deleteDialog.errorGeneric);
|
||||||
|
setDeleteTarget(null);
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
@ -994,6 +1009,7 @@ function ProductsPageInner() {
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : p.unpublishDialog.errorGeneric);
|
alert(err instanceof Error ? err.message : p.unpublishDialog.errorGeneric);
|
||||||
|
setUnpublishTarget(null);
|
||||||
} finally {
|
} finally {
|
||||||
setUnpublishing(false);
|
setUnpublishing(false);
|
||||||
}
|
}
|
||||||
@ -1104,17 +1120,9 @@ function ProductsPageInner() {
|
|||||||
: current
|
: current
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : p.stockPriceDialog.loadError);
|
||||||
setStockPriceTarget((current) =>
|
setStockPriceTarget((current) =>
|
||||||
current && current.product.id === product.id
|
current && current.product.id === product.id ? null : current
|
||||||
? {
|
|
||||||
...current,
|
|
||||||
loading: false,
|
|
||||||
error:
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: p.stockPriceDialog.loadError,
|
|
||||||
}
|
|
||||||
: current
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import Link from "next/link";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
const productSubmenu = [
|
const productSubmenu = [
|
||||||
{ label: "All Product", href: "/products" },
|
{ label: "All Product", href: "/products?tab=all" },
|
||||||
{ label: "Draft", href: "/products?tab=draft" },
|
{ label: "Draft", href: "/products?tab=draft" },
|
||||||
{ label: "In Review", href: "/products?tab=in-review" },
|
{ label: "In Review", href: "/products?tab=in-review" },
|
||||||
{ label: "International Market", href: "/products?tab=international-market" },
|
{ label: "International Market", href: "/products?tab=international-market" },
|
||||||
@ -16,7 +17,6 @@ const productSubmenu = [
|
|||||||
|
|
||||||
function ProductSubmenuNavInner() {
|
function ProductSubmenuNavInner() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const rawTab = searchParams.get("tab") ?? "";
|
const rawTab = searchParams.get("tab") ?? "";
|
||||||
const currentTab = rawTab === "all" ? "" : rawTab;
|
const currentTab = rawTab === "all" ? "" : rawTab;
|
||||||
@ -29,16 +29,15 @@ function ProductSubmenuNavInner() {
|
|||||||
const submenuTab = new URLSearchParams(
|
const submenuTab = new URLSearchParams(
|
||||||
submenu.href.split("?")[1] || ""
|
submenu.href.split("?")[1] || ""
|
||||||
).get("tab") ?? "";
|
).get("tab") ?? "";
|
||||||
const isAllProduct = submenu.href === "/products";
|
const isAllProduct = submenuTab === "all";
|
||||||
const isSubmenuActive =
|
const isSubmenuActive =
|
||||||
pathname === "/products" &&
|
pathname === "/products" &&
|
||||||
(isAllProduct ? currentTab === "" : submenuTab === currentTab);
|
(isAllProduct ? currentTab === "" : submenuTab === currentTab);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Link
|
||||||
key={submenu.href}
|
key={submenu.href}
|
||||||
type="button"
|
href={submenu.href}
|
||||||
onClick={() => router.push(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 ${
|
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
|
isSubmenuActive
|
||||||
? "bg-white text-primary border-l-4 border-primary shadow-sm rounded-l-none"
|
? "bg-white text-primary border-l-4 border-primary shadow-sm rounded-l-none"
|
||||||
@ -46,7 +45,7 @@ function ProductSubmenuNavInner() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{submenu.label}
|
{submenu.label}
|
||||||
</button>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -96,16 +96,25 @@ export function ProductVariantShowcase({
|
|||||||
const productImages = getAllProductImageRefs(product);
|
const productImages = getAllProductImageRefs(product);
|
||||||
const [selectedModelIndex, setSelectedModelIndex] = useState(0);
|
const [selectedModelIndex, setSelectedModelIndex] = useState(0);
|
||||||
const [selectedMeasurementIndex, setSelectedMeasurementIndex] = useState(0);
|
const [selectedMeasurementIndex, setSelectedMeasurementIndex] = useState(0);
|
||||||
|
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||||
|
|
||||||
const selectedModel = models[selectedModelIndex] || null;
|
const selectedModel = models[selectedModelIndex] || null;
|
||||||
const measurements = selectedModel ? getModelMeasurements(selectedModel) : [];
|
const measurements = selectedModel ? getModelMeasurements(selectedModel) : [];
|
||||||
const hasMeasurements = selectedModel ? modelHasMeasurements(selectedModel) : false;
|
const hasMeasurements = selectedModel ? modelHasMeasurements(selectedModel) : false;
|
||||||
const selectedMeasurement = hasMeasurements ? measurements[selectedMeasurementIndex] || measurements[0] || null : null;
|
const selectedMeasurement = hasMeasurements ? measurements[selectedMeasurementIndex] || measurements[0] || null : null;
|
||||||
|
const selectedProductImage =
|
||||||
|
productImages.length > 0
|
||||||
|
? productImages[Math.min(selectedImageIndex, productImages.length - 1)]
|
||||||
|
: null;
|
||||||
const selectedImageId =
|
const selectedImageId =
|
||||||
|
selectedProductImage?.id ||
|
||||||
(selectedModel?.imageId as string | undefined) ||
|
(selectedModel?.imageId as string | undefined) ||
|
||||||
productImages[0]?.id ||
|
productImages[0]?.id ||
|
||||||
null;
|
null;
|
||||||
const selectedImageUrl = imgUrl(selectedImageId, selectedModel?.image || productImages[0]?.url);
|
const selectedImageUrl = imgUrl(
|
||||||
|
selectedImageId,
|
||||||
|
selectedProductImage?.url || selectedModel?.image || productImages[0]?.url
|
||||||
|
);
|
||||||
|
|
||||||
const selectedPrice = selectedMeasurement
|
const selectedPrice = selectedMeasurement
|
||||||
? formatMoney(selectedMeasurement.price, selectedMeasurement.currency || selectedModel?.currency)
|
? formatMoney(selectedMeasurement.price, selectedMeasurement.currency || selectedModel?.currency)
|
||||||
@ -185,15 +194,23 @@ export function ProductVariantShowcase({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{productImages.length > 1 ? (
|
{productImages.length > 1 ? (
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||||
{productImages.slice(0, 4).map((imageRef, index) => {
|
{productImages.map((imageRef, index) => {
|
||||||
const image = imgUrl(imageRef.id, imageRef.url);
|
const image = imgUrl(imageRef.id, imageRef.url);
|
||||||
if (!image) return null;
|
if (!image) return null;
|
||||||
|
const active = index === Math.min(selectedImageIndex, productImages.length - 1);
|
||||||
return (
|
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 */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={image} alt={`gallery-${index + 1}`} className="h-full w-full object-cover" />
|
<img src={image} alt={`gallery-${index + 1}`} className="h-full w-full object-cover" />
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -118,10 +118,15 @@ export function getSortedProductImages(product: VariantProductLike) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAllProductImageIds(product: VariantProductLike) {
|
export function getAllProductImageIds(product: VariantProductLike) {
|
||||||
|
const seen = new Set<string>();
|
||||||
return [
|
return [
|
||||||
...(product.imageId ? [product.imageId] : []),
|
...(product.imageId ? [product.imageId] : []),
|
||||||
...getSortedProductImages(product),
|
...getSortedProductImages(product),
|
||||||
];
|
].filter((imageId) => {
|
||||||
|
if (seen.has(imageId)) return false;
|
||||||
|
seen.add(imageId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSortedProductImageRefs(product: VariantProductLike) {
|
export function getSortedProductImageRefs(product: VariantProductLike) {
|
||||||
@ -136,10 +141,17 @@ export function getSortedProductImageRefs(product: VariantProductLike) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAllProductImageRefs(product: VariantProductLike) {
|
export function getAllProductImageRefs(product: VariantProductLike) {
|
||||||
|
const seen = new Set<string>();
|
||||||
return [
|
return [
|
||||||
...(product.imageId || product.image ? [{ id: product.imageId || null, url: product.image || null }] : []),
|
...(product.imageId || product.image ? [{ id: product.imageId || null, url: product.image || null }] : []),
|
||||||
...getSortedProductImageRefs(product),
|
...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) {
|
export function getModelMeasurements(model: VariantModelLike) {
|
||||||
|
|||||||
Reference in New Issue
Block a user