Files
goodgo-platform/apps/web/components/seo/json-ld.tsx
Ho Ngoc Hai 62485fee98 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>
2026-04-11 00:16:19 +07:00

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}`,
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) }}
/>
);
}