From 528a3a863fe9061d7ac3aa50603d1fbd99ab06ed Mon Sep 17 00:00:00 2001 From: rozemary2026-a11y Date: Tue, 20 Oct 2026 13:56:31 +0000 Subject: [PATCH] feat(gallery): translate the gallery and validate its image list The image gallery rendered hard-coded English strings and trusted the image list it received from the API, so a broken address became a broken image and a duplicated address was rendered twice. - move every string of the gallery and of the lightbox into the `gallery` section of the `common` namespace, in all seven shipped languages, and use the i18next plural form for the counted strings - validate the image list before rendering it: an entry that is not text, is empty, is not a source `next/image` can load, or repeats an address is skipped and reported to the user in a status notice - clamp the lightbox index to the image list, so it can never point at an image that does not exist - fall back to a generic label when the property has no name, so the images keep an alternative text - cover the validation rules, the translations and the plural forms with tests, and document the behaviour in docs/gallery.md Closes #1264 Closes #1265 --- README.md | 1 + docs/gallery.md | 86 ++++++++++ src/components/property/ImageGallery.tsx | 96 ++++++++--- src/components/property/ImageLightbox.tsx | 52 ++++-- .../__tests__/ImageGallery.i18n.test.tsx | 127 ++++++++++++++ .../property/__tests__/ImageGallery.test.tsx | 3 + .../ImageGallery.validation.test.tsx | 111 ++++++++++++ src/lib/__tests__/galleryValidation.test.ts | 146 ++++++++++++++++ src/lib/galleryValidation.ts | 161 ++++++++++++++++++ src/locales/ar/common.json | 27 +++ src/locales/de/common.json | 27 +++ src/locales/en/common.json | 29 +++- src/locales/es/common.json | 27 +++ src/locales/fr/common.json | 27 +++ src/locales/he/common.json | 27 +++ src/locales/zh/common.json | 27 +++ src/test/i18n.ts | 50 ++++++ 17 files changed, 992 insertions(+), 32 deletions(-) create mode 100644 docs/gallery.md create mode 100644 src/components/property/__tests__/ImageGallery.i18n.test.tsx create mode 100644 src/components/property/__tests__/ImageGallery.validation.test.tsx create mode 100644 src/lib/__tests__/galleryValidation.test.ts create mode 100644 src/lib/galleryValidation.ts create mode 100644 src/test/i18n.ts 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 ( -
-
- - - -

No images available

+
+

{t('gallery.invalidImagesTitle')}

+ {issues.length > 0 && ( +
    + {issues.map((issue) => ( +
  • + {t(`gallery.issue.${issue.reason}`)} +
  • + ))} +
+ )} + {droppedCount > 0 && ( +

+ {t('gallery.imagesDropped', { count: droppedCount, max: GALLERY_MAX_IMAGES })} +

+ )} +
+ ); + }; + + if (galleryImages.length === 0) { + return ( +
+ {renderValidationNotice()} +
+
+ + + +

{t('gallery.noImages')}

+

+ {t('gallery.noImagesDescription')} +

+
); @@ -56,11 +110,13 @@ const ImageGalleryContent: React.FC = ({ return ( <>
+ {renderValidationNotice()} + {/* Main Image */}
{propertyName} = ({ {/* Overlay with image count */}
-

Click to view all {images.length} images

+

+ {t('gallery.viewAll', { count: galleryImages.length })} +

{/* Thumbnail Gallery */} - {images.length > 1 && ( + {galleryImages.length > 1 && (
- {images.slice(1, 5).map((image, index) => { + {galleryImages.slice(1, 5).map((image, index) => { const actualIndex = index + 1; return (
= ({ > {`${propertyName} = ({ {/* Hover overlay */}
- +
@@ -115,14 +173,14 @@ const ImageGalleryContent: React.FC = ({ })} {/* Show more indicator if there are more than 5 images */} - {images.length > 5 && ( + {galleryImages.length > 5 && (
- +{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 = ({
{/* Close Button */} @@ -125,7 +153,7 @@ export const ImageLightbox: React.FC = ({ ref={closeButtonRef} onClick={onClose} className="absolute right-2 top-2 z-10 rounded-full bg-white/10 p-1.5 transition-colors hover:bg-white/20 sm:right-4 sm:top-4 sm:p-2" - aria-label="Close lightbox" + aria-label={t('gallery.closeLightbox')} > @@ -138,8 +166,8 @@ export const ImageLightbox: React.FC = ({ onTouchEnd={handleTouchEnd} > {`Property = ({ @@ -173,7 +201,7 @@ export const ImageLightbox: React.FC = ({ aria-live="polite" className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1 text-sm text-white" > - {currentIndex + 1} / {images.length} + {t('gallery.counter', { current: activeIndex + 1, total: images.length })}
@@ -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' }`} > {`Thumbnail & { + 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