/** * Derive "Phù hợp với ai" personas and compose a "Vì sao nên chọn dự án này" * narrative for a Residential Project (dự án của chủ đầu tư). * * Mirrors `lib/listing-personas.ts` but keys off project-specific signals — * developer reputation, amenity mix, status, completion timing, property-type * composition, price tier — instead of per-unit fields (bedrooms/area). * * Purely client-side; no new backend fields required. Admin-authored * `project.suitableFor` / `project.whyThisLocation` are merged on top. */ import { Baby, HeartPulse, Home, Laptop, Shield, TrainFront, Trees, TrendingUp, type LucideIcon, } from 'lucide-react'; import type { NearbyPOICategory } from './analytics-api'; import type { ProjectDetail } from './du-an-api'; import type { NeighborhoodScoreResult } from './listings-api'; export type ProjectPersonaKey = | 'family_with_kids' | 'young_family' | 'commuter' | 'single_young' | 'nature_lover' | 'long_term_investor' | 'safety_first' | 'senior'; export interface ProjectPersona { key: ProjectPersonaKey; label: string; icon: LucideIcon; reason: string; } const PERSONA_LABELS: Record = { family_with_kids: { label: 'Gia đình có con nhỏ', icon: Baby }, young_family: { label: 'Gia đình trẻ', icon: Home }, commuter: { label: 'Người đi làm xa', icon: TrainFront }, single_young: { label: 'Người trẻ / độc thân', icon: Laptop }, nature_lover: { label: 'Yêu thiên nhiên', icon: Trees }, long_term_investor: { label: 'Nhà đầu tư dài hạn', icon: TrendingUp }, safety_first: { label: 'Ưu tiên an ninh', icon: Shield }, senior: { label: 'Người lớn tuổi', icon: HeartPulse }, }; 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; } /** Case-insensitive keyword hit across amenity.name + amenity.category. */ function amenityMatches(project: ProjectDetail, keywords: string[]): number { const hay = project.amenities .flatMap((a) => [a.name, a.category]) .join(' | ') .toLowerCase(); let hits = 0; for (const kw of keywords) { if (hay.includes(kw.toLowerCase())) hits += 1; } return hits; } function hasPropertyType( project: ProjectDetail, types: Array, ): boolean { return project.propertyTypes.some((t) => types.includes(t)); } /** Months until completionDate, or null if missing / already passed. */ function monthsUntilCompletion(project: ProjectDetail): number | null { if (!project.completionDate) return null; const target = new Date(project.completionDate).getTime(); const now = Date.now(); if (Number.isNaN(target) || target <= now) return null; return Math.round((target - now) / (1000 * 60 * 60 * 24 * 30)); } export function deriveProjectPersonas( project: ProjectDetail, score: NeighborhoodScoreResult | null, pois: Array<{ category: NearbyPOICategory }>, ): ProjectPersona[] { const poiCount = countPOIs(pois); const out: ProjectPersona[] = []; // ─── Gia đình có con nhỏ ────────────────────────────────── // Điểm giáo dục tốt + dự án có loại hình phù hợp cho gia đình (VILLA / // TOWNHOUSE / căn hộ) hoặc amenities có khu vui chơi / mẫu giáo. const kidAmenities = amenityMatches(project, [ 'trẻ em', 'mẫu giáo', 'trường', 'kids', 'playground', 'khu vui chơi', ]); if ( (score && score.educationScore >= 7) || kidAmenities > 0 || hasPropertyType(project, ['VILLA', 'TOWNHOUSE']) ) { const schools = poiCount.school ?? 0; const reasonBits: string[] = []; if (score && score.educationScore >= 7) reasonBits.push(`điểm giáo dục ${score.educationScore.toFixed(1)}/10`); if (schools > 0) reasonBits.push(`${schools} trường học gần`); if (kidAmenities > 0) reasonBits.push(`có ${kidAmenities} tiện ích cho trẻ em`); if (hasPropertyType(project, ['VILLA', 'TOWNHOUSE'])) reasonBits.push('có biệt thự / nhà phố'); out.push({ ...PERSONA_LABELS.family_with_kids, key: 'family_with_kids', reason: reasonBits.length > 0 ? `${reasonBits.slice(0, 2).join(', ')}.` : 'Phù hợp cho gia đình có con nhỏ.', }); } // ─── Gia đình trẻ ────────────────────────────────────────── // APARTMENT + y tế tốt (2BR không hard-code vì project là tổ hợp). if ( hasPropertyType(project, ['APARTMENT']) && 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.toFixed(1)}/10.` : `Y tế khu vực đạt ${score.healthcareScore.toFixed(1)}/10.`, }); } // ─── Người đi làm xa ─────────────────────────────────────── // Giao thông tốt hoặc có nhiều điểm metro/bus gần. const transitCount = poiCount.transit ?? 0; if ((score && score.transportScore >= 7) || transitCount >= 2) { out.push({ ...PERSONA_LABELS.commuter, key: 'commuter', reason: transitCount >= 2 ? `${transitCount} điểm metro/bus trong 2km, giao thông ${score?.transportScore.toFixed(1) ?? '?'}/10.` : `Giao thông ${score?.transportScore.toFixed(1) ?? '?'}/10.`, }); } // ─── Người trẻ / độc thân ───────────────────────────────── // APARTMENT + shopping/restaurants tốt. const restaurantCount = poiCount.restaurant ?? 0; const shoppingCount = poiCount.shopping ?? 0; if ( hasPropertyType(project, ['APARTMENT']) && score && (score.shoppingScore >= 7 || restaurantCount >= 2) ) { const detail: string[] = []; if (restaurantCount >= 2) detail.push(`${restaurantCount} nhà hàng/quán cafe`); if (shoppingCount >= 1) detail.push(`${shoppingCount} TTTM/siêu thị`); detail.push(`mua sắm ${score.shoppingScore.toFixed(1)}/10`); out.push({ ...PERSONA_LABELS.single_young, key: 'single_young', reason: `Gần ${detail.slice(0, 2).join(', ')}.`, }); } // ─── Yêu thiên nhiên ────────────────────────────────────── // Môi trường tốt HOẶC có công viên gần HOẶC amenities có hồ bơi/công viên/cây xanh. const greenAmenities = amenityMatches(project, [ 'hồ bơi', 'công viên', 'cây xanh', 'hồ cảnh quan', 'pool', 'garden', ]); const parkCount = poiCount.park ?? 0; if ((score && score.greeneryScore >= 7) || parkCount >= 1 || greenAmenities >= 2) { const bits: string[] = []; if (score && score.greeneryScore >= 7) bits.push(`môi trường ${score.greeneryScore.toFixed(1)}/10`); if (parkCount > 0) bits.push(`${parkCount} công viên gần`); if (greenAmenities > 0) bits.push(`${greenAmenities} tiện ích xanh nội khu`); out.push({ ...PERSONA_LABELS.nature_lover, key: 'nature_lover', reason: bits.slice(0, 2).join(', ') + '.', }); } // ─── Ưu tiên an ninh ────────────────────────────────────── // Safety score cao hoặc amenities có security/camera/bảo vệ 24/24. const safetyAmenities = amenityMatches(project, [ 'bảo vệ', 'an ninh', 'camera', 'security', '24/7', '24/24', ]); if ((score && score.safetyScore >= 8) || safetyAmenities >= 2) { const parts: string[] = []; if (score && score.safetyScore >= 8) parts.push(`an ninh ${score.safetyScore.toFixed(1)}/10`); if (safetyAmenities > 0) parts.push(`${safetyAmenities} lớp bảo vệ nội khu`); out.push({ ...PERSONA_LABELS.safety_first, key: 'safety_first', reason: parts.join(', ') + '.', }); } // ─── Người lớn tuổi ─────────────────────────────────────── // Y tế + môi trường đều cao, ưu tiên dự án đã hoặc sắp bàn giao. const hospitals = poiCount.hospital ?? 0; const handoverSoon = project.status === 'HANDOVER' || project.status === 'COMPLETED' || (monthsUntilCompletion(project) ?? 99) <= 6; if ( score && score.healthcareScore >= 8 && score.greeneryScore >= 6 && handoverSoon ) { out.push({ ...PERSONA_LABELS.senior, key: 'senior', reason: hospitals > 0 ? `${hospitals} bệnh viện gần, y tế ${score.healthcareScore.toFixed(1)}/10, môi trường ${score.greeneryScore.toFixed(1)}/10.` : `Y tế ${score.healthcareScore.toFixed(1)}/10, môi trường ${score.greeneryScore.toFixed(1)}/10.`, }); } // ─── Nhà đầu tư dài hạn ─────────────────────────────────── // Dự án đang xây / quy hoạch + CĐT có uy tín (>=5 dự án) + khu vực tốt // + giao thông >= 7 (tiềm năng cho thuê / tăng giá). const developerProven = project.developer.totalProjects >= 5; const earlyStage = project.status === 'PLANNING' || project.status === 'UNDER_CONSTRUCTION'; if ( earlyStage && developerProven && score && score.totalScore >= 7 && score.transportScore >= 7 ) { const months = monthsUntilCompletion(project); const timingBit = months != null ? `bàn giao sau ~${months} tháng` : 'bàn giao tương lai'; out.push({ ...PERSONA_LABELS.long_term_investor, key: 'long_term_investor', reason: `CĐT ${project.developer.totalProjects} dự án, khu vực ${score.totalScore.toFixed(1)}/10, ${timingBit}.`, }); } return out; } /** * Compose a short narrative highlighting the strongest 2-3 reasons to choose * this project. Returns null when there isn't enough signal. Blends live * neighborhood score with project-specific advantages (developer, amenity mix, * status) so the narrative is distinct from the listings version. */ export function composeWhyThisProject( project: ProjectDetail, score: NeighborhoodScoreResult | null, pois: Array<{ category: NearbyPOICategory }>, ): string | null { const parts: string[] = []; const poiCount = countPOIs(pois); // Location highlights from the score radar. if (score) { 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: 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, 2); for (const h of highlights) { parts.push( `${h.label.charAt(0).toUpperCase()}${h.label.slice(1)} đạt ${h.score.toFixed(1)}/10 (${h.detail})`, ); } } // Project-level advantage: developer reputation. if (project.developer.totalProjects >= 5) { parts.push( `CĐT ${project.developer.name} đã triển khai ${project.developer.totalProjects} dự án`, ); } // Amenity density highlight. if (project.amenities.length >= 10) { parts.push(`${project.amenities.length} tiện ích nội khu đa dạng`); } // Timing advantage. const months = monthsUntilCompletion(project); if (project.status === 'COMPLETED') { parts.push('dự án đã hoàn thành, vào ở ngay'); } else if (project.status === 'HANDOVER') { parts.push('đang bàn giao, sẵn sàng nhận nhà'); } else if (months != null && months <= 12) { parts.push(`dự kiến bàn giao sau ~${months} tháng`); } if (parts.length === 0) return null; return parts.slice(0, 4).join('. ') + '.'; }