feat(agents): add public agent profile page at /agents/[id]

Implements a public-facing agent profile page with:
- Backend: new GET /agents/:agentId/profile public API endpoint with
  agent info, active listings, quality score, and review stats
- Frontend: server-rendered profile page with generateMetadata for SEO,
  JSON-LD structured data (RealEstateAgent schema), breadcrumbs
- Agent profile displays bio, service areas, quality score gauge,
  active listing cards, reviews with star ratings, and contact CTA
- Mobile responsive layout with sticky contact sidebar on desktop
- Vietnamese UI text throughout, consistent with existing patterns

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-11 00:16:19 +07:00
parent 37fab515b7
commit 62485fee98
13 changed files with 905 additions and 5 deletions

View File

@@ -0,0 +1,85 @@
import { apiClient } from './api-client';
// ─── Interfaces ──────────────────────────────────────────
export interface AgentListingItem {
id: string;
transactionType: string;
priceVND: string;
status: string;
property: {
id: string;
title: string;
propertyType: string;
address: string;
district: string;
city: string;
areaM2: number;
bedrooms: number | null;
bathrooms: number | null;
imageUrl: string | null;
};
}
export interface AgentPublicProfile {
id: string;
fullName: string;
avatarUrl: string | null;
phone: string;
email: string | null;
agency: string | null;
licenseNumber: string | null;
bio: string | null;
qualityScore: number;
totalDeals: number;
isVerified: boolean;
serviceAreas: string[];
memberSince: string;
activeListings: AgentListingItem[];
avgReviewRating: number;
totalReviews: number;
}
export interface AgentReviewItem {
id: string;
userId: string;
userName: string | null;
targetType: string;
targetId: string;
rating: number;
comment: string | null;
createdAt: string;
}
export interface AgentReviewStats {
targetType: string;
targetId: string;
averageRating: number;
totalReviews: number;
distribution: Record<number, number>;
}
export interface PaginatedReviews {
data: AgentReviewItem[];
total: number;
page: number;
limit: number;
totalPages: number;
}
// ─── API Functions ───────────────────────────────────────
export const agentsApi = {
getPublicProfile: (agentId: string) =>
apiClient.get<AgentPublicProfile>(`/agents/${agentId}/profile`),
getReviews: (agentId: string, page = 1, limit = 10) =>
apiClient.get<PaginatedReviews>(
`/reviews?targetType=AGENT&targetId=${agentId}&page=${page}&limit=${limit}`,
),
getReviewStats: (agentId: string) =>
apiClient.get<AgentReviewStats>(
`/reviews/stats?targetType=AGENT&targetId=${agentId}`,
),
};

View File

@@ -0,0 +1,67 @@
/**
* Server-side agent data fetching for Next.js Server Components.
*
* Uses `fetch` directly (no browser-only helpers) so it can run
* inside `generateMetadata`, server pages, etc.
*/
import type { AgentPublicProfile, AgentReviewStats, PaginatedReviews } from './agents-api';
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
/**
* Fetch a public agent profile by ID — server-only.
* Returns `null` when the agent is not found (404) so callers can `notFound()`.
*/
export async function fetchAgentProfile(agentId: string): Promise<AgentPublicProfile | null> {
try {
const res = await fetch(`${API_BASE_URL}/agents/${agentId}/profile`, {
next: { revalidate: 300 }, // ISR: re-validate every 5 min
});
if (!res.ok) return null;
return (await res.json()) as AgentPublicProfile;
} catch {
return null;
}
}
/**
* Fetch reviews for an agent — server-only.
*/
export async function fetchAgentReviews(
agentId: string,
page = 1,
limit = 10,
): Promise<PaginatedReviews> {
try {
const res = await fetch(
`${API_BASE_URL}/reviews?targetType=AGENT&targetId=${agentId}&page=${page}&limit=${limit}`,
{ next: { revalidate: 300 } },
);
if (!res.ok) {
return { data: [], total: 0, page: 1, limit: 10, totalPages: 0 };
}
return (await res.json()) as PaginatedReviews;
} catch {
return { data: [], total: 0, page: 1, limit: 10, totalPages: 0 };
}
}
/**
* Fetch review stats for an agent — server-only.
*/
export async function fetchAgentReviewStats(agentId: string): Promise<AgentReviewStats | null> {
try {
const res = await fetch(
`${API_BASE_URL}/reviews/stats?targetType=AGENT&targetId=${agentId}`,
{ next: { revalidate: 300 } },
);
if (!res.ok) return null;
return (await res.json()) as AgentReviewStats;
} catch {
return null;
}
}