feat: add unit tests for featured listings, neighborhood scores + price history chart

- Add unit tests for FeatureListingHandler (6 tests) and ActivateFeaturedListingHandler (6 tests)
- Add unit tests for NeighborhoodScoreServiceImpl (5 tests) and GetNeighborhoodScoreHandler (2 tests)
- Add PriceHistoryChart component with recharts LineChart for listing detail page
- Wire up price history API client and integrate chart into listing detail view

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 18:21:44 +07:00
parent 0dda2bffdb
commit 8e9d021465
7 changed files with 560 additions and 14 deletions

View File

@@ -6,13 +6,14 @@ import * as React from 'react';
import { AddToCompareButton } from '@/components/comparison/add-to-compare-button';
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 { formatPrice, formatPricePerM2 } from '@/lib/currency';
import type { ListingDetail, NeighborhoodScoreResult } from '@/lib/listings-api';
import type { ListingDetail, NeighborhoodScoreResult, PriceHistoryItem } from '@/lib/listings-api';
import { listingsApi } from '@/lib/listings-api';
import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings';
@@ -59,6 +60,7 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
const propertyTypeLabel = getLabel(PROPERTY_TYPES, property.propertyType);
const [inquiryOpen, setInquiryOpen] = React.useState(false);
const [neighborhoodScore, setNeighborhoodScore] = React.useState<NeighborhoodScoreResult | null>(null);
const [priceHistory, setPriceHistory] = React.useState<PriceHistoryItem[]>([]);
React.useEffect(() => {
if (!property.district || !property.city) return;
@@ -68,6 +70,13 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
.catch(() => {/* silently ignore — section simply won't render */});
}, [property.district, property.city]);
React.useEffect(() => {
listingsApi
.getPriceHistory(listing.id)
.then(setPriceHistory)
.catch(() => {/* silently ignore */});
}, [listing.id]);
return (
<div className="mx-auto max-w-6xl px-4 py-6">
{/* Breadcrumb */}
@@ -201,26 +210,55 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
</CardContent>
</Card>
{/* Neighborhood Score Radar Chart */}
{neighborhoodScore && (
{/* Price History Chart */}
{priceHistory.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Đánh giá khu vực</CardTitle>
<CardTitle>Lịch sử giá</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}
/>
<PriceHistoryChart data={priceHistory} />
</CardContent>
</Card>
)}
{/* Neighborhood Score Radar Chart */}
<Card>
<CardHeader>
<CardTitle>Đánh giá khu vực</CardTitle>
</CardHeader>
<CardContent>
{neighborhoodScore ? (
<>
<div className="mb-3 flex items-center gap-2">
<Badge
variant={
neighborhoodScore.totalScore > 7
? 'success'
: neighborhoodScore.totalScore >= 5
? 'warning'
: 'destructive'
}
className="px-3 py-1 text-lg font-bold"
>
{neighborhoodScore.totalScore.toFixed(1)}/10
</Badge>
<span className="text-sm text-muted-foreground">Điểm tổng khu vực</span>
</div>
<NeighborhoodRadarChart
categories={mapScoreToCategories(neighborhoodScore)}
height={300}
/>
</>
) : (
<div className="flex h-[200px] items-center justify-center rounded-lg bg-muted/50">
<p className="text-sm text-muted-foreground">
Chưa dữ liệu đánh giá khu vực này
</p>
</div>
)}
</CardContent>
</Card>
</div>
{/* Sidebar */}

View File

@@ -0,0 +1,73 @@
'use client';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import type { PriceHistoryItem } from '@/lib/listings-api';
interface PriceHistoryChartProps {
data: PriceHistoryItem[];
height?: number;
}
function priceToMillions(priceStr: string): number {
return Math.round(Number(priceStr) / 1_000_000);
}
function formatMillions(value: number): string {
if (value >= 1000) return `${(value / 1000).toFixed(1)} tỷ`;
return `${value} tr`;
}
export function PriceHistoryChart({ data, height = 280 }: PriceHistoryChartProps) {
if (data.length === 0) return null;
const chartData = [...data]
.sort((a, b) => new Date(a.changedAt).getTime() - new Date(b.changedAt).getTime())
.map((item) => ({
date: new Date(item.changedAt).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}),
price: priceToMillions(item.newPrice),
}));
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="date" tick={{ fontSize: 11 }} className="fill-muted-foreground" />
<YAxis
tick={{ fontSize: 11 }}
className="fill-muted-foreground"
tickFormatter={(v: number) => formatMillions(v)}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '0.5rem',
fontSize: '0.875rem',
}}
formatter={(value) => [formatMillions(Number(value)), 'Giá']}
/>
<Line
type="monotone"
dataKey="price"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
);
}