From a008e623c574863ed591722b63c25470ff6ca10a Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sun, 19 Apr 2026 14:52:44 +0700 Subject: [PATCH] =?UTF-8?q?feat(listings):=20phase=20D=20=E2=80=94=20perso?= =?UTF-8?q?na=20fit=20&=20"V=C3=AC=20sao=20n=C3=AAn=20=E1=BB=9F=20=C4=91?= =?UTF-8?q?=C3=A2y"=20narrative?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New module lib/listing-personas.ts derives persona tags and a short "why live here" narrative from data the UI already has — the listing, the neighborhood score, and the nearby POI list returned by Phase C. Persona detection (emoji + short Vietnamese label): - Gia đình có con nhỏ — educationScore ≥ 7 AND bedrooms ≥ 2 - Gia đình trẻ — exactly 2 PN AND healthcareScore ≥ 7 - Người đi làm xa — metroDistanceM ≤ 1 km OR transportScore ≥ 7 OR ≥ 2 transit POIs - Người trẻ / độc thân — ≤ 1 PN OR (apartment + shopping ≥ 7 + ≥ 2 restaurants) - Yêu thiên nhiên — greeneryScore ≥ 7 OR ≥ 1 park POI - Ưu tiên an ninh — safetyScore ≥ 8 - Người lớn tuổi — healthcareScore ≥ 8 AND ≥ 2 hospital POIs - Nhà đầu tư — SALE + totalScore ≥ 75 + transportScore ≥ 7 Each persona carries a concrete reason string (uses POI counts and metro distance when available). The narrative highlights the top 3 categories scoring ≥ 7 with a matching POI detail. UI: PersonaFitCard sits between the quick-specs bar and the main grid with primary/5 background so it reads as a feature. Renders: 1) chips for each matching persona, 2) a tight bullet list of reasons, 3) the "Vì sao nên ở đây" narrative block. Silently collapses when no personas match AND no narrative can be composed. No schema change, no backend change. Phase D of 4 (next: Phase B schema columns for admin-authored overrides + Phase E AI advisor with Opus). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../listings/listing-detail-client.tsx | 70 ++++++ apps/web/lib/listing-personas.ts | 226 ++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 apps/web/lib/listing-personas.ts diff --git a/apps/web/components/listings/listing-detail-client.tsx b/apps/web/components/listings/listing-detail-client.tsx index 6646ac6..9b1179e 100644 --- a/apps/web/components/listings/listing-detail-client.tsx +++ b/apps/web/components/listings/listing-detail-client.tsx @@ -15,6 +15,7 @@ import { AiEstimateButton } from '@/components/valuation/ai-estimate-button'; 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 { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings'; @@ -186,6 +187,9 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) { )} + {/* Persona fit — "Phù hợp với ai & Vì sao nên ở đây" */} + +
{/* Main content */}
@@ -438,6 +442,72 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) { ); } +function PersonaFitCard({ + listing, + score, + pois, +}: { + listing: ListingDetail; + score: NeighborhoodScoreResult | null; + pois: POIItem[]; +}) { + const personas = React.useMemo( + () => derivePersonas(listing, score, pois), + [listing, score, pois], + ); + const narrative = React.useMemo( + () => composeWhyThisLocation(listing, score, pois), + [listing, score, pois], + ); + + // Only render when we have something meaningful to say. + if (personas.length === 0 && !narrative) return null; + + return ( + + + Phù hợp với ai? + + + {personas.length > 0 && ( +
+ {personas.map((p) => ( +
+ + {p.label} +
+ ))} +
+ )} + {personas.length > 0 && ( +
    + {personas.map((p) => ( +
  • + + + {p.label}: {p.reason} + +
  • + ))} +
+ )} + {narrative && ( +
+

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

+

{narrative}

+
+ )} +
+
+ ); +} + function QuickStat({ icon, label, value }: { icon: string; label: string; value: string }) { const icons: Record = { area: ( diff --git a/apps/web/lib/listing-personas.ts b/apps/web/lib/listing-personas.ts new file mode 100644 index 0000000..793020f --- /dev/null +++ b/apps/web/lib/listing-personas.ts @@ -0,0 +1,226 @@ +/** + * Derive "Phù hợp với ai" personas and compose a "Vì sao nên ở đây" + * narrative from existing data (listing + neighborhood score + nearby POIs). + * + * Phase D of the listings-detail enhancement: purely client-side — no new + * backend fields are needed. Admin-authored overrides can layer on top later + * once we have a `suitableFor` / `whyThisLocation` column (Phase B). + */ + +import type { NearbyPOICategory } from './analytics-api'; +import type { ListingDetail, NeighborhoodScoreResult } from './listings-api'; + +export type PersonaKey = + | 'family_with_kids' + | 'young_family' + | 'commuter' + | 'single_young' + | 'nature_lover' + | 'investor' + | 'safety_first' + | 'senior'; + +export interface Persona { + key: PersonaKey; + label: string; + emoji: string; + reason: string; +} + +const PERSONA_LABELS: Record = { + family_with_kids: { label: 'Gia đình có con nhỏ', emoji: '👨‍👩‍👧' }, + young_family: { label: 'Gia đình trẻ', emoji: '🏡' }, + commuter: { label: 'Người đi làm xa', emoji: '🚇' }, + single_young: { label: 'Người trẻ / độc thân', emoji: '🧑‍💻' }, + nature_lover: { label: 'Yêu thiên nhiên', emoji: '🌳' }, + investor: { label: 'Nhà đầu tư', emoji: '📈' }, + safety_first: { label: 'Ưu tiên an ninh', emoji: '🛡️' }, + senior: { label: 'Người lớn tuổi', emoji: '🏥' }, +}; + +type POICountByCategory = Partial>; + +function countPOIs(pois: Array<{ category: NearbyPOICategory }>): POICountByCategory { + const counts: POICountByCategory = {}; + for (const p of pois) { + counts[p.category] = (counts[p.category] ?? 0) + 1; + } + return counts; +} + +export function derivePersonas( + listing: ListingDetail, + score: NeighborhoodScoreResult | null, + pois: Array<{ category: NearbyPOICategory }>, +): Persona[] { + const { property } = listing; + const poiCount = countPOIs(pois); + const out: Persona[] = []; + + // Gia đình có con nhỏ — cần trường học + 2+ phòng ngủ. + if (score && score.educationScore >= 7 && (property.bedrooms ?? 0) >= 2) { + const schools = poiCount.school ?? 0; + out.push({ + ...PERSONA_LABELS.family_with_kids, + key: 'family_with_kids', + reason: schools > 0 + ? `Khu vực có ${schools} trường học gần & điểm giáo dục ${score.educationScore}/10.` + : `Điểm giáo dục khu vực ${score.educationScore}/10.`, + }); + } + + // Gia đình trẻ — 2 PN + y tế tốt (mẹ và bé). + if (property.bedrooms === 2 && score && score.healthcareScore >= 7) { + const hospitals = poiCount.hospital ?? 0; + out.push({ + ...PERSONA_LABELS.young_family, + key: 'young_family', + reason: hospitals > 0 + ? `Có ${hospitals} bệnh viện/phòng khám gần, y tế ${score.healthcareScore}/10.` + : `Y tế khu vực đạt ${score.healthcareScore}/10.`, + }); + } + + // Người đi làm xa — gần metro (<1km) HOẶC điểm giao thông cao. + const metroM = property.metroDistanceM; + const transitCount = poiCount.transit ?? 0; + if ((metroM != null && metroM <= 1000) || (score && score.transportScore >= 7) || transitCount >= 2) { + out.push({ + ...PERSONA_LABELS.commuter, + key: 'commuter', + reason: metroM != null && metroM <= 1000 + ? `Cách metro chỉ ${metroM < 1000 ? `${metroM}m` : `${(metroM / 1000).toFixed(1)}km`}.` + : `Giao thông ${score?.transportScore ?? '?'}/10, ${transitCount} điểm metro/bus gần.`, + }); + } + + // Người trẻ/độc thân — studio/1PN HOẶC apartment gần mua sắm/ăn uống. + const bedrooms = property.bedrooms ?? 0; + const restaurantCount = poiCount.restaurant ?? 0; + if ( + bedrooms <= 1 || + (property.propertyType === 'APARTMENT' && score && score.shoppingScore >= 7 && restaurantCount >= 2) + ) { + out.push({ + ...PERSONA_LABELS.single_young, + key: 'single_young', + reason: bedrooms <= 1 + ? `Thiết kế ${bedrooms} phòng ngủ phù hợp ở một mình / cặp đôi trẻ.` + : `Gần ${restaurantCount} nhà hàng/quán cafe, mua sắm ${score?.shoppingScore ?? '?'}/10.`, + }); + } + + // Yêu thiên nhiên — điểm môi trường cao hoặc có công viên gần. + const parkCount = poiCount.park ?? 0; + if ((score && score.greeneryScore >= 7) || parkCount >= 1) { + out.push({ + ...PERSONA_LABELS.nature_lover, + key: 'nature_lover', + reason: parkCount > 0 + ? `Có ${parkCount} công viên gần, môi trường ${score?.greeneryScore ?? '?'}/10.` + : `Môi trường khu vực ${score?.greeneryScore ?? '?'}/10.`, + }); + } + + // Ưu tiên an ninh. + if (score && score.safetyScore >= 8) { + out.push({ + ...PERSONA_LABELS.safety_first, + key: 'safety_first', + reason: `An ninh khu vực đạt ${score.safetyScore}/10.`, + }); + } + + // Người lớn tuổi — gần y tế + không ồn ào (bedrooms >= 2, có elevator ngầm + // nếu là chung cư cao tầng thì totalFloors >= 5 không phải walk-up). + const hospitals = poiCount.hospital ?? 0; + if (score && score.healthcareScore >= 8 && hospitals >= 2) { + out.push({ + ...PERSONA_LABELS.senior, + key: 'senior', + reason: `Có ${hospitals} bệnh viện gần, y tế ${score.healthcareScore}/10.`, + }); + } + + // Nhà đầu tư — SALE + transport cao + total score high → khu vực hot. + if ( + listing.transactionType === 'SALE' && + score && + score.totalScore >= 75 && + score.transportScore >= 7 + ) { + out.push({ + ...PERSONA_LABELS.investor, + key: 'investor', + reason: `Khu vực tổng điểm ${score.totalScore}/100 với giao thông ${score.transportScore}/10 — tiềm năng cho thuê tốt.`, + }); + } + + return out; +} + +/** + * Compose a short narrative highlighting the strongest 2-3 reasons to live + * here. Returns null when there isn't enough signal to say anything useful. + */ +export function composeWhyThisLocation( + listing: ListingDetail, + score: NeighborhoodScoreResult | null, + pois: Array<{ category: NearbyPOICategory }>, +): string | null { + if (!score) return null; + + const { property } = listing; + const poiCount = countPOIs(pois); + + const scoreEntries: Array<{ label: string; score: number; detail: string }> = [ + { + label: 'giáo dục', + score: score.educationScore, + detail: poiCount.school ? `${poiCount.school} trường học gần` : 'hệ thống trường đa dạng', + }, + { + label: 'y tế', + score: score.healthcareScore, + detail: poiCount.hospital ? `${poiCount.hospital} bệnh viện trong 2km` : 'tiện ích y tế đầy đủ', + }, + { + label: 'giao thông', + score: score.transportScore, + detail: + property.metroDistanceM != null && property.metroDistanceM <= 1000 + ? `cách metro chỉ ${property.metroDistanceM}m` + : poiCount.transit + ? `${poiCount.transit} điểm metro/bus gần` + : 'dễ kết nối các khu vực khác', + }, + { + label: 'mua sắm', + score: score.shoppingScore, + detail: poiCount.shopping ? `${poiCount.shopping} siêu thị/TTTM gần` : 'nhiều lựa chọn mua sắm', + }, + { + label: 'môi trường', + score: score.greeneryScore, + detail: poiCount.park ? `${poiCount.park} công viên gần` : 'không gian xanh tốt', + }, + { + label: 'an ninh', + score: score.safetyScore, + detail: 'khu dân cư yên tĩnh', + }, + ]; + + const highlights = scoreEntries + .filter((e) => e.score >= 7) + .sort((a, b) => b.score - a.score) + .slice(0, 3); + + if (highlights.length === 0) return null; + + const sentences = highlights.map( + (h) => `${h.label.charAt(0).toUpperCase()}${h.label.slice(1)} đạt ${h.score}/10 (${h.detail})`, + ); + + return `${sentences.join('. ')}.`; +}