feat: finish CMS-driven SEO and accessibility pass
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:8080
|
||||
CONTENT_CMS_ENABLED=false
|
||||
CONTENT_CMS_ENABLED=true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import AboutUs from "@/views/AboutUs";
|
||||
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
|
||||
import { cmsPageMetadata, organizationStructuredData, safeJsonLd } from "@/lib/seo";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
|
||||
export const revalidate = 300;
|
||||
@@ -12,23 +13,13 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
]);
|
||||
const title = content.seo.title || content.title || siteContent.official_name;
|
||||
const description = content.seo.description || content.intro;
|
||||
const image = content.seo.image?.url || siteContent.seo.image?.url || `${siteUrl}/favicon.ico`;
|
||||
|
||||
return {
|
||||
title: { absolute: title },
|
||||
return cmsPageMetadata({
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: "/about" },
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: `${siteUrl}/about`,
|
||||
siteName: siteContent.official_name,
|
||||
type: "website",
|
||||
images: [image],
|
||||
locale: "fa_IR",
|
||||
},
|
||||
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||
};
|
||||
image: content.seo.image || siteContent.seo.image || siteContent.logo,
|
||||
path: "/about",
|
||||
siteName: siteContent.official_name,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function AboutPage() {
|
||||
@@ -43,11 +34,7 @@ export default async function AboutPage() {
|
||||
description: content.seo.description || content.intro,
|
||||
url: `${siteUrl}/about`,
|
||||
mainEntity: {
|
||||
"@type": "Organization",
|
||||
name: siteContent.official_name,
|
||||
url: siteUrl,
|
||||
logo: siteContent.logo?.url || `${siteUrl}/favicon.ico`,
|
||||
sameAs: [siteContent.contacts.instagram, siteContent.contacts.telegram].filter(Boolean),
|
||||
...organizationStructuredData(siteContent),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,7 +42,7 @@ export default async function AboutPage() {
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData).replace(/</g, "\\u003c") }}
|
||||
dangerouslySetInnerHTML={{ __html: safeJsonLd(structuredData) }}
|
||||
/>
|
||||
<AboutUs content={content} siteContent={siteContent} />
|
||||
</>
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Link } from "@/lib/router";
|
||||
import { blogPostPath, blogPostUrl, normalizeBlogSlugParam } from "@/lib/blog-routes";
|
||||
import { extractMarkdownHeadings } from "@/lib/markdown-headings";
|
||||
import { PublicApiError, getPublicPost, getRecommendedPosts } from "@/lib/public-api";
|
||||
import { getCmsSiteContent } from "@/lib/content-cms";
|
||||
import { organizationStructuredData, safeJsonLd } from "@/lib/seo";
|
||||
import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { formatJalaliDate, getBlogCardImageUrl, getBlogHeroImageUrl, toPersianDigits } from "@/lib/utils";
|
||||
@@ -163,12 +165,19 @@ export async function generateMetadata({
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await loadPost(normalizeBlogSlugParam(slug));
|
||||
const siteContent = await getCmsSiteContent();
|
||||
const description = cleanText(post.excerpt || post.content).slice(0, 160);
|
||||
const metaTitle = post.seo_title || post.og_title || post.title;
|
||||
const metaDescription = post.seo_description || post.og_description || description;
|
||||
const canonical = post.canonical_url || blogPostPath(post.slug);
|
||||
const canonical = post.canonical_url
|
||||
? toAbsoluteUrl(post.canonical_url, siteUrl) || blogPostUrl(siteUrl, post.slug)
|
||||
: blogPostUrl(siteUrl, post.slug);
|
||||
const image = toAbsoluteUrl(
|
||||
post.og_image_url || getBlogHeroImageUrl(post),
|
||||
post.og_image_url ||
|
||||
getBlogHeroImageUrl(post) ||
|
||||
siteContent.seo.image?.url ||
|
||||
siteContent.logo?.url ||
|
||||
"/favicon.ico",
|
||||
apiBaseUrl,
|
||||
) ?? `${siteUrl}/favicon.ico`;
|
||||
|
||||
@@ -180,8 +189,8 @@ export async function generateMetadata({
|
||||
openGraph: {
|
||||
title: post.og_title || metaTitle,
|
||||
description: post.og_description || metaDescription,
|
||||
url: blogPostUrl(siteUrl, post.slug),
|
||||
siteName: "انجمن علمی مهندسی کامپیوتر شرق گیلان",
|
||||
url: canonical,
|
||||
siteName: siteContent.official_name,
|
||||
type: "article",
|
||||
images: [image],
|
||||
locale: "fa_IR",
|
||||
@@ -204,12 +213,22 @@ export default async function BlogDetailPage({
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const post = await loadPost(normalizeBlogSlugParam(slug));
|
||||
const recommendedPosts = await loadRecommended(post.slug);
|
||||
const [recommendedPosts, siteContent] = await Promise.all([
|
||||
loadRecommended(post.slug),
|
||||
getCmsSiteContent(),
|
||||
]);
|
||||
const headings = extractMarkdownHeadings(post.content);
|
||||
const description = cleanText(post.excerpt || post.content).slice(0, 160);
|
||||
const metaDescription = post.seo_description || post.og_description || description;
|
||||
const coverImage = toAbsoluteUrl(getBlogHeroImageUrl(post), apiBaseUrl);
|
||||
const seoImage = toAbsoluteUrl(post.og_image_url || getBlogHeroImageUrl(post), apiBaseUrl) ?? `${siteUrl}/favicon.ico`;
|
||||
const seoImage = toAbsoluteUrl(
|
||||
post.og_image_url ||
|
||||
getBlogHeroImageUrl(post) ||
|
||||
siteContent.seo.image?.url ||
|
||||
siteContent.logo?.url ||
|
||||
"/favicon.ico",
|
||||
apiBaseUrl,
|
||||
) ?? `${siteUrl}/favicon.ico`;
|
||||
const writers = post.writers?.length ? post.writers : [post.author];
|
||||
|
||||
const structuredData = {
|
||||
@@ -225,14 +244,7 @@ export default async function BlogDetailPage({
|
||||
"@type": "Person",
|
||||
name: personName(writer),
|
||||
})),
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "انجمن علمی مهندسی کامپیوتر شرق گیلان",
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: `${siteUrl}/favicon.ico`,
|
||||
},
|
||||
},
|
||||
publisher: organizationStructuredData(siteContent),
|
||||
keywords: post.tags.map((tag) => tag.name).join(", "),
|
||||
};
|
||||
|
||||
@@ -240,7 +252,7 @@ export default async function BlogDetailPage({
|
||||
<div className="min-h-screen bg-[linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.28))]" dir="rtl">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
dangerouslySetInnerHTML={{ __html: safeJsonLd(structuredData) }}
|
||||
/>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="gap-8 xl:flex xl:items-start">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import Blog from "@/views/Blog";
|
||||
import { getBlogBanners, getBlogFilters, getPublicPosts } from "@/lib/public-api";
|
||||
import { blogPostUrl } from "@/lib/blog-routes";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
|
||||
import { cmsPageMetadata, collectionPageStructuredData, safeJsonLd } from "@/lib/seo";
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
@@ -26,25 +28,23 @@ export async function generateMetadata({
|
||||
getCmsPageContent("blog"),
|
||||
getCmsSiteContent(),
|
||||
]);
|
||||
const title = search ? `جستوجوی وبلاگ: ${search}` : content.seo.title || content.title;
|
||||
const description = search ? `نتایج جستوجوی مقالات برای ${search}.` : content.seo.description || content.intro;
|
||||
const image = content.seo.image?.url || siteContent.seo.image?.url || siteContent.logo?.url || `${siteUrl}/favicon.ico`;
|
||||
const title = content.seo.title || content.title;
|
||||
const description = content.seo.description || content.intro;
|
||||
const hasFilters = Boolean(
|
||||
search ||
|
||||
firstString(resolved.category).trim() ||
|
||||
stringList(resolved.tag).length ||
|
||||
stringList(resolved.author).length,
|
||||
);
|
||||
|
||||
return {
|
||||
title: { absolute: title },
|
||||
return cmsPageMetadata({
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: search ? `/blog?search=${encodeURIComponent(search)}` : "/blog" },
|
||||
robots: search ? { index: false, follow: true } : undefined,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: search ? `${siteUrl}/blog?search=${encodeURIComponent(search)}` : `${siteUrl}/blog`,
|
||||
siteName: siteContent.official_name,
|
||||
type: "website",
|
||||
images: [image],
|
||||
locale: "fa_IR",
|
||||
},
|
||||
};
|
||||
image: content.seo.image || siteContent.seo.image || siteContent.logo,
|
||||
path: "/blog",
|
||||
siteName: siteContent.official_name,
|
||||
noIndex: hasFilters,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function BlogPage({
|
||||
@@ -57,23 +57,38 @@ export default async function BlogPage({
|
||||
const category = firstString(resolved.category).trim();
|
||||
const tags = stringList(resolved.tag);
|
||||
const authors = stringList(resolved.author);
|
||||
const [posts, banners, filters, content] = await Promise.all([
|
||||
const [posts, banners, filters, content, siteContent] = await Promise.all([
|
||||
getPublicPosts({ search: search || undefined, category: category || undefined, tag: tags, author: authors }),
|
||||
getBlogBanners().catch(() => []),
|
||||
getBlogFilters().catch(() => ({ categories: [], tags: [], authors: [] })),
|
||||
getCmsPageContent("blog"),
|
||||
getCmsSiteContent(),
|
||||
]);
|
||||
const structuredData = collectionPageStructuredData({
|
||||
title: content.seo.title || content.title,
|
||||
description: content.seo.description || content.intro,
|
||||
path: "/blog",
|
||||
siteContent,
|
||||
items: posts.map((post) => ({
|
||||
name: post.title,
|
||||
url: blogPostUrl(siteUrl, post.slug),
|
||||
description: post.excerpt || undefined,
|
||||
})),
|
||||
});
|
||||
|
||||
return (
|
||||
<Blog
|
||||
content={content}
|
||||
initialPosts={posts}
|
||||
initialSearch={search}
|
||||
initialCategory={category}
|
||||
initialTags={tags}
|
||||
initialAuthors={authors}
|
||||
banners={banners}
|
||||
filters={filters}
|
||||
/>
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: safeJsonLd(structuredData) }} />
|
||||
<Blog
|
||||
content={content}
|
||||
initialPosts={posts}
|
||||
initialSearch={search}
|
||||
initialCategory={category}
|
||||
initialTags={tags}
|
||||
initialAuthors={authors}
|
||||
banners={banners}
|
||||
filters={filters}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import EventDetail from "@/views/EventDetail";
|
||||
import { PublicApiError, getPublicEventBySlug } from "@/lib/public-api";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
|
||||
import { organizationStructuredData, safeJsonLd } from "@/lib/seo";
|
||||
import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
import { getEventSeoImageUrl } from "@/lib/utils";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
@@ -31,7 +33,19 @@ export async function generateMetadata({
|
||||
const { slug } = await params;
|
||||
const event = await loadEvent(slug);
|
||||
const description = cleanText(event.description).slice(0, 160);
|
||||
const image = getEventSeoImageUrl(event) || `${siteUrl}/favicon.ico`;
|
||||
const [siteContent, eventPageContent] = await Promise.all([
|
||||
getCmsSiteContent(),
|
||||
getCmsPageContent("events"),
|
||||
]);
|
||||
const image =
|
||||
toAbsoluteUrl(
|
||||
getEventSeoImageUrl(event) ||
|
||||
eventPageContent.seo.image?.url ||
|
||||
siteContent.seo.image?.url ||
|
||||
siteContent.logo?.url ||
|
||||
"/favicon.ico",
|
||||
apiBaseUrl,
|
||||
) ?? `${siteUrl}/favicon.ico`;
|
||||
|
||||
return {
|
||||
title: event.title,
|
||||
@@ -42,7 +56,7 @@ export async function generateMetadata({
|
||||
title: event.title,
|
||||
description,
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
siteName: "انجمن علمی کامپیوتر شرق گیلان",
|
||||
siteName: siteContent.official_name,
|
||||
type: "website",
|
||||
images: [image],
|
||||
locale: "fa_IR",
|
||||
@@ -62,8 +76,21 @@ export default async function EventDetailPage({
|
||||
params: Params;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const event = await loadEvent(slug);
|
||||
const [event, siteContent, eventPageContent] = await Promise.all([
|
||||
loadEvent(slug),
|
||||
getCmsSiteContent(),
|
||||
getCmsPageContent("events"),
|
||||
]);
|
||||
const description = cleanText(event.description).slice(0, 160);
|
||||
const image =
|
||||
toAbsoluteUrl(
|
||||
getEventSeoImageUrl(event) ||
|
||||
eventPageContent.seo.image?.url ||
|
||||
siteContent.seo.image?.url ||
|
||||
siteContent.logo?.url ||
|
||||
"/favicon.ico",
|
||||
apiBaseUrl,
|
||||
) ?? `${siteUrl}/favicon.ico`;
|
||||
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
@@ -72,32 +99,37 @@ export default async function EventDetailPage({
|
||||
description,
|
||||
startDate: event.start_time,
|
||||
endDate: event.end_time,
|
||||
eventStatus:
|
||||
event.status === "completed"
|
||||
? "https://schema.org/EventCompleted"
|
||||
: event.status === "cancelled"
|
||||
? "https://schema.org/EventCancelled"
|
||||
: "https://schema.org/EventScheduled",
|
||||
eventStatus: {
|
||||
published: "https://schema.org/EventScheduled",
|
||||
completed: "https://schema.org/EventCompleted",
|
||||
cancelled: "https://schema.org/EventCancelled",
|
||||
draft: "https://schema.org/EventPostponed",
|
||||
}[event.status] ?? "https://schema.org/EventScheduled",
|
||||
eventAttendanceMode:
|
||||
event.event_type === "online"
|
||||
? "https://schema.org/OnlineEventAttendanceMode"
|
||||
: event.event_type === "on_site"
|
||||
? "https://schema.org/OfflineEventAttendanceMode"
|
||||
: "https://schema.org/MixedEventAttendanceMode",
|
||||
image: [getEventSeoImageUrl(event) || `${siteUrl}/favicon.ico`],
|
||||
image: [image],
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
organizer: {
|
||||
"@type": "Organization",
|
||||
name: "انجمن علمی کامپیوتر شرق گیلان",
|
||||
url: siteUrl,
|
||||
},
|
||||
organizer: organizationStructuredData(siteContent),
|
||||
location: event.event_type === "online"
|
||||
? { "@type": "VirtualLocation", url: event.online_link || `${siteUrl}/events/${event.slug}` }
|
||||
: {
|
||||
"@type": "Place",
|
||||
name: event.location || event.address || siteContent.official_name,
|
||||
...(event.address ? { address: event.address } : {}),
|
||||
},
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
url: `${siteUrl}/events/${event.slug}`,
|
||||
priceCurrency: "IRR",
|
||||
price: String(event.price ?? 0),
|
||||
...(event.registration_start_date ? { validFrom: event.registration_start_date } : {}),
|
||||
...(event.registration_end_date ? { validThrough: event.registration_end_date } : {}),
|
||||
availability:
|
||||
(event.capacity ?? 0) > (event.registration_count ?? 0)
|
||||
event.capacity == null || event.capacity > (event.registration_count ?? 0)
|
||||
? "https://schema.org/InStock"
|
||||
: "https://schema.org/SoldOut",
|
||||
},
|
||||
@@ -107,7 +139,7 @@ export default async function EventDetailPage({
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
dangerouslySetInnerHTML={{ __html: safeJsonLd(structuredData) }}
|
||||
/>
|
||||
<EventDetail initialEvent={event} />
|
||||
</>
|
||||
|
||||
@@ -3,6 +3,7 @@ import Events from "@/views/Events";
|
||||
import { getPublicEvents } from "@/lib/public-api";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
|
||||
import { cmsPageMetadata, collectionPageStructuredData, safeJsonLd } from "@/lib/seo";
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
@@ -23,23 +24,14 @@ export async function generateMetadata({
|
||||
]);
|
||||
const title = search ? `جستوجوی رویدادها: ${search}` : content.seo.title || content.title;
|
||||
const description = search ? `نتایج جستوجوی رویدادها برای ${search}.` : content.seo.description || content.intro;
|
||||
const image = content.seo.image?.url || siteContent.seo.image?.url || siteContent.logo?.url || `${siteUrl}/favicon.ico`;
|
||||
|
||||
return {
|
||||
title: { absolute: title },
|
||||
return cmsPageMetadata({
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: search ? `/events?search=${encodeURIComponent(search)}` : "/events" },
|
||||
robots: search ? { index: false, follow: true } : undefined,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: search ? `${siteUrl}/events?search=${encodeURIComponent(search)}` : `${siteUrl}/events`,
|
||||
siteName: siteContent.official_name,
|
||||
type: "website",
|
||||
images: [image],
|
||||
locale: "fa_IR",
|
||||
},
|
||||
};
|
||||
image: content.seo.image || siteContent.seo.image || siteContent.logo,
|
||||
path: "/events",
|
||||
siteName: siteContent.official_name,
|
||||
noIndex: Boolean(search),
|
||||
});
|
||||
}
|
||||
|
||||
export default async function EventsPage({
|
||||
@@ -54,6 +46,22 @@ export default async function EventsPage({
|
||||
getCmsPageContent("events"),
|
||||
getCmsSiteContent(),
|
||||
]);
|
||||
const structuredData = collectionPageStructuredData({
|
||||
title: content.seo.title || content.title,
|
||||
description: content.seo.description || content.intro,
|
||||
path: "/events",
|
||||
siteContent,
|
||||
items: events.filter((event) => event.slug).map((event) => ({
|
||||
name: event.title,
|
||||
url: new URL(`/events/${encodeURIComponent(event.slug)}`, siteUrl).toString(),
|
||||
description: event.description,
|
||||
})),
|
||||
});
|
||||
|
||||
return <Events content={content} siteName={siteContent.official_name} initialEvents={events} initialSearch={search} />;
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: safeJsonLd(structuredData) }} />
|
||||
<Events content={content} initialEvents={events} initialSearch={search} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,12 +51,20 @@ export default async function RootLayout({
|
||||
<html lang="fa" dir="rtl" suppressHydrationWarning>
|
||||
<body className={`${vazirmatn.variable} font-sans antialiased`}>
|
||||
<Providers>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:right-4 focus:top-4 focus:z-[100] focus:rounded-lg focus:bg-background focus:px-4 focus:py-3 focus:font-semibold focus:text-foreground focus:shadow-lg focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
رفتن به محتوای اصلی
|
||||
</a>
|
||||
<Suspense fallback={null}>
|
||||
<RouteProgress />
|
||||
</Suspense>
|
||||
<Navbar siteContent={siteContent} />
|
||||
<div className="min-h-screen pb-28 md:pb-0">
|
||||
{children}
|
||||
<div id="main-content" tabIndex={-1} className="outline-none">
|
||||
{children}
|
||||
</div>
|
||||
<Footer siteContent={siteContent} />
|
||||
</div>
|
||||
<MobileBottomNav siteContent={siteContent} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import Home from "@/views/Home";
|
||||
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
|
||||
import { getPublicEvents, getPublicPosts } from "@/lib/public-api";
|
||||
import { selectHomeEvents, selectHomePosts } from "@/lib/home-content";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
import { cmsPageMetadata, organizationStructuredData, safeJsonLd } from "@/lib/seo";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
@@ -14,23 +14,13 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
]);
|
||||
const title = content.seo.title || content.hero_heading || siteContent.official_name;
|
||||
const description = content.seo.description || content.intro;
|
||||
const image = content.seo.image?.url || siteContent.seo.image?.url || `${siteUrl}/favicon.ico`;
|
||||
|
||||
return {
|
||||
title: { absolute: title },
|
||||
return cmsPageMetadata({
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: "/" },
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: siteUrl,
|
||||
siteName: siteContent.official_name,
|
||||
type: "website",
|
||||
images: [image],
|
||||
locale: "fa_IR",
|
||||
},
|
||||
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||
};
|
||||
image: content.seo.image || siteContent.seo.image || siteContent.logo,
|
||||
path: "/",
|
||||
siteName: siteContent.official_name,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
@@ -46,27 +36,15 @@ export default async function HomePage() {
|
||||
const description = content.seo.description || content.intro;
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: siteContent.official_name,
|
||||
url: siteUrl,
|
||||
sameAs: [siteContent.contacts.instagram, siteContent.contacts.telegram].filter(Boolean),
|
||||
...organizationStructuredData(siteContent),
|
||||
description,
|
||||
logo: siteContent.logo?.url || `${siteUrl}/favicon.ico`,
|
||||
contactPoint: siteContent.contacts.email
|
||||
? {
|
||||
"@type": "ContactPoint",
|
||||
email: siteContent.contacts.email,
|
||||
contactType: "customer support",
|
||||
availableLanguage: ["fa", "en"],
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData).replace(/</g, "\\u003c") }}
|
||||
dangerouslySetInnerHTML={{ __html: safeJsonLd(structuredData) }}
|
||||
/>
|
||||
<Home
|
||||
content={content}
|
||||
|
||||
110
src/lib/seo.ts
Normal file
110
src/lib/seo.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ContentImage, SiteContent } from "@/lib/content-cms";
|
||||
import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
|
||||
type CmsPageMetadataOptions = {
|
||||
title: string;
|
||||
description: string;
|
||||
image: ContentImage | null;
|
||||
path: string;
|
||||
siteName: string;
|
||||
noIndex?: boolean;
|
||||
};
|
||||
|
||||
type CollectionItem = {
|
||||
name: string;
|
||||
url: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export function cmsPageMetadata({
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
path,
|
||||
siteName,
|
||||
noIndex = false,
|
||||
}: CmsPageMetadataOptions): Metadata {
|
||||
const canonicalUrl = new URL(path, siteUrl).toString();
|
||||
const imageUrl =
|
||||
toAbsoluteUrl(image?.url, apiBaseUrl) ?? toAbsoluteUrl("/favicon.ico", siteUrl)!;
|
||||
|
||||
return {
|
||||
title: { absolute: title },
|
||||
description,
|
||||
alternates: { canonical: path },
|
||||
robots: noIndex ? { index: false, follow: true } : undefined,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: canonicalUrl,
|
||||
siteName,
|
||||
type: "website",
|
||||
images: [imageUrl],
|
||||
locale: "fa_IR",
|
||||
},
|
||||
twitter: { card: "summary_large_image", title, description, images: [imageUrl] },
|
||||
};
|
||||
}
|
||||
|
||||
export function organizationStructuredData(siteContent: SiteContent) {
|
||||
const logo =
|
||||
toAbsoluteUrl(siteContent.logo?.url, apiBaseUrl) ??
|
||||
toAbsoluteUrl("/favicon.ico", siteUrl)!;
|
||||
|
||||
return {
|
||||
"@type": "Organization",
|
||||
name: siteContent.official_name,
|
||||
url: siteUrl,
|
||||
logo,
|
||||
sameAs: [siteContent.contacts.instagram, siteContent.contacts.telegram].filter(Boolean),
|
||||
...(siteContent.contacts.email
|
||||
? {
|
||||
contactPoint: {
|
||||
"@type": "ContactPoint",
|
||||
email: siteContent.contacts.email,
|
||||
contactType: "customer support",
|
||||
availableLanguage: ["fa", "en"],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function collectionPageStructuredData({
|
||||
title,
|
||||
description,
|
||||
path,
|
||||
siteContent,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
path: string;
|
||||
siteContent: SiteContent;
|
||||
items: CollectionItem[];
|
||||
}) {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CollectionPage",
|
||||
name: title,
|
||||
description,
|
||||
url: new URL(path, siteUrl).toString(),
|
||||
publisher: organizationStructuredData(siteContent),
|
||||
mainEntity: {
|
||||
"@type": "ItemList",
|
||||
numberOfItems: items.length,
|
||||
itemListElement: items.map((item, index) => ({
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
url: item.url,
|
||||
...(item.description ? { description: item.description } : {}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function safeJsonLd(data: unknown) {
|
||||
return JSON.stringify(data).replace(/</g, "\\u003c");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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