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:
112
apps/web/app/[locale]/(public)/agents/[id]/page.tsx
Normal file
112
apps/web/app/[locale]/(public)/agents/[id]/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { AgentProfileClient } from '@/components/agents/agent-profile-client';
|
||||
import {
|
||||
JsonLd,
|
||||
generateAgentJsonLd,
|
||||
generateBreadcrumbJsonLd,
|
||||
} from '@/components/seo/json-ld';
|
||||
import { fetchAgentProfile, fetchAgentReviews } from '@/lib/agents-server';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const siteUrl = process.env['NEXT_PUBLIC_SITE_URL'] || 'https://goodgo.vn';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata (runs server-side, provides <title>, <meta>, OG, canonical)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PageProps {
|
||||
params: { locale: string; id: string };
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const agent = await fetchAgentProfile(params.id);
|
||||
if (!agent) {
|
||||
return { title: 'Không tìm thấy môi giới' };
|
||||
}
|
||||
|
||||
const title = agent.isVerified
|
||||
? `${agent.fullName} — Môi giới xác minh | GoodGo`
|
||||
: `${agent.fullName} — Môi giới BĐS | GoodGo`;
|
||||
|
||||
const description = [
|
||||
agent.agency ? `Công ty: ${agent.agency}` : null,
|
||||
agent.serviceAreas.length > 0
|
||||
? `Khu vực: ${agent.serviceAreas.join(', ')}`
|
||||
: null,
|
||||
agent.totalReviews > 0
|
||||
? `Đánh giá: ${agent.avgReviewRating}/5 (${agent.totalReviews} lượt)`
|
||||
: null,
|
||||
`${agent.activeListings.length} tin đăng đang hoạt động`,
|
||||
`${agent.totalDeals} giao dịch thành công`,
|
||||
]
|
||||
.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: 400, height: 400, 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'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page (Server Component)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default async function AgentProfilePage({ params }: PageProps) {
|
||||
const [agent, reviewsResult] = await Promise.all([
|
||||
fetchAgentProfile(params.id),
|
||||
fetchAgentReviews(params.id, 1, 10),
|
||||
]);
|
||||
|
||||
if (!agent) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Build JSON-LD structured data
|
||||
const agentJsonLd = generateAgentJsonLd(agent, siteUrl);
|
||||
const breadcrumbJsonLd = generateBreadcrumbJsonLd([
|
||||
{ name: 'Trang chủ', url: siteUrl },
|
||||
{ name: agent.fullName, url: `${siteUrl}/${params.locale}/agents/${params.id}` },
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Structured data for search engines */}
|
||||
<JsonLd data={agentJsonLd} />
|
||||
<JsonLd data={breadcrumbJsonLd} />
|
||||
|
||||
{/* Interactive client component */}
|
||||
<AgentProfileClient agent={agent} reviews={reviewsResult.data} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ describe('GoogleCallbackPage', () => {
|
||||
handleOAuthCallback: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockedUseAuthStore.mockImplementation((selector) => {
|
||||
if (typeof selector === 'function') return (selector as (s: typeof mockStore) => unknown)(mockStore);
|
||||
if (typeof selector === 'function') return (selector as unknown as (s: typeof mockStore) => unknown)(mockStore);
|
||||
return mockStore as ReturnType<typeof useAuthStore>;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('ZaloCallbackPage', () => {
|
||||
handleOAuthCallback: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockedUseAuthStore.mockImplementation((selector) => {
|
||||
if (typeof selector === 'function') return (selector as (s: typeof mockStore) => unknown)(mockStore);
|
||||
if (typeof selector === 'function') return (selector as unknown as (s: typeof mockStore) => unknown)(mockStore);
|
||||
return mockStore as ReturnType<typeof useAuthStore>;
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user