diff --git a/apps/web/components/agents/agent-profile-client.tsx b/apps/web/components/agents/agent-profile-client.tsx index ac29a73..8c26009 100644 --- a/apps/web/components/agents/agent-profile-client.tsx +++ b/apps/web/components/agents/agent-profile-client.tsx @@ -18,6 +18,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Link } from '@/i18n/navigation'; import type { AgentPublicProfile, AgentReviewItem } from '@/lib/agents-api'; import { formatPrice } from '@/lib/currency'; +import { shimmerBlurDataURL } from '@/lib/image-blur'; // --------------------------------------------------------------------------- // Props @@ -340,6 +341,8 @@ function ListingCard({ listing }: { listing: AgentPublicProfile['activeListings' fill className="object-cover transition-transform group-hover:scale-105" sizes="(max-width: 640px) 100vw, 50vw" + placeholder="blur" + blurDataURL={shimmerBlurDataURL()} /> ) : (
diff --git a/apps/web/components/comparison/comparison-table.tsx b/apps/web/components/comparison/comparison-table.tsx index 89b9eeb..af102c5 100644 --- a/apps/web/components/comparison/comparison-table.tsx +++ b/apps/web/components/comparison/comparison-table.tsx @@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Link } from '@/i18n/navigation'; import { formatPrice, formatPricePerM2 } from '@/lib/currency'; +import { shimmerBlurDataURL } from '@/lib/image-blur'; import type { ListingDetail } from '@/lib/listings-api'; const PROPERTY_TYPE_LABELS: Record = { @@ -80,6 +81,8 @@ export function ComparisonTable({ listings, onRemove }: ComparisonTableProps) { fill sizes="200px" className="object-cover" + placeholder="blur" + blurDataURL={shimmerBlurDataURL()} /> ) : (
diff --git a/apps/web/components/listings/image-gallery.tsx b/apps/web/components/listings/image-gallery.tsx index e4799c9..fb1627b 100644 --- a/apps/web/components/listings/image-gallery.tsx +++ b/apps/web/components/listings/image-gallery.tsx @@ -2,6 +2,8 @@ import Image from 'next/image'; import * as React from 'react'; +import { ImageLightbox } from '@/components/listings/image-lightbox'; +import { shimmerBlurDataURL } from '@/lib/image-blur'; import type { PropertyMedia } from '@/lib/listings-api'; import { cn } from '@/lib/utils'; @@ -13,6 +15,12 @@ interface ImageGalleryProps { export function ImageGallery({ media, className }: ImageGalleryProps) { const images = media.filter((m) => m.type === 'image').sort((a, b) => a.order - b.order); const [selectedIndex, setSelectedIndex] = React.useState(0); + const [lightboxOpen, setLightboxOpen] = React.useState(false); + + const openLightbox = React.useCallback((index: number) => { + setSelectedIndex(index); + setLightboxOpen(true); + }, []); if (images.length === 0) { return ( @@ -31,35 +39,58 @@ export function ImageGallery({ media, className }: ImageGalleryProps) {
{/* Main image */}
+ )} -
+
{selectedIndex + 1} / {images.length}
+ {/* Expand hint icon */} +
+ + + + + + + Xem toàn màn hình +
{/* Thumbnails */} @@ -69,6 +100,7 @@ export function ImageGallery({ media, className }: ImageGalleryProps) {
)} + + {/* Lightbox */} + setLightboxOpen(false)} + />
); } diff --git a/apps/web/components/listings/image-upload.tsx b/apps/web/components/listings/image-upload.tsx index b5cda51..abe1e09 100644 --- a/apps/web/components/listings/image-upload.tsx +++ b/apps/web/components/listings/image-upload.tsx @@ -84,12 +84,21 @@ export function ImageUpload({ images, onChange, maxFiles = 20, className }: Imag return (
inputRef.current?.click()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + inputRef.current?.click(); + } + }} + aria-label="Tải ảnh lên. Kéo thả ảnh vào đây hoặc nhấn để chọn" className={cn( - 'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors', + 'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-primary/50', diff --git a/apps/web/components/map/listing-map.tsx b/apps/web/components/map/listing-map.tsx index b3b93db..defaf2f 100644 --- a/apps/web/components/map/listing-map.tsx +++ b/apps/web/components/map/listing-map.tsx @@ -198,7 +198,7 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa function showPopup(map: mapboxgl.Map, marker: MapMarker) { popupRef.current?.remove(); - const popup = new mapboxgl.Popup({ offset: 25, maxWidth: '260px', closeButton: true }) + const popup = new mapboxgl.Popup({ offset: 25, maxWidth: 'min(260px, 85vw)', closeButton: true }) .setLngLat([marker.lng, marker.lat]) .setDOMContent(buildPopupContent(marker.listing)) .addTo(map); @@ -209,7 +209,7 @@ export function ListingMap({ listings, onMarkerClick, selectedListingId, classNa const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; return ( -
+
{/* Fallback when no Mapbox token */} diff --git a/apps/web/components/search/filter-bar.tsx b/apps/web/components/search/filter-bar.tsx index da63610..ad70b65 100644 --- a/apps/web/components/search/filter-bar.tsx +++ b/apps/web/components/search/filter-bar.tsx @@ -83,7 +83,7 @@ export function FilterBar({ filters, onChange, onSearch, layout = 'horizontal' } update('propertyType', e.target.value)} - className={isSidebar ? 'w-full' : 'w-44'} + className={isSidebar ? 'w-full' : 'w-full sm:w-44'} aria-label={t('allPropertyTypes')} > @@ -111,7 +111,7 @@ export function FilterBar({ filters, onChange, onSearch, layout = 'horizontal' } = 0 ? String(currentPriceIdx) : ''} onChange={(e) => handlePriceRange(e.target.value)} - className={isSidebar ? 'w-full' : 'w-40'} + className={isSidebar ? 'w-full' : 'w-full sm:w-40'} aria-label={t('allPrices')} > @@ -188,7 +188,7 @@ export function FilterBar({ filters, onChange, onSearch, layout = 'horizontal' } onSortChange(e.target.value)} - className="w-48" + className="w-full sm:w-48" >