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>
This commit is contained in:
723
docs/audits/AGENT_PROFILE_CODE_EXAMPLES.md
Normal file
723
docs/audits/AGENT_PROFILE_CODE_EXAMPLES.md
Normal file
@@ -0,0 +1,723 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user