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>
724 lines
20 KiB
Markdown
724 lines
20 KiB
Markdown
# 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
|
||
```
|
||
|