diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/saved-searches/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/saved-searches/page.tsx new file mode 100644 index 0000000..59f94cf --- /dev/null +++ b/apps/web/app/[locale]/(dashboard)/dashboard/saved-searches/page.tsx @@ -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 = { + 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 = { + 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 ( + + +
+
+ {search.name} + + 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')} + )} + +
+
+ +
+
+
+ + {/* Filter tags */} + {filterTags.length > 0 && ( +
+ {filterTags.map((tag, i) => ( + + {tag} + + ))} +
+ )} + + {/* Actions */} +
+ + + {confirmDelete ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ ); +} + +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 ( +
+ {/* Header */} +
+
+

Tìm kiếm đã lưu

+

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

+
+ +
+ + {/* Content */} + {isLoading ? ( +
+
+
+ ) : error ? ( + + +

Không thể tải danh sách tìm kiếm đã lưu

+ +
+
+ ) : !data || data.data.length === 0 ? ( + + +
+ + + +
+
+

Chưa có tìm kiếm nào được lưu

+

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

+
+ +
+
+ ) : ( +
+

+ {data.total} tìm kiếm đã lưu +

+
+ {data.data.map((search) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/components/listings/image-lightbox.tsx b/apps/web/components/listings/image-lightbox.tsx new file mode 100644 index 0000000..d976279 --- /dev/null +++ b/apps/web/components/listings/image-lightbox.tsx @@ -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, 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(focusableSelector); + firstFocusable?.focus(); + + function handleKeyDown(e: KeyboardEvent) { + if (e.key !== 'Tab' || !container) return; + + const focusableElements = container.querySelectorAll(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(null); + const thumbnailsRef = React.useRef(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 ( +
+ {/* Top bar */} +
+

+ {currentIndex + 1} / {images.length} +

+ {currentImage.caption && ( +

+ {currentImage.caption} +

+ )} + +
+ + {/* Main image area */} +
+ {/* Loading spinner */} + {!isImageLoaded && ( +
+
+
+ )} + + {/* Image */} +
+ {currentImage.caption setIsImageLoaded(true)} + /> +
+ + {/* Navigation arrows */} + {images.length > 1 && ( + <> + + + + )} +
+ + {/* Thumbnail strip */} + {images.length > 1 && ( +
+
+ {images.map((img, index) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/components/providers/web-vitals.tsx b/apps/web/components/providers/web-vitals.tsx new file mode 100644 index 0000000..686180a --- /dev/null +++ b/apps/web/components/providers/web-vitals.tsx @@ -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; +} diff --git a/apps/web/lib/hooks/use-saved-searches.ts b/apps/web/lib/hooks/use-saved-searches.ts new file mode 100644 index 0000000..74cbbbf --- /dev/null +++ b/apps/web/lib/hooks/use-saved-searches.ts @@ -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 }); + }, + }); +} diff --git a/apps/web/lib/image-blur.ts b/apps/web/lib/image-blur.ts new file mode 100644 index 0000000..46754b1 --- /dev/null +++ b/apps/web/lib/image-blur.ts @@ -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: + * + */ + +const SHIMMER_SVG = ` + + + + + + + + + + + + +`.trim(); + +const STATIC_BLUR_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)}`; +} diff --git a/apps/web/lib/saved-search-api.ts b/apps/web/lib/saved-search-api.ts new file mode 100644 index 0000000..78b1e71 --- /dev/null +++ b/apps/web/lib/saved-search-api.ts @@ -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(`/saved-searches${qs ? `?${qs}` : ''}`); + }, + + getById: (id: string) => apiClient.get(`/saved-searches/${id}`), + + create: (data: CreateSavedSearchPayload) => + apiClient.post('/saved-searches', data), + + update: (id: string, data: UpdateSavedSearchPayload) => + apiClient.patch(`/saved-searches/${id}`, data), + + delete: (id: string) => + apiClient.delete<{ deleted: boolean }>(`/saved-searches/${id}`), +}; diff --git a/apps/web/lib/web-vitals.ts b/apps/web/lib/web-vitals.ts new file mode 100644 index 0000000..a3c770a --- /dev/null +++ b/apps/web/lib/web-vitals.ts @@ -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 | 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(); +}