7 Commits

8 changed files with 90 additions and 37 deletions
+1 -1
View File
@@ -296,7 +296,7 @@ function buildAssemblyRows(params: {
rows.push({ rows.push({
key: `${groupLabel}-${paths.complete}`, key: `${groupLabel}-${paths.complete}`,
kind: "category", kind: "category",
label: "Completed Lower (Fastest)", label: `Completed ${groupLabel.split(" ")[0]} (Fastest)`,
categoryId: paths.complete, categoryId: paths.complete,
indent: 0, indent: 0,
pill: mode === "complete" ? "Selected" : undefined, pill: mode === "complete" ? "Selected" : undefined,
@@ -7,6 +7,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import type { BuilderSlotKey } from "@/types/builderSlots"; import type { BuilderSlotKey } from "@/types/builderSlots";
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings"; import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
import { trackAffiliateLinkClicked } from "@/lib/analytics"; import { trackAffiliateLinkClicked } from "@/lib/analytics";
import { ProductImage } from "@/components/parts/ProductImage";
/** /**
* API Shapes * API Shapes
@@ -493,12 +494,10 @@ export default function ProductDetailsPage() {
className="h-full w-full" className="h-full w-full"
title="Click to enlarge" title="Click to enlarge"
> >
{/* eslint-disable-next-line @next/next/no-img-element */} <ProductImage
<img
src={imageUrl} src={imageUrl}
alt={product.name} alt={product.name}
className="h-full w-full object-contain p-2" className="h-full w-full object-contain p-2"
loading="lazy"
/> />
</button> </button>
) : ( ) : (
@@ -771,8 +770,7 @@ export default function ProductDetailsPage() {
Close Close
</button> </button>
{/* eslint-disable-next-line @next/next/no-img-element */} <ProductImage
<img
src={imageUrl} src={imageUrl}
alt={product?.name ?? "Product image"} alt={product?.name ?? "Product image"}
className="max-h-[90vh] max-w-[90vw] rounded-lg border border-zinc-800 bg-black object-contain" className="max-h-[90vh] max-w-[90vw] rounded-lg border border-zinc-800 bg-black object-contain"
+6 -12
View File
@@ -1,6 +1,7 @@
// app/builds/[buildId]/page.tsx // app/builds/[buildId]/page.tsx
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ProductImage } from "@/components/parts/ProductImage";
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
@@ -169,18 +170,11 @@ export default async function BuildBreakdownPage({
> >
{/* Thumb */} {/* Thumb */}
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950"> <div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
{it.productImageUrl ? ( <ProductImage
// eslint-disable-next-line @next/next/no-img-element src={it.productImageUrl}
<img alt={it.productName ?? "Part image"}
src={it.productImageUrl} className="h-full w-full object-cover"
alt={it.productName ?? "Part image"} />
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[10px] text-zinc-600">
IMG
</div>
)}
</div> </div>
{/* Info */} {/* Info */}
+7 -11
View File
@@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import type { UiPart } from "@/types/uiPart"; import type { UiPart } from "@/types/uiPart";
import { ProductImage } from './ProductImage';
export default function PartsGrid(props: { export default function PartsGrid(props: {
viewMode: "card" | "list"; viewMode: "card" | "list";
@@ -130,17 +131,12 @@ export default function PartsGrid(props: {
> >
{/* Image */} {/* Image */}
<div className="hidden md:block justify-self-start"> <div className="hidden md:block justify-self-start">
{part.imageUrl ? ( <ProductImage
// eslint-disable-next-line @next/next/no-img-element src={part.imageUrl}
<img alt=""
src={part.imageUrl} className="h-10 w-10 rounded object-cover border border-zinc-800 bg-zinc-950"
alt="" loading="lazy"
className="h-10 w-10 rounded object-cover border border-zinc-800 bg-zinc-950" />
loading="lazy"
/>
) : (
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-950" />
)}
</div> </div>
{/* Part */} {/* Part */}
+6 -8
View File
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { ProductImage } from './ProductImage';
import PlatformSwitcher from "./PlatformSwitcher"; import PlatformSwitcher from "./PlatformSwitcher";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
@@ -149,14 +150,11 @@ export default function ProductDetailPageClient(props: {
<div className="flex flex-col gap-6 md:flex-row"> <div className="flex flex-col gap-6 md:flex-row">
<div className="w-full md:w-72"> <div className="w-full md:w-72">
<div className="aspect-square overflow-hidden rounded-lg border border-zinc-800 bg-black/30"> <div className="aspect-square overflow-hidden rounded-lg border border-zinc-800 bg-black/30">
{/* eslint-disable-next-line @next/next/no-img-element */} <ProductImage
{p.imageUrl ? ( src={p.imageUrl}
<img src={p.imageUrl} alt={p.name} className="h-full w-full object-cover" /> alt={p.name}
) : ( className="h-full w-full object-cover"
<div className="h-full w-full grid place-items-center text-sm text-zinc-600"> />
No image
</div>
)}
</div> </div>
</div> </div>
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { useEffect, useRef } from 'react';
const PLACEHOLDER = '/images/product-placeholder.svg';
interface ProductImageProps {
src?: string | null;
alt: string;
className?: string;
width?: number;
height?: number;
loading?: 'lazy' | 'eager';
}
export function ProductImage({ src, alt, className, width, height, loading }: ProductImageProps) {
const hasErrored = useRef(false);
// Reset the loop guard whenever src changes (e.g. navigating between products)
useEffect(() => {
hasErrored.current = false;
}, [src]);
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
if (hasErrored.current) return;
hasErrored.current = true;
e.currentTarget.src = PLACEHOLDER;
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src ?? PLACEHOLDER}
alt={alt}
className={className}
width={width}
height={height}
loading={loading}
onError={handleError}
/>
);
}
+4
View File
@@ -12,6 +12,10 @@ const nextConfig = {
protocol: 'https', protocol: 'https',
hostname: 'cdn.sanity.io', hostname: 'cdn.sanity.io',
}, },
{
protocol: 'https',
hostname: 'minio.dev.gofwd.group',
},
], ],
}, },
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3" preserveAspectRatio="xMidYMid meet">
<rect width="4" height="3" fill="#27272a"/>
<text
x="2"
y="1.35"
font-family="system-ui, sans-serif"
font-size="0.28"
fill="#71717a"
text-anchor="middle"
dominant-baseline="middle"
>Image</text>
<text
x="2"
y="1.75"
font-family="system-ui, sans-serif"
font-size="0.28"
fill="#71717a"
text-anchor="middle"
dominant-baseline="middle"
>Coming Soon</text>
</svg>

After

Width:  |  Height:  |  Size: 519 B