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 { 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 */}
|
||||||
|
|||||||
@@ -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)}`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user