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({
|
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"
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
protocol: 'https',
|
||||||
hostname: 'cdn.sanity.io',
|
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