feat(listings): phase A — surface usableAreaM2, floor/totalFloors, metroDistanceM
Some checks failed
E2E Tests / Playwright E2E (push) Failing after 9s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 46s
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 9s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m18s
Deploy / Build API Image (push) Failing after 28s
Deploy / Build Web Image (push) Failing after 12s
Deploy / Build AI Services Image (push) Failing after 10s
Security Scanning / Trivy Scan — Web Image (push) Failing after 31s
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Smoke Test 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
Deploy / Deploy to Production (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — AI Services Image (push) Has started running

The Property table already stores usableAreaM2, floor, totalFloors,
metroDistanceM and nearbyPOIs but the listing detail endpoint was
dropping them. Add them to ListingDetailData + the Prisma read query,
mirror the additions on the frontend ListingDetail type, and render
them on the detail page:

- Quick-specs bar now shows "Tầng X / Y" (floor/totalFloors) with a
  sensible fallback to `floors`, plus "Cách metro" when populated.
- Details card adds rows: "Diện tích sử dụng", "Tầng / Tổng tầng"
  (merges floor + totalFloors), "Cách metro gần nhất" (formatted m/km).
- New "transit" icon for the metro stat.

Purely additive surfacing — no schema change, no migration. Listings
missing these fields still render as before.

Test fixture in listing-detail-client.spec.tsx extended with the new
nullable fields so the type stays compatible.

Phase A of 4 (Listings detail enhancement plan).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-19 14:41:17 +07:00
parent 98a84e9e3f
commit 6067adc095
5 changed files with 70 additions and 3 deletions

View File

@@ -28,13 +28,18 @@ export interface ListingDetailData {
latitude: number;
longitude: number;
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: string | null;
amenities: unknown;
nearbyPOIs: unknown;
metroDistanceM: number | null;
projectName: string | null;
media: ListingMediaData[];
};

View File

@@ -62,13 +62,18 @@ export async function findByIdWithProperty(
latitude: geo.latitude,
longitude: geo.longitude,
areaM2: listing.property.areaM2,
usableAreaM2: listing.property.usableAreaM2,
bedrooms: listing.property.bedrooms,
bathrooms: listing.property.bathrooms,
floors: listing.property.floors,
floor: listing.property.floor,
totalFloors: listing.property.totalFloors,
direction: listing.property.direction,
yearBuilt: listing.property.yearBuilt,
legalStatus: listing.property.legalStatus,
amenities: listing.property.amenities,
nearbyPOIs: listing.property.nearbyPOIs,
metroDistanceM: listing.property.metroDistanceM,
projectName: listing.property.projectName,
media: listing.property.media.map((m) => ({
id: m.id,

View File

@@ -82,13 +82,18 @@ function makeListing(overrides: Partial<ListingDetail> = {}): ListingDetail {
district: 'Quận Bình Thạnh',
city: 'Hồ Chí Minh',
areaM2: 75,
usableAreaM2: 68,
bedrooms: 2,
bathrooms: 2,
floors: null,
floor: null,
totalFloors: null,
direction: 'SOUTH',
yearBuilt: 2020,
legalStatus: 'Sổ hồng',
amenities: ['Hồ bơi', 'Gym'],
nearbyPOIs: null,
metroDistanceM: null,
projectName: 'Vinhomes Central Park',
latitude: 10.7975,
longitude: 106.721,

View File

@@ -138,12 +138,29 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
{property.bathrooms != null && (
<QuickStat icon="bath" label="Phòng tắm" value={`${property.bathrooms}`} />
)}
{property.floors != null && (
{property.floor != null && property.totalFloors != null && (
<QuickStat icon="floors" label="Tầng" value={`${property.floor} / ${property.totalFloors}`} />
)}
{property.floor != null && property.totalFloors == null && (
<QuickStat icon="floors" label="Tầng" value={`${property.floor}`} />
)}
{property.floor == null && property.floors != null && (
<QuickStat icon="floors" label="Số tầng" value={`${property.floors}`} />
)}
{property.direction && (
<QuickStat icon="compass" label="Hướng" value={getLabel(DIRECTIONS, property.direction) || ''} />
)}
{property.metroDistanceM != null && (
<QuickStat
icon="transit"
label="Cách metro"
value={
property.metroDistanceM < 1000
? `${property.metroDistanceM} m`
: `${(property.metroDistanceM / 1000).toFixed(1)} km`
}
/>
)}
</div>
<div className="grid gap-6 lg:grid-cols-3">
@@ -167,14 +184,39 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<InfoItem label="Loại BĐS" value={propertyTypeLabel || '---'} />
<InfoItem label="Diện tích" value={`${property.areaM2} m\u00B2`} />
<InfoItem label="Diện tích tim tường" value={`${property.areaM2} m²`} />
<InfoItem
label="Diện tích sử dụng"
value={property.usableAreaM2 != null ? `${property.usableAreaM2}` : '---'}
/>
<InfoItem label="Phòng ngủ" value={property.bedrooms != null ? `${property.bedrooms}` : '---'} />
<InfoItem label="Phòng tắm" value={property.bathrooms != null ? `${property.bathrooms}` : '---'} />
<InfoItem label="Số tầng" value={property.floors != null ? `${property.floors}` : '---'} />
<InfoItem
label="Tầng / Tổng tầng"
value={
property.floor != null && property.totalFloors != null
? `${property.floor} / ${property.totalFloors}`
: property.floor != null
? `${property.floor}`
: property.floors != null
? `${property.floors}`
: '---'
}
/>
<InfoItem label="Hướng" value={getLabel(DIRECTIONS, property.direction) || '---'} />
<InfoItem label="Năm xây" value={property.yearBuilt ? `${property.yearBuilt}` : '---'} />
<InfoItem label="Pháp lý" value={property.legalStatus || '---'} />
<InfoItem label="Dự án" value={property.projectName || '---'} />
<InfoItem
label="Cách metro gần nhất"
value={
property.metroDistanceM != null
? property.metroDistanceM < 1000
? `${property.metroDistanceM} m`
: `${(property.metroDistanceM / 1000).toFixed(1)} km`
: '---'
}
/>
</div>
</CardContent>
</Card>
@@ -387,6 +429,11 @@ function QuickStat({ icon, label, value }: { icon: string; label: string; value:
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 9l3 3m0 0l3-3m-3 3V6m0 6l-3 3m3-3l3 3m-3-3v6" />
</svg>
),
transit: (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
),
};
return (

View File

@@ -56,13 +56,18 @@ export interface ListingDetail {
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: string | null;
amenities: string[] | null;
nearbyPOIs: unknown;
metroDistanceM: number | null;
projectName: string | null;
latitude: number | null;
longitude: number | null;