migrate to Next.js
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
.next
|
||||
node_modules
|
||||
npm-debug.log
|
||||
dist
|
||||
@@ -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
1
.gitignore
vendored
@@ -8,6 +8,7 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
dist-ssr
|
||||
.env
|
||||
|
||||
46
README.md
46
README.md
@@ -1,43 +1,41 @@
|
||||
# Frontend
|
||||
|
||||
## Stack
|
||||
- Vite + React 18 with TypeScript.
|
||||
- `@tanstack/react-query` for data fetching and caching.
|
||||
- shadcn/ui primitives (button, card, tabs, dialog, etc.) with Tailwind CSS.
|
||||
- Sonner & Toast UI for notifications, Markdown rendering, RTL layout, and Persian-digit helpers.
|
||||
- Next.js App Router with React 18 and TypeScript.
|
||||
- `@tanstack/react-query` for client-side authenticated flows.
|
||||
- Tailwind CSS and shadcn/ui components.
|
||||
- `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
|
||||
|
||||
### Install dependencies
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Configure API base URL
|
||||
```bash
|
||||
cp .env.sample .env
|
||||
```
|
||||
The local dev server runs on `http://localhost:8080`.
|
||||
|
||||
### Run dev server
|
||||
```bash
|
||||
npm run dev -- --host
|
||||
```
|
||||
|
||||
### Production build
|
||||
## Production build
|
||||
```bash
|
||||
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
|
||||
- **Public site**: homepage, events list/detail, blog list, auth flows, profile, payments.
|
||||
- **Admin dashboard**: staff-only portal with vertical tabs, user filtering, event filtering, popup detail with registrations/payments, and inline event editing/deletion.
|
||||
- **Utils**: Persian digit formatting, price conversion (Rial → Toman), shared API client with JWT token refresh handling, and helper components (scroll area, table, dialog).
|
||||
## Routes
|
||||
- Public SEO pages: `/`, `/about`, `/blog`, `/blog/[slug]`, `/events`, `/events/[slug]`
|
||||
- Client-heavy flows: `/auth`, `/profile`, `/logout`, `/payments/result`, `/reset-password/*`, `/verify-email/*`
|
||||
- Admin: `/admin/*`
|
||||
|
||||
## Testing & linting
|
||||
## Validation
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
JavaScript/TypeScript linting is configured through ESLint + `typescript-eslint`. Run lint before commits to keep code healthy.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import js from "@eslint/js";
|
||||
import nextPlugin from "@next/eslint-plugin-next";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
{ ignores: ["dist", ".next", "next-env.d.ts"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
@@ -14,12 +14,12 @@ export default tseslint.config(
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"@next/next": nextPlugin,
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...nextPlugin.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
},
|
||||
},
|
||||
|
||||
22
index.html
22
index.html
@@ -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
6
next-env.d.ts
vendored
Normal 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
25
next.config.ts
Normal 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;
|
||||
33
nginx.conf
33
nginx.conf
@@ -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
2458
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -1,14 +1,13 @@
|
||||
{
|
||||
"name": "vite_react_shadcn_ts",
|
||||
"name": "guilan-ace-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"dev": "next dev --port 8080",
|
||||
"build": "next build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"start": "next start --port 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
@@ -45,17 +44,19 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"jspdf": "^2.5.1",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next": "^15.4.6",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-qr-code": "^2.0.11",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.4",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
@@ -64,28 +65,23 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.9",
|
||||
"zod": "^3.25.76",
|
||||
"react-qr-code": "^2.0.11",
|
||||
"jspdf": "^2.5.1",
|
||||
"html2canvas": "^1.4.1"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@next/eslint-plugin-next": "^16.2.6",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^18.3.26",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^15.15.0",
|
||||
"lovable-tagger": "^1.1.10",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^5.4.19"
|
||||
"typescript-eslint": "^8.38.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
User-agent: Googlebot
|
||||
Allow: /
|
||||
|
||||
User-agent: Bingbot
|
||||
Allow: /
|
||||
|
||||
User-agent: Twitterbot
|
||||
Allow: /
|
||||
|
||||
User-agent: facebookexternalhit
|
||||
Allow: /
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
72
src/App.tsx
72
src/App.tsx
@@ -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
65
src/app/about/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
src/app/admin/events/[id]/edit/page.tsx
Normal file
11
src/app/admin/events/[id]/edit/page.tsx
Normal 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 />;
|
||||
}
|
||||
11
src/app/admin/events/[id]/page.tsx
Normal file
11
src/app/admin/events/[id]/page.tsx
Normal 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 />;
|
||||
}
|
||||
11
src/app/admin/events/page.tsx
Normal file
11
src/app/admin/events/page.tsx
Normal 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
10
src/app/admin/layout.tsx
Normal 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
5
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect("/admin/users");
|
||||
}
|
||||
11
src/app/admin/users/page.tsx
Normal file
11
src/app/admin/users/page.tsx
Normal 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
11
src/app/auth/page.tsx
Normal 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 />;
|
||||
}
|
||||
156
src/app/blog/[slug]/page.tsx
Normal file
156
src/app/blog/[slug]/page.tsx
Normal 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
51
src/app/blog/page.tsx
Normal 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} />;
|
||||
}
|
||||
115
src/app/events/[slug]/page.tsx
Normal file
115
src/app/events/[slug]/page.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
src/app/events/[slug]/success/page.tsx
Normal file
11
src/app/events/[slug]/success/page.tsx
Normal 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
51
src/app/events/page.tsx
Normal 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
34
src/app/layout.tsx
Normal 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
11
src/app/logout/page.tsx
Normal 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
19
src/app/not-found.tsx
Normal 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
56
src/app/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
src/app/payments/result/page.tsx
Normal file
16
src/app/payments/result/page.tsx
Normal 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
11
src/app/profile/page.tsx
Normal 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 />;
|
||||
}
|
||||
11
src/app/reset-password/[token]/page.tsx
Normal file
11
src/app/reset-password/[token]/page.tsx
Normal 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 />;
|
||||
}
|
||||
11
src/app/reset-password/page.tsx
Normal file
11
src/app/reset-password/page.tsx
Normal 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
23
src/app/robots.ts
Normal 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
54
src/app/sitemap.ts
Normal 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;
|
||||
}
|
||||
11
src/app/verify-email/[token]/page.tsx
Normal file
11
src/app/verify-email/[token]/page.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -1,75 +1,55 @@
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
"use client";
|
||||
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Instagram, Send, Twitter, Linkedin } from "lucide-react";
|
||||
import { api } from "@/lib/api"; // متد subscribeNewsletter را پایین توضیح دادهام
|
||||
import { Instagram, Linkedin, Send, Twitter } from "lucide-react";
|
||||
|
||||
export default function Footer() {
|
||||
// const { toast } = useToast();
|
||||
// const [email, setEmail] = React.useState("");
|
||||
// const [loading, setLoading] = React.useState(false);
|
||||
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 (
|
||||
<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="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
{/* برند + درباره + اینماد */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/favicon.ico" alt="لوگوی انجمن" className="h-9 w-9 rounded" />
|
||||
<span className="text-xl font-bold">انجمن علمی کامپیوتر گیلان</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-7">
|
||||
ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکهٔ دانشجویی و پیوند با صنعت.
|
||||
ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکهی دانشجویی و پیوند با صنعت.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
{/* لینکهای سریع */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-base font-semibold">لینکهای مفید</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><Link to="/" className="text-muted-foreground hover:text-foreground">خانه</Link></li>
|
||||
<li><Link to="/events" className="text-muted-foreground hover:text-foreground">رویدادها</Link></li>
|
||||
<li><Link to="/blog" className="text-muted-foreground hover:text-foreground">بلاگ</Link></li>
|
||||
<li><Link to="/about" className="text-muted-foreground hover:text-foreground">دربارهٔ انجمن</Link></li>
|
||||
{/* <li><Link to="/contact" className="text-muted-foreground hover:text-foreground">تماس با ما</Link></li> */}
|
||||
{/* <li><Link to="/rules" className="text-muted-foreground hover:text-foreground">قوانین و حریم خصوصی</Link></li> */}
|
||||
<li>
|
||||
<Link to="/" className="text-muted-foreground hover:text-foreground">
|
||||
خانه
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/events" className="text-muted-foreground hover:text-foreground">
|
||||
رویدادها
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/blog" className="text-muted-foreground hover:text-foreground">
|
||||
بلاگ
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/about" className="text-muted-foreground hover:text-foreground">
|
||||
دربارهی انجمن
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* اطلاعات تماس / شبکههای اجتماعی */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-base font-semibold">ارتباط با ما</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
@@ -78,53 +58,49 @@ export default function Footer() {
|
||||
</ul>
|
||||
|
||||
<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="اینستاگرام">
|
||||
<Instagram className="h-4 w-4" />
|
||||
</Button>
|
||||
</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="تلگرام">
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</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="لینکدین">
|
||||
<Linkedin className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
<a href="https://x.com" target="_blank" rel="noreferrer" className="inline-flex">
|
||||
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="ایکس (توییتر)">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
aria-label="ایکس (توییتر)"
|
||||
>
|
||||
<Twitter className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
</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">
|
||||
<a
|
||||
href="https://trustseal.enamad.ir/?id=649977&Code=m0wWM1DFYqd4fLEnjyMU3o2pupfuqDVW"
|
||||
@@ -134,7 +110,7 @@ export default function Footer() {
|
||||
>
|
||||
<img
|
||||
src="/enamad.png"
|
||||
width="125px"
|
||||
width="125"
|
||||
alt="نماد اعتماد الکترونیکی"
|
||||
referrerPolicy="origin"
|
||||
style={{ cursor: "pointer" }}
|
||||
@@ -144,12 +120,10 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* خط جداکننده */}
|
||||
<div className="my-8 h-px w-full bg-border" />
|
||||
|
||||
{/* کپیرایت */}
|
||||
<div className="flex gap-2 items-center justify-center text-sm text-muted-foreground md:flex-row">
|
||||
<div>© {year} انجمن علمی کامپیوتر گیلان — تمامی حقوق محفوظ است.</div>
|
||||
<div>© {year} انجمن علمی کامپیوتر گیلان - تمامی حقوق محفوظ است.</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -63,7 +65,7 @@ export default function Navbar() {
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56" dir="rtl">
|
||||
<DropdownMenuContent align="end" className="w-56 text-right">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
{user?.first_name || user?.last_name ? `${user?.first_name || ''} ${user?.last_name || ''}`.trim() : user?.username}
|
||||
</DropdownMenuLabel>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,56 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from 'react';
|
||||
"use client";
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
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;
|
||||
}
|
||||
export { ThemeProvider, useTheme } from "next-themes";
|
||||
|
||||
32
src/components/providers.tsx
Normal file
32
src/components/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { VariantProps, cva } from "class-variance-authority";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, toast } from "sonner";
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react";
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import type { UserProfileSchema } from '@/lib/types';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type * as Types from './types';
|
||||
import { apiBaseUrl } from '@/lib/site';
|
||||
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '') || 'https://api.east-guilan-ce.ir';
|
||||
apiBaseUrl;
|
||||
|
||||
type ApiErrorBody = {
|
||||
error?: string;
|
||||
@@ -18,8 +19,32 @@ class ApiClient {
|
||||
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 {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const token = this.getStorageValue('access_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
@@ -27,7 +52,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
private async refreshAccessToken(): Promise<string> {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
const refreshToken = this.getStorageValue('refresh_token');
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
@@ -39,14 +64,14 @@ class ApiClient {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
this.removeStorageValue('access_token');
|
||||
this.removeStorageValue('refresh_token');
|
||||
throw new Error('Session expired. Please login again.');
|
||||
}
|
||||
|
||||
const data: Types.TokenSchema = await response.json();
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
this.setStorageValue('access_token', data.access_token);
|
||||
this.setStorageValue('refresh_token', data.refresh_token);
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
@@ -75,7 +100,7 @@ class ApiClient {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
// 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) {
|
||||
this.isRefreshing = true;
|
||||
try {
|
||||
@@ -184,7 +209,6 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async getProfile() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
return this.request<Types.UserProfileSchema>('/api/auth/profile'
|
||||
);
|
||||
}
|
||||
@@ -200,7 +224,7 @@ class ApiClient {
|
||||
const formData = new FormData();
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -469,7 +493,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
@@ -614,7 +638,7 @@ class ApiClient {
|
||||
if (data.description) formData.append('description', data.description);
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
9
src/lib/helmet.tsx
Normal file
9
src/lib/helmet.tsx
Normal 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
103
src/lib/public-api.ts
Normal 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
143
src/lib/router.tsx
Normal 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
17
src/lib/site.ts
Normal 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}`;
|
||||
};
|
||||
@@ -49,6 +49,8 @@ export interface UserListSchema {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
full_name?: string | null;
|
||||
university?: string | null;
|
||||
major?: string | null;
|
||||
is_active: boolean;
|
||||
is_staff: boolean;
|
||||
is_superuser: boolean;
|
||||
@@ -61,10 +63,10 @@ export interface UserRegistrationSchema {
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
student_id: string;
|
||||
year_of_study: number;
|
||||
major: string;
|
||||
university: string;
|
||||
student_id?: string | null;
|
||||
year_of_study?: number | null;
|
||||
major?: string | null;
|
||||
university?: string | null;
|
||||
}
|
||||
|
||||
export type UserUpdateSchema = {
|
||||
@@ -74,7 +76,7 @@ export type UserUpdateSchema = {
|
||||
year_of_study?: number | null;
|
||||
major?: string | null;
|
||||
university?: string | null;
|
||||
student_id?: number | null;
|
||||
student_id?: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -107,6 +109,7 @@ export interface PostListSchema {
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
featured_image?: string;
|
||||
absolute_featured_image_url?: string | null;
|
||||
author: {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -134,6 +137,7 @@ export interface PostListSchema {
|
||||
|
||||
export interface PostDetailSchema extends PostListSchema {
|
||||
content: string;
|
||||
content_html?: string;
|
||||
updated_at: string;
|
||||
views_count?: number;
|
||||
}
|
||||
@@ -205,6 +209,8 @@ export interface EventListItemSchema {
|
||||
capacity?: number | null;
|
||||
price?: number | null;
|
||||
status: 'draft' | 'published' | 'cancelled' | 'completed';
|
||||
status_label?: string;
|
||||
event_type_label?: string;
|
||||
registration_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
e.absolute_featured_image_url ||
|
||||
e.featured_image ||
|
||||
|
||||
11
src/main.tsx
11
src/main.tsx
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Helmet } from '@/lib/helmet';
|
||||
import { Link } from '@/lib/router';
|
||||
|
||||
// ------ دادههای قابل تنظیم ------
|
||||
const SITE_URL = 'https://east-guilan-ce.ir';
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { api } from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { useAuth } from '@/contexts/AuthContext';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -42,6 +44,7 @@ export default function AdminEventEdit() {
|
||||
const navigate = useNavigate();
|
||||
const eventId = Number(id);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ['admin', 'edit-event', eventId],
|
||||
@@ -51,8 +54,8 @@ export default function AdminEventEdit() {
|
||||
|
||||
const [formData, setFormData] = React.useState({
|
||||
title: '',
|
||||
status: 'draft',
|
||||
event_type: 'online',
|
||||
status: 'draft' as NonNullable<EventUpdateSchema['status']>,
|
||||
event_type: 'online' as NonNullable<EventUpdateSchema['event_type']>,
|
||||
price: '',
|
||||
capacity: '',
|
||||
start_time: '',
|
||||
@@ -147,7 +150,7 @@ export default function AdminEventEdit() {
|
||||
event_type: formData.event_type,
|
||||
price: formData.price ? Number(formData.price) * 10 : 0,
|
||||
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,
|
||||
registration_start_date: formData.registration_start_date || null,
|
||||
registration_end_date: formData.registration_end_date || null,
|
||||
@@ -167,7 +170,12 @@ export default function AdminEventEdit() {
|
||||
/>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) => setFormData((p) => ({ ...p, status: value }))}
|
||||
onValueChange={(value) =>
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
status: value as NonNullable<EventUpdateSchema['status']>,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="وضعیت" />
|
||||
@@ -182,7 +190,12 @@ export default function AdminEventEdit() {
|
||||
</Select>
|
||||
<Select
|
||||
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>
|
||||
<SelectValue placeholder="نوع رویداد" />
|
||||
@@ -255,7 +268,7 @@ export default function AdminEventEdit() {
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
|
||||
بازگشت
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMutation.isLoading}>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
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 { api } from '@/lib/api';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -45,17 +47,23 @@ const AdminEventsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [filters, setFilters] = React.useState({
|
||||
search: '',
|
||||
status: 'all',
|
||||
type: 'all',
|
||||
sort: 'newest',
|
||||
status: 'all' as 'all' | EventListItemSchema['status'],
|
||||
type: 'all' as 'all' | EventListItemSchema['event_type'],
|
||||
sort: 'newest' as (typeof eventSortOptions)[number]['value'],
|
||||
});
|
||||
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ['admin', 'events', filters],
|
||||
queryFn: () =>
|
||||
api.getEvents({
|
||||
statuses: filters.status === 'all' ? undefined : [filters.status],
|
||||
event_type: filters.type === 'all' ? undefined : filters.type,
|
||||
statuses:
|
||||
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,
|
||||
limit: EVENTS_PAGE_SIZE,
|
||||
}),
|
||||
@@ -111,7 +119,15 @@ const AdminEventsPage: React.FC = () => {
|
||||
value={filters.search}
|
||||
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>
|
||||
<SelectValue>
|
||||
{eventStatusOptions.find((option) => option.value === filters.status)?.label ||
|
||||
@@ -126,7 +142,15 @@ const AdminEventsPage: React.FC = () => {
|
||||
))}
|
||||
</SelectContent>
|
||||
</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>
|
||||
<SelectValue>
|
||||
{{
|
||||
@@ -144,7 +168,15 @@ const AdminEventsPage: React.FC = () => {
|
||||
<SelectItem value="hybrid">ترکیبی</SelectItem>
|
||||
</SelectContent>
|
||||
</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>
|
||||
<SelectValue>
|
||||
{eventSortOptions.find((option) => option.value === filters.sort)?.label ||
|
||||
@@ -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 { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
@@ -7,7 +10,7 @@ const navItems = [
|
||||
{ to: '/admin/events', label: 'مدیریت رویدادها' },
|
||||
] as const;
|
||||
|
||||
export default function AdminLayout() {
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const isAdmin = useMemo(
|
||||
@@ -40,7 +43,7 @@ export default function AdminLayout() {
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'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-card text-muted-foreground hover:text-foreground border',
|
||||
].join(' ')
|
||||
@@ -53,7 +56,7 @@ export default function AdminLayout() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<Outlet />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
useQuery,
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Helmet } from '@/lib/helmet';
|
||||
import { Link, useNavigate } from '@/lib/router';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -1,36 +1,42 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
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';
|
||||
"use client";
|
||||
|
||||
interface Post {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
author: {
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
};
|
||||
created_at: string;
|
||||
category?: {
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type * as Types from "@/lib/types";
|
||||
|
||||
export default function Blog() {
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
type BlogProps = {
|
||||
initialPosts?: Types.PostListSchema[];
|
||||
initialSearch?: string;
|
||||
};
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.getPosts({ search: search || undefined });
|
||||
setPosts(data as Post[]);
|
||||
setPosts(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading posts:', error);
|
||||
console.error("Error loading posts:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -40,6 +46,18 @@ export default function Blog() {
|
||||
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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
@@ -70,7 +88,7 @@ export default function Blog() {
|
||||
{post.category?.name && (
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -1,43 +1,86 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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';
|
||||
"use client";
|
||||
|
||||
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 { isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [event, setEvent] = useState<Types.EventDetailSchema | null>(null);
|
||||
const [eventThumb, setEventThumb] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [event, setEvent] = useState<Types.EventDetailSchema | null>(initialEvent);
|
||||
const [eventThumb, setEventThumb] = useState<string | null>(
|
||||
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 isFree = useMemo(() => basePrice <= 0, [basePrice]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const siteUrl = 'https://east-guilan-ce.ir';
|
||||
const siteName = 'انجمن علمی کامپیوتر شرق گیلان';
|
||||
const siteName = "انجمن علمی کامپیوتر شرق گیلان";
|
||||
const defaultDescription =
|
||||
'جزئیات کامل رویدادهای انجمن علمی کامپیوتر شرق گیلان شامل زمان، مکان و شرایط ثبتنام.';
|
||||
"جزئیات کامل رویدادهای انجمن علمی کامپیوتر شرق گیلان شامل زمان، مکان و شرایط ثبتنام.";
|
||||
|
||||
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;
|
||||
const toAbsoluteUrl = (value?: string | null) => {
|
||||
if (!value) return undefined;
|
||||
if (value.startsWith("http")) return value;
|
||||
const normalizedSite = siteUrl.endsWith("/") ? siteUrl.slice(0, -1) : siteUrl;
|
||||
const normalizedPath = value.startsWith("/") ? value.slice(1) : value;
|
||||
return `${normalizedSite}/${normalizedPath}`;
|
||||
};
|
||||
|
||||
@@ -55,185 +98,215 @@ export default function EventDetail() {
|
||||
: `${siteUrl}/favicon.ico`;
|
||||
const pageTitle = event ? `${event.title} | ${siteName}` : `جزئیات رویداد | ${siteName}`;
|
||||
const pageDescription = sanitizeDescription(event?.description);
|
||||
const pageRobots = event?.status === 'draft' ? 'noindex, nofollow' : 'index, follow';
|
||||
|
||||
const [alreadyRegistered, setAlreadyRegistered] = useState(false);
|
||||
const pageRobots = event?.status === "draft" ? "noindex, nofollow" : "index, follow";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function check() {
|
||||
if (isAuthenticated && event?.id) {
|
||||
try {
|
||||
const res = await api.getRegistrationStatus(event.id);
|
||||
if (!cancelled) setAlreadyRegistered(res.is_registered);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
async function checkRegistration() {
|
||||
if (!isAuthenticated || !event?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.getRegistrationStatus(event.id);
|
||||
if (!cancelled) {
|
||||
setAlreadyRegistered(res.is_registered);
|
||||
}
|
||||
} catch {
|
||||
// Ignore registration status failures on the detail page.
|
||||
}
|
||||
}
|
||||
check();
|
||||
return () => { cancelled = true; };
|
||||
}, [isAuthenticated, event?.id]);
|
||||
|
||||
checkRegistration();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [event?.id, isAuthenticated]);
|
||||
|
||||
const goSuccess = (registrationId?: string) => {
|
||||
const q = registrationId ? `?registration_id=${registrationId}` : '';
|
||||
if (!event) return;
|
||||
const query = registrationId ? `?registration_id=${registrationId}` : "";
|
||||
setAlreadyRegistered(true);
|
||||
|
||||
toast({ title: 'ثبتنام با موفقیت انجام شد!', variant: 'success' });
|
||||
navigate(`/events/${event!.slug}/success${q}`);
|
||||
toast({ title: "ثبتنام با موفقیت انجام شد!", variant: "success" });
|
||||
navigate(`/events/${event.slug}/success${query}`);
|
||||
};
|
||||
|
||||
const handleMainCTA = async () => {
|
||||
if (!event) return;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
toast({ title: 'ابتدا وارد شوید', description: 'برای ثبتنام در رویداد باید وارد حساب کاربری خود شوید.', variant: 'destructive' });
|
||||
navigate('/auth');
|
||||
toast({
|
||||
title: "ابتدا وارد شوید",
|
||||
description: "برای ثبتنام در رویداد باید وارد حساب کاربری خود شوید.",
|
||||
variant: "destructive",
|
||||
});
|
||||
navigate("/auth");
|
||||
return;
|
||||
}
|
||||
if (isFree) {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await api.registerForEvent(event.id);
|
||||
goSuccess(res.ticket_id);
|
||||
} catch (error: unknown) {
|
||||
const msg = resolveErrorMessage(error, '');
|
||||
if (msg.includes('already registered') || msg.includes('ثبت‌نام')) {
|
||||
setAlreadyRegistered(true);
|
||||
toast({ title: 'شما قبلاً ثبتنام کردهاید', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinueFromModal = async (coupon?: string, finalAmount?: number) => {
|
||||
if (!event) return;
|
||||
if (!isAuthenticated) {
|
||||
toast({ title: 'ابتدا وارد شوید', description: 'برای ثبتنام در رویداد باید وارد حساب کاربری خود شوید.', variant: 'destructive' });
|
||||
navigate('/auth');
|
||||
if (!isFree) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await api.registerForEvent(event.id);
|
||||
goSuccess(res.ticket_id);
|
||||
} catch (error: unknown) {
|
||||
const msg = resolveErrorMessage(error, "");
|
||||
if (msg.includes("already registered")) {
|
||||
setAlreadyRegistered(true);
|
||||
toast({ title: "شما قبلاً ثبتنام کردهاید", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "خطا در ثبتنام",
|
||||
description: msg || "لطفاً دوباره تلاش کنید.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinueFromModal = async (coupon?: string, finalAmount?: number) => {
|
||||
if (!event) return;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
toast({
|
||||
title: "ابتدا وارد شوید",
|
||||
description: "برای ثبتنام در رویداد باید وارد حساب کاربری خود شوید.",
|
||||
variant: "destructive",
|
||||
});
|
||||
navigate("/auth");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const reg = await api.registerForEvent(event.id, coupon);
|
||||
|
||||
if (finalAmount === 0) {
|
||||
sessionStorage.setItem('payment:last', JSON.stringify({
|
||||
event_id: event.id,
|
||||
slug: event.slug,
|
||||
title: event.title,
|
||||
thumb: eventThumb,
|
||||
base_amount: Number(event.price ?? 0),
|
||||
discount_amount: Number(event.price ?? 0),
|
||||
amount: 0,
|
||||
started_at: new Date().toISOString(),
|
||||
success_markdown: event.registration_success_markdown,
|
||||
|
||||
}));
|
||||
api.ChangeRegistrationStatus(reg.id, 'confirmed')
|
||||
goSuccess(reg?.ticket_id);
|
||||
window.sessionStorage.setItem(
|
||||
"payment:last",
|
||||
buildPaymentSnapshot(event, eventThumb, {
|
||||
baseAmount: Number(event.price ?? 0),
|
||||
discountAmount: Number(event.price ?? 0),
|
||||
amount: 0,
|
||||
}),
|
||||
);
|
||||
await api.ChangeRegistrationStatus(reg.id, "confirmed");
|
||||
goSuccess(reg.ticket_id);
|
||||
return;
|
||||
}
|
||||
|
||||
const description = `پرداخت رویداد: ${event.title}`;
|
||||
const result = await api.createPayment({
|
||||
event_id: event.id,
|
||||
description,
|
||||
discount_code: (coupon ?? '').trim() || null,
|
||||
description: `پرداخت رویداد: ${event.title}`,
|
||||
discount_code: (coupon ?? "").trim() || null,
|
||||
});
|
||||
|
||||
|
||||
if (!result?.start_pay_url || Number(result.amount) === 0) {
|
||||
sessionStorage.setItem('payment:last', JSON.stringify({
|
||||
event_id: event.id,
|
||||
slug: event.slug,
|
||||
title: event.title,
|
||||
thumb: eventThumb,
|
||||
base_amount: result.base_amount,
|
||||
discount_amount: result.discount_amount ?? result.base_amount,
|
||||
amount: 0,
|
||||
started_at: new Date().toISOString(),
|
||||
success_markdown: event.registration_success_markdown,
|
||||
}));
|
||||
goSuccess(reg?.ticket_id);
|
||||
window.sessionStorage.setItem(
|
||||
"payment:last",
|
||||
buildPaymentSnapshot(event, eventThumb, {
|
||||
baseAmount: result.base_amount,
|
||||
discountAmount: result.discount_amount ?? result.base_amount,
|
||||
amount: 0,
|
||||
}),
|
||||
);
|
||||
goSuccess(reg.ticket_id);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
sessionStorage.setItem('payment:last', JSON.stringify({
|
||||
event_id: event.id,
|
||||
slug: event.slug,
|
||||
title: event.title,
|
||||
thumb: eventThumb,
|
||||
base_amount: result.base_amount,
|
||||
discount_amount: result.discount_amount,
|
||||
amount: result.amount,
|
||||
started_at: new Date().toISOString(),
|
||||
success_markdown: event.registration_success_markdown,
|
||||
}));
|
||||
window.sessionStorage.setItem(
|
||||
"payment:last",
|
||||
buildPaymentSnapshot(event, eventThumb, {
|
||||
baseAmount: result.base_amount,
|
||||
discountAmount: result.discount_amount,
|
||||
amount: result.amount,
|
||||
}),
|
||||
);
|
||||
window.location.href = result.start_pay_url;
|
||||
|
||||
} catch (error: unknown) {
|
||||
|
||||
const msg = resolveErrorMessage(error, '');
|
||||
if (msg.includes('already registered') || msg.includes('ثبت‌نام')) {
|
||||
const msg = resolveErrorMessage(error, "");
|
||||
if (msg.includes("already registered")) {
|
||||
setAlreadyRegistered(true);
|
||||
toast({ title: 'شما قبلاً ثبتنام کردهاید', variant: 'destructive' });
|
||||
toast({ title: "شما قبلاً ثبتنام کردهاید", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({ title: 'خطا در پردازش پرداخت', description: msg || 'لطفاً دوباره تلاش کنید.', variant: 'destructive' });
|
||||
toast({
|
||||
title: "خطا در پردازش پرداخت",
|
||||
description: msg || "لطفاً دوباره تلاش کنید.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadEvent() {
|
||||
try {
|
||||
if (!slug) return;
|
||||
|
||||
if (initialEvent && initialEvent.slug === slug) {
|
||||
setEvent(initialEvent);
|
||||
setEventThumb(getThumbUrl(initialEvent));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await api.getEventBySlug(slug);
|
||||
if (cancelled) return;
|
||||
setEvent(data);
|
||||
setEventThumb(getThumbUrl(data));
|
||||
} catch (error: unknown) {
|
||||
if (cancelled) return;
|
||||
toast({
|
||||
title: 'خطا در بارگذاری رویداد',
|
||||
description: resolveErrorMessage(error, 'لطفاً دوباره تلاش کنید.'),
|
||||
variant: 'destructive',
|
||||
title: "خطا در بارگذاری رویداد",
|
||||
description: resolveErrorMessage(error, "لطفاً دوباره تلاش کنید."),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [slug, toast]);
|
||||
}
|
||||
|
||||
loadEvent();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [initialEvent, slug, toast]);
|
||||
|
||||
const [nowTs, setNowTs] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNowTs(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
const timer = window.setInterval(() => setNowTs(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const rsTs = useMemo<number | null>(() => (
|
||||
event?.registration_start_date ? new Date(event.registration_start_date).getTime() : null
|
||||
), [event?.registration_start_date]);
|
||||
const rsTs = useMemo<number | null>(
|
||||
() => (event?.registration_start_date ? new Date(event.registration_start_date).getTime() : null),
|
||||
[event?.registration_start_date],
|
||||
);
|
||||
|
||||
const deadlineTs = useMemo<number | null>(() => (
|
||||
event?.registration_end_date ? new Date(event.registration_end_date).getTime() : null
|
||||
), [event?.registration_end_date]);
|
||||
const deadlineTs = useMemo<number | null>(
|
||||
() => (event?.registration_end_date ? new Date(event.registration_end_date).getTime() : null),
|
||||
[event?.registration_end_date],
|
||||
);
|
||||
|
||||
const remainingMs = useMemo<number | null>(() => (
|
||||
deadlineTs != null ? Math.max(0, deadlineTs - nowTs) : null
|
||||
), [deadlineTs, nowTs]);
|
||||
const remainingMs = useMemo<number | null>(
|
||||
() => (deadlineTs != null ? Math.max(0, deadlineTs - nowTs) : null),
|
||||
[deadlineTs, nowTs],
|
||||
);
|
||||
|
||||
const formatCountdownTwoDigit = (value: number) =>
|
||||
toPersianDigits(value.toString().padStart(2, '0'));
|
||||
toPersianDigits(value.toString().padStart(2, "0"));
|
||||
const formatCountdownNumber = (value: number) => formatNumberPersian(value);
|
||||
|
||||
const formatRemainingWords = (ms: number) => {
|
||||
@@ -242,39 +315,45 @@ export default function EventDetail() {
|
||||
const hours = Math.floor((total % 86400) / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 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)} ثانیه`;
|
||||
};
|
||||
|
||||
const meta = useMemo(() => {
|
||||
if (!event) return null;
|
||||
const rs = rsTs;
|
||||
const re = deadlineTs;
|
||||
const registrationOpen = (rs == null || nowTs >= rs) && (re == null || nowTs <= re);
|
||||
const registrationOpen =
|
||||
(rsTs == null || nowTs >= rsTs) && (deadlineTs == null || nowTs <= deadlineTs);
|
||||
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;
|
||||
return { registrationOpen, remaining, full };
|
||||
}, [event, rsTs, deadlineTs, nowTs]);
|
||||
}, [deadlineTs, event, nowTs, rsTs]);
|
||||
|
||||
const eventStructuredData = useMemo(() => {
|
||||
if (!event) return null;
|
||||
|
||||
const attendanceModeMap: Record<string, string> = {
|
||||
online: 'https://schema.org/OnlineEventAttendanceMode',
|
||||
on_site: 'https://schema.org/OfflineEventAttendanceMode',
|
||||
hybrid: 'https://schema.org/MixedEventAttendanceMode',
|
||||
online: "https://schema.org/OnlineEventAttendanceMode",
|
||||
on_site: "https://schema.org/OfflineEventAttendanceMode",
|
||||
hybrid: "https://schema.org/MixedEventAttendanceMode",
|
||||
};
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
published: 'https://schema.org/EventScheduled',
|
||||
completed: 'https://schema.org/EventCompleted',
|
||||
cancelled: 'https://schema.org/EventCancelled',
|
||||
draft: 'https://schema.org/EventPostponed',
|
||||
published: "https://schema.org/EventScheduled",
|
||||
completed: "https://schema.org/EventCompleted",
|
||||
cancelled: "https://schema.org/EventCancelled",
|
||||
draft: "https://schema.org/EventPostponed",
|
||||
};
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Event',
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Event",
|
||||
name: event.title,
|
||||
description: pageDescription,
|
||||
startDate: event.start_time,
|
||||
@@ -282,7 +361,7 @@ export default function EventDetail() {
|
||||
eventAttendanceMode: attendanceModeMap[event.event_type] ?? attendanceModeMap.hybrid,
|
||||
eventStatus: statusMap[event.status] ?? statusMap.published,
|
||||
organizer: {
|
||||
'@type': 'Organization',
|
||||
"@type": "Organization",
|
||||
name: siteName,
|
||||
url: siteUrl,
|
||||
},
|
||||
@@ -296,14 +375,14 @@ export default function EventDetail() {
|
||||
data.image = [primaryImage];
|
||||
}
|
||||
|
||||
if (event.event_type === 'online') {
|
||||
if (event.event_type === "online") {
|
||||
data.location = {
|
||||
'@type': 'VirtualLocation',
|
||||
"@type": "VirtualLocation",
|
||||
url: event.online_link || canonicalUrl,
|
||||
};
|
||||
} else {
|
||||
const location: Record<string, unknown> = {
|
||||
'@type': 'Place',
|
||||
"@type": "Place",
|
||||
name: event.location || event.address || siteName,
|
||||
};
|
||||
if (event.address) {
|
||||
@@ -316,11 +395,11 @@ export default function EventDetail() {
|
||||
}
|
||||
|
||||
const offers: Record<string, unknown> = {
|
||||
'@type': 'Offer',
|
||||
"@type": "Offer",
|
||||
url: canonicalUrl,
|
||||
priceCurrency: 'IRR',
|
||||
priceCurrency: "IRR",
|
||||
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) {
|
||||
@@ -331,9 +410,8 @@ export default function EventDetail() {
|
||||
}
|
||||
|
||||
data.offers = offers;
|
||||
|
||||
return data;
|
||||
}, [event, pageDescription, canonicalUrl, primaryImage, meta?.full, siteName, siteUrl]);
|
||||
}, [canonicalUrl, event, meta?.full, pageDescription, primaryImage, siteName]);
|
||||
|
||||
const helmet = (
|
||||
<Helmet>
|
||||
@@ -370,23 +448,26 @@ export default function EventDetail() {
|
||||
|
||||
if (loading) {
|
||||
return withHelmet(
|
||||
<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>
|
||||
<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>,
|
||||
);
|
||||
}
|
||||
|
||||
const beforeStart = rsTs != null && nowTs < rsTs;
|
||||
const ended = deadlineTs !== null && remainingMs === 0;
|
||||
const showCountdown = !beforeStart && deadlineTs !== null && remainingMs! > 0;
|
||||
const showCountdown = !beforeStart && deadlineTs !== null && (remainingMs ?? 0) > 0;
|
||||
|
||||
return withHelmet(
|
||||
<div className="container mx-auto px-4 py-8" dir="rtl">
|
||||
{/* --- نوار اطلاع/تایمر زیر نوار ناوبری با رنگ‌های مناسب Light/Dark --- */}
|
||||
{beforeStart && (
|
||||
<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">
|
||||
@@ -395,13 +476,13 @@ export default function EventDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCountdown && (
|
||||
{showCountdown && remainingMs != null && (
|
||||
<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="flex flex-col items-center gap-1 sm:flex-row sm:justify-center">
|
||||
<span>زمان باقیمانده تا پایان ثبتنام:</span>
|
||||
<strong className="font-extrabold tracking-wider sm:ms-1">
|
||||
{formatRemainingWords(remainingMs!)}
|
||||
{formatRemainingWords(remainingMs)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -417,7 +498,6 @@ export default function EventDetail() {
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Ù…ØØªÙˆØ§ */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="w-full aspect-video overflow-hidden rounded-lg">
|
||||
@@ -449,16 +529,15 @@ export default function EventDetail() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* گالری */}
|
||||
{event.gallery_images?.length ? (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold mb-3">گالری تصاویر</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{event.gallery_images.map((g) => (
|
||||
{event.gallery_images.map((image) => (
|
||||
<img
|
||||
key={g.id}
|
||||
src={g.absolute_image_url || ''}
|
||||
alt={g.title || ''}
|
||||
key={image.id}
|
||||
src={image.absolute_image_url || ""}
|
||||
alt={image.title || ""}
|
||||
className="w-full h-36 object-cover rounded-md"
|
||||
/>
|
||||
))}
|
||||
@@ -467,7 +546,6 @@ export default function EventDetail() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* سایدبار اطلاعات */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="lg:sticky lg:top-24">
|
||||
<Card>
|
||||
@@ -477,20 +555,18 @@ export default function EventDetail() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{event.address && <div>آدرس: {event.address}</div>}
|
||||
|
||||
<div>ظرفیت کل: {event.capacity == null ? 'نامحدود' : formatNumberPersian(event.capacity)}</div>
|
||||
{meta && (
|
||||
<>
|
||||
{!event.capacity ? null : (
|
||||
<div>
|
||||
ظرفیت باقیمانده: {meta.remaining === Infinity ? 'نامحدود' : formatNumberPersian(meta.remaining)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<div>
|
||||
ظرفیت کل: {event.capacity == null ? "نامحدود" : formatNumberPersian(event.capacity)}
|
||||
</div>
|
||||
{meta && event.capacity != null && (
|
||||
<div>
|
||||
ظرفیت باقیمانده:{" "}
|
||||
{meta.remaining === Number.POSITIVE_INFINITY
|
||||
? "نامحدود"
|
||||
: formatNumberPersian(meta.remaining)}
|
||||
</div>
|
||||
)}
|
||||
<div>هزینه حضور: {event.price ? formatToman(event.price) : 'رایگان'}</div>
|
||||
|
||||
{/* نمایش زمان شروع/پایان ثبت‌نام در UI ØØ°Ù شده */}
|
||||
<div>هزینه حضور: {event.price ? formatToman(event.price) : "رایگان"}</div>
|
||||
|
||||
<Button
|
||||
onClick={handleMainCTA}
|
||||
@@ -498,25 +574,24 @@ export default function EventDetail() {
|
||||
disabled={
|
||||
submitting ||
|
||||
alreadyRegistered ||
|
||||
event.status !== 'published' ||
|
||||
event.status !== "published" ||
|
||||
meta?.full === true ||
|
||||
!meta?.registrationOpen
|
||||
}
|
||||
>
|
||||
{event.status !== 'published'
|
||||
? 'ثبتنام این رویداد فعال نیست'
|
||||
{event.status !== "published"
|
||||
? "ثبتنام این رویداد فعال نیست"
|
||||
: alreadyRegistered
|
||||
? 'شما قبلاً ثبتنام کردهاید'
|
||||
? "شما قبلاً ثبتنام کردهاید"
|
||||
: !meta?.registrationOpen
|
||||
? 'ثبتنام هنوز آغاز نشده است'
|
||||
? "ثبتنام هنوز آغاز نشده است"
|
||||
: meta?.full
|
||||
? 'ظرفیت ثبتنام تکمیل شده است'
|
||||
? "ظرفیت ثبتنام تکمیل شده است"
|
||||
: submitting
|
||||
? 'در ØØ§Ù„ ثبت‌نام...'
|
||||
? "در حال ثبتنام..."
|
||||
: event.price === 0
|
||||
? 'ثبتنام (رایگان)'
|
||||
: 'ثبتنام و ادامه پرداخت'
|
||||
}
|
||||
? "ثبتنام (رایگان)"
|
||||
: "ثبتنام و ادامه پرداخت"}
|
||||
</Button>
|
||||
|
||||
{!isFree && (
|
||||
@@ -536,11 +611,3 @@ export default function EventDetail() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import PaymentResult from "@/components/PaymentResult";
|
||||
import { formatJalali } from "@/lib/utils";
|
||||
import Markdown from '@/components/Markdown';
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { Helmet } from "@/lib/helmet";
|
||||
|
||||
export default function EventFreeSuccessPage() {
|
||||
const { slug } = useParams();
|
||||
262
src/views/Events.tsx
Normal file
262
src/views/Events.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Helmet } from "@/lib/helmet";
|
||||
import { Link } from "@/lib/router";
|
||||
|
||||
const heroTitle = "انجمن علمی کامپیوتر دانشگاه گیلان";
|
||||
const heroDescription =
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { useSearchParams, Link } from 'react-router-dom';
|
||||
import { Helmet } from '@/lib/helmet';
|
||||
import { useSearchParams, Link } from '@/lib/router';
|
||||
import QRCode from 'react-qr-code';
|
||||
import jsPDF from 'jspdf';
|
||||
import html2canvas from 'html2canvas';
|
||||
@@ -9,6 +11,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatNumberPersian, formatToman, toPersianDigits } from '@/lib/utils';
|
||||
import Markdown from '@/components/Markdown';
|
||||
import { siteUrl } from '@/lib/site';
|
||||
|
||||
type SavedPayment = {
|
||||
event_id: number;
|
||||
@@ -19,7 +22,7 @@ type SavedPayment = {
|
||||
discount_amount?: number;
|
||||
amount?: number;
|
||||
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 successMarkdown = data?.success_markdown ?? '';
|
||||
|
||||
const siteUrl = 'https://east-guilan-ce.ir';
|
||||
const siteName = 'East Guilan CE';
|
||||
const canonicalUrl = `${siteUrl}/payments/result`;
|
||||
const toAbsoluteUrl = (url?: string | null) => {
|
||||
@@ -112,13 +114,13 @@ export default function PaymentResult() {
|
||||
|
||||
const qrValue = useMemo(() => {
|
||||
// لینک قابل بررسی/اشتراکگذاری
|
||||
const base = window.location.origin;
|
||||
const base = typeof window !== 'undefined' ? window.location.origin : siteUrl;
|
||||
const url = new URL(`${base}/payments/result`);
|
||||
if (refId) url.searchParams.set('ref_id', refId);
|
||||
if (eventId) url.searchParams.set('event_id', String(eventId));
|
||||
url.searchParams.set('status', ok ? 'success' : 'failed');
|
||||
return url.toString();
|
||||
}, [refId, eventId, ok]);
|
||||
}, [eventId, ok, refId]);
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
const el = receiptRef.current;
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type * as Types from '@/lib/types';
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { Navigate, Link } from 'react-router-dom';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { Navigate, Link } from '@/lib/router';
|
||||
import { Helmet } from '@/lib/helmet';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -191,7 +193,7 @@ export default function Profile() {
|
||||
if (!me?.major) return '—';
|
||||
if (majors) {
|
||||
const f = majors.find(m => m.code === me.major || m.label === me.major);
|
||||
return f.label;
|
||||
return f?.label ?? me.major;
|
||||
}
|
||||
return me.major;
|
||||
}, [majors, me?.major]);
|
||||
@@ -210,8 +212,9 @@ export default function Profile() {
|
||||
if (!me?.university) return '—';
|
||||
if (universities) {
|
||||
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]);
|
||||
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams, useNavigate } from '@/lib/router';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { api } from '@/lib/api';
|
||||
import { resolveErrorMessage } from '@/lib/utils';
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useParams, Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,16 +1,42 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
|
||||
"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": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"noImplicitAny": false,
|
||||
"noUnusedParameters": false,
|
||||
"skipLibCheck": true,
|
||||
"allowJs": true,
|
||||
"noUnusedLocals": false,
|
||||
"strictNullChecks": false
|
||||
}
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user