feat(web): integrate neighborhood radar chart into listing detail page

Add NeighborhoodRadarChart to listing detail view, fetching scores
from the analytics API based on the listing's district and city.
Displays a 6-axis radar chart (education, healthcare, transport,
shopping, environment, safety) with overall score and color-coded
badges.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 17:05:26 +07:00
parent 24a2fd1369
commit 8592fb436c
2 changed files with 67 additions and 1 deletions

View File

@@ -12,9 +12,15 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AiEstimateButton } from '@/components/valuation/ai-estimate-button'; import { AiEstimateButton } from '@/components/valuation/ai-estimate-button';
import { formatPrice, formatPricePerM2 } from '@/lib/currency'; import { formatPrice, formatPricePerM2 } from '@/lib/currency';
import type { ListingDetail } from '@/lib/listings-api'; import type { ListingDetail, NeighborhoodScoreResult } from '@/lib/listings-api';
import { listingsApi } from '@/lib/listings-api';
import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings'; import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings';
const NeighborhoodRadarChart = dynamic(
() => import('@/components/neighborhood').then((m) => m.NeighborhoodRadarChart),
{ ssr: false },
);
const ListingMap = dynamic( const ListingMap = dynamic(
() => import('@/components/map/listing-map').then((mod) => mod.ListingMap), () => import('@/components/map/listing-map').then((mod) => mod.ListingMap),
{ {
@@ -36,11 +42,31 @@ interface ListingDetailClientProps {
listing: ListingDetail; 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) { export function ListingDetailClient({ listing }: ListingDetailClientProps) {
const { property, seller, agent } = listing; const { property, seller, agent } = listing;
const transactionLabel = getLabel(TRANSACTION_TYPES, listing.transactionType); const transactionLabel = getLabel(TRANSACTION_TYPES, listing.transactionType);
const propertyTypeLabel = getLabel(PROPERTY_TYPES, property.propertyType); const propertyTypeLabel = getLabel(PROPERTY_TYPES, property.propertyType);
const [inquiryOpen, setInquiryOpen] = React.useState(false); const [inquiryOpen, setInquiryOpen] = React.useState(false);
const [neighborhoodScore, setNeighborhoodScore] = React.useState<NeighborhoodScoreResult | null>(null);
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]);
return ( return (
<div className="mx-auto max-w-6xl px-4 py-6"> <div className="mx-auto max-w-6xl px-4 py-6">
@@ -174,6 +200,27 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
/> />
</CardContent> </CardContent>
</Card> </Card>
{/* Neighborhood Score Radar Chart */}
{neighborhoodScore && (
<Card>
<CardHeader>
<CardTitle>Đánh giá khu vực</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-3 flex items-center gap-2">
<span className="text-2xl font-bold text-primary">
{neighborhoodScore.totalScore.toFixed(1)}
</span>
<span className="text-sm text-muted-foreground">/10 điểm tổng</span>
</div>
<NeighborhoodRadarChart
categories={mapScoreToCategories(neighborhoodScore)}
height={300}
/>
</CardContent>
</Card>
)}
</div> </div>
{/* Sidebar */} {/* Sidebar */}

View File

@@ -132,6 +132,20 @@ export interface SearchListingsParams {
limit?: number; limit?: number;
} }
export interface NeighborhoodScoreResult {
district: string;
city: string;
educationScore: number;
healthcareScore: number;
transportScore: number;
shoppingScore: number;
greeneryScore: number;
safetyScore: number;
totalScore: number;
poiCounts: Record<string, number>;
calculatedAt: string;
}
// ─── API Functions ─────────────────────────────────────── // ─── API Functions ───────────────────────────────────────
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1'; const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
@@ -188,4 +202,9 @@ export const listingsApi = {
return res.json() as Promise<{ mediaId: string; url: string }>; return res.json() as Promise<{ mediaId: string; url: string }>;
}, },
getNeighborhoodScore: (district: string, city: string = 'Hồ Chí Minh') =>
apiClient.get<NeighborhoodScoreResult>(
`/analytics/neighborhoods/${encodeURIComponent(district)}/score?city=${encodeURIComponent(city)}`,
),
}; };