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:
85
apps/web/lib/agents-api.ts
Normal file
85
apps/web/lib/agents-api.ts
Normal 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}`,
|
||||
),
|
||||
};
|
||||
67
apps/web/lib/agents-server.ts
Normal file
67
apps/web/lib/agents-server.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user