migrate to Next.js
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-20 09:46:17 +03:30
parent dacbd3a328
commit 42f2087b7c
86 changed files with 2831 additions and 2679 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.git
.next
node_modules
npm-debug.log
dist

View File

@@ -1,2 +1,2 @@
VITE_API_BASE_URL=https://api.east-guilan-ce.ir NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000
NEXT_PUBLIC_SITE_URL=http://localhost:8080

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
.next
dist dist
dist-ssr dist-ssr
.env .env

View File

@@ -1,43 +1,41 @@
# Frontend # Frontend
## Stack ## Stack
- Vite + React 18 with TypeScript. - Next.js App Router with React 18 and TypeScript.
- `@tanstack/react-query` for data fetching and caching. - `@tanstack/react-query` for client-side authenticated flows.
- shadcn/ui primitives (button, card, tabs, dialog, etc.) with Tailwind CSS. - Tailwind CSS and shadcn/ui components.
- Sonner & Toast UI for notifications, Markdown rendering, RTL layout, and Persian-digit helpers. - `next-themes`, Sonner, and toast helpers for RTL UI and notifications.
## Environment
Copy `.env.sample` to `.env`.
Required variables:
- `NEXT_PUBLIC_API_BASE_URL`
- `NEXT_PUBLIC_SITE_URL`
## Development ## Development
### Install dependencies
```bash ```bash
npm install npm install
npm run dev
``` ```
### Configure API base URL The local dev server runs on `http://localhost:8080`.
```bash
cp .env.sample .env
```
### Run dev server ## Production build
```bash
npm run dev -- --host
```
### Production build
```bash ```bash
npm run build npm run build
npm run start
``` ```
The Vite build reads `VITE_API_BASE_URL` from `.env`. The production runtime serves on port `3000` inside Docker. Dockerfiles live only in `guilan-ace-deployment`.
## Features ## Routes
- **Public site**: homepage, events list/detail, blog list, auth flows, profile, payments. - Public SEO pages: `/`, `/about`, `/blog`, `/blog/[slug]`, `/events`, `/events/[slug]`
- **Admin dashboard**: staff-only portal with vertical tabs, user filtering, event filtering, popup detail with registrations/payments, and inline event editing/deletion. - Client-heavy flows: `/auth`, `/profile`, `/logout`, `/payments/result`, `/reset-password/*`, `/verify-email/*`
- **Utils**: Persian digit formatting, price conversion (Rial → Toman), shared API client with JWT token refresh handling, and helper components (scroll area, table, dialog). - Admin: `/admin/*`
## Testing & linting ## Validation
```bash ```bash
npm run lint npm run lint
npm run build
``` ```
JavaScript/TypeScript linting is configured through ESLint + `typescript-eslint`. Run lint before commits to keep code healthy.

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,11 +1,11 @@
import js from "@eslint/js"; import js from "@eslint/js";
import nextPlugin from "@next/eslint-plugin-next";
import globals from "globals"; import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks"; import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
export default tseslint.config( export default tseslint.config(
{ ignores: ["dist"] }, { ignores: ["dist", ".next", "next-env.d.ts"] },
{ {
extends: [js.configs.recommended, ...tseslint.configs.recommended], extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"], files: ["**/*.{ts,tsx}"],
@@ -14,12 +14,12 @@ export default tseslint.config(
globals: globals.browser, globals: globals.browser,
}, },
plugins: { plugins: {
"@next/next": nextPlugin,
"react-hooks": reactHooks, "react-hooks": reactHooks,
"react-refresh": reactRefresh,
}, },
rules: { rules: {
...nextPlugin.configs.recommended.rules,
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off",
}, },
}, },

View File

@@ -1,22 +0,0 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>انجمن علمی کامپیوتر گیلان - Guilan ACE</title>
<meta name="description" content="انجمن علمی دانشجویی کامپیوتر دانشگاه گیلان" />
<meta name="author" content="Guilan ACE" />
<meta property="og:title" content="انجمن علمی کامپیوتر گیلان" />
<meta property="og:description" content="انجمن علمی دانشجویی کامپیوتر دانشگاه گیلان" />
<meta property="og:type" content="website" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

25
next.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "api.east-guilan-ce.ir",
},
{
protocol: "http",
hostname: "127.0.0.1",
port: "8000",
},
{
protocol: "http",
hostname: "localhost",
port: "8000",
},
],
},
};
export default nextConfig;

View File

@@ -1,33 +0,0 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Handle Next.js static export
location / {
try_files $uri $uri.html $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Disable access to hidden files
location ~ /\. {
deny all;
}
}

2458
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,13 @@
{ {
"name": "vite_react_shadcn_ts", "name": "guilan-ace-frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "next dev --port 8080",
"build": "vite build", "build": "next build",
"build:dev": "vite build --mode development",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "start": "next start --port 3000"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
@@ -45,17 +44,19 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"html2canvas": "^1.4.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jspdf": "^2.5.1",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"next": "^15.4.6",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.61.1", "react-hook-form": "^7.61.1",
"react-markdown": "^9.0.3", "react-markdown": "^9.0.3",
"react-qr-code": "^2.0.11",
"react-resizable-panels": "^2.1.9", "react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
@@ -64,28 +65,23 @@
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9", "vaul": "^0.9.9",
"zod": "^3.25.76", "zod": "^3.25.76"
"react-qr-code": "^2.0.11",
"jspdf": "^2.5.1",
"html2canvas": "^1.4.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.32.0", "@eslint/js": "^9.32.0",
"@next/eslint-plugin-next": "^16.2.6",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.16.5", "@types/node": "^22.16.5",
"@types/react": "^18.3.26", "@types/react": "^18.3.26",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^9.32.0", "eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0", "globals": "^15.15.0",
"lovable-tagger": "^1.1.10",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.38.0", "typescript-eslint": "^8.38.0"
"vite": "^5.4.19"
} }
} }

View File

@@ -1,14 +0,0 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /

View File

@@ -1,72 +0,0 @@
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HelmetProvider } from "react-helmet-async";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import Layout from "@/components/Layout";
import { AuthProvider } from "@/contexts/AuthContext";
import AdminLayout from "@/pages/AdminLayout";
import AdminUsers from "@/pages/AdminUsers";
import AdminEvents from "@/pages/AdminEvents";
import AdminEventEdit from "@/pages/AdminEventEdit";
import AdminEventDetail from "@/pages/AdminEventDetail";
import AboutUs from "@/pages/AboutUs";
import Auth from "@/pages/Auth";
import Blog from "@/pages/Blog";
import EventDetail from "@/pages/EventDetail";
import EventFreeSuccessPage from "@/pages/EventFreeSuccessPage";
import Events from "@/pages/Events";
import Home from "@/pages/Home";
import Logout from "@/pages/Logout";
import NotFound from "@/pages/NotFound";
import PaymentResult from "@/pages/PaymentResult";
import Profile from "@/pages/Profile";
import ResetPasswordConfirm from "@/pages/ResetPasswordConfirm";
import ResetPasswordRequest from "@/pages/ResetPasswordRequest";
import VerifyEmail from "@/pages/VerifyEmail";
const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<TooltipProvider>
<Toaster />
<Sonner />
<HelmetProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="auth" element={<Auth />} />
<Route path="logout" element={<Logout />} />
<Route path="profile" element={<Profile />} />
<Route path="blog" element={<Blog />} />
<Route path="events" element={<Events />} />
<Route path="events/:slug" element={<EventDetail />} />
<Route path="events/:slug/success" element={<EventFreeSuccessPage />} />
<Route path="payments/result" element={<PaymentResult />} />
<Route path="verify-email/:token" element={<VerifyEmail />} />
<Route path="reset-password" element={<ResetPasswordRequest />} />
<Route path="reset-password/:token" element={<ResetPasswordConfirm />} />
<Route path="/about" element={<AboutUs />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/users" replace />} />
<Route path="users" element={<AdminUsers />} />
<Route path="events" element={<AdminEvents />} />
<Route path="events/:id" element={<AdminEventDetail />} />
<Route path="events/:id/edit" element={<AdminEventEdit />} />
</Route>
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</HelmetProvider>
</TooltipProvider>
</AuthProvider>
</QueryClientProvider>
);
export default App;

65
src/app/about/page.tsx Normal file
View File

@@ -0,0 +1,65 @@
import type { Metadata } from "next";
import AboutUs from "@/views/AboutUs";
import { siteUrl } from "@/lib/site";
const title = "درباره ما | انجمن علمی کامپیوتر شرق گیلان";
const description =
"آشنایی با تاریخچه، مأموریت‌ها و دستاوردهای انجمن علمی کامپیوتر شرق گیلان و راه‌های مشارکت دانشجویان در برنامه‌های انجمن.";
export const metadata: Metadata = {
title,
description,
keywords: [
"انجمن علمی کامپیوتر شرق گیلان",
"دانشگاه گیلان",
"انجمن علمی دانشجویی",
"رویدادهای فناوری",
],
alternates: { canonical: "/about" },
openGraph: {
title,
description,
url: `${siteUrl}/about`,
siteName: "انجمن علمی کامپیوتر شرق گیلان",
type: "website",
images: [`${siteUrl}/favicon.ico`],
locale: "fa_IR",
},
twitter: {
card: "summary_large_image",
title,
description,
images: [`${siteUrl}/favicon.ico`],
},
};
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) }}
/>
<AboutUs />
</>
);
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import AdminEventEdit from "@/views/AdminEventEdit";
export const metadata: Metadata = {
title: "ویرایش رویداد",
robots: { index: false, follow: false },
};
export default function AdminEventEditPage() {
return <AdminEventEdit />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import AdminEventDetail from "@/views/AdminEventDetail";
export const metadata: Metadata = {
title: "جزئیات رویداد",
robots: { index: false, follow: false },
};
export default function AdminEventDetailPage() {
return <AdminEventDetail />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import AdminEvents from "@/views/AdminEvents";
export const metadata: Metadata = {
title: "مدیریت رویدادها",
robots: { index: false, follow: false },
};
export default function AdminEventsPage() {
return <AdminEvents />;
}

10
src/app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
import AdminLayout from "@/views/AdminLayout";
export default function AdminSectionLayout({
children,
}: {
children: ReactNode;
}) {
return <AdminLayout>{children}</AdminLayout>;
}

5
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminPage() {
redirect("/admin/users");
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import AdminUsers from "@/views/AdminUsers";
export const metadata: Metadata = {
title: "مدیریت کاربران",
robots: { index: false, follow: false },
};
export default function AdminUsersPage() {
return <AdminUsers />;
}

11
src/app/auth/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import Auth from "@/views/Auth";
export const metadata: Metadata = {
title: "ورود / ثبت‌نام",
robots: { index: false, follow: false },
};
export default function AuthPage() {
return <Auth />;
}

View File

@@ -0,0 +1,156 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import Markdown from "@/components/Markdown";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { PublicApiError, getPublicPost } from "@/lib/public-api";
import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site";
import { formatJalali } from "@/lib/utils";
type Params = Promise<{ slug: string }>;
function cleanText(value?: string | null) {
if (!value) return "";
return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
async function loadPost(slug: string) {
try {
return await getPublicPost(slug);
} catch (error) {
if (error instanceof PublicApiError && error.status === 404) {
notFound();
}
throw error;
}
}
export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
const { slug } = await params;
const post = await loadPost(slug);
const description = cleanText(post.excerpt || post.content).slice(0, 160);
const image = toAbsoluteUrl(
post.absolute_featured_image_url || post.featured_image,
apiBaseUrl,
) ?? `${siteUrl}/favicon.ico`;
return {
title: post.title,
description,
alternates: { canonical: `/blog/${post.slug}` },
openGraph: {
title: post.title,
description,
url: `${siteUrl}/blog/${post.slug}`,
siteName: "انجمن علمی کامپیوتر شرق گیلان",
type: "article",
images: [image],
locale: "fa_IR",
publishedTime: post.published_at || post.created_at,
modifiedTime: post.updated_at,
},
twitter: {
card: "summary_large_image",
title: post.title,
description,
images: [image],
},
};
}
export default async function BlogDetailPage({
params,
}: {
params: Params;
}) {
const { slug } = await params;
const post = await loadPost(slug);
const description = cleanText(post.excerpt || post.content).slice(0, 160);
const image = toAbsoluteUrl(
post.absolute_featured_image_url || post.featured_image,
apiBaseUrl,
) ?? `${siteUrl}/favicon.ico`;
const structuredData = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description,
image: [image],
datePublished: post.published_at || post.created_at,
dateModified: post.updated_at,
url: `${siteUrl}/blog/${post.slug}`,
author: {
"@type": "Person",
name: [post.author.first_name, post.author.last_name].filter(Boolean).join(" ") || post.author.username,
},
publisher: {
"@type": "Organization",
name: "انجمن علمی کامپیوتر شرق گیلان",
logo: {
"@type": "ImageObject",
url: `${siteUrl}/favicon.ico`,
},
},
};
return (
<div className="min-h-screen bg-background" dir="rtl">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<div className="container mx-auto px-4 py-8">
<div className="mb-6 flex items-center justify-between gap-3">
<Button variant="outline" asChild>
<Link href="/blog">بازگشت به وبلاگ</Link>
</Button>
<div className="text-sm text-muted-foreground">
{formatJalali(post.published_at || post.created_at, false)}
</div>
</div>
<Card>
{image && (
<div className="w-full aspect-video overflow-hidden rounded-t-lg bg-muted">
<img
src={image}
alt={post.title}
className="h-full w-full object-cover"
loading="eager"
/>
</div>
)}
<CardHeader>
<div className="flex flex-wrap gap-2">
{post.category?.name ? <Badge variant="secondary">{post.category.name}</Badge> : null}
{post.tags.map((tag) => (
<Badge key={tag.id} variant="outline">
{tag.name}
</Badge>
))}
</div>
<CardTitle className="text-3xl leading-relaxed">{post.title}</CardTitle>
<CardDescription className="text-sm">
نویسنده: {[post.author.first_name, post.author.last_name].filter(Boolean).join(" ") || post.author.username}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{post.excerpt ? (
<p className="rounded-lg border bg-muted/30 p-4 text-sm leading-7 text-muted-foreground">
{post.excerpt}
</p>
) : null}
<Markdown content={post.content} justify size="base" />
</CardContent>
</Card>
</div>
</div>
);
}

51
src/app/blog/page.tsx Normal file
View File

@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import Blog from "@/views/Blog";
import { getPublicPosts } from "@/lib/public-api";
import { siteUrl } from "@/lib/site";
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
function firstString(value?: string | string[]) {
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
}
export async function generateMetadata({
searchParams,
}: {
searchParams: SearchParams;
}): Promise<Metadata> {
const resolved = await searchParams;
const search = firstString(resolved.search).trim();
const title = search ? `جست‌وجوی وبلاگ: ${search}` : "وبلاگ";
const description = search
? `نتایج جست‌وجوی مقالات برای ${search} در وبلاگ انجمن علمی کامپیوتر شرق گیلان.`
: "مقالات، نوشته‌ها و محتوای آموزشی انجمن علمی کامپیوتر شرق گیلان.";
return {
title,
description,
alternates: { canonical: search ? `/blog?search=${encodeURIComponent(search)}` : "/blog" },
robots: search ? { index: false, follow: true } : undefined,
openGraph: {
title,
description,
url: search ? `${siteUrl}/blog?search=${encodeURIComponent(search)}` : `${siteUrl}/blog`,
siteName: "انجمن علمی کامپیوتر شرق گیلان",
type: "website",
images: [`${siteUrl}/favicon.ico`],
locale: "fa_IR",
},
};
}
export default async function BlogPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const resolved = await searchParams;
const search = firstString(resolved.search).trim();
const posts = await getPublicPosts({ search: search || undefined });
return <Blog initialPosts={posts} initialSearch={search} />;
}

View File

@@ -0,0 +1,115 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import EventDetail from "@/views/EventDetail";
import { PublicApiError, getPublicEventBySlug } from "@/lib/public-api";
import { siteUrl } from "@/lib/site";
import { getThumbUrl } from "@/lib/utils";
type Params = Promise<{ slug: string }>;
function cleanText(value?: string | null) {
if (!value) return "";
return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
async function loadEvent(slug: string) {
try {
return await getPublicEventBySlug(slug);
} catch (error) {
if (error instanceof PublicApiError && error.status === 404) {
notFound();
}
throw error;
}
}
export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
const { slug } = await params;
const event = await loadEvent(slug);
const description = cleanText(event.description).slice(0, 160);
const image = event.absolute_featured_image_url || getThumbUrl(event) || `${siteUrl}/favicon.ico`;
return {
title: event.title,
description,
alternates: { canonical: `/events/${event.slug}` },
robots: event.status === "draft" ? { index: false, follow: false } : undefined,
openGraph: {
title: event.title,
description,
url: `${siteUrl}/events/${event.slug}`,
siteName: "انجمن علمی کامپیوتر شرق گیلان",
type: "website",
images: [image],
locale: "fa_IR",
},
twitter: {
card: "summary_large_image",
title: event.title,
description,
images: [image],
},
};
}
export default async function EventDetailPage({
params,
}: {
params: Params;
}) {
const { slug } = await params;
const event = await loadEvent(slug);
const description = cleanText(event.description).slice(0, 160);
const structuredData = {
"@context": "https://schema.org",
"@type": "Event",
name: event.title,
description,
startDate: event.start_time,
endDate: event.end_time,
eventStatus:
event.status === "completed"
? "https://schema.org/EventCompleted"
: event.status === "cancelled"
? "https://schema.org/EventCancelled"
: "https://schema.org/EventScheduled",
eventAttendanceMode:
event.event_type === "online"
? "https://schema.org/OnlineEventAttendanceMode"
: event.event_type === "on_site"
? "https://schema.org/OfflineEventAttendanceMode"
: "https://schema.org/MixedEventAttendanceMode",
image: [event.absolute_featured_image_url || getThumbUrl(event) || `${siteUrl}/favicon.ico`],
url: `${siteUrl}/events/${event.slug}`,
organizer: {
"@type": "Organization",
name: "انجمن علمی کامپیوتر شرق گیلان",
url: siteUrl,
},
offers: {
"@type": "Offer",
url: `${siteUrl}/events/${event.slug}`,
priceCurrency: "IRR",
price: String(event.price ?? 0),
availability:
(event.capacity ?? 0) > (event.registration_count ?? 0)
? "https://schema.org/InStock"
: "https://schema.org/SoldOut",
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<EventDetail initialEvent={event} />
</>
);
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import EventFreeSuccessPage from "@/views/EventFreeSuccessPage";
export const metadata: Metadata = {
title: "نتیجه ثبت‌نام رویداد",
robots: { index: false, follow: false },
};
export default function EventSuccessPage() {
return <EventFreeSuccessPage />;
}

51
src/app/events/page.tsx Normal file
View File

@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import Events from "@/views/Events";
import { getPublicEvents } from "@/lib/public-api";
import { siteUrl } from "@/lib/site";
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
function firstString(value?: string | string[]) {
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
}
export async function generateMetadata({
searchParams,
}: {
searchParams: SearchParams;
}): Promise<Metadata> {
const resolved = await searchParams;
const search = firstString(resolved.search).trim();
const title = search ? `جست‌وجوی رویدادها: ${search}` : "رویدادها";
const description = search
? `نتایج جست‌وجوی رویدادها برای ${search} در انجمن علمی کامپیوتر شرق گیلان.`
: "رویدادهای جاری و گذشته انجمن علمی کامپیوتر شرق گیلان، شامل کارگاه‌ها، مسابقات و برنامه‌های آموزشی.";
return {
title,
description,
alternates: { canonical: search ? `/events?search=${encodeURIComponent(search)}` : "/events" },
robots: search ? { index: false, follow: true } : undefined,
openGraph: {
title,
description,
url: search ? `${siteUrl}/events?search=${encodeURIComponent(search)}` : `${siteUrl}/events`,
siteName: "انجمن علمی کامپیوتر شرق گیلان",
type: "website",
images: [`${siteUrl}/favicon.ico`],
locale: "fa_IR",
},
};
}
export default async function EventsPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const resolved = await searchParams;
const search = firstString(resolved.search).trim();
const events = await getPublicEvents({ search: search || undefined });
return <Events initialEvents={events} initialSearch={search} />;
}

34
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
import Providers from "@/components/providers";
import { siteUrl } from "@/lib/site";
import "../index.css";
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
default: "انجمن علمی کامپیوتر شرق گیلان",
template: "%s | انجمن علمی کامپیوتر شرق گیلان",
},
description:
"انجمن علمی کامپیوتر شرق گیلان، برگزارکننده رویدادها، کارگاه‌ها و محتوای آموزشی برای دانشجویان و علاقه‌مندان فناوری.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fa" dir="rtl" suppressHydrationWarning>
<body>
<Providers>
<Navbar />
{children}
<Footer />
</Providers>
</body>
</html>
);
}

11
src/app/logout/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import Logout from "@/views/Logout";
export const metadata: Metadata = {
title: "خروج",
robots: { index: false, follow: false },
};
export default function LogoutPage() {
return <Logout />;
}

19
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,19 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function NotFound() {
return (
<div
className="min-h-[70vh] flex flex-col items-center justify-center gap-4 px-4 text-center"
dir="rtl"
>
<h1 className="text-4xl font-bold">صفحه پیدا نشد</h1>
<p className="text-muted-foreground">
آدرسی که وارد کردهاید وجود ندارد یا جابهجا شده است.
</p>
<Button asChild>
<Link href="/">بازگشت به خانه</Link>
</Button>
</div>
);
}

56
src/app/page.tsx Normal file
View File

@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import Home from "@/views/Home";
import { siteUrl } from "@/lib/site";
const title = "انجمن علمی کامپیوتر دانشگاه گیلان";
const description =
"با ما همراه شوید و در دنیای مهندسی و علوم کامپیوتر پیشرفت کنید. رویدادها، محتوای آموزشی و جامعه‌ای پویا برای رشد شما فراهم است.";
export const metadata: Metadata = {
title,
description,
alternates: { canonical: "/" },
openGraph: {
title,
description,
url: siteUrl,
siteName: "انجمن علمی کامپیوتر شرق گیلان",
type: "website",
images: [`${siteUrl}/favicon.ico`],
locale: "fa_IR",
},
twitter: {
card: "summary_large_image",
title,
description,
images: [`${siteUrl}/favicon.ico`],
},
};
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) }}
/>
<Home />
</>
);
}

View File

@@ -0,0 +1,16 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import PaymentResult from "@/views/PaymentResult";
export const metadata: Metadata = {
title: "نتیجه پرداخت",
robots: { index: false, follow: false },
};
export default function PaymentResultPage() {
return (
<Suspense fallback={null}>
<PaymentResult />
</Suspense>
);
}

11
src/app/profile/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import Profile from "@/views/Profile";
export const metadata: Metadata = {
title: "پروفایل",
robots: { index: false, follow: false },
};
export default function ProfilePage() {
return <Profile />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import ResetPasswordConfirm from "@/views/ResetPasswordConfirm";
export const metadata: Metadata = {
title: "تعیین رمز جدید",
robots: { index: false, follow: false },
};
export default function ResetPasswordConfirmPage() {
return <ResetPasswordConfirm />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import ResetPasswordRequest from "@/views/ResetPasswordRequest";
export const metadata: Metadata = {
title: "بازیابی رمز عبور",
robots: { index: false, follow: false },
};
export default function ResetPasswordPage() {
return <ResetPasswordRequest />;
}

23
src/app/robots.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { MetadataRoute } from "next";
import { siteUrl } from "@/lib/site";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: ["/", "/about", "/blog", "/events"],
disallow: [
"/admin/",
"/auth",
"/logout",
"/profile",
"/payments/",
"/reset-password",
"/verify-email/",
],
},
],
sitemap: `${siteUrl}/sitemap.xml`,
};
}

54
src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,54 @@
import type { MetadataRoute } from "next";
import { getPublicEvents, getPublicPosts } from "@/lib/public-api";
import { siteUrl } from "@/lib/site";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const routes: MetadataRoute.Sitemap = [
{
url: siteUrl,
changeFrequency: "weekly",
priority: 1,
},
{
url: `${siteUrl}/about`,
changeFrequency: "monthly",
priority: 0.7,
},
{
url: `${siteUrl}/blog`,
changeFrequency: "weekly",
priority: 0.8,
},
{
url: `${siteUrl}/events`,
changeFrequency: "daily",
priority: 0.9,
},
];
try {
const [posts, events] = await Promise.all([
getPublicPosts({ limit: 200 }),
getPublicEvents({ limit: 200 }),
]);
routes.push(
...posts.map((post) => ({
url: `${siteUrl}/blog/${post.slug}`,
lastModified: new Date(post.published_at || post.created_at),
changeFrequency: "monthly" as const,
priority: 0.7,
})),
...events.map((event) => ({
url: `${siteUrl}/events/${event.slug}`,
lastModified: new Date(event.created_at),
changeFrequency: "weekly" as const,
priority: 0.8,
})),
);
} catch {
return routes;
}
return routes;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import VerifyEmail from "@/views/VerifyEmail";
export const metadata: Metadata = {
title: "تأیید ایمیل",
robots: { index: false, follow: false },
};
export default function VerifyEmailPage() {
return <VerifyEmail />;
}

View File

@@ -1,75 +1,55 @@
import * as React from "react"; "use client";
import { Link } from "react-router-dom";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast"; import { Instagram, Linkedin, Send, Twitter } from "lucide-react";
import { Instagram, Send, Twitter, Linkedin } from "lucide-react";
import { api } from "@/lib/api"; // متد subscribeNewsletter را پایین توضیح داده‌ام
export default function Footer() { export default function Footer() {
// const { toast } = useToast();
// const [email, setEmail] = React.useState("");
// const [loading, setLoading] = React.useState(false);
const year = new Date().getFullYear(); const year = new Date().getFullYear();
// const validateEmail = (v: string) =>
// /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim());
// const onSubmit = async (e: React.FormEvent) => {
// e.preventDefault();
// const em = email.trim();
// if (!validateEmail(em)) {
// toast({ title: "ایمیل نامعتبر است", description: "لطفاً یک ایمیل صحیح وارد کنید.", variant: "destructive" });
// return;
// }
// try {
// setLoading(true);
// const response = await api.subscribeNewsletter(em);
// if (response.success) {
// toast({ title: "عضویت موفق", description: response.message });
// setEmail("");
// } else {
// toast({ title: "عضویت ناموفق", description: response.message, variant: "destructive" });
// }
// } catch (err: any) {
// toast({ title: "خطا", description: err?.message || "مشکلی رخ داد.", variant: "destructive" });
// } finally {
// setLoading(false);
// }
// };
return ( return (
<footer className="border-t bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/40" dir="rtl"> <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="container mx-auto px-4 py-10">
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
{/* برند + درباره + اینماد */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img src="/favicon.ico" alt="لوگوی انجمن" className="h-9 w-9 rounded" /> <img src="/favicon.ico" alt="لوگوی انجمن" className="h-9 w-9 rounded" />
<span className="text-xl font-bold">انجمن علمی کامپیوتر گیلان</span> <span className="text-xl font-bold">انجمن علمی کامپیوتر گیلان</span>
</div> </div>
<p className="text-sm text-muted-foreground leading-7"> <p className="text-sm text-muted-foreground leading-7">
ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکهٔ دانشجویی و پیوند با صنعت. ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکهی دانشجویی و پیوند با صنعت.
</p> </p>
</div> </div>
{/* لینک‌های سریع */}
<div> <div>
<h4 className="mb-3 text-base font-semibold">لینکهای مفید</h4> <h4 className="mb-3 text-base font-semibold">لینکهای مفید</h4>
<ul className="space-y-2 text-sm"> <ul className="space-y-2 text-sm">
<li><Link to="/" className="text-muted-foreground hover:text-foreground">خانه</Link></li> <li>
<li><Link to="/events" className="text-muted-foreground hover:text-foreground">رویدادها</Link></li> <Link to="/" className="text-muted-foreground hover:text-foreground">
<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> </Link>
{/* <li><Link to="/contact" className="text-muted-foreground hover:text-foreground">تماس با ما</Link></li> */} </li>
{/* <li><Link to="/rules" 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>
</ul> </ul>
</div> </div>
{/* اطلاعات تماس / شبکه‌های اجتماعی */}
<div> <div>
<h4 className="mb-3 text-base font-semibold">ارتباط با ما</h4> <h4 className="mb-3 text-base font-semibold">ارتباط با ما</h4>
<ul className="space-y-2 text-sm text-muted-foreground"> <ul className="space-y-2 text-sm text-muted-foreground">
@@ -78,53 +58,49 @@ export default function Footer() {
</ul> </ul>
<div className="mt-4 flex items-center gap-2"> <div className="mt-4 flex items-center gap-2">
<a href="https://Instagram.com/guilance.ir" target="_blank" rel="noreferrer" className="inline-flex"> <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="اینستاگرام"> <Button variant="outline" size="icon" className="h-9 w-9" aria-label="اینستاگرام">
<Instagram className="h-4 w-4" /> <Instagram className="h-4 w-4" />
</Button> </Button>
</a> </a>
<a href="https://t.me/guilance" target="_blank" rel="noreferrer" className="inline-flex"> <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="تلگرام"> <Button variant="outline" size="icon" className="h-9 w-9" aria-label="تلگرام">
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
</Button> </Button>
</a> </a>
<a href="https://www.linkedin.com/in/amiirkhl/" target="_blank" rel="noreferrer" className="inline-flex"> <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="لینکدین"> <Button variant="outline" size="icon" className="h-9 w-9" aria-label="لینکدین">
<Linkedin className="h-4 w-4" /> <Linkedin className="h-4 w-4" />
</Button> </Button>
</a> </a>
<a href="https://x.com" target="_blank" rel="noreferrer" className="inline-flex"> <a href="https://x.com" target="_blank" rel="noreferrer" className="inline-flex">
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="ایکس (توییتر)"> <Button
variant="outline"
size="icon"
className="h-9 w-9"
aria-label="ایکس (توییتر)"
>
<Twitter className="h-4 w-4" /> <Twitter className="h-4 w-4" />
</Button> </Button>
</a> </a>
</div> </div>
</div> </div>
{/* خبرنامه */}
{/* <div>
<h4 className="mb-3 text-base font-semibold">عضویت در خبرنامه</h4>
<p className="mb-3 text-sm text-muted-foreground">
برای اطلاع از رویدادها و اخبار انجمن، ایمیل خود را وارد کنید.
</p>
<form onSubmit={onSubmit} className="flex flex-col sm:flex-row gap-2">
<Input
type="email"
inputMode="email"
placeholder="ایمیل شما"
dir="ltr"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="sm:flex-1 text-left"
/>
<Button type="submit" disabled={loading}>
{loading ? "در حال عضویت..." : "عضویت"}
</Button>
</form>
<p className="mt-2 text-xs text-muted-foreground">
با عضویت، با <Link to="/rules" className="underline underline-offset-4">قوانین و حریم خصوصی</Link> موافقم.
</p>
</div> */}
<div className="justify-self-end"> <div className="justify-self-end">
<a <a
href="https://trustseal.enamad.ir/?id=649977&Code=m0wWM1DFYqd4fLEnjyMU3o2pupfuqDVW" href="https://trustseal.enamad.ir/?id=649977&Code=m0wWM1DFYqd4fLEnjyMU3o2pupfuqDVW"
@@ -134,7 +110,7 @@ export default function Footer() {
> >
<img <img
src="/enamad.png" src="/enamad.png"
width="125px" width="125"
alt="نماد اعتماد الکترونیکی" alt="نماد اعتماد الکترونیکی"
referrerPolicy="origin" referrerPolicy="origin"
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
@@ -144,12 +120,10 @@ export default function Footer() {
</div> </div>
</div> </div>
{/* خط جداکننده */}
<div className="my-8 h-px w-full bg-border" /> <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 className="flex gap-2 items-center justify-center text-sm text-muted-foreground md:flex-row">
<div>© {year} انجمن علمی کامپیوتر گیلان تمامی حقوق محفوظ است.</div> <div>© {year} انجمن علمی کامپیوتر گیلان - تمامی حقوق محفوظ است.</div>
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -1,15 +0,0 @@
import { Outlet } from 'react-router-dom';
import Navbar from './Navbar';
import Footer from './Footer';
import ScrollToTop from './ScrollToTop';
export default function Layout() {
return (
<div className="min-h-screen">
<ScrollToTop onlyOnPush={false} smooth={false} />
<Navbar />
<Outlet />
<Footer />
</div>
);
}

View File

@@ -1,5 +1,7 @@
"use client";
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Link, NavLink, useNavigate } from 'react-router-dom'; import { Link, NavLink, useNavigate } from '@/lib/router';
import { Menu, ChevronDown } from 'lucide-react'; import { Menu, ChevronDown } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -63,7 +65,7 @@ export default function Navbar() {
<ChevronDown className="h-4 w-4 text-muted-foreground" /> <ChevronDown className="h-4 w-4 text-muted-foreground" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56" dir="rtl"> <DropdownMenuContent align="end" className="w-56 text-right">
<DropdownMenuLabel className="text-xs text-muted-foreground"> <DropdownMenuLabel className="text-xs text-muted-foreground">
{user?.first_name || user?.last_name ? `${user?.first_name || ''} ${user?.last_name || ''}`.trim() : user?.username} {user?.first_name || user?.last_name ? `${user?.first_name || ''} ${user?.last_name || ''}`.trim() : user?.username}
</DropdownMenuLabel> </DropdownMenuLabel>

View File

@@ -1,36 +0,0 @@
import * as React from "react";
import { useLocation, useNavigationType } from "react-router-dom";
export default function ScrollToTop({
onlyOnPush = false, // if true, keeps scroll on back/forward (POP)
smooth = false, // smooth animation
}: { onlyOnPush?: boolean; smooth?: boolean }) {
const { pathname, hash } = useLocation();
const navType = useNavigationType(); // 'PUSH' | 'POP' | 'REPLACE'
React.useLayoutEffect(() => {
// If URL has a hash (#id), scroll to that element
if (hash) {
const el = document.getElementById(hash.slice(1));
if (el) {
el.scrollIntoView({ behavior: smooth ? "smooth" : "auto", block: "start" });
return;
}
}
// If you want to keep scroll when user hits back/forward:
if (onlyOnPush && navType === "POP") return;
window.scrollTo({ top: 0, left: 0, behavior: smooth ? "smooth" : "auto" });
}, [pathname, hash, navType, onlyOnPush, smooth]);
// Disable native restoration if you always want to control it
React.useEffect(() => {
if (!onlyOnPush && "scrollRestoration" in window.history) {
const prev = window.history.scrollRestoration;
window.history.scrollRestoration = "manual";
return () => { window.history.scrollRestoration = prev as "auto" | "manual"; };
}
}, [onlyOnPush]);
return null;
}

View File

@@ -1,56 +1,3 @@
/* eslint-disable react-refresh/only-export-components */ "use client";
import * as React from 'react';
type Theme = 'light' | 'dark' | 'system'; export { ThemeProvider, useTheme } from "next-themes";
type Ctx = { theme: Theme; setTheme: (t: Theme) => void };
const ThemeContext = React.createContext<Ctx | null>(null);
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'egce-theme',
}: {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
}) {
const [theme, setTheme] = React.useState<Theme>(() => {
try { return (localStorage.getItem(storageKey) as Theme) || defaultTheme; }
catch { return defaultTheme; }
});
React.useEffect(() => {
const root = document.documentElement;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const apply = (t: Theme) => {
const isDark = t === 'system' ? mql.matches : t === 'dark';
root.classList.toggle('dark', isDark);
};
apply(theme);
const onChange = () => theme === 'system' && apply('system');
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [theme]);
React.useEffect(() => {
try {
localStorage.setItem(storageKey, theme);
} catch (error) {
console.warn('Unable to persist theme preference', error);
}
}, [theme, storageKey]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = React.useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}

View File

@@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/ThemeProvider";
import { AuthProvider } from "@/contexts/AuthContext";
export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = React.useState(() => new QueryClient());
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
storageKey="egce-theme"
>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<TooltipProvider>
<Toaster />
<Sonner />
{children}
</TooltipProvider>
</AuthProvider>
</QueryClientProvider>
</ThemeProvider>
);
}

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"; import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"; import * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"; import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label"; import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"; import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"; import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority"; import { cva } from "class-variance-authority";

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"; import * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority"; import { VariantProps, cva } from "class-variance-authority";

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner"; import { Toaster as Sonner, toast } from "sonner";

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"; import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle"; import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { UserProfileSchema } from '@/lib/types'; import type { UserProfileSchema } from '@/lib/types';

View File

@@ -1,7 +1,8 @@
import type * as Types from './types'; import type * as Types from './types';
import { apiBaseUrl } from '@/lib/site';
const API_BASE_URL = const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '') || 'https://api.east-guilan-ce.ir'; apiBaseUrl;
type ApiErrorBody = { type ApiErrorBody = {
error?: string; error?: string;
@@ -18,8 +19,32 @@ class ApiClient {
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
} }
private getStorageValue(key: string) {
if (typeof window === 'undefined') {
return null;
}
return window.localStorage.getItem(key);
}
private setStorageValue(key: string, value: string) {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(key, value);
}
private removeStorageValue(key: string) {
if (typeof window === 'undefined') {
return;
}
window.localStorage.removeItem(key);
}
private getAuthHeaders(): HeadersInit { private getAuthHeaders(): HeadersInit {
const token = localStorage.getItem('access_token'); const token = this.getStorageValue('access_token');
return { return {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
@@ -27,7 +52,7 @@ class ApiClient {
} }
private async refreshAccessToken(): Promise<string> { private async refreshAccessToken(): Promise<string> {
const refreshToken = localStorage.getItem('refresh_token'); const refreshToken = this.getStorageValue('refresh_token');
if (!refreshToken) { if (!refreshToken) {
throw new Error('No refresh token available'); throw new Error('No refresh token available');
} }
@@ -39,14 +64,14 @@ class ApiClient {
}); });
if (!response.ok) { if (!response.ok) {
localStorage.removeItem('access_token'); this.removeStorageValue('access_token');
localStorage.removeItem('refresh_token'); this.removeStorageValue('refresh_token');
throw new Error('Session expired. Please login again.'); throw new Error('Session expired. Please login again.');
} }
const data: Types.TokenSchema = await response.json(); const data: Types.TokenSchema = await response.json();
localStorage.setItem('access_token', data.access_token); this.setStorageValue('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token); this.setStorageValue('refresh_token', data.refresh_token);
return data.access_token; return data.access_token;
} }
@@ -75,7 +100,7 @@ class ApiClient {
const response = await fetch(url, config); const response = await fetch(url, config);
// Handle 401 with automatic token refresh // Handle 401 with automatic token refresh
if (response.status === 401 && localStorage.getItem('refresh_token')) { if (response.status === 401 && this.getStorageValue('refresh_token')) {
if (!this.isRefreshing) { if (!this.isRefreshing) {
this.isRefreshing = true; this.isRefreshing = true;
try { try {
@@ -184,7 +209,6 @@ class ApiClient {
} }
async getProfile() { async getProfile() {
const token = localStorage.getItem('access_token');
return this.request<Types.UserProfileSchema>('/api/auth/profile' return this.request<Types.UserProfileSchema>('/api/auth/profile'
); );
} }
@@ -200,7 +224,7 @@ class ApiClient {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const token = localStorage.getItem('access_token'); const token = this.getStorageValue('access_token');
const response = await fetch(`${this.baseUrl}/api/auth/profile/picture`, { const response = await fetch(`${this.baseUrl}/api/auth/profile/picture`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -469,7 +493,7 @@ class ApiClient {
} }
async updateEvent(eventId: number, data: Types.EventUpdateSchema) { async updateEvent(eventId: number, data: Types.EventUpdateSchema) {
return this.request<Types.EventSchema>(`/api/events/${eventId}`, { return this.request<Types.EventDetailSchema>(`/api/events/${eventId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -614,7 +638,7 @@ class ApiClient {
if (data.description) formData.append('description', data.description); if (data.description) formData.append('description', data.description);
if (data.tag_ids) formData.append('tag_ids', JSON.stringify(data.tag_ids)); if (data.tag_ids) formData.append('tag_ids', JSON.stringify(data.tag_ids));
const token = localStorage.getItem('access_token'); const token = this.getStorageValue('access_token');
const response = await fetch(`${this.baseUrl}/api/gallery/images`, { const response = await fetch(`${this.baseUrl}/api/gallery/images`, {
method: 'POST', method: 'POST',
headers: { headers: {

9
src/lib/helmet.tsx Normal file
View File

@@ -0,0 +1,9 @@
import * as React from "react";
export function Helmet({ children }: { children?: React.ReactNode }) {
return null;
}
export function HelmetProvider({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

103
src/lib/public-api.ts Normal file
View File

@@ -0,0 +1,103 @@
import type * as Types from "@/lib/types";
import { apiBaseUrl } from "@/lib/site";
const DEFAULT_REVALIDATE_SECONDS = 300;
type QueryValue =
| string
| number
| boolean
| null
| undefined
| Array<string | number | boolean | null | undefined>;
export class PublicApiError extends Error {
status: number;
constructor(path: string, status: number) {
super(`Request failed for ${path}: ${status}`);
this.name = "PublicApiError";
this.status = status;
}
}
const buildUrl = (path: string, params?: Record<string, QueryValue>) => {
const url = new URL(`${apiBaseUrl}${path}`);
if (!params) {
return url.toString();
}
Object.entries(params).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => {
if (item === undefined || item === null || item === "") {
return;
}
url.searchParams.append(key, String(item));
});
return;
}
if (value === undefined || value === null || value === "") {
return;
}
url.searchParams.append(key, String(value));
});
return url.toString();
};
async function requestJson<T>(
path: string,
options?: {
params?: Record<string, QueryValue>;
revalidate?: number;
},
) {
const response = await fetch(buildUrl(path, options?.params), {
next: { revalidate: options?.revalidate ?? DEFAULT_REVALIDATE_SECONDS },
});
if (!response.ok) {
throw new PublicApiError(path, response.status);
}
return (await response.json()) as T;
}
export async function getPublicPosts(options?: { search?: string; limit?: number }) {
const search = options?.search?.trim();
return requestJson<Types.PostListSchema[]>("/api/blog/posts", {
params: {
limit: options?.limit ?? 50,
...(search ? { search } : {}),
},
revalidate: search ? 60 : DEFAULT_REVALIDATE_SECONDS,
});
}
export async function getPublicPost(slug: string) {
return requestJson<Types.PostDetailSchema>(`/api/blog/posts/${encodeURIComponent(slug)}`);
}
export async function getPublicEvents(options?: { search?: string; limit?: number }) {
const search = options?.search?.trim();
return requestJson<Types.EventListItemSchema[]>("/api/events/", {
params: {
status: ["published", "completed"],
limit: options?.limit ?? 50,
...(search ? { search } : {}),
},
revalidate: search ? 60 : DEFAULT_REVALIDATE_SECONDS,
});
}
export async function getPublicEventBySlug(slug: string) {
return requestJson<Types.EventDetailSchema>(
`/api/events/slug/${encodeURIComponent(slug)}`,
);
}

143
src/lib/router.tsx Normal file
View File

@@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import NextLink from "next/link";
import {
useParams as useNextParams,
usePathname,
useRouter,
} from "next/navigation";
type LinkProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
to: string;
replace?: boolean;
prefetch?: boolean;
children: React.ReactNode;
};
type NavigateFunction = (
to: string | number,
options?: {
replace?: boolean;
},
) => void;
type NavLinkProps = Omit<LinkProps, "className"> & {
className?: string | ((state: { isActive: boolean }) => string);
};
export function Link({ to, replace, prefetch, children, ...props }: LinkProps) {
return (
<NextLink href={to} replace={replace} prefetch={prefetch} {...props}>
{children}
</NextLink>
);
}
export function NavLink({
to,
className,
children,
replace,
prefetch,
...props
}: NavLinkProps) {
const pathname = usePathname();
const isActive =
to === "/"
? pathname === "/"
: pathname === to || Boolean(pathname?.startsWith(`${to}/`));
const resolvedClassName =
typeof className === "function" ? className({ isActive }) : className;
return (
<Link
to={to}
replace={replace}
prefetch={prefetch}
className={resolvedClassName}
{...props}
>
{children}
</Link>
);
}
export function useNavigate(): NavigateFunction {
const router = useRouter();
return React.useCallback(
(to: string | number, options?: { replace?: boolean }) => {
if (typeof to === "number") {
if (to === -1) {
router.back();
return;
}
if (typeof window !== "undefined") {
window.history.go(to);
}
return;
}
if (options?.replace) {
router.replace(to);
return;
}
router.push(to);
},
[router],
);
}
export function useParams<T extends Record<string, string | string[] | undefined>>() {
return useNextParams() as T;
}
export function useLocation() {
const pathname = usePathname();
const [search, setSearch] = React.useState("");
React.useEffect(() => {
if (typeof window === "undefined") {
setSearch("");
return;
}
setSearch(window.location.search || "");
}, [pathname]);
return React.useMemo(
() => ({
pathname: pathname ?? "",
search,
hash: "",
}),
[pathname, search],
);
}
export function useSearchParams() {
const location = useLocation();
return React.useMemo(
() => [new URLSearchParams(location.search)] as const,
[location.search],
);
}
export function Navigate({
to,
replace = false,
}: {
to: string;
replace?: boolean;
}) {
const navigate = useNavigate();
React.useEffect(() => {
navigate(to, { replace });
}, [navigate, replace, to]);
return null;
}

17
src/lib/site.ts Normal file
View File

@@ -0,0 +1,17 @@
const trimTrailingSlash = (value: string) => value.replace(/\/$/, "");
export const siteUrl = trimTrailingSlash(
process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:8080",
);
export const apiBaseUrl = trimTrailingSlash(
process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:8000",
);
export const toAbsoluteUrl = (value?: string | null, fallbackBase = siteUrl) => {
if (!value) return undefined;
if (/^https?:\/\//i.test(value)) return value;
const normalizedBase = trimTrailingSlash(fallbackBase);
const normalizedPath = value.startsWith("/") ? value : `/${value}`;
return `${normalizedBase}${normalizedPath}`;
};

View File

@@ -49,6 +49,8 @@ export interface UserListSchema {
first_name: string; first_name: string;
last_name: string; last_name: string;
full_name?: string | null; full_name?: string | null;
university?: string | null;
major?: string | null;
is_active: boolean; is_active: boolean;
is_staff: boolean; is_staff: boolean;
is_superuser: boolean; is_superuser: boolean;
@@ -61,10 +63,10 @@ export interface UserRegistrationSchema {
username: string; username: string;
first_name: string; first_name: string;
last_name: string; last_name: string;
student_id: string; student_id?: string | null;
year_of_study: number; year_of_study?: number | null;
major: string; major?: string | null;
university: string; university?: string | null;
} }
export type UserUpdateSchema = { export type UserUpdateSchema = {
@@ -74,7 +76,7 @@ export type UserUpdateSchema = {
year_of_study?: number | null; year_of_study?: number | null;
major?: string | null; major?: string | null;
university?: string | null; university?: string | null;
student_id?: number | null; student_id?: string | null;
}; };
@@ -107,6 +109,7 @@ export interface PostListSchema {
slug: string; slug: string;
excerpt?: string; excerpt?: string;
featured_image?: string; featured_image?: string;
absolute_featured_image_url?: string | null;
author: { author: {
id: number; id: number;
username: string; username: string;
@@ -134,6 +137,7 @@ export interface PostListSchema {
export interface PostDetailSchema extends PostListSchema { export interface PostDetailSchema extends PostListSchema {
content: string; content: string;
content_html?: string;
updated_at: string; updated_at: string;
views_count?: number; views_count?: number;
} }
@@ -205,6 +209,8 @@ export interface EventListItemSchema {
capacity?: number | null; capacity?: number | null;
price?: number | null; price?: number | null;
status: 'draft' | 'published' | 'cancelled' | 'completed'; status: 'draft' | 'published' | 'cancelled' | 'completed';
status_label?: string;
event_type_label?: string;
registration_count: number; registration_count: number;
created_at: string; created_at: string;
} }

View File

@@ -35,7 +35,7 @@ export function formatJalali(iso?: string, withTime: boolean = true): string {
} }
} }
const DEFAULT_THUMB = '/images/event-placeholder.svg'; const DEFAULT_THUMB = '/placeholder.svg';
export const getThumbUrl = (e: Types.EventListItemSchema) => export const getThumbUrl = (e: Types.EventListItemSchema) =>
e.absolute_featured_image_url || e.absolute_featured_image_url ||
e.featured_image || e.featured_image ||

View File

@@ -1,11 +0,0 @@
// import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { ThemeProvider } from '@/components/ThemeProvider';
createRoot(document.getElementById("root")!).render(
<ThemeProvider defaultTheme="system" storageKey="egce-theme">
<App />
</ThemeProvider>
);

View File

@@ -1,229 +0,0 @@
import { useEffect, useState, useMemo, useCallback } from 'react';
import { Helmet } from 'react-helmet-async';
import { Link } from 'react-router-dom';
import { api } from '@/lib/api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import type * as Types from '@/lib/types';
import { formatJalali, formatNumberPersian, formatToman, getThumbUrl } from '@/lib/utils';
function labelPrice(event: Types.EventListItemSchema) {
const price = Number(event?.price ?? 0);
return price <= 0 ? "رایگان" : formatToman(price);
}
function modeFa(event_type: Types.EventListItemSchema["event_type"]) {
return event_type === "online" ? "آنلاین" : "حضوری";
}
function spotsLeft(event: Types.EventListItemSchema) {
const cap = Number(event.capacity);
const used = Number(event.registration_count);
const left = cap - used;
return left;
}
function isAvailable(event: Types.EventListItemSchema) {
const now = new Date();
const end = new Date(event.registration_end_date);
const timeOk = end.getTime() > now.getTime();
const left = spotsLeft(event);
return timeOk && left > 0;
}
function notAvailableReasonFa(event: Types.EventListItemSchema) {
const now = new Date();
const end = new Date(event.registration_end_date);
if (end.getTime() <= now.getTime()) return "ثبت‌نام پایان‌یافته";
const left = spotsLeft(event);
if (left <= 0) return "ظرفیت تکمیل";
return "غیرقابل ثبت‌نام";
}
export default function Events() {
const [events, setEvents] = useState<Types.EventListItemSchema[]>([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const siteUrl = 'https://east-guilan-ce.ir';
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 canonicalUrl = `${siteUrl}/events`;
const toAbsoluteUrl = (url?: string | null) => {
if (!url) return undefined;
if (url.startsWith('http')) return url;
const normalizedSite = siteUrl.endsWith('/') ? siteUrl.slice(0, -1) : siteUrl;
const normalizedPath = url.startsWith('/') ? url.slice(1) : url;
return `${normalizedSite}/${normalizedPath}`;
};
const ogImage = useMemo(() => {
if (!events.length) return `${siteUrl}/favicon.ico`;
return toAbsoluteUrl(getThumbUrl(events[0])) ?? `${siteUrl}/favicon.ico`;
}, [events]);
const listStructuredData = useMemo(() => {
if (!events.length) return null;
const itemListElement = events.map((eventItem, index) => {
const listItem: Record<string, unknown> = {
'@type': 'ListItem',
position: index + 1,
url: `${siteUrl}/events/${eventItem.slug}`,
name: eventItem.title,
description: eventItem.description,
startDate: eventItem.start_time,
};
if (eventItem.end_time) {
listItem.endDate = eventItem.end_time;
}
const imageUrl = toAbsoluteUrl(getThumbUrl(eventItem));
if (imageUrl) {
listItem.image = imageUrl;
}
const placeName = eventItem.location || eventItem.address;
if (placeName) {
const place: Record<string, unknown> = {
'@type': 'Place',
name: placeName,
};
if (eventItem.address) {
place.address = eventItem.address;
}
listItem.location = place;
}
return listItem;
});
return {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: pageTitle,
description: pageDescription,
url: canonicalUrl,
numberOfItems: events.length,
itemListElement,
};
}, [events, canonicalUrl, pageDescription, pageTitle]);
const loadEvents = useCallback(async () => {
try {
setLoading(true);
const data = await api.getEvents({
search: search || undefined,
statuses: ['published', 'completed'],
limit: 30,
});
setEvents(data);
} catch (error) {
console.error('Error loading events:', error);
} finally {
setLoading(false);
}
}, [search]);
useEffect(() => {
loadEvents();
}, [loadEvents]);
return (
<>
<Helmet>
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
<link rel="canonical" href={canonicalUrl} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={pageDescription} />
<meta property="og:type" content="website" />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:site_name" content={siteName} />
<meta property="og:image" content={ogImage} />
<meta property="og:locale" content="fa_IR" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={pageDescription} />
<meta name="twitter:image" content={ogImage} />
{listStructuredData && (
<script type="application/ld+json">{JSON.stringify(listStructuredData)}</script>
)}
</Helmet>
<div className="min-h-screen bg-background" dir="rtl">
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">رویدادها</h1>
<div className="mb-8">
<Input
type="text"
placeholder="جستجو در رویدادها..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-md"
/>
</div>
{loading ? (
<p className="text-center text-muted-foreground">در حال بارگذاری...</p>
) : events.length === 0 ? (
<p className="text-center text-muted-foreground">رویدادی یافت نشد</p>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{events.map((event) => (
<Link key={event.id} to={`/events/${event.slug}`} className="block h-full">
<Card className="h-full flex flex-col hover:shadow-lg transition-shadow">
<div className="w-full aspect-video overflow-hidden rounded-lg">
<img
src={getThumbUrl(event)}
alt={event.title}
className="w-full h-full object-cover"
loading="lazy"
decoding="async"
/>
</div>
{/* این رپر حالا قدِ باقی‌مانده رو می‌گیره */}
<div className="flex-1 flex flex-col justify-between">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="line-clamp-2">{event.title}</CardTitle>
<Badge variant="default">{modeFa(event.event_type)}</Badge>
</div>
<CardDescription>{formatJalali(event.start_time, false)}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-1 text-sm" dir="rtl">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">ظرفیت رویداد</span>
<span className="font-medium">
{formatNumberPersian(Number(event?.capacity ?? 0) - Number(event?.registration_count ?? 0))}/{formatNumberPersian(Number(event?.capacity ?? 0))} نفر
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">هزینهی ثبتنام</span>
<span className="font-medium">{labelPrice(event)}</span>
</div>
{isAvailable(event) ? (
<Button>جزئیات رویداد</Button>
) : (
<Button variant="secondary">{notAvailableReasonFa(event)}</Button>
)}
</div>
</CardContent>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -1,14 +0,0 @@
// Update this page (the content is just a fallback if you fail to update the page)
const Index = () => {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1>
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
</div>
</div>
);
};
export default Index;

View File

@@ -1,25 +0,0 @@
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
import { toPersianDigits } from "@/lib/utils";
const NotFound = () => {
const location = useLocation();
useEffect(() => {
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
}, [location.pathname]);
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">{toPersianDigits("404")}</h1>
<p className="mb-4 text-xl text-gray-600">Oops! Page not found</p>
<a href="/" className="text-blue-500 underline hover:text-blue-700">
Return to Home
</a>
</div>
</div>
);
};
export default NotFound;

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Helmet } from 'react-helmet-async'; import { Helmet } from '@/lib/helmet';
import { Link } from 'react-router-dom'; import { Link } from '@/lib/router';
// ------ داده‌های قابل تنظیم ------ // ------ داده‌های قابل تنظیم ------
const SITE_URL = 'https://east-guilan-ce.ir'; const SITE_URL = 'https://east-guilan-ce.ir';

View File

@@ -1,5 +1,7 @@
"use client";
import * as React from 'react'; import * as React from 'react';
import { useParams, Link, Navigate } from 'react-router-dom'; import { useParams, Link, Navigate } from '@/lib/router';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';

View File

@@ -1,5 +1,7 @@
"use client";
import * as React from 'react'; import * as React from 'react';
import { useNavigate, useParams, Navigate } from 'react-router-dom'; import { useNavigate, useParams, Navigate } from '@/lib/router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@@ -42,6 +44,7 @@ export default function AdminEventEdit() {
const navigate = useNavigate(); const navigate = useNavigate();
const eventId = Number(id); const eventId = Number(id);
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient();
const detailQuery = useQuery({ const detailQuery = useQuery({
queryKey: ['admin', 'edit-event', eventId], queryKey: ['admin', 'edit-event', eventId],
@@ -51,8 +54,8 @@ export default function AdminEventEdit() {
const [formData, setFormData] = React.useState({ const [formData, setFormData] = React.useState({
title: '', title: '',
status: 'draft', status: 'draft' as NonNullable<EventUpdateSchema['status']>,
event_type: 'online', event_type: 'online' as NonNullable<EventUpdateSchema['event_type']>,
price: '', price: '',
capacity: '', capacity: '',
start_time: '', start_time: '',
@@ -147,7 +150,7 @@ export default function AdminEventEdit() {
event_type: formData.event_type, event_type: formData.event_type,
price: formData.price ? Number(formData.price) * 10 : 0, price: formData.price ? Number(formData.price) * 10 : 0,
capacity: formData.capacity ? Number(formData.capacity) : null, capacity: formData.capacity ? Number(formData.capacity) : null,
start_time: formData.start_time || null, start_time: formData.start_time || undefined,
end_time: formData.end_time || null, end_time: formData.end_time || null,
registration_start_date: formData.registration_start_date || null, registration_start_date: formData.registration_start_date || null,
registration_end_date: formData.registration_end_date || null, registration_end_date: formData.registration_end_date || null,
@@ -167,7 +170,12 @@ export default function AdminEventEdit() {
/> />
<Select <Select
value={formData.status} value={formData.status}
onValueChange={(value) => setFormData((p) => ({ ...p, status: value }))} onValueChange={(value) =>
setFormData((p) => ({
...p,
status: value as NonNullable<EventUpdateSchema['status']>,
}))
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="وضعیت" /> <SelectValue placeholder="وضعیت" />
@@ -182,7 +190,12 @@ export default function AdminEventEdit() {
</Select> </Select>
<Select <Select
value={formData.event_type} value={formData.event_type}
onValueChange={(value) => setFormData((p) => ({ ...p, event_type: value }))} onValueChange={(value) =>
setFormData((p) => ({
...p,
event_type: value as NonNullable<EventUpdateSchema['event_type']>,
}))
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="نوع رویداد" /> <SelectValue placeholder="نوع رویداد" />
@@ -255,7 +268,7 @@ export default function AdminEventEdit() {
<Button type="button" variant="outline" onClick={() => navigate(-1)}> <Button type="button" variant="outline" onClick={() => navigate(-1)}>
بازگشت بازگشت
</Button> </Button>
<Button type="submit" disabled={updateMutation.isLoading}> <Button type="submit" disabled={updateMutation.isPending}>
ذخیره ذخیره
</Button> </Button>
</div> </div>

View File

@@ -1,6 +1,8 @@
"use client";
import * as React from 'react'; import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from '@/lib/router';
import type { EventListItemSchema } from '@/lib/types'; import type { EventListItemSchema } from '@/lib/types';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -45,17 +47,23 @@ const AdminEventsPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [filters, setFilters] = React.useState({ const [filters, setFilters] = React.useState({
search: '', search: '',
status: 'all', status: 'all' as 'all' | EventListItemSchema['status'],
type: 'all', type: 'all' as 'all' | EventListItemSchema['event_type'],
sort: 'newest', sort: 'newest' as (typeof eventSortOptions)[number]['value'],
}); });
const eventsQuery = useQuery({ const eventsQuery = useQuery({
queryKey: ['admin', 'events', filters], queryKey: ['admin', 'events', filters],
queryFn: () => queryFn: () =>
api.getEvents({ api.getEvents({
statuses: filters.status === 'all' ? undefined : [filters.status], statuses:
event_type: filters.type === 'all' ? undefined : filters.type, filters.status === 'all'
? undefined
: [filters.status as EventListItemSchema['status']],
event_type:
filters.type === 'all'
? undefined
: (filters.type as EventListItemSchema['event_type']),
search: filters.search || undefined, search: filters.search || undefined,
limit: EVENTS_PAGE_SIZE, limit: EVENTS_PAGE_SIZE,
}), }),
@@ -111,7 +119,15 @@ const AdminEventsPage: React.FC = () => {
value={filters.search} value={filters.search}
onChange={(event) => setFilters((prev) => ({ ...prev, search: event.target.value }))} onChange={(event) => setFilters((prev) => ({ ...prev, search: event.target.value }))}
/> />
<Select value={filters.status} onValueChange={(value) => setFilters((prev) => ({ ...prev, status: value }))}> <Select
value={filters.status}
onValueChange={(value) =>
setFilters((prev) => ({
...prev,
status: value as 'all' | EventListItemSchema['status'],
}))
}
>
<SelectTrigger> <SelectTrigger>
<SelectValue> <SelectValue>
{eventStatusOptions.find((option) => option.value === filters.status)?.label || {eventStatusOptions.find((option) => option.value === filters.status)?.label ||
@@ -126,7 +142,15 @@ const AdminEventsPage: React.FC = () => {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={filters.type} onValueChange={(value) => setFilters((prev) => ({ ...prev, type: value }))}> <Select
value={filters.type}
onValueChange={(value) =>
setFilters((prev) => ({
...prev,
type: value as 'all' | EventListItemSchema['event_type'],
}))
}
>
<SelectTrigger> <SelectTrigger>
<SelectValue> <SelectValue>
{{ {{
@@ -144,7 +168,15 @@ const AdminEventsPage: React.FC = () => {
<SelectItem value="hybrid">ترکیبی</SelectItem> <SelectItem value="hybrid">ترکیبی</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={filters.sort} onValueChange={(value) => setFilters((prev) => ({ ...prev, sort: value }))}> <Select
value={filters.sort}
onValueChange={(value) =>
setFilters((prev) => ({
...prev,
sort: value as (typeof eventSortOptions)[number]['value'],
}))
}
>
<SelectTrigger> <SelectTrigger>
<SelectValue> <SelectValue>
{eventSortOptions.find((option) => option.value === filters.sort)?.label || {eventSortOptions.find((option) => option.value === filters.sort)?.label ||

View File

@@ -1,4 +1,7 @@
import { Outlet, Navigate, NavLink, useLocation } from 'react-router-dom'; "use client";
import type { ReactNode } from 'react';
import { Navigate, NavLink, useLocation } from '@/lib/router';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
@@ -7,7 +10,7 @@ const navItems = [
{ to: '/admin/events', label: 'مدیریت رویدادها' }, { to: '/admin/events', label: 'مدیریت رویدادها' },
] as const; ] as const;
export default function AdminLayout() { export default function AdminLayout({ children }: { children: ReactNode }) {
const location = useLocation(); const location = useLocation();
const { user, isAuthenticated, loading } = useAuth(); const { user, isAuthenticated, loading } = useAuth();
const isAdmin = useMemo( const isAdmin = useMemo(
@@ -40,7 +43,7 @@ export default function AdminLayout() {
className={({ isActive }) => className={({ isActive }) =>
[ [
'rounded-full px-4 py-2 text-sm transition', 'rounded-full px-4 py-2 text-sm transition',
(isActive || location.pathname.startsWith(item.to)) (isActive || location.pathname?.startsWith(item.to))
? 'bg-primary text-primary-foreground shadow' ? 'bg-primary text-primary-foreground shadow'
: 'bg-card text-muted-foreground hover:text-foreground border', : 'bg-card text-muted-foreground hover:text-foreground border',
].join(' ') ].join(' ')
@@ -53,7 +56,7 @@ export default function AdminLayout() {
</div> </div>
</div> </div>
<div className="container mx-auto px-4 py-6"> <div className="container mx-auto px-4 py-6">
<Outlet /> {children}
</div> </div>
</div> </div>
); );

View File

@@ -1,3 +1,5 @@
"use client";
import * as React from 'react'; import * as React from 'react';
import { import {
useQuery, useQuery,

View File

@@ -1,6 +1,8 @@
"use client";
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { Helmet } from 'react-helmet-async'; import { Helmet } from '@/lib/helmet';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from '@/lib/router';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';

View File

@@ -1,36 +1,42 @@
import { useEffect, useState, useCallback } from 'react'; "use client";
import { Link } from 'react-router-dom';
import { api } from '@/lib/api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
interface Post { import { useCallback, useEffect, useState } from "react";
id: number; import { Link, useLocation, useNavigate } from "@/lib/router";
title: string; import { api } from "@/lib/api";
slug: string; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
excerpt?: string; import { Input } from "@/components/ui/input";
author: { import type * as Types from "@/lib/types";
username: string;
first_name: string;
last_name: string;
};
created_at: string;
category?: {
name: string;
};
}
export default function Blog() { type BlogProps = {
const [posts, setPosts] = useState<Post[]>([]); initialPosts?: Types.PostListSchema[];
const [search, setSearch] = useState(''); initialSearch?: string;
const [loading, setLoading] = useState(true); };
export default function Blog({
initialPosts = [],
initialSearch = "",
}: BlogProps) {
const navigate = useNavigate();
const location = useLocation();
const [posts, setPosts] = useState<Types.PostListSchema[]>(initialPosts);
const [search, setSearch] = useState(initialSearch);
const [loading, setLoading] = useState(!initialPosts.length && !initialSearch);
useEffect(() => {
setPosts(initialPosts);
}, [initialPosts]);
useEffect(() => {
setSearch(initialSearch);
}, [initialSearch]);
const loadPosts = useCallback(async () => { const loadPosts = useCallback(async () => {
try { try {
setLoading(true);
const data = await api.getPosts({ search: search || undefined }); const data = await api.getPosts({ search: search || undefined });
setPosts(data as Post[]); setPosts(data);
} catch (error) { } catch (error) {
console.error('Error loading posts:', error); console.error("Error loading posts:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -40,6 +46,18 @@ export default function Blog() {
loadPosts(); loadPosts();
}, [loadPosts]); }, [loadPosts]);
useEffect(() => {
const params = new URLSearchParams();
if (search.trim()) {
params.set("search", search.trim());
}
const basePath = location.pathname || "/blog";
const nextPath = params.size
? `${basePath}?${params.toString()}`
: basePath;
navigate(nextPath, { replace: true });
}, [location.pathname, navigate, search]);
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
@@ -70,7 +88,7 @@ export default function Blog() {
{post.category?.name && ( {post.category?.name && (
<span className="text-primary ml-2">{post.category.name}</span> <span className="text-primary ml-2">{post.category.name}</span>
)} )}
{new Date(post.created_at).toLocaleDateString('fa-IR')} {new Date(post.created_at).toLocaleDateString("fa-IR")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@@ -1,43 +1,86 @@
import { useEffect, useMemo, useState } from 'react'; "use client";
import { Helmet } from 'react-helmet-async';
import { useParams, useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type * as Types from '@/lib/types';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/use-toast';
import Markdown from '@/components/Markdown';
import CouponDialogFa from '@/components/CouponDialogFa';
import { formatJalali, formatNumberPersian, formatToman, getThumbUrl, resolveErrorMessage, toPersianDigits } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
const typeLabel: Record<string, string> = { online: 'آنلاین', on_site: 'حضوری', hybrid: 'آنلاین و حضوری' }; import { useEffect, useMemo, useState } from "react";
import { Helmet } from "@/lib/helmet";
import { useNavigate, useParams } from "@/lib/router";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import Markdown from "@/components/Markdown";
import CouponDialogFa from "@/components/CouponDialogFa";
import {
formatJalali,
formatNumberPersian,
formatToman,
getThumbUrl,
resolveErrorMessage,
toPersianDigits,
} from "@/lib/utils";
import { useAuth } from "@/contexts/AuthContext";
import { siteUrl } from "@/lib/site";
export default function EventDetail() { const typeLabel: Record<string, string> = {
online: "آنلاین",
on_site: "حضوری",
hybrid: "آنلاین و حضوری",
};
type EventDetailProps = {
initialEvent?: Types.EventDetailSchema | null;
};
function buildPaymentSnapshot(
event: Types.EventDetailSchema,
eventThumb: string | null,
payload: {
baseAmount: number;
discountAmount: number;
amount: number;
},
) {
return JSON.stringify({
event_id: event.id,
slug: event.slug,
title: event.title,
thumb: eventThumb,
base_amount: payload.baseAmount,
discount_amount: payload.discountAmount,
amount: payload.amount,
started_at: new Date().toISOString(),
success_markdown: event.registration_success_markdown,
});
}
export default function EventDetail({ initialEvent = null }: EventDetailProps) {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const [event, setEvent] = useState<Types.EventDetailSchema | null>(null); const [event, setEvent] = useState<Types.EventDetailSchema | null>(initialEvent);
const [eventThumb, setEventThumb] = useState(null); const [eventThumb, setEventThumb] = useState<string | null>(
const [loading, setLoading] = useState(true); initialEvent ? getThumbUrl(initialEvent) : null,
);
const [loading, setLoading] = useState(!initialEvent);
const [open, setOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [alreadyRegistered, setAlreadyRegistered] = useState(false);
const [nowTs, setNowTs] = useState(() => Date.now());
const basePrice = Number(event?.price ?? 0); const basePrice = Number(event?.price ?? 0);
const isFree = useMemo(() => basePrice <= 0, [basePrice]); const isFree = useMemo(() => basePrice <= 0, [basePrice]);
const [open, setOpen] = useState(false); const siteName = "انجمن علمی کامپیوتر شرق گیلان";
const [submitting, setSubmitting] = useState(false);
const siteUrl = 'https://east-guilan-ce.ir';
const siteName = 'انجمن علمی کامپیوتر شرق گیلان';
const defaultDescription = const defaultDescription =
'جزئیات کامل رویدادهای انجمن علمی کامپیوتر شرق گیلان شامل زمان، مکان و شرایط ثبت‌نام.'; "جزئیات کامل رویدادهای انجمن علمی کامپیوتر شرق گیلان شامل زمان، مکان و شرایط ثبت‌نام.";
const toAbsoluteUrl = (url?: string | null) => { const toAbsoluteUrl = (value?: string | null) => {
if (!url) return undefined; if (!value) return undefined;
if (url.startsWith('http')) return url; if (value.startsWith("http")) return value;
const normalizedSite = siteUrl.endsWith('/') ? siteUrl.slice(0, -1) : siteUrl; const normalizedSite = siteUrl.endsWith("/") ? siteUrl.slice(0, -1) : siteUrl;
const normalizedPath = url.startsWith('/') ? url.slice(1) : url; const normalizedPath = value.startsWith("/") ? value.slice(1) : value;
return `${normalizedSite}/${normalizedPath}`; return `${normalizedSite}/${normalizedPath}`;
}; };
@@ -55,185 +98,215 @@ export default function EventDetail() {
: `${siteUrl}/favicon.ico`; : `${siteUrl}/favicon.ico`;
const pageTitle = event ? `${event.title} | ${siteName}` : `جزئیات رویداد | ${siteName}`; const pageTitle = event ? `${event.title} | ${siteName}` : `جزئیات رویداد | ${siteName}`;
const pageDescription = sanitizeDescription(event?.description); const pageDescription = sanitizeDescription(event?.description);
const pageRobots = event?.status === 'draft' ? 'noindex, nofollow' : 'index, follow'; const pageRobots = event?.status === "draft" ? "noindex, nofollow" : "index, follow";
const [alreadyRegistered, setAlreadyRegistered] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
async function check() {
if (isAuthenticated && event?.id) { async function checkRegistration() {
if (!isAuthenticated || !event?.id) {
return;
}
try { try {
const res = await api.getRegistrationStatus(event.id); const res = await api.getRegistrationStatus(event.id);
if (!cancelled) setAlreadyRegistered(res.is_registered); if (!cancelled) {
} catch { /* ignore */ } setAlreadyRegistered(res.is_registered);
}
} catch {
// Ignore registration status failures on the detail page.
} }
} }
check();
return () => { cancelled = true; }; checkRegistration();
}, [isAuthenticated, event?.id]); return () => {
cancelled = true;
};
}, [event?.id, isAuthenticated]);
const goSuccess = (registrationId?: string) => { const goSuccess = (registrationId?: string) => {
const q = registrationId ? `?registration_id=${registrationId}` : ''; if (!event) return;
const query = registrationId ? `?registration_id=${registrationId}` : "";
setAlreadyRegistered(true); setAlreadyRegistered(true);
toast({ title: "ثبت‌نام با موفقیت انجام شد!", variant: "success" });
toast({ title: 'ثبت‌نام با موفقیت انجام شد!', variant: 'success' }); navigate(`/events/${event.slug}/success${query}`);
navigate(`/events/${event!.slug}/success${q}`);
}; };
const handleMainCTA = async () => { const handleMainCTA = async () => {
if (!event) return; if (!event) return;
if (!isAuthenticated) { if (!isAuthenticated) {
toast({ title: 'ابتدا وارد شوید', description: 'برای ثبت‌نام در رویداد باید وارد حساب کاربری خود شوید.', variant: 'destructive' }); toast({
navigate('/auth'); title: "ابتدا وارد شوید",
description: "برای ثبت‌نام در رویداد باید وارد حساب کاربری خود شوید.",
variant: "destructive",
});
navigate("/auth");
return; return;
} }
if (isFree) {
if (!isFree) {
setOpen(true);
return;
}
try { try {
setSubmitting(true); setSubmitting(true);
const res = await api.registerForEvent(event.id); const res = await api.registerForEvent(event.id);
goSuccess(res.ticket_id); goSuccess(res.ticket_id);
} catch (error: unknown) { } catch (error: unknown) {
const msg = resolveErrorMessage(error, ''); const msg = resolveErrorMessage(error, "");
if (msg.includes('already registered') || msg.includes('ثبت‌نام')) { if (msg.includes("already registered")) {
setAlreadyRegistered(true); setAlreadyRegistered(true);
toast({ title: 'شما قبلاً ثبت‌نام کرده‌اید', variant: 'destructive' }); toast({ title: "شما قبلاً ثبت‌نام کرده‌اید", variant: "destructive" });
return; return;
} }
throw error; toast({
title: "خطا در ثبت‌نام",
description: msg || "لطفاً دوباره تلاش کنید.",
variant: "destructive",
});
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
} else {
setOpen(true);
}
}; };
const handleContinueFromModal = async (coupon?: string, finalAmount?: number) => { const handleContinueFromModal = async (coupon?: string, finalAmount?: number) => {
if (!event) return; if (!event) return;
if (!isAuthenticated) { if (!isAuthenticated) {
toast({ title: 'ابتدا وارد شوید', description: 'برای ثبت‌نام در رویداد باید وارد حساب کاربری خود شوید.', variant: 'destructive' }); toast({
navigate('/auth'); title: "ابتدا وارد شوید",
description: "برای ثبت‌نام در رویداد باید وارد حساب کاربری خود شوید.",
variant: "destructive",
});
navigate("/auth");
return; return;
} }
try { try {
setSubmitting(true); setSubmitting(true);
const reg = await api.registerForEvent(event.id, coupon); const reg = await api.registerForEvent(event.id, coupon);
if (finalAmount === 0) { if (finalAmount === 0) {
sessionStorage.setItem('payment:last', JSON.stringify({ window.sessionStorage.setItem(
event_id: event.id, "payment:last",
slug: event.slug, buildPaymentSnapshot(event, eventThumb, {
title: event.title, baseAmount: Number(event.price ?? 0),
thumb: eventThumb, discountAmount: Number(event.price ?? 0),
base_amount: Number(event.price ?? 0),
discount_amount: Number(event.price ?? 0),
amount: 0, amount: 0,
started_at: new Date().toISOString(), }),
success_markdown: event.registration_success_markdown, );
await api.ChangeRegistrationStatus(reg.id, "confirmed");
})); goSuccess(reg.ticket_id);
api.ChangeRegistrationStatus(reg.id, 'confirmed')
goSuccess(reg?.ticket_id);
return; return;
} }
const description = `پرداخت رویداد: ${event.title}`;
const result = await api.createPayment({ const result = await api.createPayment({
event_id: event.id, event_id: event.id,
description, description: `پرداخت رویداد: ${event.title}`,
discount_code: (coupon ?? '').trim() || null, discount_code: (coupon ?? "").trim() || null,
}); });
if (!result?.start_pay_url || Number(result.amount) === 0) { if (!result?.start_pay_url || Number(result.amount) === 0) {
sessionStorage.setItem('payment:last', JSON.stringify({ window.sessionStorage.setItem(
event_id: event.id, "payment:last",
slug: event.slug, buildPaymentSnapshot(event, eventThumb, {
title: event.title, baseAmount: result.base_amount,
thumb: eventThumb, discountAmount: result.discount_amount ?? result.base_amount,
base_amount: result.base_amount,
discount_amount: result.discount_amount ?? result.base_amount,
amount: 0, amount: 0,
started_at: new Date().toISOString(), }),
success_markdown: event.registration_success_markdown, );
})); goSuccess(reg.ticket_id);
goSuccess(reg?.ticket_id);
return; return;
} }
window.sessionStorage.setItem(
sessionStorage.setItem('payment:last', JSON.stringify({ "payment:last",
event_id: event.id, buildPaymentSnapshot(event, eventThumb, {
slug: event.slug, baseAmount: result.base_amount,
title: event.title, discountAmount: result.discount_amount,
thumb: eventThumb,
base_amount: result.base_amount,
discount_amount: result.discount_amount,
amount: result.amount, amount: result.amount,
started_at: new Date().toISOString(), }),
success_markdown: event.registration_success_markdown, );
}));
window.location.href = result.start_pay_url; window.location.href = result.start_pay_url;
} catch (error: unknown) { } catch (error: unknown) {
const msg = resolveErrorMessage(error, "");
const msg = resolveErrorMessage(error, ''); if (msg.includes("already registered")) {
if (msg.includes('already registered') || msg.includes('ثبت‌نام')) {
setAlreadyRegistered(true); setAlreadyRegistered(true);
toast({ title: 'شما قبلاً ثبت‌نام کرده‌اید', variant: 'destructive' }); toast({ title: "شما قبلاً ثبت‌نام کرده‌اید", variant: "destructive" });
return; return;
} }
toast({ title: 'خطا در پردازش پرداخت', description: msg || 'لطفاً دوباره تلاش کنید.', variant: 'destructive' }); toast({
title: "خطا در پردازش پرداخت",
description: msg || "لطفاً دوباره تلاش کنید.",
variant: "destructive",
});
} finally { } finally {
setSubmitting(false); setSubmitting(false);
setOpen(false); setOpen(false);
} }
}; };
useEffect(() => { useEffect(() => {
(async () => { let cancelled = false;
async function loadEvent() {
try { try {
if (!slug) return; if (!slug) return;
if (initialEvent && initialEvent.slug === slug) {
setEvent(initialEvent);
setEventThumb(getThumbUrl(initialEvent));
return;
}
const data = await api.getEventBySlug(slug); const data = await api.getEventBySlug(slug);
if (cancelled) return;
setEvent(data); setEvent(data);
setEventThumb(getThumbUrl(data)); setEventThumb(getThumbUrl(data));
} catch (error: unknown) { } catch (error: unknown) {
if (cancelled) return;
toast({ toast({
title: 'خطا در بارگذاری رویداد', title: "خطا در بارگذاری رویداد",
description: resolveErrorMessage(error, 'لطفاً دوباره تلاش کنید.'), description: resolveErrorMessage(error, "لطفاً دوباره تلاش کنید."),
variant: 'destructive', variant: "destructive",
}); });
} finally { } finally {
if (!cancelled) {
setLoading(false); setLoading(false);
} }
})(); }
}, [slug, toast]); }
loadEvent();
return () => {
cancelled = true;
};
}, [initialEvent, slug, toast]);
const [nowTs, setNowTs] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
const id = window.setInterval(() => setNowTs(Date.now()), 1000); const timer = window.setInterval(() => setNowTs(Date.now()), 1000);
return () => window.clearInterval(id); return () => window.clearInterval(timer);
}, []); }, []);
const rsTs = useMemo<number | null>(() => ( const rsTs = useMemo<number | null>(
event?.registration_start_date ? new Date(event.registration_start_date).getTime() : null () => (event?.registration_start_date ? new Date(event.registration_start_date).getTime() : null),
), [event?.registration_start_date]); [event?.registration_start_date],
);
const deadlineTs = useMemo<number | null>(() => ( const deadlineTs = useMemo<number | null>(
event?.registration_end_date ? new Date(event.registration_end_date).getTime() : null () => (event?.registration_end_date ? new Date(event.registration_end_date).getTime() : null),
), [event?.registration_end_date]); [event?.registration_end_date],
);
const remainingMs = useMemo<number | null>(() => ( const remainingMs = useMemo<number | null>(
deadlineTs != null ? Math.max(0, deadlineTs - nowTs) : null () => (deadlineTs != null ? Math.max(0, deadlineTs - nowTs) : null),
), [deadlineTs, nowTs]); [deadlineTs, nowTs],
);
const formatCountdownTwoDigit = (value: number) => const formatCountdownTwoDigit = (value: number) =>
toPersianDigits(value.toString().padStart(2, '0')); toPersianDigits(value.toString().padStart(2, "0"));
const formatCountdownNumber = (value: number) => formatNumberPersian(value); const formatCountdownNumber = (value: number) => formatNumberPersian(value);
const formatRemainingWords = (ms: number) => { const formatRemainingWords = (ms: number) => {
@@ -242,39 +315,45 @@ export default function EventDetail() {
const hours = Math.floor((total % 86400) / 3600); const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60); const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60; const seconds = total % 60;
if (days === 0) return `${formatCountdownTwoDigit(hours)} ساعت و ${formatCountdownTwoDigit(minutes)} دقیقه و ${formatCountdownTwoDigit(seconds)} ثانیه`;
if (days === 0) {
return `${formatCountdownTwoDigit(hours)} ساعت و ${formatCountdownTwoDigit(minutes)} دقیقه و ${formatCountdownTwoDigit(seconds)} ثانیه`;
}
return `${formatCountdownNumber(days)} روز و ${formatCountdownTwoDigit(hours)} ساعت و ${formatCountdownTwoDigit(minutes)} دقیقه و ${formatCountdownTwoDigit(seconds)} ثانیه`; return `${formatCountdownNumber(days)} روز و ${formatCountdownTwoDigit(hours)} ساعت و ${formatCountdownTwoDigit(minutes)} دقیقه و ${formatCountdownTwoDigit(seconds)} ثانیه`;
}; };
const meta = useMemo(() => { const meta = useMemo(() => {
if (!event) return null; if (!event) return null;
const rs = rsTs; const registrationOpen =
const re = deadlineTs; (rsTs == null || nowTs >= rsTs) && (deadlineTs == null || nowTs <= deadlineTs);
const registrationOpen = (rs == null || nowTs >= rs) && (re == null || nowTs <= re);
const unlimited = event.capacity == null; const unlimited = event.capacity == null;
const remaining = unlimited ? Infinity : Math.max(0, (event.capacity || 0) - (event.registration_count || 0)); const remaining = unlimited
? Number.POSITIVE_INFINITY
: Math.max(0, (event.capacity || 0) - (event.registration_count || 0));
const full = !unlimited && remaining <= 0; const full = !unlimited && remaining <= 0;
return { registrationOpen, remaining, full }; return { registrationOpen, remaining, full };
}, [event, rsTs, deadlineTs, nowTs]); }, [deadlineTs, event, nowTs, rsTs]);
const eventStructuredData = useMemo(() => { const eventStructuredData = useMemo(() => {
if (!event) return null; if (!event) return null;
const attendanceModeMap: Record<string, string> = { const attendanceModeMap: Record<string, string> = {
online: 'https://schema.org/OnlineEventAttendanceMode', online: "https://schema.org/OnlineEventAttendanceMode",
on_site: 'https://schema.org/OfflineEventAttendanceMode', on_site: "https://schema.org/OfflineEventAttendanceMode",
hybrid: 'https://schema.org/MixedEventAttendanceMode', hybrid: "https://schema.org/MixedEventAttendanceMode",
}; };
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
published: 'https://schema.org/EventScheduled', published: "https://schema.org/EventScheduled",
completed: 'https://schema.org/EventCompleted', completed: "https://schema.org/EventCompleted",
cancelled: 'https://schema.org/EventCancelled', cancelled: "https://schema.org/EventCancelled",
draft: 'https://schema.org/EventPostponed', draft: "https://schema.org/EventPostponed",
}; };
const data: Record<string, unknown> = { const data: Record<string, unknown> = {
'@context': 'https://schema.org', "@context": "https://schema.org",
'@type': 'Event', "@type": "Event",
name: event.title, name: event.title,
description: pageDescription, description: pageDescription,
startDate: event.start_time, startDate: event.start_time,
@@ -282,7 +361,7 @@ export default function EventDetail() {
eventAttendanceMode: attendanceModeMap[event.event_type] ?? attendanceModeMap.hybrid, eventAttendanceMode: attendanceModeMap[event.event_type] ?? attendanceModeMap.hybrid,
eventStatus: statusMap[event.status] ?? statusMap.published, eventStatus: statusMap[event.status] ?? statusMap.published,
organizer: { organizer: {
'@type': 'Organization', "@type": "Organization",
name: siteName, name: siteName,
url: siteUrl, url: siteUrl,
}, },
@@ -296,14 +375,14 @@ export default function EventDetail() {
data.image = [primaryImage]; data.image = [primaryImage];
} }
if (event.event_type === 'online') { if (event.event_type === "online") {
data.location = { data.location = {
'@type': 'VirtualLocation', "@type": "VirtualLocation",
url: event.online_link || canonicalUrl, url: event.online_link || canonicalUrl,
}; };
} else { } else {
const location: Record<string, unknown> = { const location: Record<string, unknown> = {
'@type': 'Place', "@type": "Place",
name: event.location || event.address || siteName, name: event.location || event.address || siteName,
}; };
if (event.address) { if (event.address) {
@@ -316,11 +395,11 @@ export default function EventDetail() {
} }
const offers: Record<string, unknown> = { const offers: Record<string, unknown> = {
'@type': 'Offer', "@type": "Offer",
url: canonicalUrl, url: canonicalUrl,
priceCurrency: 'IRR', priceCurrency: "IRR",
price: String(event.price ?? 0), price: String(event.price ?? 0),
availability: meta?.full ? 'https://schema.org/SoldOut' : 'https://schema.org/InStock', availability: meta?.full ? "https://schema.org/SoldOut" : "https://schema.org/InStock",
}; };
if (event.registration_start_date) { if (event.registration_start_date) {
@@ -331,9 +410,8 @@ export default function EventDetail() {
} }
data.offers = offers; data.offers = offers;
return data; return data;
}, [event, pageDescription, canonicalUrl, primaryImage, meta?.full, siteName, siteUrl]); }, [canonicalUrl, event, meta?.full, pageDescription, primaryImage, siteName]);
const helmet = ( const helmet = (
<Helmet> <Helmet>
@@ -370,23 +448,26 @@ export default function EventDetail() {
if (loading) { if (loading) {
return withHelmet( return withHelmet(
<div className="min-h-[60vh] flex items-center justify-center text-muted-foreground">در حال بارگذاری رویداد...</div> <div className="min-h-[60vh] flex items-center justify-center text-muted-foreground">
); در حال بارگذاری رویداد...
} </div>,
if (!event) {
return withHelmet(
<div className="min-h-[60vh] flex items-center justify-center">رویداد مورد نظر یافت نشد.</div>
); );
} }
if (!event) {
return withHelmet(
<div className="min-h-[60vh] flex items-center justify-center">
رویداد مورد نظر یافت نشد.
</div>,
);
}
const beforeStart = rsTs != null && nowTs < rsTs; const beforeStart = rsTs != null && nowTs < rsTs;
const ended = deadlineTs !== null && remainingMs === 0; const ended = deadlineTs !== null && remainingMs === 0;
const showCountdown = !beforeStart && deadlineTs !== null && remainingMs! > 0; const showCountdown = !beforeStart && deadlineTs !== null && (remainingMs ?? 0) > 0;
return withHelmet( return withHelmet(
<div className="container mx-auto px-4 py-8" dir="rtl"> <div className="container mx-auto px-4 py-8" dir="rtl">
{/* --- نوار اطلاع/تایمر زیر نوار ناوبری با رنگ‌های مناسب Light/Dark --- */}
{beforeStart && ( {beforeStart && (
<div className="mb-6"> <div className="mb-6">
<div className="rounded-xl border p-4 text-center bg-sky-50 text-sky-900 border-sky-200 dark:bg-sky-900/30 dark:text-sky-100 dark:border-sky-800"> <div className="rounded-xl border p-4 text-center bg-sky-50 text-sky-900 border-sky-200 dark:bg-sky-900/30 dark:text-sky-100 dark:border-sky-800">
@@ -395,13 +476,13 @@ export default function EventDetail() {
</div> </div>
)} )}
{showCountdown && ( {showCountdown && remainingMs != null && (
<div className="mb-6"> <div className="mb-6">
<div className="rounded-xl border p-4 text-center bg-emerald-50 text-emerald-900 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-100 dark:border-emerald-800"> <div className="rounded-xl border p-4 text-center bg-emerald-50 text-emerald-900 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-100 dark:border-emerald-800">
<div className="flex flex-col items-center gap-1 sm:flex-row sm:justify-center"> <div className="flex flex-col items-center gap-1 sm:flex-row sm:justify-center">
<span>زمان باقیمانده تا پایان ثبتنام:</span> <span>زمان باقیمانده تا پایان ثبتنام:</span>
<strong className="font-extrabold tracking-wider sm:ms-1"> <strong className="font-extrabold tracking-wider sm:ms-1">
{formatRemainingWords(remainingMs!)} {formatRemainingWords(remainingMs)}
</strong> </strong>
</div> </div>
</div> </div>
@@ -417,7 +498,6 @@ export default function EventDetail() {
)} )}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* محتوا */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<div className="w-full aspect-video overflow-hidden rounded-lg"> <div className="w-full aspect-video overflow-hidden rounded-lg">
@@ -449,16 +529,15 @@ export default function EventDetail() {
</CardContent> </CardContent>
</Card> </Card>
{/* گالری */}
{event.gallery_images?.length ? ( {event.gallery_images?.length ? (
<div className="mt-6"> <div className="mt-6">
<h3 className="text-lg font-semibold mb-3">گالری تصاویر</h3> <h3 className="text-lg font-semibold mb-3">گالری تصاویر</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{event.gallery_images.map((g) => ( {event.gallery_images.map((image) => (
<img <img
key={g.id} key={image.id}
src={g.absolute_image_url || ''} src={image.absolute_image_url || ""}
alt={g.title || ''} alt={image.title || ""}
className="w-full h-36 object-cover rounded-md" className="w-full h-36 object-cover rounded-md"
/> />
))} ))}
@@ -467,7 +546,6 @@ export default function EventDetail() {
) : null} ) : null}
</div> </div>
{/* سایدبار اطلاعات */}
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<div className="lg:sticky lg:top-24"> <div className="lg:sticky lg:top-24">
<Card> <Card>
@@ -477,20 +555,18 @@ export default function EventDetail() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm"> <CardContent className="space-y-3 text-sm">
{event.address && <div>آدرس: {event.address}</div>} {event.address && <div>آدرس: {event.address}</div>}
<div>ظرفیت کل: {event.capacity == null ? 'نامحدود' : formatNumberPersian(event.capacity)}</div>
{meta && (
<>
{!event.capacity ? null : (
<div> <div>
ظرفیت باقیمانده: {meta.remaining === Infinity ? 'نامحدود' : formatNumberPersian(meta.remaining)} ظرفیت کل: {event.capacity == null ? "نامحدود" : formatNumberPersian(event.capacity)}
</div>
{meta && event.capacity != null && (
<div>
ظرفیت باقیمانده:{" "}
{meta.remaining === Number.POSITIVE_INFINITY
? "نامحدود"
: formatNumberPersian(meta.remaining)}
</div> </div>
)} )}
</> <div>هزینه حضور: {event.price ? formatToman(event.price) : "رایگان"}</div>
)}
<div>هزینه حضور: {event.price ? formatToman(event.price) : 'رایگان'}</div>
{/* نمایش زمان شروع/پایان ثبت‌نام در UI حذف شده */}
<Button <Button
onClick={handleMainCTA} onClick={handleMainCTA}
@@ -498,25 +574,24 @@ export default function EventDetail() {
disabled={ disabled={
submitting || submitting ||
alreadyRegistered || alreadyRegistered ||
event.status !== 'published' || event.status !== "published" ||
meta?.full === true || meta?.full === true ||
!meta?.registrationOpen !meta?.registrationOpen
} }
> >
{event.status !== 'published' {event.status !== "published"
? 'ثبت‌نام این رویداد فعال نیست' ? "ثبت‌نام این رویداد فعال نیست"
: alreadyRegistered : alreadyRegistered
? 'شما قبلاً ثبت‌نام کرده‌اید' ? "شما قبلاً ثبت‌نام کرده‌اید"
: !meta?.registrationOpen : !meta?.registrationOpen
? 'ثبت‌نام هنوز آغاز نشده است' ? "ثبت‌نام هنوز آغاز نشده است"
: meta?.full : meta?.full
? 'ظرفیت ثبت‌نام تکمیل شده است' ? "ظرفیت ثبت‌نام تکمیل شده است"
: submitting : submitting
? 'در حال ثبت‌نام...' ? "در حال ثبت‌نام..."
: event.price === 0 : event.price === 0
? 'ثبت‌نام (رایگان)' ? "ثبت‌نام (رایگان)"
: 'ثبت‌نام و ادامه پرداخت' : "ثبت‌نام و ادامه پرداخت"}
}
</Button> </Button>
{!isFree && ( {!isFree && (
@@ -536,11 +611,3 @@ export default function EventDetail() {
</div> </div>
); );
} }

View File

@@ -1,11 +1,13 @@
import { useLocation, useParams, Link } from "react-router-dom"; "use client";
import { useLocation, useParams, Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import PaymentResult from "@/components/PaymentResult"; import PaymentResult from "@/components/PaymentResult";
import { formatJalali } from "@/lib/utils"; import { formatJalali } from "@/lib/utils";
import Markdown from '@/components/Markdown'; import Markdown from '@/components/Markdown';
import { Helmet } from "react-helmet-async"; import { Helmet } from "@/lib/helmet";
export default function EventFreeSuccessPage() { export default function EventFreeSuccessPage() {
const { slug } = useParams(); const { slug } = useParams();

262
src/views/Events.tsx Normal file
View File

@@ -0,0 +1,262 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Helmet } from "@/lib/helmet";
import { Link, useLocation, useNavigate } from "@/lib/router";
import { api } from "@/lib/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import type * as Types from "@/lib/types";
import { formatJalali, formatNumberPersian, formatToman, getThumbUrl } from "@/lib/utils";
import { siteUrl } from "@/lib/site";
type EventsProps = {
initialEvents?: Types.EventListItemSchema[];
initialSearch?: string;
};
function labelPrice(event: Types.EventListItemSchema) {
const price = Number(event?.price ?? 0);
return price <= 0 ? "رایگان" : formatToman(price);
}
function modeFa(eventType: Types.EventListItemSchema["event_type"]) {
return eventType === "online" ? "آنلاین" : "حضوری";
}
function spotsLeft(event: Types.EventListItemSchema) {
const cap = Number(event.capacity);
const used = Number(event.registration_count);
return cap - used;
}
function isAvailable(event: Types.EventListItemSchema) {
const now = new Date();
const end = new Date(event.registration_end_date ?? event.start_time);
const timeOk = end.getTime() > now.getTime();
return timeOk && spotsLeft(event) > 0;
}
function notAvailableReasonFa(event: Types.EventListItemSchema) {
const now = new Date();
const end = new Date(event.registration_end_date ?? event.start_time);
if (end.getTime() <= now.getTime()) return "ثبت‌نام پایان‌یافته";
if (spotsLeft(event) <= 0) return "ظرفیت تکمیل";
return "غیرقابل ثبت‌نام";
}
export default function Events({
initialEvents = [],
initialSearch = "",
}: EventsProps) {
const navigate = useNavigate();
const location = useLocation();
const [events, setEvents] = useState<Types.EventListItemSchema[]>(initialEvents);
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 canonicalUrl = `${siteUrl}/events`;
const toAbsoluteUrl = (url?: string | null) => {
if (!url) return undefined;
if (url.startsWith("http")) return url;
const normalizedSite = siteUrl.endsWith("/") ? siteUrl.slice(0, -1) : siteUrl;
const normalizedPath = url.startsWith("/") ? url.slice(1) : url;
return `${normalizedSite}/${normalizedPath}`;
};
const ogImage = useMemo(() => {
if (!events.length) return `${siteUrl}/favicon.ico`;
return toAbsoluteUrl(getThumbUrl(events[0])) ?? `${siteUrl}/favicon.ico`;
}, [events]);
const listStructuredData = useMemo(() => {
if (!events.length) return null;
const itemListElement = events.map((eventItem, index) => {
const listItem: Record<string, unknown> = {
"@type": "ListItem",
position: index + 1,
url: `${siteUrl}/events/${eventItem.slug}`,
name: eventItem.title,
description: eventItem.description,
startDate: eventItem.start_time,
};
if (eventItem.end_time) {
listItem.endDate = eventItem.end_time;
}
const imageUrl = toAbsoluteUrl(getThumbUrl(eventItem));
if (imageUrl) {
listItem.image = imageUrl;
}
const placeName = eventItem.location || eventItem.address;
if (placeName) {
const place: Record<string, unknown> = {
"@type": "Place",
name: placeName,
};
if (eventItem.address) {
place.address = eventItem.address;
}
listItem.location = place;
}
return listItem;
});
return {
"@context": "https://schema.org",
"@type": "ItemList",
name: pageTitle,
description: pageDescription,
url: canonicalUrl,
numberOfItems: events.length,
itemListElement,
};
}, [canonicalUrl, events, pageDescription, pageTitle]);
useEffect(() => {
setEvents(initialEvents);
}, [initialEvents]);
useEffect(() => {
setSearch(initialSearch);
}, [initialSearch]);
const loadEvents = useCallback(async () => {
try {
setLoading(true);
const data = await api.getEvents({
search: search || undefined,
statuses: ["published", "completed"],
limit: 30,
});
setEvents(data);
} catch (error) {
console.error("Error loading events:", error);
} finally {
setLoading(false);
}
}, [search]);
useEffect(() => {
loadEvents();
}, [loadEvents]);
useEffect(() => {
const params = new URLSearchParams();
if (search.trim()) {
params.set("search", search.trim());
}
const basePath = location.pathname || "/events";
const nextPath = params.size
? `${basePath}?${params.toString()}`
: basePath;
navigate(nextPath, { replace: true });
}, [location.pathname, navigate, search]);
return (
<>
<Helmet>
<title>{pageTitle}</title>
<meta name="description" content={pageDescription} />
<link rel="canonical" href={canonicalUrl} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={pageDescription} />
<meta property="og:type" content="website" />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:site_name" content={siteName} />
<meta property="og:image" content={ogImage} />
<meta property="og:locale" content="fa_IR" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={pageDescription} />
<meta name="twitter:image" content={ogImage} />
{listStructuredData && (
<script type="application/ld+json">{JSON.stringify(listStructuredData)}</script>
)}
</Helmet>
<div className="min-h-screen bg-background" dir="rtl">
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">رویدادها</h1>
<div className="mb-8">
<Input
type="text"
placeholder="جستجو در رویدادها..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-md"
/>
</div>
{loading ? (
<p className="text-center text-muted-foreground">در حال بارگذاری...</p>
) : events.length === 0 ? (
<p className="text-center text-muted-foreground">رویدادی یافت نشد</p>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{events.map((event) => (
<Link key={event.id} to={`/events/${event.slug}`} className="block h-full">
<Card className="h-full flex flex-col hover:shadow-lg transition-shadow">
<div className="w-full aspect-video overflow-hidden rounded-lg">
<img
src={getThumbUrl(event)}
alt={event.title}
className="w-full h-full object-cover"
loading="lazy"
decoding="async"
/>
</div>
<div className="flex-1 flex flex-col justify-between">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="line-clamp-2">{event.title}</CardTitle>
<Badge variant="default">{modeFa(event.event_type)}</Badge>
</div>
<CardDescription>{formatJalali(event.start_time, false)}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-1 text-sm" dir="rtl">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">ظرفیت رویداد</span>
<span className="font-medium">
{formatNumberPersian(Number(event?.capacity ?? 0) - Number(event?.registration_count ?? 0))}
/
{formatNumberPersian(Number(event?.capacity ?? 0))} نفر
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">هزینهی ثبتنام</span>
<span className="font-medium">{labelPrice(event)}</span>
</div>
{isAvailable(event) ? (
<Button>جزئیات رویداد</Button>
) : (
<Button variant="secondary">{notAvailableReasonFa(event)}</Button>
)}
</div>
</CardContent>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -4,8 +4,8 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Helmet } from "react-helmet-async"; import { Helmet } from "@/lib/helmet";
import { Link } from "react-router-dom"; import { Link } from "@/lib/router";
const heroTitle = "انجمن علمی کامپیوتر دانشگاه گیلان"; const heroTitle = "انجمن علمی کامپیوتر دانشگاه گیلان";
const heroDescription = const heroDescription =

View File

@@ -1,5 +1,7 @@
"use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "@/lib/router";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";

View File

@@ -1,6 +1,8 @@
"use client";
import { useEffect, useMemo, useState, useRef } from 'react'; import { useEffect, useMemo, useState, useRef } from 'react';
import { Helmet } from 'react-helmet-async'; import { Helmet } from '@/lib/helmet';
import { useSearchParams, Link } from 'react-router-dom'; import { useSearchParams, Link } from '@/lib/router';
import QRCode from 'react-qr-code'; import QRCode from 'react-qr-code';
import jsPDF from 'jspdf'; import jsPDF from 'jspdf';
import html2canvas from 'html2canvas'; import html2canvas from 'html2canvas';
@@ -9,6 +11,7 @@ import { Button } from '@/components/ui/button';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatNumberPersian, formatToman, toPersianDigits } from '@/lib/utils'; import { formatNumberPersian, formatToman, toPersianDigits } from '@/lib/utils';
import Markdown from '@/components/Markdown'; import Markdown from '@/components/Markdown';
import { siteUrl } from '@/lib/site';
type SavedPayment = { type SavedPayment = {
event_id: number; event_id: number;
@@ -19,7 +22,7 @@ type SavedPayment = {
discount_amount?: number; discount_amount?: number;
amount?: number; amount?: number;
started_at?: string; started_at?: string;
success_markdown?: string; success_markdown?: string | null;
}; };
@@ -63,7 +66,6 @@ export default function PaymentResult() {
const receiptRef = useRef<HTMLDivElement | null>(null); const receiptRef = useRef<HTMLDivElement | null>(null);
const successMarkdown = data?.success_markdown ?? ''; const successMarkdown = data?.success_markdown ?? '';
const siteUrl = 'https://east-guilan-ce.ir';
const siteName = 'East Guilan CE'; const siteName = 'East Guilan CE';
const canonicalUrl = `${siteUrl}/payments/result`; const canonicalUrl = `${siteUrl}/payments/result`;
const toAbsoluteUrl = (url?: string | null) => { const toAbsoluteUrl = (url?: string | null) => {
@@ -112,13 +114,13 @@ export default function PaymentResult() {
const qrValue = useMemo(() => { const qrValue = useMemo(() => {
// لینک قابل بررسی/اشتراک‌گذاری // لینک قابل بررسی/اشتراک‌گذاری
const base = window.location.origin; const base = typeof window !== 'undefined' ? window.location.origin : siteUrl;
const url = new URL(`${base}/payments/result`); const url = new URL(`${base}/payments/result`);
if (refId) url.searchParams.set('ref_id', refId); if (refId) url.searchParams.set('ref_id', refId);
if (eventId) url.searchParams.set('event_id', String(eventId)); if (eventId) url.searchParams.set('event_id', String(eventId));
url.searchParams.set('status', ok ? 'success' : 'failed'); url.searchParams.set('status', ok ? 'success' : 'failed');
return url.toString(); return url.toString();
}, [refId, eventId, ok]); }, [eventId, ok, refId]);
const handleDownloadPdf = async () => { const handleDownloadPdf = async () => {
const el = receiptRef.current; const el = receiptRef.current;

View File

@@ -1,7 +1,9 @@
"use client";
import type * as Types from '@/lib/types'; import type * as Types from '@/lib/types';
import { useEffect, useRef, useState, useMemo } from 'react'; import { useEffect, useRef, useState, useMemo } from 'react';
import { Navigate, Link } from 'react-router-dom'; import { Navigate, Link } from '@/lib/router';
import { Helmet } from 'react-helmet-async'; import { Helmet } from '@/lib/helmet';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -191,7 +193,7 @@ export default function Profile() {
if (!me?.major) return '—'; if (!me?.major) return '—';
if (majors) { if (majors) {
const f = majors.find(m => m.code === me.major || m.label === me.major); const f = majors.find(m => m.code === me.major || m.label === me.major);
return f.label; return f?.label ?? me.major;
} }
return me.major; return me.major;
}, [majors, me?.major]); }, [majors, me?.major]);
@@ -210,8 +212,9 @@ export default function Profile() {
if (!me?.university) return '—'; if (!me?.university) return '—';
if (universities) { if (universities) {
const f = universities.find(u => u.code === me.university || u.label === me.university); const f = universities.find(u => u.code === me.university || u.label === me.university);
return f.label; return f?.label ?? me.university;
} }
return me.university;
}, [universities, me?.university]); }, [universities, me?.university]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);

View File

@@ -1,5 +1,7 @@
"use client";
import { useState } from 'react'; import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from '@/lib/router';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { resolveErrorMessage } from '@/lib/utils'; import { resolveErrorMessage } from '@/lib/utils';

View File

@@ -1,3 +1,5 @@
"use client";
import { useState } from 'react'; import { useState } from 'react';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { api } from '@/lib/api'; import { api } from '@/lib/api';

View File

@@ -1,5 +1,7 @@
"use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { useParams, Link } from "react-router-dom"; import { useParams, Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { import {

1
src/vite-env.d.ts vendored
View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,30 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitAny": false,
"noFallthroughCasesInSwitch": false,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -1,16 +1,42 @@
{ {
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
"compilerOptions": { "compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
"./src/*"
]
}, },
"noImplicitAny": false, "plugins": [
"noUnusedParameters": false, {
"skipLibCheck": true, "name": "next"
"allowJs": true,
"noUnusedLocals": false,
"strictNullChecks": false
} }
],
"strictNullChecks": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }

View File

@@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,18 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { componentTagger } from "lovable-tagger";
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
server: {
host: "::",
port: 8080,
},
plugins: [react(), mode === "development" && componentTagger()].filter(Boolean),
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
}));