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:
Ho Ngoc Hai
2026-04-21 03:46:19 +07:00
parent 27ba8412e1
commit 9cefd439db
5 changed files with 680 additions and 322 deletions

View File

@@ -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<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
// ---------------------------------------------------------------------------
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 (
<div className="mx-auto max-w-6xl px-4 py-6">
{/* Breadcrumb */}
<nav className="mb-4 flex items-center gap-1.5 text-sm text-muted-foreground">
<Link href="/" className="hover:text-foreground">Trang chủ</Link>
<div className="mx-auto max-w-7xl px-4 py-6 space-y-6">
{/* ── Breadcrumb ── */}
<nav className="flex items-center gap-1.5 text-xs text-foreground-muted">
<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 className="truncate text-foreground">{agent.fullName}</span>
</nav>
{/* Profile Header */}
<div className="mb-8 flex flex-col gap-6 sm:flex-row sm:items-start">
{/* Avatar */}
<div className="shrink-0">
{agent.avatarUrl ? (
<Image
src={agent.avatarUrl}
alt={agent.fullName}
width={120}
height={120}
className="h-28 w-28 rounded-full border-4 border-primary/10 object-cover sm:h-32 sm:w-32"
/>
) : (
<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">
<span className="text-4xl font-bold text-primary">
{agent.fullName.charAt(0).toUpperCase()}
</span>
</div>
)}
</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>
{/* ── Profile Header ── */}
<div className="rounded-lg border border-border bg-background-elevated shadow-elevation-1 p-6">
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
{/* Avatar */}
<div className="shrink-0">
{agent.avatarUrl ? (
<Image
src={agent.avatarUrl}
alt={agent.fullName}
width={96}
height={96}
className="h-24 w-24 rounded-full border-2 border-primary/20 object-cover"
placeholder="blur"
blurDataURL={shimmerBlurDataURL()}
/>
) : (
<div className="flex h-24 w-24 items-center justify-center rounded-full border-2 border-primary/20 bg-primary/10">
<span className="text-3xl font-bold text-primary">
{agent.fullName.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
{agent.agency && (
<p className="mb-1 flex items-center gap-1.5 text-muted-foreground">
<Building2 className="h-4 w-4 shrink-0" />
{agent.agency}
</p>
)}
{/* Info */}
<div className="min-w-0 flex-1 space-y-2">
{/* Name + badges */}
<div className="flex flex-wrap items-center gap-2">
<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 && (
<p className="mb-1 text-sm text-muted-foreground">
giấy phép: {agent.licenseNumber}
</p>
)}
{/* Meta */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-foreground-muted">
{agent.agency && (
<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">
<Calendar className="h-4 w-4 shrink-0" />
Thành viên từ {new Date(agent.memberSince).toLocaleDateString('vi-VN', {
month: 'long',
year: 'numeric',
})}
</p>
{/* Quick stats */}
<div className="mt-4 flex flex-wrap gap-4">
<StatPill
icon={<Star className="h-4 w-4 text-yellow-500" />}
label="Đánh giá"
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}`}
/>
{/* Service areas */}
{agent.serviceAreas.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{agent.serviceAreas.map((area) => (
<span
key={area}
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"
>
<MapPin className="h-3 w-3 shrink-0" />
{area}
</span>
))}
</div>
)}
</div>
</div>
{/* CTA Sidebar (desktop) */}
<div className="hidden shrink-0 sm:block">
<ContactCard agent={agent} onMessageClick={handleMessageClick} />
{/* Desktop CTA */}
<div className="hidden shrink-0 sm:block">
<ContactCard agent={agent} onMessageClick={handleMessageClick} />
</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">
{/* Main Content */}
{/* Left column — chart + table + reviews */}
<div className="space-y-6 lg:col-span-2">
{/* Bio */}
{agent.bio && (
<Card>
<CardHeader>
<CardTitle>Giới thiệu</CardTitle>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm leading-relaxed">{agent.bio}</p>
</CardContent>
</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>
{/* Performance Chart */}
<Card className="border-border bg-background-elevated shadow-elevation-1">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold text-foreground">
Hiệu suất 12 tháng tin đăng & giao dịch
</CardTitle>
<CardDescription className="text-xs text-foreground-muted">
Tổng hợp từ danh mục thực
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="relative h-20 w-20">
<svg className="h-20 w-20 -rotate-90 transform" viewBox="0 0 36 36">
<path
className="text-muted"
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"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="text-primary"
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"
stroke="currentColor"
strokeWidth="3"
strokeDasharray={`${agent.qualityScore}, 100`}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg font-bold">{agent.qualityScore}</span>
</div>
</div>
<div>
<p className="font-medium">
{agent.qualityScore >= 80
? 'Xuất sắc'
: agent.qualityScore >= 60
? 'Tốt'
: agent.qualityScore >= 40
? 'Trung bình'
: 'Cần cải thiện'}
</p>
<p className="text-sm text-muted-foreground">
Dựa trên phản hồi, thời gian phản hồi giao dịch thành công
</p>
</div>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={perfData} margin={{ top: 4, right: 16, left: -16, bottom: 0 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
strokeOpacity={0.5}
/>
<XAxis
dataKey="month"
tick={{ fontSize: 10, fill: 'hsl(var(--foreground-muted))' }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--foreground-muted))' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--background-elevated))',
border: '1px solid hsl(var(--border))',
borderRadius: '0.5rem',
fontSize: '0.75rem',
}}
formatter={(value, name) => [
value,
name === 'published' ? 'Tin đăng' : 'Giao dịch',
]}
/>
<Line
type="monotone"
dataKey="published"
stroke="hsl(var(--chart-1))"
strokeWidth={2}
dot={false}
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>
</CardContent>
</Card>
{/* Active Listings */}
{agent.activeListings.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Tin đăng đang hoạt đng ({agent.activeListings.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
{agent.activeListings.map((listing) => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
</CardContent>
</Card>
)}
{/* Listings Table */}
<Card className="border-border bg-background-elevated shadow-elevation-1">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold text-foreground">
Danh mục bất đng sản{' '}
<span className="ml-1 text-foreground-muted tabular-nums font-normal">
({VND.format(listingsTotal)})
</span>
</CardTitle>
</div>
<CardDescription className="text-xs text-foreground-muted">
Sắp xếp theo giá, diện tích, lượt xem
</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 */}
<Card>
<CardHeader>
<CardTitle>Đánh giá ({agent.totalReviews})</CardTitle>
<Card className="border-border bg-background-elevated shadow-elevation-1">
<CardHeader className="pb-2">
<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>
<CardContent>
{reviews.length > 0 ? (
<div className="space-y-4">
<div className="space-y-3">
{reviews.map((review) => (
<ReviewCard key={review.id} review={review} />
<ReviewRow key={review.id} review={review} />
))}
</div>
) : (
<p className="text-center text-sm text-muted-foreground py-4">
Chưa đánh giá nào
</p>
<EmptyState
icon={<Star className="h-6 w-6" />}
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>
</Card>
</div>
{/* Sidebar (mobile + desktop fallback) */}
<div className="space-y-6">
{/* Right column — sticky contact + quality + bio */}
<div className="space-y-4 lg:sticky lg:top-20 lg:self-start">
{/* Mobile contact */}
<div className="sm:hidden">
<ContactCard agent={agent} onMessageClick={handleMessageClick} />
</div>
<div className="hidden sm:block lg:block">
<div className="lg:sticky lg:top-20">
<div className="hidden lg:block">
<ContactCard agent={agent} onMessageClick={handleMessageClick} />
</div>
</div>
{/* Desktop contact (hidden on mobile, shown lg via sticky container) */}
<div className="hidden sm:block">
<ContactCard agent={agent} onMessageClick={handleMessageClick} />
</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 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>
@@ -289,59 +640,47 @@ export function AgentProfileClient({ agent, reviews }: AgentProfileClientProps)
// Sub-Components
// ---------------------------------------------------------------------------
function StatPill({
icon,
label,
value,
function ContactCard({
agent,
onMessageClick,
}: {
icon: React.ReactNode;
label: string;
value: string;
agent: AgentPublicProfile;
onMessageClick: () => void;
}) {
return (
<div className="flex items-center gap-2 rounded-lg border bg-card px-3 py-2">
{icon}
<div>
<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>
<Card className="border-border bg-background-elevated shadow-elevation-1">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold text-foreground">Liên hệ môi giới</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-2">
<a href={`tel:${agent.phone}`} className="block">
<Button className="w-full gap-2">
<Phone className="h-4 w-4" />
<Button className="w-full gap-2" size="sm">
<Phone className="h-3.5 w-3.5" />
Gọi ngay
</Button>
</a>
<Button variant="outline" className="w-full gap-2" onClick={onMessageClick}>
<MessageSquare className="h-4 w-4" />
<Button variant="outline" className="w-full gap-2" size="sm" onClick={onMessageClick}>
<MessageSquare className="h-3.5 w-3.5" />
Nhắn tin
</Button>
{agent.email && (
<a href={`mailto:${agent.email}`} className="block">
<Button variant="outline" className="w-full gap-2">
<Mail className="h-4 w-4" />
<Button variant="outline" className="w-full gap-2" size="sm">
<Mail className="h-3.5 w-3.5" />
Email
</Button>
</a>
)}
<div className="border-t pt-3">
<p className="text-xs text-muted-foreground">Số điện thoại</p>
<p className="text-sm font-medium">{agent.phone}</p>
<div className="border-t border-border pt-2 space-y-1.5">
<div>
<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 && (
<>
<p className="mt-2 text-xs text-muted-foreground">Email</p>
<p className="text-sm font-medium">{agent.email}</p>
</>
<div>
<p className="text-xs text-foreground-dim">Email</p>
<p className="text-xs font-medium text-foreground">{agent.email}</p>
</div>
)}
</div>
</CardContent>
@@ -349,84 +688,35 @@ function ContactCard({ agent, onMessageClick }: { agent: AgentPublicProfile; onM
);
}
function ListingCard({ listing }: { listing: AgentPublicProfile['activeListings'][number] }) {
const { property } = listing;
function ReviewRow({ review }: { review: AgentReviewItem }) {
return (
<Link href={`/listings/${listing.id}` as never} className="block">
<div className="group overflow-hidden rounded-lg border bg-card transition-shadow hover:shadow-md">
{/* Image */}
<div className="relative aspect-[16/10] overflow-hidden bg-muted">
{property.imageUrl ? (
<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 className="rounded-lg border border-border bg-background-surface p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
{(review.userName ?? 'Ẩn').charAt(0).toUpperCase()}
</div>
</div>
</div>
</Link>
);
}
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">
<div className="min-w-0">
<p className="text-xs font-medium text-foreground truncate">{review.userName ?? 'Ẩn danh'}</p>
<p className="text-xs text-foreground-dim">
{new Date(review.createdAt).toLocaleDateString('vi-VN')}
</p>
</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) => (
<Star
key={i}
className={`h-4 w-4 ${
i < review.rating
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground/30'
className={`h-3 w-3 ${
i < review.rating ? 'fill-yellow-400 text-yellow-400' : 'text-foreground-dim'
}`}
/>
))}
</div>
</div>
{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>
);