From 6ff039db1e05e22053a62668bd340ba8150e31d0 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sun, 19 Apr 2026 10:11:06 +0700 Subject: [PATCH] fix(du-an): stop detail page crash from thin backend payload + client/server flag boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split `isResidentialProjectsEnabledServer` out of the `'use client'` hook file into `lib/feature-flags/residential-projects.ts` so Server Components can import it without Next.js treating it as a client ref. - Detail endpoint preserves `media` via new `shapeProjectDetail` instead of stripping it in `shapeProject`. - `fetchProjectBySlug` now normalizes the response: fills missing arrays (media, blocks, amenities, priceRanges, priceHistory, neighborhoodScores, pois, documents) with `[]`, remaps `developer.logo` → `logoUrl`, defaults `totalProjects` to 0. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controllers/projects.controller.ts | 12 ++- .../[locale]/(public)/du-an/[slug]/page.tsx | 2 +- apps/web/lib/du-an-server.ts | 91 ++++++++++++++++++- .../lib/feature-flags/residential-projects.ts | 12 +++ .../hooks/use-residential-projects-flag.ts | 5 +- 5 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 apps/web/lib/feature-flags/residential-projects.ts diff --git a/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts b/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts index 7269e9c..b2cc5cb 100644 --- a/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts +++ b/apps/api/src/modules/projects/presentation/controllers/projects.controller.ts @@ -35,6 +35,16 @@ function shapeProject(row: T) { }; } +function shapeProjectDetail(row: T) { + // Detail endpoint: preserve `media` so the gallery on the frontend can + // render, and keep the same shape as the list response for everything else. + const shaped = shapeProject(row); + return { + ...shaped, + media: Array.isArray(row.media) ? row.media : [], + }; +} + @ApiTags('projects') @Controller('projects') export class ProjectsController { @@ -78,7 +88,7 @@ export class ProjectsController { if (!result) { throw new NotFoundException('Dự án', slugOrId); } - return shapeProject(result); + return shapeProjectDetail(result); } // ── Admin endpoints ─────────────────────────────────────────────── diff --git a/apps/web/app/[locale]/(public)/du-an/[slug]/page.tsx b/apps/web/app/[locale]/(public)/du-an/[slug]/page.tsx index 2830aea..27d69c7 100644 --- a/apps/web/app/[locale]/(public)/du-an/[slug]/page.tsx +++ b/apps/web/app/[locale]/(public)/du-an/[slug]/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { DuAnDetailClient } from '@/components/du-an/du-an-detail-client'; import { fetchProjectBySlug } from '@/lib/du-an-server'; -import { isResidentialProjectsEnabledServer } from '@/lib/hooks/use-residential-projects-flag'; +import { isResidentialProjectsEnabledServer } from '@/lib/feature-flags/residential-projects'; interface PageProps { params: Promise<{ slug: string; locale: string }>; diff --git a/apps/web/lib/du-an-server.ts b/apps/web/lib/du-an-server.ts index e717652..05fc061 100644 --- a/apps/web/lib/du-an-server.ts +++ b/apps/web/lib/du-an-server.ts @@ -5,10 +5,96 @@ * inside `generateMetadata`, `generateStaticParams`, `sitemap()`, etc. */ -import type { ProjectDetail, ProjectSummary, PaginatedResult } from './du-an-api'; +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()`. @@ -20,7 +106,8 @@ export async function fetchProjectBySlug(slug: string): Promise