Compare commits
7 Commits
eb1dfef7b7
...
47d5d17d29
| Author | SHA1 | Date | |
|---|---|---|---|
| 47d5d17d29 | |||
| c686da0c03 | |||
| 680d2b21a1 | |||
| c86d49f317 | |||
| b8a0fd1963 | |||
| 00613ebc9f | |||
| 05a4ad2720 |
@@ -296,7 +296,7 @@ function buildAssemblyRows(params: {
|
||||
rows.push({
|
||||
key: `${groupLabel}-${paths.complete}`,
|
||||
kind: "category",
|
||||
label: "Completed Lower (Fastest)",
|
||||
label: `Completed ${groupLabel.split(" ")[0]} (Fastest)`,
|
||||
categoryId: paths.complete,
|
||||
indent: 0,
|
||||
pill: mode === "complete" ? "Selected" : undefined,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { BuilderSlotKey } from "@/types/builderSlots";
|
||||
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
|
||||
import { trackAffiliateLinkClicked } from "@/lib/analytics";
|
||||
import { ProductImage } from "@/components/parts/ProductImage";
|
||||
|
||||
/**
|
||||
* API Shapes
|
||||
@@ -493,12 +494,10 @@ export default function ProductDetailsPage() {
|
||||
className="h-full w-full"
|
||||
title="Click to enlarge"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<ProductImage
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-contain p-2"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
@@ -771,8 +770,7 @@ export default function ProductDetailsPage() {
|
||||
Close
|
||||
</button>
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<ProductImage
|
||||
src={imageUrl}
|
||||
alt={product?.name ?? "Product image"}
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-lg border border-zinc-800 bg-black object-contain"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// app/builds/[buildId]/page.tsx
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ProductImage } from "@/components/parts/ProductImage";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
@@ -169,18 +170,11 @@ export default async function BuildBreakdownPage({
|
||||
>
|
||||
{/* Thumb */}
|
||||
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
|
||||
{it.productImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={it.productImageUrl}
|
||||
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>
|
||||
)}
|
||||
<ProductImage
|
||||
src={it.productImageUrl}
|
||||
alt={it.productName ?? "Part image"}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { UiPart } from "@/types/uiPart";
|
||||
import { ProductImage } from './ProductImage';
|
||||
|
||||
export default function PartsGrid(props: {
|
||||
viewMode: "card" | "list";
|
||||
@@ -130,17 +131,12 @@ export default function PartsGrid(props: {
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="hidden md:block justify-self-start">
|
||||
{part.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={part.imageUrl}
|
||||
alt=""
|
||||
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" />
|
||||
)}
|
||||
<ProductImage
|
||||
src={part.imageUrl}
|
||||
alt=""
|
||||
className="h-10 w-10 rounded object-cover border border-zinc-800 bg-zinc-950"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Part */}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ProductImage } from './ProductImage';
|
||||
import PlatformSwitcher from "./PlatformSwitcher";
|
||||
|
||||
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="w-full md:w-72">
|
||||
<div className="aspect-square overflow-hidden rounded-lg border border-zinc-800 bg-black/30">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{p.imageUrl ? (
|
||||
<img src={p.imageUrl} 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>
|
||||
)}
|
||||
<ProductImage
|
||||
src={p.imageUrl}
|
||||
alt={p.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,10 @@ const nextConfig = {
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.sanity.io',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'minio.dev.gofwd.group',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user