/** * Server-side project data fetching for Next.js Server Components. * * Uses `fetch` directly (no browser-only helpers) so it can run * inside `generateMetadata`, `generateStaticParams`, `sitemap()`, etc. */ import type { ProjectDetail, ProjectMedia, ProjectSummary, PaginatedResult, } from './du-an-api'; const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1'; /** * The backend detail endpoint currently returns a thin projection: the * repository's raw row run through `shapeProject`, which strips `media` and * omits the richer fields (`blocks`, `amenities`, `priceRanges`, …) that the * frontend detail page consumes. * * Until the backend detail endpoint is enriched, normalize the payload here * so the UI never crashes on missing arrays or mis-keyed developer fields. */ function normalizeProjectDetail(raw: unknown): ProjectDetail | null { if (!raw || typeof raw !== 'object') return null; const r = raw as Record; // Developer: backend currently returns `{ id, name, logo }`; frontend expects // `{ id, name, logoUrl, totalProjects }`. const rawDeveloper = (r['developer'] ?? {}) as Record; const developer = { id: typeof rawDeveloper['id'] === 'string' ? rawDeveloper['id'] : '', name: typeof rawDeveloper['name'] === 'string' ? rawDeveloper['name'] : '', logoUrl: (rawDeveloper['logoUrl'] as string | null | undefined) ?? (rawDeveloper['logo'] as string | null | undefined) ?? null, totalProjects: typeof rawDeveloper['totalProjects'] === 'number' ? rawDeveloper['totalProjects'] : 0, }; // Media: may be absent (stripped by backend), or present as a raw JSON array. // Backend returns media as either `string[]` (raw URLs from the seed JSON // column) or `{ id, url, type, order, caption }[]` once richer data lands. // Handle both shapes and drop entries without a resolvable URL so we never // feed an empty string to ``. const rawMediaArr = Array.isArray(r['media']) ? (r['media'] as unknown[]) : []; const media: ProjectMedia[] = rawMediaArr .map((entry, idx): ProjectMedia | null => { if (typeof entry === 'string') { if (!entry) return null; return { id: `media-${idx}`, url: entry, type: 'image', order: idx, caption: null }; } if (entry && typeof entry === 'object') { const m = entry as Record; const url = typeof m['url'] === 'string' ? m['url'] : ''; if (!url) return null; return { id: typeof m['id'] === 'string' ? m['id'] : `media-${idx}`, url, type: m['type'] === 'video' || m['type'] === 'master_plan' || m['type'] === 'document' || m['type'] === 'image' ? (m['type'] as ProjectMedia['type']) : 'image', order: typeof m['order'] === 'number' ? m['order'] : idx, caption: typeof m['caption'] === 'string' ? m['caption'] : null, }; } return null; }) .filter((m): m is ProjectMedia => m !== null); const asArray = (v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []); // Amenities in the DB are a JSON string[]; the frontend type expects // `{ id, name, icon, category }`. Normalize strings into objects so the // AmenitiesTab render has stable keys + a displayable name. const rawAmenities = Array.isArray(r['amenities']) ? (r['amenities'] as unknown[]) : []; const amenities = rawAmenities.map((a, idx) => { if (typeof a === 'string') { return { id: `amenity-${idx}`, name: a, icon: '', category: 'Tiện ích' }; } if (a && typeof a === 'object') { const o = a as Record; return { id: typeof o['id'] === 'string' ? o['id'] : `amenity-${idx}`, name: typeof o['name'] === 'string' ? o['name'] : '', icon: typeof o['icon'] === 'string' ? o['icon'] : '', category: typeof o['category'] === 'string' ? o['category'] : 'Tiện ích', }; } return { id: `amenity-${idx}`, name: '', icon: '', category: 'Tiện ích' }; }); return { id: String(r['id'] ?? ''), slug: String(r['slug'] ?? ''), name: String(r['name'] ?? ''), status: (r['status'] as ProjectDetail['status']) ?? 'PLANNING', developer, city: String(r['city'] ?? ''), district: String(r['district'] ?? ''), address: String(r['address'] ?? ''), latitude: typeof r['latitude'] === 'number' ? (r['latitude'] as number) : null, longitude: typeof r['longitude'] === 'number' ? (r['longitude'] as number) : null, thumbnailUrl: (r['thumbnailUrl'] as string | null | undefined) ?? media[0]?.url ?? null, totalArea: typeof r['totalArea'] === 'number' ? (r['totalArea'] as number) : 0, totalUnits: typeof r['totalUnits'] === 'number' ? (r['totalUnits'] as number) : 0, propertyTypes: asArray(r['propertyTypes']), minPrice: (r['minPrice'] as string | null | undefined) ?? null, maxPrice: (r['maxPrice'] as string | null | undefined) ?? null, completionDate: (r['completionDate'] as string | null | undefined) ?? null, createdAt: String(r['createdAt'] ?? new Date().toISOString()), description: typeof r['description'] === 'string' ? (r['description'] as string) : '', media, blocks: asArray(r['blocks']), amenities, priceRanges: asArray(r['priceRanges']), priceHistory: asArray(r['priceHistory']), neighborhoodScores: asArray(r['neighborhoodScores']), pois: asArray(r['pois']), documents: asArray(r['documents']), linkedListingCount: typeof r['linkedListingCount'] === 'number' ? (r['linkedListingCount'] as number) : 0, suitableFor: Array.isArray(r['suitableFor']) ? (r['suitableFor'] as unknown[]).filter((v): v is string => typeof v === 'string') : [], whyThisLocation: typeof r['whyThisLocation'] === 'string' ? (r['whyThisLocation'] as string) : null, }; } /** * Fetch a single project by slug — server-only. * Returns `null` when the project is not found (404) so callers can `notFound()`. */ export async function fetchProjectBySlug(slug: string): Promise { try { const res = await fetch(`${API_BASE_URL}/projects/${slug}`, { next: { revalidate: 300 }, // ISR: re-validate every 5 min }); if (!res.ok) return null; const raw = (await res.json()) as unknown; return normalizeProjectDetail(raw); } catch { return null; } } /** * Fetch active projects — server-only, used by the dynamic sitemap. */ export async function fetchProjects(params: { page?: number; limit?: number; city?: string; status?: string; }): Promise> { const query = new URLSearchParams({ page: String(params.page ?? 1), limit: String(params.limit ?? 100), }); if (params.city) query.append('city', params.city); if (params.status) query.append('status', params.status); try { const res = await fetch(`${API_BASE_URL}/projects?${query}`, { next: { revalidate: 3600 }, // re-validate every hour for sitemap }); if (!res.ok) { return { data: [], total: 0, page: 1, limit: 100, totalPages: 0 }; } return (await res.json()) as PaginatedResult; } catch { return { data: [], total: 0, page: 1, limit: 100, totalPages: 0 }; } }