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