From 8e9d02146555945efe2041855f9a3a147d9a51d5 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 16 Apr 2026 18:21:44 +0700 Subject: [PATCH] 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 --- .../get-neighborhood-score.handler.spec.ts | 51 +++++++ .../neighborhood-score.service.spec.ts | 132 ++++++++++++++++++ .../activate-featured-listing.handler.spec.ts | 113 +++++++++++++++ .../__tests__/feature-listing.handler.spec.ts | 128 +++++++++++++++++ .../listings/listing-detail-client.tsx | 66 +++++++-- .../listings/price-history-chart.tsx | 73 ++++++++++ apps/web/lib/listings-api.ts | 11 ++ 7 files changed, 560 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/modules/analytics/application/__tests__/get-neighborhood-score.handler.spec.ts create mode 100644 apps/api/src/modules/analytics/infrastructure/__tests__/neighborhood-score.service.spec.ts create mode 100644 apps/api/src/modules/listings/application/__tests__/activate-featured-listing.handler.spec.ts create mode 100644 apps/api/src/modules/listings/application/__tests__/feature-listing.handler.spec.ts create mode 100644 apps/web/components/listings/price-history-chart.tsx diff --git a/apps/api/src/modules/analytics/application/__tests__/get-neighborhood-score.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-neighborhood-score.handler.spec.ts new file mode 100644 index 0000000..7647cd5 --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/get-neighborhood-score.handler.spec.ts @@ -0,0 +1,51 @@ +import { type INeighborhoodScoreService, type NeighborhoodScoreResult } from '../../domain/services/neighborhood-score.service'; +import { GetNeighborhoodScoreHandler } from '../queries/get-neighborhood-score/get-neighborhood-score.handler'; +import { GetNeighborhoodScoreQuery } from '../queries/get-neighborhood-score/get-neighborhood-score.query'; + +const sampleScore: NeighborhoodScoreResult = { + district: 'Quận 1', + city: 'Hồ Chí Minh', + educationScore: 8, + healthcareScore: 7, + transportScore: 9, + shoppingScore: 6, + greeneryScore: 5, + safetyScore: 4, + totalScore: 68.5, + poiCounts: { education: 12, healthcare: 5, transport: 10, shopping: 6, greenery: 3, safety: 2 }, + calculatedAt: new Date(), +}; + +describe('GetNeighborhoodScoreHandler', () => { + let handler: GetNeighborhoodScoreHandler; + let mockService: { [K in keyof INeighborhoodScoreService]: ReturnType }; + + beforeEach(() => { + mockService = { + getScore: vi.fn(), + calculateAndSave: vi.fn(), + }; + handler = new GetNeighborhoodScoreHandler(mockService as any); + }); + + it('returns cached score when available', async () => { + mockService.getScore.mockResolvedValue(sampleScore); + + const result = await handler.execute(new GetNeighborhoodScoreQuery('Quận 1', 'Hồ Chí Minh')); + + expect(result).toEqual(sampleScore); + expect(mockService.getScore).toHaveBeenCalledWith('Quận 1', 'Hồ Chí Minh'); + expect(mockService.calculateAndSave).not.toHaveBeenCalled(); + }); + + it('calculates and saves score when no cached score exists', async () => { + mockService.getScore.mockResolvedValue(null); + mockService.calculateAndSave.mockResolvedValue(sampleScore); + + const result = await handler.execute(new GetNeighborhoodScoreQuery('Quận 2', 'Hồ Chí Minh')); + + expect(result).toEqual(sampleScore); + expect(mockService.getScore).toHaveBeenCalledWith('Quận 2', 'Hồ Chí Minh'); + expect(mockService.calculateAndSave).toHaveBeenCalledWith('Quận 2', 'Hồ Chí Minh'); + }); +}); diff --git a/apps/api/src/modules/analytics/infrastructure/__tests__/neighborhood-score.service.spec.ts b/apps/api/src/modules/analytics/infrastructure/__tests__/neighborhood-score.service.spec.ts new file mode 100644 index 0000000..3fff29d --- /dev/null +++ b/apps/api/src/modules/analytics/infrastructure/__tests__/neighborhood-score.service.spec.ts @@ -0,0 +1,132 @@ +import { NeighborhoodScoreServiceImpl } from '../services/neighborhood-score.service'; + +describe('NeighborhoodScoreServiceImpl', () => { + let service: NeighborhoodScoreServiceImpl; + let mockPrisma: { + neighborhoodScore: { findUnique: ReturnType; upsert: ReturnType }; + pOI: { count: ReturnType }; + }; + let mockLogger: { log: ReturnType }; + + beforeEach(() => { + mockPrisma = { + neighborhoodScore: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + pOI: { count: vi.fn() }, + }; + mockLogger = { log: vi.fn() }; + + service = new NeighborhoodScoreServiceImpl(mockPrisma as any, mockLogger as any); + }); + + describe('getScore', () => { + it('returns existing score from database', async () => { + const stored = { + district: 'Quận 1', + city: 'Hồ Chí Minh', + educationScore: 8, + healthcareScore: 7, + transportScore: 9, + shoppingScore: 6, + greeneryScore: 5, + safetyScore: 4, + totalScore: 68.5, + poiCounts: { education: 12, healthcare: 5 }, + calculatedAt: new Date(), + }; + mockPrisma.neighborhoodScore.findUnique.mockResolvedValue(stored); + + const result = await service.getScore('Quận 1', 'Hồ Chí Minh'); + + expect(result).not.toBeNull(); + expect(result!.district).toBe('Quận 1'); + expect(result!.totalScore).toBe(68.5); + expect(result!.poiCounts).toEqual({ education: 12, healthcare: 5 }); + }); + + it('returns null when no score exists', async () => { + mockPrisma.neighborhoodScore.findUnique.mockResolvedValue(null); + + const result = await service.getScore('Quận 99', 'Hồ Chí Minh'); + + expect(result).toBeNull(); + }); + }); + + describe('calculateAndSave', () => { + it('calculates scores from POI counts and upserts', async () => { + // Simulate POI counts: education=15 (max), healthcare=4 (50%), transport=6 (50%), + // shopping=5 (50%), greenery=3 (50%), safety=2 (50%) + const poiCountsByCategory = [15, 4, 6, 5, 3, 2]; + let callIndex = 0; + mockPrisma.pOI.count.mockImplementation(() => { + return Promise.resolve(poiCountsByCategory[callIndex++]!); + }); + + mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => { + return Promise.resolve(create); + }); + + const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh'); + + // education: 15/15 * 10 = 10 → 10 * 20/10 = 20 + // healthcare: 4/8 * 10 = 5 → 5 * 20/10 = 10 + // transport: 6/12 * 10 = 5 → 5 * 20/10 = 10 + // shopping: 5/10 * 10 = 5 → 5 * 15/10 = 7.5 + // greenery: 3/6 * 10 = 5 → 5 * 15/10 = 7.5 + // safety: 2/4 * 10 = 5 → 5 * 10/10 = 5 + // total = 20 + 10 + 10 + 7.5 + 7.5 + 5 = 60 + expect(result.educationScore).toBe(10); + expect(result.healthcareScore).toBe(5); + expect(result.totalScore).toBe(60); + expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledTimes(1); + }); + + it('caps category scores at 10', async () => { + // All categories have way more POIs than max + mockPrisma.pOI.count.mockResolvedValue(100); + mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => { + return Promise.resolve(create); + }); + + const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh'); + + // All scores capped at 10 → total = sum of weights = 100 + expect(result.educationScore).toBe(10); + expect(result.healthcareScore).toBe(10); + expect(result.transportScore).toBe(10); + expect(result.shoppingScore).toBe(10); + expect(result.greeneryScore).toBe(10); + expect(result.safetyScore).toBe(10); + expect(result.totalScore).toBe(100); + }); + + it('returns 0 scores when no POIs exist', async () => { + mockPrisma.pOI.count.mockResolvedValue(0); + mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => { + return Promise.resolve(create); + }); + + const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh'); + + expect(result.educationScore).toBe(0); + expect(result.totalScore).toBe(0); + }); + + it('logs the calculated score', async () => { + mockPrisma.pOI.count.mockResolvedValue(5); + mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => { + return Promise.resolve(create); + }); + + await service.calculateAndSave('Quận 1', 'Hồ Chí Minh'); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('Quận 1'), + 'NeighborhoodScoreService', + ); + }); + }); +}); diff --git a/apps/api/src/modules/listings/application/__tests__/activate-featured-listing.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/activate-featured-listing.handler.spec.ts new file mode 100644 index 0000000..9c96939 --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/activate-featured-listing.handler.spec.ts @@ -0,0 +1,113 @@ +import { ActivateFeaturedListingHandler } from '../event-handlers/activate-featured-listing.handler'; + +describe('ActivateFeaturedListingHandler', () => { + let handler: ActivateFeaturedListingHandler; + let mockPrisma: { + payment: { findUnique: ReturnType }; + listing: { findUnique: ReturnType; update: ReturnType }; + }; + let mockLogger: { log: ReturnType }; + + beforeEach(() => { + mockPrisma = { + payment: { findUnique: vi.fn() }, + listing: { findUnique: vi.fn(), update: vi.fn() }, + }; + mockLogger = { log: vi.fn() }; + + handler = new ActivateFeaturedListingHandler( + mockPrisma as any, + mockLogger as any, + ); + }); + + it('activates featured listing for 7 days on 199000 VND payment', async () => { + mockPrisma.payment.findUnique.mockResolvedValue({ + type: 'FEATURED_LISTING', + transactionId: 'listing-1', + amountVND: 199000n, + }); + mockPrisma.listing.findUnique.mockResolvedValue({ featuredUntil: null }); + mockPrisma.listing.update.mockResolvedValue({}); + + await handler.handle({ aggregateId: 'pay-1' } as any); + + expect(mockPrisma.listing.update).toHaveBeenCalledWith({ + where: { id: 'listing-1' }, + data: { featuredUntil: expect.any(Date) }, + }); + + const updateCall = mockPrisma.listing.update.mock.calls[0][0]; + const featuredUntil = updateCall.data.featuredUntil as Date; + const diffDays = Math.round((featuredUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); + expect(diffDays).toBe(7); + }); + + it('activates featured listing for 3 days on 99000 VND payment', async () => { + mockPrisma.payment.findUnique.mockResolvedValue({ + type: 'FEATURED_LISTING', + transactionId: 'listing-1', + amountVND: 99000n, + }); + mockPrisma.listing.findUnique.mockResolvedValue({ featuredUntil: null }); + mockPrisma.listing.update.mockResolvedValue({}); + + await handler.handle({ aggregateId: 'pay-1' } as any); + + const updateCall = mockPrisma.listing.update.mock.calls[0][0]; + const featuredUntil = updateCall.data.featuredUntil as Date; + const diffDays = Math.round((featuredUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); + expect(diffDays).toBe(3); + }); + + it('extends from existing featuredUntil if still in the future', async () => { + const futureDate = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000); // 5 days from now + mockPrisma.payment.findUnique.mockResolvedValue({ + type: 'FEATURED_LISTING', + transactionId: 'listing-1', + amountVND: 199000n, + }); + mockPrisma.listing.findUnique.mockResolvedValue({ featuredUntil: futureDate }); + mockPrisma.listing.update.mockResolvedValue({}); + + await handler.handle({ aggregateId: 'pay-1' } as any); + + const updateCall = mockPrisma.listing.update.mock.calls[0][0]; + const featuredUntil = updateCall.data.featuredUntil as Date; + // Should extend from futureDate (5 days out) + 7 days = ~12 days from now + const diffDays = Math.round((featuredUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); + expect(diffDays).toBe(12); + }); + + it('ignores non-FEATURED_LISTING payments', async () => { + mockPrisma.payment.findUnique.mockResolvedValue({ + type: 'SUBSCRIPTION', + transactionId: 'listing-1', + amountVND: 199000n, + }); + + await handler.handle({ aggregateId: 'pay-1' } as any); + + expect(mockPrisma.listing.update).not.toHaveBeenCalled(); + }); + + it('ignores payments without transactionId', async () => { + mockPrisma.payment.findUnique.mockResolvedValue({ + type: 'FEATURED_LISTING', + transactionId: null, + amountVND: 199000n, + }); + + await handler.handle({ aggregateId: 'pay-1' } as any); + + expect(mockPrisma.listing.update).not.toHaveBeenCalled(); + }); + + it('ignores payments that do not exist', async () => { + mockPrisma.payment.findUnique.mockResolvedValue(null); + + await handler.handle({ aggregateId: 'pay-1' } as any); + + expect(mockPrisma.listing.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/listings/application/__tests__/feature-listing.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/feature-listing.handler.spec.ts new file mode 100644 index 0000000..a8dc2bb --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/feature-listing.handler.spec.ts @@ -0,0 +1,128 @@ +import { ListingEntity } from '@modules/listings/domain/entities/listing.entity'; +import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository'; +import { Price } from '@modules/listings/domain/value-objects/price.vo'; +import { FeatureListingCommand } from '../commands/feature-listing/feature-listing.command'; +import { FeatureListingHandler } from '../commands/feature-listing/feature-listing.handler'; + +function createListing( + id = 'listing-1', + sellerId = 'seller-1', + agentId: string | null = null, + status: 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' = 'ACTIVE', +): ListingEntity { + const price = Price.create(2_000_000_000n).unwrap(); + const listing = ListingEntity.createNew(id, 'prop-1', sellerId, 'SALE', price, 80, agentId ?? undefined); + if (status === 'PENDING_REVIEW' || status === 'ACTIVE') listing.submitForReview(); + if (status === 'ACTIVE') listing.approve(); + listing.clearDomainEvents(); + return listing; +} + +describe('FeatureListingHandler', () => { + let handler: FeatureListingHandler; + let mockListingRepo: Pick; + let mockCommandBus: { execute: ReturnType }; + let mockLogger: { log: ReturnType; error: ReturnType }; + + beforeEach(() => { + mockListingRepo = { findById: vi.fn() }; + mockCommandBus = { + execute: vi.fn().mockResolvedValue({ + paymentId: 'pay-1', + paymentUrl: 'https://pay.example.com/checkout', + providerTxId: 'tx-1', + }), + }; + mockLogger = { log: vi.fn(), error: vi.fn() }; + + handler = new FeatureListingHandler( + mockListingRepo as any, + mockCommandBus as any, + mockLogger as any, + ); + }); + + it('creates payment for a valid feature request', async () => { + const listing = createListing('listing-1', 'seller-1', null, 'ACTIVE'); + (mockListingRepo.findById as ReturnType).mockResolvedValue(listing); + + const command = new FeatureListingCommand( + 'listing-1', 'seller-1', '7_days', 'VNPAY', + 'https://goodgo.vn/callback', '127.0.0.1', + ); + const result = await handler.execute(command); + + expect(result.paymentId).toBe('pay-1'); + expect(result.paymentUrl).toBe('https://pay.example.com/checkout'); + expect(result.package_).toBe('7_days'); + expect(result.priceVND).toBe('199000'); + expect(mockCommandBus.execute).toHaveBeenCalledTimes(1); + }); + + it('allows the assigned agent to feature the listing', async () => { + const listing = createListing('listing-1', 'seller-1', 'agent-1', 'ACTIVE'); + (mockListingRepo.findById as ReturnType).mockResolvedValue(listing); + + const command = new FeatureListingCommand( + 'listing-1', 'agent-1', '3_days', 'MOMO', + 'https://goodgo.vn/callback', '127.0.0.1', + ); + const result = await handler.execute(command); + + expect(result.paymentId).toBe('pay-1'); + expect(result.priceVND).toBe('99000'); + }); + + it('rejects feature request from unauthorized user', async () => { + const listing = createListing('listing-1', 'seller-1', null, 'ACTIVE'); + (mockListingRepo.findById as ReturnType).mockResolvedValue(listing); + + const command = new FeatureListingCommand( + 'listing-1', 'stranger', '7_days', 'VNPAY', + 'https://goodgo.vn/callback', '127.0.0.1', + ); + + await expect(handler.execute(command)).rejects.toThrow(/người bán|môi giới/); + }); + + it('rejects feature request for non-ACTIVE listing', async () => { + const listing = createListing('listing-1', 'seller-1', null, 'DRAFT'); + (mockListingRepo.findById as ReturnType).mockResolvedValue(listing); + + const command = new FeatureListingCommand( + 'listing-1', 'seller-1', '7_days', 'VNPAY', + 'https://goodgo.vn/callback', '127.0.0.1', + ); + + await expect(handler.execute(command)).rejects.toThrow(/hoạt động/); + }); + + it('throws NotFoundException for non-existent listing', async () => { + (mockListingRepo.findById as ReturnType).mockResolvedValue(null); + + const command = new FeatureListingCommand( + 'nonexistent', 'seller-1', '7_days', 'VNPAY', + 'https://goodgo.vn/callback', '127.0.0.1', + ); + + await expect(handler.execute(command)).rejects.toThrow('Listing'); + }); + + it('uses correct pricing for each package', async () => { + const listing = createListing('listing-1', 'seller-1', null, 'ACTIVE'); + (mockListingRepo.findById as ReturnType).mockResolvedValue(listing); + + for (const [pkg, expectedPrice] of [ + ['3_days', '99000'], + ['7_days', '199000'], + ['30_days', '499000'], + ] as const) { + const command = new FeatureListingCommand( + 'listing-1', 'seller-1', pkg, 'VNPAY', + 'https://goodgo.vn/callback', '127.0.0.1', + ); + const result = await handler.execute(command); + expect(result.priceVND).toBe(expectedPrice); + } + }); +}); diff --git a/apps/web/components/listings/listing-detail-client.tsx b/apps/web/components/listings/listing-detail-client.tsx index 23a30ed..1dd5658 100644 --- a/apps/web/components/listings/listing-detail-client.tsx +++ b/apps/web/components/listings/listing-detail-client.tsx @@ -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(null); + const [priceHistory, setPriceHistory] = React.useState([]); 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 (
{/* Breadcrumb */} @@ -201,26 +210,55 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) { - {/* Neighborhood Score Radar Chart */} - {neighborhoodScore && ( + {/* Price History Chart */} + {priceHistory.length > 0 && ( - Đánh giá khu vực + Lịch sử giá -
- - {neighborhoodScore.totalScore.toFixed(1)} - - /10 điểm tổng -
- +
)} + + {/* 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 */} diff --git a/apps/web/components/listings/price-history-chart.tsx b/apps/web/components/listings/price-history-chart.tsx new file mode 100644 index 0000000..99cc362 --- /dev/null +++ b/apps/web/components/listings/price-history-chart.tsx @@ -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 ( + + + + + formatMillions(v)} + /> + [formatMillions(Number(value)), 'Giá']} + /> + + + + ); +} diff --git a/apps/web/lib/listings-api.ts b/apps/web/lib/listings-api.ts index 47a0dfc..aaa2eef 100644 --- a/apps/web/lib/listings-api.ts +++ b/apps/web/lib/listings-api.ts @@ -132,6 +132,14 @@ export interface SearchListingsParams { limit?: number; } +export interface PriceHistoryItem { + id: string; + oldPrice: string; + newPrice: string; + source: string; + changedAt: string; +} + export interface NeighborhoodScoreResult { district: string; city: string; @@ -203,6 +211,9 @@ export const listingsApi = { return res.json() as Promise<{ mediaId: string; url: string }>; }, + getPriceHistory: (listingId: string) => + apiClient.get(`/listings/${listingId}/price-history`), + getNeighborhoodScore: (district: string, city: string = 'Hồ Chí Minh') => apiClient.get( `/analytics/neighborhoods/${encodeURIComponent(district)}/score?city=${encodeURIComponent(city)}`,