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 */}
-