admin/lib/seo/JsonLd.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

179 lines
4.8 KiB
TypeScript

import { SITE_SOCIAL_SAME_AS } from '@/constants/contact'
/**
* Renders a `<script type="application/ld+json">` block. Server component
* only — do not add `'use client'` here (JSON-LD needs to be in the
* initial HTML for crawlers, not injected client-side).
*/
interface JsonLdProps {
data: Record<string, unknown>
}
export const ORGANIZATION_ID = 'https://ghabilee.ir/#organization'
export const WEBSITE_ID = 'https://ghabilee.ir/#website'
const JsonLd = ({ data }: JsonLdProps) => (
<script
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
type="application/ld+json"
/>
)
export default JsonLd
export function organizationSchema({
url,
logo,
telephone,
address,
description,
}: {
url: string
logo: string
telephone: string
address: { locality: string; streetAddress: string }
description?: string
}) {
return {
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': ORGANIZATION_ID,
name: 'قبیله',
alternateName: 'Ghabilee',
url,
telephone,
logo,
...(description ? { description } : {}),
sameAs: [...SITE_SOCIAL_SAME_AS],
address: {
'@type': 'PostalAddress',
addressCountry: 'IR',
addressLocality: address.locality,
streetAddress: address.streetAddress,
},
contactPoint: {
'@type': 'ContactPoint',
telephone,
contactType: 'customer support',
areaServed: 'IR',
availableLanguage: ['fa', 'Persian'],
},
}
}
export function webSiteSchema(url: string) {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': WEBSITE_ID,
name: 'قبیله',
alternateName: 'Ghabilee',
inLanguage: 'fa-IR',
url,
publisher: { '@id': ORGANIZATION_ID },
}
}
export function breadcrumbList(items: { name: string; url: string }[], siteUrl: string) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: `${siteUrl}${item.url}`,
})),
}
}
export function collectionPage(name: string, description: string, url: string) {
return {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name,
description,
url,
}
}
export function faqPage(items: readonly { question: string; answer: string }[]) {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: { '@type': 'Answer', text: item.answer },
})),
}
}
interface EventSchemaParams {
name: string
description: string | null
startDate: string
endDate: string
url: string
image?: string
/** Exact street address. Null/blank on public pages when `addressVisibility` is `attendees_only`. */
address?: string | null
/** Coarse public location shown instead of the exact address for anonymous crawlers. */
generalArea?: string | null
cityName?: string | null
isFree: boolean
price: number
}
function nonempty(value: string | null | undefined): string | undefined {
const trimmed = value?.trim()
return trimmed ? trimmed : undefined
}
/**
* Google Event rich results require `location.address` as a `PostalAddress`
* object, not a string. Anonymous crawlers never receive the exact street
* address when the host hid it; `generalArea` + city is the public location.
*/
function eventLocation(params: Pick<EventSchemaParams, 'address' | 'generalArea' | 'cityName'>) {
const cityName = nonempty(params.cityName)
const streetAddress = nonempty(params.address) ?? nonempty(params.generalArea)
return {
'@type': 'Place' as const,
name: cityName ?? streetAddress,
address: {
'@type': 'PostalAddress' as const,
...(streetAddress ? { streetAddress } : {}),
...(cityName ? { addressLocality: cityName } : {}),
addressCountry: 'IR',
},
}
}
/** `Event` schema for canonical `/e/[slug]` rich results. */
export function eventSchema(params: EventSchemaParams) {
return {
'@context': 'https://schema.org',
'@type': 'Event',
name: params.name,
url: params.url,
description: params.description ?? undefined,
startDate: params.startDate,
endDate: params.endDate,
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
eventStatus: 'https://schema.org/EventScheduled',
location: eventLocation(params),
image: params.image ? [params.image] : undefined,
offers: {
'@type': 'Offer',
// Product prices are stored in toman; Schema.org's IRR value is rial.
price: params.isFree ? 0 : params.price * 10,
priceCurrency: 'IRR',
availability: 'https://schema.org/InStock',
url: params.url,
},
}
}