-
-
Click to view all {images.length} images
-
+ const handleLightboxNavigate = useCallback(
+ (nextIndex: number, direction: 'next' | 'previous' | 'jump') => {
+ trackGalleryEvent('gallery_lightbox_navigate', {
+ propertyName,
+ imageIndex: nextIndex,
+ imageCount,
+ direction,
+ });
+ },
+ [propertyName, imageCount],
+ );
+
+ // #1258 — grid keyboard navigation
+ const handleGridKeyDown = useCallback(
+ (e: React.KeyboardEvent, index: number) => {
+ if (!images || images.length === 0) return;
+ let next = index;
+ switch (e.key) {
+ case 'ArrowRight':
+ case 'ArrowDown':
+ e.preventDefault();
+ next = Math.min(images.length - 1, index + 1);
+ break;
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ e.preventDefault();
+ next = Math.max(0, index - 1);
+ break;
+ case 'Home':
+ e.preventDefault();
+ next = 0;
+ break;
+ case 'End':
+ e.preventDefault();
+ next = images.length - 1;
+ break;
+ case 'Enter':
+ case ' ':
+ e.preventDefault();
+ openLightbox(index, 'keyboard');
+ trackGalleryEvent('gallery_keyboard_navigate', {
+ propertyName,
+ imageIndex: index,
+ imageCount,
+ method: 'keyboard',
+ });
+ return;
+ default:
+ return;
+ }
+ setFocusIndex(next);
+ // Focus thumbnail buttons when index maps into the strip (main = 0)
+ if (next === 0) {
+ // main image is a button too
+ thumbRefs.current[0]?.focus();
+ } else {
+ thumbRefs.current[next]?.focus();
+ }
+ trackGalleryEvent('gallery_keyboard_navigate', {
+ propertyName,
+ imageIndex: next,
+ imageCount,
+ method: 'keyboard',
+ });
+ },
+ [images, openLightbox, propertyName, imageCount],
+ );
+
+ if (!images || images.length === 0) {
+ return (
+
+
+
+
No images available
+ );
+ }
- {/* Thumbnail Gallery */}
- {images.length > 1 && (
-
- {images.slice(1, 5).map((image, index) => {
- const actualIndex = index + 1;
- return (
-
openLightbox(actualIndex)}
+ return (
+ <>
+
+ {/* Main Image */}
+
+
+ {/* Thumbnail strip */}
+ {thumbSources.length > 0 && (
+
+ {thumbSources.map((image, index) => {
+ const actualIndex = index + 1;
+ return (
+
+ );
+ })}
+
+ {images.length > 5 && (
+
- )}
-
+
+ )}
+
+ )}
+
- {/* Lightbox */}
-
- >
- );
-};
+
+ >
+ );
+ },
+);
+ImageGalleryContent.displayName = 'ImageGalleryContent';
diff --git a/src/components/property/ImageLightbox.tsx b/src/components/property/ImageLightbox.tsx
index 9aabe59e..9489760b 100644
--- a/src/components/property/ImageLightbox.tsx
+++ b/src/components/property/ImageLightbox.tsx
@@ -9,6 +9,8 @@ interface ImageLightboxProps {
initialIndex?: number;
isOpen: boolean;
onClose: () => void;
+ /** Optional analytics hook when the visible image changes (#1259). */
+ onNavigate?: (index: number, direction: 'next' | 'previous' | 'jump') => void;
}
export const ImageLightbox: React.FC
= ({
@@ -16,6 +18,7 @@ export const ImageLightbox: React.FC = ({
initialIndex = 0,
isOpen,
onClose,
+ onNavigate,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [touchStart, setTouchStart] = useState(null);
@@ -45,6 +48,31 @@ export const ImageLightbox: React.FC = ({
};
}, [isOpen]);
+ const navigateNext = useCallback(() => {
+ setCurrentIndex((prev) => {
+ const next = (prev + 1) % images.length;
+ onNavigate?.(next, 'next');
+ return next;
+ });
+ }, [images.length, onNavigate]);
+
+ const navigatePrevious = useCallback(() => {
+ setCurrentIndex((prev) => {
+ const next = (prev - 1 + images.length) % images.length;
+ onNavigate?.(next, 'previous');
+ return next;
+ });
+ }, [images.length, onNavigate]);
+
+ const handleThumbnailClick = useCallback(
+ (index: number) => {
+ setCurrentIndex(index);
+ onNavigate?.(index, 'jump');
+ },
+ [onNavigate],
+ );
+
+ if (!isOpen || images.length === 0) return null;
// Keyboard navigation
useEffect(() => {
if (!isOpen) return;
@@ -70,7 +98,7 @@ export const ImageLightbox: React.FC = ({
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'unset';
};
- }, [isOpen, currentIndex]);
+ }, [isOpen, navigateNext, navigatePrevious, onClose]);
// Touch handlers for mobile swipe
const handleTouchStart = useCallback((e: React.TouchEvent) => {
@@ -95,21 +123,9 @@ export const ImageLightbox: React.FC = ({
if (isRightSwipe) {
navigatePrevious();
}
- }, [touchStart, touchEnd]);
+ }, [touchStart, touchEnd, navigateNext, navigatePrevious]);
- const navigateNext = () => {
- setCurrentIndex((prev) => (prev + 1) % images.length);
- };
- const navigatePrevious = () => {
- setCurrentIndex((prev) => (prev - 1 + images.length) % images.length);
- };
-
- const handleThumbnailClick = (index: number) => {
- setCurrentIndex(index);
- };
-
- if (!isOpen || images.length === 0) return null;
return (
// Issue #1252 — `role="dialog"` + `aria-modal` tell assistive tech that
diff --git a/src/components/property/__tests__/ImageGallery.integration.test.tsx b/src/components/property/__tests__/ImageGallery.integration.test.tsx
new file mode 100644
index 00000000..63a9ee29
--- /dev/null
+++ b/src/components/property/__tests__/ImageGallery.integration.test.tsx
@@ -0,0 +1,99 @@
+/**
+ * Gallery integration tests (#1256) covering keyboard nav (#1258),
+ * analytics (#1259), and memoization surface (#1257).
+ */
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ImageGallery } from '../ImageGallery';
+import * as analytics from '@/lib/galleryAnalytics';
+
+jest.mock('next/image', () => ({
+ __esModule: true,
+ default: (props: any) => {
+ // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text
+ return
;
+ },
+}));
+
+jest.mock('@/components/ui/SimpleErrorBoundary', () => ({
+ ErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}>,
+}));
+
+const images = [
+ '/img/a.jpg',
+ '/img/b.jpg',
+ '/img/c.jpg',
+ '/img/d.jpg',
+];
+
+describe('ImageGallery integration (#1256–#1259)', () => {
+ let trackSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ trackSpy = jest.spyOn(analytics, 'trackGalleryEvent').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ trackSpy.mockRestore();
+ });
+
+ it('renders empty state when there are no images', () => {
+ render();
+ expect(screen.getByText('No images available')).toBeInTheDocument();
+ });
+
+ it('fires gallery_view on mount', () => {
+ render();
+ expect(trackSpy).toHaveBeenCalledWith(
+ 'gallery_view',
+ expect.objectContaining({ propertyName: 'Sky Villa', imageCount: 4 }),
+ );
+ });
+
+ it('opens lightbox on main image click and tracks analytics', () => {
+ render();
+ fireEvent.click(screen.getByLabelText('View Sky Villa main image'));
+ expect(trackSpy).toHaveBeenCalledWith(
+ 'gallery_lightbox_open',
+ expect.objectContaining({ imageIndex: 0, method: 'click' }),
+ );
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('supports keyboard navigation on the main image (Enter opens)', () => {
+ render();
+ const main = screen.getByLabelText('View Sky Villa main image');
+ main.focus();
+ fireEvent.keyDown(main, { key: 'Enter' });
+ expect(trackSpy).toHaveBeenCalledWith(
+ 'gallery_lightbox_open',
+ expect.objectContaining({ method: 'keyboard' }),
+ );
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('moves focus with arrow keys on the gallery grid', () => {
+ render();
+ const main = screen.getByLabelText('View Sky Villa main image');
+ main.focus();
+ fireEvent.keyDown(main, { key: 'ArrowRight' });
+ expect(trackSpy).toHaveBeenCalledWith(
+ 'gallery_keyboard_navigate',
+ expect.objectContaining({ method: 'keyboard' }),
+ );
+ });
+
+ it('closes lightbox with Escape and tracks close', () => {
+ render();
+ fireEvent.click(screen.getByLabelText('View Sky Villa main image'));
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(trackSpy).toHaveBeenCalledWith(
+ 'gallery_lightbox_close',
+ expect.objectContaining({ propertyName: 'Sky Villa' }),
+ );
+ });
+
+ it('exports a memoized component (displayName set)', () => {
+ expect(ImageGallery.displayName).toBe('ImageGallery');
+ });
+});
diff --git a/src/lib/__tests__/galleryAnalytics.test.ts b/src/lib/__tests__/galleryAnalytics.test.ts
new file mode 100644
index 00000000..2e7610ba
--- /dev/null
+++ b/src/lib/__tests__/galleryAnalytics.test.ts
@@ -0,0 +1,39 @@
+import { trackGalleryEvent } from '../galleryAnalytics';
+
+describe('galleryAnalytics (#1259)', () => {
+ beforeEach(() => {
+ (window as any).dataLayer = [];
+ (window as any).gtag = jest.fn();
+ });
+
+ afterEach(() => {
+ delete (window as any).gtag;
+ });
+
+ it('pushes events to dataLayer and gtag', () => {
+ trackGalleryEvent('gallery_lightbox_open', {
+ propertyName: 'Villa',
+ imageIndex: 1,
+ imageCount: 4,
+ method: 'click',
+ });
+ expect((window as any).dataLayer.length).toBeGreaterThan(0);
+ expect((window as any).dataLayer[0]).toMatchObject({
+ event: 'gallery_lightbox_open',
+ category: 'gallery',
+ propertyName: 'Villa',
+ });
+ expect((window as any).gtag).toHaveBeenCalledWith(
+ 'event',
+ 'gallery_lightbox_open',
+ expect.objectContaining({ event_category: 'gallery' }),
+ );
+ });
+
+ it('does not throw when gtag is missing', () => {
+ delete (window as any).gtag;
+ expect(() =>
+ trackGalleryEvent('gallery_view', { imageCount: 0 }),
+ ).not.toThrow();
+ });
+});
diff --git a/src/lib/galleryAnalytics.ts b/src/lib/galleryAnalytics.ts
new file mode 100644
index 00000000..d092d2d3
--- /dev/null
+++ b/src/lib/galleryAnalytics.ts
@@ -0,0 +1,54 @@
+/**
+ * Gallery analytics events (#1259).
+ * Pushes structured events to the gtag dataLayer when available; no-ops otherwise.
+ */
+
+export type GalleryAnalyticsEvent =
+ | 'gallery_view'
+ | 'gallery_thumbnail_click'
+ | 'gallery_lightbox_open'
+ | 'gallery_lightbox_close'
+ | 'gallery_lightbox_navigate'
+ | 'gallery_keyboard_navigate';
+
+export interface GalleryEventPayload {
+ propertyName?: string;
+ imageIndex?: number;
+ imageCount?: number;
+ direction?: 'next' | 'previous' | 'jump';
+ method?: 'click' | 'keyboard' | 'swipe';
+}
+
+declare global {
+ interface Window {
+ dataLayer?: unknown[];
+ gtag?: (...args: unknown[]) => void;
+ }
+}
+
+/** Track a gallery interaction. Safe to call on the server (no-ops). */
+export function trackGalleryEvent(
+ event: GalleryAnalyticsEvent,
+ payload: GalleryEventPayload = {},
+): void {
+ if (typeof window === 'undefined') return;
+
+ const detail = {
+ event,
+ category: 'gallery',
+ ...payload,
+ };
+
+ try {
+ if (typeof window.gtag === 'function') {
+ window.gtag('event', event, {
+ event_category: 'gallery',
+ ...payload,
+ });
+ }
+ window.dataLayer = window.dataLayer || [];
+ window.dataLayer.push(detail);
+ } catch {
+ // Analytics must never break the UI.
+ }
+}