fix(du-an): stop detail page crash from thin backend payload + client/server flag boundary
Some checks failed
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m28s
Deploy / Build API Image (push) Failing after 26s
Deploy / Build Web Image (push) Failing after 16s
Deploy / Build AI Services Image (push) Failing after 11s
E2E Tests / Playwright E2E (push) Failing after 23s
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 11s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Has been cancelled
Security Scanning / Trivy Scan — AI Services Image (push) Has been cancelled
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — API Image (push) Has been cancelled
Some checks failed
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m28s
Deploy / Build API Image (push) Failing after 26s
Deploy / Build Web Image (push) Failing after 16s
Deploy / Build AI Services Image (push) Failing after 11s
E2E Tests / Playwright E2E (push) Failing after 23s
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 11s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Has been cancelled
Security Scanning / Trivy Scan — AI Services Image (push) Has been cancelled
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — API Image (push) Has been cancelled
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,16 @@ function shapeProject<T extends RawProjectListItem>(row: T) {
|
||||
};
|
||||
}
|
||||
|
||||
function shapeProjectDetail<T extends RawProjectListItem>(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 ───────────────────────────────────────────────
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
// Developer: backend currently returns `{ id, name, logo }`; frontend expects
|
||||
// `{ id, name, logoUrl, totalProjects }`.
|
||||
const rawDeveloper = (r['developer'] ?? {}) as Record<string, unknown>;
|
||||
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<string, unknown>[]) : [];
|
||||
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 = <T>(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<ProjectDetail['propertyTypes'][number]>(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<ProjectDetail |
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as ProjectDetail;
|
||||
const raw = (await res.json()) as unknown;
|
||||
return normalizeProjectDetail(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
12
apps/web/lib/feature-flags/residential-projects.ts
Normal file
12
apps/web/lib/feature-flags/residential-projects.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Server-side feature flag for the residential projects (Dự án) module.
|
||||
*
|
||||
* Kept in its own module (no `'use client'`) so that Server Components can
|
||||
* import it without Next.js treating it as a client boundary export. The
|
||||
* client-side hook lives in `@/lib/hooks/use-residential-projects-flag`.
|
||||
*/
|
||||
export function isResidentialProjectsEnabledServer(): boolean {
|
||||
const raw = process.env['NEXT_PUBLIC_FEATURE_RESIDENTIAL_PROJECTS'];
|
||||
if (raw == null || raw === '') return true;
|
||||
return !(raw === '0' || raw.toLowerCase() === 'false');
|
||||
}
|
||||
@@ -54,6 +54,5 @@ export function useResidentialProjectsFlag(): boolean {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function isResidentialProjectsEnabledServer(): boolean {
|
||||
return readEnvDefault();
|
||||
}
|
||||
// NOTE: The server-side helper lives in `@/lib/feature-flags/residential-projects`
|
||||
// to avoid making it a client-only export (this file has `'use client'`).
|
||||
|
||||
Reference in New Issue
Block a user