'use client'; import dynamic from 'next/dynamic'; import Link from 'next/link'; import * as React from 'react'; import { AddToCompareButton } from '@/components/comparison/add-to-compare-button'; import { AiAdviceCards } from '@/components/listings/ai-advice-cards'; import { ImageGallery } from '@/components/listings/image-gallery'; import { InquiryModal } from '@/components/listings/inquiry-modal'; import { PriceHistoryChart } from '@/components/listings/price-history-chart'; import { SocialShare } from '@/components/listings/social-share'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { AiEstimateButton } from '@/components/valuation/ai-estimate-button'; import { analyticsApi } from '@/lib/analytics-api'; import type { NearbyPOI } from '@/lib/analytics-api'; import { formatPrice, formatPricePerM2 } from '@/lib/currency'; import { composeWhyThisLocation, derivePersonas } from '@/lib/listing-personas'; import { type AgentQualityScore, type ListingDetail, type ListingSimilarItem, type NeighborhoodScoreResult, type PriceHistoryItem, listingsApi, } from '@/lib/listings-api'; import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES, FURNISHING_OPTIONS, PROPERTY_CONDITION_OPTIONS, } from '@/lib/validations/listings'; import type { POIItem } from '@/components/neighborhood'; const NeighborhoodRadarChart = dynamic( () => import('@/components/neighborhood').then((m) => m.NeighborhoodRadarChart), { ssr: false }, ); const NeighborhoodPOIMap = dynamic( () => import('@/components/neighborhood').then((m) => m.NeighborhoodPOIMap), { ssr: false, loading: () => (

Đang tải bản đồ...

), }, ); // ─── Helpers ────────────────────────────────────────────── function getLabel(list: readonly { value: string; label: string }[], value: string | null) { if (!value) return null; return list.find((item) => item.value === value)?.label ?? value; } function formatVND(value: string | number) { return new Intl.NumberFormat('vi-VN').format(Number(value)); } function daysOnMarket(publishedAt: string | null): number | null { if (!publishedAt) return null; return Math.floor((Date.now() - new Date(publishedAt).getTime()) / 86_400_000); } // ─── Sub-components ──────────────────────────────────────── /** * KPI card for the trader-style strip. */ function KpiCard({ label, value, sub, signal, }: { label: string; value: React.ReactNode; sub?: React.ReactNode; signal?: 'up' | 'down' | 'neutral'; }) { const signalClass = signal === 'up' ? 'text-[hsl(var(--signal-up))]' : signal === 'down' ? 'text-[hsl(var(--signal-down))]' : 'text-foreground-muted'; return (
{label} {value} {sub && ( {sub} )}
); } /** * Tier badge for agent quality score. */ const TIER_COLORS: Record = { BRONZE: 'bg-amber-700/20 text-amber-500 border-amber-700/40', SILVER: 'bg-slate-400/20 text-slate-300 border-slate-400/40', GOLD: 'bg-yellow-400/20 text-yellow-300 border-yellow-500/40', PLATINUM: 'bg-cyan-400/20 text-cyan-300 border-cyan-400/40', }; /** * Comps table — similar listings in same district, sorted by pricePerM². */ function CompsTable({ items }: { items: ListingSimilarItem[] }) { if (items.length === 0) { return (

Không có bất động sản tương tự trong quận này

); } return (
{items.map((comp, i) => ( ))}
Tiêu đề Diện tích Giá Quận
{comp.title} {comp.publishedAt && (

{new Date(comp.publishedAt).toLocaleDateString('vi-VN')}

)}
{comp.areaM2} m² {formatVND(comp.priceVND)} {comp.district}
); } /** * Compact agent card with quality score. */ function AgentCard({ agent, agentQualityScore, onInquiry, }: { agent: ListingDetail['agent']; agentQualityScore: AgentQualityScore | null; onInquiry: () => void; }) { if (!agent) return null; return (
{agent.agency && (

{agent.agency}

)} {agentQualityScore && (
{agentQualityScore.tier} {agentQualityScore.score.toFixed(1)} điểm
)}
); } // ─── Persona fit ─────────────────────────────────────────── function mapScoreToCategories(result: NeighborhoodScoreResult) { return [ { category: 'education', label: 'Giáo dục', score: result.educationScore }, { category: 'healthcare', label: 'Y tế', score: result.healthcareScore }, { category: 'transport', label: 'Giao thông', score: result.transportScore }, { category: 'shopping', label: 'Mua sắm', score: result.shoppingScore }, { category: 'environment', label: 'Môi trường', score: result.greeneryScore }, { category: 'safety', label: 'An ninh', score: result.safetyScore }, ]; } function PersonaFitCard({ listing, score, pois, }: { listing: ListingDetail; score: NeighborhoodScoreResult | null; pois: POIItem[]; }) { const adminPicks = listing.property.suitableFor ?? []; const adminNarrative = listing.property.whyThisLocation?.trim() || null; const derived = React.useMemo( () => derivePersonas(listing, score, pois), [listing, score, pois], ); const derivedNarrative = React.useMemo( () => composeWhyThisLocation(listing, score, pois), [listing, score, pois], ); const narrative = adminNarrative ?? derivedNarrative; const derivedFiltered = derived.filter((d) => !adminPicks.includes(d.label)); if (adminPicks.length === 0 && derivedFiltered.length === 0 && !narrative) return null; return ( Phù hợp với ai? {(adminPicks.length > 0 || derivedFiltered.length > 0) && (
{adminPicks.map((label) => (
{label} Người đăng chọn
))} {derivedFiltered.map((p) => (
{p.label}
))}
)} {derivedFiltered.length > 0 && (
    {derivedFiltered.map((p) => (
  • {p.label}: {p.reason}
  • ))}
)} {narrative && (

Vì sao nên ở đây

{narrative}

)}
); } // ─── Utility sub-components ──────────────────────────────── function QuickStat({ icon, label, value }: { icon: string; label: string; value: string }) { const icons: Record = { area: ( ), bed: ( ), bath: ( ), floors: ( ), compass: ( ), transit: ( ), }; return (
{icons[icon]}

{label}

{value}

); } function InfoItem({ label, value }: { label: string; value: string }) { return (

{label}

{value}

); } // ─── Main component ──────────────────────────────────────── interface ListingDetailClientProps { listing: ListingDetail; } export function ListingDetailClient({ listing }: ListingDetailClientProps) { const { property, seller, agent } = listing; const transactionLabel = getLabel(TRANSACTION_TYPES, listing.transactionType); const propertyTypeLabel = getLabel(PROPERTY_TYPES, property.propertyType); const [inquiryOpen, setInquiryOpen] = React.useState(false); const [neighborhoodScore, setNeighborhoodScore] = React.useState(null); const [priceHistory, setPriceHistory] = React.useState([]); const [comps, setComps] = React.useState([]); const [compsLoaded, setCompsLoaded] = React.useState(false); const [nearbyPois, setNearbyPois] = React.useState([]); React.useEffect(() => { if (!property.district || !property.city) return; listingsApi .getNeighborhoodScore(property.district, property.city) .then(setNeighborhoodScore) .catch(() => {/* silently ignore */}); }, [property.district, property.city]); React.useEffect(() => { const { latitude, longitude } = property; if (latitude == null || longitude == null) return; analyticsApi .getNearbyPOIs(latitude, longitude) .then((res) => { const mapped: POIItem[] = res.pois.map((p: NearbyPOI) => ({ id: p.id, name: p.name, category: p.category, lat: p.lat, lng: p.lng, distance: p.distance, })); setNearbyPois(mapped); }) .catch(() => {/* silently ignore */}); }, [property.latitude, property.longitude]); React.useEffect(() => { listingsApi .getPriceHistory(listing.id) .then(setPriceHistory) .catch(() => {/* silently ignore */}); }, [listing.id]); React.useEffect(() => { listingsApi .getSimilar(listing.id, 5) .then((data) => { setComps(data); setCompsLoaded(true); }) .catch(() => { setCompsLoaded(true); // show empty state on error }); }, [listing.id]); const dom = daysOnMarket(listing.publishedAt); return ( /* pb-28 reserves space for the sticky action bar on mobile */
{/* ── Breadcrumb + header strip ─────────────────────────── */} {/* ── Title + status pills ──────────────────────────────── */}
{transactionLabel && ( {transactionLabel} )} {propertyTypeLabel && {propertyTypeLabel}} {listing.status !== 'ACTIVE' && ( {listing.status} )}

{property.title}

{property.address}, {property.ward}, {property.district}, {property.city}

{/* ── KPI strip (trader-style) ──────────────────────────── */}
{listing.pricePerM2 != null && ( )} {listing.valuationEstimate != null && ( )} {listing.inquiryCount != null && ( )} {listing.agentQualityScore != null && ( )} {dom != null && ( 90 ? 'down' : dom > 30 ? 'neutral' : 'up'} /> )}
{/* ── Image gallery ─────────────────────────────────────── */} {/* ── Quick specs bar ───────────────────────────────────── */}
{property.bedrooms != null && ( )} {property.bathrooms != null && ( )} {property.floor != null && property.totalFloors != null && ( )} {property.floor != null && property.totalFloors == null && ( )} {property.floor == null && property.floors != null && ( )} {property.direction && ( )} {property.metroDistanceM != null && ( )}
{/* ── Persona fit ───────────────────────────────────────── */} {/* ── Main two-column layout ────────────────────────────── */}
{/* Left — main content */}
{/* Price chart */} Lịch sử giá {priceHistory.length > 0 ? ( ) : (

Chưa có lịch sử biến động giá cho tin này

)}
{/* Comps table */} Bất động sản tương tự {compsLoaded ? ( ) : (

Đang tải...

)}
{/* Description */} Mô tả

{property.description}

{/* Details */} Thông tin chi tiết
0 ? property.viewType.join(' • ') : '---'} /> {property.petFriendly !== null && ( )}
{/* Amenities */} {property.amenities && property.amenities.length > 0 && ( Tiện ích
{property.amenities.map((a) => ( {a} ))}
)} {/* Map */} Vị trí trên bản đồ {property.latitude != null && property.longitude != null ? ( <>

Tìm thấy {nearbyPois.length} điểm quan tâm trong bán kính 2 km

) : (

Chưa có tọa độ cho tin đăng này

)}
{/* Neighborhood score radar */} Đánh giá khu vực {neighborhoodScore ? ( <>
7 ? 'success' : neighborhoodScore.totalScore >= 5 ? 'warning' : 'destructive' } className="px-3 py-1 text-lg font-bold" > {neighborhoodScore.totalScore.toFixed(1)}/10 Điểm tổng khu vực
) : (

Chưa có dữ liệu đánh giá khu vực này

)}
{/* Right — sidebar */}
{/* Price card */}

{formatVND(listing.priceVND)} đ

{listing.pricePerM2 != null && (

~{formatPricePerM2(listing.pricePerM2)}

)} {listing.rentPriceMonthly && (

Thuê: {formatPrice(listing.rentPriceMonthly)}/tháng

)} {listing.valuationEstimate && (

AVM: {formatVND(listing.valuationEstimate.value)} đ  · {Math.round(listing.valuationEstimate.confidence * 100)}% tin cậy

)}
{/* Agent card compact */} {agent && ( setInquiryOpen(true)} /> )} {/* Seller contact */} Liên hệ người đăng

{seller.fullName}

{seller.phone}

{agent?.agency && listing.commissionPct != null && (

Môi giới: {agent.agency}

Hoa hồng: {listing.commissionPct}%

)}
{/* Sharing */} {/* Stats */}

{listing.viewCount}

Lượt xem

{listing.saveCount}

Lượt lưu

{listing.similarCount}

Tin tương tự

{listing.publishedAt && (

Đăng ngày {new Date(listing.publishedAt).toLocaleDateString('vi-VN')}

)}
{/* AI tools */}
{/* ── Sticky action bar (mobile + desktop) ─────────────── */}

{property.title}

{formatVND(listing.priceVND)} đ

); }