@@ -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.emoji}
+ {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('. ')}.`;
+}