/** * 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. const rawMedia = Array.isArray(r['media']) ? (r['media'] as Record[]) : []; const media: ProjectMedia[] = rawMedia.map((m, idx) => ({ id: typeof m['id'] === 'string' ? m['id'] : `media-${idx}`, url: typeof m['url'] === 'string' ? m['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, })); const asArray = (v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []); return { id: String(r['id'] ?? ''), slug: String(r['slug'] ?? ''), name: String(r['name'] ?? ''), status: (r['status'] as ProjectDetail['status']) ?? 'SELLING', 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: asArray(r['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, }; } /** * 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 }; } }