diff --git a/README.md b/README.md
index 8aa1b5e2..590bc875 100644
--- a/README.md
+++ b/README.md
@@ -141,6 +141,7 @@ NEXT_PUBLIC_SENTRY_DSN=your_sentry_dsn
- **[⚡ Lazy Loading](./docs/lazy-loading-system.md)** - Bundle-splitting and lazy-loading patterns
- **[🔒 CSP & Security Headers](./docs/csp.md)** - Content Security Policy configuration
- **[🛡️ Phishing Denylist](./docs/phishing-denylist.md)** - Suspicious-domain protections
+- **[🖼️ Gallery](./docs/gallery.md)** - Image gallery validation and translations
### Repository Structure
diff --git a/docs/gallery.md b/docs/gallery.md
new file mode 100644
index 00000000..42a257cb
--- /dev/null
+++ b/docs/gallery.md
@@ -0,0 +1,86 @@
+# Gallery
+
+`ImageGallery` (`src/components/property/ImageGallery.tsx`) renders the photos of a
+property: one large image, a strip of thumbnails and the `ImageLightbox` that opens
+over them. It is rendered by `PropertyDetailServer` from `property.images`.
+
+```tsx
+
+```
+
+The component is a client component: it holds the lightbox state and the loading
+skeleton of the main image.
+
+## Validation
+
+The image list reaches the browser from the API, so it is validated before
+anything is rendered. The rules live in `src/lib/galleryValidation.ts` and are
+implemented with a zod schema (`galleryImageSchema`) plus a cross entry check for
+duplicates:
+
+| Rule | Reason code | What happens |
+| --- | --- | --- |
+| The entry is text | `notAString` | The entry is skipped |
+| The entry is not empty once trimmed | `empty` | The entry is skipped |
+| The entry is an `http(s)` URL or a path that starts with `/` | `invalidSource` | The entry is skipped |
+| The address was already used | `duplicate` | The entry is skipped |
+| The gallery is longer than `GALLERY_MAX_IMAGES` (50) | — | The extra images are not rendered and counted |
+
+`next/image` only accepts an absolute `http(s)` URL or a path that starts with a
+slash, so a relative path without the leading slash and a `data:` URL are rejected
+before they can turn into a broken image.
+
+Every rejected entry is reported to the user in a `role="status"` notice above the
+gallery, together with the reason it was skipped. A gallery whose images are all
+invalid shows that notice and the empty state.
+
+`validateGalleryImages` returns the renderable images, the rejected entries with
+their reason and the number of images that did not fit into the maximum:
+
+```ts
+const { images, issues, droppedCount } = validateGalleryImages(property.images)
+```
+
+## Translations
+
+Every string the gallery renders comes from the `gallery` section of the `common`
+namespace (`src/locales//common.json`). All seven shipped languages carry
+the same keys, which the translation completeness test enforces.
+
+| Key | Used for |
+| --- | --- |
+| `gallery.label` | Accessible name of the gallery |
+| `gallery.noImages` | Empty state headline |
+| `gallery.noImagesDescription` | Empty state explanation |
+| `gallery.viewAll` | Overlay on the main image, pluralised on the image count |
+| `gallery.moreImages` | The "more images" tile under the thumbnails |
+| `gallery.imageAlt` | Alternative text of a thumbnail |
+| `gallery.mainImageAlt` | Alternative text of the main image |
+| `gallery.defaultPropertyName` | Fallback when the property has no name |
+| `gallery.lightboxLabel` | Accessible name of the lightbox dialog |
+| `gallery.lightboxImageAlt` | Alternative text of the lightbox image |
+| `gallery.thumbnailAlt` | Alternative text of a lightbox thumbnail |
+| `gallery.closeLightbox` | Accessible name of the close button |
+| `gallery.previousImage` | Accessible name of the previous button |
+| `gallery.nextImage` | Accessible name of the next button |
+| `gallery.counter` | The "1 / 3" position indicator |
+| `gallery.invalidImagesTitle` | Headline of the validation notice |
+| `gallery.imagesDropped` | How many images were not rendered |
+| `gallery.issue.*` | The reason an entry was skipped |
+
+Counted strings use the i18next plural form (`key` plus `key_one`), and the base
+key is kept as well so languages with more plural categories than English — Arabic
+and Hebrew — still resolve to a sentence instead of a raw key.
+
+## Tests
+
+- `src/components/property/__tests__/ImageGallery.test.tsx`: rendering, thumbnails
+ and the lightbox.
+- `src/components/property/__tests__/ImageGallery.validation.test.tsx`: which images
+ are rendered, which are skipped and what the user is told about it.
+- `src/components/property/__tests__/ImageGallery.i18n.test.tsx`: the translated
+ strings, the plural forms and the key parity between the languages.
+- `src/lib/__tests__/galleryValidation.test.ts`: the validation rules themselves.
diff --git a/src/components/property/ImageGallery.tsx b/src/components/property/ImageGallery.tsx
index 54cbcf47..1564dc3c 100644
--- a/src/components/property/ImageGallery.tsx
+++ b/src/components/property/ImageGallery.tsx
@@ -1,9 +1,15 @@
'use client';
-import React, { useState } from 'react';
+import React, { useMemo, useState } from 'react';
import Image from 'next/image';
+import { useTranslation } from 'react-i18next';
import { ImageLightbox } from './ImageLightbox';
import { ErrorBoundary } from '@/components/ui/SimpleErrorBoundary';
+import {
+ GALLERY_MAX_IMAGES,
+ normalizeGalleryPropertyName,
+ validateGalleryImages,
+} from '@/lib/galleryValidation';
interface ImageGalleryProps {
images: string[];
@@ -26,12 +32,25 @@ const ImageGalleryContent: React.FC = ({
images,
propertyName
}) => {
+ const { t } = useTranslation('common');
const [lightboxIndex, setLightboxIndex] = useState(null);
// Issue #1253 — the gallery had no loading state, so an image that was still
// being fetched left an empty grey box. The skeleton is drawn over the
// image until the browser reports it has loaded.
const [mainImageLoaded, setMainImageLoaded] = useState(false);
+ // Issue #1265 — the image list comes from the API and cannot be trusted, so
+ // it is validated before anything is rendered. Entries that are broken,
+ // empty or duplicated are skipped and reported to the user instead of
+ // turning into broken images.
+ const validation = useMemo(() => validateGalleryImages(images), [images]);
+ const galleryImages = validation.images;
+
+ // Issue #1264 — the property name is the alternative text of every image,
+ // so a missing name falls back to a generic, translated label.
+ const accessibleName =
+ normalizeGalleryPropertyName(propertyName) || t('gallery.defaultPropertyName');
+
const openLightbox = (index: number) => {
setLightboxIndex(index);
};
@@ -40,14 +59,49 @@ const ImageGalleryContent: React.FC = ({
setLightboxIndex(null);
};
- if (!images || images.length === 0) {
+ const renderValidationNotice = () => {
+ const { issues, droppedCount } = validation;
+ if (issues.length === 0 && droppedCount === 0) return null;
+
return (
-
@@ -115,14 +173,14 @@ const ImageGalleryContent: React.FC = ({
})}
{/* Show more indicator if there are more than 5 images */}
- {images.length > 5 && (
+ {galleryImages.length > 5 && (
- more
+ {t('gallery.moreImages', { count: galleryImages.length - 5 })}
@@ -134,7 +192,7 @@ const ImageGalleryContent: React.FC = ({
{/* Lightbox */}
void;
}
+/**
+ * Keeps an index inside the image list.
+ *
+ * @param index - The index to clamp.
+ * @param length - The number of images in the list.
+ * @returns A finite index between 0 and `length - 1`, or 0 for an empty list.
+ */
+function clampIndex(index: number, length: number): number {
+ if (length <= 0) return 0;
+ if (!Number.isFinite(index)) return 0;
+ return Math.min(Math.max(Math.trunc(index), 0), length - 1);
+}
+
export const ImageLightbox: React.FC = ({
images,
initialIndex = 0,
isOpen,
onClose,
}) => {
+ const { t } = useTranslation('common');
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [touchStart, setTouchStart] = useState(null);
const [touchEnd, setTouchEnd] = useState(null);
const closeButtonRef = useRef(null);
const previouslyFocused = useRef(null);
+ // Issue #1265 — the lightbox is opened with an index that comes from the
+ // gallery, and the list can be shorter than that index (or be empty) when
+ // the gallery is re-validated. The index is therefore clamped to the list
+ // instead of being trusted, so the lightbox can never point at an image
+ // that does not exist.
+ const safeIndex = clampIndex(initialIndex, images.length);
+
+ // The rendered index is clamped as well, so a stale or out of range state
+ // can never make the lightbox render an image that is not in the list.
+ const activeIndex = clampIndex(currentIndex, images.length);
+
// Reset index when lightbox opens with new initialIndex
useEffect(() => {
if (isOpen) {
- setCurrentIndex(initialIndex);
+ setCurrentIndex(safeIndex);
}
- }, [initialIndex, isOpen]);
+ }, [safeIndex, isOpen]);
// Issue #1252 — the overlay was a plain div, so a screen reader had no way
// of knowing a dialog had opened, and focus stayed wherever it was behind
@@ -98,10 +124,12 @@ export const ImageLightbox: React.FC = ({
}, [touchStart, touchEnd]);
const navigateNext = () => {
+ if (images.length === 0) return;
setCurrentIndex((prev) => (prev + 1) % images.length);
};
const navigatePrevious = () => {
+ if (images.length === 0) return;
setCurrentIndex((prev) => (prev - 1 + images.length) % images.length);
};
@@ -117,7 +145,7 @@ export const ImageLightbox: React.FC = ({
@@ -186,16 +214,16 @@ export const ImageLightbox: React.FC = ({
onClick={() => handleThumbnailClick(index)}
// Issue #1252 — `aria-current` marks the thumbnail that is
// currently shown.
- aria-current={index === currentIndex ? "true" : undefined}
+ aria-current={index === activeIndex ? "true" : undefined}
className={`flex-shrink-0 relative rounded-lg overflow-hidden border-2 transition-all ${
- index === currentIndex
+ index === activeIndex
? 'border-blue-500 scale-110 '
: 'border-transparent hover:border-white/50'
}`}
>
& {
+ priority?: boolean;
+ fill?: boolean;
+ sizes?: string;
+};
+
+jest.mock('next/image', () => ({
+ __esModule: true,
+ default: ({
+ priority: _priority,
+ fill: _fill,
+ sizes: _sizes,
+ ...imgProps
+ }: NextImageMockProps) => ,
+}));
+
+const images = [
+ 'https://images.unsplash.com/photo-1',
+ 'https://images.unsplash.com/photo-2',
+];
+
+afterEach(async () => {
+ await act(async () => {
+ await i18n.changeLanguage('en');
+ });
+});
+
+describe('ImageGallery translations', () => {
+ it('renders the translated empty state', () => {
+ render();
+
+ expect(screen.getByText('No images available')).toBeInTheDocument();
+ expect(
+ screen.getByText('This property has no photos yet.')
+ ).toBeInTheDocument();
+ });
+
+ it('renders the translated alternative text', () => {
+ render();
+
+ expect(screen.getByAltText('Sunset Villa')).toBeInTheDocument();
+ expect(screen.getByAltText('Sunset Villa - Image 2')).toBeInTheDocument();
+ });
+
+ it('renders the translated lightbox', () => {
+ render();
+
+ fireEvent.click(screen.getByAltText('Sunset Villa'));
+
+ expect(screen.getByLabelText('Close lightbox')).toBeInTheDocument();
+ expect(screen.getByLabelText('Next image')).toBeInTheDocument();
+ expect(screen.getByLabelText('Previous image')).toBeInTheDocument();
+ expect(screen.getByAltText('Property image 1')).toBeInTheDocument();
+ expect(screen.getByAltText('Thumbnail 1')).toBeInTheDocument();
+ });
+
+ it('uses the singular form for a single image', () => {
+ render();
+
+ expect(
+ screen.getByText('Click to view all 1 image')
+ ).toBeInTheDocument();
+ });
+
+ it('uses the plural form for several images', () => {
+ render();
+
+ expect(
+ screen.getByText('Click to view all 2 images')
+ ).toBeInTheDocument();
+ });
+
+ it('falls back to a generic name when the property has no name', () => {
+ render();
+
+ expect(screen.getByAltText('Property')).toBeInTheDocument();
+ expect(screen.getByAltText('Property - Image 2')).toBeInTheDocument();
+ });
+
+ it('renders the gallery in the selected language', async () => {
+ render();
+
+ await act(async () => {
+ await i18n.changeLanguage('es');
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('No hay imágenes disponibles')).toBeInTheDocument();
+ });
+ });
+});
+
+describe('gallery translations', () => {
+ const galleryKeys = (translations: { gallery: Record }) =>
+ Object.keys(translations.gallery).sort();
+
+ it('ships the same gallery keys in every language', () => {
+ const expected = galleryKeys(enTranslations);
+
+ expect(galleryKeys(esTranslations)).toEqual(expected);
+ expect(galleryKeys(frTranslations)).toEqual(expected);
+ expect(galleryKeys(deTranslations)).toEqual(expected);
+ expect(galleryKeys(zhTranslations)).toEqual(expected);
+ expect(galleryKeys(arTranslations)).toEqual(expected);
+ expect(galleryKeys(heTranslations)).toEqual(expected);
+ });
+
+ it('keeps the gallery section in the resources the test helper loads', () => {
+ Object.entries(testResources).forEach(([locale, namespaces]) => {
+ expect(
+ (namespaces.common as { gallery?: unknown }).gallery
+ ).toBeDefined();
+ });
+ });
+});
diff --git a/src/components/property/__tests__/ImageGallery.test.tsx b/src/components/property/__tests__/ImageGallery.test.tsx
index 37ba2dc6..1f378edd 100644
--- a/src/components/property/__tests__/ImageGallery.test.tsx
+++ b/src/components/property/__tests__/ImageGallery.test.tsx
@@ -2,6 +2,9 @@ import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ImageGallery } from '../ImageGallery';
import { ImageLightbox } from '../ImageLightbox';
+// Boots i18next with the real translations so the gallery renders its
+// translated strings instead of the raw translation keys.
+import '@/test/i18n';
// Mock next/image to render a plain img so tests don't hit loader/hostname validation.
// Strip next/image-only props (priority, sizes, fill) that are not valid img attributes.
diff --git a/src/components/property/__tests__/ImageGallery.validation.test.tsx b/src/components/property/__tests__/ImageGallery.validation.test.tsx
new file mode 100644
index 00000000..d01a9e7e
--- /dev/null
+++ b/src/components/property/__tests__/ImageGallery.validation.test.tsx
@@ -0,0 +1,111 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ImageGallery } from '../ImageGallery';
+import { GALLERY_MAX_IMAGES } from '@/lib/galleryValidation';
+import '@/test/i18n';
+
+type NextImageMockProps = React.ImgHTMLAttributes & {
+ priority?: boolean;
+ fill?: boolean;
+ sizes?: string;
+};
+
+jest.mock('next/image', () => ({
+ __esModule: true,
+ default: ({
+ priority: _priority,
+ fill: _fill,
+ sizes: _sizes,
+ ...imgProps
+ }: NextImageMockProps) => ,
+}));
+
+const validImage = 'https://images.unsplash.com/photo-1';
+const secondImage = 'https://images.unsplash.com/photo-2';
+
+describe('ImageGallery validation', () => {
+ it('renders only the images that pass validation', () => {
+ render(
+
+ );
+
+ expect(screen.getByAltText('Sunset Villa')).toHaveAttribute('src', validImage);
+ expect(screen.getByAltText('Sunset Villa - Image 2')).toHaveAttribute('src', secondImage);
+ });
+
+ it('reports the entries that were skipped and why', () => {
+ render(
+
+ );
+
+ const notice = screen.getByRole('status');
+ expect(notice).toHaveTextContent('Some images could not be displayed');
+ expect(
+ notice.querySelector('li')?.parentElement?.children
+ ).toHaveLength(2);
+ expect(screen.getByText(/not a valid http\(s\) address or site path/i)).toBeInTheDocument();
+ expect(screen.getByText(/had an empty address/i)).toBeInTheDocument();
+ });
+
+ it('reports a duplicated address once', () => {
+ render(
+
+ );
+
+ expect(screen.getAllByText(/listed more than once/i)).toHaveLength(1);
+ expect(screen.getByAltText('Sunset Villa')).toBeInTheDocument();
+ });
+
+ it('shows the empty state when every image was rejected', () => {
+ render();
+
+ expect(screen.getByText('No images available')).toBeInTheDocument();
+ expect(screen.queryByAltText('Sunset Villa')).not.toBeInTheDocument();
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Some images could not be displayed'
+ );
+ });
+
+ it('does not show a notice when every image is valid', () => {
+ render();
+
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ });
+
+ it('reports the images that did not fit into the maximum', () => {
+ const images = Array.from(
+ { length: GALLERY_MAX_IMAGES + 2 },
+ (_unused, index) => `https://images.unsplash.com/photo-${index}`
+ );
+
+ render();
+
+ expect(
+ screen.getByText(
+ `2 images are not shown because a gallery holds at most ${GALLERY_MAX_IMAGES} images.`
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('opens the lightbox with the validated images only', () => {
+ render(
+
+ );
+
+ fireEvent.click(screen.getByAltText('Sunset Villa'));
+
+ expect(screen.getByText('1 / 2')).toBeInTheDocument();
+ });
+});
diff --git a/src/lib/__tests__/galleryValidation.test.ts b/src/lib/__tests__/galleryValidation.test.ts
new file mode 100644
index 00000000..b383a84f
--- /dev/null
+++ b/src/lib/__tests__/galleryValidation.test.ts
@@ -0,0 +1,146 @@
+import {
+ GALLERY_MAX_IMAGES,
+ galleryImageSchema,
+ isSupportedImageSource,
+ normalizeGalleryPropertyName,
+ validateGalleryImages,
+} from "@/lib/galleryValidation"
+
+describe("isSupportedImageSource", () => {
+ it("accepts an absolute http(s) URL", () => {
+ expect(isSupportedImageSource("https://images.unsplash.com/photo-1")).toBe(true)
+ expect(isSupportedImageSource("http://cdn.example.com/a.jpg")).toBe(true)
+ })
+
+ it("accepts a root relative path", () => {
+ expect(isSupportedImageSource("/images/property-1.jpg")).toBe(true)
+ })
+
+ it("rejects a relative path without a leading slash", () => {
+ expect(isSupportedImageSource("images/property-1.jpg")).toBe(false)
+ })
+
+ it("rejects a protocol next/image cannot load", () => {
+ expect(isSupportedImageSource("data:image/png;base64,AAAA")).toBe(false)
+ expect(isSupportedImageSource("ftp://example.com/a.jpg")).toBe(false)
+ })
+
+ it("rejects an address without a host", () => {
+ expect(isSupportedImageSource("https://")).toBe(false)
+ })
+})
+
+describe("galleryImageSchema", () => {
+ it("trims the address", () => {
+ const parsed = galleryImageSchema.parse(" https://images.unsplash.com/photo-1 ")
+ expect(parsed).toBe("https://images.unsplash.com/photo-1")
+ })
+
+ it("rejects a value that is not text", () => {
+ expect(galleryImageSchema.safeParse(42).success).toBe(false)
+ })
+
+ it("rejects an empty address", () => {
+ expect(galleryImageSchema.safeParse(" ").success).toBe(false)
+ })
+})
+
+describe("validateGalleryImages", () => {
+ it("returns an empty result for a list that is not an array", () => {
+ expect(validateGalleryImages(undefined)).toEqual({
+ images: [],
+ issues: [],
+ droppedCount: 0,
+ })
+ })
+
+ it("keeps valid images in their original order", () => {
+ const result = validateGalleryImages([
+ "https://images.unsplash.com/photo-1",
+ "/images/photo-2.jpg",
+ "https://images.unsplash.com/photo-3",
+ ])
+
+ expect(result.issues).toHaveLength(0)
+ expect(result.droppedCount).toBe(0)
+ expect(result.images).toEqual([
+ "https://images.unsplash.com/photo-1",
+ "/images/photo-2.jpg",
+ "https://images.unsplash.com/photo-3",
+ ])
+ })
+
+ it("trims the addresses it keeps", () => {
+ const result = validateGalleryImages([" https://images.unsplash.com/photo-1 "])
+
+ expect(result.images).toEqual(["https://images.unsplash.com/photo-1"])
+ })
+
+ it("reports an entry that is not text", () => {
+ const result = validateGalleryImages(["https://images.unsplash.com/photo-1", 7])
+
+ expect(result.images).toEqual(["https://images.unsplash.com/photo-1"])
+ expect(result.issues).toEqual([
+ { index: 1, value: 7, reason: "notAString" },
+ ])
+ })
+
+ it("reports an empty address", () => {
+ const result = validateGalleryImages([" "])
+
+ expect(result.images).toEqual([])
+ expect(result.issues).toEqual([
+ { index: 0, value: " ", reason: "empty" },
+ ])
+ })
+
+ it("reports an address next/image cannot load", () => {
+ const result = validateGalleryImages(["images/photo-1.jpg"])
+
+ expect(result.images).toEqual([])
+ expect(result.issues).toEqual([
+ { index: 0, value: "images/photo-1.jpg", reason: "invalidSource" },
+ ])
+ })
+
+ it("reports a duplicated address and keeps the first occurrence", () => {
+ const result = validateGalleryImages([
+ "https://images.unsplash.com/photo-1",
+ "https://images.unsplash.com/photo-1",
+ ])
+
+ expect(result.images).toEqual(["https://images.unsplash.com/photo-1"])
+ expect(result.issues).toEqual([
+ {
+ index: 1,
+ value: "https://images.unsplash.com/photo-1",
+ reason: "duplicate",
+ },
+ ])
+ })
+
+ it("drops the images that do not fit into the maximum and counts them", () => {
+ const images = Array.from(
+ { length: GALLERY_MAX_IMAGES + 3 },
+ (_unused, index) => `https://images.unsplash.com/photo-${index}`
+ )
+
+ const result = validateGalleryImages(images)
+
+ expect(result.images).toHaveLength(GALLERY_MAX_IMAGES)
+ expect(result.droppedCount).toBe(3)
+ expect(result.issues).toHaveLength(0)
+ })
+})
+
+describe("normalizeGalleryPropertyName", () => {
+ it("trims the name", () => {
+ expect(normalizeGalleryPropertyName(" Sunset Villa ")).toBe("Sunset Villa")
+ })
+
+ it("reports a missing name as an empty string", () => {
+ expect(normalizeGalleryPropertyName(" ")).toBe("")
+ expect(normalizeGalleryPropertyName(undefined)).toBe("")
+ expect(normalizeGalleryPropertyName(null)).toBe("")
+ })
+})
diff --git a/src/lib/galleryValidation.ts b/src/lib/galleryValidation.ts
new file mode 100644
index 00000000..565ca40b
--- /dev/null
+++ b/src/lib/galleryValidation.ts
@@ -0,0 +1,161 @@
+import { z } from "zod"
+
+/**
+ * Validation of the image list a property gallery is rendered from.
+ *
+ * The gallery is fed by data that reaches the browser from the API, so the
+ * list cannot be trusted to be well formed: it can be empty, hold entries
+ * that are not strings, hold addresses `next/image` cannot load, or repeat the
+ * same address several times. Rendering such a list unvalidated produces
+ * broken images, duplicated entries and an unreadable lightbox.
+ *
+ * The rules implemented here are the ones the renderer can actually honour:
+ *
+ * 1. an entry must be a string;
+ * 2. it must not be empty once trimmed;
+ * 3. it must be a source `next/image` accepts — an absolute `http(s)` URL or
+ * a root relative path (`/images/foo.jpg`). A relative path without the
+ * leading slash and a `data:` URL are rejected by `next/image` itself, so
+ * they are rejected here as well;
+ * 4. the same address must not be listed twice;
+ * 5. at most `GALLERY_MAX_IMAGES` images are rendered, so a gallery with a
+ * pathological number of images cannot lock up the page.
+ *
+ * Every rule is reported per entry, so the caller can tell the user exactly
+ * which images were skipped and why.
+ */
+
+/** Maximum number of images a single gallery renders. */
+export const GALLERY_MAX_IMAGES = 50
+
+/** Why a gallery entry was rejected. */
+export type GalleryImageIssueReason =
+ | "notAString"
+ | "empty"
+ | "invalidSource"
+ | "duplicate"
+
+/** A single gallery entry that was rejected. */
+export interface GalleryImageIssue {
+ /** Zero based position of the entry in the original list. */
+ index: number
+ /** The rejected value, kept as-is so the caller can log or display it. */
+ value: unknown
+ /** The rule the entry broke. */
+ reason: GalleryImageIssueReason
+}
+
+/** Outcome of validating a gallery image list. */
+export interface GalleryValidationResult {
+ /** The entries that can be rendered, in their original order. */
+ images: string[]
+ /** The entries that were rejected, in their original order. */
+ issues: GalleryImageIssue[]
+ /** Number of valid entries dropped because the gallery was too long. */
+ droppedCount: number
+}
+
+const ABSOLUTE_URL_PATTERN = /^https?:\/\//i
+
+/**
+ * Reports whether a gallery address can be rendered by `next/image`.
+ *
+ * @param value - The address to check.
+ * @returns `true` for an absolute `http(s)` URL or a root relative path.
+ */
+export function isSupportedImageSource(value: string): boolean {
+ if (value.startsWith("/")) return true
+ if (!ABSOLUTE_URL_PATTERN.test(value)) return false
+
+ try {
+ return Boolean(new URL(value).hostname)
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Schema for a single gallery image address.
+ *
+ * `next/image` only accepts an absolute `http(s)` URL or a path that starts
+ * with a slash, so those are the only two shapes that are considered valid
+ * here. The schema also trims the address, so the parsed value is the one
+ * that is rendered.
+ */
+export const galleryImageSchema = z
+ .string({ error: "Image address must be text." })
+ .trim()
+ .min(1, "Image address cannot be empty.")
+ .refine((value) => isSupportedImageSource(value), {
+ message: "Image address must be an http(s) URL or a path that starts with '/'.",
+ })
+
+/**
+ * Explains why an address was rejected.
+ *
+ * @param value - The rejected value.
+ * @returns The reason code matching the rule the value broke.
+ */
+function reasonForRejectedValue(value: unknown): GalleryImageIssueReason {
+ if (typeof value !== "string") return "notAString"
+ if (value.trim().length === 0) return "empty"
+ return "invalidSource"
+}
+
+/**
+ * Validates the image list of a gallery.
+ *
+ * @param input - The raw list, usually straight from the API.
+ * @returns The renderable images, the rejected entries with their reason and
+ * the number of valid entries that were dropped because the gallery was
+ * longer than `GALLERY_MAX_IMAGES`.
+ */
+export function validateGalleryImages(input: unknown): GalleryValidationResult {
+ if (!Array.isArray(input)) {
+ return { images: [], issues: [], droppedCount: 0 }
+ }
+
+ const images: string[] = []
+ const issues: GalleryImageIssue[] = []
+ const seen = new Set()
+ let droppedCount = 0
+
+ input.forEach((value, index) => {
+ const parsed = galleryImageSchema.safeParse(value)
+
+ if (!parsed.success) {
+ issues.push({ index, value, reason: reasonForRejectedValue(value) })
+ return
+ }
+
+ if (seen.has(parsed.data)) {
+ issues.push({ index, value, reason: "duplicate" })
+ return
+ }
+
+ seen.add(parsed.data)
+
+ if (images.length < GALLERY_MAX_IMAGES) {
+ images.push(parsed.data)
+ } else {
+ droppedCount += 1
+ }
+ })
+
+ return { images, issues, droppedCount }
+}
+
+/**
+ * Normalises the property name a gallery is labelled with.
+ *
+ * The name ends up in the alternative text of every image, so an empty or
+ * whitespace only name is reported as missing and the caller falls back to a
+ * generic label.
+ *
+ * @param value - The property name to normalise.
+ * @returns The trimmed name, or an empty string when there is no usable name.
+ */
+export function normalizeGalleryPropertyName(value: unknown): string {
+ if (typeof value !== "string") return ""
+ return value.trim()
+}
diff --git a/src/locales/ar/common.json b/src/locales/ar/common.json
index 1ada85f7..2112771e 100644
--- a/src/locales/ar/common.json
+++ b/src/locales/ar/common.json
@@ -293,6 +293,33 @@
"securityWarning": "شارك فقط روابط من نطاقات PropChain الموثوقة",
"ariaLabel": "رمز QR لرابط العقار"
},
+ "gallery": {
+ "label": "صور العقار",
+ "noImages": "لا توجد صور متاحة",
+ "noImagesDescription": "لا توجد صور لهذا العقار بعد.",
+ "viewAll": "انقر لعرض جميع الصور البالغة {{count}}",
+ "viewAll_one": "انقر لعرض صورة واحدة",
+ "moreImages": "+{{count}} إضافية",
+ "imageAlt": "{{property}} - صورة {{index}}",
+ "mainImageAlt": "{{property}}",
+ "defaultPropertyName": "العقار",
+ "lightboxLabel": "صور العقار",
+ "lightboxImageAlt": "صورة العقار {{index}}",
+ "thumbnailAlt": "صورة مصغّرة {{index}}",
+ "closeLightbox": "إغلاق معرض الصور",
+ "previousImage": "الصورة السابقة",
+ "nextImage": "الصورة التالية",
+ "counter": "{{current}} / {{total}}",
+ "invalidImagesTitle": "تعذّر عرض بعض الصور",
+ "imagesDropped": "لا يتم عرض {{count}} صورة لأن المعرض يحتوي على {{max}} صورة كحد أقصى.",
+ "issue": {
+ "notAString": "إحدى مدخلات الصور ليست عنوانًا نصيًا.",
+ "empty": "إحدى مدخلات الصور بعنوان فارغ.",
+ "invalidSource": "إحدى مدخلات الصور ليست عنوان http(s) أو مسارًا صالحًا داخل الموقع.",
+ "duplicate": "تكرّر عنوان الصورة نفسه أكثر من مرة."
+ },
+ "imagesDropped_one": "لا يتم عرض {{count}} صورة لأن المعرض يحتوي على {{max}} صورة كحد أقصى."
+ },
"networkSwitcher": {
"selectNetwork": "اختر الشبكة",
"switchingNetwork": "جارٍ تبديل الشبكة..."
diff --git a/src/locales/de/common.json b/src/locales/de/common.json
index 54f55d8c..94d326ed 100644
--- a/src/locales/de/common.json
+++ b/src/locales/de/common.json
@@ -293,6 +293,33 @@
"securityWarning": "Teilen Sie nur URLs von vertrauenswürdigen PropChain-Domains",
"ariaLabel": "QR-Code für Immobilienlink"
},
+ "gallery": {
+ "label": "Immobilienbilder",
+ "noImages": "Keine Bilder verfügbar",
+ "noImagesDescription": "Für diese Immobilie gibt es noch keine Fotos.",
+ "viewAll": "Klicken, um alle {{count}} Bilder anzuzeigen",
+ "viewAll_one": "Klicken, um {{count}} Bild anzuzeigen",
+ "moreImages": "+{{count}} weitere",
+ "imageAlt": "{{property}} - Bild {{index}}",
+ "mainImageAlt": "{{property}}",
+ "defaultPropertyName": "Immobilie",
+ "lightboxLabel": "Immobilienbilder",
+ "lightboxImageAlt": "Immobilienbild {{index}}",
+ "thumbnailAlt": "Vorschaubild {{index}}",
+ "closeLightbox": "Galerie schließen",
+ "previousImage": "Vorheriges Bild",
+ "nextImage": "Nächstes Bild",
+ "counter": "{{current}} / {{total}}",
+ "invalidImagesTitle": "Einige Bilder konnten nicht angezeigt werden",
+ "imagesDropped": "{{count}} Bilder werden nicht angezeigt, da eine Galerie höchstens {{max}} Bilder enthält.",
+ "issue": {
+ "notAString": "Ein Bild-Eintrag war keine Textadresse.",
+ "empty": "Ein Bild-Eintrag hatte eine leere Adresse.",
+ "invalidSource": "Ein Bild-Eintrag war weder eine gültige http(s)-Adresse noch ein gültiger Website-Pfad.",
+ "duplicate": "Dieselbe Bildadresse war mehrfach aufgeführt."
+ },
+ "imagesDropped_one": "{{count}} Bild wird nicht angezeigt, da eine Galerie höchstens {{max}} Bilder enthält."
+ },
"networkSwitcher": {
"selectNetwork": "Netzwerk auswählen",
"switchingNetwork": "Netzwerk wird gewechselt..."
diff --git a/src/locales/en/common.json b/src/locales/en/common.json
index 6da45123..9347fa6b 100644
--- a/src/locales/en/common.json
+++ b/src/locales/en/common.json
@@ -326,6 +326,33 @@
"securityWarning": "Only share URLs from trusted PropChain domains",
"ariaLabel": "QR code for property link"
},
+ "gallery": {
+ "label": "Property images",
+ "noImages": "No images available",
+ "noImagesDescription": "This property has no photos yet.",
+ "viewAll": "Click to view all {{count}} images",
+ "viewAll_one": "Click to view all {{count}} image",
+ "moreImages": "+{{count}} more",
+ "imageAlt": "{{property}} - Image {{index}}",
+ "mainImageAlt": "{{property}}",
+ "defaultPropertyName": "Property",
+ "lightboxLabel": "Property images",
+ "lightboxImageAlt": "Property image {{index}}",
+ "thumbnailAlt": "Thumbnail {{index}}",
+ "closeLightbox": "Close lightbox",
+ "previousImage": "Previous image",
+ "nextImage": "Next image",
+ "counter": "{{current}} / {{total}}",
+ "invalidImagesTitle": "Some images could not be displayed",
+ "imagesDropped": "{{count}} images are not shown because a gallery holds at most {{max}} images.",
+ "issue": {
+ "notAString": "An image entry was not a text address.",
+ "empty": "An image entry had an empty address.",
+ "invalidSource": "An image entry was not a valid http(s) address or site path.",
+ "duplicate": "The same image address was listed more than once."
+ },
+ "imagesDropped_one": "{{count}} image is not shown because a gallery holds at most {{max}} images."
+ },
"networkSwitcher": {
"selectNetwork": "Select Network",
"switchingNetwork": "Switching network..."
@@ -340,4 +367,4 @@
"leaderboardBlurb": "Check how you rank among other referrers in the PropChain community.",
"viewLeaderboard": "View Leaderboard"
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/es/common.json b/src/locales/es/common.json
index 4dc16f55..35c1276b 100644
--- a/src/locales/es/common.json
+++ b/src/locales/es/common.json
@@ -293,6 +293,33 @@
"securityWarning": "Comparte solo URLs de dominios PropChain de confianza",
"ariaLabel": "Código QR para enlace de propiedad"
},
+ "gallery": {
+ "label": "Imágenes de la propiedad",
+ "noImages": "No hay imágenes disponibles",
+ "noImagesDescription": "Esta propiedad todavía no tiene fotos.",
+ "viewAll": "Haz clic para ver las {{count}} imágenes",
+ "viewAll_one": "Haz clic para ver {{count}} imagen",
+ "moreImages": "+{{count}} más",
+ "imageAlt": "{{property}} - Imagen {{index}}",
+ "mainImageAlt": "{{property}}",
+ "defaultPropertyName": "Propiedad",
+ "lightboxLabel": "Imágenes de la propiedad",
+ "lightboxImageAlt": "Imagen de la propiedad {{index}}",
+ "thumbnailAlt": "Miniatura {{index}}",
+ "closeLightbox": "Cerrar la galería",
+ "previousImage": "Imagen anterior",
+ "nextImage": "Imagen siguiente",
+ "counter": "{{current}} / {{total}}",
+ "invalidImagesTitle": "Algunas imágenes no se pudieron mostrar",
+ "imagesDropped": "{{count}} imágenes no se muestran porque una galería contiene como máximo {{max}} imágenes.",
+ "issue": {
+ "notAString": "Una entrada de imagen no era una dirección de texto.",
+ "empty": "Una entrada de imagen tenía una dirección vacía.",
+ "invalidSource": "Una entrada de imagen no era una dirección http(s) ni una ruta del sitio válida.",
+ "duplicate": "La misma dirección de imagen aparecía más de una vez."
+ },
+ "imagesDropped_one": "{{count}} imagen no se muestra porque una galería contiene como máximo {{max}} imágenes."
+ },
"networkSwitcher": {
"selectNetwork": "Seleccionar red",
"switchingNetwork": "Cambiando de red..."
diff --git a/src/locales/fr/common.json b/src/locales/fr/common.json
index ea43c1af..91d4b125 100644
--- a/src/locales/fr/common.json
+++ b/src/locales/fr/common.json
@@ -293,6 +293,33 @@
"securityWarning": "Ne partagez que des URL de domaines PropChain de confiance",
"ariaLabel": "Code QR pour le lien de la propriété"
},
+ "gallery": {
+ "label": "Images de la propriété",
+ "noImages": "Aucune image disponible",
+ "noImagesDescription": "Cette propriété n'a pas encore de photos.",
+ "viewAll": "Cliquez pour voir les {{count}} images",
+ "viewAll_one": "Cliquez pour voir {{count}} image",
+ "moreImages": "+{{count}} de plus",
+ "imageAlt": "{{property}} - Image {{index}}",
+ "mainImageAlt": "{{property}}",
+ "defaultPropertyName": "Propriété",
+ "lightboxLabel": "Images de la propriété",
+ "lightboxImageAlt": "Image de la propriété {{index}}",
+ "thumbnailAlt": "Miniature {{index}}",
+ "closeLightbox": "Fermer la galerie",
+ "previousImage": "Image précédente",
+ "nextImage": "Image suivante",
+ "counter": "{{current}} / {{total}}",
+ "invalidImagesTitle": "Certaines images n'ont pas pu être affichées",
+ "imagesDropped": "{{count}} images ne sont pas affichées car une galerie contient au maximum {{max}} images.",
+ "issue": {
+ "notAString": "Une entrée d'image n'était pas une adresse textuelle.",
+ "empty": "Une entrée d'image avait une adresse vide.",
+ "invalidSource": "Une entrée d'image n'était pas une adresse http(s) ni un chemin de site valide.",
+ "duplicate": "La même adresse d'image apparaissait plusieurs fois."
+ },
+ "imagesDropped_one": "{{count}} image n'est pas affichée car une galerie contient au maximum {{max}} images."
+ },
"networkSwitcher": {
"selectNetwork": "Sélectionner le réseau",
"switchingNetwork": "Changement de réseau..."
diff --git a/src/locales/he/common.json b/src/locales/he/common.json
index d8012b5b..5af40639 100644
--- a/src/locales/he/common.json
+++ b/src/locales/he/common.json
@@ -293,6 +293,33 @@
"securityWarning": "שתף רק כתובות URL מדומיינים מהימנים של PropChain",
"ariaLabel": "QR Code לקישור נכס"
},
+ "gallery": {
+ "label": "תמונות הנכס",
+ "noImages": "אין תמונות זמינות",
+ "noImagesDescription": "לנכס הזה עדיין אין תמונות.",
+ "viewAll": "לחץ כדי להציג את כל {{count}} התמונות",
+ "viewAll_one": "לחץ כדי להציג תמונה אחת",
+ "moreImages": "+{{count}} נוספות",
+ "imageAlt": "{{property}} - תמונה {{index}}",
+ "mainImageAlt": "{{property}}",
+ "defaultPropertyName": "נכס",
+ "lightboxLabel": "תמונות הנכס",
+ "lightboxImageAlt": "תמונת נכס {{index}}",
+ "thumbnailAlt": "תמונה ממוזערת {{index}}",
+ "closeLightbox": "סגירת מציג התמונות",
+ "previousImage": "התמונה הקודמת",
+ "nextImage": "התמונה הבאה",
+ "counter": "{{current}} / {{total}}",
+ "invalidImagesTitle": "חלק מהתמונות לא ניתנו להצגה",
+ "imagesDropped": "{{count}} תמונות אינן מוצגות מכיוון שגלריה מכילה {{max}} תמונות לכל היותר.",
+ "issue": {
+ "notAString": "רשומת תמונה אחת אינה כתובת טקסט.",
+ "empty": "רשומת תמונה אחת הייתה עם כתובת ריקה.",
+ "invalidSource": "רשומת תמונה אחת אינה כתובת http(s) תקינה או נתיב תקין באתר.",
+ "duplicate": "אותה כתובת תמונה הופיעה יותר מפעם אחת."
+ },
+ "imagesDropped_one": "תמונה אחת אינה מוצגת מכיוון שגלריה מכילה {{max}} תמונות לכל היותר."
+ },
"networkSwitcher": {
"selectNetwork": "בחר רשת",
"switchingNetwork": "מחליף רשת..."
diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json
index 405d270c..9be4d4ba 100644
--- a/src/locales/zh/common.json
+++ b/src/locales/zh/common.json
@@ -293,6 +293,33 @@
"securityWarning": "仅分享来自受信任 PropChain 域名的 URL",
"ariaLabel": "房产链接二维码"
},
+ "gallery": {
+ "label": "房产图片",
+ "noImages": "暂无图片",
+ "noImagesDescription": "该房产还没有照片。",
+ "viewAll": "点击查看全部 {{count}} 张图片",
+ "viewAll_one": "点击查看 {{count}} 张图片",
+ "moreImages": "还有 {{count}} 张",
+ "imageAlt": "{{property}} - 图片 {{index}}",
+ "mainImageAlt": "{{property}}",
+ "defaultPropertyName": "房产",
+ "lightboxLabel": "房产图片",
+ "lightboxImageAlt": "房产图片 {{index}}",
+ "thumbnailAlt": "缩略图 {{index}}",
+ "closeLightbox": "关闭图片查看器",
+ "previousImage": "上一张图片",
+ "nextImage": "下一张图片",
+ "counter": "{{current}} / {{total}}",
+ "invalidImagesTitle": "部分图片无法显示",
+ "imagesDropped": "最多只能显示 {{max}} 张图片,因此有 {{count}} 张图片未显示。",
+ "issue": {
+ "notAString": "某个图片条目不是文本地址。",
+ "empty": "某个图片条目的地址为空。",
+ "invalidSource": "某个图片条目不是有效的 http(s) 地址或站内路径。",
+ "duplicate": "同一个图片地址出现了多次。"
+ },
+ "imagesDropped_one": "最多只能显示 {{max}} 张图片,因此有 {{count}} 张图片未显示。"
+ },
"networkSwitcher": {
"selectNetwork": "选择网络",
"switchingNetwork": "正在切换网络..."
diff --git a/src/test/i18n.ts b/src/test/i18n.ts
new file mode 100644
index 00000000..59499a95
--- /dev/null
+++ b/src/test/i18n.ts
@@ -0,0 +1,50 @@
+/**
+ * Test helper that boots i18next with the real translation resources.
+ *
+ * The application initialises i18next in `src/lib/i18n.ts`, which turns the
+ * debug log on in development and installs the browser language detector.
+ * Both are unhelpful in a test run: the detector picks a language from the
+ * environment instead of the one the test asks for, and the debug log buries
+ * the test output.
+ *
+ * Importing this module gives a test the same translations the application
+ * renders, with the language under the control of the test.
+ */
+import i18n from "i18next"
+import { initReactI18next } from "react-i18next"
+
+import arTranslations from "@/locales/ar/common.json"
+import deTranslations from "@/locales/de/common.json"
+import enTranslations from "@/locales/en/common.json"
+import esTranslations from "@/locales/es/common.json"
+import frTranslations from "@/locales/fr/common.json"
+import heTranslations from "@/locales/he/common.json"
+import zhTranslations from "@/locales/zh/common.json"
+
+export const testResources = {
+ en: { common: enTranslations },
+ es: { common: esTranslations },
+ fr: { common: frTranslations },
+ de: { common: deTranslations },
+ zh: { common: zhTranslations },
+ ar: { common: arTranslations },
+ he: { common: heTranslations },
+}
+
+if (!i18n.isInitialized) {
+ i18n.use(initReactI18next).init({
+ resources: testResources,
+ lng: "en",
+ fallbackLng: "en",
+ defaultNS: "common",
+ ns: ["common"],
+ interpolation: {
+ escapeValue: false,
+ },
+ react: {
+ useSuspense: false,
+ },
+ })
+}
+
+export default i18n