feat(web): add saved searches, image lightbox, and web vitals tracking
New features: - Saved searches dashboard page with CRUD hooks and API client - Image lightbox component for property gallery full-screen viewing - Web vitals provider and reporting utilities for performance monitoring - Image blur placeholder generation utility Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
349
apps/web/components/listings/image-lightbox.tsx
Normal file
349
apps/web/components/listings/image-lightbox.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import * as React from 'react';
|
||||
import type { PropertyMedia } from '@/lib/listings-api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────
|
||||
|
||||
interface ImageLightboxProps {
|
||||
images: PropertyMedia[];
|
||||
initialIndex?: number;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ─── Swipe hook ─────────────────────────────────────────
|
||||
|
||||
function useSwipe(onSwipeLeft: () => void, onSwipeRight: () => void) {
|
||||
const touchStart = React.useRef<{ x: number; y: number } | null>(null);
|
||||
const touchEnd = React.useRef<{ x: number; y: number } | null>(null);
|
||||
const minSwipeDistance = 50;
|
||||
|
||||
const onTouchStart = React.useCallback((e: React.TouchEvent) => {
|
||||
const touch = e.targetTouches[0];
|
||||
if (!touch) return;
|
||||
touchEnd.current = null;
|
||||
touchStart.current = { x: touch.clientX, y: touch.clientY };
|
||||
}, []);
|
||||
|
||||
const onTouchMove = React.useCallback((e: React.TouchEvent) => {
|
||||
const touch = e.targetTouches[0];
|
||||
if (!touch) return;
|
||||
touchEnd.current = { x: touch.clientX, y: touch.clientY };
|
||||
}, []);
|
||||
|
||||
const onTouchEnd = React.useCallback(() => {
|
||||
if (!touchStart.current || !touchEnd.current) return;
|
||||
const distanceX = touchStart.current.x - touchEnd.current.x;
|
||||
const distanceY = Math.abs(touchStart.current.y - touchEnd.current.y);
|
||||
// Only consider horizontal swipes (ignore vertical scrolling)
|
||||
if (Math.abs(distanceX) > minSwipeDistance && Math.abs(distanceX) > distanceY) {
|
||||
if (distanceX > 0) {
|
||||
onSwipeLeft();
|
||||
} else {
|
||||
onSwipeRight();
|
||||
}
|
||||
}
|
||||
}, [onSwipeLeft, onSwipeRight]);
|
||||
|
||||
return { onTouchStart, onTouchMove, onTouchEnd };
|
||||
}
|
||||
|
||||
// ─── Focus trap hook ────────────────────────────────────
|
||||
|
||||
function useFocusTrap(containerRef: React.RefObject<HTMLDivElement | null>, active: boolean) {
|
||||
React.useEffect(() => {
|
||||
if (!active) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
const focusableSelector =
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
// Focus the first focusable element
|
||||
const firstFocusable = container.querySelector<HTMLElement>(focusableSelector);
|
||||
firstFocusable?.focus();
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Tab' || !container) return;
|
||||
|
||||
const focusableElements = container.querySelectorAll<HTMLElement>(focusableSelector);
|
||||
if (focusableElements.length === 0) return;
|
||||
|
||||
const first = focusableElements[0] as HTMLElement | undefined;
|
||||
const last = focusableElements[focusableElements.length - 1] as HTMLElement | undefined;
|
||||
if (!first || !last) return;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [containerRef, active]);
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────
|
||||
|
||||
export function ImageLightbox({ images, initialIndex = 0, open, onClose }: ImageLightboxProps) {
|
||||
const [currentIndex, setCurrentIndex] = React.useState(initialIndex);
|
||||
const [isImageLoaded, setIsImageLoaded] = React.useState(false);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const thumbnailsRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Reset index when lightbox opens with a new initialIndex
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setCurrentIndex(initialIndex);
|
||||
setIsImageLoaded(false);
|
||||
}
|
||||
}, [open, initialIndex]);
|
||||
|
||||
// Lock body scroll
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Focus trap
|
||||
useFocusTrap(containerRef, open);
|
||||
|
||||
// Navigation handlers
|
||||
const goToPrevious = React.useCallback(() => {
|
||||
setCurrentIndex((i) => (i > 0 ? i - 1 : images.length - 1));
|
||||
setIsImageLoaded(false);
|
||||
}, [images.length]);
|
||||
|
||||
const goToNext = React.useCallback(() => {
|
||||
setCurrentIndex((i) => (i < images.length - 1 ? i + 1 : 0));
|
||||
setIsImageLoaded(false);
|
||||
}, [images.length]);
|
||||
|
||||
// Keyboard navigation
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
goToPrevious();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
goToNext();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose, goToPrevious, goToNext]);
|
||||
|
||||
// Swipe support
|
||||
const swipeHandlers = useSwipe(goToNext, goToPrevious);
|
||||
|
||||
// Scroll active thumbnail into view
|
||||
React.useEffect(() => {
|
||||
if (!open || !thumbnailsRef.current) return;
|
||||
const activeThumb = thumbnailsRef.current.children[currentIndex] as HTMLElement | undefined;
|
||||
activeThumb?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
}, [currentIndex, open]);
|
||||
|
||||
// Preload adjacent images
|
||||
React.useEffect(() => {
|
||||
if (!open || images.length <= 1) return;
|
||||
const preloadIndices = [
|
||||
(currentIndex + 1) % images.length,
|
||||
(currentIndex - 1 + images.length) % images.length,
|
||||
];
|
||||
preloadIndices.forEach((idx) => {
|
||||
const imageToPreload = images[idx];
|
||||
if (!imageToPreload) return;
|
||||
const img = new window.Image();
|
||||
img.src = imageToPreload.url;
|
||||
});
|
||||
}, [currentIndex, open, images]);
|
||||
|
||||
if (!open || images.length === 0) return null;
|
||||
|
||||
const currentImage = images[currentIndex];
|
||||
if (!currentImage) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-50 flex flex-col bg-black/95"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Xem ảnh toàn màn hình"
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<p className="text-sm text-white/80">
|
||||
{currentIndex + 1} / {images.length}
|
||||
</p>
|
||||
{currentImage.caption && (
|
||||
<p className="mx-4 flex-1 truncate text-center text-sm text-white/70">
|
||||
{currentImage.caption}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-2 text-white/80 transition-colors hover:bg-white/10 hover:text-white"
|
||||
aria-label="Đóng (Escape)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main image area */}
|
||||
<div
|
||||
className="relative flex flex-1 items-center justify-center px-12 md:px-20"
|
||||
{...swipeHandlers}
|
||||
>
|
||||
{/* Loading spinner */}
|
||||
{!isImageLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white/80" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image */}
|
||||
<div className="relative h-full w-full">
|
||||
<Image
|
||||
key={currentImage.id}
|
||||
src={currentImage.url}
|
||||
alt={currentImage.caption || `Ảnh ${currentIndex + 1}`}
|
||||
fill
|
||||
sizes="100vw"
|
||||
className={cn(
|
||||
'object-contain transition-opacity duration-200',
|
||||
isImageLoaded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
priority
|
||||
onLoad={() => setIsImageLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/25 md:left-4"
|
||||
aria-label="Ảnh trước (phím mũi tên trái)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/25 md:right-4"
|
||||
aria-label="Ảnh tiếp (phím mũi tên phải)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail strip */}
|
||||
{images.length > 1 && (
|
||||
<div className="border-t border-white/10 px-4 py-3">
|
||||
<div
|
||||
ref={thumbnailsRef}
|
||||
className="flex justify-center gap-2 overflow-x-auto"
|
||||
role="tablist"
|
||||
aria-label="Danh sách ảnh thu nhỏ"
|
||||
>
|
||||
{images.map((img, index) => (
|
||||
<button
|
||||
key={img.id}
|
||||
role="tab"
|
||||
aria-selected={index === currentIndex}
|
||||
aria-label={img.caption || `Ảnh ${index + 1}`}
|
||||
onClick={() => {
|
||||
setCurrentIndex(index);
|
||||
setIsImageLoaded(false);
|
||||
}}
|
||||
className={cn(
|
||||
'relative h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border-2 transition-all md:h-16 md:w-16',
|
||||
index === currentIndex
|
||||
? 'border-white ring-1 ring-white/50'
|
||||
: 'border-transparent opacity-50 hover:opacity-80',
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={img.url}
|
||||
alt={img.caption || `Thumbnail ${index + 1}`}
|
||||
fill
|
||||
sizes="64px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
apps/web/components/providers/web-vitals.tsx
Normal file
42
apps/web/components/providers/web-vitals.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Client component that initialises Core Web Vitals reporting.
|
||||
*
|
||||
* Tracks: LCP, FCP, CLS, TTFB, INP (the five web-vitals v5 metrics).
|
||||
* FID was deprecated — INP is the modern responsiveness metric;
|
||||
* FCP (First Contentful Paint) replaces FID in the library.
|
||||
*
|
||||
* Metrics are batched and sent to the backend `/web-vitals` endpoint via
|
||||
* `navigator.sendBeacon` for reliability during page transitions.
|
||||
*/
|
||||
export function WebVitals() {
|
||||
useEffect(() => {
|
||||
// Dynamic import so the library is only loaded in the browser
|
||||
import('web-vitals').then(({ onLCP, onFCP, onCLS, onTTFB, onINP }) => {
|
||||
import('@/lib/web-vitals').then(({ reportWebVital, flushWebVitals }) => {
|
||||
onLCP(reportWebVital);
|
||||
onFCP(reportWebVital);
|
||||
onCLS(reportWebVital);
|
||||
onTTFB(reportWebVital);
|
||||
onINP(reportWebVital);
|
||||
|
||||
// Flush remaining metrics when the page is hidden (tab switch, close, navigate)
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
flushWebVitals();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user