diff --git a/apps/web/app/(admin)/admin/kyc/page.tsx b/apps/web/app/(admin)/admin/kyc/page.tsx index 43b89e7..841090b 100644 --- a/apps/web/app/(admin)/admin/kyc/page.tsx +++ b/apps/web/app/(admin)/admin/kyc/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState, useCallback } from 'react'; +import Image from 'next/image'; import { CheckCircle, XCircle, @@ -97,11 +98,13 @@ function KycDetailView({ item, onApprove, onReject }: { {kycData.frontImageUrl && (
Mặt trước
-
- + Mặt trước giấy tờ
@@ -109,11 +112,13 @@ function KycDetailView({ item, onApprove, onReject }: { {kycData.backImageUrl && (
Mặt sau
-
- + Mặt sau giấy tờ
@@ -121,11 +126,13 @@ function KycDetailView({ item, onApprove, onReject }: { {kycData.selfieUrl && (
Ảnh selfie
-
- + Selfie
diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index b36c01e..79c28a1 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -22,12 +22,16 @@ export default function LoginPage() { const [showPassword, setShowPassword] = useState(false); const oauthError = searchParams.get('error'); - const oauthErrorMessage = - oauthError === 'oauth_failed' - ? 'Đăng nhập bằng mạng xã hội thất bại. Vui lòng thử lại.' - : oauthError - ? decodeURIComponent(oauthError) - : null; + const OAUTH_ERROR_MESSAGES: Record = { + oauth_failed: 'Đăng nhập bằng mạng xã hội thất bại. Vui lòng thử lại.', + access_denied: 'Bạn đã từ chối quyền truy cập. Vui lòng thử lại.', + invalid_request: 'Yêu cầu đăng nhập không hợp lệ. Vui lòng thử lại.', + server_error: 'Lỗi máy chủ. Vui lòng thử lại sau.', + temporarily_unavailable: 'Dịch vụ tạm thời không khả dụng. Vui lòng thử lại sau.', + }; + const oauthErrorMessage = oauthError + ? OAUTH_ERROR_MESSAGES[oauthError] ?? 'Đã xảy ra lỗi khi đăng nhập. Vui lòng thử lại.' + : null; const { register, diff --git a/apps/web/app/(dashboard)/dashboard/page.tsx b/apps/web/app/(dashboard)/dashboard/page.tsx index c9f665e..2e452fc 100644 --- a/apps/web/app/(dashboard)/dashboard/page.tsx +++ b/apps/web/app/(dashboard)/dashboard/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import Image from 'next/image'; import Link from 'next/link'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -282,12 +283,14 @@ export default function DashboardPage() { href={`/listings/${listing.id}`} className="flex items-center gap-4 rounded-lg border p-3 transition-colors hover:bg-accent" > -
+
{listing.property.media.length > 0 ? ( - ) : (
diff --git a/apps/web/app/(dashboard)/listings/page.tsx b/apps/web/app/(dashboard)/listings/page.tsx index f11d697..0c2e0da 100644 --- a/apps/web/app/(dashboard)/listings/page.tsx +++ b/apps/web/app/(dashboard)/listings/page.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; +import Image from 'next/image'; import Link from 'next/link'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -203,14 +204,16 @@ export default function ListingsPage() {
{listing.property.media.length > 0 ? ( - {listing.property.title} ) : (
- Chua co anh + Chưa có ảnh
)}
@@ -279,12 +282,14 @@ export default function ListingsPage() { href={`/listings/${listing.id}`} className="group flex items-center gap-3" > -
+
{listing.property.media.length > 0 ? ( - ) : (
diff --git a/apps/web/app/(public)/page.tsx b/apps/web/app/(public)/page.tsx index 2548b4c..411fbd8 100644 --- a/apps/web/app/(public)/page.tsx +++ b/apps/web/app/(public)/page.tsx @@ -37,15 +37,22 @@ export default function LandingPage() { const [propertyType, setPropertyType] = React.useState(''); const [featuredListings, setFeaturedListings] = React.useState([]); const [loadingFeatured, setLoadingFeatured] = React.useState(true); + const [featuredError, setFeaturedError] = React.useState(false); - React.useEffect(() => { + const fetchFeatured = React.useCallback(() => { + setLoadingFeatured(true); + setFeaturedError(false); listingsApi .search({ status: 'ACTIVE', limit: 6 }) .then((res) => setFeaturedListings(res.data)) - .catch(() => {}) + .catch(() => setFeaturedError(true)) .finally(() => setLoadingFeatured(false)); }, []); + React.useEffect(() => { + fetchFeatured(); + }, [fetchFeatured]); + const handleSearch = (e: React.FormEvent) => { e.preventDefault(); const params = new URLSearchParams(); @@ -147,6 +154,13 @@ export default function LandingPage() {
+ ) : featuredError ? ( +
+

Không thể tải tin đăng. Vui lòng thử lại.

+ +
) : featuredListings.length > 0 ? (
{featuredListings.map((listing) => ( diff --git a/apps/web/app/(public)/search/page.tsx b/apps/web/app/(public)/search/page.tsx index 75c3108..85c1783 100644 --- a/apps/web/app/(public)/search/page.tsx +++ b/apps/web/app/(public)/search/page.tsx @@ -42,6 +42,7 @@ function SearchContent() { const [page, setPage] = React.useState(Number(searchParams.get('page')) || 1); const [result, setResult] = React.useState | null>(null); const [loading, setLoading] = React.useState(true); + const [searchError, setSearchError] = React.useState(false); const [viewMode, setViewMode] = React.useState('list'); const [showMobileFilters, setShowMobileFilters] = React.useState(false); const [selectedListingId, setSelectedListingId] = React.useState(); @@ -67,10 +68,14 @@ function SearchContent() { if (filters.maxArea) params['maxArea'] = Number(filters.maxArea); if (filters.bedrooms) params['bedrooms'] = Number(filters.bedrooms); + setSearchError(false); listingsApi .search(params) .then(setResult) - .catch(() => setResult(null)) + .catch(() => { + setResult(null); + setSearchError(true); + }) .finally(() => setLoading(false)); }, [filters, page]); @@ -214,6 +219,8 @@ function SearchContent() { void; }) { useEffect(() => { - console.error('Unhandled error:', error); + // Report to error tracking service in production; log digest only + if (process.env.NODE_ENV === 'production') { + // TODO: integrate with Sentry/Datadog when available + // errorReporter.captureException(error); + } else { + console.error('Unhandled error:', error); + } }, [error]); return ( diff --git a/apps/web/components/listings/image-gallery.tsx b/apps/web/components/listings/image-gallery.tsx index 900c114..f0339ea 100644 --- a/apps/web/components/listings/image-gallery.tsx +++ b/apps/web/components/listings/image-gallery.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; +import Image from 'next/image'; import { cn } from '@/lib/utils'; import type { PropertyMedia } from '@/lib/listings-api'; @@ -30,10 +31,13 @@ export function ImageGallery({ media, className }: ImageGalleryProps) {
{/* Main image */}
- {images[selectedIndex]?.caption {images.length > 1 && ( <> @@ -66,14 +70,16 @@ export function ImageGallery({ media, className }: ImageGalleryProps) { key={img.id} onClick={() => setSelectedIndex(index)} className={cn( - 'h-16 w-16 flex-shrink-0 overflow-hidden rounded-md border-2 transition-colors', + 'relative h-16 w-16 flex-shrink-0 overflow-hidden rounded-md border-2 transition-colors', index === selectedIndex ? 'border-primary' : 'border-transparent opacity-70 hover:opacity-100', )} > - {img.caption ))} diff --git a/apps/web/components/search/property-card.tsx b/apps/web/components/search/property-card.tsx index e7e6447..d1b7915 100644 --- a/apps/web/components/search/property-card.tsx +++ b/apps/web/components/search/property-card.tsx @@ -1,5 +1,6 @@ 'use client'; +import Image from 'next/image'; import Link from 'next/link'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -27,75 +28,91 @@ interface PropertyCardProps { } export function PropertyCard({ listing, compact }: PropertyCardProps) { + const transactionLabel = listing.transactionType === 'SALE' ? 'Bán' : 'Cho thuê'; + const propertyTypeLabel = PROPERTY_TYPE_LABELS[listing.property.propertyType] || listing.property.propertyType; + return ( - - -
- {listing.property.media.length > 0 ? ( - {listing.property.title} - ) : ( -
- Chưa có ảnh -
- )} -
- - {listing.transactionType === 'SALE' ? 'Bán' : 'Cho thuê'} - - - {PROPERTY_TYPE_LABELS[listing.property.propertyType] || listing.property.propertyType} - -
- {listing.property.media.length > 1 && ( -
- - {listing.property.media.length} ảnh +
+ + +
+ {listing.property.media.length > 0 ? ( + {`Ảnh + ) : ( + + )} +
+ + {transactionLabel} + + + {propertyTypeLabel}
- )} -
- -

- {formatPrice(listing.priceVND)} VNĐ - {listing.transactionType === 'RENT' && listing.rentPriceMonthly && ( - /tháng - )} -

-

{listing.property.title}

-

- {listing.property.address}, {listing.property.district}, {listing.property.city} -

-
- - {listing.property.areaM2} m² - - {listing.property.bedrooms != null && ( - - {listing.property.bedrooms} PN - - )} - {listing.property.bathrooms != null && listing.property.bathrooms > 0 && ( - - {listing.property.bathrooms} PT - - )} - {listing.property.direction && ( - - Hướng {listing.property.direction === 'NORTH' ? 'Bắc' : - listing.property.direction === 'SOUTH' ? 'Nam' : - listing.property.direction === 'EAST' ? 'Đông' : - listing.property.direction === 'WEST' ? 'Tây' : - listing.property.direction} - + {listing.property.media.length > 1 && ( +
+ + {listing.property.media.length} ảnh + +
)}
-
-
- + +

+ {formatPrice(listing.priceVND)} VNĐ + {listing.transactionType === 'RENT' && listing.rentPriceMonthly && ( + /tháng + )} +

+

{listing.property.title}

+

+ {listing.property.address}, {listing.property.district}, {listing.property.city} +

+
    +
  • + + {listing.property.areaM2} m² + +
  • + {listing.property.bedrooms != null && ( +
  • + + {listing.property.bedrooms} PN + +
  • + )} + {listing.property.bathrooms != null && listing.property.bathrooms > 0 && ( +
  • + + {listing.property.bathrooms} PT + +
  • + )} + {listing.property.direction && ( +
  • + + Hướng {listing.property.direction === 'NORTH' ? 'Bắc' : + listing.property.direction === 'SOUTH' ? 'Nam' : + listing.property.direction === 'EAST' ? 'Đông' : + listing.property.direction === 'WEST' ? 'Tây' : + listing.property.direction} + +
  • + )} +
+
+ + +
); } diff --git a/apps/web/components/search/search-results.tsx b/apps/web/components/search/search-results.tsx index 6a4ad8a..6d3f318 100644 --- a/apps/web/components/search/search-results.tsx +++ b/apps/web/components/search/search-results.tsx @@ -9,6 +9,8 @@ import type { ListingDetail, PaginatedResult } from '@/lib/listings-api'; interface SearchResultsProps { result: PaginatedResult | null; loading: boolean; + error?: boolean; + onRetry?: () => void; page: number; sort: string; onPageChange: (page: number) => void; @@ -18,6 +20,8 @@ interface SearchResultsProps { export function SearchResults({ result, loading, + error, + onRetry, page, sort, onPageChange, @@ -31,6 +35,20 @@ export function SearchResults({ ); } + if (error) { + return ( +
+

Không thể tải kết quả tìm kiếm

+

Đã xảy ra lỗi. Vui lòng thử lại.

+ {onRetry && ( + + )} +
+ ); + } + if (!result || result.data.length === 0) { return (
diff --git a/apps/web/next.config.js b/apps/web/next.config.js index f83dccb..3b88687 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -2,6 +2,28 @@ const nextConfig = { reactStrictMode: true, output: 'standalone', + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '**', + }, + ], + }, + async headers() { + return [ + { + source: '/(.*)', + headers: [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'X-XSS-Protection', value: '1; mode=block' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(self)' }, + ], + }, + ]; + }, }; module.exports = nextConfig;