'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 { ListingDetail, NeighborhoodScoreResult, PriceHistoryItem } from '@/lib/listings-api'; import { 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: () => (

{'\u0110ang t\u1ea3i b\u1ea3n \u0111\u1ed3...'}

), }, ); function getLabel(list: readonly { value: string; label: string }[], value: string | null) { if (!value) return null; return list.find((item) => item.value === value)?.label ?? value; } interface ListingDetailClientProps { listing: ListingDetail; } 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 }, ]; } 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 [nearbyPois, setNearbyPois] = React.useState([]); React.useEffect(() => { if (!property.district || !property.city) return; listingsApi .getNeighborhoodScore(property.district, property.city) .then(setNeighborhoodScore) .catch(() => {/* silently ignore — section simply won't render */}); }, [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 — map still renders without POIs */}); }, [property.latitude, property.longitude]); React.useEffect(() => { listingsApi .getPriceHistory(listing.id) .then(setPriceHistory) .catch(() => {/* silently ignore */}); }, [listing.id]); return (
{/* Breadcrumb */} {/* Header */}
{transactionLabel && ( {transactionLabel} )} {propertyTypeLabel && {propertyTypeLabel}}

{property.title}

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

{formatPrice(listing.priceVND)} VND

{listing.pricePerM2 != null && (

~{formatPricePerM2(listing.pricePerM2)}

)} {listing.rentPriceMonthly && (

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

)}
{/* 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 — "Phù hợp với ai & Vì sao nên ở đây" */}
{/* Main content */}
{/* 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

)}
{/* Price History Chart */} {priceHistory.length > 0 && ( Lịch sử giá )} {/* Neighborhood Score Radar Chart */} Đá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

)}
{/* Sidebar */}
{/* Contact card */} Liên hệ

{seller.fullName}

{seller.phone}

{agent && (

Môi giới

{agent.agency &&

{agent.agency}

} {listing.commissionPct != null && (

Hoa hồng: {listing.commissionPct}%

)}
)}
{/* Social sharing + QR code */} {/* AI Estimate (legacy AVM — preserved) */} {/* AI advisor (Claude — analysis + valuation) */} {/* Stats */}

{listing.viewCount}

Lượt xem

{listing.saveCount}

Lượt lưu

{listing.inquiryCount}

Liên hệ

{listing.publishedAt && (

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

)}
); } function PersonaFitCard({ listing, score, pois, }: { listing: ListingDetail; score: NeighborhoodScoreResult | null; pois: POIItem[]; }) { const adminPicks = listing.property.suitableFor ?? []; const adminNarrative = listing.property.whyThisLocation?.trim() || null; // Derive personas purely from signals — then prepend admin picks, de-duping // against derived labels so we never double up. const derived = React.useMemo( () => derivePersonas(listing, score, pois), [listing, score, pois], ); const derivedNarrative = React.useMemo( () => composeWhyThisLocation(listing, score, pois), [listing, score, pois], ); // Admin narrative wins when present — that's the authoritative version. const narrative = adminNarrative ?? derivedNarrative; // Merge: admin picks first (each shown as "admin-chosen"), then derived // personas whose labels aren't already in the admin picks. const derivedFiltered = derived.filter((d) => !adminPicks.includes(d.label)); // Only render when we have something meaningful to say. 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}

)}
); } 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}

); }