import { apiClient } from './api-client'; /** * Backend `/analytics/*` endpoints are wrapped by `CacheMetaInterceptor`, * returning `{ data: T, cacheMeta: {...} }`. This helper unwraps so callers * receive plain `T` (the cacheMeta field is currently unused on the client). */ interface CacheMetaEnvelope { data: T; cacheMeta: { cachedAt: string | null; nextRefreshAt: string | null; source: string }; } async function unwrap(p: Promise | T>): Promise { const raw = await p; if (raw && typeof raw === 'object' && 'data' in raw && 'cacheMeta' in raw) { return (raw as CacheMetaEnvelope).data; } return raw as T; } export interface MarketReportDistrict { district: string; city: string; propertyType: string; period: string; medianPrice: string; avgPriceM2: number; totalListings: number; daysOnMarket: number; inventoryLevel: number; absorptionRate: number | null; yoyChange: number | null; } export interface MarketReportResponse { city: string; period: string; districts: MarketReportDistrict[]; } export interface HeatmapDataPoint { district: string; city: string; avgPriceM2: number; totalListings: number; medianPrice: string; } export interface HeatmapResponse { city: string; period: string; dataPoints: HeatmapDataPoint[]; } export interface PriceTrendPoint { period: string; medianPrice: string; avgPriceM2: number; totalListings: number; } export interface PriceTrendResponse { district: string; city: string; propertyType: string; trend: PriceTrendPoint[]; } export interface DistrictStats { district: string; city: string; propertyType: string; medianPrice: string; avgPriceM2: number; totalListings: number; daysOnMarket: number; inventoryLevel: number; absorptionRate: number | null; yoyChange: number | null; } export interface DistrictStatsResponse { city: string; period: string; 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 type AiConfidence = 'low' | 'medium' | 'high'; export interface ListingAiValuation { estimateVND: number; lowVND: number; highVND: number; confidence: AiConfidence; rationale: string; } export interface ListingAiAdviceBody { summary: string; pros: string[]; cons: string[]; suitableFor: string[]; } export interface ListingAiAdvice { valuation: ListingAiValuation; advice: ListingAiAdviceBody; model: string; cacheHit: boolean; cacheUsage?: { input: number; cacheCreation: number; cacheRead: number; output: number; }; } /** Project AI advice — same advice block as listings but no valuation. */ export interface ProjectAiAdvice { advice: ListingAiAdviceBody; model: string; cacheHit: boolean; cacheUsage?: { input: number; cacheCreation: number; cacheRead: number; output: number; }; } /* ------------------------------------------------------------------ */ /* Market Snapshot */ /* ------------------------------------------------------------------ */ export interface PriceChangePct { d1: number; d7: number; d30: number; } export interface MarketSnapshotResponse { city: string; propertyType?: string; activeCount: number; avgPrice: number; medianPrice: number; priceChangePct: PriceChangePct; avgPricePerM2: number; daysOnMarket: number; newListings24h: number; cachedAt: string | null; nextRefreshAt: string | null; } /* ------------------------------------------------------------------ */ /* Price Movers */ /* ------------------------------------------------------------------ */ export interface PriceMoverItem { districtId: string; name: string; currentAvgPrice: number; previousAvgPrice: number; changePct: number; sampleSize: number; } export interface PriceMoversResponse { direction: 'up' | 'down'; period: string; level: string; limit: number; movers: PriceMoverItem[]; } /* ------------------------------------------------------------------ */ /* Trending Areas */ /* ------------------------------------------------------------------ */ export interface TrendingAreaItem { districtId: string; name: string; listings: number; inquiries: number; views: number; priceChangePct: number | null; scoreRank: number; } export interface TrendingAreasResponse { period: number; level: string; limit: number; areas: TrendingAreaItem[]; } export const analyticsApi = { // All /analytics/* endpoints are wrapped by the backend CacheMetaInterceptor. // We unwrap the `{ data, cacheMeta }` envelope so callers get the plain DTO. getMarketReport: (city: string, period: string, propertyType?: string) => { const params = new URLSearchParams({ city, period }); if (propertyType) params.set('propertyType', propertyType); return unwrap( apiClient.get>( `/analytics/market-report?${params}`, ), ); }, getHeatmap: (city: string, period: string) => { const params = new URLSearchParams({ city, period }); return unwrap( apiClient.get>(`/analytics/heatmap?${params}`), ); }, getPriceTrend: (district: string, city: string, propertyType: string, periods: string[]) => { const params = new URLSearchParams({ district, city, propertyType, periods: periods.join(',') }); return unwrap( apiClient.get>( `/analytics/price-trend?${params}`, ), ); }, getDistrictStats: (city: string, period: string) => { const params = new URLSearchParams({ city, period }); return unwrap( apiClient.get>( `/analytics/district-stats?${params}`, ), ); }, getNearbyPOIs: (lat: number, lng: number, radius = 2000, limit = 30) => unwrap( apiClient.get>( `/analytics/pois/nearby?lat=${lat}&lng=${lng}&radius=${radius}&limit=${limit}`, ), ), getListingAiAdvice: (listingId: string) => unwrap( apiClient.post>( `/analytics/listings/${listingId}/ai-advice`, ), ), getProjectAiAdvice: (projectId: string) => unwrap( apiClient.post>( `/analytics/projects/${projectId}/ai-advice`, ), ), getMarketSnapshot: (city: string, propertyType?: string) => { const params = new URLSearchParams({ city }); if (propertyType) params.set('propertyType', propertyType); return unwrap( apiClient.get>( `/analytics/market-snapshot?${params}`, ), ); }, getPriceMovers: (direction: 'up' | 'down', period = '7d', limit = 5) => { const params = new URLSearchParams({ direction, period, limit: String(limit) }); return unwrap( apiClient.get>( `/analytics/price-movers?${params}`, ), ); }, getTrendingAreas: (period = 7, limit = 10) => { const params = new URLSearchParams({ period: String(period), limit: String(limit) }); return unwrap( apiClient.get>( `/analytics/trending-areas?${params}`, ), ); }, };