# Agent Public Profile Page — Code Examples & Implementation Templates ## 1️⃣ BACKEND: API Endpoint Creation ### File: `apps/api/src/modules/agents/application/queries/get-agent-profile/get-agent-profile.query.ts` ```typescript export class GetAgentProfileQuery { constructor(public readonly agentId: string) {} } ``` ### File: `apps/api/src/modules/agents/application/queries/get-agent-profile/get-agent-profile.handler.ts` ```typescript import { Inject } from '@nestjs/common'; import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { NotFoundException } from '@modules/shared'; import { AGENT_REPOSITORY, type IAgentRepository, } from '../../../domain/repositories/agent.repository'; import { GetAgentProfileQuery } from './get-agent-profile.query'; @QueryHandler(GetAgentProfileQuery) export class GetAgentProfileHandler implements IQueryHandler { constructor( @Inject(AGENT_REPOSITORY) private readonly agentRepo: IAgentRepository, ) {} async execute(query: GetAgentProfileQuery) { const agent = await this.agentRepo.findById(query.agentId); if (!agent) { throw new NotFoundException('Agent not found'); } return this.agentRepo.getPublicProfile(query.agentId); } } ``` ### File: `apps/api/src/modules/agents/presentation/dto/agent-public-profile.dto.ts` ```typescript import { ApiProperty } from '@nestjs/swagger'; export class AgentPublicProfileDto { @ApiProperty() id: string; @ApiProperty() fullName: string; @ApiProperty({ nullable: true }) avatarUrl: string | null; @ApiProperty({ nullable: true }) licenseNumber: string | null; @ApiProperty({ nullable: true }) agency: string | null; @ApiProperty() qualityScore: number; @ApiProperty({ nullable: true }) bio: string | null; @ApiProperty({ type: [String] }) serviceAreas: string[]; @ApiProperty() isVerified: boolean; @ApiProperty() totalListings: number; @ApiProperty() activeListings: number; @ApiProperty() avgReviewRating: number; @ApiProperty() totalReviews: number; @ApiProperty() createdAt: string; @ApiProperty() updatedAt: string; } ``` ### File: Update `apps/api/src/modules/agents/presentation/controllers/agents.controller.ts` ```typescript import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { JwtAuthGuard, RolesGuard, Roles } from '@modules/auth'; import { GetAgentProfileQuery } from '../../application/queries/get-agent-profile/get-agent-profile.query'; import { AgentPublicProfileDto } from '../dto/agent-public-profile.dto'; @ApiTags('agents') @Controller('agents') export class AgentsController { constructor( private readonly commandBus: CommandBus, private readonly queryBus: QueryBus, ) {} // ── Public endpoint ──────────────────────────────────────── @ApiOperation({ summary: 'Get public agent profile' }) @ApiParam({ name: 'agentId', description: 'Agent ID' }) @ApiResponse({ status: 200, description: 'Agent profile', type: AgentPublicProfileDto }) @ApiResponse({ status: 404, description: 'Agent not found' }) @Get(':agentId/profile') async getPublicProfile(@Param('agentId') agentId: string): Promise { return this.queryBus.execute(new GetAgentProfileQuery(agentId)); } // ── Existing endpoints (unchanged) ───────────────────────── // ... rest of controller } ``` ### File: Update `apps/api/src/modules/agents/domain/repositories/agent.repository.ts` ```typescript 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; // NEW METHOD: getPublicProfile(agentId: string): Promise; } ``` ### File: Update `apps/api/src/modules/agents/infrastructure/repositories/prisma-agent.repository.ts` ```typescript async getPublicProfile(agentId: string) { const agent = await this.prisma.agent.findUnique({ where: { id: agentId }, include: { user: { select: { fullName: true, avatarUrl: true, email: true, phone: true, }, }, }, }); if (!agent) return null; // Get stats in parallel const [totalListings, activeListings, reviewStats] = await Promise.all([ this.prisma.listing.count({ where: { agentId }, }), this.prisma.listing.count({ where: { agentId, status: 'ACTIVE' }, }), this.prisma.review.aggregate({ where: { targetId: agentId, targetType: 'AGENT' }, _avg: { rating: true }, _count: true, }), ]); return { id: agent.id, fullName: agent.user.fullName, avatarUrl: agent.user.avatarUrl, licenseNumber: agent.licenseNumber, agency: agent.agency, qualityScore: agent.qualityScore, bio: agent.bio, serviceAreas: agent.serviceAreas as string[], isVerified: agent.isVerified, totalListings, activeListings, avgReviewRating: reviewStats._avg.rating ?? 0, totalReviews: reviewStats._count, createdAt: agent.createdAt.toISOString(), updatedAt: agent.updatedAt.toISOString(), }; } ``` --- ## 2️⃣ FRONTEND: API Client ### File: `apps/web/lib/agents-api.ts` ```typescript import { apiClient } from './api-client'; export interface AgentPublicProfile { id: string; fullName: string; avatarUrl: string | null; licenseNumber: string | null; agency: string | null; qualityScore: number; bio: string | null; serviceAreas: string[]; isVerified: boolean; totalListings: number; activeListings: number; avgReviewRating: number; totalReviews: number; createdAt: string; updatedAt: string; } export const agentsApi = { getById: (id: string) => apiClient.get(`/agents/${id}/profile`), }; ``` ### File: `apps/web/lib/agents-server.ts` ```typescript const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1'; export async function fetchAgentById(id: string) { try { const res = await fetch(`${API_BASE_URL}/agents/${id}/profile`, { next: { revalidate: 3600 }, // ISR: revalidate every 1 hour }); if (!res.ok) return null; return res.json(); } catch { return null; } } ``` --- ## 3️⃣ FRONTEND: Server Component (Page) ### File: `apps/web/app/[locale]/(public)/agents/[id]/page.tsx` ```typescript import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { AgentDetailClient } from '@/components/agents/agent-detail-client'; import { JsonLd, generateBreadcrumbJsonLd, } from '@/components/seo/json-ld'; import { fetchAgentById } from '@/lib/agents-server'; const siteUrl = process.env['NEXT_PUBLIC_SITE_URL'] || 'https://goodgo.vn'; interface PageProps { params: { locale: string; id: string }; } export async function generateMetadata({ params }: PageProps): Promise { const agent = await fetchAgentById(params.id); if (!agent) { return { title: 'Agent not found' }; } const title = `${agent.fullName} — Real Estate Agent at GoodGo`; const description = [ agent.bio || 'Real estate agent', `Quality Score: ${agent.qualityScore}/100`, `${agent.activeListings} active listings`, `⭐ ${agent.avgReviewRating} (${agent.totalReviews} reviews)`, ] .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: 200, height: 200, 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'], }, }; } export default async function AgentProfilePage({ params }: PageProps) { const agent = await fetchAgentById(params.id); if (!agent) { notFound(); } const agentJsonLd = { '@context': 'https://schema.org', '@type': 'LocalBusiness', name: agent.fullName, description: agent.bio, image: agent.avatarUrl, url: `${siteUrl}/${params.locale}/agents/${params.id}`, ...(agent.serviceAreas.length > 0 && { areaServed: agent.serviceAreas.map((area) => ({ '@type': 'Place', name: area, })), }), aggregateRating: { '@type': 'AggregateRating', ratingValue: agent.avgReviewRating, reviewCount: agent.totalReviews, }, }; const breadcrumbJsonLd = generateBreadcrumbJsonLd([ { name: 'Home', url: siteUrl }, { name: 'Agents', url: `${siteUrl}/agents` }, { name: agent.fullName, url: `${siteUrl}/${params.locale}/agents/${params.id}` }, ]); return ( <> ); } ``` --- ## 4️⃣ FRONTEND: Client Components ### File: `apps/web/components/agents/agent-detail-client.tsx` ```typescript 'use client'; import * as React from 'react'; import { AgentDetailClient as AgentDetailHeader } from './agent-header'; import { AgentListingsSection } from './agent-listings-section'; import { AgentReviewsSection } from './agent-reviews-section'; import type { AgentPublicProfile } from '@/lib/agents-api'; interface AgentDetailClientProps { agent: AgentPublicProfile; } export function AgentDetailClient({ agent }: AgentDetailClientProps) { return (
); } ``` ### File: `apps/web/components/agents/agent-header.tsx` ```typescript 'use client'; import Image from 'next/image'; import * as React from 'react'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; import type { AgentPublicProfile } from '@/lib/agents-api'; interface AgentDetailClientProps { agent: AgentPublicProfile; } export function AgentDetailClient({ agent }: AgentDetailClientProps) { return (
{/* Avatar */}
{agent.avatarUrl ? ( {agent.fullName} ) : (
No photo
)}
{/* Info */}

{agent.fullName}

{/* Badges */}
{agent.isVerified && ( ✓ Verified )} ⭐ {agent.qualityScore.toFixed(1)}/100
{/* Details */}
{agent.licenseNumber && ( <>
License
{agent.licenseNumber}
)} {agent.agency && ( <>
Agency
{agent.agency}
)}
Listings
{agent.activeListings} active
Reviews
{agent.avgReviewRating.toFixed(1)} ({agent.totalReviews})
{/* Bio */} {agent.bio && (

{agent.bio}

)} {/* Service Areas */} {agent.serviceAreas.length > 0 && (

Serves:

{agent.serviceAreas.map((area) => ( 📍 {area} ))}
)}
); } ``` ### File: `apps/web/components/agents/agent-listings-section.tsx` ```typescript 'use client'; import * as React from 'react'; import { PropertyCard } from '@/components/search/property-card'; import { listingsApi, type ListingDetail } from '@/lib/listings-api'; interface AgentListingsSectionProps { agentId: string; } export function AgentListingsSection({ agentId }: AgentListingsSectionProps) { const [listings, setListings] = React.useState([]); const [loading, setLoading] = React.useState(true); React.useEffect(() => { listingsApi .search({ agentId, status: 'ACTIVE', limit: 12 }) .then((res) => setListings(res.data)) .finally(() => setLoading(false)); }, [agentId]); return (

Active Listings ({listings.length})

{listings.length === 0 ? 'No active listings' : 'Browse properties from this agent'}

{loading ? (
{Array.from({ length: 6 }).map((_, i) => (
))}
) : listings.length > 0 ? (
{listings.map((listing) => ( ))}
) : (
No active listings available
)}
); } ``` ### File: `apps/web/components/agents/agent-reviews-section.tsx` ```typescript 'use client'; import * as React from 'react'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; import { apiClient } from '@/lib/api-client'; import type { ListingDetail } from '@/lib/listings-api'; interface ReviewItem { id: string; rating: number; comment: string | null; createdAt: string; user: { fullName: string; avatarUrl: string | null; }; } interface ReviewStats { averageRating: number; totalReviews: number; } interface AgentReviewsSectionProps { agentId: string; } export function AgentReviewsSection({ agentId }: AgentReviewsSectionProps) { const [reviews, setReviews] = React.useState([]); const [stats, setStats] = React.useState(null); const [loading, setLoading] = React.useState(true); React.useEffect(() => { Promise.all([ apiClient.get(`/reviews?targetType=AGENT&targetId=${agentId}&limit=10`), apiClient.get(`/reviews/stats?targetType=AGENT&targetId=${agentId}`), ]) .then(([reviewsRes, statsRes]) => { setReviews(reviewsRes.data); setStats(statsRes); }) .finally(() => setLoading(false)); }, [agentId]); if (loading) return
Loading reviews...
; return (

Customer Reviews

{stats && (
{stats.averageRating.toFixed(1)}
out of 5.0
Based on {stats.totalReviews} reviews
)}
{reviews.length > 0 ? (
{reviews.map((review) => (
{review.user.fullName}
{Array.from({ length: 5 }).map((_, i) => ( {i < review.rating ? '⭐' : '☆'} ))}
{new Date(review.createdAt).toLocaleDateString()}
{review.comment && (

{review.comment}

)}
))}
) : (
No reviews yet
)}
); } ``` --- ## 5️⃣ STYLING REFERENCE All components use: - **Tailwind CSS** classes directly (no CSS modules) - **Responsive breakpoints**: `md:`, `lg:` - **Dark mode**: Uses CSS variables in `globals.css` - **Component pattern**: Card → CardContent ### Common spacing patterns: ```typescript // Sections
{/* content */}
// Cards {/* content */} // Grid
{/* items */}
``` --- ## 🧪 Testing Checklist ```bash # Backend API Test curl http://localhost:3001/api/v1/agents/[valid-agent-id]/profile # Expected Response { "id": "...", "fullName": "...", "qualityScore": 4.5, "totalListings": 45, "activeListings": 32, "avgReviewRating": 4.7, "totalReviews": 120, ... } ``` ```typescript // Frontend Test import { agentsApi } from '@/lib/agents-api'; const agent = await agentsApi.getById('agent-id-here'); console.log(agent); // Should match structure above ```