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