feat: render CMS-driven public redesign

This commit is contained in:
2026-09-28 20:29:03 +03:30
parent 1979eaf19a
commit 64c0105134
22 changed files with 1448 additions and 786 deletions

View File

@@ -1,65 +1,63 @@
import type { Metadata } from "next";
import AboutUs from "@/views/AboutUs";
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
import { siteUrl } from "@/lib/site";
const title = "درباره ما | انجمن علمی کامپیوتر شرق گیلان";
const description =
"آشنایی با تاریخچه، مأموریت‌ها و دستاوردهای انجمن علمی کامپیوتر شرق گیلان و راه‌های مشارکت دانشجویان در برنامه‌های انجمن.";
export const revalidate = 300;
export const metadata: Metadata = {
title,
description,
keywords: [
"انجمن علمی کامپیوتر شرق گیلان",
"دانشگاه گیلان",
"انجمن علمی دانشجویی",
"رویدادهای فناوری",
],
alternates: { canonical: "/about" },
openGraph: {
title,
export async function generateMetadata(): Promise<Metadata> {
const [content, siteContent] = await Promise.all([
getCmsPageContent("about"),
getCmsSiteContent(),
]);
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 },
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] },
};
}
export default async function AboutPage() {
const [content, siteContent] = await Promise.all([
getCmsPageContent("about"),
getCmsSiteContent(),
]);
const structuredData = {
"@context": "https://schema.org",
"@type": "AboutPage",
name: content.seo.title || content.title,
description: content.seo.description || content.intro,
url: `${siteUrl}/about`,
siteName: "انجمن علمی کامپیوتر شرق گیلان",
type: "website",
images: [`${siteUrl}/favicon.ico`],
locale: "fa_IR",
},
twitter: {
card: "summary_large_image",
title,
description,
images: [`${siteUrl}/favicon.ico`],
},
};
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),
},
};
const structuredData = {
"@context": "https://schema.org",
"@type": "AboutPage",
name: title,
description,
url: `${siteUrl}/about`,
mainEntity: {
"@type": "Organization",
name: "انجمن علمی کامپیوتر شرق گیلان",
url: siteUrl,
logo: `${siteUrl}/favicon.ico`,
sameAs: [
"https://instagram.com/guilance.ir",
"https://t.me/guilance",
"https://t.me/guilancea",
],
},
};
export default function AboutPage() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData).replace(/</g, "\\u003c") }}
/>
<AboutUs />
<AboutUs content={content} siteContent={siteContent} />
</>
);
}

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Blog from "@/views/Blog";
import { getBlogBanners, getBlogFilters, getPublicPosts } from "@/lib/public-api";
import { siteUrl } from "@/lib/site";
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
@@ -21,13 +22,16 @@ export async function generateMetadata({
}): Promise<Metadata> {
const resolved = await searchParams;
const search = firstString(resolved.search).trim();
const title = search ? `جست‌وجوی وبلاگ: ${search}` : "وبلاگ";
const description = search
? `نتایج جست‌وجوی مقالات برای ${search} در وبلاگ انجمن علمی کامپیوتر شرق گیلان.`
: "مقالات، نوشته‌ها و محتوای آموزشی انجمن علمی کامپیوتر شرق گیلان.";
const [content, siteContent] = await Promise.all([
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`;
return {
title,
title: { absolute: title },
description,
alternates: { canonical: search ? `/blog?search=${encodeURIComponent(search)}` : "/blog" },
robots: search ? { index: false, follow: true } : undefined,
@@ -35,9 +39,9 @@ export async function generateMetadata({
title,
description,
url: search ? `${siteUrl}/blog?search=${encodeURIComponent(search)}` : `${siteUrl}/blog`,
siteName: "انجمن علمی کامپیوتر شرق گیلان",
siteName: siteContent.official_name,
type: "website",
images: [`${siteUrl}/favicon.ico`],
images: [image],
locale: "fa_IR",
},
};
@@ -53,14 +57,16 @@ 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] = await Promise.all([
const [posts, banners, filters, content] = await Promise.all([
getPublicPosts({ search: search || undefined, category: category || undefined, tag: tags, author: authors }),
getBlogBanners().catch(() => []),
getBlogFilters().catch(() => ({ categories: [], tags: [], authors: [] })),
getCmsPageContent("blog"),
]);
return (
<Blog
content={content}
initialPosts={posts}
initialSearch={search}
initialCategory={category}

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Events from "@/views/Events";
import { getPublicEvents } from "@/lib/public-api";
import { siteUrl } from "@/lib/site";
import { getCmsPageContent, getCmsSiteContent } from "@/lib/content-cms";
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
@@ -16,13 +17,16 @@ export async function generateMetadata({
}): Promise<Metadata> {
const resolved = await searchParams;
const search = firstString(resolved.search).trim();
const title = search ? `جست‌وجوی رویدادها: ${search}` : "رویدادها";
const description = search
? `نتایج جست‌وجوی رویدادها برای ${search} در انجمن علمی کامپیوتر شرق گیلان.`
: "رویدادهای جاری و گذشته انجمن علمی کامپیوتر شرق گیلان، شامل کارگاه‌ها، مسابقات و برنامه‌های آموزشی.";
const [content, siteContent] = await Promise.all([
getCmsPageContent("events"),
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`;
return {
title,
title: { absolute: title },
description,
alternates: { canonical: search ? `/events?search=${encodeURIComponent(search)}` : "/events" },
robots: search ? { index: false, follow: true } : undefined,
@@ -30,9 +34,9 @@ export async function generateMetadata({
title,
description,
url: search ? `${siteUrl}/events?search=${encodeURIComponent(search)}` : `${siteUrl}/events`,
siteName: "انجمن علمی کامپیوتر شرق گیلان",
siteName: siteContent.official_name,
type: "website",
images: [`${siteUrl}/favicon.ico`],
images: [image],
locale: "fa_IR",
},
};
@@ -45,7 +49,11 @@ export default async function EventsPage({
}) {
const resolved = await searchParams;
const search = firstString(resolved.search).trim();
const events = await getPublicEvents({ search: search || undefined });
const [events, content, siteContent] = await Promise.all([
getPublicEvents({ search: search || undefined }),
getCmsPageContent("events"),
getCmsSiteContent(),
]);
return <Events initialEvents={events} initialSearch={search} />;
return <Events content={content} siteName={siteContent.official_name} initialEvents={events} initialSearch={search} />;
}

View File

@@ -6,6 +6,7 @@ import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
import Providers from "@/components/providers";
import RouteProgress from "@/components/RouteProgress";
import { getCmsSiteContent } from "@/lib/content-cms";
import { siteUrl } from "@/lib/site";
import "../index.css";
@@ -17,21 +18,35 @@ const vazirmatn = localFont({
fallback: ["Tahoma", "Arial", "sans-serif"],
});
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
default: "انجمن علمی کامپیوتر شرق گیلان",
template: "%s | انجمن علمی کامپیوتر شرق گیلان",
},
description:
"انجمن علمی کامپیوتر شرق گیلان، برگزارکننده رویدادها، کارگاه‌ها و محتوای آموزشی برای دانشجویان و علاقه‌مندان فناوری.",
};
export async function generateMetadata(): Promise<Metadata> {
const siteContent = await getCmsSiteContent();
const title = siteContent.seo.title || siteContent.official_name;
const description = siteContent.seo.description || siteContent.description;
const image = siteContent.seo.image?.url || siteContent.logo?.url || `${siteUrl}/favicon.ico`;
export default function RootLayout({
return {
metadataBase: new URL(siteUrl),
title: {
default: title,
template: `%s | ${siteContent.short_name || siteContent.official_name}`,
},
description,
openGraph: {
siteName: siteContent.official_name,
images: [image],
locale: "fa_IR",
type: "website",
},
};
}
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const siteContent = await getCmsSiteContent();
return (
<html lang="fa" dir="rtl" suppressHydrationWarning>
<body className={`${vazirmatn.variable} font-sans antialiased`}>
@@ -39,12 +54,12 @@ export default function RootLayout({
<Suspense fallback={null}>
<RouteProgress />
</Suspense>
<Navbar />
<Navbar siteContent={siteContent} />
<div className="min-h-screen pb-28 md:pb-0">
{children}
<Footer />
<Footer siteContent={siteContent} />
</div>
<MobileBottomNav />
<MobileBottomNav siteContent={siteContent} />
</Providers>
</body>
</html>

View File

@@ -1,56 +1,80 @@
import type { Metadata } from "next";
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";
const title = "انجمن علمی کامپیوتر دانشگاه گیلان";
const description =
"با ما همراه شوید و در دنیای مهندسی و علوم کامپیوتر پیشرفت کنید. رویدادها، محتوای آموزشی و جامعه‌ای پویا برای رشد شما فراهم است.";
export const revalidate = 300;
export const metadata: Metadata = {
title,
description,
alternates: { canonical: "/" },
openGraph: {
title,
export async function generateMetadata(): Promise<Metadata> {
const [siteContent, content] = await Promise.all([
getCmsSiteContent(),
getCmsPageContent("home"),
]);
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 },
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] },
};
}
export default async function HomePage() {
const [siteContent, content, eventResult, postResult] = await Promise.all([
getCmsSiteContent(),
getCmsPageContent("home"),
getPublicEvents({ limit: 30 }).catch(() => []),
getPublicPosts({ limit: 30 }).catch(() => []),
]);
const selectedEvents = selectHomeEvents(eventResult);
const posts = selectHomePosts(postResult);
const title = content.seo.title || content.hero_heading || siteContent.official_name;
const description = content.seo.description || content.intro;
const structuredData = {
"@context": "https://schema.org",
"@type": "Organization",
name: siteContent.official_name,
url: siteUrl,
siteName: "انجمن علمی کامپیوتر شرق گیلان",
type: "website",
images: [`${siteUrl}/favicon.ico`],
locale: "fa_IR",
},
twitter: {
card: "summary_large_image",
title,
sameAs: [siteContent.contacts.instagram, siteContent.contacts.telegram].filter(Boolean),
description,
images: [`${siteUrl}/favicon.ico`],
},
};
logo: siteContent.logo?.url || `${siteUrl}/favicon.ico`,
contactPoint: siteContent.contacts.email
? {
"@type": "ContactPoint",
email: siteContent.contacts.email,
contactType: "customer support",
availableLanguage: ["fa", "en"],
}
: undefined,
};
const structuredData = {
"@context": "https://schema.org",
"@type": "Organization",
name: title,
url: siteUrl,
sameAs: [`${siteUrl}/blog`, `${siteUrl}/events`],
description,
logo: `${siteUrl}/favicon.ico`,
contactPoint: {
"@type": "ContactPoint",
email: "admin@east-guilan-ce.ir",
contactType: "customer support",
availableLanguage: ["fa", "en"],
},
};
export default function HomePage() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData).replace(/</g, "\\u003c") }}
/>
<Home
content={content}
siteContent={siteContent}
events={selectedEvents.events}
posts={posts}
isEventArchive={selectedEvents.isArchive}
/>
<Home />
</>
);
}

View File

@@ -0,0 +1,39 @@
import type { AnchorHTMLAttributes, ReactNode } from "react";
import { Link } from "@/lib/router";
type ContentActionLinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
href: string;
fallbackHref: string;
children: ReactNode;
};
function isSafeInternalPath(value: string) {
const hasControlCharacter = Array.from(value).some((character) => character.charCodeAt(0) < 0x20);
return value.startsWith("/") && !value.startsWith("//") && !value.includes("\\") && !hasControlCharacter;
}
function safeHref(value: string, fallbackHref: string) {
const trimmed = value.trim();
if (isSafeInternalPath(trimmed)) return trimmed;
try {
const parsed = new URL(trimmed);
if (parsed.protocol === "https:" && parsed.hostname && !parsed.username && !parsed.password) {
return parsed.toString();
}
} catch {
// An invalid CMS link falls back to the known local destination.
}
return isSafeInternalPath(fallbackHref) ? fallbackHref : "/";
}
export default function ContentActionLink({ href, fallbackHref, children, ...props }: ContentActionLinkProps) {
const destination = safeHref(href, fallbackHref);
const className = props.className;
const shared = { ...props, className };
if (destination.startsWith("https://")) {
return <a href={destination} target="_blank" rel="noopener noreferrer" {...shared}>{children}</a>;
}
return <Link to={destination} {...shared}>{children}</Link>;
}

View File

@@ -1,129 +1,126 @@
"use client";
import { Instagram, Mail, MapPin, Phone, Send, Globe2 } from "lucide-react";
import Image from "next/image";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { Instagram, Linkedin, Send, Twitter } from "lucide-react";
import type { SiteContent } from "@/lib/content-cms";
export default function Footer() {
function hasRealContact(value: string) {
return Boolean(value.trim()) && !/\.invalid(?:\/|$)/i.test(value) && !value.includes("0000000000");
}
export default function Footer({ siteContent }: { siteContent: SiteContent }) {
const year = new Date().getFullYear();
const contacts = [
hasRealContact(siteContent.contacts.email)
? { label: "ایمیل", value: siteContent.contacts.email, href: `mailto:${siteContent.contacts.email}`, icon: Mail, dir: "ltr" as const }
: null,
hasRealContact(siteContent.contacts.phone)
? { label: "تلفن", value: siteContent.contacts.phone, href: `tel:${siteContent.contacts.phone}`, icon: Phone, dir: "ltr" as const }
: null,
hasRealContact(siteContent.contacts.telegram)
? { label: "تلگرام", value: "تلگرام انجمن", href: siteContent.contacts.telegram, icon: Send, dir: undefined }
: null,
hasRealContact(siteContent.contacts.instagram)
? { label: "اینستاگرام", value: "اینستاگرام انجمن", href: siteContent.contacts.instagram, icon: Instagram, dir: undefined }
: null,
hasRealContact(siteContent.contacts.website)
? { label: "وب‌سایت", value: siteContent.contacts.website, href: siteContent.contacts.website, icon: Globe2, dir: "ltr" as const }
: null,
].filter((contact) => contact !== null);
return (
<footer
className="border-t bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/40"
dir="rtl"
>
<div className="container mx-auto px-4 py-10">
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
<footer className="bg-footer text-footer-foreground" dir="rtl">
<div className="container mx-auto px-4 py-10 md:py-12">
<div className="grid gap-9 sm:grid-cols-2 lg:grid-cols-[1.5fr_0.8fr_1fr]">
<div className="space-y-4">
<div className="flex items-center gap-2">
<img src="/favicon.ico" alt="لوگوی انجمن" className="h-9 w-9 rounded" />
<span className="text-xl font-bold">انجمن علمی کامپیوتر گیلان</span>
</div>
<p className="text-sm text-muted-foreground leading-7">
ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکه‌ی دانشجویی و پیوند با صنعت.
</p>
<Link to="/" className="inline-flex items-center gap-3 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-white/10 p-1.5">
<Image
src={siteContent.logo?.url || "/favicon.ico"}
alt={siteContent.logo?.alt || siteContent.short_name || "لوگوی انجمن"}
width={44}
height={44}
unoptimized
className="h-full w-full object-contain"
/>
</span>
<span className="text-base font-bold leading-7">{siteContent.official_name}</span>
</Link>
{siteContent.description ? (
<p className="max-w-xl text-sm leading-7 text-footer-muted">
{siteContent.description}
</p>
) : null}
</div>
<div>
<h4 className="mb-3 text-base font-semibold">لینک‌های مفید</h4>
<ul className="space-y-2 text-sm">
<li>
<Link to="/" className="text-muted-foreground hover:text-foreground">
خانه
</Link>
</li>
<li>
<Link to="/events" className="text-muted-foreground hover:text-foreground">
رویدادها
</Link>
</li>
<li>
<Link to="/blog" className="text-muted-foreground hover:text-foreground">
بلاگ
</Link>
</li>
<li>
<Link to="/about" className="text-muted-foreground hover:text-foreground">
درباره‌ی انجمن
</Link>
</li>
<h2 className="mb-4 text-sm font-bold">دسترسی سریع</h2>
<ul className="space-y-3 text-sm text-footer-muted">
<li><Link className="transition-colors hover:text-footer-foreground" to="/">{siteContent.navigation.home}</Link></li>
<li><Link className="transition-colors hover:text-footer-foreground" to="/about">{siteContent.navigation.about}</Link></li>
<li><Link className="transition-colors hover:text-footer-foreground" to="/events">{siteContent.navigation.events}</Link></li>
<li><Link className="transition-colors hover:text-footer-foreground" to="/blog">{siteContent.navigation.blog}</Link></li>
</ul>
</div>
<div>
<h4 className="mb-3 text-base font-semibold">ارتباط با ما</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>ایمیل: info@east-guilan-ce.ir</li>
<li>آدرس: دانشگاه گیلان، دانشکده‌ی فنی و مهندسی شرق گیلان</li>
</ul>
<div className="mt-4 flex items-center gap-2">
<a
href="https://Instagram.com/guilance.ir"
target="_blank"
rel="noreferrer"
className="inline-flex"
>
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="اینستاگرام">
<Instagram className="h-4 w-4" />
</Button>
</a>
<a
href="https://t.me/guilance"
target="_blank"
rel="noreferrer"
className="inline-flex"
>
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="تلگرام">
<Send className="h-4 w-4" />
</Button>
</a>
<a
href="https://www.linkedin.com/in/amiirkhl/"
target="_blank"
rel="noreferrer"
className="inline-flex"
>
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="لینکدین">
<Linkedin className="h-4 w-4" />
</Button>
</a>
<a href="https://x.com" target="_blank" rel="noreferrer" className="inline-flex">
<Button
variant="outline"
size="icon"
className="h-9 w-9"
aria-label="ایکس (توییتر)"
>
<Twitter className="h-4 w-4" />
</Button>
</a>
</div>
</div>
<div className="justify-self-end">
<a
href="https://trustseal.enamad.ir/?id=649977&Code=m0wWM1DFYqd4fLEnjyMU3o2pupfuqDVW"
target="_blank"
rel="noreferrer"
referrerPolicy="origin"
>
<img
src="/enamad.png"
width="125"
alt="نماد اعتماد الکترونیکی"
referrerPolicy="origin"
style={{ cursor: "pointer" }}
data-code="m0wWM1DFYqd4fLEnjyMU3o2pupfuqDVW"
/>
</a>
<h2 className="mb-4 text-sm font-bold">ارتباط با انجمن</h2>
{siteContent.address ? (
<p className="mb-3 flex items-start gap-2 text-sm leading-6 text-footer-muted">
<MapPin aria-hidden="true" className="mt-1 h-4 w-4 shrink-0" />
<span>{siteContent.address}</span>
</p>
) : null}
{contacts.length ? (
<ul className="space-y-3 text-sm text-footer-muted">
{contacts.map(({ label, value, href, icon: Icon, dir }) => (
<li key={label}>
<a
href={href}
target={href.startsWith("http") ? "_blank" : undefined}
rel={href.startsWith("http") ? "noreferrer" : undefined}
className="inline-flex min-h-11 items-center gap-2 transition-colors hover:text-footer-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<Icon aria-hidden="true" className="h-4 w-4 shrink-0" />
<span dir={dir}>{value}</span>
</a>
</li>
))}
</ul>
) : null}
</div>
</div>
<div className="my-8 h-px w-full bg-border" />
<div className="flex gap-2 items-center justify-center text-sm text-muted-foreground md:flex-row">
<div>© {year} انجمن علمی کامپیوتر گیلان - تمامی حقوق محفوظ است.</div>
<div className="my-8 h-px bg-footer-foreground/15" />
{siteContent.enamad_image ? (
<div className="mb-7 flex justify-start">
<div className="rounded-xl bg-white p-2 shadow-sm">
{siteContent.enamad_url ? (
<a href={siteContent.enamad_url} target="_blank" rel="noopener noreferrer" aria-label="مشاهده گواهی اعتماد الکترونیکی">
<Image
src={siteContent.enamad_image.url}
alt={siteContent.enamad_image.alt || "نشان اعتماد الکترونیکی"}
width={96}
height={96}
unoptimized
className="h-24 w-24 object-contain"
/>
</a>
) : (
<Image
src={siteContent.enamad_image.url}
alt={siteContent.enamad_image.alt || "نشان اعتماد الکترونیکی"}
width={96}
height={96}
unoptimized
className="h-24 w-24 object-contain"
/>
)}
</div>
</div>
) : null}
<div className="flex flex-col gap-2 text-xs text-footer-muted sm:flex-row sm:items-center sm:justify-between">
<span>© {year} {siteContent.short_name || siteContent.official_name}</span>
{siteContent.footer_copy ? <span>{siteContent.footer_copy}</span> : null}
</div>
</div>
</footer>

View File

@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
import type { SiteContent } from "@/lib/content-cms";
type NavItem = {
key: string;
@@ -22,7 +23,7 @@ const isProfileOrAuthPath = (pathname: string) =>
pathname.startsWith("/auth/") ||
pathname.startsWith("/reset-password");
export default function MobileBottomNav() {
export default function MobileBottomNav({ siteContent }: { siteContent: SiteContent }) {
const pathname = usePathname() || "/";
const { isAuthenticated } = useAuth();
@@ -33,21 +34,21 @@ export default function MobileBottomNav() {
const items: NavItem[] = [
{
key: "home",
label: "خانه",
label: siteContent.navigation.home,
href: "/",
icon: Home,
matches: (current) => current === "/",
},
{
key: "events",
label: "رویدادها",
label: siteContent.navigation.events,
href: "/events",
icon: CalendarDays,
matches: (current) => current === "/events" || current.startsWith("/events/"),
},
{
key: "blog",
label: "بلاگ",
label: siteContent.navigation.blog,
href: "/blog",
icon: Newspaper,
matches: (current) => current === "/blog" || current.startsWith("/blog/"),
@@ -67,8 +68,8 @@ export default function MobileBottomNav() {
style={{ bottom: "calc(env(safe-area-inset-bottom) + 0.9rem)" }}
>
<nav
aria-label="Mobile navigation"
className="mx-auto flex w-full max-w-sm items-center justify-between rounded-[1.75rem] border border-white/20 bg-background/70 px-2 py-2 shadow-[0_18px_60px_rgba(15,23,42,0.18)] backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/65"
aria-label="ناوبری اصلی موبایل"
className="mx-auto flex w-full max-w-md items-center justify-between rounded-2xl border border-border bg-background/95 px-1.5 py-1.5 shadow-lg backdrop-blur-xl"
dir="rtl"
>
{items.map((item) => {
@@ -80,15 +81,15 @@ export default function MobileBottomNav() {
key={item.key}
to={item.href}
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-1 rounded-2xl px-2 py-2 text-[11px] font-medium transition-all",
"flex min-h-11 min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-xl px-1 py-1.5 text-[10px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-white/30 hover:text-foreground dark:hover:bg-white/10",
? "bg-secondary text-primary"
: "text-muted-foreground hover:bg-secondary hover:text-foreground",
)}
aria-current={active ? "page" : undefined}
>
<Icon className={cn("h-5 w-5", active ? "scale-105" : "")} />
<span>{item.label}</span>
<Icon aria-hidden="true" className="h-4 w-4" />
<span className="max-w-full truncate">{item.label}</span>
</Link>
);
})}

View File

@@ -29,7 +29,7 @@ export default function ModeToggle({ className }: { className?: string }) {
variant="ghost"
size="icon"
className={cn(
"rounded-full border-0 bg-transparent shadow-none transition hover:bg-background/45 hover:shadow-sm",
"rounded-full border-0 bg-transparent text-foreground shadow-none transition hover:bg-secondary hover:text-secondary-foreground hover:shadow-sm dark:hover:bg-secondary dark:hover:text-secondary-foreground",
className,
)}
aria-label={`تغییر تم به حالت ${nextThemeLabel}`}

View File

@@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { useMemo } from "react";
import Image from "next/image";
import { LayoutDashboard, LogOut, RotateCcw, UserRound } from "lucide-react";
import { Link, NavLink } from "@/lib/router";
import { useAuth } from "@/contexts/AuthContext";
@@ -18,16 +19,17 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { SiteContent } from "@/lib/content-cms";
const NavItem = ({ to, children }: { to: string; children: ReactNode }) => (
<NavLink
to={to}
className={({ isActive }) =>
cn(
"rounded-full px-3 py-2 text-sm font-medium transition-colors",
"rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
? "bg-secondary text-primary"
: "text-muted-foreground hover:bg-secondary hover:text-foreground",
)
}
>
@@ -115,34 +117,42 @@ function ProfileAvatarMenu() {
);
}
export default function Navbar() {
export default function Navbar({ siteContent }: { siteContent: SiteContent }) {
const { isAuthenticated } = useAuth();
return (
<nav
className="sticky top-0 z-40 border-b bg-background/75 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55"
aria-label="ناوبری اصلی"
className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85"
dir="rtl"
>
<div className="container mx-auto px-4 py-3">
<div className="flex items-center justify-between gap-4">
<Link to="/" className="flex min-w-0 items-center gap-3">
<div className="hidden rounded-2xl sm:flex">
<img src="/favicon.ico" alt="لوگوی انجمن" className="h-10 w-10 object-contain" />
<div className="container mx-auto px-4 py-2">
<div className="flex min-h-14 items-center justify-between gap-4">
<Link to="/" className="flex min-w-0 items-center gap-3 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<div className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-brand-wash p-1.5">
<Image
src={siteContent.logo?.url || "/favicon.ico"}
alt={siteContent.logo?.alt || siteContent.short_name || "لوگوی انجمن"}
width={44}
height={44}
unoptimized
className="h-full w-full object-contain"
/>
</div>
<div className="min-w-0 text-right">
<p className="truncate text-sm font-semibold text-foreground sm:text-base">
انجمن علمی مهندسی کامپیوتر
{siteContent.short_name || siteContent.official_name}
</p>
<p className="hidden text-xs text-muted-foreground sm:block">
دانشکده‌ی فنی و مهندسی شرق گیلان
{siteContent.faculty_subline}
</p>
</div>
</Link>
<div className="hidden items-center gap-2 md:flex">
<NavItem to="/">خانه</NavItem>
<NavItem to="/blog">بلاگ</NavItem>
<NavItem to="/events">رویدادها</NavItem>
<div className="hidden items-center gap-1 md:flex">
<NavItem to="/">{siteContent.navigation.home}</NavItem>
<NavItem to="/events">{siteContent.navigation.events}</NavItem>
<NavItem to="/blog">{siteContent.navigation.blog}</NavItem>
<ModeToggle />
{isAuthenticated ? <NotificationsBell /> : null}
<ProfileAvatarMenu />

View File

@@ -0,0 +1,122 @@
import Image from "next/image";
import { ArrowUpLeft, CalendarDays, MapPin } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Link } from "@/lib/router";
import ContentActionLink from "@/components/ContentActionLink";
import type { HomeContentPage } from "@/lib/content-cms";
import type { EventListItemSchema } from "@/lib/types";
import { formatJalali, getEventCardImageUrl } from "@/lib/utils";
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
type FeaturedEventsProps = {
content: HomeContentPage;
events: EventListItemSchema[];
isArchive: boolean;
};
export default function FeaturedEvents({ content, events, isArchive }: FeaturedEventsProps) {
return (
<section className="py-14 md:py-20" aria-labelledby="home-events-title">
<div className="container mx-auto px-4">
<div className="mb-7 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between md:mb-9">
<div className="max-w-2xl">
<h2 id="home-events-title" className="text-2xl font-bold leading-relaxed text-foreground md:text-3xl">
{content.featured_events_heading}
</h2>
<p className="mt-1 text-sm leading-7 text-muted-foreground md:text-base">
{content.featured_events_intro}
</p>
</div>
<ContentActionLink
href={content.featured_events_url}
fallbackHref="/events"
className="inline-flex min-h-11 w-fit items-center gap-2 rounded-lg font-semibold text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{content.featured_events_link_label}
<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
</ContentActionLink>
</div>
{events.length ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{events.map((event) => {
const candidateImage = getEventCardImageUrl(event);
const hasImage = candidateImage !== "/placeholder.svg";
const image = hasImage ? toAbsoluteUrl(candidateImage, apiBaseUrl) : undefined;
return (
<Card key={event.id} className="overflow-hidden rounded-2xl border-border/80 shadow-none transition-colors hover:border-primary/40">
<div className="relative aspect-[16/10] bg-brand-wash">
{image ? (
<Image
src={image}
alt={`پوستر ${event.title}`}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
unoptimized
className="object-contain p-2"
/>
) : (
<div className="grid h-full place-items-center text-brand-deep" aria-hidden="true">
<CalendarDays className="h-10 w-10" strokeWidth={1.5} />
</div>
)}
{isArchive ? (
<Badge variant="secondary" className="absolute right-3 top-3 border border-border/70 bg-background/95 text-foreground">
از آرشیو
</Badge>
) : null}
</div>
<CardHeader className="space-y-2 p-4 pb-2">
<CardTitle className="text-lg leading-7">
<Link to={`/events/${encodeURIComponent(event.slug)}`} className="rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{event.title}
</Link>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-1">
<p className="line-clamp-2 min-h-12 text-sm leading-6 text-muted-foreground">
{event.description}
</p>
<div className="flex flex-wrap gap-x-4 gap-y-2 border-t border-border/70 pt-3 text-xs leading-5 text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<CalendarDays aria-hidden="true" className="h-4 w-4 shrink-0 text-primary" />
<time dateTime={event.start_time}>{formatJalali(event.start_time, false)}</time>
</span>
{(event.location || event.address || event.event_type_label) ? (
<span className="inline-flex items-center gap-1.5">
<MapPin aria-hidden="true" className="h-4 w-4 shrink-0 text-primary" />
<span>{event.location || event.address || event.event_type_label}</span>
</span>
) : null}
</div>
</CardContent>
<CardFooter className="px-4 pb-4 pt-0">
<Link
to={`/events/${encodeURIComponent(event.slug)}`}
className="inline-flex min-h-10 items-center gap-2 rounded-lg px-2 text-sm font-semibold text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{content.featured_event_action_label}
<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
</Link>
</CardFooter>
</Card>
);
})}
</div>
) : (
<p role="status" className="rounded-xl border border-dashed border-border px-5 py-8 text-center text-sm text-muted-foreground">
در حال حاضر رویدادی برای نمایش نیست.
</p>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,145 @@
import Image from "next/image";
import { ArrowUpLeft, BookOpen, Newspaper } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardFooter,
} from "@/components/ui/card";
import { Link } from "@/lib/router";
import ContentActionLink from "@/components/ContentActionLink";
import { blogPostPath } from "@/lib/blog-routes";
import type { HomeContentPage, SiteContent } from "@/lib/content-cms";
import type { PostListSchema } from "@/lib/types";
import { formatJalaliDate, getBlogCardImageUrl } from "@/lib/utils";
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
type FeaturedPostsProps = {
content: HomeContentPage;
siteContent: SiteContent;
posts: PostListSchema[];
};
function PostCover({ post, brandName, compact = false }: {
post: PostListSchema;
brandName: string;
compact?: boolean;
}) {
const imageUrl = getBlogCardImageUrl(post);
const image = imageUrl ? toAbsoluteUrl(imageUrl, apiBaseUrl) : undefined;
return (
<div className={`relative overflow-hidden bg-primary text-primary-foreground ${compact ? "h-full min-h-24 w-24 shrink-0" : "aspect-[16/10] md:aspect-auto md:min-h-64"}`}>
{image ? (
<Image
src={image}
alt={post.title}
fill
sizes={compact ? "96px" : "(max-width: 768px) 100vw, 45vw"}
unoptimized
className="object-cover"
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-5 text-center" aria-hidden="true">
{compact ? <BookOpen className="h-7 w-7 text-primary-foreground" strokeWidth={1.5} /> : <Newspaper className="h-10 w-10 text-primary-foreground" strokeWidth={1.5} />}
<span className="text-xs font-semibold text-primary-foreground/85">{brandName}</span>
</div>
)}
</div>
);
}
function PostDescription({ post }: { post: PostListSchema }) {
return post.excerpt ? (
<p className="mt-2 line-clamp-2 text-sm leading-6 text-muted-foreground">{post.excerpt}</p>
) : null;
}
export default function FeaturedPosts({ content, siteContent, posts }: FeaturedPostsProps) {
const [lead, ...rest] = posts;
return (
<section className="border-y border-border/70 bg-brand-wash/55 py-14 md:py-20" aria-labelledby="home-posts-title">
<div className="container mx-auto px-4">
<div className="mb-7 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between md:mb-9">
<div className="max-w-2xl">
<h2 id="home-posts-title" className="text-2xl font-bold leading-relaxed text-foreground md:text-3xl">
{content.featured_posts_heading}
</h2>
<p className="mt-1 text-sm leading-7 text-muted-foreground md:text-base">
{content.featured_posts_intro}
</p>
</div>
<ContentActionLink
href={content.featured_posts_url}
fallbackHref="/blog"
className="inline-flex min-h-11 w-fit items-center gap-2 rounded-lg font-semibold text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{content.featured_posts_link_label}
<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
</ContentActionLink>
</div>
{lead ? (
<div className="grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
<Card className="overflow-hidden rounded-2xl border-border/80 shadow-none md:grid md:grid-cols-[0.9fr_1.1fr]">
<PostCover post={lead} brandName={siteContent.short_name} />
<div className="flex flex-col justify-center">
<CardContent className="p-5 md:p-7">
{lead.category?.name ? <Badge variant="secondary" className="mb-3">{lead.category.name}</Badge> : null}
<h3 className="text-xl font-bold leading-8 md:text-2xl">
<Link to={blogPostPath(lead.slug)} className="rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{lead.title}
</Link>
</h3>
<PostDescription post={lead} />
{lead.published_at ? (
<time className="mt-4 block text-xs text-muted-foreground" dateTime={lead.published_at}>
{formatJalaliDate(lead.published_at)}
</time>
) : null}
</CardContent>
<CardFooter className="px-5 pb-5 pt-0 md:px-7 md:pb-7">
<Link to={blogPostPath(lead.slug)} className="inline-flex min-h-11 items-center gap-2 rounded-lg font-semibold text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{content.featured_post_action_label}
<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
</Link>
</CardFooter>
</div>
</Card>
{rest.length ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
{rest.map((post) => (
<Card key={post.id} className="flex overflow-hidden rounded-2xl border-border/80 shadow-none sm:min-h-36 lg:min-h-0">
<PostCover post={post} brandName={siteContent.short_name} compact />
<CardContent className="min-w-0 p-4">
{post.category?.name ? <Badge variant="outline" className="mb-2">{post.category.name}</Badge> : null}
<h3 className="line-clamp-2 font-bold leading-7">
<Link to={blogPostPath(post.slug)} className="rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{post.title}
</Link>
</h3>
<PostDescription post={post} />
<Link
to={blogPostPath(post.slug)}
className="mt-2 inline-flex min-h-10 items-center gap-2 rounded-lg font-semibold text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{content.featured_post_action_label}
<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
</Link>
</CardContent>
</Card>
))}
</div>
) : null}
</div>
) : (
<p role="status" className="rounded-xl border border-dashed border-border px-5 py-8 text-center text-sm text-muted-foreground">
هنوز نوشته‌ای برای نمایش نیست.
</p>
)}
</div>
</section>
);
}

View File

@@ -9,32 +9,41 @@ All colors MUST be HSL.
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--foreground: 150 2% 20%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card-foreground: 150 2% 20%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--popover-foreground: 150 2% 20%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--primary: 190 47% 29%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--secondary: 170 29% 96%;
--secondary-foreground: 190 51% 23%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--muted: 170 29% 96%;
--muted-foreground: 186 13% 37%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--accent: 48 88% 78%;
--accent-foreground: 150 2% 20%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--border: 174 18% 87%;
--input: 174 18% 87%;
--ring: 190 47% 29%;
--brand-teal: 190 47% 29%;
--brand-deep: 190 51% 23%;
--brand-gold: 48 88% 78%;
--brand-wash: 170 29% 96%;
--brand-charcoal: 150 2% 20%;
--footer-background: 150 2% 20%;
--footer-foreground: 0 0% 100%;
--footer-muted: 172 13% 82%;
--radius: 0.5rem;
@@ -57,33 +66,43 @@ All colors MUST be HSL.
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
color-scheme: dark;
--background: 194 35% 12%;
--foreground: 165 20% 95%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--card: 190 33% 17%;
--card-foreground: 165 20% 95%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--popover: 190 33% 17%;
--popover-foreground: 165 20% 95%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--primary: 178 38% 73%;
--primary-foreground: 194 35% 12%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--secondary: 190 30% 22%;
--secondary-foreground: 165 20% 95%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--muted: 190 33% 17%;
--muted-foreground: 184 12% 69%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--accent: 48 88% 78%;
--accent-foreground: 150 2% 20%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--border: 190 24% 27%;
--input: 190 24% 27%;
--ring: 178 38% 73%;
--brand-teal: 178 38% 73%;
--brand-deep: 178 38% 73%;
--brand-gold: 48 88% 78%;
--brand-wash: 190 30% 22%;
--brand-charcoal: 165 20% 95%;
--footer-background: 194 35% 12%;
--footer-foreground: 165 20% 95%;
--footer-muted: 184 12% 69%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
@@ -99,6 +118,7 @@ All colors MUST be HSL.
@layer base {
html {
scroll-behavior: smooth;
scroll-padding-top: 5rem;
}
* {
@@ -107,5 +127,33 @@ All colors MUST be HSL.
body {
@apply bg-background font-sans text-foreground;
min-width: 320px;
}
:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 3px;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
html.dark a.home-hero-secondary:hover,
html.dark a.home-hero-secondary:focus-visible {
background-color: hsl(var(--secondary)) !important;
border-color: hsl(var(--ring)) !important;
color: hsl(var(--secondary-foreground)) !important;
}

View File

@@ -1,5 +1,6 @@
import "server-only";
import { cache } from "react";
import { apiBaseUrl } from "@/lib/site";
export type ContentPageSlug = "home" | "about" | "events" | "blog";
@@ -21,6 +22,8 @@ export type SiteContent = {
faculty_subline: string;
description: string;
logo: ContentImage | null;
enamad_image: ContentImage | null;
enamad_url: string;
navigation: Record<ContentPageSlug, string>;
contacts: {
email: string;
@@ -46,16 +49,26 @@ export type HomeContentPage = PageBase & {
hero_body: string;
hero_image: ContentImage | null;
hero_action_label: string;
hero_action_url: string;
hero_secondary_action_label: string;
hero_secondary_action_url: string;
featured_events_heading: string;
featured_events_intro: string;
featured_events_link_label: string;
featured_events_url: string;
featured_event_action_label: string;
featured_posts_heading: string;
featured_posts_intro: string;
featured_posts_link_label: string;
featured_posts_url: string;
featured_post_action_label: string;
participation_heading: string;
participation_body: string;
participation_image: ContentImage | null;
participation_action_label: string;
participation_action_url: string;
participation_secondary_action_label: string;
participation_secondary_action_url: string;
};
export type AboutContentItem = {
@@ -71,17 +84,77 @@ export type AboutContentPage = PageBase & {
identity_body: string;
mission_heading: string;
mission_body: string;
vision_heading: string;
vision_body: string;
activities_heading: string;
activities_body: string;
values_heading: string;
values_body: string;
committees_heading: string;
committees_body: string;
affiliations_heading: string;
affiliations_body: string;
faq_heading: string;
contact_heading: string;
members_heading: string;
members_intro: string;
gallery_heading: string;
gallery_intro: string;
past_events_heading: string;
history_heading: string;
audience_heading: string;
year_events_heading: string;
cta_heading: string;
cta_body: string;
cta_action_label: string;
cta_action_url: string;
items: AboutContentItem[];
/** @deprecated Use years[].members. Kept for API compatibility. */
members: AboutMember[];
gallery_images: AboutGalleryImage[];
audiences: AboutAudience[];
milestones: AboutMilestone[];
years: AboutAssociationYear[];
};
export type AboutMember = {
name: string;
role: string;
entry_year: string;
bio: string;
photo: ContentImage | null;
position: number;
};
export type AboutGalleryImage = {
image: ContentImage | null;
caption: string;
position: number;
};
export type AboutAudience = {
title: string;
text: string;
icon: "students" | "alumni" | "faculty" | "partners";
position: number;
};
export type AboutMilestone = {
year_label: string;
title: string;
description: string;
position: number;
};
export type AboutYearEvent = {
id: number;
title: string;
slug: string;
start_time: string;
featured_image: ContentImage | null;
};
export type AboutAssociationYear = {
year_label: string;
position: number;
members: AboutMember[];
selected_events: AboutYearEvent[];
};
export type EventsContentPage = PageBase & { slug: "events" };
@@ -102,6 +175,8 @@ const MOCK_SITE_CONTENT: SiteContent = {
faculty_subline: "دانشکده نمونه",
description: "این متن نمونه است و پس از ورود محتوای اصلی از پنل مدیریت جایگزین می‌شود.",
logo: null,
enamad_image: { url: "/enamad.png", alt: "نشان اعتماد الکترونیکی" },
enamad_url: "",
navigation: { home: "خانه", about: "درباره ما", events: "رویدادها", blog: "بلاگ" },
contacts: {
email: "contact@example.invalid",
@@ -132,17 +207,27 @@ const MOCK_PAGE_CONTENT: Record<ContentPageSlug, CmsContentPage> = {
hero_heading: "عنوان نمونه صفحه اصلی",
hero_body: "توضیحات نمونه برای معرفی انجمن و فعالیت‌های آن.",
hero_image: null,
hero_action_label: "مشاهده نمونه‌ها",
hero_action_label: "مشاهده رویدادها",
hero_action_url: "/events",
hero_secondary_action_label: "مطالب بلاگ",
hero_secondary_action_url: "/blog",
featured_events_heading: "رویدادهای نمونه",
featured_events_intro: "توضیحات نمونه برای بخش رویدادها.",
featured_events_link_label: "همه رویدادها",
featured_events_url: "/events",
featured_event_action_label: "مشاهده رویداد",
featured_posts_heading: "نوشته‌های نمونه",
featured_posts_intro: "توضیحات نمونه برای بخش نوشته‌ها.",
featured_posts_link_label: "همه نوشته‌ها",
featured_posts_url: "/blog",
featured_post_action_label: "خواندن مطلب",
participation_heading: "به ما بپیوندید",
participation_body: "این متن نمونه راه‌های مشارکت را توضیح می‌دهد.",
participation_image: null,
participation_action_label: "اطلاعات بیشتر",
participation_action_url: "/auth",
participation_secondary_action_label: "درباره انجمن",
participation_secondary_action_url: "/about",
},
about: {
slug: "about",
@@ -157,24 +242,59 @@ const MOCK_PAGE_CONTENT: Record<ContentPageSlug, CmsContentPage> = {
identity_body: "اطلاعات نمونه درباره هویت انجمن.",
mission_heading: "ماموریت نمونه",
mission_body: "توضیحات نمونه درباره ماموریت انجمن.",
vision_heading: "چشم‌انداز نمونه",
vision_body: "جامعه‌ای برای یادگیری، تجربه‌کردن و رشد در کنار یکدیگر.",
activities_heading: "فعالیت‌های نمونه",
activities_body: "توضیحات نمونه درباره فعالیت‌ها.",
activities_body: "کارگاه، گفت‌وگو و تجربه‌های عملی در علوم کامپیوتر.",
values_heading: "ارزش‌های نمونه",
values_body: "یادگیری، همکاری و مسئولیت‌پذیری نمونه.",
committees_heading: "کمیته‌های نمونه",
committees_body: "اطلاعات نمونه درباره کمیته‌ها.",
affiliations_heading: "همکاری‌های نمونه",
affiliations_body: "اطلاعات نمونه درباره گروه‌های همکار.",
faq_heading: "پرسش‌های نمونه",
contact_heading: "راه‌های ارتباطی نمونه",
members_heading: "اعضای انجمن",
members_intro: "با اعضایی آشنا شوید که برای ساختن تجربه‌های بهتر در انجمن همکاری می‌کنند.",
gallery_heading: "خاطرات انجمن",
gallery_intro: "تصاویری از گردهمایی‌ها، برنامه‌ها و لحظه‌هایی که کنار هم ساخته‌ایم.",
past_events_heading: "رویدادهای برگزارشده",
history_heading: "مسیر انجمن",
audience_heading: "برای چه کسانی؟",
year_events_heading: "لحظه‌های هر سال",
cta_heading: "به جمع ما بپیوندید",
cta_body: "اگر دوست دارید یاد بگیرید و در ساختن تجربه‌های تازه سهیم شوید، با ما همراه باشید.",
cta_action_label: "راه‌های پیوستن",
cta_action_url: "",
items: [
{ kind: "mission", title: "ماموریت نمونه", text: "این متن نمونه برای توضیح ماموریت انجمن است.", position: 0 },
{ kind: "activities", title: "فعالیت نمونه", text: "این متن نمونه یکی از فعالیت‌های انجمن را معرفی می‌کند.", position: 0 },
{ kind: "values", title: "ارزش نمونه", text: "این متن نمونه یکی از ارزش‌های انجمن را توضیح می‌دهد.", position: 0 },
{ kind: "committees", title: "کمیته نمونه", text: "این متن نمونه برای معرفی یک کمیته است.", position: 0 },
{ kind: "affiliations", title: "همکاری نمونه", text: "این متن نمونه برای معرفی یک گروه همکار است.", position: 0 },
{ kind: "faq", title: "پرسش نمونه", text: "پاسخ نمونه؛ اطلاعات نهایی را از پنل مدیریت وارد کنید.", position: 0 },
],
members: [],
gallery_images: [
{ image: null, caption: "نمونه‌ای از دورهمی‌های انجمن", position: 0 },
{ image: null, caption: "نمونه‌ای از لحظه‌های یادگیری", position: 1 },
{ image: null, caption: "نمونه‌ای از همکاری دانشجویی", position: 2 },
{ image: null, caption: "نمونه‌ای از خاطرات مشترک", position: 3 },
],
audiences: [
{ title: "دانشجویان", text: "برای یادگیری و ساختن تجربه‌های تازه کنار هم.", icon: "students", position: 0 },
{ title: "دانش‌آموختگان", text: "برای به‌اشتراک‌گذاشتن تجربه و همراهی با نسل بعد.", icon: "alumni", position: 1 },
{ title: "همراهان صنعت", text: "برای نزدیک‌ترکردن دانشگاه به جامعه و کار حرفه‌ای.", icon: "partners", position: 2 },
],
milestones: [
{ year_label: "۱۴۰۱", title: "آغاز یک مسیر مشترک", description: "نمونه‌ای از نقطه‌های مهم در مسیر شکل‌گیری انجمن.", position: 0 },
{ year_label: "۱۴۰۳", title: "گسترش تجربه‌های یادگیری", description: "نمونه‌ای از رشد برنامه‌ها و همکاری‌های دانشجویی.", position: 1 },
],
years: [
{
year_label: "۱۴۰۴–۱۴۰۵",
position: 0,
members: [
{ name: "نام عضو نمونه", role: "نقش در انجمن", entry_year: "۱۴۰۴–۱۴۰۵", bio: "معرفی کوتاه نمونه برای این عضو.", photo: null, position: 0 },
],
selected_events: [
{ id: 0, title: "عنوان رویداد نمونه", slug: "", start_time: "2025-10-01T12:00:00Z", featured_image: null },
],
},
],
},
events: {
slug: "events",
@@ -245,7 +365,7 @@ async function requestContent<T>(path: string): Promise<T> {
return (await response.json()) as T;
}
export async function getCmsSiteContent(): Promise<SiteContent> {
export const getCmsSiteContent = cache(async (): Promise<SiteContent> => {
if (!CMS_ENABLED) return MOCK_SITE_CONTENT;
try {
const content = await requestContent<SiteContent>("/site");
@@ -253,7 +373,7 @@ export async function getCmsSiteContent(): Promise<SiteContent> {
} catch {
return MOCK_SITE_CONTENT;
}
}
});
export async function getCmsPageContent<Slug extends ContentPageSlug>(
slug: Slug,

37
src/lib/home-content.ts Normal file
View File

@@ -0,0 +1,37 @@
import type * as Types from "@/lib/types";
export function selectHomeEvents(
events: Types.EventListItemSchema[],
now = Date.now(),
) {
const ordered = events.slice().sort(
(left, right) => Date.parse(left.start_time) - Date.parse(right.start_time),
);
const upcoming = ordered.filter(
(event) => event.status === "published" && Date.parse(event.start_time) >= now,
);
if (upcoming.length) {
return { events: upcoming.slice(0, 3), isArchive: false };
}
const archive = ordered
.filter((event) => event.status === "completed" || Date.parse(event.start_time) < now)
.reverse()
.slice(0, 3);
return { events: archive, isArchive: archive.length > 0 };
}
export function selectHomePosts(posts: Types.PostListSchema[]) {
const recent = posts.slice().sort(
(left, right) =>
Date.parse(right.published_at || right.created_at) -
Date.parse(left.published_at || left.created_at),
);
const featured = recent.filter((post) => post.is_featured).slice(0, 3);
const featuredIds = new Set(featured.map((post) => post.id));
const remaining = recent.filter((post) => !featuredIds.has(post.id));
return [...featured, ...remaining.slice(0, 3 - featured.length)];
}

View File

@@ -104,6 +104,7 @@ export function NavLink({
replace={replace}
prefetch={prefetch}
className={resolvedClassName}
aria-current={isActive ? "page" : undefined}
{...props}
>
{children}

View File

@@ -1,384 +1,304 @@
import React from 'react';
import Image from "next/image";
import {
ArrowUpLeft,
BookOpen,
Camera,
Eye,
GraduationCap,
Handshake,
HeartHandshake,
Lightbulb,
Sparkles,
UsersRound,
} from "lucide-react";
import ContentActionLink from "@/components/ContentActionLink";
import type { AboutAudience, AboutContentPage, SiteContent } from "@/lib/content-cms";
import AboutYearArchive from "@/views/about/YearArchive";
import { Helmet } from '@/lib/helmet';
import { Link } from '@/lib/router';
// ------ داده‌های قابل تنظیم ------
const SITE_URL = 'https://east-guilan-ce.ir';
const SITE_NAME_FA = 'انجمن علمی کامپیوتر شرق گیلان';
const ABOUT_CANONICAL = `${SITE_URL}/about`;
const ABOUT_TITLE = `درباره ما | ${SITE_NAME_FA}`;
const ABOUT_DESCRIPTION =
'آشنایی با تاریخچه، مأموریت‌ها و دستاوردهای انجمن علمی کامپیوتر شرق گیلان و راه‌های مشارکت دانشجویان در برنامه‌های انجمن.';
const ABOUT_KEYWORDS =
'انجمن علمی کامپیوتر شرق گیلان, انجمن علمی مهندسی کامپیوتر دانشگاه گیلان، انجمن علمی علوم کامپیوتر، انجمن علمی شرق گیلان، انجمن علمی مهندسی کامپیوتر شرق گیلان، دانشکده فنی و مهندسی شرق گیلان، انجمن علمی کامپیوتر, فعالیت‌های دانشجویی, انجمن‌های علمی ایران, رویدادهای فناوری، برنامه نویسی، انجمن علمی دانشجویی، دانشگاه گیلان، فنی شرق';
const ABOUT_STRUCTURED_DATA = {
'@context': 'https://schema.org',
'@type': 'AboutPage',
name: ABOUT_TITLE,
description: ABOUT_DESCRIPTION,
url: ABOUT_CANONICAL,
mainEntity: {
'@type': 'Organization',
name: SITE_NAME_FA,
url: SITE_URL,
logo: `${SITE_URL}/favicon.ico`,
sameAs: [
'https://instagram.com/guilance.ir',
'https://t.me/guilance',
'https://t.me/guilancea'
],
areaServed: 'IR',
contactPoint: [
{
'@type': 'ContactPoint',
contactType: 'customer support',
email: 'eastguilanceassociation@gmail.com',
availableLanguage: ['fa'],
areaServed: 'IR'
}
]
}
type AboutUsProps = {
content: AboutContentPage;
siteContent: SiteContent;
};
const ORG = {
title: 'انجمن‌های علمی کامپیوتر گیلان', // تیتر کلی
subtitle:
'پیوند دانشگاه و صنعت با برگزاری رویدادها، کارگاه‌ها و نشست‌های تخصصی — با محوریت رشد مهارت‌های فنی و مسیرهای شغلی.',
foundedYear: '۱۴۰۴',
membersApprox: '۱۰۰+',
eventsCount: '۱۰+',
volunteersCount: '۲۰+',
};
const audienceIcons = {
students: GraduationCap,
alumni: UsersRound,
faculty: BookOpen,
partners: Handshake,
} satisfies Record<AboutAudience["icon"], typeof GraduationCap>;
// اگر چند انجمن زیرمجموعه دارید، اینجا معرفی کنید
const ASSOCIATIONS = [
{
name: 'انجمن علمی مهندسی کامپیوتر دانشکده‌ی فنی شرق',
university: 'دانشگاه گیلان',
city: 'رودسر',
foundedYear: '۱۳۹۲',
about:
'برگزاری رویدادهای آموزشی و صنعتی، منتورینگ دانشجویی، و اتصال دانشجویان به فرصت‌های شغلی و پژوهشی.',
focusAreas: ['مهندسی نرم‌افزار', 'هوش مصنوعی و داده', 'طراحی محصول', 'DevOps/امنیت'],
links: {
website: 'https://east-guilan-ce.ir',
instagram: '"https://instagram.com/guilance.ir',
telegram: 'https://t.me/guilance',
email: 'eastguilanceassociation@gmail.com',
},
},
{
name: 'انجمن علمی مهندسی کامپیوتر دانشکده‌ی فنی',
university: 'دانشگاه گیلان',
city: 'رشت',
foundedYear: '۱۳۵۳',
about: 'برگزاری رویدادهای آموزشی و صنعتی، منتورینگ دانشجویی، و اتصال دانشجویان به فرصت‌های شغلی و پژوهشی.',
focusAreas: ['مهندسی نرم‌افزار', 'هوش مصنوعی و داده', 'DevOps'],
links: {
instagram: 'https://instagram.com/ce.guilan',
telegram: 'https://t.me/CSAOEF',
email: 'cesa@guilan.ac.ir'
},
},
{
name: 'انجمن علمی علوم کامپیوتر دانشکده‌ی علوم‌پایه',
university: 'دانشگاه گیلان',
city: 'رشت',
foundedYear: '۱۳۵۳',
about: 'برگزاری رویدادهای آموزشی و صنعتی، منتورینگ دانشجویی، و اتصال دانشجویان به فرصت‌های شغلی و پژوهشی.',
focusAreas: ['امنیت', 'سیستم‌عامل', 'سخت‌افزار'],
links: {
instagram: 'https://instagram.com/csguilan',
telegram: 'https://t.me/guilanCS',
},
},
const galleryTones = [
"bg-gradient-to-br from-primary/20 via-brand-wash to-accent/30",
"bg-gradient-to-br from-accent/50 via-secondary to-primary/15",
"bg-gradient-to-br from-secondary via-brand-wash to-primary/20",
"bg-gradient-to-br from-brand-wash via-accent/25 to-secondary",
];
// کمیته‌ها/گروه‌های کاری
const COMMITTEES = [
{
title: 'کمیته آموزش',
desc: 'برنامه‌ریزی و اجرای دوره‌ها و کارگاه‌های مهارتی، هماهنگی با مدرسین و طراحی مسیرهای یادگیری.'
},
{
title: 'کمیته صنعت و اشتغال',
desc: 'تعامل با شرکت‌ها، دعوت از متخصصان صنعت، و شبکه‌سازی برای فرصت‌های کارآموزی و استخدام.'
},
{
title: 'کمیته محتوای دیجیتال',
desc: 'تولید محتوای آموزشی، خبرنامه، مدیریت شبکه‌های اجتماعی و پوشش رسانه‌ای رویدادها.'
},
];
function AboutHero({ content, siteContent }: AboutUsProps) {
const associationImage = siteContent.logo || {
url: "/favicon.ico",
alt: siteContent.short_name || "نشان انجمن",
};
// راه‌های ارتباطی اصلی صفحه
const CONTACTS = [
{
label: 'ایمیل',
value: 'eastguilanceassociation@gmail.com',
href: 'mailto:eastguilanceassociation@gmail.com',
},
{
label: 'تلگرام',
value: '@GuilanCEA',
href: 'https://t.me/guilancea',
},
{
label: 'اینستاگرام',
value: '@GuilanCE.ir',
href: 'https://instagram.com/guilance.ir',
},
{
label: 'وب‌سایت',
value: 'east-guilan-ce.ir',
href: 'https://east-guilan-ce.ir',
},
];
export default function AboutUs() {
return (
<>
<Helmet>
<title>{ABOUT_TITLE}</title>
<meta name="description" content={ABOUT_DESCRIPTION} />
<meta name="keywords" content={ABOUT_KEYWORDS} />
<meta name="robots" content="index, follow" />
<link rel="canonical" href={ABOUT_CANONICAL} />
<meta property="og:title" content={ABOUT_TITLE} />
<meta property="og:description" content={ABOUT_DESCRIPTION} />
<meta property="og:type" content="website" />
<meta property="og:url" content={ABOUT_CANONICAL} />
<meta property="og:site_name" content={SITE_NAME_FA} />
<meta property="og:image" content={`${SITE_URL}/favicon.ico`} />
<meta property="og:locale" content="fa_IR" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={ABOUT_TITLE} />
<meta name="twitter:description" content={ABOUT_DESCRIPTION} />
<meta name="twitter:image" content={`${SITE_URL}/favicon.ico`} />
<script type="application/ld+json">{JSON.stringify(ABOUT_STRUCTURED_DATA)}</script>
</Helmet>
<div className="min-h-screen bg-background" dir="rtl">
{/* Hero */}
<section className="bg-gradient-to-b from-muted/40 to-transparent">
<div className="container mx-auto max-w-6xl px-4 py-12">
<h1 className="text-3xl md:text-4xl font-extrabold tracking-tight text-foreground">{ORG.title}</h1>
<p className="mt-3 text-muted-foreground leading-7">{ORG.subtitle}</p>
<section className="relative overflow-hidden border-b border-border bg-secondary/50" aria-labelledby="about-title">
<div className="container mx-auto grid max-w-6xl items-start gap-9 px-4 py-12 md:py-16 lg:grid-cols-[1.08fr_0.92fr] lg:gap-14 lg:py-20">
<div className="relative z-10 max-w-3xl">
{siteContent.faculty_subline ? (
<p className="mb-4 inline-flex items-center gap-2 text-sm font-semibold text-primary">
<span className="h-2 w-2 rounded-full bg-accent" aria-hidden="true" />
{siteContent.faculty_subline}
</p>
) : null}
<h1 id="about-title" className="text-3xl font-extrabold leading-[1.45] tracking-tight text-foreground md:text-5xl">
{content.title || content.identity_heading}
</h1>
<p className="mt-5 max-w-2xl whitespace-pre-line text-base leading-8 text-muted-foreground md:text-lg">
{content.intro || content.identity_body}
</p>
</div>
{/* آمار کوتاه */}
<div className="mt-6 grid grid-cols-2 gap-4 md:grid-cols-4">
<Stat label="سال تأسیس" value={ORG.foundedYear} />
<Stat label="اعضا" value={ORG.membersApprox} />
<Stat label="رویدادها" value={ORG.eventsCount} />
<Stat label="داوطلبان" value={ORG.volunteersCount} />
<div className="relative mx-auto w-full max-w-xl">
<div className="relative grid min-h-[260px] place-items-center overflow-hidden rounded-[2rem] border border-primary/20 bg-primary p-8 md:min-h-[340px]">
<div className="absolute -left-10 -top-16 h-56 w-56 rounded-full border border-primary-foreground/20" aria-hidden="true" />
<div className="absolute -bottom-24 -right-12 h-64 w-64 rounded-full border border-accent/45" aria-hidden="true" />
<div className="relative grid h-36 w-36 place-items-center rounded-[2rem] border border-primary-foreground/25 bg-background/10 p-5 md:h-48 md:w-48">
<Image
src={associationImage.url}
alt={associationImage.alt}
width={192}
height={192}
unoptimized
priority
className="h-full w-full object-contain"
/>
</div>
<div className="absolute bottom-4 left-4 right-4 flex items-center justify-between gap-3 rounded-xl border border-border/70 bg-card/95 px-4 py-3 shadow-sm backdrop-blur-sm md:bottom-6 md:left-6 md:right-6">
<span className="inline-flex items-center gap-2 text-sm font-bold text-foreground">
<Sparkles aria-hidden="true" className="h-4 w-4 text-primary" />
{siteContent.short_name || siteContent.official_name}
</span>
<span className="text-xs text-muted-foreground">{content.identity_heading}</span>
</div>
</div>
</div>
</section>
</div>
</section>
);
}
{/* درباره ما */}
<section className="container mx-auto max-w-6xl px-4 py-10">
<div className="grid gap-6 md:grid-cols-3">
<div className="md:col-span-2">
<Card>
<CardHeader title="ماموریت ما" subtitle="چرا اینجا هستیم؟" />
<div className="p-6 pt-0 text-sm leading-7 text-muted-foreground">
<p>
توانمندسازی دانشجویان با مهارت‌های موردنیاز صنعت، ایجاد شبکه‌های حرفه‌ای و فراهم‌کردن مسیرهای یادگیری
پروژه‌محور؛ از معرفی نقش‌ها و مسیرهای شغلی تا تمرین مهارت‌های نرم و فنی.
</p>
</div>
</Card>
function PurposeSection({ content }: { content: AboutContentPage }) {
const cards = [
{ title: content.mission_heading, text: content.mission_body, Icon: Lightbulb, tone: "bg-primary text-primary-foreground" },
{ title: content.vision_heading, text: content.vision_body, Icon: Eye, tone: "bg-card text-card-foreground" },
{ title: content.values_heading, text: content.values_body, Icon: HeartHandshake, tone: "bg-accent text-accent-foreground" },
].filter((card) => card.title || card.text);
<Card className="mt-6">
<CardHeader title="ارزش‌های ما" subtitle="چه چیزهایی برای‌مان مهم است؟" />
<ul className="p-6 pt-0 grid grid-cols-1 gap-3 text-sm text-muted-foreground md:grid-cols-2">
<li className="rounded-xl border p-3">یادگیری پیوسته و اشتراک دانش</li>
<li className="rounded-xl border p-3">کیفیت، شفافیت و مسئولیت‌پذیری</li>
<li className="rounded-xl border p-3">فرصت برابر برای مشارکت</li>
<li className="rounded-xl border p-3">ارتباط مؤثر با صنعت و جامعه</li>
</ul>
</Card>
</div>
if (!cards.length && !content.activities_body) return null;
{/* تماس سریع */}
<div>
<Card>
<CardHeader title="راه‌های ارتباطی" subtitle="با ما در ارتباط باشید" />
<div className="p-6 pt-0 space-y-3">
{CONTACTS.map((c) => (
<a
key={c.label}
href={c.href}
target={c.href?.startsWith('http') ? '_blank' : undefined}
rel={c.href?.startsWith('http') ? 'noopener noreferrer' : undefined}
className="flex items-center justify-between rounded-xl border p-3 transition-colors hover:bg-muted/50"
>
<span className="text-sm text-foreground">{c.label}</span>
<span className="text-xs text-muted-foreground ltr:ml-2 rtl:mr-2 truncate">{c.value}</span>
</a>
))}
</div>
</Card>
<Card className="mt-6">
<CardHeader title="ساعات پاسخ‌گویی" subtitle="پشتیبانی داوطلبانه" />
<div className="p-6 pt-0 text-sm text-muted-foreground">
<p>روزهای شنبه تا چهارشنبه، ساعت ۱۰ تا ۱۸</p>
<p className="mt-2">پاسخ‌ها توسط تیم داوطلبان انجام می‌شود.</p>
</div>
</Card>
</div>
return (
<section aria-label="ماموریت، چشم‌انداز و ارزش‌های انجمن">
{cards.length ? (
<div className="grid gap-4 md:grid-cols-3">
{cards.map(({ title, text, Icon, tone }, index) => (
<article
key={`${title}-${index}`}
className={`relative overflow-hidden rounded-3xl border border-border/80 p-6 md:p-7 ${tone}`}
>
<span className="mb-5 grid h-11 w-11 place-items-center rounded-2xl border border-current/15 bg-background/15">
<Icon aria-hidden="true" className="h-5 w-5" />
</span>
{title ? <h2 className="text-xl font-bold leading-8">{title}</h2> : null}
{text ? <p className="mt-3 whitespace-pre-line text-sm leading-7 opacity-90 md:text-base">{text}</p> : null}
{index === 0 ? <span className="pointer-events-none absolute -bottom-16 -left-10 h-40 w-40 rounded-full border border-current/15" aria-hidden="true" /> : null}
</article>
))}
</div>
</section>
) : null}
{/* انجمن‌ها */}
<section className="bg-muted/20">
<div className="container mx-auto max-w-6xl px-4 py-10">
<h2 className="text-2xl font-bold text-foreground">انجمن‌ها</h2>
<p className="mt-2 text-sm text-muted-foreground">اطلاعات انجمن‌های زیرمجموعه/همکار</p>
{content.activities_heading || content.activities_body ? (
<details className="group mt-4 rounded-2xl border border-border bg-card px-5 py-4">
<summary className="flex min-h-8 cursor-pointer list-none items-center justify-between gap-4 font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span className="inline-flex items-center gap-3">
<BookOpen aria-hidden="true" className="h-5 w-5 text-primary" />
{content.activities_heading}
</span>
<span className="text-xl text-primary transition-transform group-open:rotate-45" aria-hidden="true">+</span>
</summary>
{content.activities_body ? <p className="mt-3 whitespace-pre-line border-t border-border pt-3 text-sm leading-7 text-muted-foreground">{content.activities_body}</p> : null}
</details>
) : null}
</section>
);
}
<div className="mt-6 grid gap-6 md:grid-cols-2">
{ASSOCIATIONS.map((a) => (
<Card key={a.name}>
<div className="p-6">
<div className="flex items-baseline justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-foreground">{a.name}</h3>
<p className="text-xs text-muted-foreground mt-1">
{a.university}{a.city ? ` • ${a.city}` : ''}{' '}
{a.foundedYear && `• تأسیس: ${a.foundedYear}`}
</p>
</div>
</div>
<p className="mt-3 text-sm leading-7 text-muted-foreground">{a.about}</p>
function AudienceSection({ content }: { content: AboutContentPage }) {
if (!content.audiences.length) return null;
{a.focusAreas?.length ? (
<div className="mt-4 flex flex-wrap gap-2">
{a.focusAreas.map((f) => (
<span key={f} className="rounded-full border px-3 py-1 text-xs text-muted-foreground">
{f}
</span>
))}
</div>
) : null}
return (
<section aria-labelledby="about-audience-title">
<div className="mb-6 max-w-2xl">
<h2 id="about-audience-title" className="text-2xl font-bold leading-relaxed text-foreground md:text-3xl">
{content.audience_heading}
</h2>
</div>
<div className="grid gap-4 md:grid-cols-3">
{content.audiences.map((audience, index) => {
const Icon = audienceIcons[audience.icon] || UsersRound;
return (
<article
key={`${audience.position}-${audience.title}`}
className={`flex min-h-44 flex-col justify-start rounded-3xl border border-border p-5 md:p-6 ${index === 1 ? "bg-primary text-primary-foreground" : "bg-card text-card-foreground"}`}
>
<span className="mb-5 grid h-12 w-12 shrink-0 place-items-center rounded-2xl border border-current/15 bg-background/15">
<Icon aria-hidden="true" className="h-6 w-6" />
</span>
<div>
<h3 className="text-lg font-bold leading-7">{audience.title}</h3>
<p className="mt-2 text-sm leading-7 opacity-90">{audience.text}</p>
</div>
</article>
);
})}
</div>
</section>
);
}
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
{a.links?.website && (
<a className="rounded-xl border p-3 hover:bg-muted/50" href={a.links.website} target="_blank" rel="noreferrer">وب‌سایت</a>
)}
{a.links?.instagram && (
<a className="rounded-xl border p-3 hover:bg-muted/50" href={a.links.instagram} target="_blank" rel="noreferrer">اینستاگرام</a>
)}
{a.links?.telegram && (
<a className="rounded-xl border p-3 hover:bg-muted/50" href={a.links.telegram} target="_blank" rel="noreferrer">تلگرام</a>
)}
{a.links?.email && (
<a className="rounded-xl border p-3 hover:bg-muted/50" href={`mailto:${a.links.email}`}>ایمیل</a>
)}
</div>
</div>
</Card>
function AssociationHistory({ content }: { content: AboutContentPage }) {
if (!content.milestones.length) return null;
return (
<section aria-labelledby="about-history-title" className="grid gap-8 lg:grid-cols-[0.72fr_1.28fr] lg:gap-14">
<div className="max-w-md">
<h2 id="about-history-title" className="text-2xl font-bold leading-relaxed text-foreground md:text-3xl">
{content.history_heading}
</h2>
</div>
<ol className="relative space-y-7 md:space-y-9">
<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>
</section>
);
}
function MemoriesGallery({ content }: { content: AboutContentPage }) {
if (!content.gallery_images.length) return null;
return (
<section aria-labelledby="about-gallery-title">
<div className="mb-6 max-w-2xl">
<h2 id="about-gallery-title" className="inline-flex items-center gap-3 text-2xl font-bold leading-relaxed text-foreground md:text-3xl">
<Camera aria-hidden="true" className="h-6 w-6 text-primary" />
{content.gallery_heading}
</h2>
{content.gallery_intro ? <p className="mt-2 text-sm leading-7 text-muted-foreground md:text-base">{content.gallery_intro}</p> : null}
</div>
<div className="grid auto-rows-[150px] grid-cols-2 gap-3 md:auto-rows-[190px] md:grid-cols-4 md:gap-4">
{content.gallery_images.map((item, index) => (
<figure key={`${item.position}-${item.caption}`} className={`group relative overflow-hidden rounded-3xl border border-border ${index % 5 === 0 ? "row-span-2 md:col-span-2" : ""}`}>
{item.image ? (
<Image
src={item.image.url}
alt={item.image.alt}
fill
unoptimized
sizes="(max-width: 768px) 50vw, 25vw"
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
) : (
<div className={`grid h-full place-items-center ${galleryTones[index % galleryTones.length]}`} role="img" aria-label={item.caption}>
<span className="grid h-16 w-16 place-items-center rounded-2xl border border-primary/20 bg-card/75 text-primary">
<Camera aria-hidden="true" className="h-7 w-7" />
</span>
</div>
)}
{item.caption ? <figcaption className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 to-transparent px-4 pb-4 pt-10 text-sm font-semibold text-white">{item.caption}</figcaption> : null}
</figure>
))}
</div>
</section>
);
}
function ClosingSection({ content }: { content: AboutContentPage }) {
const faqItems = content.items.filter((item) => item.kind === "faq");
if (!faqItems.length && !content.cta_heading && !content.cta_body && !content.cta_action_label) return null;
return (
<section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]" aria-label="پرسش‌های متداول و پیوستن به انجمن">
{faqItems.length ? (
<div>
<h2 className="mb-4 text-2xl font-bold leading-relaxed text-foreground">{content.faq_heading}</h2>
<div className="space-y-3">
{faqItems.map((item) => (
<details key={`${item.position}-${item.title}`} className="group rounded-2xl border border-border bg-card p-4 open:bg-brand-wash/55">
<summary className="flex min-h-8 w-full cursor-pointer list-none items-center justify-between gap-4 font-semibold leading-7 text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span className="min-w-0 flex-1">{item.title}</span>
<span className="shrink-0 text-xl text-primary transition-transform group-open:rotate-45" aria-hidden="true">+</span>
</summary>
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-muted-foreground">{item.text}</p>
</details>
))}
</div>
</div>
</section>
) : null}
{/* کمیته‌ها */}
<section className="container mx-auto max-w-6xl px-4 py-10">
<h2 className="text-2xl font-bold text-foreground">کمیته‌ها و گروه‌های کاری</h2>
<div className="mt-6 grid gap-4 md:grid-cols-3">
{COMMITTEES.map((c) => (
<Card key={c.title}>
<div className="p-5">
<h3 className="text-base font-semibold text-foreground">{c.title}</h3>
<p className="mt-2 text-sm text-muted-foreground leading-7">{c.desc}</p>
</div>
</Card>
))}
<aside className="flex flex-col items-start justify-between gap-7 rounded-3xl bg-primary p-6 text-primary-foreground md:p-8">
<div>
<span className="mb-5 grid h-11 w-11 place-items-center rounded-2xl border border-primary-foreground/20 bg-background/10">
<HeartHandshake aria-hidden="true" className="h-5 w-5" />
</span>
<h2 className="text-2xl font-bold leading-relaxed">{content.cta_heading}</h2>
{content.cta_body ? <p className="mt-3 max-w-md text-sm leading-7 opacity-90">{content.cta_body}</p> : null}
</div>
</section>
{content.cta_action_label ? (
content.cta_action_url ? (
<ContentActionLink
href={content.cta_action_url}
fallbackHref="/"
className="inline-flex min-h-11 items-center gap-2 rounded-xl bg-accent px-5 font-bold text-accent-foreground transition-colors hover:bg-accent/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-primary"
>
{content.cta_action_label}<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
</ContentActionLink>
) : (
<span aria-disabled="true" className="inline-flex min-h-11 items-center gap-2 rounded-xl border border-primary-foreground/35 px-5 font-bold text-primary-foreground/80">
{content.cta_action_label}<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
</span>
)
) : null}
</aside>
</section>
);
}
{/* پرسش‌های متداول */}
<section className="bg-muted/20">
<div className="container mx-auto max-w-6xl px-4 py-10">
<h2 className="text-2xl font-bold text-foreground">پرسش‌های متداول</h2>
<div className="mt-6 space-y-3">
<FAQ q="چطور در رویدادها شرکت کنم؟" a="برای ثبت‌نام در رویدادها ابتدا باید حساب‌کاربری با کددانشجویی خود ایجاد کنید و سپس از طریق صفحه‌ی ثبت‌نام در رویداد موردنظرتان شرکت کنید :)" />
<FAQ q="آیا امکان همکاری انجمن‌ها/شرکت‌ها هم وجود دارد؟" a="بله، ما همیشه آماده‌ی همکاری با سازمان‌ها و انجمن‌های مختلف دانشگاه‌ها جهت توانمندسازی دانشجویان با مهارت‌های موردنیاز صنعت و ایجاد شبکه‌های حرفه‌ای هستیم." />
<FAQ q="برای سخنرانی/منتورینگ به چه چیزهایی نیاز است؟" a="رزومه کوتاه، موضوع پیشنهادی و زمان‌های دسترس‌پذیرتان را ارسال کنید تا هماهنگ کنیم." />
</div>
</div>
</section>
{/* نقشه و آدرس اختیاری */}
<section className="container mx-auto max-w-6xl px-4 py-10">
<h2 className="text-2xl font-bold text-foreground">نشانی دانشکده</h2>
<div className="mt-4 grid gap-6 md:grid-cols-2">
<Card>
<div className="p-5 text-sm text-muted-foreground leading-7">
<p>آدرس: گیلان، رودسر، واجارگاه، دانشکده‌ی فنی و مهندسی شرق گیلان</p>
<p>کدپستی: ۴۴۹۱۸۹۸۵۶۶</p>
<p>تلفن: ۰۱۳-۴۲۶۸۸۴۴۷</p>
<p className="mt-3">برای هماهنگی حضوری از قبل پیام بدهید.</p>
</div>
</Card>
<div className="rounded-xl border overflow-hidden min-h-[280px] bg-muted/40 flex items-center justify-center text-sm text-muted-foreground">
<iframe
src="https://maps.google.com/maps?q=37.06285,50.42324&hl=fa&z=16&output=embed"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="w-full aspect-video border-0"
/>
</div>
</div>
</section>
{/* CTA */}
<section className="bg-gradient-to-t from-muted/40 to-transparent">
<div className="container mx-auto max-w-6xl px-4 py-12 text-center">
<h3 className="text-xl md:text-2xl font-bold text-foreground">مایل به همکاری هستید؟</h3>
<p className="mt-2 text-sm text-muted-foreground">برای مشارکت داوطلبانه، سخنرانی، اسپانسری یا همکاری صنعتی به ما پیام دهید.</p>
<div className="mt-4 inline-flex gap-3">
<a href="mailto:eastguilanceassociation@gmail.com" className="rounded-xl border px-5 py-2 text-sm hover:bg-muted/50">ایجاد ارتباط</a>
<a href="https://t.me/guilancea" target="_blank" rel="noreferrer" className="rounded-xl bg-primary px-5 py-2 text-sm text-primary-foreground">
پیام در تلگرام
</a>
</div>
</div>
</section>
export default function AboutUs({ content, siteContent }: AboutUsProps) {
return (
<main className="bg-background" dir="rtl">
<AboutHero content={content} siteContent={siteContent} />
<div className="container mx-auto max-w-6xl space-y-16 px-4 py-12 md:space-y-20 md:py-16">
<PurposeSection content={content} />
<AudienceSection content={content} />
<AssociationHistory content={content} />
<AboutYearArchive
years={content.years}
heading={content.members_heading}
intro={content.members_intro}
eventsHeading={content.year_events_heading}
/>
<MemoriesGallery content={content} />
<ClosingSection content={content} />
</div>
</>
);
}
// --------- اجزای کوچک ---------
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border bg-background p-4 text-center">
<div className="text-2xl font-extrabold text-foreground">{value}</div>
<div className="text-xs text-muted-foreground mt-1">{label}</div>
</div>
);
}
function Card({ children, className = '' }: React.PropsWithChildren<{ className?: string }>) {
return <div className={`rounded-2xl border bg-card text-card-foreground shadow-sm ${className}`}>{children}</div>;
}
function CardHeader({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<div className="p-6 pb-4">
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
{subtitle ? <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p> : null}
</div>
);
}
function FAQ({ q, a }: { q: string; a: string }) {
return (
<details className="rounded-xl border bg-background p-4">
<summary className="cursor-pointer select-none text-sm font-medium text-foreground">{q}</summary>
<p className="mt-2 text-sm leading-7 text-muted-foreground">{a}</p>
</details>
</main>
);
}

View File

@@ -18,9 +18,11 @@ import { Link, useLocation, useNavigate } from "@/lib/router";
import { blogPostPath } from "@/lib/blog-routes";
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
import type * as Types from "@/lib/types";
import type { BlogContentPage } from "@/lib/content-cms";
import { cn, formatJalaliDate, getBlogCardImageUrl } from "@/lib/utils";
type BlogProps = {
content: BlogContentPage;
initialPosts?: Types.PostListSchema[];
initialSearch?: string;
initialCategory?: string;
@@ -102,6 +104,7 @@ function BlogBannerSlider({ banners }: { banners: Types.BlogBannerSchema[] }) {
}
export default function Blog({
content,
initialPosts = [],
initialSearch = "",
initialCategory = "",
@@ -347,10 +350,9 @@ export default function Blog({
<div className="mb-8 flex flex-col gap-5 md:flex-row md:items-end md:justify-between">
<div className="text-right">
<p className="mb-2 text-sm font-medium text-primary">خواندنی‌های انجمن</p>
<h1 className="text-4xl font-black tracking-tight">بلاگ</h1>
<h1 className="text-4xl font-black tracking-tight">{content.title}</h1>
<p className="mt-3 max-w-2xl text-sm leading-7 text-muted-foreground">
نوشته‌های آموزشی، تجربه‌های دانشجویی و یادداشت‌های تخصصی اعضای انجمن علمی.
{content.intro}
</p>
</div>
<div className="flex w-full max-w-md items-center gap-2">

View File

@@ -12,8 +12,11 @@ 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;
};
@@ -49,6 +52,8 @@ function notAvailableReasonFa(event: Types.EventListItemSchema) {
}
export default function Events({
content,
siteName,
initialEvents = [],
initialSearch = "",
}: EventsProps) {
@@ -58,10 +63,8 @@ export default function Events({
const [search, setSearch] = useState(initialSearch);
const [loading, setLoading] = useState(!initialEvents.length && !initialSearch);
const siteName = "East Guilan CE";
const pageTitle = `Events | ${siteName}`;
const pageDescription =
"Discover upcoming and past events organized by the East Guilan Computer Engineering Association, including workshops, competitions, and community programs.";
const pageTitle = content.seo.title || content.title;
const pageDescription = content.seo.description || content.intro;
const canonicalUrl = `${siteUrl}/events`;
const toAbsoluteUrl = (url?: string | null) => {
@@ -189,7 +192,8 @@ export default function Events({
<div className="min-h-screen bg-background" dir="rtl">
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">رویدادها</h1>
<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}
<div className="mb-8">
<Input

View File

@@ -1,130 +1,121 @@
/**
* Home page highlighting the association mission, key offerings, and primary calls to action.
*/
import Image from "next/image";
import { ArrowUpLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Helmet } from "@/lib/helmet";
import { Link } from "@/lib/router";
import FeaturedEvents from "@/components/home/FeaturedEvents";
import FeaturedPosts from "@/components/home/FeaturedPosts";
import ContentActionLink from "@/components/ContentActionLink";
import type { HomeContentPage, SiteContent } from "@/lib/content-cms";
import type { EventListItemSchema, PostListSchema } from "@/lib/types";
const heroTitle = "انجمن علمی کامپیوتر دانشگاه گیلان";
const heroDescription =
"با ما همراه شوید و در دنیای مهندسی/علوم کامپیوتر و فناوری پیشرفت کنید. رویدادها، محتوای آموزشی و جامعه‌ای پویا برای رشد شما فراهم است.";
const structuredData = {
"@context": "https://schema.org",
"@type": "Organization",
name: heroTitle,
url: "https://east-guilan-ce.ir",
sameAs: ["https://east-guilan-ce.ir/blog", "https://east-guilan-ce.ir/events"],
description: heroDescription,
logo: "https://east-guilan-ce.ir/favicon.ico",
contactPoint: {
"@type": "ContactPoint",
email: "admin@east-guilan-ce.ir",
contactType: "customer support",
availableLanguage: ["fa", "en"]
}
type HomeProps = {
content: HomeContentPage;
siteContent: SiteContent;
events: EventListItemSchema[];
posts: PostListSchema[];
isEventArchive: boolean;
};
export default function Home() {
export default function Home({ content, siteContent, events, posts, isEventArchive }: HomeProps) {
const heroImage = content.hero_image || siteContent.logo || {
url: "/favicon.ico",
alt: siteContent.short_name || "نشان انجمن",
};
return (
<>
<Helmet>
<title>{heroTitle}</title>
<meta name="description" content={heroDescription} />
<meta property="og:title" content={heroTitle} />
<meta property="og:description" content={heroDescription} />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://east-guilan-ce.ir" />
<meta property="og:image" content="https://east-guilan-ce.ir/favicon.ico" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={heroTitle} />
<meta name="twitter:description" content={heroDescription} />
<script type="application/ld+json">{JSON.stringify(structuredData)}</script>
</Helmet>
<main className="min-h-screen bg-background" dir="rtl">
<header className="bg-gradient-to-b from-primary/10 to-background py-20">
<div className="container mx-auto px-4 text-center">
<h1 className="mb-6 text-4xl font-extrabold leading-tight text-primary sm:text-5xl">{heroTitle}</h1>
<p className="mx-auto mb-10 max-w-2xl text-lg text-muted-foreground sm:text-xl">{heroDescription}</p>
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
<Link to="/events">
<Button size="lg" aria-label="مشاهده رویدادهای انجمن">
مشاهده رویدادها
</Button>
</Link>
<Link to="/blog">
<Button size="lg" variant="outline" aria-label="مطالعه مقالات آموزشی">
خواندن مقالات
</Button>
</Link>
</div>
</div>
</header>
<section className="py-16" aria-labelledby="about-section">
<div className="container mx-auto px-4">
<h2 id="about-section" className="mb-12 text-center text-3xl font-bold">
درباره انجمن
</h2>
<div className="grid gap-8 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>رویدادهای تخصصی</CardTitle>
<CardDescription>برگزاری کارگاه‌ها، سمینارها و نشست‌های علمی</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
با شرکت در رویدادهای ما، دانش و مهارت‌های خود را در کنار اساتید و متخصصان ارتقا دهید.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>مقالات آموزشی</CardTitle>
<CardDescription>دسترسی به مطالب آموزشی و پژوهشی باکیفیت</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
تازه‌ترین مقالات تخصصی در حوزه‌های مختلف علوم کامپیوتر برای یادگیری و توسعه فردی.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>جامعه فعال</CardTitle>
<CardDescription>ارتباط با دانشجویان و اساتید علاقه‌مند به فناوری</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
به جامعه‌ای پویا بپیوندید و از فرصت‌های همکاری، شبکه‌سازی و رشد شخصی بهره‌مند شوید.
</p>
</CardContent>
</Card>
</div>
</div>
</section>
<section className="bg-primary/5 py-16" aria-labelledby="cta-section">
<div className="container mx-auto px-4 text-center">
<h2 id="cta-section" className="mb-4 text-3xl font-bold">
آماده عضویت هستید؟
</h2>
<p className="mx-auto mb-8 max-w-xl text-muted-foreground">
همین حالا به جمع ما بپیوندید و از برنامه‌ها و فرصت‌های یادگیری انجمن علمی کامپیوتر دانشگاه گیلان استفاده کنید.
<main className="bg-background" dir="rtl">
<section className="border-b border-border/70 bg-brand-wash/45" aria-labelledby="home-hero-title">
<div className="container mx-auto grid items-center gap-8 px-4 py-10 md:grid-cols-[1.15fr_0.85fr] md:gap-12 md:py-16">
<div>
<p className="mb-3 text-sm font-semibold text-brand-deep">
{siteContent.faculty_subline}
</p>
<Link to="/auth">
<Button size="lg" aria-label="ثبت نام در انجمن علمی کامپیوتر">
ثبت‌نام کنید
<h1 id="home-hero-title" className="max-w-3xl text-3xl font-extrabold leading-[1.45] tracking-tight text-brand-deep sm:text-4xl md:text-5xl">
{content.hero_heading || content.title}
</h1>
<p className="mt-4 max-w-2xl text-base leading-8 text-muted-foreground md:text-lg">
{content.intro}
</p>
{content.hero_body ? (
<p className="mt-2 max-w-2xl text-sm leading-7 text-muted-foreground">
{content.hero_body}
</p>
) : null}
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<Button asChild className="min-h-11 rounded-[10px] px-5">
<ContentActionLink
href={content.hero_action_url}
fallbackHref="/events"
className="inline-flex items-center gap-2"
>
{content.hero_action_label}
<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
</ContentActionLink>
</Button>
</Link>
<Button asChild variant="outline" className="home-hero-secondary min-h-11 rounded-[10px] border-primary/35 bg-background px-5 text-primary">
<ContentActionLink href={content.hero_secondary_action_url} fallbackHref="/blog">
{content.hero_secondary_action_label || siteContent.navigation.blog}
</ContentActionLink>
</Button>
</div>
</div>
</section>
</main>
</>
<div className="relative grid min-h-48 place-items-center overflow-hidden rounded-2xl border border-border/80 bg-brand-wash px-6 py-10 md:min-h-[320px]">
<div className="pointer-events-none absolute inset-4 rounded-xl border border-primary/20" aria-hidden="true" />
<div className="pointer-events-none absolute inset-x-0 top-0 h-1.5 bg-accent" aria-hidden="true" />
<div className="pointer-events-none absolute -bottom-24 -left-16 h-56 w-56 rounded-full border border-primary/20" aria-hidden="true" />
<Image
src={heroImage.url}
alt={heroImage.alt || "تصویر معرفی انجمن"}
width={240}
height={240}
unoptimized
className="relative z-10 h-36 w-36 object-contain drop-shadow-xl md:h-48 md:w-48"
priority
/>
</div>
</div>
</section>
<FeaturedEvents content={content} events={events} isArchive={isEventArchive} />
<FeaturedPosts content={content} siteContent={siteContent} posts={posts} />
<section className="bg-primary text-primary-foreground" aria-labelledby="home-participation-title">
<div className="container mx-auto flex flex-col gap-6 px-4 py-12 md:flex-row md:items-center md:justify-between md:py-16">
{content.participation_image ? (
<div className="hidden h-24 w-24 shrink-0 place-items-center overflow-hidden rounded-2xl border border-primary-foreground/20 bg-background/10 p-2 md:grid">
<Image
src={content.participation_image.url}
alt={content.participation_image.alt || ""}
width={96}
height={96}
unoptimized
className="h-full w-full object-contain"
/>
</div>
) : null}
<div className="max-w-2xl">
<h2 id="home-participation-title" className="text-2xl font-bold leading-relaxed md:text-3xl">
{content.participation_heading}
</h2>
<p className="mt-2 text-sm leading-7 opacity-90 md:text-base">
{content.participation_body}
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button asChild className="min-h-11 rounded-[10px] bg-accent px-5 text-accent-foreground hover:bg-accent/90">
<ContentActionLink href={content.participation_action_url} fallbackHref="/auth" className="inline-flex items-center gap-2">
{content.participation_action_label}
<ArrowUpLeft aria-hidden="true" className="h-4 w-4" />
</ContentActionLink>
</Button>
<Button asChild variant="outline" className="min-h-11 rounded-[10px] border-primary-foreground/45 bg-transparent px-5 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground">
<ContentActionLink href={content.participation_secondary_action_url} fallbackHref="/about">
{content.participation_secondary_action_label}
</ContentActionLink>
</Button>
</div>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import { ArrowUpLeft, CalendarDays } from "lucide-react";
import { Link } from "@/lib/router";
import type { AboutAssociationYear, AboutMember, AboutYearEvent } from "@/lib/content-cms";
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
import { formatJalali } from "@/lib/utils";
type YearArchiveProps = {
years: AboutAssociationYear[];
heading: string;
intro: string;
eventsHeading: string;
};
function memberInitial(name: string) {
return name.trim().slice(0, 1) || "؟";
}
function MemberCard({ member }: { member: AboutMember }) {
return (
<article className="group overflow-hidden rounded-2xl border border-border bg-card transition-colors hover:border-primary/40">
<div className="relative aspect-[4/3] overflow-hidden bg-brand-wash">
{member.photo ? (
<Image
src={member.photo.url}
alt={member.photo.alt || member.name}
fill
unoptimized
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
) : (
<div className="grid h-full place-items-center bg-gradient-to-br from-accent/40 via-brand-wash to-secondary">
<span className="grid h-20 w-20 place-items-center rounded-full border border-primary/20 bg-card/90 text-3xl font-bold text-primary shadow-sm" aria-label={member.name}>
{memberInitial(member.name)}
</span>
</div>
)}
</div>
<div className="p-4">
<p className="text-xs font-semibold text-primary">{member.role}</p>
<h3 className="mt-1 text-lg font-bold leading-7 text-foreground">{member.name}</h3>
{member.bio ? <p className="mt-2 line-clamp-3 text-sm leading-7 text-muted-foreground">{member.bio}</p> : null}
</div>
</article>
);
}
function EventPoster({ event }: { event: AboutYearEvent }) {
const imageUrl = event.featured_image
? toAbsoluteUrl(event.featured_image.url, apiBaseUrl)
: null;
return (
<div className="group overflow-hidden rounded-2xl border border-border bg-card transition-colors hover:border-primary/40">
<div className="relative aspect-[16/10] overflow-hidden bg-brand-wash">
{imageUrl ? (
<Image
src={imageUrl}
alt={event.featured_image?.alt || `پوستر ${event.title}`}
fill
unoptimized
sizes="(max-width: 768px) 90vw, 33vw"
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
) : (
<div className="grid h-full place-items-center bg-gradient-to-br from-accent/40 via-brand-wash to-secondary text-primary">
<CalendarDays aria-hidden="true" className="h-10 w-10" strokeWidth={1.5} />
</div>
)}
</div>
<div className="flex min-h-24 items-center justify-between gap-3 p-4">
<div className="min-w-0">
<h4 className="line-clamp-2 font-bold leading-7 text-foreground">{event.title}</h4>
{event.start_time ? <time className="mt-1 block text-xs text-muted-foreground" dateTime={event.start_time}>{formatJalali(event.start_time, false)}</time> : null}
</div>
{event.slug ? <ArrowUpLeft aria-hidden="true" className="h-5 w-5 shrink-0 text-primary transition-transform group-hover:-translate-x-1 group-hover:-translate-y-1" /> : null}
</div>
</div>
);
}
export default function YearArchive({ years, heading, intro, eventsHeading }: YearArchiveProps) {
const [selectedLabel, setSelectedLabel] = useState(years[years.length - 1]?.year_label ?? "");
const selectedYear = years.find((year) => year.year_label === selectedLabel) ?? years[years.length - 1];
if (!years.length || !selectedYear) return null;
return (
<section aria-labelledby="about-years-title">
<div className="mb-7 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="max-w-2xl">
<h2 id="about-years-title" className="text-2xl font-bold leading-relaxed text-foreground md:text-3xl">{heading}</h2>
{intro ? <p className="mt-2 text-sm leading-7 text-muted-foreground md:text-base">{intro}</p> : null}
</div>
<p className="shrink-0 rounded-full border border-border bg-card px-4 py-2 text-sm font-semibold text-muted-foreground">
{selectedYear.year_label}
</p>
</div>
<div className="mb-7 overflow-x-auto pb-2" role="group" aria-label="انتخاب سال فعالیت انجمن">
<div className="flex min-w-max gap-2">
{years.map((year, index) => (
<button
key={`${year.year_label}-${index}`}
type="button"
aria-pressed={year.year_label === selectedYear.year_label}
onClick={() => setSelectedLabel(year.year_label)}
className={`min-h-11 rounded-full border px-5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
year.year_label === selectedYear.year_label
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:border-primary/40 hover:bg-brand-wash hover:text-foreground"
}`}
>
{year.year_label}
</button>
))}
</div>
</div>
<div className="space-y-9">
<div>
<h3 className="sr-only">{selectedYear.year_label} — {heading}</h3>
{selectedYear.members.length ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{selectedYear.members.map((member, index) => <MemberCard key={`${member.name}-${member.role}-${index}`} member={member} />)}
</div>
) : (
<p className="rounded-2xl border border-dashed border-border bg-card/60 px-5 py-6 text-sm text-muted-foreground">برای این سال هنوز عضوی ثبت نشده است.</p>
)}
</div>
{selectedYear.selected_events.length ? (
<div>
<div className="mb-4 flex items-center gap-3">
<span className="h-px flex-1 bg-border" aria-hidden="true" />
<h3 className="text-base font-bold text-foreground">{eventsHeading}</h3>
<span className="h-px flex-1 bg-border" aria-hidden="true" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{selectedYear.selected_events.map((event) => (
event.slug ? (
<Link
key={event.id}
to={`/events/${encodeURIComponent(event.slug)}`}
aria-label={`${event.title}؛ مشاهده جزئیات رویداد`}
className="block rounded-2xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<EventPoster event={event} />
</Link>
) : <EventPoster key={event.id} event={event} />
))}
</div>
</div>
) : null}
</div>
</section>
);
}