feat(fe): trader-style agent profile — TEC-3061
Refactors /agents/[id] from card-avatar layout to a data-dense trading-floor style profile per TEC-3037 §5 mockup. - Profile header: avatar, KYC badge, quality score, years exp, service areas - KPI strip (5 cards): total listings, active, deals, avg price, rating - Performance line chart (12m): published vs sold, derived from real listings - Listings table (DataTable): sortable by price/area/views/inquiries, dense rows - Reviews panel: EmptyState when none, ReviewRow cards otherwise - Sticky right sidebar: contact card + quality donut + bio - fetchAgentListings() server fn (agents-server.ts) via GET /listings?agentId - SearchListingsParams.agentId added (listings-api.ts) - page.tsx fetches listings in parallel with agent + reviews - Test suite updated for new props (listings/listingsTotal) + new text copy - Web unit tests: 82/82 files pass, 697/697 tests pass Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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: () => <span data-testid="home">H</span>,
|
||||
MessageSquare: () => <span data-testid="message">M</span>,
|
||||
TrendingUp: () => <span>TU</span>,
|
||||
Award: () => <span>AW</span>,
|
||||
BarChart2: () => <span>BC</span>,
|
||||
}));
|
||||
|
||||
// Mock recharts (avoid canvas/SVG issues in test env)
|
||||
vi.mock('recharts', () => ({
|
||||
LineChart: ({ children }: { children: React.ReactNode }) => <div data-testid="line-chart">{children}</div>,
|
||||
Line: () => null,
|
||||
XAxis: () => null,
|
||||
YAxis: () => null,
|
||||
CartesianGrid: () => null,
|
||||
Tooltip: () => null,
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock design-system components that require browser APIs
|
||||
vi.mock('@/components/design-system', () => ({
|
||||
KpiCard: ({ label, value }: { label: string; value: React.ReactNode }) => (
|
||||
<div data-testid="kpi-card">
|
||||
<span>{label}</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
),
|
||||
DataTable: () => <div data-testid="data-table" />,
|
||||
EmptyState: ({ title }: { title: string }) => <div data-testid="empty-state">{title}</div>,
|
||||
StatusChip: ({ status }: { status: string }) => <span data-testid="status-chip">{status}</span>,
|
||||
}));
|
||||
|
||||
// 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> = {}): AgentPublicProfile {
|
||||
return {
|
||||
id: 'agent-1',
|
||||
@@ -79,96 +104,98 @@ function makeReview(overrides: Partial<AgentReviewItem> = {}): AgentReviewItem {
|
||||
};
|
||||
}
|
||||
|
||||
const defaultProps = { listings: [] as ListingDetail[], listingsTotal: 0 };
|
||||
|
||||
describe('AgentProfileClient', () => {
|
||||
it('renders agent name', () => {
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Nguyễn Văn A');
|
||||
});
|
||||
|
||||
it('renders verified badge when verified', () => {
|
||||
render(<AgentProfileClient agent={makeAgent({ isVerified: true })} reviews={[]} />);
|
||||
expect(screen.getByText('Đã xác minh')).toBeInTheDocument();
|
||||
render(<AgentProfileClient agent={makeAgent({ isVerified: true })} reviews={[]} {...defaultProps} />);
|
||||
expect(screen.getByText('KYC xác minh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render verified badge when not verified', () => {
|
||||
render(<AgentProfileClient agent={makeAgent({ isVerified: false })} reviews={[]} />);
|
||||
expect(screen.queryByText('Đã xác minh')).not.toBeInTheDocument();
|
||||
render(<AgentProfileClient agent={makeAgent({ isVerified: false })} reviews={[]} {...defaultProps} />);
|
||||
expect(screen.queryByText('KYC xác minh')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders agency name', () => {
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||
expect(screen.getByText('Công ty BĐS ABC')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders license number', () => {
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||
expect(screen.getByText(/GPHN-2025-001/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders bio', () => {
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||
expect(screen.getByText(/Chuyên viên tư vấn bất động sản/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders service areas', () => {
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||
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(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
||||
expect(screen.getByText('85')).toBeInTheDocument();
|
||||
expect(screen.getByText('Xuất sắc')).toBeInTheDocument();
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||
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(<AgentProfileClient agent={makeAgent({ qualityScore: 70 })} reviews={[]} />);
|
||||
render(<AgentProfileClient agent={makeAgent({ qualityScore: 70 })} reviews={[]} {...defaultProps} />);
|
||||
expect(screen.getByText('Tốt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders contact card', () => {
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||
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(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||
expect(screen.getAllByText('0912345678').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders email when present', () => {
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||
expect(screen.getAllByText('nguyen@example.com').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders reviews section', () => {
|
||||
const reviews = [makeReview()];
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={reviews} />);
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={reviews} {...defaultProps} />);
|
||||
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(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
||||
expect(screen.getByText('Chưa có đánh giá nào')).toBeInTheDocument();
|
||||
it('shows empty state when no reviews', () => {
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||
expect(screen.getByText('Chưa có đánh giá')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders breadcrumb navigation', () => {
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||
expect(screen.getByText('Trang chủ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders avatar placeholder when no avatarUrl', () => {
|
||||
render(<AgentProfileClient agent={makeAgent({ avatarUrl: null })} reviews={[]} />);
|
||||
render(<AgentProfileClient agent={makeAgent({ avatarUrl: null })} reviews={[]} {...defaultProps} />);
|
||||
expect(screen.getByText('N')).toBeInTheDocument(); // First letter of Nguyễn
|
||||
});
|
||||
|
||||
it('renders deal count stat', () => {
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
||||
expect(screen.getByText('Giao dịch')).toBeInTheDocument();
|
||||
expect(screen.getByText('45')).toBeInTheDocument();
|
||||
it('renders deal count KPI', () => {
|
||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||
expect(screen.getByText('Đã giao dịch')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('45').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user