Improve agent profile client, comparison table, image gallery/upload, listing map, filter bar, property card, and search results components with better error handling, type safety, and UX refinements. Co-Authored-By: Paperclip <noreply@paperclip.ing>
411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
BadgeCheck,
|
|
Building2,
|
|
Calendar,
|
|
MapPin,
|
|
Phone,
|
|
Mail,
|
|
Star,
|
|
Home,
|
|
MessageSquare,
|
|
} from 'lucide-react';
|
|
import Image from 'next/image';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } 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';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Props
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface AgentProfileClientProps {
|
|
agent: AgentPublicProfile;
|
|
reviews: AgentReviewItem[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function AgentProfileClient({ agent, reviews }: AgentProfileClientProps) {
|
|
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>
|
|
<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>
|
|
)}
|
|
</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>
|
|
)}
|
|
|
|
{agent.licenseNumber && (
|
|
<p className="mb-1 text-sm text-muted-foreground">
|
|
Mã giấy phép: {agent.licenseNumber}
|
|
</p>
|
|
)}
|
|
|
|
<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}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* CTA Sidebar (desktop) */}
|
|
<div className="hidden shrink-0 sm:block">
|
|
<ContactCard agent={agent} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-3">
|
|
{/* Main Content */}
|
|
<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>
|
|
</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 và giao dịch thành công
|
|
</p>
|
|
</div>
|
|
</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>
|
|
)}
|
|
|
|
{/* Reviews */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Đánh giá ({agent.totalReviews})</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{reviews.length > 0 ? (
|
|
<div className="space-y-4">
|
|
{reviews.map((review) => (
|
|
<ReviewCard key={review.id} review={review} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-center text-sm text-muted-foreground py-4">
|
|
Chưa có đánh giá nào
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Sidebar (mobile + desktop fallback) */}
|
|
<div className="space-y-6">
|
|
<div className="sm:hidden">
|
|
<ContactCard agent={agent} />
|
|
</div>
|
|
<div className="hidden sm:block lg:block">
|
|
<div className="lg:sticky lg:top-20">
|
|
<div className="hidden lg:block">
|
|
<ContactCard agent={agent} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sub-Components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function StatPill({
|
|
icon,
|
|
label,
|
|
value,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
value: string;
|
|
}) {
|
|
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 }: { agent: AgentPublicProfile }) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Liên hệ môi giới</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<a href={`tel:${agent.phone}`} className="block">
|
|
<Button className="w-full gap-2">
|
|
<Phone className="h-4 w-4" />
|
|
Gọi ngay
|
|
</Button>
|
|
</a>
|
|
<Button variant="outline" className="w-full gap-2">
|
|
<MessageSquare className="h-4 w-4" />
|
|
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" />
|
|
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>
|
|
{agent.email && (
|
|
<>
|
|
<p className="mt-2 text-xs text-muted-foreground">Email</p>
|
|
<p className="text-sm font-medium">{agent.email}</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function ListingCard({ listing }: { listing: AgentPublicProfile['activeListings'][number] }) {
|
|
const { property } = listing;
|
|
|
|
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>
|
|
</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">
|
|
{new Date(review.createdAt).toLocaleDateString('vi-VN')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-0.5">
|
|
{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'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{review.comment && (
|
|
<p className="text-sm text-muted-foreground">{review.comment}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|