Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
179 lines
4.8 KiB
TypeScript
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,
|
|
},
|
|
}
|
|
}
|