Files
goodgo-platform/docs/audits/AGENT_PROFILE_CODE_EXAMPLES.md
Ho Ngoc Hai b8512ebff4 docs: consolidate audit and analysis reports into docs/audits/
Move 36 root-level audit/analysis documents and 7 web app audit documents
into docs/audits/ directory to declutter the project root. Remove stale
EXPLORATION_SUMMARY.txt.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-11 01:37:50 +07:00

724 lines
20 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<GetAgentProfileQuery> {
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<AgentPublicProfileDto> {
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<void>;
getDashboard(agentId: string): Promise<AgentDashboardData>;
// NEW METHOD:
getPublicProfile(agentId: string): Promise<AgentPublicProfileDto>;
}
```
### 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<AgentPublicProfile>(`/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<Metadata> {
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 (
<>
<JsonLd data={agentJsonLd} />
<JsonLd data={breadcrumbJsonLd} />
<AgentDetailClient agent={agent} />
</>
);
}
```
---
## 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 (
<main>
<AgentDetailHeader agent={agent} />
<AgentReviewsSection agentId={agent.id} />
<AgentListingsSection agentId={agent.id} />
</main>
);
}
```
### 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 (
<section className="py-16 md:py-24">
<div className="mx-auto max-w-7xl px-4">
<Card>
<CardContent className="p-6 md:p-8">
<div className="flex flex-col gap-6 md:flex-row">
{/* Avatar */}
<div className="flex-shrink-0">
{agent.avatarUrl ? (
<Image
src={agent.avatarUrl}
alt={agent.fullName}
width={120}
height={120}
className="h-28 w-28 rounded-lg object-cover"
/>
) : (
<div className="h-28 w-28 rounded-lg bg-muted flex items-center justify-center text-muted-foreground">
No photo
</div>
)}
</div>
{/* Info */}
<div className="flex-1">
<h1 className="text-3xl font-bold">{agent.fullName}</h1>
{/* Badges */}
<div className="mt-3 flex flex-wrap gap-2">
{agent.isVerified && (
<Badge variant="default"> Verified</Badge>
)}
<Badge variant="secondary">
{agent.qualityScore.toFixed(1)}/100
</Badge>
</div>
{/* Details */}
<dl className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-3">
{agent.licenseNumber && (
<>
<dt className="text-sm font-medium text-muted-foreground">License</dt>
<dd className="text-sm font-semibold">{agent.licenseNumber}</dd>
</>
)}
{agent.agency && (
<>
<dt className="text-sm font-medium text-muted-foreground">Agency</dt>
<dd className="text-sm font-semibold">{agent.agency}</dd>
</>
)}
<div>
<dt className="text-sm font-medium text-muted-foreground">Listings</dt>
<dd className="text-sm font-semibold">{agent.activeListings} active</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Reviews</dt>
<dd className="text-sm font-semibold">
{agent.avgReviewRating.toFixed(1)} ({agent.totalReviews})
</dd>
</div>
</dl>
{/* Bio */}
{agent.bio && (
<p className="mt-4 text-sm text-muted-foreground">{agent.bio}</p>
)}
{/* Service Areas */}
{agent.serviceAreas.length > 0 && (
<div className="mt-4">
<p className="text-xs font-medium text-muted-foreground">Serves:</p>
<div className="mt-1 flex flex-wrap gap-2">
{agent.serviceAreas.map((area) => (
<Badge key={area} variant="outline">
📍 {area}
</Badge>
))}
</div>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
</section>
);
}
```
### 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<ListingDetail[]>([]);
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 (
<section className="py-16 md:py-24">
<div className="mx-auto max-w-7xl px-4">
<h2 className="text-2xl font-bold">Active Listings ({listings.length})</h2>
<p className="mt-2 text-muted-foreground">
{listings.length === 0 ? 'No active listings' : 'Browse properties from this agent'}
</p>
{loading ? (
<div className="mt-8 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-80 rounded-lg bg-muted animate-pulse"
/>
))}
</div>
) : listings.length > 0 ? (
<div className="mt-8 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{listings.map((listing) => (
<PropertyCard key={listing.id} listing={listing} />
))}
</div>
) : (
<div className="mt-8 text-center text-muted-foreground">
No active listings available
</div>
)}
</div>
</section>
);
}
```
### 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<ReviewItem[]>([]);
const [stats, setStats] = React.useState<ReviewStats | null>(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 <div className="py-16 text-center text-muted-foreground">Loading reviews...</div>;
return (
<section className="py-16 md:py-24 bg-muted/50">
<div className="mx-auto max-w-7xl px-4">
<div className="mb-8">
<h2 className="text-2xl font-bold">Customer Reviews</h2>
{stats && (
<div className="mt-4 flex items-center gap-4">
<div>
<div className="text-3xl font-bold">{stats.averageRating.toFixed(1)}</div>
<div className="text-sm text-muted-foreground">out of 5.0</div>
</div>
<div className="text-sm text-muted-foreground">
Based on {stats.totalReviews} reviews
</div>
</div>
)}
</div>
{reviews.length > 0 ? (
<div className="space-y-4">
{reviews.map((review) => (
<Card key={review.id}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div>
<div className="font-semibold">{review.user.fullName}</div>
<div className="flex gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<span key={i} className="text-lg">
{i < review.rating ? '⭐' : '☆'}
</span>
))}
</div>
</div>
<div className="text-xs text-muted-foreground">
{new Date(review.createdAt).toLocaleDateString()}
</div>
</div>
{review.comment && (
<p className="mt-2 text-sm text-muted-foreground">{review.comment}</p>
)}
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-center text-muted-foreground">
No reviews yet
</div>
)}
</div>
</section>
);
}
```
---
## 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
<section className="py-16 md:py-24">
<div className="mx-auto max-w-7xl px-4">
{/* content */}
</div>
</section>
// Cards
<Card>
<CardContent className="p-4 md:p-6">
{/* content */}
</CardContent>
</Card>
// Grid
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* items */}
</div>
```
---
## 🧪 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
```