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:
@@ -12,9 +12,15 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AiEstimateButton } from '@/components/valuation/ai-estimate-button';
|
||||
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';
|
||||
|
||||
const NeighborhoodRadarChart = dynamic(
|
||||
() => import('@/components/neighborhood').then((m) => m.NeighborhoodRadarChart),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const ListingMap = dynamic(
|
||||
() => import('@/components/map/listing-map').then((mod) => mod.ListingMap),
|
||||
{
|
||||
@@ -36,11 +42,31 @@ 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<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 (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
@@ -174,6 +200,27 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
|
||||
/>
|
||||
</CardContent>
|
||||
</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>
|
||||
|
||||
{/* Sidebar */}
|
||||
|
||||
@@ -132,6 +132,20 @@ export interface SearchListingsParams {
|
||||
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 ───────────────────────────────────────
|
||||
|
||||
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 }>;
|
||||
},
|
||||
|
||||
getNeighborhoodScore: (district: string, city: string = 'Hồ Chí Minh') =>
|
||||
apiClient.get<NeighborhoodScoreResult>(
|
||||
`/analytics/neighborhoods/${encodeURIComponent(district)}/score?city=${encodeURIComponent(city)}`,
|
||||
),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user