feat(agents): add public agent profile page at /agents/[id]
Implements a public-facing agent profile page with: - Backend: new GET /agents/:agentId/profile public API endpoint with agent info, active listings, quality score, and review stats - Frontend: server-rendered profile page with generateMetadata for SEO, JSON-LD structured data (RealEstateAgent schema), breadcrumbs - Agent profile displays bio, service areas, quality score gauge, active listing cards, reviews with star ratings, and contact CTA - Mobile responsive layout with sticky contact sidebar on desktop - Vietnamese UI text throughout, consistent with existing patterns Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -3,13 +3,14 @@ import { CqrsModule } from '@nestjs/cqrs';
|
|||||||
import { RecalculateQualityScoreHandler } from './application/commands/recalculate-quality-score/recalculate-quality-score.handler';
|
import { RecalculateQualityScoreHandler } from './application/commands/recalculate-quality-score/recalculate-quality-score.handler';
|
||||||
import { ReviewEventsListener } from './application/listeners/review-events.listener';
|
import { ReviewEventsListener } from './application/listeners/review-events.listener';
|
||||||
import { GetAgentDashboardHandler } from './application/queries/get-agent-dashboard/get-agent-dashboard.handler';
|
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 { AGENT_REPOSITORY } from './domain/repositories/agent.repository';
|
||||||
import { PrismaAgentRepository } from './infrastructure/repositories/prisma-agent.repository';
|
import { PrismaAgentRepository } from './infrastructure/repositories/prisma-agent.repository';
|
||||||
import { AgentsController } from './presentation/controllers/agents.controller';
|
import { AgentsController } from './presentation/controllers/agents.controller';
|
||||||
|
|
||||||
const CommandHandlers = [RecalculateQualityScoreHandler];
|
const CommandHandlers = [RecalculateQualityScoreHandler];
|
||||||
|
|
||||||
const QueryHandlers = [GetAgentDashboardHandler];
|
const QueryHandlers = [GetAgentDashboardHandler, GetAgentPublicProfileHandler];
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CqrsModule],
|
imports: [CqrsModule],
|
||||||
|
|||||||
@@ -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<GetAgentPublicProfileQuery>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
@Inject(AGENT_REPOSITORY)
|
||||||
|
private readonly agentRepo: IAgentRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
query: GetAgentPublicProfileQuery,
|
||||||
|
): Promise<AgentPublicProfileData | null> {
|
||||||
|
return this.agentRepo.getPublicProfile(query.agentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export class GetAgentPublicProfileQuery {
|
||||||
|
constructor(public readonly agentId: string) {}
|
||||||
|
}
|
||||||
@@ -17,9 +17,48 @@ export interface AgentDashboardData {
|
|||||||
totalReviews: number;
|
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 {
|
export interface IAgentRepository {
|
||||||
findByUserId(userId: string): Promise<{ id: string; userId: string; qualityScore: number } | null>;
|
findByUserId(userId: string): Promise<{ id: string; userId: string; qualityScore: number } | null>;
|
||||||
findById(agentId: 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<void>;
|
updateQualityScore(agentId: string, score: number): Promise<void>;
|
||||||
getDashboard(agentId: string): Promise<AgentDashboardData>;
|
getDashboard(agentId: string): Promise<AgentDashboardData>;
|
||||||
|
getPublicProfile(agentId: string): Promise<AgentPublicProfileData | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { type PrismaService } from '@modules/shared';
|
import { type PrismaService } from '@modules/shared';
|
||||||
import {
|
import {
|
||||||
type AgentDashboardData,
|
type AgentDashboardData,
|
||||||
|
type AgentPublicProfileData,
|
||||||
|
type AgentPublicListingItem,
|
||||||
type IAgentRepository,
|
type IAgentRepository,
|
||||||
} from '../../domain/repositories/agent.repository';
|
} from '../../domain/repositories/agent.repository';
|
||||||
|
|
||||||
@@ -119,4 +121,105 @@ export class PrismaAgentRepository implements IAgentRepository {
|
|||||||
]);
|
]);
|
||||||
return { total, active };
|
return { total, active };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPublicProfile(agentId: string): Promise<AgentPublicProfileData | null> {
|
||||||
|
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<AgentPublicListingItem[]> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
} from '@modules/auth';
|
} from '@modules/auth';
|
||||||
import { RecalculateQualityScoreCommand } from '../../application/commands/recalculate-quality-score/recalculate-quality-score.command';
|
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 { 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')
|
@ApiTags('agents')
|
||||||
@Controller('agents')
|
@Controller('agents')
|
||||||
@@ -41,6 +42,23 @@ export class AgentsController {
|
|||||||
return this.queryBus.execute(new GetAgentDashboardQuery(user.sub));
|
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<AgentPublicProfileData> {
|
||||||
|
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')
|
@ApiBearerAuth('JWT')
|
||||||
@ApiOperation({ summary: 'Recalculate quality score (admin/system)' })
|
@ApiOperation({ summary: 'Recalculate quality score (admin/system)' })
|
||||||
@ApiParam({ name: 'agentId', description: 'Agent ID' })
|
@ApiParam({ name: 'agentId', description: 'Agent ID' })
|
||||||
|
|||||||
112
apps/web/app/[locale]/(public)/agents/[id]/page.tsx
Normal file
112
apps/web/app/[locale]/(public)/agents/[id]/page.tsx
Normal file
@@ -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 <title>, <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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ describe('GoogleCallbackPage', () => {
|
|||||||
handleOAuthCallback: vi.fn().mockResolvedValue(undefined),
|
handleOAuthCallback: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
mockedUseAuthStore.mockImplementation((selector) => {
|
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>;
|
return mockStore as ReturnType<typeof useAuthStore>;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe('ZaloCallbackPage', () => {
|
|||||||
handleOAuthCallback: vi.fn().mockResolvedValue(undefined),
|
handleOAuthCallback: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
mockedUseAuthStore.mockImplementation((selector) => {
|
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>;
|
return mockStore as ReturnType<typeof useAuthStore>;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
407
apps/web/components/agents/agent-profile-client.tsx
Normal file
407
apps/web/components/agents/agent-profile-client.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { AgentPublicProfile } from '@/lib/agents-api';
|
||||||
import type { ListingDetail } from '@/lib/listings-api';
|
import type { ListingDetail } from '@/lib/listings-api';
|
||||||
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
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">
|
// React component that renders <script type="application/ld+json">
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
85
apps/web/lib/agents-api.ts
Normal file
85
apps/web/lib/agents-api.ts
Normal file
@@ -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}`,
|
||||||
|
),
|
||||||
|
};
|
||||||
67
apps/web/lib/agents-server.ts
Normal file
67
apps/web/lib/agents-server.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user