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:
@@ -0,0 +1,270 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
useSavedSearches,
|
||||
useDeleteSavedSearch,
|
||||
useUpdateSavedSearch,
|
||||
} from '@/lib/hooks/use-saved-searches';
|
||||
import { type SavedSearch, type SavedSearchFilters } from '@/lib/saved-search-api';
|
||||
|
||||
const PROPERTY_TYPE_LABELS: Record<string, string> = {
|
||||
APARTMENT: 'Chung cư',
|
||||
HOUSE: 'Nhà phố',
|
||||
VILLA: 'Biệt thự',
|
||||
LAND: 'Đất nền',
|
||||
OFFICE: 'Văn phòng',
|
||||
SHOPHOUSE: 'Shophouse',
|
||||
};
|
||||
|
||||
const TRANSACTION_TYPE_LABELS: Record<string, string> = {
|
||||
SALE: 'Bán',
|
||||
RENT: 'Cho thuê',
|
||||
};
|
||||
|
||||
function formatPrice(value: string): string {
|
||||
const num = Number(value);
|
||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`;
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
|
||||
return num.toLocaleString('vi-VN');
|
||||
}
|
||||
|
||||
function formatFilters(filters: SavedSearchFilters): string[] {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (filters.transactionType) {
|
||||
parts.push(TRANSACTION_TYPE_LABELS[filters.transactionType] ?? filters.transactionType);
|
||||
}
|
||||
if (filters.propertyType) {
|
||||
parts.push(PROPERTY_TYPE_LABELS[filters.propertyType] ?? filters.propertyType);
|
||||
}
|
||||
if (filters.district) parts.push(filters.district);
|
||||
if (filters.city) parts.push(filters.city);
|
||||
if (filters.priceMin && filters.priceMax) {
|
||||
parts.push(`${formatPrice(filters.priceMin)} - ${formatPrice(filters.priceMax)}`);
|
||||
} else if (filters.priceMin) {
|
||||
parts.push(`Từ ${formatPrice(filters.priceMin)}`);
|
||||
} else if (filters.priceMax) {
|
||||
parts.push(`Đến ${formatPrice(filters.priceMax)}`);
|
||||
}
|
||||
if (filters.areaMin || filters.areaMax) {
|
||||
if (filters.areaMin && filters.areaMax) {
|
||||
parts.push(`${filters.areaMin} - ${filters.areaMax} m²`);
|
||||
} else if (filters.areaMin) {
|
||||
parts.push(`Từ ${filters.areaMin} m²`);
|
||||
} else if (filters.areaMax) {
|
||||
parts.push(`Đến ${filters.areaMax} m²`);
|
||||
}
|
||||
}
|
||||
if (filters.bedrooms) parts.push(`${filters.bedrooms}+ phòng ngủ`);
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function SavedSearchCard({
|
||||
search,
|
||||
onDelete,
|
||||
onToggleAlert,
|
||||
onApplySearch,
|
||||
}: {
|
||||
search: SavedSearch;
|
||||
onDelete: (id: string) => void;
|
||||
onToggleAlert: (id: string, enabled: boolean) => void;
|
||||
onApplySearch: (filters: SavedSearchFilters) => void;
|
||||
}) {
|
||||
const [confirmDelete, setConfirmDelete] = React.useState(false);
|
||||
const filterTags = formatFilters(search.filters);
|
||||
|
||||
return (
|
||||
<Card className="transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate text-base">{search.name}</CardTitle>
|
||||
<CardDescription className="mt-1 text-xs">
|
||||
Tạo lúc {new Date(search.createdAt).toLocaleDateString('vi-VN')}
|
||||
{search.lastAlertAt && (
|
||||
<> · Thông báo gần nhất: {new Date(search.lastAlertAt).toLocaleDateString('vi-VN')}</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="ml-2 flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onToggleAlert(search.id, !search.alertEnabled)}
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors ${
|
||||
search.alertEnabled
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||
}`}
|
||||
title={search.alertEnabled ? 'Tắt thông báo' : 'Bật thông báo'}
|
||||
>
|
||||
{search.alertEnabled ? 'Thông báo bật' : 'Thông báo tắt'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Filter tags */}
|
||||
{filterTags.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||
{filterTags.map((tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-md bg-accent px-2 py-0.5 text-xs text-accent-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => onApplySearch(search.filters)}
|
||||
>
|
||||
<svg className="mr-1.5 h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Tìm kiếm
|
||||
</Button>
|
||||
|
||||
{confirmDelete ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
onDelete(search.id);
|
||||
setConfirmDelete(false);
|
||||
}}
|
||||
>
|
||||
Xác nhận xóa
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
>
|
||||
Hủy
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
<svg className="mr-1 h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Xóa
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SavedSearchesPage() {
|
||||
const router = useRouter();
|
||||
const { data, isLoading, error } = useSavedSearches();
|
||||
const deleteMutation = useDeleteSavedSearch();
|
||||
const updateMutation = useUpdateSavedSearch();
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
deleteMutation.mutate(id);
|
||||
};
|
||||
|
||||
const handleToggleAlert = (id: string, enabled: boolean) => {
|
||||
updateMutation.mutate({ id, data: { alertEnabled: enabled } });
|
||||
};
|
||||
|
||||
const handleApplySearch = (filters: SavedSearchFilters) => {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) params.set(key, String(value));
|
||||
});
|
||||
const qs = params.toString();
|
||||
router.push(`/search${qs ? `?${qs}` : ''}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold md:text-3xl">Tìm kiếm đã lưu</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Quản lý các bộ lọc tìm kiếm và nhận thông báo khi có kết quả mới
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => router.push('/search')}>
|
||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Tìm kiếm mới
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<CardContent className="flex h-48 flex-col items-center justify-center gap-3">
|
||||
<p className="text-sm text-destructive">Không thể tải danh sách tìm kiếm đã lưu</p>
|
||||
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
|
||||
Thử lại
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : !data || data.data.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex h-64 flex-col items-center justify-center gap-4 text-center">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<svg className="h-8 w-8 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Chưa có tìm kiếm nào được lưu</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Bạn có thể lưu bộ lọc tìm kiếm từ trang tìm kiếm để nhận thông báo khi có kết quả mới
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => router.push('/search')}>
|
||||
Đi đến trang tìm kiếm
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.total} tìm kiếm đã lưu
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.data.map((search) => (
|
||||
<SavedSearchCard
|
||||
key={search.id}
|
||||
search={search}
|
||||
onDelete={handleDelete}
|
||||
onToggleAlert={handleToggleAlert}
|
||||
onApplySearch={handleApplySearch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
57
apps/web/lib/hooks/use-saved-searches.ts
Normal file
57
apps/web/lib/hooks/use-saved-searches.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { savedSearchApi, type CreateSavedSearchPayload, type UpdateSavedSearchPayload } from '@/lib/saved-search-api';
|
||||
|
||||
export const savedSearchKeys = {
|
||||
all: ['savedSearches'] as const,
|
||||
list: (params?: { page?: number; limit?: number }) => ['savedSearches', 'list', params] as const,
|
||||
detail: (id: string) => ['savedSearches', 'detail', id] as const,
|
||||
};
|
||||
|
||||
export function useSavedSearches(params?: { page?: number; limit?: number }) {
|
||||
return useQuery({
|
||||
queryKey: savedSearchKeys.list(params),
|
||||
queryFn: () => savedSearchApi.list(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSavedSearchDetail(id: string) {
|
||||
return useQuery({
|
||||
queryKey: savedSearchKeys.detail(id),
|
||||
queryFn: () => savedSearchApi.getById(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSavedSearch() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateSavedSearchPayload) => savedSearchApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: savedSearchKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSavedSearch() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateSavedSearchPayload }) =>
|
||||
savedSearchApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: savedSearchKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSavedSearch() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => savedSearchApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: savedSearchKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
53
apps/web/lib/image-blur.ts
Normal file
53
apps/web/lib/image-blur.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Tiny SVG-based blur placeholder for next/image.
|
||||
*
|
||||
* Generates a small, inline base64-encoded SVG that next/image displays while
|
||||
* the real image loads. This eliminates layout shift (CLS) and provides a smooth
|
||||
* visual transition without needing to pre-compute per-image blur hashes.
|
||||
*
|
||||
* Usage:
|
||||
* <Image placeholder="blur" blurDataURL={shimmerBlurDataURL()} ... />
|
||||
*/
|
||||
|
||||
const SHIMMER_SVG = `
|
||||
<svg width="400" height="300" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#e5e7eb"/>
|
||||
<stop offset="50%" stop-color="#f3f4f6"/>
|
||||
<stop offset="100%" stop-color="#e5e7eb"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="300" fill="#e5e7eb"/>
|
||||
<rect width="400" height="300" fill="url(#g)">
|
||||
<animate attributeName="x" from="-400" to="400" dur="1.5s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
</svg>`.trim();
|
||||
|
||||
const STATIC_BLUR_SVG = `
|
||||
<svg width="8" height="6" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="8" height="6" fill="#d1d5db"/>
|
||||
</svg>`.trim();
|
||||
|
||||
function toBase64(str: string): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return Buffer.from(str).toString('base64');
|
||||
}
|
||||
return btoa(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated shimmer blur placeholder (for listing thumbnails).
|
||||
* Shows a shimmer animation while the image loads.
|
||||
*/
|
||||
export function shimmerBlurDataURL(): string {
|
||||
return `data:image/svg+xml;base64,${toBase64(SHIMMER_SVG)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static grey blur placeholder (for small thumbnails like table rows).
|
||||
* Lightweight, no animation.
|
||||
*/
|
||||
export function staticBlurDataURL(): string {
|
||||
return `data:image/svg+xml;base64,${toBase64(STATIC_BLUR_SVG)}`;
|
||||
}
|
||||
66
apps/web/lib/saved-search-api.ts
Normal file
66
apps/web/lib/saved-search-api.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { apiClient } from './api-client';
|
||||
|
||||
// ─── Interfaces ──────────────────────────────────────────
|
||||
|
||||
export interface SavedSearchFilters {
|
||||
transactionType?: string;
|
||||
propertyType?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
priceMin?: string;
|
||||
priceMax?: string;
|
||||
areaMin?: string;
|
||||
areaMax?: string;
|
||||
bedrooms?: string;
|
||||
}
|
||||
|
||||
export interface SavedSearch {
|
||||
id: string;
|
||||
name: string;
|
||||
filters: SavedSearchFilters;
|
||||
alertEnabled: boolean;
|
||||
lastAlertAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SavedSearchListResult {
|
||||
data: SavedSearch[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface CreateSavedSearchPayload {
|
||||
name: string;
|
||||
filters: SavedSearchFilters;
|
||||
alertEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateSavedSearchPayload {
|
||||
name?: string;
|
||||
filters?: SavedSearchFilters;
|
||||
alertEnabled?: boolean;
|
||||
}
|
||||
|
||||
// ─── API Functions ───────────────────────────────────────
|
||||
|
||||
export const savedSearchApi = {
|
||||
list: (params: { page?: number; limit?: number } = {}) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params.page) query.append('page', String(params.page));
|
||||
if (params.limit) query.append('limit', String(params.limit));
|
||||
const qs = query.toString();
|
||||
return apiClient.get<SavedSearchListResult>(`/saved-searches${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
|
||||
getById: (id: string) => apiClient.get<SavedSearch>(`/saved-searches/${id}`),
|
||||
|
||||
create: (data: CreateSavedSearchPayload) =>
|
||||
apiClient.post<SavedSearch>('/saved-searches', data),
|
||||
|
||||
update: (id: string, data: UpdateSavedSearchPayload) =>
|
||||
apiClient.patch<SavedSearch>(`/saved-searches/${id}`, data),
|
||||
|
||||
delete: (id: string) =>
|
||||
apiClient.delete<{ deleted: boolean }>(`/saved-searches/${id}`),
|
||||
};
|
||||
119
apps/web/lib/web-vitals.ts
Normal file
119
apps/web/lib/web-vitals.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { Metric } from 'web-vitals';
|
||||
|
||||
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
|
||||
|
||||
/**
|
||||
* Baseline targets for Core Web Vitals:
|
||||
* LCP < 2.5s (good), < 4.0s (needs improvement)
|
||||
* FCP < 1.8s (good), < 3.0s (needs improvement)
|
||||
* CLS < 0.1 (good), < 0.25 (needs improvement)
|
||||
* TTFB < 800ms (good)
|
||||
* INP < 200ms (good), < 500ms (needs improvement)
|
||||
*/
|
||||
export const WEB_VITALS_THRESHOLDS = {
|
||||
LCP: { good: 2500, poor: 4000 },
|
||||
FCP: { good: 1800, poor: 3000 },
|
||||
CLS: { good: 0.1, poor: 0.25 },
|
||||
TTFB: { good: 800, poor: 1800 },
|
||||
INP: { good: 200, poor: 500 },
|
||||
} as const;
|
||||
|
||||
type VitalName = keyof typeof WEB_VITALS_THRESHOLDS;
|
||||
|
||||
function getRating(name: string, value: number): 'good' | 'needs-improvement' | 'poor' {
|
||||
const thresholds = WEB_VITALS_THRESHOLDS[name as VitalName];
|
||||
if (!thresholds) return 'good';
|
||||
if (value <= thresholds.good) return 'good';
|
||||
if (value <= thresholds.poor) return 'needs-improvement';
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
interface WebVitalPayload {
|
||||
name: string;
|
||||
value: number;
|
||||
rating: string;
|
||||
delta: number;
|
||||
id: string;
|
||||
navigationType: string;
|
||||
url: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Queue metrics and flush in batches to avoid per-metric requests. */
|
||||
const queue: WebVitalPayload[] = [];
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const FLUSH_INTERVAL_MS = 5000;
|
||||
const MAX_BATCH_SIZE = 10;
|
||||
|
||||
function flushQueue(): void {
|
||||
if (queue.length === 0) return;
|
||||
|
||||
const batch = queue.splice(0, MAX_BATCH_SIZE);
|
||||
const body = JSON.stringify({ metrics: batch });
|
||||
|
||||
// Use sendBeacon for reliability during page unload; fall back to fetch
|
||||
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
||||
const blob = new Blob([body], { type: 'application/json' });
|
||||
const sent = navigator.sendBeacon(`${API_BASE_URL}/web-vitals`, blob);
|
||||
if (!sent) {
|
||||
sendViaFetch(body);
|
||||
}
|
||||
} else {
|
||||
sendViaFetch(body);
|
||||
}
|
||||
}
|
||||
|
||||
function sendViaFetch(body: string): void {
|
||||
fetch(`${API_BASE_URL}/web-vitals`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
keepalive: true,
|
||||
}).catch(() => {
|
||||
// Silently drop — metrics are best-effort
|
||||
});
|
||||
}
|
||||
|
||||
function enqueue(payload: WebVitalPayload): void {
|
||||
queue.push(payload);
|
||||
|
||||
if (queue.length >= MAX_BATCH_SIZE) {
|
||||
if (flushTimer) clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
flushQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!flushTimer) {
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
flushQueue();
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** Report a single web-vitals Metric to the backend. */
|
||||
export function reportWebVital(metric: Metric): void {
|
||||
const payload: WebVitalPayload = {
|
||||
name: metric.name,
|
||||
value: metric.value,
|
||||
rating: metric.rating ?? getRating(metric.name, metric.value),
|
||||
delta: metric.delta,
|
||||
id: metric.id,
|
||||
navigationType: metric.navigationType ?? 'unknown',
|
||||
url: typeof location !== 'undefined' ? location.pathname : '',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
enqueue(payload);
|
||||
}
|
||||
|
||||
/** Flush any remaining metrics (call on page hide / unload). */
|
||||
export function flushWebVitals(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushQueue();
|
||||
}
|
||||
Reference in New Issue
Block a user