feat(listings): phase C — nearby POIs on listing detail map
Some checks failed
CI / E2E Tests (push) Has been skipped
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 6s
Security Scanning / Trivy Scan — API Image (push) Failing after 25s
Security Scanning / Trivy Scan — Web Image (push) Failing after 26s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 23s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 56s
Deploy / Build API Image (push) Failing after 18s
Deploy / Build Web Image (push) Failing after 10s
Deploy / Build AI Services Image (push) Failing after 9s
E2E Tests / Playwright E2E (push) Failing after 7s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 2s
Security Scanning / Trivy Filesystem Scan (push) Failing after 26s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped

Backend
-------
- New endpoint GET /analytics/pois/nearby?lat&lng&radius&limit (public,
  no guard). Mirrors the neighborhoods/:district/score shape.
- Prisma $queryRawUnsafe with PostGIS ST_DWithin on POI.location::geography
  and ST_Distance for the ordered-by-distance result. Default radius 2km,
  max 10km; default limit 30, max 100.
- Response maps POIType enum → frontend POICategory so the existing pill
  filter in NeighborhoodPOIMap works out of the box:
    SCHOOL/UNIVERSITY → school
    HOSPITAL/CLINIC/PHARMACY → hospital
    METRO_STATION/BUS_STOP → transit
    MALL/MARKET/SUPERMARKET/BANK/ATM → shopping
    RESTAURANT/CAFE → restaurant
    PARK → park
    else → shopping (fallback, still filterable)
- New files: application/queries/get-nearby-pois/{query,handler}.ts +
  presentation/dto/get-nearby-pois.dto.ts. Registered in analytics.module.ts.

Frontend
--------
- analytics-api.ts: exports NearbyPOI, NearbyPOIsResponse, NearbyPOICategory
  and analyticsApi.getNearbyPOIs(lat, lng, radius?, limit?).
- listing-detail-client.tsx: the "Vị trí trên bản đồ" card no longer
  renders <ListingMap> for a single pin — it now renders
  <NeighborhoodPOIMap> with the property's coords as center, the nearby
  POIs as markers, and the existing category-filter pills. A small
  "Tìm thấy N điểm quan tâm trong bán kính 2 km" summary sits below.
- The neighborhood score radar card remains below, untouched.
- The spec fixture + mocks extended for the new analyticsApi dependency.

No schema change, no migration. Phase C of 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-19 14:48:09 +07:00
parent 6067adc095
commit 08c8b5e027
8 changed files with 266 additions and 7 deletions

View File

@@ -48,6 +48,25 @@ vi.mock('@/components/listings/inquiry-modal', () => ({
InquiryModal: () => null,
}));
// Mock analytics API (used for nearby POIs fetch)
vi.mock('@/lib/analytics-api', () => ({
analyticsApi: {
getNearbyPOIs: vi.fn().mockResolvedValue({ pois: [], center: { lat: 0, lng: 0 } }),
},
}));
// Mock listings API (used for neighborhood score + price history)
vi.mock('@/lib/listings-api', async () => {
const actual = await vi.importActual<typeof import('@/lib/listings-api')>('@/lib/listings-api');
return {
...actual,
listingsApi: {
getNeighborhoodScore: vi.fn().mockResolvedValue(null),
getPriceHistory: vi.fn().mockResolvedValue([]),
},
};
});
// Mock currency
vi.mock('@/lib/currency', () => ({
formatPrice: (price: string) => {

View File

@@ -12,22 +12,25 @@ 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 { analyticsApi } from '@/lib/analytics-api';
import type { NearbyPOI } from '@/lib/analytics-api';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
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';
import type { POIItem } from '@/components/neighborhood';
const NeighborhoodRadarChart = dynamic(
() => import('@/components/neighborhood').then((m) => m.NeighborhoodRadarChart),
{ ssr: false },
);
const ListingMap = dynamic(
() => import('@/components/map/listing-map').then((mod) => mod.ListingMap),
const NeighborhoodPOIMap = dynamic(
() => import('@/components/neighborhood').then((m) => m.NeighborhoodPOIMap),
{
ssr: false,
loading: () => (
<div className="flex h-[300px] items-center justify-center rounded-lg bg-muted">
<div className="flex h-[400px] items-center justify-center rounded-lg bg-muted">
<p className="text-sm text-muted-foreground">{'\u0110ang t\u1ea3i b\u1ea3n \u0111\u1ed3...'}</p>
</div>
),
@@ -61,6 +64,7 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
const [inquiryOpen, setInquiryOpen] = React.useState(false);
const [neighborhoodScore, setNeighborhoodScore] = React.useState<NeighborhoodScoreResult | null>(null);
const [priceHistory, setPriceHistory] = React.useState<PriceHistoryItem[]>([]);
const [nearbyPois, setNearbyPois] = React.useState<POIItem[]>([]);
React.useEffect(() => {
if (!property.district || !property.city) return;
@@ -70,6 +74,25 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
.catch(() => {/* silently ignore — section simply won't render */});
}, [property.district, property.city]);
React.useEffect(() => {
const { latitude, longitude } = property;
if (latitude == null || longitude == null) return;
analyticsApi
.getNearbyPOIs(latitude, longitude)
.then((res) => {
const mapped: POIItem[] = res.pois.map((p: NearbyPOI) => ({
id: p.id,
name: p.name,
category: p.category,
lat: p.lat,
lng: p.lng,
distance: p.distance,
}));
setNearbyPois(mapped);
})
.catch(() => {/* silently ignore — map still renders without POIs */});
}, [property.latitude, property.longitude]);
React.useEffect(() => {
listingsApi
.getPriceHistory(listing.id)
@@ -245,10 +268,24 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
<CardTitle>Vị trí trên bản đ</CardTitle>
</CardHeader>
<CardContent>
<ListingMap
listings={[listing]}
className="h-[300px]"
/>
{property.latitude != null && property.longitude != null ? (
<>
<NeighborhoodPOIMap
center={{ lat: property.latitude, lng: property.longitude }}
pois={nearbyPois}
height="400px"
/>
<p className="mt-3 text-sm text-muted-foreground">
Tìm thấy {nearbyPois.length} điểm quan tâm trong bán kính 2 km
</p>
</>
) : (
<div className="flex h-[300px] items-center justify-center rounded-lg bg-muted">
<p className="text-sm text-muted-foreground">
Chưa tọa đ cho tin đăng này
</p>
</div>
)}
</CardContent>
</Card>

View File

@@ -67,6 +67,30 @@ export interface DistrictStatsResponse {
districts: DistrictStats[];
}
export type NearbyPOICategory =
| 'school'
| 'hospital'
| 'transit'
| 'shopping'
| 'restaurant'
| 'park';
export interface NearbyPOI {
id: string;
name: string;
type: string;
category: NearbyPOICategory;
lat: number;
lng: number;
distance: number;
address: string | null;
}
export interface NearbyPOIsResponse {
pois: NearbyPOI[];
center: { lat: number; lng: number };
}
export const analyticsApi = {
getMarketReport: (city: string, period: string, propertyType?: string) => {
const params = new URLSearchParams({ city, period });
@@ -88,4 +112,9 @@ export const analyticsApi = {
const params = new URLSearchParams({ city, period });
return apiClient.get<DistrictStatsResponse>(`/analytics/district-stats?${params}`);
},
getNearbyPOIs: (lat: number, lng: number, radius = 2000, limit = 30) =>
apiClient.get<NearbyPOIsResponse>(
`/analytics/pois/nearby?lat=${lat}&lng=${lng}&radius=${radius}&limit=${limit}`,
),
};