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>
This commit is contained in:
188
apps/web/components/seo/json-ld.tsx
Normal file
188
apps/web/components/seo/json-ld.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
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'],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user