'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) => (
))}
)}
{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 (
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
);
}