diff --git a/apps/api/src/modules/agents/agents.module.ts b/apps/api/src/modules/agents/agents.module.ts index 201073c..a0cc327 100644 --- a/apps/api/src/modules/agents/agents.module.ts +++ b/apps/api/src/modules/agents/agents.module.ts @@ -3,13 +3,14 @@ import { CqrsModule } from '@nestjs/cqrs'; import { RecalculateQualityScoreHandler } from './application/commands/recalculate-quality-score/recalculate-quality-score.handler'; import { ReviewEventsListener } from './application/listeners/review-events.listener'; import { GetAgentDashboardHandler } from './application/queries/get-agent-dashboard/get-agent-dashboard.handler'; +import { GetAgentPublicProfileHandler } from './application/queries/get-agent-public-profile/get-agent-public-profile.handler'; import { AGENT_REPOSITORY } from './domain/repositories/agent.repository'; import { PrismaAgentRepository } from './infrastructure/repositories/prisma-agent.repository'; import { AgentsController } from './presentation/controllers/agents.controller'; const CommandHandlers = [RecalculateQualityScoreHandler]; -const QueryHandlers = [GetAgentDashboardHandler]; +const QueryHandlers = [GetAgentDashboardHandler, GetAgentPublicProfileHandler]; @Module({ imports: [CqrsModule], diff --git a/apps/api/src/modules/agents/application/queries/get-agent-public-profile/get-agent-public-profile.handler.ts b/apps/api/src/modules/agents/application/queries/get-agent-public-profile/get-agent-public-profile.handler.ts new file mode 100644 index 0000000..ddc3154 --- /dev/null +++ b/apps/api/src/modules/agents/application/queries/get-agent-public-profile/get-agent-public-profile.handler.ts @@ -0,0 +1,24 @@ +import { Inject } from '@nestjs/common'; +import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { + AGENT_REPOSITORY, + type AgentPublicProfileData, + type IAgentRepository, +} from '../../../domain/repositories/agent.repository'; +import { GetAgentPublicProfileQuery } from './get-agent-public-profile.query'; + +@QueryHandler(GetAgentPublicProfileQuery) +export class GetAgentPublicProfileHandler + implements IQueryHandler +{ + constructor( + @Inject(AGENT_REPOSITORY) + private readonly agentRepo: IAgentRepository, + ) {} + + async execute( + query: GetAgentPublicProfileQuery, + ): Promise { + return this.agentRepo.getPublicProfile(query.agentId); + } +} diff --git a/apps/api/src/modules/agents/application/queries/get-agent-public-profile/get-agent-public-profile.query.ts b/apps/api/src/modules/agents/application/queries/get-agent-public-profile/get-agent-public-profile.query.ts new file mode 100644 index 0000000..34d3db7 --- /dev/null +++ b/apps/api/src/modules/agents/application/queries/get-agent-public-profile/get-agent-public-profile.query.ts @@ -0,0 +1,3 @@ +export class GetAgentPublicProfileQuery { + constructor(public readonly agentId: string) {} +} diff --git a/apps/api/src/modules/agents/domain/repositories/agent.repository.ts b/apps/api/src/modules/agents/domain/repositories/agent.repository.ts index 979a234..54b1f3f 100644 --- a/apps/api/src/modules/agents/domain/repositories/agent.repository.ts +++ b/apps/api/src/modules/agents/domain/repositories/agent.repository.ts @@ -17,9 +17,48 @@ export interface AgentDashboardData { totalReviews: number; } +export interface AgentPublicListingItem { + id: string; + transactionType: string; + priceVND: string; + status: string; + property: { + id: string; + title: string; + propertyType: string; + address: string; + district: string; + city: string; + areaM2: number; + bedrooms: number | null; + bathrooms: number | null; + imageUrl: string | null; + }; +} + +export interface AgentPublicProfileData { + id: string; + fullName: string; + avatarUrl: string | null; + phone: string; + email: string | null; + agency: string | null; + licenseNumber: string | null; + bio: string | null; + qualityScore: number; + totalDeals: number; + isVerified: boolean; + serviceAreas: string[]; + memberSince: string; + activeListings: AgentPublicListingItem[]; + avgReviewRating: number; + totalReviews: number; +} + export interface IAgentRepository { findByUserId(userId: string): Promise<{ id: string; userId: string; qualityScore: number } | null>; findById(agentId: string): Promise<{ id: string; userId: string; qualityScore: number } | null>; updateQualityScore(agentId: string, score: number): Promise; getDashboard(agentId: string): Promise; + getPublicProfile(agentId: string): Promise; } diff --git a/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts b/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts index 8248294..758c9c1 100644 --- a/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts +++ b/apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts @@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common'; import { type PrismaService } from '@modules/shared'; import { type AgentDashboardData, + type AgentPublicProfileData, + type AgentPublicListingItem, type IAgentRepository, } from '../../domain/repositories/agent.repository'; @@ -119,4 +121,105 @@ export class PrismaAgentRepository implements IAgentRepository { ]); return { total, active }; } + + async getPublicProfile(agentId: string): Promise { + const agent = await this.prisma.agent.findUnique({ + where: { id: agentId }, + include: { + user: { + select: { + fullName: true, + avatarUrl: true, + phone: true, + email: true, + createdAt: true, + }, + }, + }, + }); + + if (!agent) return null; + + const [listings, reviewStats] = await Promise.all([ + this.getActiveListingsForAgent(agentId), + this.prisma.review.aggregate({ + where: { targetType: 'AGENT', targetId: agentId }, + _avg: { rating: true }, + _count: { rating: true }, + }), + ]); + + return { + id: agent.id, + fullName: agent.user.fullName, + avatarUrl: agent.user.avatarUrl, + phone: agent.user.phone, + email: agent.user.email, + agency: agent.agency, + licenseNumber: agent.licenseNumber, + bio: agent.bio, + qualityScore: agent.qualityScore, + totalDeals: agent.totalDeals, + isVerified: agent.isVerified, + serviceAreas: (agent.serviceAreas as string[]) ?? [], + memberSince: agent.createdAt.toISOString(), + activeListings: listings, + avgReviewRating: Math.round((reviewStats._avg.rating ?? 0) * 10) / 10, + totalReviews: reviewStats._count.rating, + }; + } + + private async getActiveListingsForAgent( + agentId: string, + ): Promise { + const listings = await this.prisma.listing.findMany({ + where: { agentId, status: 'ACTIVE' }, + take: 12, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + transactionType: true, + priceVND: true, + status: true, + property: { + select: { + id: true, + title: true, + propertyType: true, + address: true, + district: true, + city: true, + areaM2: true, + bedrooms: true, + bathrooms: true, + media: { + where: { type: 'image' }, + take: 1, + orderBy: { order: 'asc' }, + select: { url: true }, + }, + }, + }, + }, + }); + + return listings.map((l) => ({ + id: l.id, + transactionType: l.transactionType, + priceVND: l.priceVND.toString(), + status: l.status, + property: { + id: l.property.id, + title: l.property.title, + propertyType: l.property.propertyType, + address: l.property.address, + district: l.property.district, + city: l.property.city, + areaM2: l.property.areaM2, + bedrooms: l.property.bedrooms, + bathrooms: l.property.bathrooms, + imageUrl: l.property.media[0]?.url ?? null, + }, + })); + } } diff --git a/apps/api/src/modules/agents/presentation/controllers/agents.controller.ts b/apps/api/src/modules/agents/presentation/controllers/agents.controller.ts index 827fb52..169db83 100644 --- a/apps/api/src/modules/agents/presentation/controllers/agents.controller.ts +++ b/apps/api/src/modules/agents/presentation/controllers/agents.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Controller, Get, NotFoundException, Param, Post, UseGuards } from '@nestjs/common'; import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { ApiBearerAuth, @@ -16,7 +16,8 @@ import { } from '@modules/auth'; import { RecalculateQualityScoreCommand } from '../../application/commands/recalculate-quality-score/recalculate-quality-score.command'; import { GetAgentDashboardQuery } from '../../application/queries/get-agent-dashboard/get-agent-dashboard.query'; -import { type AgentDashboardData } from '../../domain/repositories/agent.repository'; +import { GetAgentPublicProfileQuery } from '../../application/queries/get-agent-public-profile/get-agent-public-profile.query'; +import { type AgentDashboardData, type AgentPublicProfileData } from '../../domain/repositories/agent.repository'; @ApiTags('agents') @Controller('agents') @@ -41,6 +42,23 @@ export class AgentsController { return this.queryBus.execute(new GetAgentDashboardQuery(user.sub)); } + @ApiOperation({ summary: 'Get public agent profile' }) + @ApiParam({ name: 'agentId', description: 'Agent ID' }) + @ApiResponse({ status: 200, description: 'Agent public profile data' }) + @ApiResponse({ status: 404, description: 'Agent not found' }) + @Get(':agentId/profile') + async getPublicProfile( + @Param('agentId') agentId: string, + ): Promise { + const profile = await this.queryBus.execute( + new GetAgentPublicProfileQuery(agentId), + ); + if (!profile) { + throw new NotFoundException('Không tìm thấy môi giới'); + } + return profile; + } + @ApiBearerAuth('JWT') @ApiOperation({ summary: 'Recalculate quality score (admin/system)' }) @ApiParam({ name: 'agentId', description: 'Agent ID' }) diff --git a/apps/web/app/[locale]/(public)/agents/[id]/page.tsx b/apps/web/app/[locale]/(public)/agents/[id]/page.tsx new file mode 100644 index 0000000..11bf017 --- /dev/null +++ b/apps/web/app/[locale]/(public)/agents/[id]/page.tsx @@ -0,0 +1,112 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { AgentProfileClient } from '@/components/agents/agent-profile-client'; +import { + JsonLd, + generateAgentJsonLd, + generateBreadcrumbJsonLd, +} from '@/components/seo/json-ld'; +import { fetchAgentProfile, fetchAgentReviews } from '@/lib/agents-server'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const siteUrl = process.env['NEXT_PUBLIC_SITE_URL'] || 'https://goodgo.vn'; + +// --------------------------------------------------------------------------- +// Metadata (runs server-side, provides , <meta>, OG, canonical) +// --------------------------------------------------------------------------- + +interface PageProps { + params: { locale: string; id: string }; +} + +export async function generateMetadata({ params }: PageProps): Promise<Metadata> { + const agent = await fetchAgentProfile(params.id); + if (!agent) { + return { title: 'Không tìm thấy môi giới' }; + } + + const title = agent.isVerified + ? `${agent.fullName} — Môi giới xác minh | GoodGo` + : `${agent.fullName} — Môi giới BĐS | GoodGo`; + + const description = [ + agent.agency ? `Công ty: ${agent.agency}` : null, + agent.serviceAreas.length > 0 + ? `Khu vực: ${agent.serviceAreas.join(', ')}` + : null, + agent.totalReviews > 0 + ? `Đánh giá: ${agent.avgReviewRating}/5 (${agent.totalReviews} lượt)` + : null, + `${agent.activeListings.length} tin đăng đang hoạt động`, + `${agent.totalDeals} giao dịch thành công`, + ] + .filter(Boolean) + .join(' | '); + + const canonicalUrl = `${siteUrl}/${params.locale}/agents/${params.id}`; + + return { + title, + description, + alternates: { + canonical: canonicalUrl, + languages: { + vi: `${siteUrl}/vi/agents/${params.id}`, + en: `${siteUrl}/en/agents/${params.id}`, + }, + }, + openGraph: { + type: 'profile', + locale: params.locale === 'vi' ? 'vi_VN' : 'en_US', + url: canonicalUrl, + title, + description, + siteName: 'GoodGo', + images: agent.avatarUrl + ? [{ url: agent.avatarUrl, width: 400, height: 400, alt: agent.fullName }] + : [{ url: '/og-image.png', width: 1200, height: 630, alt: 'GoodGo' }], + }, + twitter: { + card: 'summary', + title, + description, + images: agent.avatarUrl ? [agent.avatarUrl] : ['/og-image.png'], + }, + }; +} + +// --------------------------------------------------------------------------- +// Page (Server Component) +// --------------------------------------------------------------------------- + +export default async function AgentProfilePage({ params }: PageProps) { + const [agent, reviewsResult] = await Promise.all([ + fetchAgentProfile(params.id), + fetchAgentReviews(params.id, 1, 10), + ]); + + if (!agent) { + notFound(); + } + + // Build JSON-LD structured data + const agentJsonLd = generateAgentJsonLd(agent, siteUrl); + const breadcrumbJsonLd = generateBreadcrumbJsonLd([ + { name: 'Trang chủ', url: siteUrl }, + { name: agent.fullName, url: `${siteUrl}/${params.locale}/agents/${params.id}` }, + ]); + + return ( + <> + {/* Structured data for search engines */} + <JsonLd data={agentJsonLd} /> + <JsonLd data={breadcrumbJsonLd} /> + + {/* Interactive client component */} + <AgentProfileClient agent={agent} reviews={reviewsResult.data} /> + </> + ); +} diff --git a/apps/web/app/[locale]/auth/callback/__tests__/google-callback.spec.tsx b/apps/web/app/[locale]/auth/callback/__tests__/google-callback.spec.tsx index fe15229..2be79fe 100644 --- a/apps/web/app/[locale]/auth/callback/__tests__/google-callback.spec.tsx +++ b/apps/web/app/[locale]/auth/callback/__tests__/google-callback.spec.tsx @@ -40,7 +40,7 @@ describe('GoogleCallbackPage', () => { handleOAuthCallback: vi.fn().mockResolvedValue(undefined), }; mockedUseAuthStore.mockImplementation((selector) => { - if (typeof selector === 'function') return (selector as (s: typeof mockStore) => unknown)(mockStore); + if (typeof selector === 'function') return (selector as unknown as (s: typeof mockStore) => unknown)(mockStore); return mockStore as ReturnType<typeof useAuthStore>; }); }); diff --git a/apps/web/app/[locale]/auth/callback/__tests__/zalo-callback.spec.tsx b/apps/web/app/[locale]/auth/callback/__tests__/zalo-callback.spec.tsx index de87fd7..026cb0f 100644 --- a/apps/web/app/[locale]/auth/callback/__tests__/zalo-callback.spec.tsx +++ b/apps/web/app/[locale]/auth/callback/__tests__/zalo-callback.spec.tsx @@ -40,7 +40,7 @@ describe('ZaloCallbackPage', () => { handleOAuthCallback: vi.fn().mockResolvedValue(undefined), }; mockedUseAuthStore.mockImplementation((selector) => { - if (typeof selector === 'function') return (selector as (s: typeof mockStore) => unknown)(mockStore); + if (typeof selector === 'function') return (selector as unknown as (s: typeof mockStore) => unknown)(mockStore); return mockStore as ReturnType<typeof useAuthStore>; }); }); diff --git a/apps/web/components/agents/agent-profile-client.tsx b/apps/web/components/agents/agent-profile-client.tsx new file mode 100644 index 0000000..ac29a73 --- /dev/null +++ b/apps/web/components/agents/agent-profile-client.tsx @@ -0,0 +1,407 @@ +'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'; + +// --------------------------------------------------------------------------- +// 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" + /> + ) : ( + <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> + ); +} diff --git a/apps/web/components/seo/json-ld.tsx b/apps/web/components/seo/json-ld.tsx index e759a7e..6ddb0fa 100644 --- a/apps/web/components/seo/json-ld.tsx +++ b/apps/web/components/seo/json-ld.tsx @@ -1,3 +1,4 @@ +import type { AgentPublicProfile } from '@/lib/agents-api'; import type { ListingDetail } from '@/lib/listings-api'; import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings'; @@ -170,6 +171,46 @@ export function generateWebsiteJsonLd(siteUrl: string) { }; } +// --------------------------------------------------------------------------- +// Agent profile JSON-LD (RealEstateAgent) +// --------------------------------------------------------------------------- + +export function generateAgentJsonLd(agent: AgentPublicProfile, siteUrl: string) { + const jsonLd: Record<string, unknown> = { + '@context': 'https://schema.org', + '@type': 'RealEstateAgent', + name: agent.fullName, + url: `${siteUrl}/agents/${agent.id}`, + description: agent.bio ?? `Môi giới bất động sản tại GoodGo`, + ...(agent.avatarUrl && { image: agent.avatarUrl }), + telephone: agent.phone, + ...(agent.email && { email: agent.email }), + ...(agent.agency && { + worksFor: { + '@type': 'RealEstateAgent', + name: agent.agency, + }, + }), + ...(agent.serviceAreas.length > 0 && { + areaServed: agent.serviceAreas.map((area) => ({ + '@type': 'AdministrativeArea', + name: area, + })), + }), + ...(agent.totalReviews > 0 && { + aggregateRating: { + '@type': 'AggregateRating', + ratingValue: agent.avgReviewRating, + reviewCount: agent.totalReviews, + bestRating: 5, + worstRating: 1, + }, + }), + }; + + return jsonLd; +} + // --------------------------------------------------------------------------- // React component that renders <script type="application/ld+json"> // --------------------------------------------------------------------------- diff --git a/apps/web/lib/agents-api.ts b/apps/web/lib/agents-api.ts new file mode 100644 index 0000000..87394c8 --- /dev/null +++ b/apps/web/lib/agents-api.ts @@ -0,0 +1,85 @@ +import { apiClient } from './api-client'; + +// ─── Interfaces ────────────────────────────────────────── + +export interface AgentListingItem { + id: string; + transactionType: string; + priceVND: string; + status: string; + property: { + id: string; + title: string; + propertyType: string; + address: string; + district: string; + city: string; + areaM2: number; + bedrooms: number | null; + bathrooms: number | null; + imageUrl: string | null; + }; +} + +export interface AgentPublicProfile { + id: string; + fullName: string; + avatarUrl: string | null; + phone: string; + email: string | null; + agency: string | null; + licenseNumber: string | null; + bio: string | null; + qualityScore: number; + totalDeals: number; + isVerified: boolean; + serviceAreas: string[]; + memberSince: string; + activeListings: AgentListingItem[]; + avgReviewRating: number; + totalReviews: number; +} + +export interface AgentReviewItem { + id: string; + userId: string; + userName: string | null; + targetType: string; + targetId: string; + rating: number; + comment: string | null; + createdAt: string; +} + +export interface AgentReviewStats { + targetType: string; + targetId: string; + averageRating: number; + totalReviews: number; + distribution: Record<number, number>; +} + +export interface PaginatedReviews { + data: AgentReviewItem[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +// ─── API Functions ─────────────────────────────────────── + +export const agentsApi = { + getPublicProfile: (agentId: string) => + apiClient.get<AgentPublicProfile>(`/agents/${agentId}/profile`), + + getReviews: (agentId: string, page = 1, limit = 10) => + apiClient.get<PaginatedReviews>( + `/reviews?targetType=AGENT&targetId=${agentId}&page=${page}&limit=${limit}`, + ), + + getReviewStats: (agentId: string) => + apiClient.get<AgentReviewStats>( + `/reviews/stats?targetType=AGENT&targetId=${agentId}`, + ), +}; diff --git a/apps/web/lib/agents-server.ts b/apps/web/lib/agents-server.ts new file mode 100644 index 0000000..c3b2f0e --- /dev/null +++ b/apps/web/lib/agents-server.ts @@ -0,0 +1,67 @@ +/** + * Server-side agent data fetching for Next.js Server Components. + * + * Uses `fetch` directly (no browser-only helpers) so it can run + * inside `generateMetadata`, server pages, etc. + */ + +import type { AgentPublicProfile, AgentReviewStats, PaginatedReviews } from './agents-api'; + +const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1'; + +/** + * Fetch a public agent profile by ID — server-only. + * Returns `null` when the agent is not found (404) so callers can `notFound()`. + */ +export async function fetchAgentProfile(agentId: string): Promise<AgentPublicProfile | null> { + try { + const res = await fetch(`${API_BASE_URL}/agents/${agentId}/profile`, { + next: { revalidate: 300 }, // ISR: re-validate every 5 min + }); + + if (!res.ok) return null; + return (await res.json()) as AgentPublicProfile; + } catch { + return null; + } +} + +/** + * Fetch reviews for an agent — server-only. + */ +export async function fetchAgentReviews( + agentId: string, + page = 1, + limit = 10, +): Promise<PaginatedReviews> { + try { + const res = await fetch( + `${API_BASE_URL}/reviews?targetType=AGENT&targetId=${agentId}&page=${page}&limit=${limit}`, + { next: { revalidate: 300 } }, + ); + + if (!res.ok) { + return { data: [], total: 0, page: 1, limit: 10, totalPages: 0 }; + } + return (await res.json()) as PaginatedReviews; + } catch { + return { data: [], total: 0, page: 1, limit: 10, totalPages: 0 }; + } +} + +/** + * Fetch review stats for an agent — server-only. + */ +export async function fetchAgentReviewStats(agentId: string): Promise<AgentReviewStats | null> { + try { + const res = await fetch( + `${API_BASE_URL}/reviews/stats?targetType=AGENT&targetId=${agentId}`, + { next: { revalidate: 300 } }, + ); + + if (!res.ok) return null; + return (await res.json()) as AgentReviewStats; + } catch { + return null; + } +}