# Trang Hồ Sơ Công Khai Môi Giới — Ví Dụ Mã Nguồn & Mẫu Triển Khai

## 1️⃣ BACKEND: Tạo Endpoint API

### 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: Cập nhật `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,
  ) {}

  // ── Endpoint công khai ────────────────────────────────────────
  @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));
  }

  // ── Các endpoint hiện có (không thay đổi) ─────────────────────────
  // ... phần còn lại của controller
}
```

### File: Cập nhật `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>;
  
  // PHƯƠNG THỨC MỚI:
  getPublicProfile(agentId: string): Promise<AgentPublicProfileDto>;
}
```

### File: Cập nhật `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;

  // Lấy thống kê song song
  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: làm mới mỗi 1 giờ
    });
    if (!res.ok) return null;
    return res.json();
  } catch {
    return null;
  }
}
```

---

## 3️⃣ FRONTEND: Server Component (Trang)

### 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: Các Client Component

### 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">
              {/* Ảnh đại diện */}
              <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>

              {/* Thông tin */}
              <div className="flex-1">
                <h1 className="text-3xl font-bold">{agent.fullName}</h1>

                {/* Huy hiệu */}
                <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>

                {/* Chi tiết */}
                <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>

                {/* Tiểu sử */}
                {agent.bio && (
                  <p className="mt-4 text-sm text-muted-foreground">{agent.bio}</p>
                )}

                {/* Khu vực phục vụ */}
                {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">Đánh Giá Của Khách Hàng</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">trên 5,0</div>
              </div>
              <div className="text-sm text-muted-foreground">
                Dựa trên {stats.totalReviews} đánh giá
              </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">
            Chưa có đánh giá
          </div>
        )}
      </div>
    </section>
  );
}
```

---

## 5️⃣ THAM KHẢO GIAO DIỆN

Tất cả các component đều sử dụng:
- Các class **Tailwind CSS** trực tiếp (không dùng CSS module)
- **Breakpoint responsive**: `md:`, `lg:`
- **Chế độ tối**: Dùng biến CSS trong `globals.css`
- **Mẫu component**: Card → CardContent

### Các mẫu khoảng cách thông dụng:
```typescript
// Các phần
<section className="py-16 md:py-24">
  <div className="mx-auto max-w-7xl px-4">
    {/* nội dung */}
  </div>
</section>

// Card
<Card>
  <CardContent className="p-4 md:p-6">
    {/* nội dung */}
  </CardContent>
</Card>

// Lưới
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  {/* các phần tử */}
</div>
```

---

## 🧪 Danh Sách Kiểm Thử

```bash
# Kiểm thử API Backend
curl http://localhost:3001/api/v1/agents/[valid-agent-id]/profile

# Phản hồi kỳ vọng
{
  "id": "...",
  "fullName": "...",
  "qualityScore": 4.5,
  "totalListings": 45,
  "activeListings": 32,
  "avgReviewRating": 4.7,
  "totalReviews": 120,
  ...
}
```

```typescript
// Kiểm thử Frontend
import { agentsApi } from '@/lib/agents-api';

const agent = await agentsApi.getById('agent-id-here');
console.log(agent); // Phải khớp với cấu trúc trên
```
