The "Nhắn tin" (Message) button on the agent profile ContactCard had no onClick handler. Now opens the InquiryModal using the agent's first active listing, or falls back to SMS for agents with no listings. Co-Authored-By: Paperclip <noreply@paperclip.ing>
434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
BadgeCheck,
|
|
Building2,
|
|
Calendar,
|
|
MapPin,
|
|
Phone,
|
|
Mail,
|
|
Star,
|
|
Home,
|
|
MessageSquare,
|
|
} from 'lucide-react';
|
|
import Image from 'next/image';
|
|
import * as React from 'react';
|
|
import { InquiryModal } from '@/components/listings/inquiry-modal';
|
|
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) {
|
|
const [inquiryOpen, setInquiryOpen] = React.useState(false);
|
|
const firstListing = agent.activeListings[0] ?? null;
|
|
|
|
const handleMessageClick = React.useCallback(() => {
|
|
if (firstListing) {
|
|
setInquiryOpen(true);
|
|
} else {
|
|
window.location.href = `sms:${agent.phone}`;
|
|
}
|
|
}, [firstListing, agent.phone]);
|
|
|
|
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} onMessageClick={handleMessageClick} />
|
|
</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} 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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{firstListing && (
|
|
<InquiryModal
|
|
open={inquiryOpen}
|
|
onOpenChange={setInquiryOpen}
|
|
listingId={firstListing.id}
|
|
listingTitle={firstListing.property.title}
|
|
sellerName={agent.fullName}
|
|
/>
|
|
)}
|
|
</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, onMessageClick }: { agent: AgentPublicProfile; onMessageClick: () => void }) {
|
|
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" onClick={onMessageClick}>
|
|
<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>
|
|
);
|
|
}
|