feat: finish CMS-driven SEO and accessibility pass

This commit is contained in:
2026-09-28 21:34:43 +03:30
parent 64c0105134
commit 4c969a7c9f
13 changed files with 307 additions and 393 deletions

View File

@@ -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>
);
}