Files
goodgo-platform/apps/web/components/seo/json-ld.tsx
Ho Ngoc Hai 50c5168529 feat(web): add SEO optimization — JSON-LD, dynamic sitemap, meta tags for listings
Add comprehensive SEO support for property listing pages to improve
organic search visibility and social sharing.

Changes:
- Convert listing detail page from client-only to server component wrapper
  with generateMetadata() for per-listing title, description, OG tags,
  canonical URLs, and hreflang alternates
- Add JSON-LD structured data (Schema.org RealEstateListing) with price,
  location, property specs, and breadcrumb markup
- Add Website JSON-LD with SearchAction to root layout
- Upgrade sitemap.xml to dynamically include all active listings across
  both locales (vi, en) with ISR revalidation
- Improve robots.txt with pagination/sort exclusions and GPTBot block
- Create server-side fetch utility (listings-server.ts) for SSR data
- Extract client UI into ListingDetailClient component

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 20:38:28 +07:00

189 lines
5.6 KiB
TypeScript

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