diff --git a/apps/web/app/[locale]/(public)/agents/[id]/page.tsx b/apps/web/app/[locale]/(public)/agents/[id]/page.tsx index f893a4f..5029dea 100644 --- a/apps/web/app/[locale]/(public)/agents/[id]/page.tsx +++ b/apps/web/app/[locale]/(public)/agents/[id]/page.tsx @@ -6,7 +6,11 @@ import { generateAgentJsonLd, generateBreadcrumbJsonLd, } from '@/components/seo/json-ld'; -import { fetchAgentProfile, fetchAgentReviews } from '@/lib/agents-server'; +import { + fetchAgentProfile, + fetchAgentReviews, + fetchAgentListings, +} from '@/lib/agents-server'; // --------------------------------------------------------------------------- // Constants @@ -85,9 +89,10 @@ export async function generateMetadata({ params: paramsPromise }: PageProps): Pr export default async function AgentProfilePage({ params: paramsPromise }: PageProps) { const params = await paramsPromise; - const [agent, reviewsResult] = await Promise.all([ + const [agent, reviewsResult, listingsResult] = await Promise.all([ fetchAgentProfile(params.id), fetchAgentReviews(params.id, 1, 10), + fetchAgentListings(params.id, 1, 50), ]); if (!agent) { @@ -98,6 +103,7 @@ export default async function AgentProfilePage({ params: paramsPromise }: PagePr const agentJsonLd = generateAgentJsonLd(agent, siteUrl); const breadcrumbJsonLd = generateBreadcrumbJsonLd([ { name: 'Trang chủ', url: siteUrl }, + { name: 'Môi giới', url: `${siteUrl}/${params.locale}/agents` }, { name: agent.fullName, url: `${siteUrl}/${params.locale}/agents/${params.id}` }, ]); @@ -108,7 +114,12 @@ export default async function AgentProfilePage({ params: paramsPromise }: PagePr {/* Interactive client component */} - + ); } diff --git a/apps/web/components/agents/__tests__/agent-profile-client.spec.tsx b/apps/web/components/agents/__tests__/agent-profile-client.spec.tsx index e87ed71..264042a 100644 --- a/apps/web/components/agents/__tests__/agent-profile-client.spec.tsx +++ b/apps/web/components/agents/__tests__/agent-profile-client.spec.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { AgentPublicProfile, AgentReviewItem } from '@/lib/agents-api'; +import type { ListingDetail } from '@/lib/listings-api'; import { AgentProfileClient } from '../agent-profile-client'; // Mock next/image @@ -21,6 +22,33 @@ vi.mock('lucide-react', () => ({ ), Home: () => H, MessageSquare: () => M, + TrendingUp: () => TU, + Award: () => AW, + BarChart2: () => BC, +})); + +// Mock recharts (avoid canvas/SVG issues in test env) +vi.mock('recharts', () => ({ + LineChart: ({ children }: { children: React.ReactNode }) =>
{children}
, + Line: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +// Mock design-system components that require browser APIs +vi.mock('@/components/design-system', () => ({ + KpiCard: ({ label, value }: { label: string; value: React.ReactNode }) => ( +
+ {label} + {value} +
+ ), + DataTable: () =>
, + EmptyState: ({ title }: { title: string }) =>
{title}
, + StatusChip: ({ status }: { status: string }) => {status}, })); // Mock i18n/navigation @@ -30,19 +58,16 @@ vi.mock('@/i18n/navigation', () => ({ ), })); -// Mock currency -vi.mock('@/lib/currency', () => ({ - formatPrice: (price: string) => { - const n = Number(price); - return n >= 1_000_000_000 ? `${(n / 1_000_000_000).toFixed(1)} tỷ` : String(n); - }, -})); - // Mock image-blur vi.mock('@/lib/image-blur', () => ({ shimmerBlurDataURL: () => 'data:image/svg+xml;base64,mock', })); +// Mock inquiry modal +vi.mock('@/components/listings/inquiry-modal', () => ({ + InquiryModal: () => null, +})); + function makeAgent(overrides: Partial = {}): AgentPublicProfile { return { id: 'agent-1', @@ -79,96 +104,98 @@ function makeReview(overrides: Partial = {}): AgentReviewItem { }; } +const defaultProps = { listings: [] as ListingDetail[], listingsTotal: 0 }; + describe('AgentProfileClient', () => { it('renders agent name', () => { - render(); + render(); expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Nguyễn Văn A'); }); it('renders verified badge when verified', () => { - render(); - expect(screen.getByText('Đã xác minh')).toBeInTheDocument(); + render(); + expect(screen.getByText('KYC xác minh')).toBeInTheDocument(); }); it('does not render verified badge when not verified', () => { - render(); - expect(screen.queryByText('Đã xác minh')).not.toBeInTheDocument(); + render(); + expect(screen.queryByText('KYC xác minh')).not.toBeInTheDocument(); }); it('renders agency name', () => { - render(); + render(); expect(screen.getByText('Công ty BĐS ABC')).toBeInTheDocument(); }); it('renders license number', () => { - render(); + render(); expect(screen.getByText(/GPHN-2025-001/)).toBeInTheDocument(); }); it('renders bio', () => { - render(); + render(); expect(screen.getByText(/Chuyên viên tư vấn bất động sản/)).toBeInTheDocument(); }); it('renders service areas', () => { - render(); + render(); expect(screen.getByText('Quận 7')).toBeInTheDocument(); expect(screen.getByText('Quận 2')).toBeInTheDocument(); expect(screen.getByText('Nhà Bè')).toBeInTheDocument(); }); it('renders quality score', () => { - render(); - expect(screen.getByText('85')).toBeInTheDocument(); - expect(screen.getByText('Xuất sắc')).toBeInTheDocument(); + render(); + expect(screen.getAllByText('85').length).toBeGreaterThan(0); + expect(screen.getAllByText('Xuất sắc').length).toBeGreaterThan(0); }); it('renders "Tốt" for quality score 60-79', () => { - render(); + render(); expect(screen.getByText('Tốt')).toBeInTheDocument(); }); it('renders contact card', () => { - render(); + render(); expect(screen.getAllByText('Liên hệ môi giới').length).toBeGreaterThan(0); expect(screen.getAllByText('Gọi ngay').length).toBeGreaterThan(0); }); it('renders phone number', () => { - render(); + render(); expect(screen.getAllByText('0912345678').length).toBeGreaterThan(0); }); it('renders email when present', () => { - render(); + render(); expect(screen.getAllByText('nguyen@example.com').length).toBeGreaterThan(0); }); it('renders reviews section', () => { const reviews = [makeReview()]; - render(); + render(); expect(screen.getByText('Tư vấn rất nhiệt tình')).toBeInTheDocument(); expect(screen.getByText('Trần Thị B')).toBeInTheDocument(); }); - it('shows "Chưa có đánh giá nào" when no reviews', () => { - render(); - expect(screen.getByText('Chưa có đánh giá nào')).toBeInTheDocument(); + it('shows empty state when no reviews', () => { + render(); + expect(screen.getByText('Chưa có đánh giá')).toBeInTheDocument(); }); it('renders breadcrumb navigation', () => { - render(); + render(); expect(screen.getByText('Trang chủ')).toBeInTheDocument(); }); it('renders avatar placeholder when no avatarUrl', () => { - render(); + render(); expect(screen.getByText('N')).toBeInTheDocument(); // First letter of Nguyễn }); - it('renders deal count stat', () => { - render(); - expect(screen.getByText('Giao dịch')).toBeInTheDocument(); - expect(screen.getByText('45')).toBeInTheDocument(); + it('renders deal count KPI', () => { + render(); + expect(screen.getByText('Đã giao dịch')).toBeInTheDocument(); + expect(screen.getAllByText('45').length).toBeGreaterThan(0); }); }); diff --git a/apps/web/components/agents/agent-profile-client.tsx b/apps/web/components/agents/agent-profile-client.tsx index 00f872b..1ddc2e3 100644 --- a/apps/web/components/agents/agent-profile-client.tsx +++ b/apps/web/components/agents/agent-profile-client.tsx @@ -10,32 +10,236 @@ import { Star, Home, MessageSquare, + TrendingUp, + + Award, + BarChart2, } from 'lucide-react'; import Image from 'next/image'; import * as React from 'react'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import { + KpiCard, + DataTable, + EmptyState, + StatusChip, + type DataTableColumn +} from '@/components/design-system'; import { InquiryModal } from '@/components/listings/inquiry-modal'; -import { Badge } from '@/components/ui/badge'; +import { Badge as UiBadge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Link } from '@/i18n/navigation'; import type { AgentPublicProfile, AgentReviewItem } from '@/lib/agents-api'; -import { formatPrice } from '@/lib/currency'; import { shimmerBlurDataURL } from '@/lib/image-blur'; +import type { ListingDetail } from '@/lib/listings-api'; // --------------------------------------------------------------------------- -// Props +// Helpers +// --------------------------------------------------------------------------- + +const VND = new Intl.NumberFormat('vi-VN'); + +function fmtVND(value: string | number | bigint): string { + const n = typeof value === 'bigint' ? Number(value) : Number(value); + if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} tỷ`; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)} tr`; + return VND.format(n); +} + +function qualityLabel(score: number): string { + if (score >= 80) return 'Xuất sắc'; + if (score >= 60) return 'Tốt'; + if (score >= 40) return 'Trung bình'; + return 'Cần cải thiện'; +} + +function qualityColor(score: number): string { + if (score >= 80) return 'text-signal-up'; + if (score >= 60) return 'text-primary'; + if (score >= 40) return 'text-signal-neutral'; + return 'text-signal-down'; +} + +// --------------------------------------------------------------------------- +// Types // --------------------------------------------------------------------------- interface AgentProfileClientProps { agent: AgentPublicProfile; reviews: AgentReviewItem[]; + /** Agent's managed listings — fetched server-side. */ + listings: ListingDetail[]; + /** Total listing count (may exceed `listings.length` if paginated). */ + listingsTotal: number; +} + +// --------------------------------------------------------------------------- +// Listings table columns +// --------------------------------------------------------------------------- + +const listingColumns: DataTableColumn[] = [ + { + id: 'title', + header: 'Bất động sản', + cell: (row) => ( + +

+ {row.property.title} +

+

+ {row.property.district}, {row.property.city} +

+ + ), + width: '30%', + }, + { + id: 'type', + header: 'Loại', + cell: (row) => ( + + {row.transactionType === 'SALE' ? 'Bán' : 'Cho thuê'} + + ), + width: '8%', + }, + { + id: 'status', + header: 'Trạng thái', + cell: (row) => { + const s = row.status.toLowerCase() as Parameters[0]['status']; + const safe = ['active', 'pending', 'sold', 'rented', 'rejected', 'draft'].includes(s) + ? s + : 'draft'; + return ; + }, + width: '10%', + }, + { + id: 'area', + header: 'DT (m²)', + numeric: true, + align: 'right', + sortable: true, + sortValue: (row) => row.property.areaM2, + cell: (row) => ( + {row.property.areaM2} + ), + width: '8%', + }, + { + id: 'price', + header: 'Giá', + numeric: true, + align: 'right', + sortable: true, + sortValue: (row) => Number(row.priceVND), + cell: (row) => ( + + {fmtVND(row.priceVND)} + + ), + width: '12%', + }, + { + id: 'pricePerM2', + header: 'đ/m²', + numeric: true, + align: 'right', + sortable: true, + sortValue: (row) => row.pricePerM2 ?? 0, + cell: (row) => + row.pricePerM2 != null ? ( + + {fmtVND(row.pricePerM2)} + + ) : ( + + ), + width: '12%', + }, + { + id: 'views', + header: 'Lượt xem', + numeric: true, + align: 'right', + sortable: true, + sortValue: (row) => row.viewCount, + cell: (row) => ( + {VND.format(row.viewCount)} + ), + width: '10%', + }, + { + id: 'inquiries', + header: 'Liên hệ', + numeric: true, + align: 'right', + sortable: true, + sortValue: (row) => row.inquiryCount ?? 0, + cell: (row) => + row.inquiryCount != null ? ( + {row.inquiryCount} + ) : ( + + ), + width: '10%', + }, +]; + +// --------------------------------------------------------------------------- +// Performance chart — derived from real listings data +// --------------------------------------------------------------------------- + +interface MonthBucket { + month: string; + published: number; + sold: number; +} + +function buildPerformanceData(listings: ListingDetail[]): MonthBucket[] { + const map = new Map(); + + const now = new Date(); + for (let i = 11; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + const label = d.toLocaleDateString('vi-VN', { month: 'short', year: '2-digit' }); + map.set(key, { month: label, published: 0, sold: 0 }); + } + + for (const l of listings) { + const src = l.publishedAt ?? l.createdAt; + const d = new Date(src); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + const bucket = map.get(key); + if (!bucket) continue; + bucket.published++; + if (l.status === 'SOLD' || l.status === 'RENTED') bucket.sold++; + } + + return Array.from(map.values()); } // --------------------------------------------------------------------------- // Main Component // --------------------------------------------------------------------------- -export function AgentProfileClient({ agent, reviews }: AgentProfileClientProps) { +export function AgentProfileClient({ + agent, + reviews, + listings, + listingsTotal, +}: AgentProfileClientProps) { const [inquiryOpen, setInquiryOpen] = React.useState(false); const firstListing = agent.activeListings[0] ?? null; @@ -47,228 +251,375 @@ export function AgentProfileClient({ agent, reviews }: AgentProfileClientProps) } }, [firstListing, agent.phone]); + const perfData = React.useMemo(() => buildPerformanceData(listings), [listings]); + + // Derived KPIs from real data + const activeCount = listings.filter((l) => l.status === 'ACTIVE').length; + const avgPriceVND = + listings.length > 0 + ? listings.reduce((acc, l) => acc + Number(l.priceVND), 0) / listings.length + : null; + + const yearsExp = Math.floor( + (Date.now() - new Date(agent.memberSince).getTime()) / (365.25 * 24 * 3600 * 1000), + ); + return ( -
- {/* Breadcrumb */} -