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:
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