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:
@@ -6,7 +6,11 @@ import {
|
|||||||
generateAgentJsonLd,
|
generateAgentJsonLd,
|
||||||
generateBreadcrumbJsonLd,
|
generateBreadcrumbJsonLd,
|
||||||
} from '@/components/seo/json-ld';
|
} from '@/components/seo/json-ld';
|
||||||
import { fetchAgentProfile, fetchAgentReviews } from '@/lib/agents-server';
|
import {
|
||||||
|
fetchAgentProfile,
|
||||||
|
fetchAgentReviews,
|
||||||
|
fetchAgentListings,
|
||||||
|
} from '@/lib/agents-server';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -85,9 +89,10 @@ export async function generateMetadata({ params: paramsPromise }: PageProps): Pr
|
|||||||
|
|
||||||
export default async function AgentProfilePage({ params: paramsPromise }: PageProps) {
|
export default async function AgentProfilePage({ params: paramsPromise }: PageProps) {
|
||||||
const params = await paramsPromise;
|
const params = await paramsPromise;
|
||||||
const [agent, reviewsResult] = await Promise.all([
|
const [agent, reviewsResult, listingsResult] = await Promise.all([
|
||||||
fetchAgentProfile(params.id),
|
fetchAgentProfile(params.id),
|
||||||
fetchAgentReviews(params.id, 1, 10),
|
fetchAgentReviews(params.id, 1, 10),
|
||||||
|
fetchAgentListings(params.id, 1, 50),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
@@ -98,6 +103,7 @@ export default async function AgentProfilePage({ params: paramsPromise }: PagePr
|
|||||||
const agentJsonLd = generateAgentJsonLd(agent, siteUrl);
|
const agentJsonLd = generateAgentJsonLd(agent, siteUrl);
|
||||||
const breadcrumbJsonLd = generateBreadcrumbJsonLd([
|
const breadcrumbJsonLd = generateBreadcrumbJsonLd([
|
||||||
{ name: 'Trang chủ', url: siteUrl },
|
{ 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}` },
|
{ name: agent.fullName, url: `${siteUrl}/${params.locale}/agents/${params.id}` },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -108,7 +114,12 @@ export default async function AgentProfilePage({ params: paramsPromise }: PagePr
|
|||||||
<JsonLd data={breadcrumbJsonLd} />
|
<JsonLd data={breadcrumbJsonLd} />
|
||||||
|
|
||||||
{/* Interactive client component */}
|
{/* Interactive client component */}
|
||||||
<AgentProfileClient agent={agent} reviews={reviewsResult.data} />
|
<AgentProfileClient
|
||||||
|
agent={agent}
|
||||||
|
reviews={reviewsResult.data}
|
||||||
|
listings={listingsResult.data}
|
||||||
|
listingsTotal={listingsResult.total}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import type { AgentPublicProfile, AgentReviewItem } from '@/lib/agents-api';
|
import type { AgentPublicProfile, AgentReviewItem } from '@/lib/agents-api';
|
||||||
|
import type { ListingDetail } from '@/lib/listings-api';
|
||||||
import { AgentProfileClient } from '../agent-profile-client';
|
import { AgentProfileClient } from '../agent-profile-client';
|
||||||
|
|
||||||
// Mock next/image
|
// Mock next/image
|
||||||
@@ -21,6 +22,33 @@ vi.mock('lucide-react', () => ({
|
|||||||
),
|
),
|
||||||
Home: () => <span data-testid="home">H</span>,
|
Home: () => <span data-testid="home">H</span>,
|
||||||
MessageSquare: () => <span data-testid="message">M</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
|
// 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
|
// Mock image-blur
|
||||||
vi.mock('@/lib/image-blur', () => ({
|
vi.mock('@/lib/image-blur', () => ({
|
||||||
shimmerBlurDataURL: () => 'data:image/svg+xml;base64,mock',
|
shimmerBlurDataURL: () => 'data:image/svg+xml;base64,mock',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock inquiry modal
|
||||||
|
vi.mock('@/components/listings/inquiry-modal', () => ({
|
||||||
|
InquiryModal: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
function makeAgent(overrides: Partial<AgentPublicProfile> = {}): AgentPublicProfile {
|
function makeAgent(overrides: Partial<AgentPublicProfile> = {}): AgentPublicProfile {
|
||||||
return {
|
return {
|
||||||
id: 'agent-1',
|
id: 'agent-1',
|
||||||
@@ -79,96 +104,98 @@ function makeReview(overrides: Partial<AgentReviewItem> = {}): AgentReviewItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultProps = { listings: [] as ListingDetail[], listingsTotal: 0 };
|
||||||
|
|
||||||
describe('AgentProfileClient', () => {
|
describe('AgentProfileClient', () => {
|
||||||
it('renders agent name', () => {
|
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');
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Nguyễn Văn A');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders verified badge when verified', () => {
|
it('renders verified badge when verified', () => {
|
||||||
render(<AgentProfileClient agent={makeAgent({ isVerified: true })} reviews={[]} />);
|
render(<AgentProfileClient agent={makeAgent({ isVerified: true })} reviews={[]} {...defaultProps} />);
|
||||||
expect(screen.getByText('Đã xác minh')).toBeInTheDocument();
|
expect(screen.getByText('KYC xác minh')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render verified badge when not verified', () => {
|
it('does not render verified badge when not verified', () => {
|
||||||
render(<AgentProfileClient agent={makeAgent({ isVerified: false })} reviews={[]} />);
|
render(<AgentProfileClient agent={makeAgent({ isVerified: false })} reviews={[]} {...defaultProps} />);
|
||||||
expect(screen.queryByText('Đã xác minh')).not.toBeInTheDocument();
|
expect(screen.queryByText('KYC xác minh')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders agency name', () => {
|
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();
|
expect(screen.getByText('Công ty BĐS ABC')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders license number', () => {
|
it('renders license number', () => {
|
||||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||||
expect(screen.getByText(/GPHN-2025-001/)).toBeInTheDocument();
|
expect(screen.getByText(/GPHN-2025-001/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders bio', () => {
|
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();
|
expect(screen.getByText(/Chuyên viên tư vấn bất động sản/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders service areas', () => {
|
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 7')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Quận 2')).toBeInTheDocument();
|
expect(screen.getByText('Quận 2')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Nhà Bè')).toBeInTheDocument();
|
expect(screen.getByText('Nhà Bè')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders quality score', () => {
|
it('renders quality score', () => {
|
||||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||||
expect(screen.getByText('85')).toBeInTheDocument();
|
expect(screen.getAllByText('85').length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('Xuất sắc')).toBeInTheDocument();
|
expect(screen.getAllByText('Xuất sắc').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders "Tốt" for quality score 60-79', () => {
|
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();
|
expect(screen.getByText('Tốt')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders contact card', () => {
|
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('Liên hệ môi giới').length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText('Gọi ngay').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Gọi ngay').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders phone number', () => {
|
it('renders phone number', () => {
|
||||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||||
expect(screen.getAllByText('0912345678').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('0912345678').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders email when present', () => {
|
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);
|
expect(screen.getAllByText('nguyen@example.com').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders reviews section', () => {
|
it('renders reviews section', () => {
|
||||||
const reviews = [makeReview()];
|
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('Tư vấn rất nhiệt tình')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Trần Thị B')).toBeInTheDocument();
|
expect(screen.getByText('Trần Thị B')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows "Chưa có đánh giá nào" when no reviews', () => {
|
it('shows empty state when no reviews', () => {
|
||||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||||
expect(screen.getByText('Chưa có đánh giá nào')).toBeInTheDocument();
|
expect(screen.getByText('Chưa có đánh giá')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders breadcrumb navigation', () => {
|
it('renders breadcrumb navigation', () => {
|
||||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||||
expect(screen.getByText('Trang chủ')).toBeInTheDocument();
|
expect(screen.getByText('Trang chủ')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders avatar placeholder when no avatarUrl', () => {
|
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
|
expect(screen.getByText('N')).toBeInTheDocument(); // First letter of Nguyễn
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders deal count stat', () => {
|
it('renders deal count KPI', () => {
|
||||||
render(<AgentProfileClient agent={makeAgent()} reviews={[]} />);
|
render(<AgentProfileClient agent={makeAgent()} reviews={[]} {...defaultProps} />);
|
||||||
expect(screen.getByText('Giao dịch')).toBeInTheDocument();
|
expect(screen.getByText('Đã giao dịch')).toBeInTheDocument();
|
||||||
expect(screen.getByText('45')).toBeInTheDocument();
|
expect(screen.getAllByText('45').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,32 +10,236 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Home,
|
Home,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
TrendingUp,
|
||||||
|
|
||||||
|
Award,
|
||||||
|
BarChart2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import * as React from 'react';
|
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 { 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 { 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 { Link } from '@/i18n/navigation';
|
||||||
import type { AgentPublicProfile, AgentReviewItem } from '@/lib/agents-api';
|
import type { AgentPublicProfile, AgentReviewItem } from '@/lib/agents-api';
|
||||||
import { formatPrice } from '@/lib/currency';
|
|
||||||
import { shimmerBlurDataURL } from '@/lib/image-blur';
|
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 {
|
interface AgentProfileClientProps {
|
||||||
agent: AgentPublicProfile;
|
agent: AgentPublicProfile;
|
||||||
reviews: AgentReviewItem[];
|
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<ListingDetail>[] = [
|
||||||
|
{
|
||||||
|
id: 'title',
|
||||||
|
header: 'Bất động sản',
|
||||||
|
cell: (row) => (
|
||||||
|
<Link href={`/listings/${row.id}` as never} className="block min-w-0">
|
||||||
|
<p className="truncate text-xs font-medium text-foreground hover:text-primary transition-colors">
|
||||||
|
{row.property.title}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-foreground-muted">
|
||||||
|
{row.property.district}, {row.property.city}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
width: '30%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'type',
|
||||||
|
header: 'Loại',
|
||||||
|
cell: (row) => (
|
||||||
|
<span className="text-xs text-foreground-muted">
|
||||||
|
{row.transactionType === 'SALE' ? 'Bán' : 'Cho thuê'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
width: '8%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Trạng thái',
|
||||||
|
cell: (row) => {
|
||||||
|
const s = row.status.toLowerCase() as Parameters<typeof StatusChip>[0]['status'];
|
||||||
|
const safe = ['active', 'pending', 'sold', 'rented', 'rejected', 'draft'].includes(s)
|
||||||
|
? s
|
||||||
|
: 'draft';
|
||||||
|
return <StatusChip status={safe} />;
|
||||||
|
},
|
||||||
|
width: '10%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'area',
|
||||||
|
header: 'DT (m²)',
|
||||||
|
numeric: true,
|
||||||
|
align: 'right',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (row) => row.property.areaM2,
|
||||||
|
cell: (row) => (
|
||||||
|
<span className="text-xs tabular-nums text-foreground">{row.property.areaM2}</span>
|
||||||
|
),
|
||||||
|
width: '8%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'price',
|
||||||
|
header: 'Giá',
|
||||||
|
numeric: true,
|
||||||
|
align: 'right',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (row) => Number(row.priceVND),
|
||||||
|
cell: (row) => (
|
||||||
|
<span className="text-xs font-semibold tabular-nums text-primary">
|
||||||
|
{fmtVND(row.priceVND)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
width: '12%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pricePerM2',
|
||||||
|
header: 'đ/m²',
|
||||||
|
numeric: true,
|
||||||
|
align: 'right',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (row) => row.pricePerM2 ?? 0,
|
||||||
|
cell: (row) =>
|
||||||
|
row.pricePerM2 != null ? (
|
||||||
|
<span className="text-xs tabular-nums text-foreground-muted">
|
||||||
|
{fmtVND(row.pricePerM2)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-foreground-dim">—</span>
|
||||||
|
),
|
||||||
|
width: '12%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'views',
|
||||||
|
header: 'Lượt xem',
|
||||||
|
numeric: true,
|
||||||
|
align: 'right',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (row) => row.viewCount,
|
||||||
|
cell: (row) => (
|
||||||
|
<span className="text-xs tabular-nums text-foreground-muted">{VND.format(row.viewCount)}</span>
|
||||||
|
),
|
||||||
|
width: '10%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inquiries',
|
||||||
|
header: 'Liên hệ',
|
||||||
|
numeric: true,
|
||||||
|
align: 'right',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (row) => row.inquiryCount ?? 0,
|
||||||
|
cell: (row) =>
|
||||||
|
row.inquiryCount != null ? (
|
||||||
|
<span className="text-xs tabular-nums text-foreground-muted">{row.inquiryCount}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-foreground-dim">—</span>
|
||||||
|
),
|
||||||
|
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<string, MonthBucket>();
|
||||||
|
|
||||||
|
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
|
// Main Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function AgentProfileClient({ agent, reviews }: AgentProfileClientProps) {
|
export function AgentProfileClient({
|
||||||
|
agent,
|
||||||
|
reviews,
|
||||||
|
listings,
|
||||||
|
listingsTotal,
|
||||||
|
}: AgentProfileClientProps) {
|
||||||
const [inquiryOpen, setInquiryOpen] = React.useState(false);
|
const [inquiryOpen, setInquiryOpen] = React.useState(false);
|
||||||
const firstListing = agent.activeListings[0] ?? null;
|
const firstListing = agent.activeListings[0] ?? null;
|
||||||
|
|
||||||
@@ -47,228 +251,375 @@ export function AgentProfileClient({ agent, reviews }: AgentProfileClientProps)
|
|||||||
}
|
}
|
||||||
}, [firstListing, agent.phone]);
|
}, [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 (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
<div className="mx-auto max-w-7xl px-4 py-6 space-y-6">
|
||||||
{/* Breadcrumb */}
|
{/* ── Breadcrumb ── */}
|
||||||
<nav className="mb-4 flex items-center gap-1.5 text-sm text-muted-foreground">
|
<nav className="flex items-center gap-1.5 text-xs text-foreground-muted">
|
||||||
<Link href="/" className="hover:text-foreground">Trang chủ</Link>
|
<Link href="/" className="hover:text-foreground transition-colors">Trang chủ</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link href="/agents" className="hover:text-foreground transition-colors">Môi giới</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="truncate text-foreground">{agent.fullName}</span>
|
<span className="truncate text-foreground">{agent.fullName}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Profile Header */}
|
{/* ── Profile Header ── */}
|
||||||
<div className="mb-8 flex flex-col gap-6 sm:flex-row sm:items-start">
|
<div className="rounded-lg border border-border bg-background-elevated shadow-elevation-1 p-6">
|
||||||
{/* Avatar */}
|
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
|
||||||
<div className="shrink-0">
|
{/* Avatar */}
|
||||||
{agent.avatarUrl ? (
|
<div className="shrink-0">
|
||||||
<Image
|
{agent.avatarUrl ? (
|
||||||
src={agent.avatarUrl}
|
<Image
|
||||||
alt={agent.fullName}
|
src={agent.avatarUrl}
|
||||||
width={120}
|
alt={agent.fullName}
|
||||||
height={120}
|
width={96}
|
||||||
className="h-28 w-28 rounded-full border-4 border-primary/10 object-cover sm:h-32 sm:w-32"
|
height={96}
|
||||||
/>
|
className="h-24 w-24 rounded-full border-2 border-primary/20 object-cover"
|
||||||
) : (
|
placeholder="blur"
|
||||||
<div className="flex h-28 w-28 items-center justify-center rounded-full border-4 border-primary/10 bg-primary/5 sm:h-32 sm:w-32">
|
blurDataURL={shimmerBlurDataURL()}
|
||||||
<span className="text-4xl font-bold text-primary">
|
/>
|
||||||
{agent.fullName.charAt(0).toUpperCase()}
|
) : (
|
||||||
</span>
|
<div className="flex h-24 w-24 items-center justify-center rounded-full border-2 border-primary/20 bg-primary/10">
|
||||||
</div>
|
<span className="text-3xl font-bold text-primary">
|
||||||
)}
|
{agent.fullName.charAt(0).toUpperCase()}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
{/* Agent Info */}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
|
||||||
<h1 className="text-2xl font-bold md:text-3xl">{agent.fullName}</h1>
|
|
||||||
{agent.isVerified && (
|
|
||||||
<Badge variant="success" className="gap-1">
|
|
||||||
<BadgeCheck className="h-3.5 w-3.5" />
|
|
||||||
Đã xác minh
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{agent.agency && (
|
{/* Info */}
|
||||||
<p className="mb-1 flex items-center gap-1.5 text-muted-foreground">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<Building2 className="h-4 w-4 shrink-0" />
|
{/* Name + badges */}
|
||||||
{agent.agency}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</p>
|
<h1 className="text-xl font-bold text-foreground md:text-2xl">{agent.fullName}</h1>
|
||||||
)}
|
{agent.isVerified && (
|
||||||
|
<UiBadge variant="success" className="gap-1 text-xs">
|
||||||
|
<BadgeCheck className="h-3 w-3" />
|
||||||
|
KYC xác minh
|
||||||
|
</UiBadge>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`text-sm font-semibold tabular-nums ${qualityColor(agent.qualityScore)}`}
|
||||||
|
title="Điểm chất lượng"
|
||||||
|
>
|
||||||
|
★ {agent.qualityScore}/100 · {qualityLabel(agent.qualityScore)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{agent.licenseNumber && (
|
{/* Meta */}
|
||||||
<p className="mb-1 text-sm text-muted-foreground">
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-foreground-muted">
|
||||||
Mã giấy phép: {agent.licenseNumber}
|
{agent.agency && (
|
||||||
</p>
|
<span className="flex items-center gap-1">
|
||||||
)}
|
<Building2 className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
{agent.agency}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{agent.licenseNumber && (
|
||||||
|
<span>Giấy phép: {agent.licenseNumber}</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
{yearsExp > 0 ? `${yearsExp} năm kinh nghiệm` : 'Mới tham gia'} · từ{' '}
|
||||||
|
{new Date(agent.memberSince).toLocaleDateString('vi-VN', {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
{/* Service areas */}
|
||||||
<Calendar className="h-4 w-4 shrink-0" />
|
{agent.serviceAreas.length > 0 && (
|
||||||
Thành viên từ {new Date(agent.memberSince).toLocaleDateString('vi-VN', {
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
month: 'long',
|
{agent.serviceAreas.map((area) => (
|
||||||
year: 'numeric',
|
<span
|
||||||
})}
|
key={area}
|
||||||
</p>
|
className="inline-flex items-center gap-1 rounded-md bg-background-surface px-2 py-0.5 text-xs text-foreground-muted ring-1 ring-inset ring-border"
|
||||||
|
>
|
||||||
{/* Quick stats */}
|
<MapPin className="h-3 w-3 shrink-0" />
|
||||||
<div className="mt-4 flex flex-wrap gap-4">
|
{area}
|
||||||
<StatPill
|
</span>
|
||||||
icon={<Star className="h-4 w-4 text-yellow-500" />}
|
))}
|
||||||
label="Đánh giá"
|
</div>
|
||||||
value={agent.totalReviews > 0
|
)}
|
||||||
? `${agent.avgReviewRating}/5 (${agent.totalReviews})`
|
|
||||||
: 'Chưa có'}
|
|
||||||
/>
|
|
||||||
<StatPill
|
|
||||||
icon={<Home className="h-4 w-4 text-primary" />}
|
|
||||||
label="Tin đăng"
|
|
||||||
value={`${agent.activeListings.length}`}
|
|
||||||
/>
|
|
||||||
<StatPill
|
|
||||||
icon={<BadgeCheck className="h-4 w-4 text-green-500" />}
|
|
||||||
label="Giao dịch"
|
|
||||||
value={`${agent.totalDeals}`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CTA Sidebar (desktop) */}
|
{/* Desktop CTA */}
|
||||||
<div className="hidden shrink-0 sm:block">
|
<div className="hidden shrink-0 sm:block">
|
||||||
<ContactCard agent={agent} onMessageClick={handleMessageClick} />
|
<ContactCard agent={agent} onMessageClick={handleMessageClick} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── KPI Strip ── */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
<KpiCard
|
||||||
|
label="Tổng tin đăng"
|
||||||
|
value={VND.format(listingsTotal)}
|
||||||
|
icon={<Home className="h-4 w-4" />}
|
||||||
|
footnote="Tất cả thời gian"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Đang hoạt động"
|
||||||
|
value={VND.format(activeCount)}
|
||||||
|
icon={<TrendingUp className="h-4 w-4" />}
|
||||||
|
footnote="Đang rao bán"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Đã giao dịch"
|
||||||
|
value={VND.format(agent.totalDeals)}
|
||||||
|
icon={<Award className="h-4 w-4" />}
|
||||||
|
footnote="Tổng deals thành công"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Giá TB"
|
||||||
|
value={avgPriceVND != null ? fmtVND(avgPriceVND) : '—'}
|
||||||
|
icon={<BarChart2 className="h-4 w-4" />}
|
||||||
|
footnote="Danh mục hiện tại"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Đánh giá"
|
||||||
|
value={
|
||||||
|
agent.totalReviews > 0
|
||||||
|
? `${agent.avgReviewRating.toFixed(1)}/5`
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
icon={<Star className="h-4 w-4" />}
|
||||||
|
footnote={
|
||||||
|
agent.totalReviews > 0
|
||||||
|
? `${agent.totalReviews} lượt đánh giá`
|
||||||
|
: 'Chưa có đánh giá'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main Grid ── */}
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
{/* Main Content */}
|
{/* Left column — chart + table + reviews */}
|
||||||
<div className="space-y-6 lg:col-span-2">
|
<div className="space-y-6 lg:col-span-2">
|
||||||
{/* Bio */}
|
{/* Performance Chart */}
|
||||||
{agent.bio && (
|
<Card className="border-border bg-background-elevated shadow-elevation-1">
|
||||||
<Card>
|
<CardHeader className="pb-2">
|
||||||
<CardHeader>
|
<CardTitle className="text-sm font-semibold text-foreground">
|
||||||
<CardTitle>Giới thiệu</CardTitle>
|
Hiệu suất 12 tháng — tin đăng & giao dịch
|
||||||
</CardHeader>
|
</CardTitle>
|
||||||
<CardContent>
|
<CardDescription className="text-xs text-foreground-muted">
|
||||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{agent.bio}</p>
|
Tổng hợp từ danh mục thực
|
||||||
</CardContent>
|
</CardDescription>
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Service Areas */}
|
|
||||||
{agent.serviceAreas.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Khu vực hoạt động</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{agent.serviceAreas.map((area) => (
|
|
||||||
<Badge key={area} variant="secondary" className="gap-1">
|
|
||||||
<MapPin className="h-3 w-3" />
|
|
||||||
{area}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quality Score */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Chỉ số chất lượng</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center gap-4">
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
<div className="relative h-20 w-20">
|
<LineChart data={perfData} margin={{ top: 4, right: 16, left: -16, bottom: 0 }}>
|
||||||
<svg className="h-20 w-20 -rotate-90 transform" viewBox="0 0 36 36">
|
<CartesianGrid
|
||||||
<path
|
strokeDasharray="3 3"
|
||||||
className="text-muted"
|
stroke="hsl(var(--border))"
|
||||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
strokeOpacity={0.5}
|
||||||
fill="none"
|
/>
|
||||||
stroke="currentColor"
|
<XAxis
|
||||||
strokeWidth="3"
|
dataKey="month"
|
||||||
/>
|
tick={{ fontSize: 10, fill: 'hsl(var(--foreground-muted))' }}
|
||||||
<path
|
tickLine={false}
|
||||||
className="text-primary"
|
axisLine={false}
|
||||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
/>
|
||||||
fill="none"
|
<YAxis
|
||||||
stroke="currentColor"
|
tick={{ fontSize: 10, fill: 'hsl(var(--foreground-muted))' }}
|
||||||
strokeWidth="3"
|
tickLine={false}
|
||||||
strokeDasharray={`${agent.qualityScore}, 100`}
|
axisLine={false}
|
||||||
strokeLinecap="round"
|
allowDecimals={false}
|
||||||
/>
|
/>
|
||||||
</svg>
|
<Tooltip
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
contentStyle={{
|
||||||
<span className="text-lg font-bold">{agent.qualityScore}</span>
|
backgroundColor: 'hsl(var(--background-elevated))',
|
||||||
</div>
|
border: '1px solid hsl(var(--border))',
|
||||||
</div>
|
borderRadius: '0.5rem',
|
||||||
<div>
|
fontSize: '0.75rem',
|
||||||
<p className="font-medium">
|
}}
|
||||||
{agent.qualityScore >= 80
|
formatter={(value, name) => [
|
||||||
? 'Xuất sắc'
|
value,
|
||||||
: agent.qualityScore >= 60
|
name === 'published' ? 'Tin đăng' : 'Giao dịch',
|
||||||
? 'Tốt'
|
]}
|
||||||
: agent.qualityScore >= 40
|
/>
|
||||||
? 'Trung bình'
|
<Line
|
||||||
: 'Cần cải thiện'}
|
type="monotone"
|
||||||
</p>
|
dataKey="published"
|
||||||
<p className="text-sm text-muted-foreground">
|
stroke="hsl(var(--chart-1))"
|
||||||
Dựa trên phản hồi, thời gian phản hồi và giao dịch thành công
|
strokeWidth={2}
|
||||||
</p>
|
dot={false}
|
||||||
</div>
|
name="published"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="sold"
|
||||||
|
stroke="hsl(var(--signal-up))"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
name="sold"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="mt-2 flex items-center gap-4 text-xs text-foreground-muted">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-4 rounded-sm" style={{ background: 'hsl(var(--chart-1))' }} />
|
||||||
|
Tin đăng
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="inline-block h-2 w-4 rounded-sm" style={{ background: 'hsl(var(--signal-up))' }} />
|
||||||
|
Giao dịch thành công
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Active Listings */}
|
{/* Listings Table */}
|
||||||
{agent.activeListings.length > 0 && (
|
<Card className="border-border bg-background-elevated shadow-elevation-1">
|
||||||
<Card>
|
<CardHeader className="pb-2">
|
||||||
<CardHeader>
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>Tin đăng đang hoạt động ({agent.activeListings.length})</CardTitle>
|
<CardTitle className="text-sm font-semibold text-foreground">
|
||||||
</CardHeader>
|
Danh mục bất động sản{' '}
|
||||||
<CardContent>
|
<span className="ml-1 text-foreground-muted tabular-nums font-normal">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
({VND.format(listingsTotal)})
|
||||||
{agent.activeListings.map((listing) => (
|
</span>
|
||||||
<ListingCard key={listing.id} listing={listing} />
|
</CardTitle>
|
||||||
))}
|
</div>
|
||||||
</div>
|
<CardDescription className="text-xs text-foreground-muted">
|
||||||
</CardContent>
|
Sắp xếp theo giá, diện tích, lượt xem
|
||||||
</Card>
|
</CardDescription>
|
||||||
)}
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<DataTable
|
||||||
|
columns={listingColumns}
|
||||||
|
data={listings}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
defaultSortId="price"
|
||||||
|
defaultSortDir="desc"
|
||||||
|
dense
|
||||||
|
stickyHeader
|
||||||
|
emptyText={
|
||||||
|
<EmptyState
|
||||||
|
icon={<Home className="h-6 w-6" />}
|
||||||
|
title="Chưa có tin đăng"
|
||||||
|
description="Môi giới này chưa có bất động sản nào"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Reviews */}
|
{/* Reviews */}
|
||||||
<Card>
|
<Card className="border-border bg-background-elevated shadow-elevation-1">
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardTitle>Đánh giá ({agent.totalReviews})</CardTitle>
|
<CardTitle className="text-sm font-semibold text-foreground">
|
||||||
|
Đánh giá{' '}
|
||||||
|
<span className="ml-1 text-foreground-muted font-normal tabular-nums">
|
||||||
|
({agent.totalReviews})
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
{agent.totalReviews > 0 && (
|
||||||
|
<CardDescription className="text-xs text-foreground-muted">
|
||||||
|
Trung bình{' '}
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{agent.avgReviewRating.toFixed(1)}/5
|
||||||
|
</span>
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{reviews.length > 0 ? (
|
{reviews.length > 0 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{reviews.map((review) => (
|
{reviews.map((review) => (
|
||||||
<ReviewCard key={review.id} review={review} />
|
<ReviewRow key={review.id} review={review} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center text-sm text-muted-foreground py-4">
|
<EmptyState
|
||||||
Chưa có đánh giá nào
|
icon={<Star className="h-6 w-6" />}
|
||||||
</p>
|
title="Chưa có đánh giá"
|
||||||
|
description="Chưa có khách hàng nào để lại đánh giá cho môi giới này"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar (mobile + desktop fallback) */}
|
{/* Right column — sticky contact + quality + bio */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 lg:sticky lg:top-20 lg:self-start">
|
||||||
|
{/* Mobile contact */}
|
||||||
<div className="sm:hidden">
|
<div className="sm:hidden">
|
||||||
<ContactCard agent={agent} onMessageClick={handleMessageClick} />
|
<ContactCard agent={agent} onMessageClick={handleMessageClick} />
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block lg:block">
|
|
||||||
<div className="lg:sticky lg:top-20">
|
{/* Desktop contact (hidden on mobile, shown lg via sticky container) */}
|
||||||
<div className="hidden lg:block">
|
<div className="hidden sm:block">
|
||||||
<ContactCard agent={agent} onMessageClick={handleMessageClick} />
|
<ContactCard agent={agent} onMessageClick={handleMessageClick} />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Quality Score */}
|
||||||
|
<Card className="border-border bg-background-elevated shadow-elevation-1">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-semibold text-foreground">Chất lượng môi giới</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Donut */}
|
||||||
|
<div className="relative h-16 w-16 shrink-0">
|
||||||
|
<svg className="h-16 w-16 -rotate-90" viewBox="0 0 36 36">
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
strokeWidth="3"
|
||||||
|
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="hsl(var(--primary))"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeDasharray={`${agent.qualityScore}, 100`}
|
||||||
|
strokeLinecap="round"
|
||||||
|
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-sm font-bold tabular-nums text-foreground">
|
||||||
|
{agent.qualityScore}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className={`text-sm font-semibold ${qualityColor(agent.qualityScore)}`}>
|
||||||
|
{qualityLabel(agent.qualityScore)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-foreground-muted">
|
||||||
|
Dựa trên phản hồi, thời gian phản hồi và deals thành công
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Bio */}
|
||||||
|
{agent.bio && (
|
||||||
|
<Card className="border-border bg-background-elevated shadow-elevation-1">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-semibold text-foreground">Giới thiệu</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="whitespace-pre-wrap text-xs leading-relaxed text-foreground-muted">
|
||||||
|
{agent.bio}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -289,59 +640,47 @@ export function AgentProfileClient({ agent, reviews }: AgentProfileClientProps)
|
|||||||
// Sub-Components
|
// Sub-Components
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function StatPill({
|
function ContactCard({
|
||||||
icon,
|
agent,
|
||||||
label,
|
onMessageClick,
|
||||||
value,
|
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
agent: AgentPublicProfile;
|
||||||
label: string;
|
onMessageClick: () => void;
|
||||||
value: string;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-lg border bg-card px-3 py-2">
|
<Card className="border-border bg-background-elevated shadow-elevation-1">
|
||||||
{icon}
|
<CardHeader className="pb-2">
|
||||||
<div>
|
<CardTitle className="text-sm font-semibold text-foreground">Liên hệ môi giới</CardTitle>
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
|
||||||
<p className="text-sm font-semibold">{value}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContactCard({ agent, onMessageClick }: { agent: AgentPublicProfile; onMessageClick: () => void }) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">Liên hệ môi giới</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-2">
|
||||||
<a href={`tel:${agent.phone}`} className="block">
|
<a href={`tel:${agent.phone}`} className="block">
|
||||||
<Button className="w-full gap-2">
|
<Button className="w-full gap-2" size="sm">
|
||||||
<Phone className="h-4 w-4" />
|
<Phone className="h-3.5 w-3.5" />
|
||||||
Gọi ngay
|
Gọi ngay
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
<Button variant="outline" className="w-full gap-2" onClick={onMessageClick}>
|
<Button variant="outline" className="w-full gap-2" size="sm" onClick={onMessageClick}>
|
||||||
<MessageSquare className="h-4 w-4" />
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
Nhắn tin
|
Nhắn tin
|
||||||
</Button>
|
</Button>
|
||||||
{agent.email && (
|
{agent.email && (
|
||||||
<a href={`mailto:${agent.email}`} className="block">
|
<a href={`mailto:${agent.email}`} className="block">
|
||||||
<Button variant="outline" className="w-full gap-2">
|
<Button variant="outline" className="w-full gap-2" size="sm">
|
||||||
<Mail className="h-4 w-4" />
|
<Mail className="h-3.5 w-3.5" />
|
||||||
Email
|
Email
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<div className="border-t pt-3">
|
<div className="border-t border-border pt-2 space-y-1.5">
|
||||||
<p className="text-xs text-muted-foreground">Số điện thoại</p>
|
<div>
|
||||||
<p className="text-sm font-medium">{agent.phone}</p>
|
<p className="text-xs text-foreground-dim">Số điện thoại</p>
|
||||||
|
<p className="text-xs font-medium text-foreground">{agent.phone}</p>
|
||||||
|
</div>
|
||||||
{agent.email && (
|
{agent.email && (
|
||||||
<>
|
<div>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">Email</p>
|
<p className="text-xs text-foreground-dim">Email</p>
|
||||||
<p className="text-sm font-medium">{agent.email}</p>
|
<p className="text-xs font-medium text-foreground">{agent.email}</p>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -349,84 +688,35 @@ function ContactCard({ agent, onMessageClick }: { agent: AgentPublicProfile; onM
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListingCard({ listing }: { listing: AgentPublicProfile['activeListings'][number] }) {
|
function ReviewRow({ review }: { review: AgentReviewItem }) {
|
||||||
const { property } = listing;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/listings/${listing.id}` as never} className="block">
|
<div className="rounded-lg border border-border bg-background-surface p-3">
|
||||||
<div className="group overflow-hidden rounded-lg border bg-card transition-shadow hover:shadow-md">
|
<div className="flex items-start justify-between gap-2">
|
||||||
{/* Image */}
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="relative aspect-[16/10] overflow-hidden bg-muted">
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
|
||||||
{property.imageUrl ? (
|
{(review.userName ?? 'Ẩn').charAt(0).toUpperCase()}
|
||||||
<Image
|
|
||||||
src={property.imageUrl}
|
|
||||||
alt={property.title}
|
|
||||||
fill
|
|
||||||
className="object-cover transition-transform group-hover:scale-105"
|
|
||||||
sizes="(max-width: 640px) 100vw, 50vw"
|
|
||||||
placeholder="blur"
|
|
||||||
blurDataURL={shimmerBlurDataURL()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<Home className="h-8 w-8 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Badge className="absolute left-2 top-2" variant={listing.transactionType === 'SALE' ? 'default' : 'secondary'}>
|
|
||||||
{listing.transactionType === 'SALE' ? 'Bán' : 'Cho thuê'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-3">
|
|
||||||
<h3 className="line-clamp-1 text-sm font-semibold">{property.title}</h3>
|
|
||||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<MapPin className="h-3 w-3 shrink-0" />
|
|
||||||
{property.district}, {property.city}
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 flex items-center justify-between">
|
|
||||||
<p className="text-sm font-bold text-primary">{formatPrice(listing.priceVND)} VND</p>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>{property.areaM2} m²</span>
|
|
||||||
{property.bedrooms != null && <span>{property.bedrooms} PN</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="min-w-0">
|
||||||
</div>
|
<p className="text-xs font-medium text-foreground truncate">{review.userName ?? 'Ẩn danh'}</p>
|
||||||
</Link>
|
<p className="text-xs text-foreground-dim">
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReviewCard({ review }: { review: AgentReviewItem }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border p-4">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary">
|
|
||||||
{(review.userName ?? 'Ẩn danh').charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">{review.userName ?? 'Ẩn danh'}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{new Date(review.createdAt).toLocaleDateString('vi-VN')}
|
{new Date(review.createdAt).toLocaleDateString('vi-VN')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0.5">
|
{/* Stars */}
|
||||||
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<Star
|
<Star
|
||||||
key={i}
|
key={i}
|
||||||
className={`h-4 w-4 ${
|
className={`h-3 w-3 ${
|
||||||
i < review.rating
|
i < review.rating ? 'fill-yellow-400 text-yellow-400' : 'text-foreground-dim'
|
||||||
? 'fill-yellow-400 text-yellow-400'
|
|
||||||
: 'text-muted-foreground/30'
|
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{review.comment && (
|
{review.comment && (
|
||||||
<p className="text-sm text-muted-foreground">{review.comment}</p>
|
<p className="mt-2 text-xs leading-relaxed text-foreground-muted">{review.comment}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AgentPublicProfile, AgentReviewStats, PaginatedReviews } from './agents-api';
|
import type { AgentPublicProfile, AgentReviewStats, PaginatedReviews } from './agents-api';
|
||||||
|
import type { ListingDetail, PaginatedResult } from './listings-api';
|
||||||
|
|
||||||
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
|
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
|
||||||
|
|
||||||
@@ -65,3 +66,30 @@ export async function fetchAgentReviewStats(agentId: string): Promise<AgentRevie
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch listings managed by a given agent — server-only.
|
||||||
|
* Returns `{ data: [], total: 0 }` on error so callers degrade gracefully.
|
||||||
|
*/
|
||||||
|
export async function fetchAgentListings(
|
||||||
|
agentId: string,
|
||||||
|
page = 1,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<{ data: ListingDetail[]; total: number }> {
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
agentId,
|
||||||
|
page: String(page),
|
||||||
|
limit: String(limit),
|
||||||
|
});
|
||||||
|
const res = await fetch(`${API_BASE_URL}/listings?${qs.toString()}`, {
|
||||||
|
next: { revalidate: 300 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return { data: [], total: 0 };
|
||||||
|
const result = (await res.json()) as PaginatedResult<ListingDetail>;
|
||||||
|
return { data: result.data, total: result.total };
|
||||||
|
} catch {
|
||||||
|
return { data: [], total: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ export interface SearchListingsParams {
|
|||||||
minArea?: number;
|
minArea?: number;
|
||||||
maxArea?: number;
|
maxArea?: number;
|
||||||
bedrooms?: number;
|
bedrooms?: number;
|
||||||
|
/** Filter by assigned agent ID */
|
||||||
|
agentId?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user