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>
230 lines
6.8 KiB
TypeScript
230 lines
6.8 KiB
TypeScript
import type { AgentPublicProfile } from '@/lib/agents-api';
|
|
import type { ListingDetail } from '@/lib/listings-api';
|
|
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getLabel(list: readonly { value: string; label: string }[], value: string | null) {
|
|
if (!value) return null;
|
|
return list.find((item) => item.value === value)?.label ?? value;
|
|
}
|
|
|
|
function formatPriceNumber(priceVND: string): number {
|
|
return Number(priceVND);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JSON-LD generator for a single listing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function generateListingJsonLd(listing: ListingDetail, siteUrl: string) {
|
|
const { property } = listing;
|
|
|
|
const propertyTypeLabel = getLabel(PROPERTY_TYPES, property.propertyType) ?? property.propertyType;
|
|
const transactionLabel = getLabel(TRANSACTION_TYPES, listing.transactionType) ?? listing.transactionType;
|
|
|
|
const fullAddress = [property.address, property.ward, property.district, property.city]
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
|
|
const images = property.media
|
|
.filter((m) => m.type === 'image')
|
|
.map((m) => m.url);
|
|
|
|
const priceNum = formatPriceNumber(listing.priceVND);
|
|
|
|
// Schema.org RealEstateListing
|
|
// https://schema.org/RealEstateListing
|
|
const jsonLd: Record<string, unknown> = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'RealEstateListing',
|
|
name: property.title,
|
|
description: property.description?.slice(0, 500),
|
|
url: `${siteUrl}/listings/${listing.id}`,
|
|
datePosted: listing.publishedAt ?? listing.createdAt,
|
|
...(images.length > 0 && { image: images }),
|
|
|
|
// Offer
|
|
offers: {
|
|
'@type': 'Offer',
|
|
price: priceNum,
|
|
priceCurrency: 'VND',
|
|
availability: listing.status === 'ACTIVE'
|
|
? 'https://schema.org/InStock'
|
|
: 'https://schema.org/SoldOut',
|
|
...(listing.transactionType === 'RENT' && listing.rentPriceMonthly && {
|
|
priceSpecification: {
|
|
'@type': 'UnitPriceSpecification',
|
|
price: Number(listing.rentPriceMonthly),
|
|
priceCurrency: 'VND',
|
|
unitText: 'MONTH',
|
|
},
|
|
}),
|
|
},
|
|
|
|
// Location
|
|
contentLocation: {
|
|
'@type': 'Place',
|
|
name: fullAddress,
|
|
address: {
|
|
'@type': 'PostalAddress',
|
|
streetAddress: property.address,
|
|
addressLocality: property.district,
|
|
addressRegion: property.city,
|
|
addressCountry: 'VN',
|
|
},
|
|
...(property.latitude != null && property.longitude != null && {
|
|
geo: {
|
|
'@type': 'GeoCoordinates',
|
|
latitude: property.latitude,
|
|
longitude: property.longitude,
|
|
},
|
|
}),
|
|
},
|
|
|
|
// Additional property details via additionalProperty
|
|
additionalProperty: [
|
|
{
|
|
'@type': 'PropertyValue',
|
|
name: 'Loại BDS',
|
|
value: propertyTypeLabel,
|
|
},
|
|
{
|
|
'@type': 'PropertyValue',
|
|
name: 'Loại giao dich',
|
|
value: transactionLabel,
|
|
},
|
|
{
|
|
'@type': 'PropertyValue',
|
|
name: 'Dien tich',
|
|
value: `${property.areaM2} m²`,
|
|
unitCode: 'MTK',
|
|
},
|
|
...(property.bedrooms != null
|
|
? [{
|
|
'@type': 'PropertyValue' as const,
|
|
name: 'Phong ngu',
|
|
value: property.bedrooms,
|
|
}]
|
|
: []),
|
|
...(property.bathrooms != null
|
|
? [{
|
|
'@type': 'PropertyValue' as const,
|
|
name: 'Phong tam',
|
|
value: property.bathrooms,
|
|
}]
|
|
: []),
|
|
...(property.floors != null
|
|
? [{
|
|
'@type': 'PropertyValue' as const,
|
|
name: 'So tang',
|
|
value: property.floors,
|
|
}]
|
|
: []),
|
|
],
|
|
};
|
|
|
|
return jsonLd;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BreadcrumbList JSON-LD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function generateBreadcrumbJsonLd(
|
|
items: { name: string; url: string }[],
|
|
) {
|
|
return {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'BreadcrumbList',
|
|
itemListElement: items.map((item, index) => ({
|
|
'@type': 'ListItem',
|
|
position: index + 1,
|
|
name: item.name,
|
|
item: item.url,
|
|
})),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Website (Organization) JSON-LD — for the root layout
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function generateWebsiteJsonLd(siteUrl: string) {
|
|
return {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'WebSite',
|
|
name: 'GoodGo',
|
|
url: siteUrl,
|
|
description: 'Nen tang bat dong san thong minh tai Viet Nam',
|
|
potentialAction: {
|
|
'@type': 'SearchAction',
|
|
target: {
|
|
'@type': 'EntryPoint',
|
|
urlTemplate: `${siteUrl}/search?q={search_term_string}`,
|
|
},
|
|
'query-input': 'required name=search_term_string',
|
|
},
|
|
inLanguage: ['vi', 'en'],
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Agent profile JSON-LD (RealEstateAgent)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function generateAgentJsonLd(agent: AgentPublicProfile, siteUrl: string) {
|
|
const jsonLd: Record<string, unknown> = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'RealEstateAgent',
|
|
name: agent.fullName,
|
|
url: `${siteUrl}/agents/${agent.id}`,
|
|
description: agent.bio ?? `Môi giới bất động sản tại GoodGo`,
|
|
...(agent.avatarUrl && { image: agent.avatarUrl }),
|
|
telephone: agent.phone,
|
|
...(agent.email && { email: agent.email }),
|
|
...(agent.agency && {
|
|
worksFor: {
|
|
'@type': 'RealEstateAgent',
|
|
name: agent.agency,
|
|
},
|
|
}),
|
|
...(agent.serviceAreas.length > 0 && {
|
|
areaServed: agent.serviceAreas.map((area) => ({
|
|
'@type': 'AdministrativeArea',
|
|
name: area,
|
|
})),
|
|
}),
|
|
...(agent.totalReviews > 0 && {
|
|
aggregateRating: {
|
|
'@type': 'AggregateRating',
|
|
ratingValue: agent.avgReviewRating,
|
|
reviewCount: agent.totalReviews,
|
|
bestRating: 5,
|
|
worstRating: 1,
|
|
},
|
|
}),
|
|
};
|
|
|
|
return jsonLd;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// React component that renders <script type="application/ld+json">
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface JsonLdProps {
|
|
data: Record<string, unknown> | Record<string, unknown>[];
|
|
}
|
|
|
|
export function JsonLd({ data }: JsonLdProps) {
|
|
return (
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
|
|
/>
|
|
);
|
|
}
|