Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions apps/web/src/components/campaign/CampaignImageGallery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import CampaignImageGallery from "./CampaignImageGallery";

vi.mock("next/image", () => ({
default: (props: Record<string, unknown>) => <img {...props} />,
}));

describe("CampaignImageGallery", () => {
it("loads only the hero eagerly and lazy-loads the remaining images", () => {
const { getAllByRole } = render(
<CampaignImageGallery
images={[
{ src: "/hero.jpg", alt: "Campaign hero" },
{ src: "/detail.jpg", alt: "Campaign detail" },
{ src: "/impact.jpg", alt: "Campaign impact" },
]}
/>,
);

const images = getAllByRole("img");

expect(images[0].getAttribute("loading")).toBe("eager");
expect(images[0].getAttribute("fetchpriority")).toBe("high");
expect(images[1].getAttribute("loading")).toBe("lazy");
expect(images[1].getAttribute("fetchpriority")).toBe("auto");
expect(images[2].getAttribute("loading")).toBe("lazy");
expect(images[2].getAttribute("fetchpriority")).toBe("auto");
});

it("does not render an empty gallery", () => {
const { queryByTestId } = render(<CampaignImageGallery images={[]} />);

expect(queryByTestId("campaign-image-gallery")).toBeNull();
});
});
62 changes: 62 additions & 0 deletions apps/web/src/components/campaign/CampaignImageGallery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use client";

import Image from "next/image";

export type CampaignImage = {
src: string;
alt: string;
};

type CampaignImageGalleryProps = {
images: CampaignImage[];
};

/**
* Renders campaign detail images without requesting the whole gallery during
* the initial mobile render. The first image is the above-the-fold hero;
* every following image is below the fold and uses the browser's native lazy
* loading path.
*/
export function CampaignImageGallery({ images }: CampaignImageGalleryProps) {
if (images.length === 0) return null;

return (
<section aria-label="Campaign images" data-testid="campaign-image-gallery">
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
{images.map((image, index) => {
const isHero = index === 0;

return (
<figure
className={
isHero
? "relative col-span-2 aspect-[16/9] overflow-hidden rounded-xl md:col-span-3"
: "relative aspect-square overflow-hidden rounded-xl"
}
key={`${image.src}-${index}`}
>
<Image
src={image.src}
alt={image.alt}
fill
sizes={
isHero
? "(max-width: 767px) 100vw, 100vw"
: "(max-width: 767px) 50vw, 33vw"
}
className="object-cover"
priority={isHero}
loading={isHero ? "eager" : "lazy"}
fetchPriority={isHero ? "high" : "auto"}
decoding="async"
unoptimized
/>
</figure>
);
})}
</div>
</section>
);
}

export default CampaignImageGallery;
Loading