feat: finish CMS-driven SEO and accessibility pass
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user