import { apiClient } from './api-client'; // ─── Enums ─────────────────────────────────────────────── export type TransactionType = 'SALE' | 'RENT'; export type PropertyType = 'APARTMENT' | 'HOUSE' | 'VILLA' | 'LAND' | 'OFFICE' | 'SHOPHOUSE'; export type ListingStatus = | 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' | 'RESERVED' | 'SOLD' | 'RENTED' | 'EXPIRED' | 'REJECTED'; export type Direction = | 'NORTH' | 'SOUTH' | 'EAST' | 'WEST' | 'NORTHEAST' | 'NORTHWEST' | 'SOUTHEAST' | 'SOUTHWEST'; export type Furnishing = 'FULLY_FURNISHED' | 'BASIC_FURNISHED' | 'UNFURNISHED'; export type PropertyCondition = 'NEW' | 'LIKE_NEW' | 'RENOVATED' | 'USED'; export type LegalStatus = 'SO_DO' | 'SO_HONG' | 'LAND_USE_RIGHT' | 'JOINT_USE_RIGHT' | 'AWAITING' | 'NO_CERTIFICATE'; export type FlagReason = 'SCAM' | 'DUPLICATE' | 'WRONG_INFO' | 'ALREADY_SOLD' | 'INAPPROPRIATE'; export interface ReportListingResult { flagId: string; listingId: string; totalReports: number; autoFlagged: boolean; } // ─── Interfaces ────────────────────────────────────────── export interface PropertyMedia { id: string; url: string; type: 'image' | 'video'; order: number; caption: string | null; } // ─── Enrichment types ──────────────────────────────────── export interface ValuationEstimate { value: string; confidence: number; modelVersion: string; estimatedAt: string; } export interface AgentQualityScore { score: number; tier: 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINUM'; } export interface ListingSimilarItem { id: string; title: string; priceVND: string; areaM2: number; district: string; thumbnailUrl: string | null; publishedAt: string | null; } // ─── Main types ─────────────────────────────────────────── export interface ListingDetail { id: string; status: ListingStatus; transactionType: TransactionType; priceVND: string; pricePerM2: number | null; rentPriceMonthly: string | null; commissionPct: number | null; viewCount: number; saveCount: number; inquiryCount: number | null; publishedAt: string | null; createdAt: string; /** AVM valuation estimate — null when service is unavailable */ valuationEstimate: ValuationEstimate | null; /** Agent quality score — null when no agent is assigned */ agentQualityScore: AgentQualityScore | null; /** Count of active comparable listings */ similarCount: number; property: { id: string; propertyType: PropertyType; title: string; description: string; address: string; ward: string; district: string; city: string; areaM2: number; usableAreaM2: number | null; bedrooms: number | null; bathrooms: number | null; floors: number | null; floor: number | null; totalFloors: number | null; direction: Direction | null; yearBuilt: number | null; legalStatus: LegalStatus | null; certificateVerified: boolean; amenities: string[] | null; nearbyPOIs: unknown; metroDistanceM: number | null; projectName: string | null; latitude: number | null; longitude: number | null; furnishing: Furnishing | null; propertyCondition: PropertyCondition | null; balconyDirection: Direction | null; maintenanceFeeVND: string | null; parkingSlots: number | null; viewType: string[]; petFriendly: boolean | null; suitableFor: string[]; whyThisLocation: string | null; media: PropertyMedia[]; thumbnail?: string | null; }; seller: { id: string; fullName: string; phone: string; }; agent: { id: string; userId: string; agency: string | null; } | null; } export interface PaginatedResult { data: T[]; total: number; page: number; limit: number; totalPages: number; } export interface CreateListingPayload { transactionType: TransactionType; priceVND: string; propertyType: PropertyType; title: string; description: string; address: string; ward: string; district: string; city: string; latitude: number; longitude: number; areaM2: number; usableAreaM2?: number; bedrooms?: number; bathrooms?: number; floors?: number; floor?: number; totalFloors?: number; direction?: Direction; yearBuilt?: number; legalStatus?: string; amenities?: string[]; projectName?: string; rentPriceMonthly?: string; commissionPct?: number; // Rich property fields (Phase B) furnishing?: Furnishing; propertyCondition?: PropertyCondition; balconyDirection?: Direction; maintenanceFeeVND?: string; parkingSlots?: number; viewType?: string[]; petFriendly?: boolean; suitableFor?: string[]; whyThisLocation?: string; } export interface SearchListingsParams { status?: ListingStatus; transactionType?: TransactionType; propertyType?: PropertyType; city?: string; district?: string; ward?: string; minPrice?: string; maxPrice?: string; minArea?: number; maxArea?: number; bedrooms?: number; /** Filter by assigned agent ID */ agentId?: string; /** Server-side sort column */ sortBy?: string; /** Sort direction */ order?: 'asc' | 'desc'; /** Free-text search query */ q?: string; page?: number; limit?: number; } export interface PriceHistoryItem { id: string; oldPrice: string; newPrice: string; source: string; changedAt: string; } export interface NeighborhoodScoreResult { district: string; city: string; educationScore: number; healthcareScore: number; transportScore: number; shoppingScore: number; greeneryScore: number; safetyScore: number; totalScore: number; poiCounts: Record; calculatedAt: string; } // ─── API Functions ─────────────────────────────────────── const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface UpdateListingPayload extends Partial {} export const listingsApi = { create: (data: CreateListingPayload) => apiClient.post<{ listingId: string; propertyId: string; status: string }>( '/listings', data, ), update: (id: string, data: UpdateListingPayload) => apiClient.patch<{ listingId: string; propertyId: string; status: string }>( `/listings/${id}`, data, ), delete: (id: string) => apiClient.delete<{ success: boolean }>(`/listings/${id}`), getById: (id: string) => apiClient.get(`/listings/${id}`), search: (params: SearchListingsParams = {}) => { const query = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== '') query.append(key, String(value)); }); const qs = query.toString(); return apiClient.get>(`/listings${qs ? `?${qs}` : ''}`); }, updateStatus: (id: string, status: ListingStatus, moderationNotes?: string) => apiClient.patch<{ status: string }>(`/listings/${id}/status`, { status, moderationNotes, }), uploadMedia: async (listingId: string, file: File, caption?: string) => { const formData = new FormData(); formData.append('file', file); if (caption) formData.append('caption', caption); const csrfToken = typeof document !== 'undefined' ? document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/)?.[1] : undefined; const headers: HeadersInit = {}; if (csrfToken) { headers['X-CSRF-Token'] = decodeURIComponent(csrfToken); } const res = await fetch(`${API_BASE_URL}/listings/${listingId}/media`, { method: 'POST', credentials: 'include', headers, body: formData, }); if (!res.ok) { const error = await res.json().catch(() => ({ message: res.statusText })); throw new Error(error.message || 'Upload failed'); } return res.json() as Promise<{ mediaId: string; url: string }>; }, getPriceHistory: (listingId: string) => apiClient.get(`/listings/${listingId}/price-history`), getSimilar: (listingId: string, limit = 5) => apiClient.get(`/listings/${listingId}/similar?limit=${limit}`), getNeighborhoodScore: (district: string, city: string = 'Hồ Chí Minh') => apiClient .get<{ data: NeighborhoodScoreResult }>( `/analytics/neighborhoods/${encodeURIComponent(district)}/score?city=${encodeURIComponent(city)}`, ) .then((res) => res.data), reportListing: (listingId: string, reason: FlagReason, description?: string) => apiClient.post(`/listings/${listingId}/report`, { reason, description }), };