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

@@ -175,19 +175,21 @@ function AssociationHistory({ content }: { content: AboutContentPage }) {
{content.history_heading}
</h2>
</div>
<ol className="relative space-y-7 md:space-y-9">
<div className="relative">
<span className="pointer-events-none absolute bottom-1 right-1.5 top-1 w-px bg-primary/25" aria-hidden="true" />
{content.milestones.map((milestone, index) => (
<li key={`${milestone.year_label}-${milestone.position}-${milestone.title}`} className="relative flex items-start gap-5">
<span className={`relative z-10 mt-1.5 h-3 w-3 shrink-0 rounded-full border-2 border-background ${index === content.milestones.length - 1 ? "bg-accent" : "bg-primary"}`} aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="mb-2 text-sm font-bold text-primary">{milestone.year_label}</p>
<h3 className="text-lg font-bold leading-7 text-foreground">{milestone.title}</h3>
<p className="mt-2 max-w-2xl whitespace-pre-line text-sm leading-7 text-muted-foreground">{milestone.description}</p>
</div>
</li>
))}
</ol>
<ol className="relative space-y-7 md:space-y-9">
{content.milestones.map((milestone, index) => (
<li key={`${milestone.year_label}-${milestone.position}-${milestone.title}`} className="relative flex items-start gap-5">
<span className={`relative z-10 mt-1.5 h-3 w-3 shrink-0 rounded-full border-2 border-background ${index === content.milestones.length - 1 ? "bg-accent" : "bg-primary"}`} aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="mb-2 text-sm font-bold text-primary">{milestone.year_label}</p>
<h3 className="text-lg font-bold leading-7 text-foreground">{milestone.title}</h3>
<p className="mt-2 max-w-2xl whitespace-pre-line text-sm leading-7 text-muted-foreground">{milestone.description}</p>
</div>
</li>
))}
</ol>
</div>
</section>
);
}

View File

@@ -344,7 +344,7 @@ export default function Blog({
);
return (
<div className="min-h-screen bg-[linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.32))]" dir="rtl">
<main className="min-h-screen bg-[linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.32))]" dir="rtl">
<div className="container mx-auto px-4 py-10">
<BlogBannerSlider banners={banners} />
@@ -359,6 +359,7 @@ export default function Blog({
<Input
type="text"
placeholder="جستجو در نوشته‌ها..."
aria-label="جستجو در نوشته‌ها"
value={searchDraft}
onChange={(event) => setSearchDraft(event.target.value)}
className="h-12 flex-1 rounded-2xl bg-background/80 text-right shadow-sm backdrop-blur"
@@ -451,6 +452,6 @@ export default function Blog({
)}
</div>
</div>
</div>
</main>
);
}

View File

@@ -1,7 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Helmet } from "@/lib/helmet";
import { useNavigate, useParams } from "@/lib/router";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
@@ -27,7 +26,6 @@ import {
toPersianDigits,
} from "@/lib/utils";
import { useAuth } from "@/contexts/AuthContext";
import { siteUrl } from "@/lib/site";
const typeLabel: Record<string, string> = {
online: "آنلاین",
@@ -80,34 +78,6 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
const basePrice = Number(event?.price ?? 0);
const isFree = useMemo(() => basePrice <= 0, [basePrice]);
const siteName = "انجمن علمی کامپیوتر شرق گیلان";
const defaultDescription =
"جزئیات کامل رویدادهای انجمن علمی کامپیوتر شرق گیلان شامل زمان، مکان و شرایط ثبت‌نام.";
const toAbsoluteUrl = (value?: string | null) => {
if (!value) return undefined;
if (value.startsWith("http")) return value;
const normalizedSite = siteUrl.endsWith("/") ? siteUrl.slice(0, -1) : siteUrl;
const normalizedPath = value.startsWith("/") ? value.slice(1) : value;
return `${normalizedSite}/${normalizedPath}`;
};
const sanitizeDescription = (value?: string | null) => {
if (!value) return defaultDescription;
const stripped = value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
if (!stripped) return defaultDescription;
if (stripped.length <= 160) return stripped;
return `${stripped.slice(0, 157)}...`;
};
const canonicalUrl = event ? `${siteUrl}/events/${event.slug}` : `${siteUrl}/events`;
const primaryImage = event
? toAbsoluteUrl(getEventSeoImageUrl(event)) ?? `${siteUrl}/favicon.ico`
: `${siteUrl}/favicon.ico`;
const pageTitle = event ? `${event.title} | ${siteName}` : `جزئیات رویداد | ${siteName}`;
const pageDescription = sanitizeDescription(event?.description);
const pageRobots = event?.status === "draft" ? "noindex, nofollow" : "index, follow";
useEffect(() => {
let cancelled = false;
@@ -356,138 +326,19 @@ export default function EventDetail({ initialEvent = null }: EventDetailProps) {
[event],
);
const eventStructuredData = useMemo(() => {
if (!event) return null;
const attendanceModeMap: Record<string, string> = {
online: "https://schema.org/OnlineEventAttendanceMode",
on_site: "https://schema.org/OfflineEventAttendanceMode",
hybrid: "https://schema.org/MixedEventAttendanceMode",
};
const statusMap: Record<string, string> = {
published: "https://schema.org/EventScheduled",
completed: "https://schema.org/EventCompleted",
cancelled: "https://schema.org/EventCancelled",
draft: "https://schema.org/EventPostponed",
};
const data: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "Event",
name: event.title,
description: pageDescription,
startDate: event.start_time,
url: canonicalUrl,
eventAttendanceMode: attendanceModeMap[event.event_type] ?? attendanceModeMap.hybrid,
eventStatus: statusMap[event.status] ?? statusMap.published,
organizer: {
"@type": "Organization",
name: siteName,
url: siteUrl,
},
};
if (event.end_time) {
data.endDate = event.end_time;
}
if (primaryImage) {
data.image = [primaryImage];
}
if (event.event_type === "online") {
data.location = {
"@type": "VirtualLocation",
url: event.online_link || canonicalUrl,
};
} else {
const location: Record<string, unknown> = {
"@type": "Place",
name: event.location || event.address || siteName,
};
if (event.address) {
location.address = event.address;
}
if (event.location) {
location.description = event.location;
}
data.location = location;
}
const offers: Record<string, unknown> = {
"@type": "Offer",
url: canonicalUrl,
priceCurrency: "IRR",
price: String(event.price ?? 0),
availability: meta?.full ? "https://schema.org/SoldOut" : "https://schema.org/InStock",
};
if (event.registration_start_date) {
offers.validFrom = event.registration_start_date;
}
if (event.registration_end_date) {
offers.validThrough = event.registration_end_date;
}
data.offers = offers;
return data;
}, [canonicalUrl, event, meta?.full, pageDescription, primaryImage, siteName]);
const helmet = (
<Helmet>
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
<meta name="robots" content={pageRobots} />
<link rel="canonical" href={canonicalUrl} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={pageDescription} />
<meta property="og:type" content="event" />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:site_name" content={siteName} />
<meta property="og:image" content={primaryImage} />
<meta property="og:locale" content="fa_IR" />
{event?.start_time && <meta property="event:start_time" content={event.start_time} />}
{event?.end_time && <meta property="event:end_time" content={event.end_time} />}
{event?.updated_at && <meta property="og:updated_time" content={event.updated_at} />}
<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={primaryImage} />
{eventStructuredData && (
<script type="application/ld+json">{JSON.stringify(eventStructuredData)}</script>
)}
</Helmet>
);
const withHelmet = (node: JSX.Element) => (
<>
{helmet}
{node}
</>
);
if (loading) {
return withHelmet(
<div className="min-h-[60vh] flex items-center justify-center text-muted-foreground">
در حال بارگذاری رویداد...
</div>,
);
return <div className="min-h-[60vh] flex items-center justify-center text-muted-foreground">در حال بارگذاری رویداد...</div>;
}
if (!event) {
return withHelmet(
<div className="min-h-[60vh] flex items-center justify-center">
رویداد مورد نظر یافت نشد.
</div>,
);
return <div className="min-h-[60vh] flex items-center justify-center">رویداد مورد نظر یافت نشد.</div>;
}
const beforeStart = rsTs != null && nowTs < rsTs;
const ended = deadlineTs !== null && remainingMs === 0;
const showCountdown = !beforeStart && deadlineTs !== null && (remainingMs ?? 0) > 0;
return withHelmet(
return (
<div className="container mx-auto px-4 py-8" dir="rtl">
{beforeStart && (
<div className="mb-6">

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