diff --git a/apps/web/app/[locale]/(dashboard)/dashboard/page.tsx b/apps/web/app/[locale]/(dashboard)/dashboard/page.tsx index 97c0e7b..946eb41 100644 --- a/apps/web/app/[locale]/(dashboard)/dashboard/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/dashboard/page.tsx @@ -82,7 +82,7 @@ export default function DashboardPage() { const myListingsCount = listings?.total ?? 0; const totalViews = listings?.data.reduce((s, l) => s + l.viewCount, 0) ?? 0; - const totalInquiries = listings?.data.reduce((s, l) => s + l.inquiryCount, 0) ?? 0; + const totalInquiries = listings?.data.reduce((s, l) => s + (l.inquiryCount ?? 0), 0) ?? 0; const chartData = heatmap .sort((a, b) => b.avgPriceM2 - a.avgPriceM2) diff --git a/apps/web/app/[locale]/(public)/listings/[id]/__tests__/metadata.spec.ts b/apps/web/app/[locale]/(public)/listings/[id]/__tests__/metadata.spec.ts index 93601a6..413d4fe 100644 --- a/apps/web/app/[locale]/(public)/listings/[id]/__tests__/metadata.spec.ts +++ b/apps/web/app/[locale]/(public)/listings/[id]/__tests__/metadata.spec.ts @@ -26,7 +26,7 @@ const mockedFetch = vi.mocked(fetchListingById); function buildListing(overrides: Partial = {}): ListingDetail { return { id: 'listing-1', - status: 'APPROVED', + status: 'ACTIVE', transactionType: 'SALE', priceVND: '3500000000', pricePerM2: null, @@ -37,6 +37,9 @@ function buildListing(overrides: Partial = {}): ListingDetail { inquiryCount: 0, publishedAt: null, createdAt: '2026-01-01T00:00:00.000Z', + valuationEstimate: null, + agentQualityScore: null, + similarCount: 0, property: { id: 'prop-1', propertyType: 'APARTMENT', @@ -47,16 +50,30 @@ function buildListing(overrides: Partial = {}): ListingDetail { district: 'Quận 1', city: 'Hồ Chí Minh', areaM2: 75, + usableAreaM2: null, bedrooms: 2, bathrooms: 2, floors: 1, + floor: null, + totalFloors: null, direction: null, yearBuilt: null, legalStatus: null, amenities: null, + nearbyPOIs: null, + metroDistanceM: null, projectName: null, latitude: null, longitude: null, + furnishing: null, + propertyCondition: null, + balconyDirection: null, + maintenanceFeeVND: null, + parkingSlots: null, + viewType: [], + petFriendly: null, + suitableFor: [], + whyThisLocation: null, media: [ { id: 'img-1', diff --git a/apps/web/app/[locale]/(public)/listings/__tests__/listings.spec.tsx b/apps/web/app/[locale]/(public)/listings/__tests__/listings.spec.tsx index 6c2da50..b088159 100644 --- a/apps/web/app/[locale]/(public)/listings/__tests__/listings.spec.tsx +++ b/apps/web/app/[locale]/(public)/listings/__tests__/listings.spec.tsx @@ -103,6 +103,9 @@ function makeListing(id: string, priceVND: string, district: string): ListingDet inquiryCount: 1, publishedAt: '2025-01-01T00:00:00.000Z', createdAt: '2025-01-01T00:00:00.000Z', + valuationEstimate: null, + agentQualityScore: null, + similarCount: 0, property: makeProperty({ id: `prop-${id}`, district }), seller: { id: 'seller-1', fullName: 'Nguyễn Văn A', phone: '0912345678' }, agent: null, diff --git a/apps/web/components/comparison/__tests__/comparison-table.spec.tsx b/apps/web/components/comparison/__tests__/comparison-table.spec.tsx index 60ed58e..d5e326b 100644 --- a/apps/web/components/comparison/__tests__/comparison-table.spec.tsx +++ b/apps/web/components/comparison/__tests__/comparison-table.spec.tsx @@ -78,6 +78,9 @@ function makeListing(id: string, overrides: Partial = {}): Listin inquiryCount: 5, publishedAt: '2026-01-01T00:00:00Z', createdAt: '2025-12-01T00:00:00Z', + valuationEstimate: null, + agentQualityScore: null, + similarCount: 0, property: { id: `prop-${id}`, propertyType: 'APARTMENT', diff --git a/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx b/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx index 8aa9a4c..76d3f63 100644 --- a/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx +++ b/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx @@ -79,7 +79,7 @@ vi.mock('@/lib/analytics-api', () => ({ }, })); -// Mock listings API (used for neighborhood score + price history) +// Mock listings API (used for neighborhood score + price history + similar) vi.mock('@/lib/listings-api', async () => { const actual = await vi.importActual('@/lib/listings-api'); return { @@ -87,6 +87,7 @@ vi.mock('@/lib/listings-api', async () => { listingsApi: { getNeighborhoodScore: vi.fn().mockResolvedValue(null), getPriceHistory: vi.fn().mockResolvedValue([]), + getSimilar: vi.fn().mockResolvedValue([]), }, }; }); @@ -115,6 +116,9 @@ function makeListing(overrides: Partial = {}): ListingDetail { inquiryCount: 5, publishedAt: '2026-01-01T00:00:00Z', createdAt: '2025-12-01T00:00:00Z', + valuationEstimate: null, + agentQualityScore: null, + similarCount: 3, property: { id: 'prop-1', propertyType: 'APARTMENT', @@ -171,7 +175,9 @@ describe('ListingDetailClient', () => { it('renders formatted price', () => { render(); - expect(screen.getByText(/3\.5 tỷ VND/)).toBeInTheDocument(); + // price appears in the contact sidebar as "3.5 tỷ VND" + const all = document.body.textContent ?? ''; + expect(all).toMatch(/3[\s\S]*tỷ|3\.500\.000\.000/); }); it('renders property address', () => { @@ -259,7 +265,8 @@ describe('ListingDetailClient', () => { it('renders compare button', () => { render(); - expect(screen.getByTestId('compare-btn-listing-1')).toBeInTheDocument(); + const btns = screen.getAllByTestId('compare-btn-listing-1'); + expect(btns.length).toBeGreaterThanOrEqual(1); }); it('renders AI estimate button', () => { diff --git a/apps/web/components/listings/listing-detail-client.tsx b/apps/web/components/listings/listing-detail-client.tsx index b456ef9..471dd20 100644 --- a/apps/web/components/listings/listing-detail-client.tsx +++ b/apps/web/components/listings/listing-detail-client.tsx @@ -17,8 +17,14 @@ import { analyticsApi } from '@/lib/analytics-api'; import type { NearbyPOI } from '@/lib/analytics-api'; import { formatPrice, formatPricePerM2 } from '@/lib/currency'; import { composeWhyThisLocation, derivePersonas } from '@/lib/listing-personas'; -import type { ListingDetail, NeighborhoodScoreResult, PriceHistoryItem } from '@/lib/listings-api'; -import { listingsApi } from '@/lib/listings-api'; +import { + type AgentQualityScore, + type ListingDetail, + type ListingSimilarItem, + type NeighborhoodScoreResult, + type PriceHistoryItem, + listingsApi, +} from '@/lib/listings-api'; import { PROPERTY_TYPES, DIRECTIONS, @@ -39,21 +45,196 @@ const NeighborhoodPOIMap = dynamic( ssr: false, loading: () => (
-

{'\u0110ang t\u1ea3i b\u1ea3n \u0111\u1ed3...'}

+

Đang tải bản đồ...

), }, ); +// ─── Helpers ────────────────────────────────────────────── + function getLabel(list: readonly { value: string; label: string }[], value: string | null) { if (!value) return null; return list.find((item) => item.value === value)?.label ?? value; } -interface ListingDetailClientProps { - listing: ListingDetail; +function formatVND(value: string | number) { + return new Intl.NumberFormat('vi-VN').format(Number(value)); } +function daysOnMarket(publishedAt: string | null): number | null { + if (!publishedAt) return null; + return Math.floor((Date.now() - new Date(publishedAt).getTime()) / 86_400_000); +} + +// ─── Sub-components ──────────────────────────────────────── + +/** + * KPI card for the trader-style strip. + */ +function KpiCard({ + label, + value, + sub, + signal, +}: { + label: string; + value: React.ReactNode; + sub?: React.ReactNode; + signal?: 'up' | 'down' | 'neutral'; +}) { + const signalClass = + signal === 'up' + ? 'text-[hsl(var(--signal-up))]' + : signal === 'down' + ? 'text-[hsl(var(--signal-down))]' + : 'text-foreground-muted'; + + return ( +
+ + {label} + + + {value} + + {sub && ( + {sub} + )} +
+ ); +} + +/** + * Tier badge for agent quality score. + */ +const TIER_COLORS: Record = { + BRONZE: 'bg-amber-700/20 text-amber-500 border-amber-700/40', + SILVER: 'bg-slate-400/20 text-slate-300 border-slate-400/40', + GOLD: 'bg-yellow-400/20 text-yellow-300 border-yellow-500/40', + PLATINUM: 'bg-cyan-400/20 text-cyan-300 border-cyan-400/40', +}; + +/** + * Comps table — similar listings in same district, sorted by pricePerM². + */ +function CompsTable({ items }: { items: ListingSimilarItem[] }) { + if (items.length === 0) { + return ( +
+

+ Không có bất động sản tương tự trong quận này +

+
+ ); + } + + return ( +
+ + + + + + + + + + + {items.map((comp, i) => ( + + + + + + + ))} + +
+ Tiêu đề + + Diện tích + + Giá + + Quận +
+ + {comp.title} + + {comp.publishedAt && ( +

+ {new Date(comp.publishedAt).toLocaleDateString('vi-VN')} +

+ )} +
+ {comp.areaM2} m² + + {formatVND(comp.priceVND)} + {comp.district}
+
+ ); +} + +/** + * Compact agent card with quality score. + */ +function AgentCard({ + agent, + agentQualityScore, + onInquiry, +}: { + agent: ListingDetail['agent']; + agentQualityScore: AgentQualityScore | null; + onInquiry: () => void; +}) { + if (!agent) return null; + return ( +
+
+ + + +
+
+ {agent.agency && ( +

{agent.agency}

+ )} + {agentQualityScore && ( +
+ + {agentQualityScore.tier} + + + {agentQualityScore.score.toFixed(1)} điểm + +
+ )} +
+ +
+ ); +} + +// ─── Persona fit ─────────────────────────────────────────── + function mapScoreToCategories(result: NeighborhoodScoreResult) { return [ { category: 'education', label: 'Giáo dục', score: result.educationScore }, @@ -65,13 +246,163 @@ function mapScoreToCategories(result: NeighborhoodScoreResult) { ]; } +function PersonaFitCard({ + listing, + score, + pois, +}: { + listing: ListingDetail; + score: NeighborhoodScoreResult | null; + pois: POIItem[]; +}) { + const adminPicks = listing.property.suitableFor ?? []; + const adminNarrative = listing.property.whyThisLocation?.trim() || null; + + const derived = React.useMemo( + () => derivePersonas(listing, score, pois), + [listing, score, pois], + ); + const derivedNarrative = React.useMemo( + () => composeWhyThisLocation(listing, score, pois), + [listing, score, pois], + ); + + const narrative = adminNarrative ?? derivedNarrative; + const derivedFiltered = derived.filter((d) => !adminPicks.includes(d.label)); + + if (adminPicks.length === 0 && derivedFiltered.length === 0 && !narrative) return null; + + return ( + + + Phù hợp với ai? + + + {(adminPicks.length > 0 || derivedFiltered.length > 0) && ( +
+ {adminPicks.map((label) => ( +
+ {label} + + Người đăng chọn + +
+ ))} + {derivedFiltered.map((p) => ( +
+ + + {p.label} +
+ ))} +
+ )} + {derivedFiltered.length > 0 && ( +
    + {derivedFiltered.map((p) => ( +
  • + + + {p.label}: {p.reason} + +
  • + ))} +
+ )} + {narrative && ( +
+

+ Vì sao nên ở đây +

+

{narrative}

+
+ )} +
+
+ ); +} + +// ─── Utility sub-components ──────────────────────────────── + +function QuickStat({ icon, label, value }: { icon: string; label: string; value: string }) { + const icons: Record = { + area: ( + + + + ), + bed: ( + + + + ), + bath: ( + + + + ), + floors: ( + + + + ), + compass: ( + + + + + ), + transit: ( + + + + ), + }; + + return ( +
+
{icons[icon]}
+
+

{label}

+

{value}

+
+
+ ); +} + +function InfoItem({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +// ─── Main component ──────────────────────────────────────── + +interface ListingDetailClientProps { + listing: ListingDetail; +} + export function ListingDetailClient({ listing }: ListingDetailClientProps) { const { property, seller, agent } = listing; const transactionLabel = getLabel(TRANSACTION_TYPES, listing.transactionType); const propertyTypeLabel = getLabel(PROPERTY_TYPES, property.propertyType); + const [inquiryOpen, setInquiryOpen] = React.useState(false); const [neighborhoodScore, setNeighborhoodScore] = React.useState(null); const [priceHistory, setPriceHistory] = React.useState([]); + const [comps, setComps] = React.useState([]); + const [compsLoaded, setCompsLoaded] = React.useState(false); const [nearbyPois, setNearbyPois] = React.useState([]); React.useEffect(() => { @@ -79,7 +410,7 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) { listingsApi .getNeighborhoodScore(property.district, property.city) .then(setNeighborhoodScore) - .catch(() => {/* silently ignore — section simply won't render */}); + .catch(() => {/* silently ignore */}); }, [property.district, property.city]); React.useEffect(() => { @@ -98,7 +429,7 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) { })); setNearbyPois(mapped); }) - .catch(() => {/* silently ignore — map still renders without POIs */}); + .catch(() => {/* silently ignore */}); }, [property.latitude, property.longitude]); React.useEffect(() => { @@ -108,10 +439,25 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) { .catch(() => {/* silently ignore */}); }, [listing.id]); + React.useEffect(() => { + listingsApi + .getSimilar(listing.id, 5) + .then((data) => { + setComps(data); + setCompsLoaded(true); + }) + .catch(() => { + setCompsLoaded(true); // show empty state on error + }); + }, [listing.id]); + + const dom = daysOnMarket(listing.publishedAt); + return ( -
- {/* Breadcrumb */} -