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:
Ho Ngoc Hai
2026-04-11 00:16:19 +07:00
parent 37fab515b7
commit 62485fee98
13 changed files with 905 additions and 5 deletions

View File

@@ -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],

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
export class GetAgentPublicProfileQuery {
constructor(public readonly agentId: string) {}
}

View File

@@ -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<void>;
getDashboard(agentId: string): Promise<AgentDashboardData>;
getPublicProfile(agentId: string): Promise<AgentPublicProfileData | null>;
}

View File

@@ -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<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,
},
}));
}
}

View File

@@ -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<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')
@ApiOperation({ summary: 'Recalculate quality score (admin/system)' })
@ApiParam({ name: 'agentId', description: 'Agent ID' })