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:
Ho Ngoc Hai
2026-04-11 01:39:22 +07:00
parent 97c7d58f5e
commit 8ca64e3267
7 changed files with 956 additions and 0 deletions

View File

@@ -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}`);
} else if (filters.areaMin) {
parts.push(`Từ ${filters.areaMin}`);
} else if (filters.areaMax) {
parts.push(`Đến ${filters.areaMax}`);
}
}
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 các bộ lọc tìm kiếm nhận thông báo khi 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 tìm kiếm nào đưc lưu</p>
<p className="mt-1 text-sm text-muted-foreground">
Bạn thể lưu bộ lọc tìm kiếm từ trang tìm kiếm đ nhận thông báo khi 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>
);
}

View 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>
);
}

View 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;
}

View 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 });
},
});
}

View 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)}`;
}

View 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
View 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();
}