feat: finish CMS-driven SEO and accessibility pass
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Helmet } from "@/lib/helmet";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -11,12 +10,10 @@ import { Button } from "@/components/ui/button";
|
||||
import ProgressiveImage from "@/components/ProgressiveImage";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { formatJalali, formatNumberPersian, formatToman, getEventCardImageUrl } from "@/lib/utils";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
import type { EventsContentPage } from "@/lib/content-cms";
|
||||
|
||||
type EventsProps = {
|
||||
content: EventsContentPage;
|
||||
siteName: string;
|
||||
initialEvents?: Types.EventListItemSchema[];
|
||||
initialSearch?: string;
|
||||
};
|
||||
@@ -53,7 +50,6 @@ function notAvailableReasonFa(event: Types.EventListItemSchema) {
|
||||
|
||||
export default function Events({
|
||||
content,
|
||||
siteName,
|
||||
initialEvents = [],
|
||||
initialSearch = "",
|
||||
}: EventsProps) {
|
||||
@@ -63,71 +59,6 @@ export default function Events({
|
||||
const [search, setSearch] = useState(initialSearch);
|
||||
const [loading, setLoading] = useState(!initialEvents.length && !initialSearch);
|
||||
|
||||
const pageTitle = content.seo.title || content.title;
|
||||
const pageDescription = content.seo.description || content.intro;
|
||||
const canonicalUrl = `${siteUrl}/events`;
|
||||
|
||||
const toAbsoluteUrl = (url?: string | null) => {
|
||||
if (!url) return undefined;
|
||||
if (url.startsWith("http")) return url;
|
||||
const normalizedSite = siteUrl.endsWith("/") ? siteUrl.slice(0, -1) : siteUrl;
|
||||
const normalizedPath = url.startsWith("/") ? url.slice(1) : url;
|
||||
return `${normalizedSite}/${normalizedPath}`;
|
||||
};
|
||||
|
||||
const ogImage = useMemo(() => {
|
||||
if (!events.length) return `${siteUrl}/favicon.ico`;
|
||||
return toAbsoluteUrl(getEventCardImageUrl(events[0])) ?? `${siteUrl}/favicon.ico`;
|
||||
}, [events]);
|
||||
|
||||
const listStructuredData = useMemo(() => {
|
||||
if (!events.length) return null;
|
||||
|
||||
const itemListElement = events.map((eventItem, index) => {
|
||||
const listItem: Record<string, unknown> = {
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
url: `${siteUrl}/events/${eventItem.slug}`,
|
||||
name: eventItem.title,
|
||||
description: eventItem.description,
|
||||
startDate: eventItem.start_time,
|
||||
};
|
||||
|
||||
if (eventItem.end_time) {
|
||||
listItem.endDate = eventItem.end_time;
|
||||
}
|
||||
|
||||
const imageUrl = toAbsoluteUrl(getEventCardImageUrl(eventItem));
|
||||
if (imageUrl) {
|
||||
listItem.image = imageUrl;
|
||||
}
|
||||
|
||||
const placeName = eventItem.location || eventItem.address;
|
||||
if (placeName) {
|
||||
const place: Record<string, unknown> = {
|
||||
"@type": "Place",
|
||||
name: placeName,
|
||||
};
|
||||
if (eventItem.address) {
|
||||
place.address = eventItem.address;
|
||||
}
|
||||
listItem.location = place;
|
||||
}
|
||||
|
||||
return listItem;
|
||||
});
|
||||
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ItemList",
|
||||
name: pageTitle,
|
||||
description: pageDescription,
|
||||
url: canonicalUrl,
|
||||
numberOfItems: events.length,
|
||||
itemListElement,
|
||||
};
|
||||
}, [canonicalUrl, events, pageDescription, pageTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
setEvents(initialEvents);
|
||||
}, [initialEvents]);
|
||||
@@ -169,28 +100,7 @@ export default function Events({
|
||||
}, [location.pathname, navigate, search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={pageDescription} />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={pageDescription} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:site_name" content={siteName} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:locale" content="fa_IR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={pageTitle} />
|
||||
<meta name="twitter:description" content={pageDescription} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
{listStructuredData && (
|
||||
<script type="application/ld+json">{JSON.stringify(listStructuredData)}</script>
|
||||
)}
|
||||
</Helmet>
|
||||
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<main className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="mb-3 text-4xl font-bold">{content.title}</h1>
|
||||
{content.intro ? <p className="mb-8 max-w-3xl leading-8 text-muted-foreground">{content.intro}</p> : null}
|
||||
@@ -199,6 +109,7 @@ export default function Events({
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="جستجو در رویدادها..."
|
||||
aria-label="جستجو در رویدادها"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-md"
|
||||
@@ -260,7 +171,6 @@ export default function Events({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user