Refactor authentication handling across PostDetail, ProfilePage, Radar, and RadarPost components
- Replaced localStorage-based authentication checks with context-based checks using useAuth. - Updated login handling in PostDetail to navigate to the login page instead of setting localStorage directly. - Simplified ProfilePage to check for member presence instead of token. - Removed token dependency from InfoTab, PasswordTab, and ContentTab components. - Adjusted Radar and RadarPost components to utilize context for authentication state.
|
|
@ -9,6 +9,9 @@ dist-ssr
|
||||||
build
|
build
|
||||||
.cache
|
.cache
|
||||||
|
|
||||||
|
# The admin panel is a separate app — not part of the frontend build
|
||||||
|
panel
|
||||||
|
|
||||||
# Dependencies (reinstalled in the image)
|
# Dependencies (reinstalled in the image)
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
@ -29,8 +32,7 @@ Thumbs.db
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# Docker
|
# Docker — keep Dockerfile in the context (Liara builds from it)
|
||||||
Dockerfile
|
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
<<<<<<< HEAD
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|
@ -13,6 +12,14 @@ dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Secrets / env — never commit (keep the example template)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
secrets/
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|
@ -28,34 +35,3 @@ dist-ssr
|
||||||
/[0-9]*.png
|
/[0-9]*.png
|
||||||
brand_pages/
|
brand_pages/
|
||||||
/موکاپ*.jpg
|
/موکاپ*.jpg
|
||||||
=======
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
# Root-level temp images (not website assets)
|
|
||||||
/[0-9]*.jpg
|
|
||||||
/[0-9]*.png
|
|
||||||
brand_pages/
|
|
||||||
/موکاپ*.jpg
|
|
||||||
>>>>>>> space/main
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
- iframe [ref=e2]:
|
||||||
|
- generic [active] [ref=f3e1]:
|
||||||
|
- generic [ref=f3e5]: "502"
|
||||||
|
- generic [ref=f3e6]:
|
||||||
|
- list [ref=f3e7]:
|
||||||
|
- listitem [ref=f3e8]: شاید پورتی که برنامهی شما به آن listen میکند را اشتباه وارد کردهاید.
|
||||||
|
- listitem [ref=f3e9]: لازم است که برنامهی شما حتما روی آدرس 0.0.0.0 اجرا شود.
|
||||||
|
- listitem [ref=f3e10]: از صفحهی برنامکها، وضعیت آخرین استقرار خود را بررسی کنید.
|
||||||
|
- listitem [ref=f3e11]: و یا ممکن است هیچ مشکلی وجود نداشته باشد و صرفا باید کمی صبر کنید تا سرویستان آماده شود.
|
||||||
|
- link "مشاهدهی لاگها" [ref=f3e13] [cursor=pointer]:
|
||||||
|
- /url: https://console.liara.ir/apps
|
||||||
|
- contentinfo [ref=f3e14]:
|
||||||
|
- link [ref=f3e15] [cursor=pointer]:
|
||||||
|
- /url: https://liara.ir
|
||||||
10
Dockerfile
|
|
@ -3,7 +3,7 @@
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# Stage 1 — Build the Vite/React app
|
# Stage 1 — Build the Vite/React app
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
FROM node:22-alpine AS builder
|
FROM docker-mirror.liara.ir/library/node:22-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -13,7 +13,11 @@ WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --no-audit --no-fund --legacy-peer-deps
|
RUN npm ci --no-audit --no-fund --legacy-peer-deps
|
||||||
|
|
||||||
# Build the static bundle
|
# Build the static bundle.
|
||||||
|
# VITE_PANEL_API is inlined into the bundle at build time (Vite reads it during
|
||||||
|
# `npm run build`), so it must be present as an env var here — not at runtime.
|
||||||
|
ARG VITE_PANEL_API=https://cms-steelforesight.liara.run
|
||||||
|
ENV VITE_PANEL_API=$VITE_PANEL_API
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
@ -23,7 +27,7 @@ RUN npm run build
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# nginx-unprivileged runs as uid 101 by default — required by HF Spaces,
|
# nginx-unprivileged runs as uid 101 by default — required by HF Spaces,
|
||||||
# which forbids running as root.
|
# which forbids running as root.
|
||||||
FROM nginxinc/nginx-unprivileged:alpine AS runner
|
FROM docker-mirror.liara.ir/nginxinc/nginx-unprivileged:alpine AS runner
|
||||||
|
|
||||||
# SPA-aware server config that listens on 7860 (HF Spaces default port)
|
# SPA-aware server config that listens on 7860 (HF Spaces default port)
|
||||||
COPY --chown=nginx:nginx nginx.conf /etc/nginx/conf.d/default.conf
|
COPY --chown=nginx:nginx nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
# Liara Deployment Playbook (عمومی)
|
||||||
|
|
||||||
|
راهنمای عمومی دیپلوی روی Liara — جمعبندی مشکلات رایج و راهحلهاشون.
|
||||||
|
برای **هر پروژهای** قابل استفادهست. جای `<...>`ها مقدار پروژهی خودت رو بذار.
|
||||||
|
|
||||||
|
> قرارداد نامگذاری در این سند:
|
||||||
|
> `<app>` = اسم اپ، `<frontend-app>` = اپ فرانت، `<panel-app>` = اپ بکاند/پنل،
|
||||||
|
> `<db-internal-host>` = هاست داخلی دیتابیس، `<TOKEN>` = API token لیارا،
|
||||||
|
> `<port>` = پورتی که اپ روش گوش میده (Docker static: معمولاً 7860، Node: 3000).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱) CLI — احراز هویت
|
||||||
|
|
||||||
|
**مشکل:** دستورها با `Authentication failed` رد میشن، حتی بعد از `liara login` (توکن بین شلها/محیطها share نمیشه).
|
||||||
|
|
||||||
|
**راهحل:** روی **هر** دستور `--api-token` بده:
|
||||||
|
```bash
|
||||||
|
liara deploy --app <app> --api-token "<TOKEN>"
|
||||||
|
```
|
||||||
|
توکن از داشبورد Liara → API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲) `liara deploy` روی prompt پورت هنگ میکنه
|
||||||
|
|
||||||
|
**مشکل:** دیپلوی روی `Enter the port your app listens to:` گیر میکنه و چیزی به Liara نمیرسه (مخصوصاً در حالت غیرتعاملی).
|
||||||
|
|
||||||
|
**راهحل:** پورت رو صریح بده — هم فلگ، هم `liara.json`:
|
||||||
|
```bash
|
||||||
|
liara deploy --app <app> --port <port> ...
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{ "platform": "docker", "port": <port> }
|
||||||
|
```
|
||||||
|
اپ هم باید روی `process.env.PORT` گوش بده (Liara اون رو inject میکنه).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۳) `Dockerfile cannot be empty`
|
||||||
|
|
||||||
|
**علت:** `.dockerignore` خود `Dockerfile` رو exclude کرده.
|
||||||
|
|
||||||
|
**راهحل:** `Dockerfile` رو از `.dockerignore` **بردار** — Liara باید Dockerfile رو توی آرشیو ببینه.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۴) Docker Hub از ایران بلاکه (مهمترین برای Docker)
|
||||||
|
|
||||||
|
**مشکل:** `FROM <image>` با `Get "https://registry-1.docker.io/...": Client.Timeout exceeded` تایماوت میخوره.
|
||||||
|
|
||||||
|
**راهحل:** image پایه رو از **میرور Liara** بکش:
|
||||||
|
```dockerfile
|
||||||
|
# ایمیج رسمی (node, nginx, postgres, python, ...):
|
||||||
|
FROM docker-mirror.liara.ir/library/<image>:<tag>
|
||||||
|
# ایمیج یوزر/سازمان:
|
||||||
|
FROM docker-mirror.liara.ir/<org>/<image>:<tag>
|
||||||
|
```
|
||||||
|
|
||||||
|
میرورهای دیگر Liara (در صورت نیاز داخل build):
|
||||||
|
| نوع | میرور |
|
||||||
|
|---|---|
|
||||||
|
| Docker Hub | `docker-mirror.liara.ir` |
|
||||||
|
| Alpine (apk) | `https://linux-mirror.liara.ir/repository/alpine/` |
|
||||||
|
| npm | `https://package-mirror.liara.ir/repository/npm/` |
|
||||||
|
|
||||||
|
> `npm ci`/`pip install` معمولاً از ایران بدون میرور کار میکنن؛ فقط Docker Hub همیشه مشکلسازه.
|
||||||
|
> اگه داخل build `apk add` داری، میرور apk رو هم ست کن.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۵) لوکیشن build
|
||||||
|
|
||||||
|
**نکتهی کلیدی:** **لوکیشن build ≠ لوکیشن میزبانی.** اپ همیشه روی منطقهی خودش سرو میشه؛ `--build-location` فقط میگه image کجا کامپایل شه.
|
||||||
|
|
||||||
|
- build ایران + میرورهای بالا → سریع و بدون تحریم:
|
||||||
|
```bash
|
||||||
|
liara deploy --app <app> --build-location iran ...
|
||||||
|
```
|
||||||
|
- اگه از میرور استفاده نمیکنی، build ایران به Docker Hub نمیرسه (از مورد ۴ استفاده کن).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۶) متغیرهای build-time در فرانت (Vite / CRA / Next static)
|
||||||
|
|
||||||
|
**مشکل:** متغیر API URL رو بهعنوان env روی اپ ست کردی ولی فرانت بهش وصل نشد.
|
||||||
|
|
||||||
|
**علت:** باندلرها متغیرهای `VITE_*` / `REACT_APP_*` / `NEXT_PUBLIC_*` رو **موقع build داخل باندل مینویسن** — env runtime روی کانتینر استاتیک بیاثره.
|
||||||
|
|
||||||
|
**راهحل:** توی Dockerfile قبل از build:
|
||||||
|
```dockerfile
|
||||||
|
ARG VITE_API_URL=https://<panel-app>.liara.run
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
RUN npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۷) اتصال اپ به دیتابیس (Private Network)
|
||||||
|
|
||||||
|
**مشکلها:**
|
||||||
|
- آدرس داخلی دیتابیس → `getaddrinfo ENOTFOUND <db-internal-host>`.
|
||||||
|
- آدرس عمومی دیتابیس از داخل اپ Liara → `ECONNREFUSED`.
|
||||||
|
|
||||||
|
**علت:** اپهای Liara به endpoint عمومی DB وصل نمیشن؛ و هاست داخلی فقط وقتی resolve میشه که اپ و DB **روی یک Private Network مشترک** باشن.
|
||||||
|
|
||||||
|
**راهحل:**
|
||||||
|
1. هم دیتابیس هم اپ رو به **یک Private Network** وصل کن.
|
||||||
|
2. **connection string کاملِ** داخلی (نه فقط host:port):
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgresql://<user>:<pass>@<db-internal-host>:5432/<db>
|
||||||
|
```
|
||||||
|
3. روی شبکهی داخلی TLS لازم نیست (برعکس endpoint عمومی که TLS میخواد).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۸) اپ موقع بوت کرش میکنه چون env ست نیست
|
||||||
|
|
||||||
|
**نشانه:** کانتینر `unhealthy` / `exit 255` و لاگ مثل `FATAL: <X> is not set`.
|
||||||
|
|
||||||
|
**راهحل:** env های حیاتی رو **قبل** از انتظارِ سالمبودن ست کن. تغییر env خودش ریاستارت میکنه.
|
||||||
|
ست با CLI (بهجای داشبورد):
|
||||||
|
```bash
|
||||||
|
liara env:set "KEY=VALUE" "KEY2=VALUE2" --app <app> --force --api-token "<TOKEN>"
|
||||||
|
```
|
||||||
|
> اگه کد بدون یه env حیاتی `process.exit` میکنه، تا ست نشه اصلاً بالا نمیاد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۹) فایلسیستم Ephemeral
|
||||||
|
|
||||||
|
**مشکلها:** دیتابیس فایلی (SQLite) و پوشهی آپلود با هر ریدیپلوی پاک میشن؛ نوشتن/`mkdir` روی مسیرهای read-only کرش میکنه.
|
||||||
|
|
||||||
|
**راهحل:**
|
||||||
|
- دیتای دائمی → **دیتابیس مدیریتشده**.
|
||||||
|
- فایل/مدیا → **Object Storage** (مورد ۱۰).
|
||||||
|
- اگه واقعاً دیسک لازمه → یه **Disk** بساز و mount کن؛ مسیر رو **env-based** کن.
|
||||||
|
- هر نوشتن روی دیسک رو دفاعی بنویس که موقع بوت کرش نکنه:
|
||||||
|
```js
|
||||||
|
const dir = process.env.UPLOADS_DIR || path.join(__dirname, 'uploads');
|
||||||
|
try { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } catch {}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱۰) Object Storage (S3) برای مدیا
|
||||||
|
|
||||||
|
- S3-compatible؛ endpoint مثل `storage.iran.liara.site` + bucket + access key + secret key.
|
||||||
|
- در کد: SDK استری (`@aws-sdk/client-s3`)، `forcePathStyle: true`، endpoint = `https://<endpoint>`.
|
||||||
|
- URL عمومی فایل: `https://<bucket>.<endpoint>/<key>`.
|
||||||
|
- **باکت رو public کن** وگرنه فایلها 403 میشن.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱۱) پلتفرم اپ ثابته
|
||||||
|
|
||||||
|
نمیشه پلتفرم یه اپ رو عوض کرد (Node↔Docker↔Static). اگه اشتباه ساختی، **پاک و با پلتفرم درست دوباره بساز** (env و اتصال شبکهی خصوصی رو هم از نو ست کن).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱۲) CORS
|
||||||
|
|
||||||
|
**مشکل:** بکاند درخواستها رو با `Not allowed by CORS` رد میکنه (حتی origin خودش).
|
||||||
|
|
||||||
|
**راهحل:** allow-list رو شامل **دامنهی خود اپ + دامنهی فرانت** کن:
|
||||||
|
```
|
||||||
|
ALLOWED_ORIGINS=https://<panel-app>.liara.run,https://<frontend-app>.liara.run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱۳) Seed / یوزر ادمین
|
||||||
|
|
||||||
|
دیتابیس تازه خالیه → لاگین «رمز اشتباه». seed رو از Console اپ بزن. اگه اسکریپت seed یوزر موجود رو رد میکنه و رمز عوض نمیشه، از **upsert** استفاده کن.
|
||||||
|
نکتهی مهم برای اسکریپتهای seed: آدرس و رمز رو **env-based** کن تا هم لوکال هم روی Liara کار کنه:
|
||||||
|
```js
|
||||||
|
const BASE = process.env.SEED_BASE || `http://localhost:${process.env.PORT || 3000}`;
|
||||||
|
```
|
||||||
|
> روش بدون TLS-dependency: اسکریپت seed که فقط `fetch` میزنه رو میتونی از ماشین خودت با `SEED_BASE=https://<panel-app>.liara.run` اجرا کنی.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱۴) همگامسازی lockfile
|
||||||
|
|
||||||
|
بعد از هر تغییر وابستگیها، **لوکال `npm install`** بزن تا `package-lock.json` بهروز شه، بعد دیپلوی — وگرنه `npm ci` روی Liara بهخاطر mismatch خطا میده. `node_modules` رو پوش نکن (`.liaraignore` / `.dockerignore`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱۵) لاگ و exit code گمراهکننده
|
||||||
|
|
||||||
|
- `liara deploy` گاهی با `exit code 2` تموم میشه ولی واقعاً **موفق** بوده (خطا فقط مرحلهی خوندن لاگ بعد از دیپلوی).
|
||||||
|
- `liara logs` گاهی لاگ **کششده** میده.
|
||||||
|
|
||||||
|
**راهحل:** وضعیت واقعی رو از خروجی کامل دیپلوی (`√ Release created` / `Deployment finished successfully`) و یه **درخواست HTTP واقعی به دامنه** بگیر؛ به exit code تنها اعتماد نکن.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## فایلهای پایهی هر اپ
|
||||||
|
|
||||||
|
`liara.json` (Docker):
|
||||||
|
```json
|
||||||
|
{ "platform": "docker", "port": <port> }
|
||||||
|
```
|
||||||
|
`liara.json` (Node):
|
||||||
|
```json
|
||||||
|
{ "platform": "node" }
|
||||||
|
```
|
||||||
|
`.liaraignore`:
|
||||||
|
```
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## چکلیست دیپلوی (ترتیب درست)
|
||||||
|
|
||||||
|
1. **دیتابیس** بساز → Private Network روشن.
|
||||||
|
2. **اپ بکاند** بساز → به همون Private Network وصلش کن.
|
||||||
|
3. env های بکاند رو ست کن (`DATABASE_URL` داخلیِ کامل، سکرتها، `ALLOWED_ORIGINS`، کلیدهای Object Storage، `NODE_ENV=production`).
|
||||||
|
4. `package-lock.json` رو لوکال sync کن.
|
||||||
|
5. (Docker) Dockerfile: میرور `docker-mirror.liara.ir` + `ARG` برای متغیرهای build-time. `.dockerignore` نباید `Dockerfile` رو حذف کنه.
|
||||||
|
6. `liara deploy --app <app> --port <port> --build-location iran --api-token "<TOKEN>"`.
|
||||||
|
7. سلامت رو با درخواست HTTP به دامنه چک کن (نه فقط exit code).
|
||||||
|
8. **seed محتوا/ادمین** (قبل از دیپلوی فرانت، تا سایت خالی نباشه).
|
||||||
|
9. باکت Object Storage رو public کن.
|
||||||
|
10. **فرانت** رو با متغیر build-time = دامنهی بکاند دیپلوی کن؛ `ALLOWED_ORIGINS` بکاند رو شامل دامنهی فرانت کن.
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"platform": "docker",
|
||||||
|
"port": 7860
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
# Copy this file to ".env" and fill in real values.
|
# Copy this file to ".env" and fill in real values.
|
||||||
|
|
||||||
|
# PostgreSQL connection — REQUIRED. Copy from the Liara database dashboard.
|
||||||
|
# Format: postgresql://root:<PASSWORD>@<HOST>.liara.cloud:<PORT>/<DB_NAME>
|
||||||
|
DATABASE_URL=postgresql://root:password@localhost:5432/andishkade
|
||||||
|
# Set to "disable" only when connecting over Liara's internal private network (no TLS).
|
||||||
|
# Leave unset (TLS on) for the public connection string.
|
||||||
|
DATABASE_SSL=
|
||||||
|
|
||||||
# JWT signing secret — REQUIRED. Must be 32+ random chars. Never commit the real value.
|
# JWT signing secret — REQUIRED. Must be 32+ random chars. Never commit the real value.
|
||||||
# Generate: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
# Generate: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||||
JWT_SECRET=replace-with-64-random-hex-chars
|
JWT_SECRET=replace-with-64-random-hex-chars
|
||||||
|
|
@ -17,9 +24,20 @@ PUBLIC_ORIGIN=http://localhost:3001
|
||||||
# Comma-separated origins allowed to call the API (your frontend URL)
|
# Comma-separated origins allowed to call the API (your frontend URL)
|
||||||
ALLOWED_ORIGINS=http://localhost:5173
|
ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
# Contact form recipients (comma-separated)
|
# Contact form recipients (comma-separated) — REQUIRED for the contact form to send
|
||||||
MAIL_TO=alirezaameria2@gmail.com, mtdemne@gmail.com
|
MAIL_TO=recipient@example.com, second@example.com
|
||||||
|
|
||||||
# Gmail credentials for contact form emails
|
# Gmail credentials for contact form emails
|
||||||
MAIL_USER=your-gmail@gmail.com
|
MAIL_USER=your-gmail@gmail.com
|
||||||
MAIL_PASS=your-gmail-app-password
|
MAIL_PASS=your-gmail-app-password
|
||||||
|
|
||||||
|
# Local uploads dir — on Liara point this at a mounted Disk (e.g. /var/lib/uploads).
|
||||||
|
# Ignored when object storage (below) is configured.
|
||||||
|
UPLOADS_DIR=
|
||||||
|
|
||||||
|
# Object storage (Liara / S3-compatible). When all four are set, image + video
|
||||||
|
# uploads go to the bucket instead of local disk. Bucket must allow public reads.
|
||||||
|
LIARA_ENDPOINT=storage.iran.liara.site
|
||||||
|
LIARA_BUCKET=your-bucket-name
|
||||||
|
LIARA_ACCESS_KEY=your-access-key
|
||||||
|
LIARA_SECRET_KEY=your-secret-key
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
uploads
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
261
panel/db.js
|
|
@ -1,26 +1,103 @@
|
||||||
import Database from 'better-sqlite3';
|
import pg from 'pg';
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const { Pool } = pg;
|
||||||
const dbPath = path.join(__dirname, 'data.db');
|
|
||||||
|
|
||||||
export const db = new Database(dbPath);
|
if (!process.env.DATABASE_URL) {
|
||||||
db.pragma('journal_mode = WAL');
|
console.error('FATAL: DATABASE_URL is not set. Point it at your Liara PostgreSQL connection string.');
|
||||||
db.pragma('foreign_keys = ON');
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// safe migrations — ignore if column already exists
|
// Liara managed Postgres requires TLS when reached over the public endpoint.
|
||||||
for (const sql of [
|
// Set DATABASE_SSL=disable only when connecting over the internal private network.
|
||||||
"ALTER TABLE articles ADD COLUMN status TEXT DEFAULT 'published'",
|
const pool = new Pool({
|
||||||
"ALTER TABLE articles ADD COLUMN key_data TEXT",
|
connectionString: process.env.DATABASE_URL,
|
||||||
]) { try { db.exec(sql) } catch {} }
|
ssl: process.env.DATABASE_SSL === 'disable' ? false : { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
export { pool };
|
||||||
|
|
||||||
db.exec(`
|
// pg dates come back as JS Date objects; the API historically returned ISO-ish
|
||||||
|
// strings from SQLite. Coerce timestamp/date OIDs back to the raw string pg sends.
|
||||||
|
pg.types.setTypeParser(1114, (v) => v); // timestamp without time zone
|
||||||
|
pg.types.setTypeParser(1184, (v) => v); // timestamptz
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a better-sqlite3-style statement into a pg query.
|
||||||
|
* - named params (`@name`) when a single plain-object arg is passed
|
||||||
|
* - positional params (`?`) otherwise
|
||||||
|
* Returns { text, values } for pool.query.
|
||||||
|
*/
|
||||||
|
function buildQuery(sql, args) {
|
||||||
|
const isNamed =
|
||||||
|
args.length === 1 &&
|
||||||
|
args[0] != null &&
|
||||||
|
typeof args[0] === 'object' &&
|
||||||
|
!Array.isArray(args[0]) &&
|
||||||
|
/@\w+/.test(sql);
|
||||||
|
|
||||||
|
if (isNamed) {
|
||||||
|
const obj = args[0];
|
||||||
|
const values = [];
|
||||||
|
const text = sql.replace(/@(\w+)/g, (_, name) => {
|
||||||
|
values.push(name in obj ? obj[name] : null);
|
||||||
|
return '$' + values.length;
|
||||||
|
});
|
||||||
|
return { text, values };
|
||||||
|
}
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
const text = sql.replace(/\?/g, () => '$' + ++i);
|
||||||
|
return { text, values: args.flat() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* better-sqlite3-shaped async facade over pg.
|
||||||
|
* db.prepare(sql).get(params) -> first row | undefined
|
||||||
|
* db.prepare(sql).all(params) -> rows[]
|
||||||
|
* db.prepare(sql).run(params) -> { changes, lastInsertRowid, rows }
|
||||||
|
* NOTE: every method returns a Promise — callers MUST await.
|
||||||
|
* For INSERTs that need the new id, append `RETURNING id` to the SQL;
|
||||||
|
* run() exposes it as `lastInsertRowid`.
|
||||||
|
*/
|
||||||
|
export const db = {
|
||||||
|
prepare(sql) {
|
||||||
|
return {
|
||||||
|
async get(...args) {
|
||||||
|
const { text, values } = buildQuery(sql, args);
|
||||||
|
const res = await pool.query(text, values);
|
||||||
|
return res.rows[0];
|
||||||
|
},
|
||||||
|
async all(...args) {
|
||||||
|
const { text, values } = buildQuery(sql, args);
|
||||||
|
const res = await pool.query(text, values);
|
||||||
|
return res.rows;
|
||||||
|
},
|
||||||
|
async run(...args) {
|
||||||
|
const { text, values } = buildQuery(sql, args);
|
||||||
|
const res = await pool.query(text, values);
|
||||||
|
return {
|
||||||
|
changes: res.rowCount,
|
||||||
|
lastInsertRowid: res.rows[0]?.id,
|
||||||
|
rows: res.rows,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async exec(sql) {
|
||||||
|
await pool.query(sql);
|
||||||
|
},
|
||||||
|
async query(text, params) {
|
||||||
|
return pool.query(text, params);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── schema (idempotent) — runs once at import via top-level await ──
|
||||||
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id SERIAL PRIMARY KEY,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
role TEXT NOT NULL DEFAULT 'admin',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS articles (
|
CREATE TABLE IF NOT EXISTS articles (
|
||||||
|
|
@ -40,10 +117,11 @@ db.exec(`
|
||||||
cover_image TEXT,
|
cover_image TEXT,
|
||||||
tags TEXT,
|
tags TEXT,
|
||||||
featured INTEGER DEFAULT 0,
|
featured INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
status TEXT DEFAULT 'published',
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
key_data TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_articles_created ON articles(created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_articles_created ON articles(created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_articles_featured ON articles(featured);
|
CREATE INDEX IF NOT EXISTS idx_articles_featured ON articles(featured);
|
||||||
|
|
||||||
|
|
@ -54,10 +132,9 @@ db.exec(`
|
||||||
date TEXT,
|
date TEXT,
|
||||||
level TEXT NOT NULL CHECK (level IN ('critical','high','medium','low','opportunity')),
|
level TEXT NOT NULL CHECK (level IN ('critical','high','medium','low','opportunity')),
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_risks_sort ON risk_signals(sort_order, created_at);
|
CREATE INDEX IF NOT EXISTS idx_risks_sort ON risk_signals(sort_order, created_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS events (
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
|
@ -78,8 +155,8 @@ db.exec(`
|
||||||
description_en TEXT,
|
description_en TEXT,
|
||||||
registration_open INTEGER DEFAULT 1,
|
registration_open INTEGER DEFAULT 1,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_sort ON events(sort_order, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_events_sort ON events(sort_order, created_at DESC);
|
||||||
|
|
||||||
|
|
@ -91,8 +168,8 @@ db.exec(`
|
||||||
desc_fa TEXT,
|
desc_fa TEXT,
|
||||||
desc_en TEXT,
|
desc_en TEXT,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_vision_sort ON vision_items(sort_order, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_vision_sort ON vision_items(sort_order, created_at DESC);
|
||||||
|
|
||||||
|
|
@ -103,8 +180,8 @@ db.exec(`
|
||||||
role_fa TEXT,
|
role_fa TEXT,
|
||||||
role_en TEXT,
|
role_en TEXT,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_advisory_sort ON advisory_board(sort_order, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_advisory_sort ON advisory_board(sort_order, created_at DESC);
|
||||||
|
|
||||||
|
|
@ -129,8 +206,8 @@ db.exec(`
|
||||||
twitter TEXT,
|
twitter TEXT,
|
||||||
website TEXT,
|
website TEXT,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_team_sort ON team_members(sort_order, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_team_sort ON team_members(sort_order, created_at DESC);
|
||||||
|
|
||||||
|
|
@ -148,8 +225,8 @@ db.exec(`
|
||||||
cta_fa TEXT,
|
cta_fa TEXT,
|
||||||
cta_en TEXT,
|
cta_en TEXT,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_plans_sort ON plans(sort_order);
|
CREATE INDEX IF NOT EXISTS idx_plans_sort ON plans(sort_order);
|
||||||
|
|
||||||
|
|
@ -162,12 +239,16 @@ db.exec(`
|
||||||
title_fa TEXT,
|
title_fa TEXT,
|
||||||
date_fa TEXT,
|
date_fa TEXT,
|
||||||
excerpt_fa TEXT,
|
excerpt_fa TEXT,
|
||||||
|
body_fa TEXT,
|
||||||
|
analysis_fa TEXT,
|
||||||
title_en TEXT,
|
title_en TEXT,
|
||||||
date_en TEXT,
|
date_en TEXT,
|
||||||
excerpt_en TEXT,
|
excerpt_en TEXT,
|
||||||
|
body_en TEXT,
|
||||||
|
analysis_en TEXT,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_radar_sort ON radar_items(sort_order, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_radar_sort ON radar_items(sort_order, created_at DESC);
|
||||||
|
|
||||||
|
|
@ -191,8 +272,8 @@ db.exec(`
|
||||||
banner_desc_en TEXT,
|
banner_desc_en TEXT,
|
||||||
banner_book_img TEXT,
|
banner_book_img TEXT,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_radar_pages_sort ON radar_pages(sort_order);
|
CREATE INDEX IF NOT EXISTS idx_radar_pages_sort ON radar_pages(sort_order);
|
||||||
|
|
||||||
|
|
@ -211,8 +292,8 @@ db.exec(`
|
||||||
position TEXT DEFAULT 'hero',
|
position TEXT DEFAULT 'hero',
|
||||||
active INTEGER DEFAULT 1,
|
active INTEGER DEFAULT 1,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_banners_active ON banners(active, sort_order);
|
CREATE INDEX IF NOT EXISTS idx_banners_active ON banners(active, sort_order);
|
||||||
|
|
||||||
|
|
@ -229,8 +310,8 @@ db.exec(`
|
||||||
excerpt_en TEXT,
|
excerpt_en TEXT,
|
||||||
scroll_dir TEXT CHECK(scroll_dir IN ('left','right','up')) DEFAULT 'up',
|
scroll_dir TEXT CHECK(scroll_dir IN ('left','right','up')) DEFAULT 'up',
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_factory_reports_sort ON factory_reports(sort_order, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_factory_reports_sort ON factory_reports(sort_order, created_at DESC);
|
||||||
|
|
||||||
|
|
@ -243,8 +324,8 @@ db.exec(`
|
||||||
grid_col INTEGER DEFAULT 1,
|
grid_col INTEGER DEFAULT 1,
|
||||||
grid_row INTEGER DEFAULT 1,
|
grid_row INTEGER DEFAULT 1,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_integrations_sort ON integrations(sort_order, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_integrations_sort ON integrations(sort_order, created_at DESC);
|
||||||
|
|
||||||
|
|
@ -255,8 +336,8 @@ db.exec(`
|
||||||
value INTEGER DEFAULT 0,
|
value INTEGER DEFAULT 0,
|
||||||
suffix_fa TEXT,
|
suffix_fa TEXT,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_institute_stats_sort ON institute_stats(sort_order);
|
CREATE INDEX IF NOT EXISTS idx_institute_stats_sort ON institute_stats(sort_order);
|
||||||
|
|
||||||
|
|
@ -269,8 +350,8 @@ db.exec(`
|
||||||
change_percent REAL DEFAULT 0,
|
change_percent REAL DEFAULT 0,
|
||||||
trend TEXT CHECK(trend IN ('up','down','flat')) DEFAULT 'flat',
|
trend TEXT CHECK(trend IN ('up','down','flat')) DEFAULT 'flat',
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_market_prices_sort ON market_prices(sort_order);
|
CREATE INDEX IF NOT EXISTS idx_market_prices_sort ON market_prices(sort_order);
|
||||||
|
|
||||||
|
|
@ -281,8 +362,8 @@ db.exec(`
|
||||||
value REAL DEFAULT 0,
|
value REAL DEFAULT 0,
|
||||||
meta TEXT,
|
meta TEXT,
|
||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_chart_points_series ON market_chart_points(series, sort_order);
|
CREATE INDEX IF NOT EXISTS idx_chart_points_series ON market_chart_points(series, sort_order);
|
||||||
|
|
||||||
|
|
@ -294,42 +375,53 @@ db.exec(`
|
||||||
change_value REAL,
|
change_value REAL,
|
||||||
change_pct REAL,
|
change_pct REAL,
|
||||||
source_url TEXT,
|
source_url TEXT,
|
||||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS members (
|
CREATE TABLE IF NOT EXISTS members (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id SERIAL PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
email TEXT NOT NULL UNIQUE,
|
email TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
phone TEXT,
|
phone TEXT,
|
||||||
avatar TEXT,
|
avatar TEXT,
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_members_email ON members(email);
|
CREATE INDEX IF NOT EXISTS idx_members_email ON members(email);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS purchases (
|
CREATE TABLE IF NOT EXISTS purchases (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id SERIAL PRIMARY KEY,
|
||||||
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||||||
article_id TEXT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
article_id TEXT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
amount INTEGER NOT NULL DEFAULT 0,
|
amount INTEGER NOT NULL DEFAULT 0,
|
||||||
purchased_at TEXT NOT NULL DEFAULT (datetime('now')),
|
purchased_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
UNIQUE(member_id, article_id)
|
UNIQUE(member_id, article_id)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_purchases_member ON purchases(member_id, purchased_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_purchases_member ON purchases(member_id, purchased_at DESC);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
/* idempotent column migrations for already-created tables */
|
// idempotent column adds (Postgres supports IF NOT EXISTS on ADD COLUMN)
|
||||||
function ensureColumn(table, col, def) {
|
for (const sql of [
|
||||||
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
|
"ALTER TABLE articles ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'published'",
|
||||||
if (!cols.some((c) => c.name === col)) {
|
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS key_data TEXT',
|
||||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`);
|
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS body_fa TEXT',
|
||||||
}
|
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS body_en TEXT',
|
||||||
|
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS analysis_fa TEXT',
|
||||||
|
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS analysis_en TEXT',
|
||||||
|
'ALTER TABLE banners ADD COLUMN IF NOT EXISTS overline_fa TEXT',
|
||||||
|
'ALTER TABLE banners ADD COLUMN IF NOT EXISTS overline_en TEXT',
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'admin'",
|
||||||
|
]) {
|
||||||
|
await pool.query(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// promote the founding admin (lowest id) to owner if none exists yet
|
||||||
|
const { rows: ownerRows } = await pool.query("SELECT 1 FROM users WHERE role = 'owner' LIMIT 1");
|
||||||
|
if (ownerRows.length === 0) {
|
||||||
|
await pool.query("UPDATE users SET role = 'owner' WHERE id = (SELECT id FROM users ORDER BY id LIMIT 1)");
|
||||||
}
|
}
|
||||||
ensureColumn('banners', 'overline_fa', 'TEXT');
|
|
||||||
ensureColumn('banners', 'overline_en', 'TEXT');
|
|
||||||
|
|
||||||
export function rowToPrice(row) {
|
export function rowToPrice(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
@ -345,27 +437,28 @@ export function rowToPrice(row) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upsertPrice(p) {
|
export async function upsertPrice(p) {
|
||||||
db.prepare(`
|
await pool.query(
|
||||||
INSERT INTO prices (symbol, name, value, unit, change_value, change_pct, source_url, fetched_at)
|
`INSERT INTO prices (symbol, name, value, unit, change_value, change_pct, source_url, fetched_at)
|
||||||
VALUES (@symbol, @name, @value, @unit, @change_value, @change_pct, @source_url, datetime('now'))
|
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
|
||||||
ON CONFLICT(symbol) DO UPDATE SET
|
ON CONFLICT(symbol) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = EXCLUDED.name,
|
||||||
value = excluded.value,
|
value = EXCLUDED.value,
|
||||||
unit = excluded.unit,
|
unit = EXCLUDED.unit,
|
||||||
change_value = excluded.change_value,
|
change_value = EXCLUDED.change_value,
|
||||||
change_pct = excluded.change_pct,
|
change_pct = EXCLUDED.change_pct,
|
||||||
source_url = excluded.source_url,
|
source_url = EXCLUDED.source_url,
|
||||||
fetched_at = excluded.fetched_at
|
fetched_at = EXCLUDED.fetched_at`,
|
||||||
`).run({
|
[
|
||||||
symbol: p.symbol,
|
p.symbol,
|
||||||
name: p.name,
|
p.name,
|
||||||
value: p.value ?? null,
|
p.value ?? null,
|
||||||
unit: p.unit ?? null,
|
p.unit ?? null,
|
||||||
change_value: p.changeValue ?? null,
|
p.changeValue ?? null,
|
||||||
change_pct: p.changePct ?? null,
|
p.changePct ?? null,
|
||||||
source_url: p.sourceUrl ?? null,
|
p.sourceUrl ?? null,
|
||||||
});
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rowToEvent(row) {
|
export function rowToEvent(row) {
|
||||||
|
|
@ -576,9 +669,13 @@ export function rowToRadarItem(row) {
|
||||||
titleFa: row.title_fa,
|
titleFa: row.title_fa,
|
||||||
dateFa: row.date_fa,
|
dateFa: row.date_fa,
|
||||||
excerptFa: row.excerpt_fa,
|
excerptFa: row.excerpt_fa,
|
||||||
|
bodyFa: row.body_fa || null,
|
||||||
|
analysisFa: row.analysis_fa || null,
|
||||||
titleEn: row.title_en,
|
titleEn: row.title_en,
|
||||||
dateEn: row.date_en,
|
dateEn: row.date_en,
|
||||||
excerptEn: row.excerpt_en,
|
excerptEn: row.excerpt_en,
|
||||||
|
bodyEn: row.body_en || null,
|
||||||
|
analysisEn: row.analysis_en || null,
|
||||||
sortOrder: row.sort_order,
|
sortOrder: row.sort_order,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
import Database from 'better-sqlite3';
|
import { db, pool } from './db.js';
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const db = new Database(path.join(__dirname, 'data.db'));
|
|
||||||
|
|
||||||
// Gregorian ISO → Jalali parts using Intl (same engine the site's calendar uses)
|
// Gregorian ISO → Jalali parts using Intl (same engine the site's calendar uses)
|
||||||
function jalali(iso) {
|
function jalali(iso) {
|
||||||
|
|
@ -75,7 +70,7 @@ const events = [
|
||||||
];
|
];
|
||||||
|
|
||||||
// replace the old mock events with the real future ones
|
// replace the old mock events with the real future ones
|
||||||
db.prepare('DELETE FROM events').run();
|
await db.prepare('DELETE FROM events').run();
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO events
|
INSERT INTO events
|
||||||
|
|
@ -88,9 +83,10 @@ const stmt = db.prepare(`
|
||||||
@registration_open, @sort_order)
|
@registration_open, @sort_order)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
events.forEach((e, i) => {
|
for (let i = 0; i < events.length; i++) {
|
||||||
|
const e = events[i];
|
||||||
const j = jalali(e.iso);
|
const j = jalali(e.iso);
|
||||||
stmt.run({
|
await stmt.run({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
title_fa: e.titleFa, title_en: e.titleEn,
|
title_fa: e.titleFa, title_en: e.titleEn,
|
||||||
date_fa: j.day, date_en: j.day,
|
date_fa: j.day, date_en: j.day,
|
||||||
|
|
@ -103,6 +99,8 @@ events.forEach((e, i) => {
|
||||||
registration_open: 1, sort_order: i,
|
registration_open: 1, sort_order: i,
|
||||||
});
|
});
|
||||||
console.log(`✓ ${e.id} → ${j.day} ${j.monthFa} ${j.year}`);
|
console.log(`✓ ${e.id} → ${j.day} ${j.monthFa} ${j.year}`);
|
||||||
});
|
}
|
||||||
|
|
||||||
console.log(`\nDone: ${events.length} events inserted`);
|
console.log(`\nDone: ${events.length} events inserted`);
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
import Database from 'better-sqlite3';
|
import { db, pool } from './db.js';
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const db = new Database(path.join(__dirname, 'data.db'));
|
|
||||||
|
|
||||||
// Real issues of Mobarakeh Steel's "ماهنامه تحلیلی کارخانه" (titles/descriptions/images
|
// Real issues of Mobarakeh Steel's "ماهنامه تحلیلی کارخانه" (titles/descriptions/images
|
||||||
// taken from msc.ir). The listing page itself is WAF-gated, so only the latest 3 issues
|
// taken from msc.ir). The listing page itself is WAF-gated, so only the latest 3 issues
|
||||||
|
|
@ -36,7 +31,7 @@ const reports = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
db.prepare('DELETE FROM factory_reports').run();
|
await db.prepare('DELETE FROM factory_reports').run();
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO factory_reports
|
INSERT INTO factory_reports
|
||||||
|
|
@ -45,8 +40,9 @@ const stmt = db.prepare(`
|
||||||
(@id, @img, @tag_fa, @tag_en, @title_fa, @title_en, @date_fa, @date_en, @excerpt_fa, @excerpt_en, @scroll_dir, @sort_order)
|
(@id, @img, @tag_fa, @tag_en, @title_fa, @title_en, @date_fa, @date_en, @excerpt_fa, @excerpt_en, @scroll_dir, @sort_order)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
reports.forEach((r, i) => {
|
for (let i = 0; i < reports.length; i++) {
|
||||||
stmt.run({
|
const r = reports[i];
|
||||||
|
await stmt.run({
|
||||||
id: r.id, img: r.img,
|
id: r.id, img: r.img,
|
||||||
tag_fa: 'ماهنامه تحلیلی کارخانه', tag_en: 'Factory Analysis',
|
tag_fa: 'ماهنامه تحلیلی کارخانه', tag_en: 'Factory Analysis',
|
||||||
title_fa: r.titleFa, title_en: r.titleEn,
|
title_fa: r.titleFa, title_en: r.titleEn,
|
||||||
|
|
@ -55,6 +51,8 @@ reports.forEach((r, i) => {
|
||||||
scroll_dir: r.dir, sort_order: i,
|
scroll_dir: r.dir, sort_order: i,
|
||||||
});
|
});
|
||||||
console.log(`✓ ${r.id} → ${r.titleFa}`);
|
console.log(`✓ ${r.id} → ${r.titleFa}`);
|
||||||
});
|
}
|
||||||
|
|
||||||
console.log(`\nDone: ${reports.length} factory reports inserted`);
|
console.log(`\nDone: ${reports.length} factory reports inserted`);
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"platform": "node",
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
|
@ -12,13 +12,14 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.104.1",
|
"@anthropic-ai/sdk": "^0.104.1",
|
||||||
|
"@aws-sdk/client-s3": "^3.700.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^11.3.0",
|
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
|
"express-async-errors": "^3.1.1",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
|
@ -27,6 +28,7 @@
|
||||||
"nodemailer": "^8.0.10",
|
"nodemailer": "^8.0.10",
|
||||||
"openai": "^6.42.0",
|
"openai": "^6.42.0",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
|
"pg": "^8.13.1",
|
||||||
"sharp": "^0.35.1"
|
"sharp": "^0.35.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,21 @@
|
||||||
const $ = (sel, root = document) => root.querySelector(sel);
|
const $ = (sel, root = document) => root.querySelector(sel);
|
||||||
const root = $('#app');
|
const root = $('#app');
|
||||||
|
|
||||||
|
const TAB_GROUPS = [
|
||||||
|
{ label: 'محتوا', tabs: ['articles', 'events', 'radar', 'radarPages', 'factoryReports'] },
|
||||||
|
{ label: 'داده', tabs: ['risks', 'mprices', 'stats', 'charts', 'integrations'] },
|
||||||
|
{ label: 'سایت', tabs: ['vision', 'advisory', 'team', 'experts', 'plans', 'banners'] },
|
||||||
|
{ label: 'ابزار', tabs: ['pdfAnalyze', 'import', 'users'] },
|
||||||
|
];
|
||||||
|
const TAB_LABELS = {
|
||||||
|
articles: 'مطالب', risks: 'ریسکها', events: 'رویدادها',
|
||||||
|
radar: 'رادار', radarPages: 'صفحات رادار', factoryReports: 'گزارش کارخانه',
|
||||||
|
integrations: 'منابع داده', stats: 'آمار', mprices: 'قیمتها',
|
||||||
|
charts: 'نمودارها', vision: 'چشمانداز', advisory: 'شورای مشورتی',
|
||||||
|
team: 'تیم', experts: 'خبرگان', plans: 'پلانها', banners: 'بنرها',
|
||||||
|
pdfAnalyze: '📄 PDF', import: '⬇️ ایمپورت', users: 'کاربران',
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
'بازار جهانی', 'سیاستگذاری', 'انرژی و ESG', 'صادرات', 'تولید داخلی', 'ریسک و بحران',
|
'بازار جهانی', 'سیاستگذاری', 'انرژی و ESG', 'صادرات', 'تولید داخلی', 'ریسک و بحران',
|
||||||
|
|
@ -18,6 +33,25 @@ const TYPES = [
|
||||||
{ value: 'radar', label: 'رادار آینده' },
|
{ value: 'radar', label: 'رادار آینده' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CONTENT_TYPES = [
|
||||||
|
{ key: 'report', label: 'گزارش تحلیلی', icon: '📊', desc: 'گزارش فصلی، فلش یا ویژه با تحلیل بازار', defaultType: 'quarterly', defaultCategory: '' },
|
||||||
|
{ key: 'news', label: 'خبر', icon: '📰', desc: 'اخبار صنعت فولاد', defaultType: 'news', defaultCategory: 'اخبار صنعت' },
|
||||||
|
{ key: 'video', label: 'ویدیوکست', icon: '🎬', desc: 'ویدیوی تحلیلی یا مصاحبه', defaultType: 'video', defaultCategory: '' },
|
||||||
|
{ key: 'podcast', label: 'پادکست', icon: '🎙️', desc: 'اپیزود پادکست صوتی', defaultType: 'podcast', defaultCategory: '' },
|
||||||
|
{ key: 'factory', label: 'گزارش کارخانه', icon: '🏭', desc: 'گزارش تولید و عملیات کارخانه', defaultType: 'factoryReport', defaultCategory: 'گزارش کارخانه' },
|
||||||
|
{ key: 'radar', label: 'رادار آینده', icon: '🔭', desc: 'از تب «رادار» در منوی بالا مدیریت میشود', defaultType: '', defaultCategory: '', externalTab: 'radar' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function inferContentType(a) {
|
||||||
|
if (!a) return 'report';
|
||||||
|
if (a.type === 'video') return 'video';
|
||||||
|
if (a.type === 'podcast') return 'podcast';
|
||||||
|
if (a.type === 'radar') return 'radar';
|
||||||
|
if (a.type === 'factoryReport' || a.category === 'گزارش کارخانه') return 'factory';
|
||||||
|
if (a.type === 'news') return 'news';
|
||||||
|
return 'report';
|
||||||
|
}
|
||||||
|
|
||||||
const EVENT_TYPES = [
|
const EVENT_TYPES = [
|
||||||
{ value: 'conference', label: 'کنگره' },
|
{ value: 'conference', label: 'کنگره' },
|
||||||
{ value: 'exhibition', label: 'نمایشگاه' },
|
{ value: 'exhibition', label: 'نمایشگاه' },
|
||||||
|
|
@ -111,7 +145,6 @@ function renderLogin(errorMsg = '') {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { username: fd.get('username'), password: fd.get('password') },
|
body: { username: fd.get('username'), password: fd.get('password') },
|
||||||
});
|
});
|
||||||
if (data.token) sessionStorage.setItem('sf_token', data.token);
|
|
||||||
renderList();
|
renderList();
|
||||||
} catch {
|
} catch {
|
||||||
renderLogin('نام کاربری یا رمز عبور اشتباه است');
|
renderLogin('نام کاربری یا رمز عبور اشتباه است');
|
||||||
|
|
@ -120,34 +153,24 @@ function renderLogin(errorMsg = '') {
|
||||||
}
|
}
|
||||||
|
|
||||||
function topbarHtml(active, primaryLabel) {
|
function topbarHtml(active, primaryLabel) {
|
||||||
|
const activeGroup = TAB_GROUPS.find(g => g.tabs.includes(active)) || TAB_GROUPS[0];
|
||||||
|
const groupBtns = TAB_GROUPS.map(g => `
|
||||||
|
<button class="group-btn${g === activeGroup ? ' group-active' : ''}" data-tab="${g.tabs[0]}">${g.label}</button>
|
||||||
|
`).join('');
|
||||||
|
const subTabs = activeGroup.tabs.map(t => `
|
||||||
|
<button data-tab="${t}" class="${active === t ? 'tab-active' : ''}">${TAB_LABELS[t]}</button>
|
||||||
|
`).join('');
|
||||||
return `
|
return `
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
<div class="topbar-row">
|
||||||
<div class="brand">پنل اندیشکده فولاد</div>
|
<div class="brand">پنل اندیشکده فولاد</div>
|
||||||
<nav class="tabs">
|
<nav class="groups">${groupBtns}</nav>
|
||||||
<button data-tab="articles" class="${active==='articles'?'tab-active':''}">مطالب</button>
|
|
||||||
<button data-tab="risks" class="${active==='risks' ?'tab-active':''}">ریسکها</button>
|
|
||||||
<button data-tab="events" class="${active==='events' ?'tab-active':''}">رویدادها</button>
|
|
||||||
<button data-tab="radar" class="${active==='radar' ?'tab-active':''}">رادار</button>
|
|
||||||
<button data-tab="radarPages" class="${active==='radarPages'?'tab-active':''}">صفحات رادار</button>
|
|
||||||
<button data-tab="factoryReports" class="${active==='factoryReports'?'tab-active':''}">گزارش کارخانه</button>
|
|
||||||
<button data-tab="integrations" class="${active==='integrations'?'tab-active':''}">منابع داده</button>
|
|
||||||
<button data-tab="stats" class="${active==='stats' ?'tab-active':''}">آمار</button>
|
|
||||||
<button data-tab="mprices" class="${active==='mprices' ?'tab-active':''}">قیمتها</button>
|
|
||||||
<button data-tab="charts" class="${active==='charts' ?'tab-active':''}">نمودارها</button>
|
|
||||||
<button data-tab="vision" class="${active==='vision' ?'tab-active':''}">چشمانداز</button>
|
|
||||||
<button data-tab="advisory" class="${active==='advisory'?'tab-active':''}">شورای مشورتی</button>
|
|
||||||
<button data-tab="team" class="${active==='team' ?'tab-active':''}">تیم</button>
|
|
||||||
<button data-tab="experts" class="${active==='experts' ?'tab-active':''}">خبرگان</button>
|
|
||||||
<button data-tab="plans" class="${active==='plans' ?'tab-active':''}">پلانها</button>
|
|
||||||
<button data-tab="banners" class="${active==='banners' ?'tab-active':''}">بنرها</button>
|
|
||||||
<button data-tab="pdfAnalyze" class="${active==='pdfAnalyze'?'tab-active':''}">📄 آنالیز PDF</button>
|
|
||||||
<button data-tab="import" class="${active==='import' ?'tab-active':''}">⬇️ ایمپورت</button>
|
|
||||||
<button data-tab="users" class="${active==='users' ?'tab-active':''}">کاربران</button>
|
|
||||||
</nav>
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="newBtn" class="primary">${primaryLabel}</button>
|
<button id="newBtn" class="primary">${primaryLabel}</button>
|
||||||
<button id="logoutBtn" class="ghost">خروج</button>
|
<button id="logoutBtn" class="ghost">خروج</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="tabs">${subTabs}</nav>
|
||||||
</header>
|
</header>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
@ -187,20 +210,28 @@ async function renderList() {
|
||||||
${topbarHtml('articles', '+ مطلب جدید')}
|
${topbarHtml('articles', '+ مطلب جدید')}
|
||||||
<main class="page">
|
<main class="page">
|
||||||
<h2>مطالب</h2>
|
<h2>مطالب</h2>
|
||||||
|
<div id="cat-bar" class="cat-bar">
|
||||||
|
<button class="cat-btn active" data-cat="">همه</button>
|
||||||
|
${CATEGORIES.map(c => `<button class="cat-btn" data-cat="${escapeAttr(c)}">${escapeHtml(c)}</button>`).join('')}
|
||||||
|
</div>
|
||||||
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
|
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
|
||||||
</main>
|
</main>
|
||||||
`;
|
`;
|
||||||
wireTabs();
|
wireTabs();
|
||||||
$('#newBtn').addEventListener('click', () => renderEditor(null));
|
$('#newBtn').addEventListener('click', () => renderEditor(null));
|
||||||
|
|
||||||
try {
|
let allItems = [];
|
||||||
const items = await api('/api/articles?limit=200');
|
let activeCat = '';
|
||||||
if (!items.length) {
|
|
||||||
$('#list').innerHTML = `<p class="muted">هنوز مطلبی ثبت نشده است.</p>`;
|
const safeCoverUrl = (u) => /^\/uploads\/[a-zA-Z0-9._-]+$/.test(String(u || '')) ? u : '';
|
||||||
|
|
||||||
|
function renderItems() {
|
||||||
|
const filtered = activeCat ? allItems.filter(a => a.category === activeCat) : allItems;
|
||||||
|
if (!filtered.length) {
|
||||||
|
$('#list').innerHTML = `<p class="muted">مطلبی در این دستهبندی یافت نشد.</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const safeCoverUrl = (u) => /^\/uploads\/[a-zA-Z0-9._-]+$/.test(String(u || '')) ? u : '';
|
$('#list').innerHTML = filtered.map((a) => `
|
||||||
$('#list').innerHTML = items.map((a) => `
|
|
||||||
<article class="row" data-id="${escapeAttr(a.id)}">
|
<article class="row" data-id="${escapeAttr(a.id)}">
|
||||||
<div class="row-cover" style="background-image:url('${escapeAttr(safeCoverUrl(a.coverImage))}')"></div>
|
<div class="row-cover" style="background-image:url('${escapeAttr(safeCoverUrl(a.coverImage))}')"></div>
|
||||||
<div class="row-main">
|
<div class="row-main">
|
||||||
|
|
@ -218,6 +249,15 @@ async function renderList() {
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#cat-bar').addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.cat-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
activeCat = btn.dataset.cat;
|
||||||
|
document.querySelectorAll('.cat-btn').forEach(b => b.classList.toggle('active', b === btn));
|
||||||
|
renderItems();
|
||||||
|
});
|
||||||
|
|
||||||
$('#list').addEventListener('click', async (e) => {
|
$('#list').addEventListener('click', async (e) => {
|
||||||
const btn = e.target.closest('button[data-action]');
|
const btn = e.target.closest('button[data-action]');
|
||||||
|
|
@ -233,117 +273,206 @@ async function renderList() {
|
||||||
renderList();
|
renderList();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
allItems = await api('/api/articles?limit=500');
|
||||||
|
if (!allItems.length) {
|
||||||
|
$('#list').innerHTML = `<p class="muted">هنوز مطلبی ثبت نشده است.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderItems();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
$('#list').innerHTML = `<p class="error">خطا در بارگذاری مطالب.</p>`;
|
$('#list').innerHTML = `<p class="error">خطا در بارگذاری مطالب.</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEditor(article, draft = {}) {
|
function renderTypeSelector() {
|
||||||
const a = article || {
|
|
||||||
title: draft.title || '', category: draft.category || '', type: draft.type || '',
|
|
||||||
author: '', authorRole: '', authorInitial: '',
|
|
||||||
publishDate: '', pages: 0, price: 0, isFree: true,
|
|
||||||
summary: draft.summary || '', body: '',
|
|
||||||
coverImage: '', tags: [], featured: false,
|
|
||||||
};
|
|
||||||
const isEdit = !!article;
|
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">${isEdit ? 'ویرایش مطلب' : 'مطلب جدید'}</div>
|
<div class="topbar-row">
|
||||||
|
<div class="brand">مطلب جدید</div>
|
||||||
|
<nav class="groups"></nav>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="backBtn" class="ghost">← بازگشت</button>
|
<button id="backBtn" class="ghost">← بازگشت</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tabs"></div>
|
||||||
|
</header>
|
||||||
|
<main class="page">
|
||||||
|
<h2>چه نوع محتوایی میخواهید منتشر کنید؟</h2>
|
||||||
|
<div class="type-grid">
|
||||||
|
${CONTENT_TYPES.map(ct => `
|
||||||
|
<button class="type-card" data-ctype="${ct.key}">
|
||||||
|
<span class="type-icon">${ct.icon}</span>
|
||||||
|
<strong>${ct.label}</strong>
|
||||||
|
<span class="type-desc">${ct.desc}</span>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
`;
|
||||||
|
$('#backBtn').addEventListener('click', () => renderList());
|
||||||
|
document.querySelectorAll('.type-card').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const ct = CONTENT_TYPES.find(c => c.key === btn.dataset.ctype);
|
||||||
|
if (ct.externalTab === 'radar') { renderRadarList(); return; }
|
||||||
|
renderEditor(null, { contentType: ct.key, type: ct.defaultType, category: ct.defaultCategory });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditor(article, draft = {}) {
|
||||||
|
if (!article && !draft.contentType) {
|
||||||
|
renderTypeSelector();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = draft.contentType || inferContentType(article);
|
||||||
|
const ctInfo = CONTENT_TYPES.find(c => c.key === contentType) || CONTENT_TYPES[0];
|
||||||
|
const isEdit = !!article;
|
||||||
|
const a = article || {
|
||||||
|
title: '', category: draft.category || ctInfo.defaultCategory,
|
||||||
|
type: draft.type || ctInfo.defaultType,
|
||||||
|
author: '', authorRole: '', authorInitial: '',
|
||||||
|
publishDate: '', pages: 0, price: 0, isFree: true,
|
||||||
|
summary: '', body: '', coverImage: '', tags: [], featured: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const REPORT_TYPES = TYPES.filter(t => ['quarterly','flash','special','risk','free'].includes(t.value));
|
||||||
|
const hasCover = ['report','news','video','podcast','factory'].includes(contentType);
|
||||||
|
const hasCategory = ['report','news'].includes(contentType);
|
||||||
|
const hasAuthor = ['report','video','podcast'].includes(contentType);
|
||||||
|
const hasPages = ['report','factory'].includes(contentType);
|
||||||
|
const hasPrice = contentType === 'report';
|
||||||
|
const hasBody = ['report','news','factory'].includes(contentType);
|
||||||
|
const hasMediaLink = ['video','podcast'].includes(contentType);
|
||||||
|
const hasReportType = contentType === 'report';
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-row">
|
||||||
|
<div class="brand">${ctInfo.icon} ${isEdit ? 'ویرایش' : 'ایجاد'} — ${ctInfo.label}</div>
|
||||||
|
<nav class="groups"></nav>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="backBtn" class="ghost">← بازگشت</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tabs"></div>
|
||||||
</header>
|
</header>
|
||||||
<main class="page editor">
|
<main class="page editor">
|
||||||
<form id="editorForm" class="grid">
|
<form id="editorForm" class="grid">
|
||||||
|
|
||||||
<div class="col span-2">
|
<div class="col span-2">
|
||||||
<label>عنوان
|
<label>عنوان
|
||||||
<input name="title" value="${escapeAttr(a.title)}" required />
|
<input name="title" value="${escapeAttr(a.title)}" required placeholder="عنوان ${ctInfo.label}…" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${hasCategory ? `
|
||||||
<label>دستهبندی
|
<label>دستهبندی
|
||||||
<select name="category">
|
<select name="category">
|
||||||
<option value="">—</option>
|
<option value="">انتخاب کنید…</option>
|
||||||
${CATEGORIES.map(c => `<option value="${c}" ${c===a.category?'selected':''}>${c}</option>`).join('')}
|
${CATEGORIES.map(c => `<option value="${c}" ${c===a.category?'selected':''}>${c}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>` : `<input type="hidden" name="category" value="${escapeAttr(a.category)}" />`}
|
||||||
|
|
||||||
<label>نوع
|
${hasReportType ? `
|
||||||
|
<label>نوع گزارش
|
||||||
<select name="type">
|
<select name="type">
|
||||||
<option value="">—</option>
|
${REPORT_TYPES.map(t => `<option value="${t.value}" ${t.value===a.type?'selected':''}>${t.label}</option>`).join('')}
|
||||||
${TYPES.map(t => `<option value="${t.value}" ${t.value===a.type?'selected':''}>${t.label}</option>`).join('')}
|
|
||||||
</select>
|
</select>
|
||||||
|
</label>` : `<input type="hidden" name="type" value="${escapeAttr(a.type)}" />`}
|
||||||
|
|
||||||
|
<label>تاریخ انتشار (مثلاً «بهمن ۱۴۰۳»)
|
||||||
|
<input name="publishDate" value="${escapeAttr(a.publishDate)}" placeholder="بهمن ۱۴۰۳" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>نویسنده
|
${hasAuthor ? `
|
||||||
|
<label>نویسنده / مجری
|
||||||
<input name="author" value="${escapeAttr(a.author)}" />
|
<input name="author" value="${escapeAttr(a.author)}" />
|
||||||
</label>
|
</label>
|
||||||
<label>سمت نویسنده
|
<label>سمت
|
||||||
<input name="authorRole" value="${escapeAttr(a.authorRole)}" />
|
<input name="authorRole" value="${escapeAttr(a.authorRole)}" />
|
||||||
</label>
|
</label>
|
||||||
<label>حرف ابتدای نام (برای آواتار)
|
<label>حرف آواتار
|
||||||
<input name="authorInitial" maxlength="2" value="${escapeAttr(a.authorInitial)}" />
|
<input name="authorInitial" maxlength="2" value="${escapeAttr(a.authorInitial)}" />
|
||||||
</label>
|
</label>` : `<input type="hidden" name="author" /><input type="hidden" name="authorRole" /><input type="hidden" name="authorInitial" />`}
|
||||||
<label>تاریخ انتشار (شمسی، مثلاً «بهمن ۱۴۰۳»)
|
|
||||||
<input name="publishDate" value="${escapeAttr(a.publishDate)}" />
|
${hasPages ? `
|
||||||
</label>
|
|
||||||
<label>تعداد صفحات
|
<label>تعداد صفحات
|
||||||
<input name="pages" type="number" min="0" value="${a.pages || 0}" />
|
<input name="pages" type="number" min="0" value="${a.pages || 0}" />
|
||||||
</label>
|
</label>` : `<input type="hidden" name="pages" value="0" />`}
|
||||||
<label>قیمت (ریال) — اگر رایگان است صفر
|
|
||||||
|
${hasPrice ? `
|
||||||
|
<label>قیمت (ریال)
|
||||||
<input name="price" type="number" min="0" value="${a.price || 0}" />
|
<input name="price" type="number" min="0" value="${a.price || 0}" />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="checkbox" style="align-self:flex-end;padding-bottom:10px">
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" name="isFree" ${a.isFree?'checked':''} />
|
<input type="checkbox" name="isFree" ${a.isFree?'checked':''} />
|
||||||
<span>رایگان است</span>
|
<span>رایگان</span>
|
||||||
</label>
|
</label>` : `<input type="hidden" name="price" value="0" /><input type="hidden" name="isFree" value="true" />`}
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" name="featured" ${a.featured?'checked':''} />
|
|
||||||
<span>بهعنوان مطلب ویژه نمایش داده شود</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
|
${hasCover ? `
|
||||||
<div class="col span-2">
|
<div class="col span-2">
|
||||||
<label>برچسبها (با ویرگول فارسی یا انگلیسی جدا کنید)
|
<label>تصویر شاخص
|
||||||
<input name="tags" value="${escapeAttr((a.tags || []).join('، '))}" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col span-2">
|
|
||||||
<label>عکس کاور
|
|
||||||
<div class="cover-row">
|
<div class="cover-row">
|
||||||
<input type="file" id="coverFile" accept="image/*" />
|
<input type="file" id="coverFile" accept="image/*" />
|
||||||
<input type="hidden" name="coverImage" value="${escapeAttr(a.coverImage)}" />
|
<input type="hidden" name="coverImage" value="${escapeAttr(a.coverImage)}" />
|
||||||
<img id="coverPreview" alt="" src="${escapeAttr(a.coverImage)}" />
|
<img id="coverPreview" alt="" src="${escapeAttr(a.coverImage)}" style="${a.coverImage?'':'display:none'}" />
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>` : `<input type="hidden" name="coverImage" value="" />`}
|
||||||
|
|
||||||
<div class="col span-2">
|
<div class="col span-2">
|
||||||
<label>خلاصه
|
<label>${contentType==='radar'?'توضیح سیگنال':'خلاصه'}
|
||||||
<textarea name="summary" rows="3">${escapeHtml(a.summary)}</textarea>
|
<textarea name="summary" rows="3">${escapeHtml(a.summary)}</textarea>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${hasBody ? `
|
||||||
<div class="col span-2">
|
<div class="col span-2">
|
||||||
<label>متن کامل مطلب
|
<label>متن کامل
|
||||||
<textarea name="body" rows="14">${escapeHtml(a.body)}</textarea>
|
<textarea name="body" rows="12">${escapeHtml(a.body)}</textarea>
|
||||||
|
</label>
|
||||||
|
</div>` : ``}
|
||||||
|
|
||||||
|
${hasMediaLink ? `
|
||||||
|
<div class="col span-2">
|
||||||
|
<label>لینک ویدیو / فایل صوتی
|
||||||
|
<input name="body" id="mediaUrl" value="${escapeAttr(a.body)}" placeholder="https://… یا فایل آپلود کن" />
|
||||||
|
</label>
|
||||||
|
<div class="cover-row" style="margin-top:8px">
|
||||||
|
<input type="file" id="mediaFile" accept="video/*,audio/*" />
|
||||||
|
<span id="mediaStatus" class="muted" style="font-size:12px"></span>
|
||||||
|
</div>
|
||||||
|
</div>` : (hasBody ? `` : `<input type="hidden" name="body" value="" />`)}
|
||||||
|
|
||||||
|
<div class="col span-2">
|
||||||
|
<label>برچسبها (با ویرگول جدا کنید)
|
||||||
|
<input name="tags" value="${escapeAttr((a.tags||[]).join('، '))}" placeholder="فولاد، بازار جهانی، …" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="featured" ${a.featured?'checked':''} />
|
||||||
|
<span>مطلب ویژه (نمایش برجسته)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="col span-2 row-end">
|
<div class="col span-2 row-end">
|
||||||
<button type="submit" class="primary">${isEdit ? 'ذخیره تغییرات' : 'ایجاد و انتشار'}</button>
|
<button type="submit" class="primary">${isEdit ? 'ذخیره تغییرات' : `انتشار ${ctInfo.label}`}</button>
|
||||||
|
${!isEdit ? `<button type="button" id="changeTypeBtn" class="ghost">← تغییر نوع محتوا</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
$('#backBtn').addEventListener('click', () => renderList());
|
$('#backBtn').addEventListener('click', () => renderList());
|
||||||
|
document.getElementById('changeTypeBtn')?.addEventListener('click', () => renderTypeSelector());
|
||||||
|
|
||||||
$('#coverFile').addEventListener('change', async (e) => {
|
const coverFile = document.getElementById('coverFile');
|
||||||
|
if (coverFile) {
|
||||||
|
coverFile.addEventListener('change', async (e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
|
|
@ -351,27 +480,49 @@ function renderEditor(article, draft = {}) {
|
||||||
try {
|
try {
|
||||||
const { url } = await api('/api/uploads', { method: 'POST', body: fd, isForm: true });
|
const { url } = await api('/api/uploads', { method: 'POST', body: fd, isForm: true });
|
||||||
$('input[name="coverImage"]').value = url;
|
$('input[name="coverImage"]').value = url;
|
||||||
$('#coverPreview').src = url;
|
const prev = document.getElementById('coverPreview');
|
||||||
|
prev.src = url; prev.style.display = '';
|
||||||
|
} catch (err) { alert('آپلود ناموفق بود: ' + err.message); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// video / podcast media file → object storage (fills the media URL field)
|
||||||
|
const mediaFile = document.getElementById('mediaFile');
|
||||||
|
if (mediaFile) {
|
||||||
|
mediaFile.addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const status = document.getElementById('mediaStatus');
|
||||||
|
if (status) status.textContent = 'در حال آپلود فایل… (بسته به حجم چند لحظه طول میکشد)';
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
try {
|
||||||
|
const { url } = await api('/api/uploads/raw', { method: 'POST', body: fd, isForm: true });
|
||||||
|
document.getElementById('mediaUrl').value = url;
|
||||||
|
if (status) status.textContent = '✓ آپلود شد و در آبجکت استوریج ذخیره شد';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('آپلود ناموفق بود: ' + err.message);
|
if (status) status.textContent = '';
|
||||||
|
alert('آپلود فایل ناموفق بود: ' + err.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$('#editorForm').addEventListener('submit', async (e) => {
|
$('#editorForm').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const fd = new FormData(e.target);
|
const fd = new FormData(e.target);
|
||||||
const tagsRaw = String(fd.get('tags') || '').split(/[,،]/).map(s => s.trim()).filter(Boolean);
|
const tagsRaw = String(fd.get('tags') || '').split(/[,،]/).map(s => s.trim()).filter(Boolean);
|
||||||
|
const isFreeVal = fd.get('isFree');
|
||||||
const payload = {
|
const payload = {
|
||||||
title: fd.get('title'),
|
title: fd.get('title'),
|
||||||
category: fd.get('category') || null,
|
category: fd.get('category') || null,
|
||||||
type: fd.get('type') || null,
|
type: fd.get('type') || null,
|
||||||
author: fd.get('author') || null,
|
author: fd.get('author') || null,
|
||||||
authorRole: fd.get('authorRole') || null,
|
authorRole: fd.get('authorRole') || null,
|
||||||
authorInitial: fd.get('authorInitial') || null,
|
authorInitial:fd.get('authorInitial') || null,
|
||||||
publishDate: fd.get('publishDate') || null,
|
publishDate: fd.get('publishDate') || null,
|
||||||
pages: Number(fd.get('pages') || 0),
|
pages: Number(fd.get('pages') || 0),
|
||||||
price: Number(fd.get('price') || 0),
|
price: Number(fd.get('price') || 0),
|
||||||
isFree: fd.get('isFree') === 'on',
|
isFree: isFreeVal === 'on' || isFreeVal === 'true',
|
||||||
featured: fd.get('featured') === 'on',
|
featured: fd.get('featured') === 'on',
|
||||||
summary: fd.get('summary') || null,
|
summary: fd.get('summary') || null,
|
||||||
body: fd.get('body') || null,
|
body: fd.get('body') || null,
|
||||||
|
|
@ -385,9 +536,7 @@ function renderEditor(article, draft = {}) {
|
||||||
await api('/api/articles', { method: 'POST', body: payload });
|
await api('/api/articles', { method: 'POST', body: payload });
|
||||||
}
|
}
|
||||||
renderList();
|
renderList();
|
||||||
} catch (err) {
|
} catch (err) { alert('ذخیره ناموفق بود: ' + err.message); }
|
||||||
alert('ذخیره ناموفق بود: ' + err.message);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -952,26 +1101,50 @@ function renderPlanEditor(plan) {
|
||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
// RADAR (رادار آینده)
|
// RADAR (رادار آینده)
|
||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
|
// section pages on the site → which raw categories feed them
|
||||||
|
const RADAR_PAGE_SLUGS = [
|
||||||
|
{ slug: 'foresight', label: 'آیندهپژوهی و رصد هوشمند', cats: ['market','tech','commodity','geo','energy'] },
|
||||||
|
{ slug: 'market', label: 'بازار و زنجیره فولاد', cats: ['market','commodity'] },
|
||||||
|
{ slug: 'sustainability', label: 'پایداری و فولاد سبز', cats: ['energy','commodity'] },
|
||||||
|
{ slug: 'geopolitics', label: 'ژئوپلیتیک و اقتصاد جهانی', cats: ['geo','market'] },
|
||||||
|
{ slug: 'technology', label: 'فناوری و نوآوری', cats: ['tech'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function sectionLabelsForCategory(cat) {
|
||||||
|
return RADAR_PAGE_SLUGS.filter(s => s.cats.includes(cat)).map(s => s.label);
|
||||||
|
}
|
||||||
|
|
||||||
async function renderRadarList() {
|
async function renderRadarList() {
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
${topbarHtml('radar', '+ سیگنال جدید')}
|
${topbarHtml('radar', '+ مطلب رادار جدید')}
|
||||||
<main class="page">
|
<main class="page">
|
||||||
<h2>رادار آینده</h2>
|
<h2>رادار آینده</h2>
|
||||||
|
<div id="section-bar" class="cat-bar">
|
||||||
|
<button class="cat-btn active" data-slug="">همه بخشها</button>
|
||||||
|
${RADAR_PAGE_SLUGS.map(s => `<button class="cat-btn" data-slug="${s.slug}">${s.label}</button>`).join('')}
|
||||||
|
</div>
|
||||||
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
|
<div id="list" class="list"><p class="muted">در حال بارگذاری…</p></div>
|
||||||
</main>`;
|
</main>`;
|
||||||
wireTabs();
|
wireTabs();
|
||||||
$('#newBtn').addEventListener('click', () => renderRadarEditor(null));
|
$('#newBtn').addEventListener('click', () => renderRadarEditor(null));
|
||||||
try {
|
|
||||||
const items = await api('/api/radar?limit=200');
|
let allItems = [];
|
||||||
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز سیگنالی ثبت نشده.</p>`; return; }
|
let activeSlug = '';
|
||||||
|
|
||||||
const safeImgUrl = (u) => /^(\/uploads\/[a-zA-Z0-9._-]+|\/[a-zA-Z0-9._\/-]+)$/.test(String(u || '')) ? u : '';
|
const safeImgUrl = (u) => /^(\/uploads\/[a-zA-Z0-9._-]+|\/[a-zA-Z0-9._\/-]+)$/.test(String(u || '')) ? u : '';
|
||||||
$('#list').innerHTML = items.map(r => `
|
|
||||||
|
function renderItems() {
|
||||||
|
const activePage = RADAR_PAGE_SLUGS.find(s => s.slug === activeSlug);
|
||||||
|
const filtered = activePage ? allItems.filter(r => activePage.cats.includes(r.category)) : allItems;
|
||||||
|
if (!filtered.length) { $('#list').innerHTML = `<p class="muted">مطلبی در این بخش یافت نشد.</p>`; return; }
|
||||||
|
$('#list').innerHTML = filtered.map(r => {
|
||||||
|
const sections = sectionLabelsForCategory(r.category);
|
||||||
|
return `
|
||||||
<article class="row" data-id="${escapeAttr(r.id)}">
|
<article class="row" data-id="${escapeAttr(r.id)}">
|
||||||
<div class="row-cover" style="background-image:url('${escapeAttr(safeImgUrl(r.img))}')"></div>
|
<div class="row-cover" style="background-image:url('${escapeAttr(safeImgUrl(r.img))}')"></div>
|
||||||
<div class="row-main">
|
<div class="row-main">
|
||||||
<div class="row-meta">
|
<div class="row-meta">
|
||||||
${r.category ? `<span class="pill">${escapeHtml(RADAR_CATEGORIES.find(c=>c.value===r.category)?.label||r.category)}</span>` : ''}
|
${sections.map(s => `<span class="pill">${escapeHtml(s)}</span>`).join('')}
|
||||||
${r.authorName ? `<span class="muted">${escapeHtml(r.authorName)}</span>` : ''}
|
|
||||||
${r.dateFa ? `<span class="muted">${escapeHtml(r.dateFa)}</span>` : ''}
|
${r.dateFa ? `<span class="muted">${escapeHtml(r.dateFa)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<h3>${escapeHtml(r.titleFa||'')}</h3>
|
<h3>${escapeHtml(r.titleFa||'')}</h3>
|
||||||
|
|
@ -981,51 +1154,147 @@ async function renderRadarList() {
|
||||||
<button data-action="edit">ویرایش</button>
|
<button data-action="edit">ویرایش</button>
|
||||||
<button data-action="delete" class="danger">حذف</button>
|
<button data-action="delete" class="danger">حذف</button>
|
||||||
</div>
|
</div>
|
||||||
</article>`).join('');
|
</article>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#section-bar').addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('.cat-btn'); if (!btn) return;
|
||||||
|
activeSlug = btn.dataset.slug;
|
||||||
|
document.querySelectorAll('#section-bar .cat-btn').forEach(b => b.classList.toggle('active', b === btn));
|
||||||
|
renderItems();
|
||||||
|
});
|
||||||
|
|
||||||
$('#list').addEventListener('click', async ev => {
|
$('#list').addEventListener('click', async ev => {
|
||||||
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
|
const btn = ev.target.closest('button[data-action]'); if (!btn) return;
|
||||||
const id = btn.closest('.row').dataset.id;
|
const id = btn.closest('.row').dataset.id;
|
||||||
if (btn.dataset.action === 'edit') renderRadarEditor(await api(`/api/radar/${id}`));
|
if (btn.dataset.action === 'edit') renderRadarEditor(await api(`/api/radar/${id}`));
|
||||||
else if (btn.dataset.action === 'delete') {
|
else if (btn.dataset.action === 'delete') {
|
||||||
if (!confirm('این سیگنال حذف شود؟')) return;
|
if (!confirm('این مطلب حذف شود؟')) return;
|
||||||
await api(`/api/radar/${id}`, { method: 'DELETE' });
|
await api(`/api/radar/${id}`, { method: 'DELETE' });
|
||||||
renderRadarList();
|
renderRadarList();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
allItems = await api('/api/radar?limit=500');
|
||||||
|
if (!allItems.length) { $('#list').innerHTML = `<p class="muted">هنوز مطلبی ثبت نشده.</p>`; return; }
|
||||||
|
renderItems();
|
||||||
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
|
} catch (err) { $('#list').innerHTML = `<p class="error">خطا: ${escapeHtml(err.message)}</p>`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRadarEditor(item) {
|
function renderRadarEditor(item) {
|
||||||
const r = item || { category:'', img:'', authorName:'', authorAvatar:'',
|
const r = item || { category:'', img:'', authorName:'', authorAvatar:'',
|
||||||
titleFa:'', dateFa:'', excerptFa:'', titleEn:'', dateEn:'', excerptEn:'', sortOrder:0 };
|
titleFa:'', dateFa:'', excerptFa:'', bodyFa:'', analysisFa:'',
|
||||||
|
titleEn:'', dateEn:'', excerptEn:'', bodyEn:'', analysisEn:'', sortOrder:0 };
|
||||||
const isEdit = !!item;
|
const isEdit = !!item;
|
||||||
|
|
||||||
|
function sectionBadges(cat) {
|
||||||
|
const labels = sectionLabelsForCategory(cat);
|
||||||
|
if (!labels.length) return '<span class="muted" style="font-size:12px">—</span>';
|
||||||
|
return labels.map(l => `<span class="pill green">${escapeHtml(l)}</span>`).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">${isEdit?'ویرایش سیگنال':'سیگنال جدید'}</div>
|
<div class="topbar-row">
|
||||||
|
<div class="brand">🔭 ${isEdit?'ویرایش':'مطلب جدید'} — رادار آینده</div>
|
||||||
|
<nav class="groups"></nav>
|
||||||
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
|
<div class="actions"><button id="backBtn" class="ghost">← بازگشت</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="tabs"></div>
|
||||||
</header>
|
</header>
|
||||||
<main class="page editor">
|
<main class="page editor">
|
||||||
<form id="editorForm" class="grid">
|
<form id="editorForm" class="grid">
|
||||||
<label>عنوان (فارسی) <input name="titleFa" value="${escapeAttr(r.titleFa)}" required /></label>
|
|
||||||
<label>عنوان (انگلیسی) <input name="titleEn" value="${escapeAttr(r.titleEn||'')}" /></label>
|
<div class="col span-2">
|
||||||
<label>دستهبندی
|
<label>بخش سایت
|
||||||
<select name="category">
|
<select name="category" id="catSelect">
|
||||||
<option value="">—</option>
|
<option value="">انتخاب کنید…</option>
|
||||||
${RADAR_CATEGORIES.map(c=>`<option value="${c.value}" ${c.value===r.category?'selected':''}>${c.label}</option>`).join('')}
|
${RADAR_CATEGORIES.map(c=>`<option value="${c.value}" ${c.value===r.category?'selected':''}>${c.label}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${r.sortOrder||0}" /></label>
|
<div id="sectionBadges" style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap;align-items:center">
|
||||||
|
<span class="muted" style="font-size:12px">نمایش در بخشهای سایت:</span>
|
||||||
|
${sectionBadges(r.category)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col span-2">
|
||||||
|
<label>تصویر شاخص
|
||||||
|
<div class="cover-row">
|
||||||
|
<input type="file" id="radarCoverFile" accept="image/*" />
|
||||||
|
<input type="hidden" name="img" value="${escapeAttr(r.img||'')}" />
|
||||||
|
<img id="radarCoverPreview" alt="" src="${escapeAttr(r.img||'')}" style="${r.img?'':'display:none'}" />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col span-2" style="border-top:1px solid var(--rule);padding-top:16px;margin-top:4px">
|
||||||
|
<strong style="font-size:13px;color:var(--ink-3)">فارسی</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col span-2">
|
||||||
|
<label>عنوان (فارسی) <input name="titleFa" value="${escapeAttr(r.titleFa)}" required /></label>
|
||||||
|
</div>
|
||||||
|
<label>تاریخ (فارسی) <input name="dateFa" value="${escapeAttr(r.dateFa||'')}" placeholder="۱۴ خرداد ۱۴۰۴" /></label>
|
||||||
<label>نام نویسنده <input name="authorName" value="${escapeAttr(r.authorName||'')}" /></label>
|
<label>نام نویسنده <input name="authorName" value="${escapeAttr(r.authorName||'')}" /></label>
|
||||||
<label>آواتار نویسنده (URL) <input name="authorAvatar" value="${escapeAttr(r.authorAvatar||'')}" /></label>
|
<div class="col span-2">
|
||||||
<div class="col span-2"><label>تصویر (URL) <input name="img" value="${escapeAttr(r.img||'')}" /></label></div>
|
<label>چکیده (فارسی) <textarea name="excerptFa" rows="3">${escapeHtml(r.excerptFa||'')}</textarea></label>
|
||||||
<label>تاریخ (فارسی) <input name="dateFa" value="${escapeAttr(r.dateFa||'')}" /></label>
|
</div>
|
||||||
<label>تاریخ (انگلیسی) <input name="dateEn" value="${escapeAttr(r.dateEn||'')}" /></label>
|
<div class="col span-2">
|
||||||
<div class="col span-2"><label>چکیده (فارسی) <textarea name="excerptFa" rows="4">${escapeHtml(r.excerptFa||'')}</textarea></label></div>
|
<label>متن کامل (فارسی) <textarea name="bodyFa" rows="8">${escapeHtml(r.bodyFa||'')}</textarea></label>
|
||||||
<div class="col span-2"><label>چکیده (انگلیسی) <textarea name="excerptEn" rows="4">${escapeHtml(r.excerptEn||'')}</textarea></label></div>
|
</div>
|
||||||
<div class="col span-2 row-end"><button type="submit" class="primary">${isEdit?'ذخیره':'ایجاد'}</button></div>
|
<div class="col span-2">
|
||||||
|
<label>تحلیل استراتژیک (فارسی — برای اعضا) <textarea name="analysisFa" rows="4">${escapeHtml(r.analysisFa||'')}</textarea></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col span-2" style="border-top:1px solid var(--rule);padding-top:16px;margin-top:4px">
|
||||||
|
<strong style="font-size:13px;color:var(--ink-3)">English</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col span-2">
|
||||||
|
<label>Title (English) <input name="titleEn" value="${escapeAttr(r.titleEn||'')}" /></label>
|
||||||
|
</div>
|
||||||
|
<label>Date (English) <input name="dateEn" value="${escapeAttr(r.dateEn||'')}" placeholder="June 4, 2025" /></label>
|
||||||
|
<div></div>
|
||||||
|
<div class="col span-2">
|
||||||
|
<label>Excerpt (English) <textarea name="excerptEn" rows="3">${escapeHtml(r.excerptEn||'')}</textarea></label>
|
||||||
|
</div>
|
||||||
|
<div class="col span-2">
|
||||||
|
<label>Body (English) <textarea name="bodyEn" rows="8">${escapeHtml(r.bodyEn||'')}</textarea></label>
|
||||||
|
</div>
|
||||||
|
<div class="col span-2">
|
||||||
|
<label>Strategic Analysis (English) <textarea name="analysisEn" rows="4">${escapeHtml(r.analysisEn||'')}</textarea></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>ترتیب نمایش <input name="sortOrder" type="number" value="${r.sortOrder||0}" /></label>
|
||||||
|
|
||||||
|
<div class="col span-2 row-end">
|
||||||
|
<button type="submit" class="primary">${isEdit?'ذخیره تغییرات':'انتشار مطلب رادار'}</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</main>`;
|
</main>`;
|
||||||
|
|
||||||
$('#backBtn').addEventListener('click', () => renderRadarList());
|
$('#backBtn').addEventListener('click', () => renderRadarList());
|
||||||
|
|
||||||
|
document.getElementById('catSelect').addEventListener('change', e => {
|
||||||
|
const cat = e.target.value;
|
||||||
|
const badgesEl = document.getElementById('sectionBadges');
|
||||||
|
badgesEl.innerHTML = `<span class="muted" style="font-size:12px">نمایش در بخشهای سایت:</span> ${sectionBadges(cat)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('radarCoverFile').addEventListener('change', async e => {
|
||||||
|
const file = e.target.files?.[0]; if (!file) return;
|
||||||
|
const fd = new FormData(); fd.append('file', file);
|
||||||
|
try {
|
||||||
|
const { url } = await api('/api/uploads', { method: 'POST', body: fd, isForm: true });
|
||||||
|
$('input[name="img"]').value = url;
|
||||||
|
const prev = document.getElementById('radarCoverPreview');
|
||||||
|
prev.src = url; prev.style.display = '';
|
||||||
|
} catch (err) { alert('آپلود ناموفق: ' + err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
$('#editorForm').addEventListener('submit', async ev => {
|
$('#editorForm').addEventListener('submit', async ev => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const fd = new FormData(ev.target);
|
const fd = new FormData(ev.target);
|
||||||
|
|
@ -1033,9 +1302,12 @@ function renderRadarEditor(item) {
|
||||||
titleFa: fd.get('titleFa'), titleEn: fd.get('titleEn')||null,
|
titleFa: fd.get('titleFa'), titleEn: fd.get('titleEn')||null,
|
||||||
category: fd.get('category')||null,
|
category: fd.get('category')||null,
|
||||||
img: fd.get('img')||null,
|
img: fd.get('img')||null,
|
||||||
authorName: fd.get('authorName')||null, authorAvatar: fd.get('authorAvatar')||null,
|
authorName: fd.get('authorName')||null,
|
||||||
|
authorAvatar: r.authorAvatar || null,
|
||||||
dateFa: fd.get('dateFa')||null, dateEn: fd.get('dateEn')||null,
|
dateFa: fd.get('dateFa')||null, dateEn: fd.get('dateEn')||null,
|
||||||
excerptFa: fd.get('excerptFa')||null, excerptEn: fd.get('excerptEn')||null,
|
excerptFa: fd.get('excerptFa')||null, excerptEn: fd.get('excerptEn')||null,
|
||||||
|
bodyFa: fd.get('bodyFa')||null, bodyEn: fd.get('bodyEn')||null,
|
||||||
|
analysisFa: fd.get('analysisFa')||null, analysisEn: fd.get('analysisEn')||null,
|
||||||
sortOrder: Number(fd.get('sortOrder')||0),
|
sortOrder: Number(fd.get('sortOrder')||0),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
|
@ -1046,17 +1318,6 @@ function renderRadarEditor(item) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════
|
|
||||||
// RADAR PAGES (صفحات رادار — hero + banner per category)
|
|
||||||
// ═══════════════════════════════════════════════
|
|
||||||
const RADAR_PAGE_SLUGS = [
|
|
||||||
{ slug: 'foresight', label: 'آیندهپژوهی و رصد هوشمند' },
|
|
||||||
{ slug: 'market', label: 'بازار و زنجیره فولاد' },
|
|
||||||
{ slug: 'sustainability', label: 'پایداری و فولاد سبز' },
|
|
||||||
{ slug: 'geopolitics', label: 'ژئوپلیتیک و اقتصاد جهانی' },
|
|
||||||
{ slug: 'technology', label: 'فناوری و نوآوری' },
|
|
||||||
];
|
|
||||||
|
|
||||||
async function renderRadarPagesList() {
|
async function renderRadarPagesList() {
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
${topbarHtml('radarPages', '')}
|
${topbarHtml('radarPages', '')}
|
||||||
|
|
@ -1958,9 +2219,7 @@ function renderImport() {
|
||||||
statusEl.innerHTML = '';
|
statusEl.innerHTML = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/admin/import', { method:'POST', body: JSON.stringify(data) });
|
const result = await api('/api/admin/import', { method:'POST', body: data });
|
||||||
if (!res.ok) { const e = await res.json(); throw new Error(e.error || res.status); }
|
|
||||||
const result = await res.json();
|
|
||||||
|
|
||||||
resultEl.innerHTML = `
|
resultEl.innerHTML = `
|
||||||
<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:10px;padding:1.25rem 1.5rem">
|
<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:10px;padding:1.25rem 1.5rem">
|
||||||
|
|
@ -2023,10 +2282,10 @@ function renderPdfAnalyze() {
|
||||||
reader.onerror = reject;
|
reader.onerror = reject;
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
const _token = sessionStorage.getItem('sf_token') || '';
|
// session travels in the HttpOnly cookie (credentials:'include'); no token in JS
|
||||||
const _res = await fetch('/api/pdf-analyze', {
|
const _res = await fetch('/api/pdf-analyze', {
|
||||||
method: 'POST', credentials: 'include',
|
method: 'POST', credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json', ...(_token ? { 'Authorization': 'Bearer ' + _token } : {}) },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ base64 }),
|
body: JSON.stringify({ base64 }),
|
||||||
});
|
});
|
||||||
if (!_res.ok) {
|
if (!_res.ok) {
|
||||||
|
|
@ -2079,11 +2338,11 @@ function renderPdfAnalyze() {
|
||||||
async function runGenerate(textSrc, titleSrc, catKey) {
|
async function runGenerate(textSrc, titleSrc, catKey) {
|
||||||
const gr = $('#generateResult');
|
const gr = $('#generateResult');
|
||||||
gr.innerHTML = '<p class="muted" style="font-size:13px">در حال تولید محتوا... ⏳</p>';
|
gr.innerHTML = '<p class="muted" style="font-size:13px">در حال تولید محتوا... ⏳</p>';
|
||||||
const _token = sessionStorage.getItem('sf_token') || '';
|
// session travels in the HttpOnly cookie (credentials:'include'); no token in JS
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/pdf-generate', {
|
const r = await fetch('/api/pdf-generate', {
|
||||||
method: 'POST', credentials: 'include',
|
method: 'POST', credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json', ...(_token ? { 'Authorization': 'Bearer ' + _token } : {}) },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text: textSrc, category: SECTION_LABELS[catKey] || catKey }),
|
body: JSON.stringify({ text: textSrc, category: SECTION_LABELS[catKey] || catKey }),
|
||||||
});
|
});
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
|
|
@ -2190,11 +2449,11 @@ function renderPdfAnalyze() {
|
||||||
$('#generateContentBtn').addEventListener('click', async () => {
|
$('#generateContentBtn').addEventListener('click', async () => {
|
||||||
const gr = $('#generateResult');
|
const gr = $('#generateResult');
|
||||||
gr.innerHTML = '<p class="muted" style="font-size:13px">در حال تولید محتوا... ⏳</p>';
|
gr.innerHTML = '<p class="muted" style="font-size:13px">در حال تولید محتوا... ⏳</p>';
|
||||||
const _token = sessionStorage.getItem('sf_token') || '';
|
// session travels in the HttpOnly cookie (credentials:'include'); no token in JS
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/pdf-generate', {
|
const r = await fetch('/api/pdf-generate', {
|
||||||
method: 'POST', credentials: 'include',
|
method: 'POST', credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json', ...(_token ? { 'Authorization': 'Bearer ' + _token } : {}) },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text: data.excerpt + '\n\n' + (data.text || '').slice(0, 5000), category: c.label }),
|
body: JSON.stringify({ text: data.excerpt + '\n\n' + (data.text || '').slice(0, 5000), category: c.label }),
|
||||||
});
|
});
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
|
|
|
||||||
|
|
@ -104,21 +104,43 @@ textarea { resize: vertical; min-height: 80px; }
|
||||||
.card button { width: 100%; margin-top: 20px; }
|
.card button { width: 100%; margin-top: 20px; }
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.topbar-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px 32px;
|
padding: 12px 32px;
|
||||||
background: white;
|
|
||||||
border-bottom: 1px solid var(--rule);
|
border-bottom: 1px solid var(--rule);
|
||||||
}
|
}
|
||||||
.topbar .brand { font-weight: 900; font-size: 15px; letter-spacing: -.3px; }
|
.topbar .brand { font-weight: 900; font-size: 15px; letter-spacing: -.3px; }
|
||||||
.topbar .actions { display: flex; gap: 10px; }
|
.topbar .actions { display: flex; gap: 10px; }
|
||||||
|
|
||||||
.tabs {
|
.groups {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
flex: 1;
|
}
|
||||||
justify-content: center;
|
.groups .group-btn {
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
.groups .group-btn:hover { background: var(--rule); color: var(--ink); }
|
||||||
|
.groups .group-btn.group-active { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0 32px;
|
||||||
|
background: white;
|
||||||
}
|
}
|
||||||
.tabs button {
|
.tabs button {
|
||||||
border: none;
|
border: none;
|
||||||
|
|
@ -126,18 +148,12 @@ textarea { resize: vertical; min-height: 80px; }
|
||||||
color: var(--ink-3);
|
color: var(--ink-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 8px 18px;
|
padding: 10px 16px;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
.tabs button:hover {
|
.tabs button:hover { background: transparent; color: var(--ink); }
|
||||||
background: transparent;
|
.tabs button.tab-active { color: var(--ink); border-bottom-color: var(--red); }
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
.tabs button.tab-active {
|
|
||||||
color: var(--ink);
|
|
||||||
border-bottom-color: var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.risk-row { grid-template-columns: 1fr auto; }
|
.risk-row { grid-template-columns: 1fr auto; }
|
||||||
.risk-row .row-meta strong { font-size: 13px; }
|
.risk-row .row-meta strong { font-size: 13px; }
|
||||||
|
|
@ -149,7 +165,50 @@ textarea { resize: vertical; min-height: 80px; }
|
||||||
.pill.level-opportunity { background: #1e40af; color: white; }
|
.pill.level-opportunity { background: #1e40af; color: white; }
|
||||||
|
|
||||||
.page { max-width: 1100px; margin: 0 auto; padding: 32px; }
|
.page { max-width: 1100px; margin: 0 auto; padding: 32px; }
|
||||||
.page h2 { font-size: 22px; letter-spacing: -.5px; margin: 0 0 24px; }
|
.page h2 { font-size: 22px; letter-spacing: -.5px; margin: 0 0 16px; }
|
||||||
|
|
||||||
|
.cat-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.cat-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.cat-btn:hover { background: var(--rule); }
|
||||||
|
.cat-btn.active { background: var(--accent, #2563eb); color: #fff; border-color: transparent; }
|
||||||
|
|
||||||
|
.type-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.type-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 20px 20px 18px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: right;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.type-card:hover { border-color: var(--ink); box-shadow: 0 2px 8px rgba(0,0,0,.08); background: white; color: var(--ink); }
|
||||||
|
.type-icon { font-size: 28px; line-height: 1; }
|
||||||
|
.type-card strong { font-size: 15px; font-weight: 800; }
|
||||||
|
.type-desc { font-size: 12px; color: var(--ink-5); font-weight: 400; line-height: 1.4; }
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -136,8 +136,8 @@ export async function scrapeOnce() {
|
||||||
// At least one source returned — replace the rows from those sources only.
|
// At least one source returned — replace the rows from those sources only.
|
||||||
// This way a temporary failure of one page doesn't wipe the other's data.
|
// This way a temporary failure of one page doesn't wipe the other's data.
|
||||||
for (const { src, items } of successful) {
|
for (const { src, items } of successful) {
|
||||||
db.prepare('DELETE FROM prices WHERE source_url = ?').run(src.url);
|
await db.prepare('DELETE FROM prices WHERE source_url = ?').run(src.url);
|
||||||
for (const it of items) upsertPrice(it);
|
for (const it of items) await upsertPrice(it);
|
||||||
}
|
}
|
||||||
return successful.reduce((s, x) => s + x.items.length, 0);
|
return successful.reduce((s, x) => s + x.items.length, 0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,702 +1,184 @@
|
||||||
/**
|
// panel/seed-content.js
|
||||||
* One-shot content seeder — run once:
|
// Run: node panel/seed-content.js
|
||||||
* node panel/seed-content.js
|
// Seeds all hardcoded frontend content into the panel DB via the REST API.
|
||||||
*
|
// Idempotent: skips any endpoint that already has data.
|
||||||
* Inserts (or replaces) all hardcoded frontend content into the panel DB.
|
|
||||||
* Safe to re-run; uses INSERT OR REPLACE throughout.
|
|
||||||
*/
|
|
||||||
import { db } from './db.js';
|
|
||||||
|
|
||||||
const seed = db.transaction(() => {
|
const BASE = process.env.SEED_BASE || `http://localhost:${process.env.PORT || 3000}`;
|
||||||
|
const USERNAME = process.env.ADMIN_USERNAME || 'admin';
|
||||||
|
const PASSWORD = process.env.ADMIN_PASSWORD || 'Andishkade1403';
|
||||||
|
|
||||||
/* ─── REPORTS → articles table ─────────────────────────────── */
|
let cookie = '';
|
||||||
const upsertArticle = db.prepare(`
|
|
||||||
INSERT OR REPLACE INTO articles
|
|
||||||
(id, title, category, type, author, author_role, author_initial,
|
|
||||||
publish_date, pages, price, is_free, summary, tags, featured,
|
|
||||||
created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(@id, @title, @category, @type, @author, @author_role, @author_initial,
|
|
||||||
@publish_date, @pages, @price, @is_free, @summary, @tags, @featured,
|
|
||||||
datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
|
|
||||||
const reports = [
|
async function req(path, { method = 'GET', body } = {}) {
|
||||||
{
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
id: 'iran-steel-global-trade-scenarios-1404',
|
if (cookie) headers['Cookie'] = cookie;
|
||||||
title: 'ایران در نقشه جدید تجارت فولاد جهان: چهار سناریو برای ۱۴۰۴',
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
category: 'بازار جهانی', type: 'special',
|
method, headers,
|
||||||
author: 'دکتر علی محمدی', author_role: 'مدیر ارشد پژوهش', author_initial: 'م',
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
publish_date: 'بهمن ۱۴۰۳', pages: 84, price: 850000, is_free: 0, featured: 1,
|
});
|
||||||
summary: 'این گزارش ویژه با تحلیل روندهای کلان اقتصاد جهانی، چهار سناریوی محتمل برای جایگاه صادرات فولاد ایران در سال ۱۴۰۴ را بررسی میکند. مدلسازی کمی بر اساس دادههای تجارت ۱۵ کشور رقیب انجام شده و توصیههای سیاستگذاری مشخص ارائه میدهد.',
|
const text = await res.text();
|
||||||
tags: JSON.stringify(['تجارت جهانی', 'سناریوسازی', 'صادرات', 'سیاستگذاری', '۱۴۰۴']),
|
if (!res.ok) throw new Error(`${method} ${path} → ${res.status}: ${text}`);
|
||||||
},
|
const sc = res.headers.get('set-cookie');
|
||||||
{
|
if (sc) cookie = sc.split(';')[0];
|
||||||
id: 'steel-energy-consumption-efficiency-1403',
|
return text ? JSON.parse(text) : null;
|
||||||
title: 'مصرف انرژی در فولادسازی ایران: مقایسه با استانداردهای جهانی',
|
}
|
||||||
category: 'انرژی و ESG', type: 'free',
|
|
||||||
author: 'مهندس سارا رضایی', author_role: 'پژوهشگر ارشد انرژی', author_initial: 'ر',
|
|
||||||
publish_date: 'دی ۱۴۰۳', pages: 28, price: 0, is_free: 1, featured: 0,
|
|
||||||
summary: 'این گزارش رایگان مصرف انرژی واحدهای فولادسازی ایران را با میانگین جهانی و استانداردهای IEA مقایسه میکند. شکاف بهرهوری انرژی در کورههای قوس الکتریکی و کنورتور شناسایی شده است.',
|
|
||||||
tags: JSON.stringify(['انرژی', 'بهرهوری', 'ESG', 'کوره قوس الکتریکی', 'استاندارد جهانی']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'global-steel-market-q3-1403',
|
|
||||||
title: 'گزارش فصلی بازار جهانی فولاد: پاییز ۱۴۰۳',
|
|
||||||
category: 'بازار جهانی', type: 'quarterly',
|
|
||||||
author: 'دکتر حسین کریمی', author_role: 'تحلیلگر ارشد بازار', author_initial: 'ک',
|
|
||||||
publish_date: 'آذر ۱۴۰۳', pages: 52, price: 450000, is_free: 0, featured: 0,
|
|
||||||
summary: 'تحلیل جامع قیمتها، تقاضا و عرضه در بازار جهانی فولاد طی فصل پاییز ۱۴۰۳. روند کاهش تقاضای چین و افزایش ظرفیت تولید هند بهتفصیل بررسی شده است. چشمانداز قیمتی برای زمستان ۱۴۰۳ نیز ارائه میشود.',
|
|
||||||
tags: JSON.stringify(['بازار جهانی', 'قیمتگذاری', 'چین', 'هند', 'پاییز ۱۴۰۳']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'iran-steel-export-sanctions-impact-1403',
|
|
||||||
title: 'اثر تحریمهای جدید بر مسیرهای صادراتی فولاد ایران',
|
|
||||||
category: 'صادرات', type: 'risk',
|
|
||||||
author: 'دکتر فاطمه احمدی', author_role: 'مدیر تحقیقات ریسک', author_initial: 'ا',
|
|
||||||
publish_date: 'آذر ۱۴۰۳', pages: 36, price: 380000, is_free: 0, featured: 0,
|
|
||||||
summary: 'این گزارش اثرات بسته تحریمی اخیر بر کانالهای صادرات فولاد ایران به بازارهای منطقهای را ارزیابی میکند. ریسکهای لجستیکی، بانکی و قراردادی مستند شده است.',
|
|
||||||
tags: JSON.stringify(['تحریم', 'صادرات', 'ریسک', 'لجستیک', 'بازارهای منطقهای']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'flash-iron-ore-price-surge-1403',
|
|
||||||
title: 'فلش: جهش قیمت سنگآهن و تأثیر بر هزینه تمامشده فولاد ایران',
|
|
||||||
category: 'بازار جهانی', type: 'flash',
|
|
||||||
author: 'مهندس رضا توکلی', author_role: 'تحلیلگر بازار کامودیتی', author_initial: 'ت',
|
|
||||||
publish_date: 'آبان ۱۴۰۳', pages: 12, price: 120000, is_free: 0, featured: 0,
|
|
||||||
summary: 'گزارش فوری درباره افزایش ۱۸ درصدی قیمت سنگآهن در بورس دالیان و اثر فوری آن بر هزینه تمامشده تولید فولاد در ایران. برآورد اثر بر حاشیه سود کارخانههای داخلی ارائه شده است.',
|
|
||||||
tags: JSON.stringify(['سنگآهن', 'قیمت', 'هزینه تمامشده', 'فوری']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'domestic-production-dri-outlook-1403',
|
|
||||||
title: 'چشمانداز تولید آهن احیای مستقیم ایران تا افق ۱۴۰۵',
|
|
||||||
category: 'تولید داخلی', type: 'quarterly',
|
|
||||||
author: 'دکتر علی محمدی', author_role: 'مدیر ارشد پژوهش', author_initial: 'م',
|
|
||||||
publish_date: 'مهر ۱۴۰۳', pages: 44, price: 420000, is_free: 0, featured: 0,
|
|
||||||
summary: 'ایران با بیش از ۳۰ میلیون تن ظرفیت DRI، رتبه اول جهان را دارد؛ این گزارش مسیر توسعه این صنعت تا ۱۴۰۵ را مدلسازی میکند. بررسی پروژههای در دست احداث و موانع تأمین گاز بهتفصیل آمده است.',
|
|
||||||
tags: JSON.stringify(['DRI', 'آهن احیای مستقیم', 'ظرفیت', 'تولید', 'گاز طبیعی']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'esg-carbon-emissions-steel-iran-1403',
|
|
||||||
title: 'کربنزدایی در صنعت فولاد ایران: الزامات و فرصتها',
|
|
||||||
category: 'انرژی و ESG', type: 'special',
|
|
||||||
author: 'مهندس سارا رضایی', author_role: 'پژوهشگر ارشد انرژی', author_initial: 'ر',
|
|
||||||
publish_date: 'شهریور ۱۴۰۳', pages: 58, price: 520000, is_free: 0, featured: 0,
|
|
||||||
summary: 'الزامات آینده CBAM اتحادیه اروپا و اثر آن بر رقابتپذیری صادرات فولاد ایران تحلیل شده است. مسیرهای فناوری کربنزدایی شامل هیدروژن سبز و CCS برای شرایط ایران ارزیابی شدهاند.',
|
|
||||||
tags: JSON.stringify(['ESG', 'کربن', 'CBAM', 'هیدروژن سبز', 'محیط زیست']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'policy-steel-pricing-reform-1403',
|
|
||||||
title: 'اصلاح ساختار قیمتگذاری فولاد: درسهایی از تجربه ترکیه و هند',
|
|
||||||
category: 'سیاستگذاری', type: 'quarterly',
|
|
||||||
author: 'دکتر فاطمه احمدی', author_role: 'مدیر تحقیقات ریسک', author_initial: 'ا',
|
|
||||||
publish_date: 'مرداد ۱۴۰۳', pages: 46, price: 390000, is_free: 0, featured: 0,
|
|
||||||
summary: 'مقایسه تطبیقی ساختار قیمتگذاری فولاد در ایران، ترکیه و هند با تمرکز بر نقش دولت و مکانیسمهای بازار. اثربخشی سیاستهای قیمتگذاری دستوری در حفظ تعادل عرضه-تقاضا ارزیابی شده است.',
|
|
||||||
tags: JSON.stringify(['سیاستگذاری', 'قیمتگذاری', 'ترکیه', 'هند', 'اصلاح ساختار']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'flash-china-steel-overcapacity-1403',
|
|
||||||
title: 'فلش: مازاد ظرفیت فولاد چین و موج صادراتی به آسیای غربی',
|
|
||||||
category: 'بازار جهانی', type: 'flash',
|
|
||||||
author: 'دکتر حسین کریمی', author_role: 'تحلیلگر ارشد بازار', author_initial: 'ک',
|
|
||||||
publish_date: 'تیر ۱۴۰۳', pages: 10, price: 110000, is_free: 0, featured: 0,
|
|
||||||
summary: 'چین در نیمه اول ۲۰۲۴ رکورد صادرات فولاد را با ۵۴ میلیون تن شکست. این گزارش فوری تأثیر مستقیم این موج صادراتی بر بازارهای ایران، عراق و امارات را تحلیل میکند.',
|
|
||||||
tags: JSON.stringify(['چین', 'مازاد ظرفیت', 'صادرات', 'آسیای غربی', 'رقابت']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'risk-geopolitical-middle-east-steel-1403',
|
|
||||||
title: 'ریسکهای ژئوپلیتیک خاورمیانه و زنجیره تأمین فولاد ایران',
|
|
||||||
category: 'ریسک و بحران', type: 'risk',
|
|
||||||
author: 'دکتر فاطمه احمدی', author_role: 'مدیر تحقیقات ریسک', author_initial: 'ا',
|
|
||||||
publish_date: 'خرداد ۱۴۰۳', pages: 40, price: 360000, is_free: 0, featured: 0,
|
|
||||||
summary: 'تحلیل ریسک ژئوپلیتیک منطقه خاورمیانه بر امنیت زنجیره تأمین فولاد ایران از زاویه واردات مواد اولیه و صادرات محصول نهایی. سناریوهای بحران در مسیرهای دریایی خلیج فارس و دریای سرخ مدلسازی شدهاند.',
|
|
||||||
tags: JSON.stringify(['ژئوپلیتیک', 'خاورمیانه', 'زنجیره تأمین', 'ریسک', 'دریای سرخ']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'export-iraq-market-analysis-1403',
|
|
||||||
title: 'تحلیل بازار فولاد عراق: فرصتهای صادراتی ایران',
|
|
||||||
category: 'صادرات', type: 'quarterly',
|
|
||||||
author: 'مهندس رضا توکلی', author_role: 'تحلیلگر بازار کامودیتی', author_initial: 'ت',
|
|
||||||
publish_date: 'اردیبهشت ۱۴۰۳', pages: 38, price: 340000, is_free: 0, featured: 0,
|
|
||||||
summary: 'عراق با تقاضای سالانه بیش از ۵ میلیون تن فولاد، مهمترین بازار صادراتی ایران است. این گزارش رقابتپذیری فولاد ایران در برابر رقبای ترکیه، چین و اوکراین در بازار عراق را ارزیابی میکند.',
|
|
||||||
tags: JSON.stringify(['عراق', 'صادرات', 'بازار', 'رقابتپذیری', 'میلگرد']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'domestic-capacity-utilization-1402',
|
|
||||||
title: 'ظرفیت بهرهبرداری واحدهای فولادسازی ایران: آسیبشناسی و راهکار',
|
|
||||||
category: 'تولید داخلی', type: 'quarterly',
|
|
||||||
author: 'دکتر علی محمدی', author_role: 'مدیر ارشد پژوهش', author_initial: 'م',
|
|
||||||
publish_date: 'فروردین ۱۴۰۳', pages: 50, price: 410000, is_free: 0, featured: 0,
|
|
||||||
summary: 'میانگین ضریب بهرهبرداری کارخانههای فولادسازی ایران به ۶۸ درصد رسیده که پایینتر از میانگین جهانی ۷۶ درصد است. این گزارش موانع عملیاتی، تأمین مواد اولیه و بازار را شناسایی کرده است.',
|
|
||||||
tags: JSON.stringify(['ظرفیت', 'بهرهبرداری', 'کارایی', 'تولید', 'کارخانه']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'flash-rebar-demand-construction-1402',
|
|
||||||
title: 'فلش: کاهش تقاضای میلگرد در بخش ساختمانی و اثر بر کارخانهها',
|
|
||||||
category: 'تولید داخلی', type: 'flash',
|
|
||||||
author: 'مهندس سارا رضایی', author_role: 'پژوهشگر ارشد انرژی', author_initial: 'ر',
|
|
||||||
publish_date: 'اسفند ۱۴۰۲', pages: 14, price: 115000, is_free: 0, featured: 0,
|
|
||||||
summary: 'رکود در بازار مسکن منجر به کاهش ۱۲ درصدی تقاضای میلگرد در زمستان ۱۴۰۲ شده است. این گزارش فوری اثر این کاهش بر درآمد و برنامه تولید کارخانههای میلگردساز را ارزیابی میکند.',
|
|
||||||
tags: JSON.stringify(['میلگرد', 'ساختمان', 'تقاضا', 'رکود', 'تولید داخلی']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'policy-privatization-steel-companies-1402',
|
|
||||||
title: 'واگذاری شرکتهای فولادی دولتی: ارزیابی تجربه یک دهه خصوصیسازی',
|
|
||||||
category: 'سیاستگذاری', type: 'special',
|
|
||||||
author: 'دکتر حسین کریمی', author_role: 'تحلیلگر ارشد بازار', author_initial: 'ک',
|
|
||||||
publish_date: 'بهمن ۱۴۰۲', pages: 62, price: 560000, is_free: 0, featured: 0,
|
|
||||||
summary: 'ارزیابی جامع نتایج خصوصیسازی شرکتهای فولادی بزرگ ایران در یک دهه گذشته با شاخصهای بهرهوری، اشتغال و سرمایهگذاری. الگوی بهینه مشارکت دولتی-خصوصی در صنعت فولاد پیشنهاد میشود.',
|
|
||||||
tags: JSON.stringify(['خصوصیسازی', 'سیاستگذاری', 'دولت', 'مدیریت', 'بهرهوری']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'risk-water-scarcity-steel-industry-1402',
|
|
||||||
title: 'بحران آب و تهدیدات پایداری صنعت فولاد ایران',
|
|
||||||
category: 'ریسک و بحران', type: 'risk',
|
|
||||||
author: 'مهندس سارا رضایی', author_role: 'پژوهشگر ارشد انرژی', author_initial: 'ر',
|
|
||||||
publish_date: 'دی ۱۴۰۲', pages: 42, price: 370000, is_free: 0, featured: 0,
|
|
||||||
summary: 'استرس آبی در کلاستر فولادسازی اصفهان-یزد-خراسان به نقطه بحرانی رسیده است. این گزارش ریسک تعطیلی یا کاهش تولید ناشی از کمبود آب را برای ۱۴ واحد تولیدی ارزیابی میکند.',
|
|
||||||
tags: JSON.stringify(['آب', 'بحران', 'پایداری', 'اصفهان', 'ریسک محیط زیستی']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'global-market-q1-1402',
|
|
||||||
title: 'گزارش فصلی بازار جهانی فولاد: بهار ۱۴۰۲',
|
|
||||||
category: 'بازار جهانی', type: 'quarterly',
|
|
||||||
author: 'دکتر علی محمدی', author_role: 'مدیر ارشد پژوهش', author_initial: 'م',
|
|
||||||
publish_date: 'تیر ۱۴۰۲', pages: 54, price: 440000, is_free: 0, featured: 0,
|
|
||||||
summary: 'بررسی روندهای بازار جهانی فولاد در بهار ۱۴۰۲ با تمرکز بر بازگشایی اقتصاد چین و اثرات آن بر قیمتها. تأثیر افزایش نرخ بهره فدرال رزرو بر سرمایهگذاری در ساختمان و زیرساخت تحلیل شده است.',
|
|
||||||
tags: JSON.stringify(['بازار جهانی', 'چین', 'نرخ بهره', 'تقاضا', 'بهار ۱۴۰۲']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'export-africa-emerging-markets-1402',
|
|
||||||
title: 'فرصتهای صادرات فولاد ایران به بازارهای نوظهور آفریقا',
|
|
||||||
category: 'صادرات', type: 'special',
|
|
||||||
author: 'دکتر فاطمه احمدی', author_role: 'مدیر تحقیقات ریسک', author_initial: 'ا',
|
|
||||||
publish_date: 'آبان ۱۴۰۲', pages: 48, price: 480000, is_free: 0, featured: 0,
|
|
||||||
summary: 'آفریقا با رشد سالانه ۶ درصد در تقاضای فولاد، بازار استراتژیک بلندمدت برای صادرکنندگان ایران محسوب میشود. این گزارش ۸ بازار هدف اولویتدار آفریقایی را با تحلیل ریسک و فرصت معرفی میکند.',
|
|
||||||
tags: JSON.stringify(['آفریقا', 'بازارهای نوظهور', 'صادرات', 'تنوعسازی', 'بلندمدت']),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'flash-gas-price-hike-steel-cost-1403',
|
|
||||||
title: 'فلش: اثر افزایش قیمت گاز بر قیمت تمامشده فولاد ایران',
|
|
||||||
category: 'انرژی و ESG', type: 'flash',
|
|
||||||
author: 'مهندس رضا توکلی', author_role: 'تحلیلگر بازار کامودیتی', author_initial: 'ت',
|
|
||||||
publish_date: 'شهریور ۱۴۰۳', pages: 11, price: 105000, is_free: 0, featured: 0,
|
|
||||||
summary: 'افزایش ۳۰ درصدی قیمت گاز صنعتی در مهر ۱۴۰۳ هزینه تولید هر تن DRI را حدود ۱۲۰۰ تومان افزایش میدهد. این گزارش فوری اثر این شوک هزینهای بر حاشیه سود تولیدکنندگان DRI و فولاد را محاسبه میکند.',
|
|
||||||
tags: JSON.stringify(['گاز', 'انرژی', 'هزینه تولید', 'DRI', 'قیمتگذاری']),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const r of reports) {
|
async function seedIfEmpty(label, checkPath, items, postFn) {
|
||||||
upsertArticle.run(r);
|
const existing = await req(checkPath);
|
||||||
|
if (Array.isArray(existing) && existing.length > 0) {
|
||||||
|
console.log(` SKIP ${label} (${existing.length} records exist)`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
console.log(` ✓ ${reports.length} گزارش وارد شد`);
|
console.log(` SEED ${label} (${items.length} items)...`);
|
||||||
|
for (const item of items) {
|
||||||
/* ─── EVENTS ────────────────────────────────────────────────── */
|
try { await postFn(item); }
|
||||||
const upsertEvent = db.prepare(`
|
catch (e) { console.warn(` WARN ${label}:`, e.message); }
|
||||||
INSERT OR REPLACE INTO events
|
|
||||||
(id, title_fa, date_fa, month_fa, year, type,
|
|
||||||
location_fa, city_fa, description_fa, registration_open, sort_order,
|
|
||||||
created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(@id, @title_fa, @date_fa, @month_fa, @year, @type,
|
|
||||||
@location_fa, @city_fa, @description_fa, @registration_open, @sort_order,
|
|
||||||
datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
|
|
||||||
const events = [
|
|
||||||
{
|
|
||||||
id: 'iran-steel-congress-1403',
|
|
||||||
title_fa: 'کنگره بینالمللی فولاد ایران ۱۴۰۳',
|
|
||||||
date_fa: '12', month_fa: 'اسفند', year: '1403',
|
|
||||||
type: 'conference',
|
|
||||||
location_fa: 'مرکز همایشهای بینالمللی تهران', city_fa: 'تهران',
|
|
||||||
description_fa: 'بزرگترین گردهمایی سالانه متخصصان، سیاستگذاران و فعالان صنعت فولاد ایران با حضور ۱۲۰۰ نفر شرکتکننده از ۲۵ کشور.',
|
|
||||||
registration_open: 1, sort_order: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'middle-east-steel-metals-2024',
|
|
||||||
title_fa: 'Middle East Steel & Metals 2024',
|
|
||||||
date_fa: '25', month_fa: 'اسفند', year: '1403',
|
|
||||||
type: 'exhibition',
|
|
||||||
location_fa: 'مرکز نمایشگاهی دبی ورلد ترید سنتر', city_fa: 'دبی',
|
|
||||||
description_fa: 'نمایشگاه تخصصی فولاد و فلزات خاورمیانه با حضور بیش از ۳۰۰ شرکت از ۴۰ کشور؛ فرصتی برای معرفی ظرفیتهای صادراتی صنعت فولاد ایران.',
|
|
||||||
registration_open: 1, sort_order: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'steel-policy-seminar-1404',
|
|
||||||
title_fa: 'نشست تخصصی سیاستگذاری فولاد ۱۴۰۴',
|
|
||||||
date_fa: '8', month_fa: 'فروردین', year: '1404',
|
|
||||||
type: 'seminar',
|
|
||||||
location_fa: 'پژوهشکده توسعه صنعتی ایران', city_fa: 'تهران',
|
|
||||||
description_fa: 'نشست تخصصی با حضور نمایندگان وزارت صمت، سازمان ایمیدرو و انجمنهای تخصصی برای بررسی سیاستهای سال ۱۴۰۴ در صنعت فولاد.',
|
|
||||||
registration_open: 1, sort_order: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'world-steel-forum-2025',
|
|
||||||
title_fa: 'World Steel Forum 2025',
|
|
||||||
date_fa: '20', month_fa: 'فروردین', year: '1404',
|
|
||||||
type: 'international',
|
|
||||||
location_fa: 'مرکز کنفرانس برلین', city_fa: 'برلین',
|
|
||||||
description_fa: 'اجلاس سالانه انجمن جهانی فولاد با تمرکز بر کربنزدایی، تجارت جهانی و تحولات ساختاری صنعت فولاد تا افق ۲۰۳۰.',
|
|
||||||
registration_open: 0, sort_order: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'iran-metallurgy-expo-1404',
|
|
||||||
title_fa: 'نمایشگاه متالورژی ایران',
|
|
||||||
date_fa: '15', month_fa: 'اردیبهشت', year: '1404',
|
|
||||||
type: 'exhibition',
|
|
||||||
location_fa: 'نمایشگاه بینالمللی اصفهان', city_fa: 'اصفهان',
|
|
||||||
description_fa: 'نمایشگاه تخصصی متالورژی، فولاد و فلزات غیرآهنی ایران با محوریت فناوریهای نوین تولید و تجهیزات صنعتی.',
|
|
||||||
registration_open: 0, sort_order: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'oecd-steel-summit-2025',
|
|
||||||
title_fa: 'اجلاس OECD فولاد',
|
|
||||||
date_fa: '3', month_fa: 'خرداد', year: '1404',
|
|
||||||
type: 'international',
|
|
||||||
location_fa: 'دفتر مرکزی OECD', city_fa: 'پاریس',
|
|
||||||
description_fa: 'نشست سالانه کمیته فولاد OECD برای بررسی مازاد ظرفیت جهانی، سیاستهای تجاری و استانداردهای پایداری در صنعت فولاد.',
|
|
||||||
registration_open: 0, sort_order: 6,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const e of events) {
|
|
||||||
upsertEvent.run(e);
|
|
||||||
}
|
}
|
||||||
console.log(` ✓ ${events.length} رویداد وارد شد`);
|
console.log(` OK ${label}`);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── TEAM MEMBERS ──────────────────────────────────────────── */
|
// ── DATA ──────────────────────────────────────────────────────────────────────
|
||||||
const upsertTeam = db.prepare(`
|
|
||||||
INSERT OR REPLACE INTO team_members
|
|
||||||
(id, name_fa, role_fa, initial, bio_fa, expertise,
|
|
||||||
email, report_count, is_expert, sort_order,
|
|
||||||
created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(@id, @name_fa, @role_fa, @initial, @bio_fa, @expertise,
|
|
||||||
@email, @report_count, @is_expert, @sort_order,
|
|
||||||
datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
|
|
||||||
const team = [
|
const EVENTS = [
|
||||||
{
|
{ titleFa: 'کنگره بینالمللی فولاد ایران ۱۴۰۳', titleEn: 'Iran International Steel Congress 1403', dateFa: '12', dateEn: '12', monthFa: 'اسفند', monthEn: 'March', year: 1403, type: 'conference', locationFa: 'مرکز همایشهای بینالمللی تهران', locationEn: 'Tehran International Conference Centre', cityFa: 'تهران', cityEn: 'Tehran', descriptionFa: 'بزرگترین گردهمایی سالانه متخصصان، سیاستگذاران و فعالان صنعت فولاد ایران با حضور ۱۲۰۰ نفر شرکتکننده از ۲۵ کشور.', descriptionEn: 'The largest annual gathering of Iran steel industry specialists — 1,200 participants from 25 countries.', registrationOpen: 1, sortOrder: 1 },
|
||||||
id: 'ali-mohammadi',
|
{ titleFa: 'Middle East Steel & Metals 2024', titleEn: 'Middle East Steel & Metals 2024', dateFa: '25', dateEn: '25', monthFa: 'اسفند', monthEn: 'March', year: 1403, type: 'exhibition', locationFa: 'مرکز نمایشگاهی دبی ورلد ترید سنتر', locationEn: 'Dubai World Trade Centre', cityFa: 'دبی', cityEn: 'Dubai', descriptionFa: 'نمایشگاه تخصصی فولاد و فلزات خاورمیانه با حضور بیش از ۳۰۰ شرکت از ۴۰ کشور.', descriptionEn: 'Middle East steel and metals exhibition — 300+ companies from 40 countries.', registrationOpen: 1, sortOrder: 2 },
|
||||||
name_fa: 'دکتر علی محمدی', role_fa: 'مدیر ارشد پژوهش', initial: 'م',
|
{ titleFa: 'نشست تخصصی سیاستگذاری فولاد ۱۴۰۴', titleEn: 'Steel Policy Seminar 1404', dateFa: '8', dateEn: '8', monthFa: 'فروردین', monthEn: 'April', year: 1404, type: 'seminar', locationFa: 'پژوهشکده توسعه صنعتی ایران', locationEn: 'Iran Industrial Development Research Institute', cityFa: 'تهران', cityEn: 'Tehran', descriptionFa: 'نشست تخصصی با حضور نمایندگان وزارت صمت، سازمان ایمیدرو و انجمنهای تخصصی.', descriptionEn: 'Expert session with representatives from MIMT, IMIDRO and industry associations.', registrationOpen: 1, sortOrder: 3 },
|
||||||
bio_fa: 'دکتری اقتصاد صنعتی از دانشگاه تهران با بیش از ۱۸ سال تجربه در تحلیل بازارهای فولاد و فلزات. پیش از پیوستن به اندیشکده، مشاور ارشد سازمان توسعه و نوسازی معادن و صنایع معدنی ایران (ایمیدرو) بوده است. بیش از ۵۰ گزارش تحلیلی و ۱۲ مقاله علمی در حوزه اقتصاد فولاد منتشر کرده است.',
|
{ titleFa: 'World Steel Forum 2025', titleEn: 'World Steel Forum 2025', dateFa: '20', dateEn: '20', monthFa: 'فروردین', monthEn: 'April', year: 1404, type: 'international', locationFa: 'مرکز کنفرانس برلین', locationEn: 'Berlin Conference Centre', cityFa: 'برلین', cityEn: 'Berlin', descriptionFa: 'اجلاس سالانه انجمن جهانی فولاد با تمرکز بر کربنزدایی، تجارت جهانی و تحولات ساختاری.', descriptionEn: 'World Steel Association annual summit on decarbonisation, global trade and structural change.', registrationOpen: 0, sortOrder: 4 },
|
||||||
expertise: JSON.stringify(['اقتصاد صنعتی', 'تحلیل بازار فولاد', 'سیاستگذاری صنعتی', 'مدلسازی اقتصادی', 'تجارت بینالملل']),
|
{ titleFa: 'نمایشگاه متالورژی ایران', titleEn: 'Iran Metallurgy Expo', dateFa: '15', dateEn: '15', monthFa: 'اردیبهشت', monthEn: 'May', year: 1404, type: 'exhibition', locationFa: 'نمایشگاه بینالمللی اصفهان', locationEn: 'Isfahan International Exhibition', cityFa: 'اصفهان', cityEn: 'Isfahan', descriptionFa: 'نمایشگاه تخصصی متالورژی، فولاد و فلزات غیرآهنی ایران با فناوریهای نوین تولید.', descriptionEn: 'Specialised Iran metallurgy, steel and non-ferrous metals exhibition.', registrationOpen: 0, sortOrder: 5 },
|
||||||
email: 'mohammadi@andishkade-foolad.ir',
|
{ titleFa: 'اجلاس OECD فولاد', titleEn: 'OECD Steel Summit', dateFa: '3', dateEn: '3', monthFa: 'خرداد', monthEn: 'June', year: 1404, type: 'international', locationFa: 'دفتر مرکزی OECD', locationEn: 'OECD Headquarters', cityFa: 'پاریس', cityEn: 'Paris', descriptionFa: 'نشست سالانه کمیته فولاد OECD برای بررسی مازاد ظرفیت جهانی و استانداردهای پایداری.', descriptionEn: 'OECD Steel Committee annual session on global overcapacity and sustainability standards.', registrationOpen: 0, sortOrder: 6 },
|
||||||
report_count: 52, is_expert: 0, sort_order: 1,
|
];
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'fateme-ahmadi',
|
|
||||||
name_fa: 'دکتر فاطمه احمدی', role_fa: 'مدیر تحقیقات ریسک', initial: 'ا',
|
|
||||||
bio_fa: 'دکتری مدیریت ریسک از دانشگاه علم و صنعت ایران با تخصص در ارزیابی ریسکهای ژئوپلیتیک و زنجیره تأمین. سابقه مشاوره به چندین شرکت بزرگ فولادی کشور در حوزه مدیریت ریسک تجاری و قراردادی را دارد. نویسنده کتاب «مدیریت ریسک در صنایع معدنی و فلزی» است.',
|
|
||||||
expertise: JSON.stringify(['مدیریت ریسک', 'تحریمپژوهی', 'ژئوپلیتیک انرژی', 'تأمین مالی پروژه', 'مذاکرات تجاری']),
|
|
||||||
email: 'ahmadi@andishkade-foolad.ir',
|
|
||||||
report_count: 38, is_expert: 0, sort_order: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hosein-karimi',
|
|
||||||
name_fa: 'دکتر حسین کریمی', role_fa: 'تحلیلگر ارشد بازار', initial: 'ک',
|
|
||||||
bio_fa: 'دکتری مهندسی مواد از دانشگاه صنعتی اصفهان با تخصص ترکیبی در متالورژی و اقتصاد بازار کامودیتیها. پنج سال سابقه کار در واحد پژوهش و توسعه فولاد مبارکه اصفهان دارد. بهطور منظم گزارشهای فصلی بازار جهانی فولاد را تهیه و منتشر میکند.',
|
|
||||||
expertise: JSON.stringify(['بازار جهانی فولاد', 'قیمتگذاری کامودیتی', 'متالورژی صنعتی', 'پیشبینی تقاضا', 'بازارهای آسیایی']),
|
|
||||||
email: 'karimi@andishkade-foolad.ir',
|
|
||||||
report_count: 41, is_expert: 0, sort_order: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sara-rezaei',
|
|
||||||
name_fa: 'مهندس سارا رضایی', role_fa: 'پژوهشگر ارشد انرژی', initial: 'ر',
|
|
||||||
bio_fa: 'کارشناسی ارشد مهندسی انرژی از دانشگاه شریف با تمرکز بر بهینهسازی مصرف انرژی در صنایع سنگین. تجربه عملیاتی در حسابرسی انرژی کارخانههای فولادی در اصفهان، خراسان و هرمزگان دارد.',
|
|
||||||
expertise: JSON.stringify(['انرژی صنعتی', 'ESG و پایداری', 'کربنزدایی', 'بهرهوری انرژی', 'مقررات زیستمحیطی']),
|
|
||||||
email: 'rezaei@andishkade-foolad.ir',
|
|
||||||
report_count: 29, is_expert: 0, sort_order: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'reza-tavakoli',
|
|
||||||
name_fa: 'مهندس رضا توکلی', role_fa: 'تحلیلگر بازار کامودیتی', initial: 'ت',
|
|
||||||
bio_fa: 'کارشناسی ارشد اقتصاد از دانشگاه تهران با تخصص در تحلیل بنیادی و تکنیکال بازارهای کامودیتی. سابقه فعالیت در کارگزاریهای بورس کالا و تهیه گزارشهای فوری برای معاملهگران فولاد را دارد.',
|
|
||||||
expertise: JSON.stringify(['بورس کالا', 'تحلیل تکنیکال', 'بازارهای آتی', 'قیمتگذاری سنگآهن', 'بازار داخلی فولاد']),
|
|
||||||
email: 'tavakoli@andishkade-foolad.ir',
|
|
||||||
report_count: 34, is_expert: 0, sort_order: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'maryam-hosseini',
|
|
||||||
name_fa: 'دکتر مریم حسینی', role_fa: 'پژوهشگر ارشد صادرات', initial: 'ح',
|
|
||||||
bio_fa: 'دکتری بازاریابی بینالملل از دانشگاه الزهرا با تجربه گسترده در مطالعات بازارهای صادراتی آسیا، آفریقا و اروپای شرقی. پیش از این در اتاق بازرگانی ایران و کمیته فولاد آن مشغول به فعالیت بوده است.',
|
|
||||||
expertise: JSON.stringify(['بازاریابی بینالملل', 'توسعه صادرات', 'بازار آفریقا و آسیا', 'استراتژی بازار', 'مذاکرات تجاری']),
|
|
||||||
email: 'hosseini@andishkade-foolad.ir',
|
|
||||||
report_count: 22, is_expert: 0, sort_order: 6,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const m of team) {
|
const TEAM = [
|
||||||
upsertTeam.run(m);
|
{ nameFa: 'دکتر علی محمدی', nameEn: 'Dr. Ali Mohammadi', roleFa: 'مدیر ارشد پژوهش', roleEn: 'Chief Research Officer', initial: 'م', bioFa: 'دکتری اقتصاد صنعتی از دانشگاه تهران با بیش از ۱۸ سال تجربه در تحلیل بازارهای فولاد و فلزات.', bioEn: 'PhD in Industrial Economics from University of Tehran, 18+ years in steel/metals market analysis.', expertise: ['اقتصاد صنعتی','تحلیل بازار فولاد','سیاستگذاری صنعتی','مدلسازی اقتصادی','تجارت بینالملل'], reportCount: 52, email: 'mohammadi@andishkade-foolad.ir', isExpert: 0, sortOrder: 1 },
|
||||||
|
{ nameFa: 'دکتر فاطمه احمدی', nameEn: 'Dr. Fatemeh Ahmadi', roleFa: 'مدیر تحقیقات ریسک', roleEn: 'Risk Research Director', initial: 'ا', bioFa: 'دکتری مدیریت ریسک از دانشگاه علم و صنعت ایران با تخصص در ارزیابی ریسکهای ژئوپلیتیک.', bioEn: 'PhD in Risk Management from IUST, specialising in geopolitical and supply-chain risk.', expertise: ['مدیریت ریسک','تحریمپژوهی','ژئوپلیتیک انرژی','تأمین مالی پروژه','مذاکرات تجاری'], reportCount: 38, email: 'ahmadi@andishkade-foolad.ir', isExpert: 0, sortOrder: 2 },
|
||||||
|
{ nameFa: 'دکتر حسین کریمی', nameEn: 'Dr. Hossein Karimi', roleFa: 'تحلیلگر ارشد بازار', roleEn: 'Senior Market Analyst', initial: 'ک', bioFa: 'دکتری مهندسی مواد از دانشگاه صنعتی اصفهان با تخصص در متالورژی و اقتصاد بازار کامودیتیها.', bioEn: 'PhD in Materials Engineering from Isfahan UT, combined expertise in metallurgy and commodity economics.', expertise: ['بازار جهانی فولاد','قیمتگذاری کامودیتی','متالورژی صنعتی','پیشبینی تقاضا','بازارهای آسیایی'], reportCount: 41, email: 'karimi@andishkade-foolad.ir', isExpert: 0, sortOrder: 3 },
|
||||||
|
{ nameFa: 'مهندس سارا رضایی', nameEn: 'Eng. Sara Rezaei', roleFa: 'پژوهشگر ارشد انرژی', roleEn: 'Senior Energy Researcher', initial: 'ر', bioFa: 'کارشناسی ارشد مهندسی انرژی از دانشگاه شریف با تمرکز بر بهینهسازی مصرف انرژی در صنایع سنگین.', bioEn: 'MSc in Energy Engineering from Sharif UT, focusing on energy optimisation in heavy industry.', expertise: ['انرژی صنعتی','ESG و پایداری','کربنزدایی','بهرهوری انرژی','مقررات زیستمحیطی'], reportCount: 29, email: 'rezaei@andishkade-foolad.ir', isExpert: 0, sortOrder: 4 },
|
||||||
|
{ nameFa: 'مهندس رضا توکلی', nameEn: 'Eng. Reza Tavakoli', roleFa: 'تحلیلگر بازار کامودیتی', roleEn: 'Commodity Market Analyst', initial: 'ت', bioFa: 'کارشناسی ارشد اقتصاد از دانشگاه تهران با تخصص در تحلیل بنیادی و تکنیکال بازارهای کامودیتی.', bioEn: 'MSc in Economics from University of Tehran, specialising in fundamental and technical commodity analysis.', expertise: ['بورس کالا','تحلیل تکنیکال','بازارهای آتی','قیمتگذاری سنگآهن','بازار داخلی فولاد'], reportCount: 34, email: 'tavakoli@andishkade-foolad.ir', isExpert: 0, sortOrder: 5 },
|
||||||
|
{ nameFa: 'دکتر مریم حسینی', nameEn: 'Dr. Maryam Hosseini', roleFa: 'پژوهشگر ارشد صادرات', roleEn: 'Senior Export Researcher', initial: 'ح', bioFa: 'دکتری بازاریابی بینالملل از دانشگاه الزهرا با تجربه گسترده در بازارهای صادراتی آسیا، آفریقا و اروپای شرقی.', bioEn: 'PhD in International Marketing from Alzahra University, extensive experience in Asia, Africa and Eastern Europe export markets.', expertise: ['بازاریابی بینالملل','توسعه صادرات','بازار آفریقا و آسیا','استراتژی بازار','مذاکرات تجاری'], reportCount: 22, email: 'hosseini@andishkade-foolad.ir', isExpert: 0, sortOrder: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLANS = [
|
||||||
|
{ nameFa: 'فردی', nameEn: 'Individual', price: 350000, periodFa: 'ماهانه', periodEn: 'Monthly', features: ['دسترسی به تمام گزارشهای پایه','دانلود ۵ گزارش در ماه','دریافت خبرنامه هفتگی','دسترسی به دادههای قیمت لحظهای','آرشیو ۱۲ ماه گذشته'], isFeatured: 0, ctaFa: 'شروع اشتراک', ctaEn: 'Start subscription', sortOrder: 1 },
|
||||||
|
{ nameFa: 'سازمانی', nameEn: 'Organisational', price: 1200000, periodFa: 'ماهانه', periodEn: 'Monthly', features: ['دسترسی کامل به تمام گزارشها','دانلود نامحدود گزارش','دسترسی برای ۵ کاربر همزمان','گزارشهای ویژه و تحلیلهای فوری','آرشیو کامل از ابتدای تأسیس','مشاوره تلفنی ماهانه با تحلیلگران','داشبورد اختصاصی تحلیل بازار'], badgeFa: 'پرطرفدار', badgeEn: 'Popular', isFeatured: 1, ctaFa: 'شروع اشتراک', ctaEn: 'Start subscription', sortOrder: 2 },
|
||||||
|
{ nameFa: 'وزارتخانه', nameEn: 'Ministry', price: 0, periodFa: 'سالانه', periodEn: 'Annual', features: ['دسترسی نامحدود برای تمام کارکنان','سفارش گزارشهای تحلیلی اختصاصی','نشستهای تخصصی حضوری با تیم پژوهشی','پشتیبانی اولویتدار ۲۴/۷','داشبورد تصمیمگیری یکپارچه','API اتصال به سیستمهای داخلی','آموزش و کارگاههای تخصصی دورهای','گزارشدهی سفارشی برای سیاستگذاران'], isFeatured: 0, ctaFa: 'درخواست مشاوره', ctaEn: 'Request consultation', sortOrder: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BANNERS = [
|
||||||
|
{ position: 'featured', active: 1, image: '/Horizontal_Book_Mockup_6 1.webp', ctaUrl: '', overlineFa: 'ماهنامه شماره ۱۲ منتشر شد...', titleFa: 'دیپلماسی معدنی در عصر انسدادهای ژئوپلیتیک', subtitleFa: 'Mineral Diplomacy in the Era of Geopolitical Blockades', ctaLabelFa: 'دانلود گزارش', overlineEn: 'Monthly issue #12 is out...', titleEn: 'Mineral Diplomacy in the Era of Geopolitical Blockades', subtitleEn: 'دیپلماسی معدنی در عصر انسدادهای ژئوپلیتیک', ctaLabelEn: 'Download report', sortOrder: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VISION_ITEMS = [
|
||||||
|
{ icon: 'FlaskConical', titleFa: 'پیشرو در پژوهش', titleEn: 'Research Leadership', descFa: 'تولید تحلیلهای راهبردی و گزارشهای تخصصی در حوزه صنعت فولاد', descEn: 'Producing strategic analyses and specialised reports in the steel industry', sortOrder: 1 },
|
||||||
|
{ icon: 'Network', titleFa: 'شبکهسازی خبرگان', titleEn: 'Expert Networking', descFa: 'ایجاد پیوند میان متخصصان، سیاستگذاران و فعالان صنعتی', descEn: 'Building connections between specialists, policymakers, and industry practitioners', sortOrder: 2 },
|
||||||
|
{ icon: 'Leaf', titleFa: 'فولاد سبز', titleEn: 'Green Steel', descFa: 'ترویج رویکردهای پایدار و کاهش اثرات زیستمحیطی در زنجیره فولاد', descEn: 'Promoting sustainable approaches and reducing environmental impacts across the steel chain', sortOrder: 3 },
|
||||||
|
{ icon: 'Telescope', titleFa: 'آیندهپژوهی', titleEn: 'Futures Research', descFa: 'رصد هوشمند تحولات جهانی و ارائه نقشه راه برای صنعت ایران', descEn: "Smart monitoring of global developments and providing roadmaps for Iran's industry", sortOrder: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ADVISORY_BOARD = [
|
||||||
|
{ nameFa: 'دکتر علیرضا اکبریان', nameEn: 'Dr. Alireza Akbarian', roleFa: 'مدیر اندیشکده', roleEn: 'Institute Director', sortOrder: 1 },
|
||||||
|
{ nameFa: 'مهندس سارا محمدی', nameEn: 'Eng. Sara Mohammadi', roleFa: 'پژوهشگر ارشد فولاد سبز', roleEn: 'Senior Green Steel Researcher', sortOrder: 2 },
|
||||||
|
{ nameFa: 'دکتر محمد جلالی', nameEn: 'Dr. Mohammad Jalali', roleFa: 'تحلیلگر بازارهای جهانی', roleEn: 'Global Markets Analyst', sortOrder: 3 },
|
||||||
|
{ nameFa: 'دکتر فاطمه رضایی', nameEn: 'Dr. Fatemeh Rezaei', roleFa: 'متخصص ژئوپلیتیک صنعتی', roleEn: 'Industrial Geopolitics Specialist', sortOrder: 4 },
|
||||||
|
{ nameFa: 'مهندس رضا کریمی', nameEn: 'Eng. Reza Karimi', roleFa: 'کارشناس فناوری و نوآوری', roleEn: 'Technology & Innovation Expert', sortOrder: 5 },
|
||||||
|
{ nameFa: 'دکتر نیلوفر حسینی', nameEn: 'Dr. Niloofar Hosseini', roleFa: 'پژوهشگر آیندهپژوهی', roleEn: 'Futures Research Specialist', sortOrder: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VIDEOS = [
|
||||||
|
{ title: 'نگاهی به آن سوی مرزهای تولید', author: 'مهندس سیامک فجری', summary: 'فراتر از دادههای خام قیمت و تناژ، به سراغ مهندسی تغییر و معماری آینده صنعت میرویم.', type: 'video', category: 'تولید داخلی', publishDate: '۲۵ خرداد ۱۴۰۵', coverImage: '/news/photo-1504307651254-35680f356dfd.jpg', isFree: true, tags: ['EP.12','ویدیوکست'] },
|
||||||
|
{ title: 'تحلیل بازار جهانی فلزات پایه', author: 'دکتر نیلوفر احمدی', summary: 'نوسانات قیمت مس، آلومینیوم و روی و تأثیر آن بر زنجیره ارزش فولاد.', type: 'video', category: 'بازار جهانی', publishDate: '۱۸ خرداد ۱۴۰۵', coverImage: '/news/photo-1558618666-fcd25c85cd64.jpg', isFree: true, tags: ['EP.11','ویدیوکست'] },
|
||||||
|
{ title: 'ژئوپلیتیک و صادرات فولاد ایران', author: 'مهندس فرید تهرانی', summary: 'تأثیر تحولات منطقهای بر بازارهای صادراتی فولاد ایران و فرصتهای پیش رو.', type: 'video', category: 'صادرات', publishDate: '۱۱ خرداد ۱۴۰۵', coverImage: '/news/photo-1504328345606-18bbc8c9d7d1.jpg', isFree: true, tags: ['EP.10','ویدیوکست'] },
|
||||||
|
{ title: 'سرمایهگذاری در زنجیره ارزش فولاد', author: 'دکتر آرش کریمی', summary: 'فرصتها و چالشهای سرمایهگذاری در بخشهای مختلف زنجیره ارزش فولاد.', type: 'video', category: 'بازار جهانی', publishDate: '۴ خرداد ۱۴۰۵', coverImage: '/news/photo-1611273426858-450d8e3c9fce.jpg', isFree: true, tags: ['EP.09','ویدیوکست'] },
|
||||||
|
{ title: 'آینده فولاد سبز در ایران', author: 'مهندس کاوه رستمی', summary: 'مسیر گذار به فولاد کمکربن در ایران؛ چالشها، فرصتها و ضرورت سرمایهگذاری.', type: 'video', category: 'انرژی و ESG', publishDate: '۲۸ اردیبهشت ۱۴۰۵', coverImage: '/news/photo-1566873535350-6586b0b7a1f2.jpg', isFree: true, tags: ['EP.08','ویدیوکست'] },
|
||||||
|
{ title: 'هوش مصنوعی و تحول کارخانههای فولاد', author: 'دکتر رضا محمدی', summary: 'چگونه یادگیری ماشین کارایی خطوط تولید فولاد را متحول میکند.', type: 'video', category: 'تولید داخلی', publishDate: '۲۱ اردیبهشت ۱۴۰۵', coverImage: '/news/photo-1578662996442-48f60103fc96.jpg', isFree: true, tags: ['EP.07','ویدیوکست'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PODCASTS = [
|
||||||
|
{ title: 'کالبدشکافی عقلانی پیچیدگیهای صنعتی', author: 'مهندس سیامک شجاعی', summary: 'صنعت فولاد در عصر حاضر، در پیچیدهترین چرخه حیات خود قرار گرفته است؛ جایی که همگرایی هوش مصنوعی، الزامات زیستمحیطی و تنشهای ژئواکونومیک، قواعد کلاسیک تولید و تجارت را بهکلی بازنویسی کردهاند.', type: 'podcast', category: 'تولید داخلی', publishDate: '۲۵ / خرداد / ۱۴۰۵', coverImage: '/news/photo-1540575467063-178a50c2df87.jpg', isFree: true, tags: ['اپیزود ۷۷','پادکست'] },
|
||||||
|
{ title: 'آزمایشگاه کوره؛ فناوریهای نوین فولادسازی', author: 'دکتر رضا محمدی', summary: 'در این اپیزود با نگاهی موشکافانه به چالشهای فنی گذار از کورههای بلند به فولادسازی پاک میپردازیم.', type: 'podcast', category: 'تولید داخلی', publishDate: '۱۸ / خرداد / ۱۴۰۵', coverImage: '/news/photo-1566873535350-6586b0b7a1f2.jpg', isFree: true, tags: ['اپیزود ۷۶','پادکست'] },
|
||||||
|
{ title: 'معمار فولاد؛ رهبری در عصر تحول', author: 'مهندس کاوه رستمی', summary: 'داستان ساختن فولاد فردا؛ روایت رهبرانی که مدلهای سنتی کسبوکار را به چالش کشیدهاند.', type: 'podcast', category: 'تولید داخلی', publishDate: '۱۱ / خرداد / ۱۴۰۵', coverImage: '/news/photo-1578662996442-48f60103fc96.jpg', isFree: true, tags: ['اپیزود ۷۵','پادکست'] },
|
||||||
|
{ title: 'فولاد سبز؛ رویا یا واقعیت؟', author: 'دکتر نیلوفر احمدی', summary: 'بررسی مسیر دشوار و پرهزینه گذار به تولید فولاد با کمترین انتشار کربن و آنچه صنعت ایران باید بداند.', type: 'podcast', category: 'انرژی و ESG', publishDate: '۴ / خرداد / ۱۴۰۵', coverImage: '/news/photo-1504328345606-18bbc8c9d7d1.jpg', isFree: true, tags: ['اپیزود ۷۴','پادکست'] },
|
||||||
|
{ title: 'بازی بزرگ؛ چین، هند و آینده بازار فولاد', author: 'مهندس فرید تهرانی', summary: 'چگونه رقابت دو غول آسیایی مسیر بازار جهانی فولاد را در دهه پیش رو تغییر خواهد داد.', type: 'podcast', category: 'بازار جهانی', publishDate: '۲۸ / اردیبهشت / ۱۴۰۵', coverImage: '/news/photo-1558618666-fcd25c85cd64.jpg', isFree: true, tags: ['اپیزود ۷۳','پادکست'] },
|
||||||
|
{ title: 'هوش مصنوعی در خط تولید فولاد', author: 'دکتر آرش کریمی', summary: 'کاربردهای عملی یادگیری ماشین در بهینهسازی فرآیند ذوب، کنترل کیفیت و نگهداری پیشگیرانه.', type: 'podcast', category: 'تولید داخلی', publishDate: '۲۱ / اردیبهشت / ۱۴۰۵', coverImage: '/news/photo-1611273426858-450d8e3c9fce.jpg', isFree: true, tags: ['اپیزود ۷۲','پادکست'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RADAR_ITEMS = [
|
||||||
|
{ category: 'commodity', img: '/news/photo-1611273426858-450d8e3c9fce.jpg', authorName: 'دکتر سارا احمدی', authorAvatar: '/news/photo-1494790108377-be9c29b29330.jpg', titleFa: 'پیشبینی بازار با هوش مصنوعی', dateFa: '۱۴ اردیبهشت ۱۴۰۵', excerptFa: 'مدلهای یادگیری ماشین تقاضای لیتیوم و فلزات باتری را در افق ۲۰۳۵ بازتعریف میکنند.', titleEn: 'AI-Powered Market Forecasting', dateEn: 'May 4, 2026', excerptEn: 'Machine-learning models are redefining lithium and battery-metal demand on the 2035 horizon.', sortOrder: 1 },
|
||||||
|
{ category: 'tech', img: '/news/photo-1581092160607-ee22731c9b4e.jpg', authorName: 'مهندس رضا کریمی', authorAvatar: '/news/photo-1500648767791-00dcc994a43e.jpg', titleFa: 'تحول دیجیتال در زنجیره ارزش فولاد', dateFa: '۱۴ اردیبهشت ۱۴۰۵', excerptFa: 'تحول دیجیتال و اتوماسیون هوشمند در حال تغییر بنیادین زنجیره ارزش فولاد هستند.', titleEn: 'Digital Transformation in Steel Value Chain', dateEn: 'May 4, 2026', excerptEn: 'Digital transformation and smart automation are fundamentally reshaping the steel value chain.', sortOrder: 2 },
|
||||||
|
{ category: 'market', img: '/news/photo-1504307651254-35680f356dfd.jpg', authorName: 'دکتر مریم رضایی', authorAvatar: '/news/photo-1438761681033-6461ffad8d80.jpg', titleFa: 'تأثیر نوسانات طلا بر سرمایهگذاری فولاد', dateFa: '۱۴ اردیبهشت ۱۴۰۵', excerptFa: 'تحلیل روند قیمت طلا و فلزات گرانبها و اثر آن بر سرمایهگذاری در زنجیره فولاد.', titleEn: 'Gold Volatility Impact on Steel Investment', dateEn: 'May 4, 2026', excerptEn: 'Gold and precious-metal price trends and their effect on steel-chain investment.', sortOrder: 3 },
|
||||||
|
{ category: 'geo', img: '/news/photo-1518770660439-4636190af475.jpg', authorName: 'دکتر علی موسوی', authorAvatar: '/news/photo-1472099645785-5658abf4ff4e.jpg', titleFa: 'ژئوپلیتیک و بازارهای صادراتی فولاد ایران', dateFa: '۱۴ اردیبهشت ۱۴۰۵', excerptFa: 'هندسه قدرت جهانی و اثر تحولات ژئوپلیتیک بر بازارهای صادراتی فولاد ایران.', titleEn: "Geopolitics and Iran's Steel Export Markets", dateEn: 'May 4, 2026', excerptEn: "Global power geometry and the impact of geopolitics on Iran's steel export markets.", sortOrder: 4 },
|
||||||
|
{ category: 'energy', img: '/news/photo-1466611653911-95081537e5b7.jpg', authorName: 'مهندس نازنین حسینی', authorAvatar: '/news/photo-1544005313-94ddf0286df2.jpg', titleFa: 'گذار انرژی و فولاد سبز', dateFa: '۱۲ اردیبهشت ۱۴۰۵', excerptFa: 'هیدروژن سبز و برق تجدیدپذیر مسیر کربنزدایی صنعت فولاد را شتاب میدهند.', titleEn: 'Energy Transition & Green Steel', dateEn: 'May 2, 2026', excerptEn: 'Green hydrogen and renewable power are accelerating steel decarbonization.', sortOrder: 5 },
|
||||||
|
{ category: 'tech', img: '/news/photo-1567789884554-0b844b597180.jpg', authorName: 'دکتر سارا احمدی', authorAvatar: '/news/photo-1494790108377-be9c29b29330.jpg', titleFa: 'اتوماسیون خطوط نورد', dateFa: '۱۰ اردیبهشت ۱۴۰۵', excerptFa: 'سیستمهای کنترل هوشمند بهرهوری خطوط نورد را تا ۲۲٪ افزایش دادهاند.', titleEn: 'Rolling Line Automation', dateEn: 'Apr 30, 2026', excerptEn: 'Smart control systems have boosted rolling-line productivity by up to 22%.', sortOrder: 6 },
|
||||||
|
{ category: 'commodity', img: '/news/photo-1570168007204-dfb528c6958f.jpg', authorName: 'مهندس رضا کریمی', authorAvatar: '/news/photo-1500648767791-00dcc994a43e.jpg', titleFa: 'زنجیره تأمین سنگآهن', dateFa: '۸ اردیبهشت ۱۴۰۵', excerptFa: 'نوسانات عرضه سنگآهن استرالیا و برزیل و پیامد آن برای تولیدکنندگان منطقه.', titleEn: 'Iron Ore Supply Chain', dateEn: 'Apr 28, 2026', excerptEn: 'Australian and Brazilian iron-ore supply swings and their regional fallout.', sortOrder: 7 },
|
||||||
|
{ category: 'market', img: '/news/photo-1540575467063-178a50c2df87.jpg', authorName: 'دکتر مریم رضایی', authorAvatar: '/news/photo-1438761681033-6461ffad8d80.jpg', titleFa: 'تحلیل بازار جهانی فولاد', dateFa: '۶ اردیبهشت ۱۴۰۵', excerptFa: 'سیل صادرات فولاد چین و فشار قیمتی بر بازارهای هدف صادراتی ایران.', titleEn: 'Global Steel Market Analysis', dateEn: 'Apr 26, 2026', excerptEn: "China's steel export flood and price pressure on Iran's target export markets.", sortOrder: 8 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FACTORY_REPORTS = [
|
||||||
|
{ img: '/news/photo-1504307651254-35680f356dfd.jpg', scrollDir: 'left', tagFa: 'تولید و صادرات', titleFa: 'تحلیل عملکرد تولید و صادرات', dateFa: 'بهمن ۱۴۰۳', excerptFa: 'بررسی آمار تولید ماهانه، مقایسه با بودجه سالانه و روند صادرات فولاد تخت در بازارهای منطقهای.', tagEn: 'Output & Exports', titleEn: 'Production & Export Performance', dateEn: 'Feb 2025', excerptEn: 'Monthly output vs. annual budget and the flat-steel export trend across regional markets.', sortOrder: 1 },
|
||||||
|
{ img: '/news/photo-1581092160607-ee22731c9b4e.jpg', scrollDir: 'left', tagFa: 'بازار و قیمت', titleFa: 'وضعیت بازار و قیمتگذاری', dateFa: 'دی ۱۴۰۳', excerptFa: 'تحلیل نوسانات قیمت فولاد تخت در بازار داخلی و اثر نرخ ارز بر چشمانداز زمستان.', tagEn: 'Market & Price', titleEn: 'Market & Pricing Outlook', dateEn: 'Jan 2025', excerptEn: 'Flat-steel price swings in the domestic market and the FX effect on the winter outlook.', sortOrder: 2 },
|
||||||
|
{ img: '/news/photo-1518770660439-4636190af475.jpg', scrollDir: 'left', tagFa: 'فناوری', titleFa: 'دیجیتالسازی خطوط تولید', dateFa: 'آذر ۱۴۰۳', excerptFa: 'گزارشی از پیادهسازی سامانههای کنترل هوشمند و اثر آن بر بهرهوری خطوط نورد.', tagEn: 'Technology', titleEn: 'Production Line Digitalization', dateEn: 'Dec 2024', excerptEn: 'Rolling-out smart control systems and their impact on rolling-line productivity.', sortOrder: 3 },
|
||||||
|
{ img: '/news/photo-1567789884554-0b844b597180.jpg', scrollDir: 'up', tagFa: 'نیروی انسانی', titleFa: 'تحلیل نیروی انسانی و بهرهوری', dateFa: 'آبان ۱۴۰۳', excerptFa: 'شاخصهای بهرهوری نیروی کار، آموزشهای ماهانه و آمار ایمنی شغلی.', tagEn: 'Workforce', titleEn: 'Workforce & Productivity', dateEn: 'Nov 2024', excerptEn: 'Labor productivity indicators, monthly training and occupational-safety statistics.', sortOrder: 4 },
|
||||||
|
{ img: '/news/photo-1466611653911-95081537e5b7.jpg', scrollDir: 'up', tagFa: 'پایداری', titleFa: 'گزارش پایداری و محیط زیست', dateFa: 'آذر ۱۴۰۳', excerptFa: 'شاخصهای زیستمحیطی ماهانه؛ مصرف انرژی، کاهش انتشار کربن و بازچرخانی پساب صنعتی.', tagEn: 'Sustainability', titleEn: 'Sustainability & Environment', dateEn: 'Dec 2024', excerptEn: 'Monthly environmental metrics: energy use, carbon reduction and industrial water recycling.', sortOrder: 5 },
|
||||||
|
{ img: '/news/photo-1570168007204-dfb528c6958f.jpg', scrollDir: 'up', tagFa: 'زنجیره تأمین', titleFa: 'تأمین مواد اولیه و لجستیک', dateFa: 'مهر ۱۴۰۳', excerptFa: 'وضعیت تأمین سنگآهن و قراضه، موجودی انبار و تحلیل ریسک زنجیره تأمین.', tagEn: 'Supply Chain', titleEn: 'Raw Materials & Logistics', dateEn: 'Oct 2024', excerptEn: 'Iron-ore and scrap supply, inventory levels and supply-chain risk analysis.', sortOrder: 6 },
|
||||||
|
{ img: '/news/photo-1611273426858-450d8e3c9fce.jpg', scrollDir: 'right', tagFa: 'سرمایهگذاری', titleFa: 'طرحهای توسعه و سرمایهگذاری', dateFa: 'شهریور ۱۴۰۳', excerptFa: 'پیشرفت طرحهای توسعه ظرفیت و تحلیل بازده سرمایهگذاری پروژههای جاری.', tagEn: 'Investment', titleEn: 'Expansion & Investment Plans', dateEn: 'Sep 2024', excerptEn: 'Capacity-expansion progress and ROI analysis of ongoing projects.', sortOrder: 7 },
|
||||||
|
{ img: '/news/photo-1485827404703-89b55fcc595e.jpg', scrollDir: 'right', tagFa: 'کیفیت', titleFa: 'کنترل کیفیت با هوش مصنوعی', dateFa: 'مرداد ۱۴۰۳', excerptFa: 'کاربرد بینایی ماشین در تشخیص عیوب سطحی و کاهش نرخ ضایعات تولید.', tagEn: 'Quality', titleEn: 'AI-Driven Quality Control', dateEn: 'Aug 2024', excerptEn: 'Machine vision for surface-defect detection and lower production scrap rates.', sortOrder: 8 },
|
||||||
|
{ img: '/news/photo-1613665813446-82a78c468a1d.jpg', scrollDir: 'right', tagFa: 'انرژی', titleFa: 'تحلیل مصرف انرژی و بهینهسازی', dateFa: 'تیر ۱۴۰۳', excerptFa: 'الگوی مصرف برق و گاز، فرصتهای بهینهسازی و اثر آن بر بهای تمامشده.', tagEn: 'Energy', titleEn: 'Energy Use & Optimization', dateEn: 'Jul 2024', excerptEn: 'Electricity and gas usage patterns, optimization opportunities and cost impact.', sortOrder: 9 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const INTEGRATIONS = [
|
||||||
|
{ name: 'London Metal Exchange', icon: 'exchange', accent: '#CD9E53', gridCol: 6, gridRow: 1, sortOrder: 1 },
|
||||||
|
{ name: 'Shanghai Futures Exchange', icon: 'trending', accent: '#c0392b', gridCol: 4, gridRow: 2, sortOrder: 2 },
|
||||||
|
{ name: 'S&P Global Platts', icon: 'chart', accent: '#2471a3', gridCol: 6, gridRow: 2, sortOrder: 3 },
|
||||||
|
{ name: 'Reuters', icon: 'newspaper', accent: '#e67e22', gridCol: 3, gridRow: 3, sortOrder: 4 },
|
||||||
|
{ name: 'Fastmarkets', icon: 'activity', accent: '#1e8449', gridCol: 5, gridRow: 3, sortOrder: 5 },
|
||||||
|
{ name: 'World Steel Association', icon: 'building', accent: '#7d3c98', gridCol: 4, gridRow: 4, sortOrder: 6 },
|
||||||
|
{ name: 'Mysteel', icon: 'database', accent: '#16a085', gridCol: 6, gridRow: 4, sortOrder: 7 },
|
||||||
|
{ name: 'CME Group', icon: 'coins', accent: '#2980b9', gridCol: 5, gridRow: 5, sortOrder: 8 },
|
||||||
|
{ name: 'Dalian Commodity Exchange', icon: 'globe', accent: '#d35400', gridCol: 3, gridRow: 5, sortOrder: 9 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── MAIN ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Logging in...');
|
||||||
|
await req('/api/auth/login', { method: 'POST', body: { username: USERNAME, password: PASSWORD } });
|
||||||
|
console.log('OK. Seeding...\n');
|
||||||
|
|
||||||
|
await seedIfEmpty('Events', '/api/events?limit=1', EVENTS, i => req('/api/events', { method: 'POST', body: i }));
|
||||||
|
await seedIfEmpty('Team', '/api/team?limit=1', TEAM, i => req('/api/team', { method: 'POST', body: i }));
|
||||||
|
await seedIfEmpty('Plans', '/api/plans?limit=1', PLANS, i => req('/api/plans', { method: 'POST', body: i }));
|
||||||
|
await seedIfEmpty('Banners', '/api/banners?limit=1', BANNERS, i => req('/api/banners', { method: 'POST', body: i }));
|
||||||
|
await seedIfEmpty('Vision Items', '/api/vision-items?limit=1', VISION_ITEMS, i => req('/api/vision-items', { method: 'POST', body: i }));
|
||||||
|
await seedIfEmpty('Advisory Board', '/api/advisory-board?limit=1', ADVISORY_BOARD, i => req('/api/advisory-board', { method: 'POST', body: i }));
|
||||||
|
await seedIfEmpty('Radar Items', '/api/radar?limit=1', RADAR_ITEMS, i => req('/api/radar', { method: 'POST', body: i }));
|
||||||
|
await seedIfEmpty('Factory Reports','/api/factory-reports?limit=1', FACTORY_REPORTS,i => req('/api/factory-reports', { method: 'POST', body: i }));
|
||||||
|
await seedIfEmpty('Integrations', '/api/integrations?limit=1', INTEGRATIONS, i => req('/api/integrations', { method: 'POST', body: i }));
|
||||||
|
|
||||||
|
// Videos + Podcasts live in /api/articles — check by type
|
||||||
|
const existingVideos = await req('/api/articles?type=video&limit=1');
|
||||||
|
if (Array.isArray(existingVideos) && existingVideos.length > 0) {
|
||||||
|
console.log(` SKIP Videos (records exist)`);
|
||||||
|
} else {
|
||||||
|
console.log(` SEED Videos (${VIDEOS.length} items)...`);
|
||||||
|
for (const v of VIDEOS) {
|
||||||
|
try { await req('/api/articles', { method: 'POST', body: v }); }
|
||||||
|
catch (e) { console.warn(' WARN Video:', e.message); }
|
||||||
}
|
}
|
||||||
console.log(` ✓ ${team.length} عضو تیم وارد شد`);
|
console.log(' OK Videos');
|
||||||
|
|
||||||
/* ─── PLANS ─────────────────────────────────────────────────── */
|
|
||||||
const upsertPlan = db.prepare(`
|
|
||||||
INSERT OR REPLACE INTO plans
|
|
||||||
(id, name_fa, price, period_fa, features, badge_fa, is_featured, cta_fa, sort_order,
|
|
||||||
created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(@id, @name_fa, @price, @period_fa, @features, @badge_fa, @is_featured, @cta_fa, @sort_order,
|
|
||||||
datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
|
|
||||||
const plans = [
|
|
||||||
{
|
|
||||||
id: 'individual',
|
|
||||||
name_fa: 'فردی', price: 350000, period_fa: 'ماهانه',
|
|
||||||
features: JSON.stringify([
|
|
||||||
'دسترسی به تمام گزارشهای پایه',
|
|
||||||
'دانلود ۵ گزارش در ماه',
|
|
||||||
'دریافت خبرنامه هفتگی',
|
|
||||||
'دسترسی به دادههای قیمت لحظهای',
|
|
||||||
'آرشیو ۱۲ ماه گذشته',
|
|
||||||
]),
|
|
||||||
badge_fa: null, is_featured: 0, cta_fa: 'شروع اشتراک', sort_order: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'organizational',
|
|
||||||
name_fa: 'سازمانی', price: 1200000, period_fa: 'ماهانه',
|
|
||||||
features: JSON.stringify([
|
|
||||||
'دسترسی کامل به تمام گزارشها',
|
|
||||||
'دانلود نامحدود گزارش',
|
|
||||||
'دسترسی برای ۵ کاربر همزمان',
|
|
||||||
'گزارشهای ویژه و تحلیلهای فوری',
|
|
||||||
'آرشیو کامل از ابتدای تأسیس',
|
|
||||||
'مشاوره تلفنی ماهانه با تحلیلگران',
|
|
||||||
'داشبورد اختصاصی تحلیل بازار',
|
|
||||||
]),
|
|
||||||
badge_fa: 'پرطرفدار', is_featured: 1, cta_fa: 'شروع اشتراک', sort_order: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ministry',
|
|
||||||
name_fa: 'وزارتخانه', price: 0, period_fa: 'سالانه',
|
|
||||||
features: JSON.stringify([
|
|
||||||
'دسترسی نامحدود برای تمام کارکنان',
|
|
||||||
'سفارش گزارشهای تحلیلی اختصاصی',
|
|
||||||
'نشستهای تخصصی حضوری با تیم پژوهشی',
|
|
||||||
'پشتیبانی اولویتدار ۲۴/۷',
|
|
||||||
'داشبورد تصمیمگیری یکپارچه',
|
|
||||||
'API اتصال به سیستمهای داخلی',
|
|
||||||
'آموزش و کارگاههای تخصصی دورهای',
|
|
||||||
'گزارشدهی سفارشی برای سیاستگذاران',
|
|
||||||
]),
|
|
||||||
badge_fa: null, is_featured: 0, cta_fa: 'درخواست مشاوره', sort_order: 3,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const p of plans) {
|
|
||||||
upsertPlan.run(p);
|
|
||||||
}
|
}
|
||||||
console.log(` ✓ ${plans.length} پلن اشتراک وارد شد`);
|
|
||||||
|
|
||||||
/* ─── RISK SIGNALS ──────────────────────────────────────────── */
|
const existingPodcasts = await req('/api/articles?type=podcast&limit=1');
|
||||||
const upsertRisk = db.prepare(`
|
if (Array.isArray(existingPodcasts) && existingPodcasts.length > 0) {
|
||||||
INSERT OR REPLACE INTO risk_signals
|
console.log(` SKIP Podcasts (records exist)`);
|
||||||
(id, quote, name, date, level, sort_order, created_at, updated_at)
|
} else {
|
||||||
VALUES
|
console.log(` SEED Podcasts (${PODCASTS.length} items)...`);
|
||||||
(@id, @quote, @name, @date, @level, @sort_order,
|
for (const p of PODCASTS) {
|
||||||
datetime('now'), datetime('now'))
|
try { await req('/api/articles', { method: 'POST', body: p }); }
|
||||||
`);
|
catch (e) { console.warn(' WARN Podcast:', e.message); }
|
||||||
|
}
|
||||||
const risks = [
|
console.log(' OK Podcasts');
|
||||||
{
|
|
||||||
id: 'china-export-pressure-1404',
|
|
||||||
quote: 'فشار صادراتی فولاد چین در ۵ ماه اول ۲۰۲۵ به ۵۴ میلیون تن رسید — ۱۸٪ بیشتر از سال قبل. بازارهای هدف ایران در عراق، امارات و آفریقا در معرض فشار قیمتی شدید هستند.',
|
|
||||||
name: 'فشار صادرات چین', date: 'اردیبهشت ۱۴۰۴',
|
|
||||||
level: 'high', sort_order: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'domestic-gas-restriction-1404',
|
|
||||||
quote: 'محدودیتهای گاز صنعتی در زمستان ۱۴۰۳ ظرفیت تولید واحدهای DRI را تا ۳۵٪ کاهش داد. احتمال تکرار این محدودیت در زمستان ۱۴۰۴ با توجه به کمبود زیرساخت انتقال گاز بالاست.',
|
|
||||||
name: 'محدودیت انرژی داخلی', date: 'فروردین ۱۴۰۴',
|
|
||||||
level: 'medium', sort_order: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'iraq-market-opportunity-1404',
|
|
||||||
quote: 'کاهش صادرات فولاد ترکیه به عراق در اثر تضعیف لیر ترکیه، فرصت تصاحب ۱۲٪ از سهم بازار را برای صادرکنندگان ایرانی ایجاد کرده است.',
|
|
||||||
name: 'فرصت بازار عراق', date: 'اردیبهشت ۱۴۰۴',
|
|
||||||
level: 'opportunity', sort_order: 3,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const r of risks) {
|
|
||||||
upsertRisk.run(r);
|
|
||||||
}
|
}
|
||||||
console.log(` ✓ ${risks.length} سیگنال ریسک وارد شد`);
|
|
||||||
|
|
||||||
/* ─── RADAR ITEMS ───────────────────────────────────────────── */
|
console.log('\nDone. Panel DB seeded.');
|
||||||
const upsertRadar = db.prepare(`
|
}
|
||||||
INSERT OR REPLACE INTO radar_items
|
|
||||||
(id, category, img, author_name, author_avatar,
|
|
||||||
title_fa, date_fa, excerpt_fa, title_en, date_en, excerpt_en, sort_order,
|
|
||||||
created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(@id, @category, @img, @author_name, @author_avatar,
|
|
||||||
@title_fa, @date_fa, @excerpt_fa, @title_en, @date_en, @excerpt_en, @sort_order,
|
|
||||||
datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
|
|
||||||
const radar = [
|
main().catch(err => { console.error('FATAL:', err.message); process.exit(1); });
|
||||||
{ id: 'r1', category: 'commodity', img: '/news/photo-1611273426858-450d8e3c9fce.jpg',
|
|
||||||
author_name: 'دکتر سارا احمدی', author_avatar: '/news/photo-1494790108377-be9c29b29330.jpg',
|
|
||||||
title_fa: 'پیشبینی بازار با هوش مصنوعی', date_fa: '۱۴ اردیبهشت ۱۴۰۵',
|
|
||||||
excerpt_fa: 'مدلهای یادگیری ماشین تقاضای لیتیوم و فلزات باتری را در افق ۲۰۳۵ بازتعریف میکنند.',
|
|
||||||
title_en: 'AI-Powered Market Forecasting', date_en: 'May 4, 2026',
|
|
||||||
excerpt_en: 'Machine-learning models are redefining lithium and battery-metal demand on the 2035 horizon.', sort_order: 1 },
|
|
||||||
{ id: 'r2', category: 'tech', img: '/news/photo-1581092160607-ee22731c9b4e.jpg',
|
|
||||||
author_name: 'مهندس رضا کریمی', author_avatar: '/news/photo-1500648767791-00dcc994a43e.jpg',
|
|
||||||
title_fa: 'پیشبینی بازار با هوش مصنوعی', date_fa: '۱۴ اردیبهشت ۱۴۰۵',
|
|
||||||
excerpt_fa: 'تحول دیجیتال و اتوماسیون هوشمند در حال تغییر بنیادین زنجیره ارزش فولاد هستند.',
|
|
||||||
title_en: 'AI-Powered Market Forecasting', date_en: 'May 4, 2026',
|
|
||||||
excerpt_en: 'Digital transformation and smart automation are fundamentally reshaping the steel value chain.', sort_order: 2 },
|
|
||||||
{ id: 'r3', category: 'market', img: '/news/photo-1504307651254-35680f356dfd.jpg',
|
|
||||||
author_name: 'دکتر مریم رضایی', author_avatar: '/news/photo-1438761681033-6461ffad8d80.jpg',
|
|
||||||
title_fa: 'پیشبینی بازار با هوش مصنوعی', date_fa: '۱۴ اردیبهشت ۱۴۰۵',
|
|
||||||
excerpt_fa: 'تحلیل روند قیمت طلا و فلزات گرانبها و اثر آن بر سرمایهگذاری در زنجیره فولاد.',
|
|
||||||
title_en: 'AI-Powered Market Forecasting', date_en: 'May 4, 2026',
|
|
||||||
excerpt_en: 'Gold and precious-metal price trends and their effect on steel-chain investment.', sort_order: 3 },
|
|
||||||
{ id: 'r4', category: 'geo', img: '/news/photo-1518770660439-4636190af475.jpg',
|
|
||||||
author_name: 'دکتر علی موسوی', author_avatar: '/news/photo-1472099645785-5658abf4ff4e.jpg',
|
|
||||||
title_fa: 'پیشبینی بازار با هوش مصنوعی', date_fa: '۱۴ اردیبهشت ۱۴۰۵',
|
|
||||||
excerpt_fa: 'هندسه قدرت جهانی و اثر تحولات ژئوپلیتیک بر بازارهای صادراتی فولاد ایران.',
|
|
||||||
title_en: 'AI-Powered Market Forecasting', date_en: 'May 4, 2026',
|
|
||||||
excerpt_en: "Global power geometry and the impact of geopolitics on Iran's steel export markets.", sort_order: 4 },
|
|
||||||
{ id: 'r5', category: 'energy', img: '/news/photo-1466611653911-95081537e5b7.jpg',
|
|
||||||
author_name: 'مهندس نازنین حسینی', author_avatar: '/news/photo-1544005313-94ddf0286df2.jpg',
|
|
||||||
title_fa: 'گذار انرژی و فولاد سبز', date_fa: '۱۲ اردیبهشت ۱۴۰۵',
|
|
||||||
excerpt_fa: 'هیدروژن سبز و برق تجدیدپذیر مسیر کربنزدایی صنعت فولاد را شتاب میدهند.',
|
|
||||||
title_en: 'Energy Transition & Green Steel', date_en: 'May 2, 2026',
|
|
||||||
excerpt_en: 'Green hydrogen and renewable power are accelerating steel decarbonization.', sort_order: 5 },
|
|
||||||
{ id: 'r6', category: 'tech', img: '/news/photo-1567789884554-0b844b597180.jpg',
|
|
||||||
author_name: 'دکتر سارا احمدی', author_avatar: '/news/photo-1494790108377-be9c29b29330.jpg',
|
|
||||||
title_fa: 'اتوماسیون خطوط نورد', date_fa: '۱۰ اردیبهشت ۱۴۰۵',
|
|
||||||
excerpt_fa: 'سیستمهای کنترل هوشمند بهرهوری خطوط نورد را تا ۲۲٪ افزایش دادهاند.',
|
|
||||||
title_en: 'Rolling Line Automation', date_en: 'Apr 30, 2026',
|
|
||||||
excerpt_en: 'Smart control systems have boosted rolling-line productivity by up to 22%.', sort_order: 6 },
|
|
||||||
{ id: 'r7', category: 'commodity', img: '/news/photo-1570168007204-dfb528c6958f.jpg',
|
|
||||||
author_name: 'مهندس رضا کریمی', author_avatar: '/news/photo-1500648767791-00dcc994a43e.jpg',
|
|
||||||
title_fa: 'زنجیره تأمین سنگآهن', date_fa: '۸ اردیبهشت ۱۴۰۵',
|
|
||||||
excerpt_fa: 'نوسانات عرضه سنگآهن استرالیا و برزیل و پیامد آن برای تولیدکنندگان منطقه.',
|
|
||||||
title_en: 'Iron Ore Supply Chain', date_en: 'Apr 28, 2026',
|
|
||||||
excerpt_en: 'Australian and Brazilian iron-ore supply swings and their regional fallout.', sort_order: 7 },
|
|
||||||
{ id: 'r8', category: 'market', img: '/news/photo-1540575467063-178a50c2df87.jpg',
|
|
||||||
author_name: 'دکتر مریم رضایی', author_avatar: '/news/photo-1438761681033-6461ffad8d80.jpg',
|
|
||||||
title_fa: 'تحلیل بازار جهانی فولاد', date_fa: '۶ اردیبهشت ۱۴۰۵',
|
|
||||||
excerpt_fa: 'سیل صادرات فولاد چین و فشار قیمتی بر بازارهای هدف صادراتی ایران.',
|
|
||||||
title_en: 'Global Steel Market Analysis', date_en: 'Apr 26, 2026',
|
|
||||||
excerpt_en: "China's steel export flood and price pressure on Iran's target markets.", sort_order: 8 },
|
|
||||||
];
|
|
||||||
for (const r of radar) { upsertRadar.run(r); }
|
|
||||||
console.log(` ✓ ${radar.length} سیگنال رادار وارد شد`);
|
|
||||||
|
|
||||||
/* ─── FACTORY REPORTS ───────────────────────────────────────── */
|
|
||||||
const upsertFactoryReport = db.prepare(`
|
|
||||||
INSERT OR REPLACE INTO factory_reports
|
|
||||||
(id, img, tag_fa, tag_en, title_fa, title_en, date_fa, date_en,
|
|
||||||
excerpt_fa, excerpt_en, scroll_dir, sort_order, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(@id, @img, @tag_fa, @tag_en, @title_fa, @title_en, @date_fa, @date_en,
|
|
||||||
@excerpt_fa, @excerpt_en, @scroll_dir, @sort_order, datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
const factoryReports = [
|
|
||||||
{ id: 'fr1', img: '/news/photo-1504307651254-35680f356dfd.jpg', scroll_dir: 'left', sort_order: 1,
|
|
||||||
tag_fa: 'تولید و صادرات', title_fa: 'تحلیل عملکرد تولید و صادرات', date_fa: 'بهمن ۱۴۰۳',
|
|
||||||
excerpt_fa: 'بررسی آمار تولید ماهانه، مقایسه با بودجه سالانه و روند صادرات فولاد تخت در بازارهای منطقهای.',
|
|
||||||
tag_en: 'Output & Exports', title_en: 'Production & Export Performance', date_en: 'Feb 2025',
|
|
||||||
excerpt_en: 'Monthly output vs. annual budget and the flat-steel export trend across regional markets.' },
|
|
||||||
{ id: 'fr2', img: '/news/photo-1581092160607-ee22731c9b4e.jpg', scroll_dir: 'left', sort_order: 2,
|
|
||||||
tag_fa: 'بازار و قیمت', title_fa: 'وضعیت بازار و قیمتگذاری', date_fa: 'دی ۱۴۰۳',
|
|
||||||
excerpt_fa: 'تحلیل نوسانات قیمت فولاد تخت در بازار داخلی و اثر نرخ ارز بر چشمانداز زمستان.',
|
|
||||||
tag_en: 'Market & Price', title_en: 'Market & Pricing Outlook', date_en: 'Jan 2025',
|
|
||||||
excerpt_en: 'Flat-steel price swings in the domestic market and the FX effect on the winter outlook.' },
|
|
||||||
{ id: 'fr3', img: '/news/photo-1518770660439-4636190af475.jpg', scroll_dir: 'left', sort_order: 3,
|
|
||||||
tag_fa: 'فناوری', title_fa: 'دیجیتالسازی خطوط تولید', date_fa: 'آذر ۱۴۰۳',
|
|
||||||
excerpt_fa: 'گزارشی از پیادهسازی سامانههای کنترل هوشمند و اثر آن بر بهرهوری خطوط نورد.',
|
|
||||||
tag_en: 'Technology', title_en: 'Production Line Digitalization', date_en: 'Dec 2024',
|
|
||||||
excerpt_en: 'Rolling-out smart control systems and their impact on rolling-line productivity.' },
|
|
||||||
{ id: 'fr4', img: '/news/photo-1567789884554-0b844b597180.jpg', scroll_dir: 'up', sort_order: 4,
|
|
||||||
tag_fa: 'نیروی انسانی', title_fa: 'تحلیل نیروی انسانی و بهرهوری', date_fa: 'آبان ۱۴۰۳',
|
|
||||||
excerpt_fa: 'شاخصهای بهرهوری نیروی کار، آموزشهای ماهانه و آمار ایمنی شغلی.',
|
|
||||||
tag_en: 'Workforce', title_en: 'Workforce & Productivity', date_en: 'Nov 2024',
|
|
||||||
excerpt_en: 'Labor productivity indicators, monthly training and occupational-safety statistics.' },
|
|
||||||
{ id: 'fr5', img: '/news/photo-1466611653911-95081537e5b7.jpg', scroll_dir: 'up', sort_order: 5,
|
|
||||||
tag_fa: 'پایداری', title_fa: 'گزارش پایداری و محیط زیست', date_fa: 'آذر ۱۴۰۳',
|
|
||||||
excerpt_fa: 'شاخصهای زیستمحیطی ماهانه؛ مصرف انرژی، کاهش انتشار کربن و بازچرخانی پساب صنعتی.',
|
|
||||||
tag_en: 'Sustainability', title_en: 'Sustainability & Environment', date_en: 'Dec 2024',
|
|
||||||
excerpt_en: 'Monthly environmental metrics: energy use, carbon reduction and industrial water recycling.' },
|
|
||||||
{ id: 'fr6', img: '/news/photo-1570168007204-dfb528c6958f.jpg', scroll_dir: 'up', sort_order: 6,
|
|
||||||
tag_fa: 'زنجیره تأمین', title_fa: 'تأمین مواد اولیه و لجستیک', date_fa: 'مهر ۱۴۰۳',
|
|
||||||
excerpt_fa: 'وضعیت تأمین سنگآهن و قراضه، موجودی انبار و تحلیل ریسک زنجیره تأمین.',
|
|
||||||
tag_en: 'Supply Chain', title_en: 'Raw Materials & Logistics', date_en: 'Oct 2024',
|
|
||||||
excerpt_en: 'Iron-ore and scrap supply, inventory levels and supply-chain risk analysis.' },
|
|
||||||
{ id: 'fr7', img: '/news/photo-1611273426858-450d8e3c9fce.jpg', scroll_dir: 'right', sort_order: 7,
|
|
||||||
tag_fa: 'سرمایهگذاری', title_fa: 'طرحهای توسعه و سرمایهگذاری', date_fa: 'شهریور ۱۴۰۳',
|
|
||||||
excerpt_fa: 'پیشرفت طرحهای توسعه ظرفیت و تحلیل بازده سرمایهگذاری پروژههای جاری.',
|
|
||||||
tag_en: 'Investment', title_en: 'Expansion & Investment Plans', date_en: 'Sep 2024',
|
|
||||||
excerpt_en: 'Capacity-expansion progress and ROI analysis of ongoing projects.' },
|
|
||||||
{ id: 'fr8', img: '/news/photo-1485827404703-89b55fcc595e.jpg', scroll_dir: 'right', sort_order: 8,
|
|
||||||
tag_fa: 'کیفیت', title_fa: 'کنترل کیفیت با هوش مصنوعی', date_fa: 'مرداد ۱۴۰۳',
|
|
||||||
excerpt_fa: 'کاربرد بینایی ماشین در تشخیص عیوب سطحی و کاهش نرخ ضایعات تولید.',
|
|
||||||
tag_en: 'Quality', title_en: 'AI-Driven Quality Control', date_en: 'Aug 2024',
|
|
||||||
excerpt_en: 'Machine vision for surface-defect detection and lower production scrap rates.' },
|
|
||||||
{ id: 'fr9', img: '/news/photo-1613665813446-82a78c468a1d.jpg', scroll_dir: 'right', sort_order: 9,
|
|
||||||
tag_fa: 'انرژی', title_fa: 'تحلیل مصرف انرژی و بهینهسازی', date_fa: 'تیر ۱۴۰۳',
|
|
||||||
excerpt_fa: 'الگوی مصرف برق و گاز، فرصتهای بهینهسازی و اثر آن بر بهای تمامشده.',
|
|
||||||
tag_en: 'Energy', title_en: 'Energy Use & Optimization', date_en: 'Jul 2024',
|
|
||||||
excerpt_en: 'Electricity and gas usage patterns, optimization opportunities and cost impact.' },
|
|
||||||
];
|
|
||||||
for (const r of factoryReports) { upsertFactoryReport.run(r); }
|
|
||||||
console.log(` ✓ ${factoryReports.length} گزارش کارخانه وارد شد`);
|
|
||||||
|
|
||||||
/* ─── INTEGRATIONS ──────────────────────────────────────────── */
|
|
||||||
const upsertIntegration = db.prepare(`
|
|
||||||
INSERT OR REPLACE INTO integrations
|
|
||||||
(id, name, icon, logo, accent, grid_col, grid_row, sort_order, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(@id, @name, @icon, @logo, @accent, @grid_col, @grid_row, @sort_order, datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
const integrations = [
|
|
||||||
{ id: 'lme', name: 'London Metal Exchange', icon: 'exchange', logo: null, accent: '#CD9E53', grid_col: 6, grid_row: 1, sort_order: 1 },
|
|
||||||
{ id: 'shfe', name: 'Shanghai Futures Exchange', icon: 'trending', logo: null, accent: '#c0392b', grid_col: 4, grid_row: 2, sort_order: 2 },
|
|
||||||
{ id: 'platts', name: 'S&P Global Platts', icon: 'chart', logo: null, accent: '#2471a3', grid_col: 6, grid_row: 2, sort_order: 3 },
|
|
||||||
{ id: 'reuters', name: 'Reuters', icon: 'newspaper', logo: null, accent: '#e67e22', grid_col: 3, grid_row: 3, sort_order: 4 },
|
|
||||||
{ id: 'fastmarkets', name: 'Fastmarkets', icon: 'activity', logo: null, accent: '#1e8449', grid_col: 5, grid_row: 3, sort_order: 5 },
|
|
||||||
{ id: 'worldsteel', name: 'World Steel Association', icon: 'building', logo: null, accent: '#7d3c98', grid_col: 4, grid_row: 4, sort_order: 6 },
|
|
||||||
{ id: 'mysteel', name: 'Mysteel', icon: 'database', logo: null, accent: '#16a085', grid_col: 6, grid_row: 4, sort_order: 7 },
|
|
||||||
{ id: 'cme', name: 'CME Group', icon: 'coins', logo: null, accent: '#2980b9', grid_col: 5, grid_row: 5, sort_order: 8 },
|
|
||||||
{ id: 'dce', name: 'Dalian Commodity Exchange', icon: 'globe', logo: null, accent: '#d35400', grid_col: 3, grid_row: 5, sort_order: 9 },
|
|
||||||
];
|
|
||||||
for (const it of integrations) { upsertIntegration.run(it); }
|
|
||||||
console.log(` ✓ ${integrations.length} منبع داده وارد شد`);
|
|
||||||
|
|
||||||
/* ─── INSTITUTE STATS ───────────────────────────────────────── */
|
|
||||||
const upsertStat = db.prepare(`
|
|
||||||
INSERT OR REPLACE INTO institute_stats
|
|
||||||
(id, label_fa, label_en, value, suffix_fa, sort_order, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(@id, @label_fa, @label_en, @value, @suffix_fa, @sort_order, datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
const instituteStats = [
|
|
||||||
{ id: 'total-reports', label_fa: 'گزارش منتشرشده', label_en: 'Published Reports', value: 280, suffix_fa: '+', sort_order: 1 },
|
|
||||||
{ id: 'years', label_fa: 'سال تجربه', label_en: 'Years of Experience', value: 12, suffix_fa: null, sort_order: 2 },
|
|
||||||
{ id: 'experts', label_fa: 'کارشناس', label_en: 'Experts', value: 45, suffix_fa: null, sort_order: 3 },
|
|
||||||
{ id: 'member-orgs', label_fa: 'سازمان عضو', label_en: 'Member Organizations', value: 60, suffix_fa: '+', sort_order: 4 },
|
|
||||||
{ id: 'subscribers', label_fa: 'مشترک', label_en: 'Subscribers', value: 2800, suffix_fa: '+', sort_order: 5 },
|
|
||||||
];
|
|
||||||
for (const s of instituteStats) { upsertStat.run(s); }
|
|
||||||
console.log(` ✓ ${instituteStats.length} آمار وارد شد`);
|
|
||||||
|
|
||||||
/* ─── MARKET PRICES ─────────────────────────────────────────── */
|
|
||||||
const upsertMarketPrice = db.prepare(`
|
|
||||||
INSERT OR REPLACE INTO market_prices
|
|
||||||
(id, name, value, unit, change, change_percent, trend, sort_order, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(@id, @name, @value, @unit, @change, @change_percent, @trend, @sort_order, datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
const marketPrices = [
|
|
||||||
{ id: 'steel-billet', name: 'شمش فولاد', value: 18400, unit: 'تومان/کیلو', change: 220, change_percent: 1.2, trend: 'up', sort_order: 1 },
|
|
||||||
{ id: 'rebar-a3', name: 'میلگرد A3', value: 17800, unit: 'تومان/کیلو', change: -90, change_percent: -0.5, trend: 'down', sort_order: 2 },
|
|
||||||
{ id: 'beam-14', name: 'تیرآهن ۱۴', value: 16900, unit: 'تومان/کیلو', change: 135, change_percent: 0.8, trend: 'up', sort_order: 3 },
|
|
||||||
{ id: 'scrap-iron', name: 'آهن قراضه', value: 550, unit: 'دلار/تن', change: -11, change_percent: -2.0, trend: 'down', sort_order: 4 },
|
|
||||||
{ id: 'cold-rolled-sheet', name: 'ورق سرد', value: 22100, unit: 'تومان/کیلو', change: 66, change_percent: 0.3, trend: 'up', sort_order: 5 },
|
|
||||||
{ id: 'angle-bar-80', name: 'نبشی ۸۰', value: 15600, unit: 'تومان/کیلو', change: -172, change_percent: -1.1, trend: 'down', sort_order: 6 },
|
|
||||||
{ id: 'mobarakeh-steel', name: 'فولاد مبارکه', value: 12840, unit: 'ریال', change: -143, change_percent: -1.1, trend: 'down', sort_order: 7 },
|
|
||||||
];
|
|
||||||
for (const p of marketPrices) { upsertMarketPrice.run(p); }
|
|
||||||
console.log(` ✓ ${marketPrices.length} قیمت بازار وارد شد`);
|
|
||||||
|
|
||||||
/* ─── MARKET CHART POINTS ───────────────────────────────────── */
|
|
||||||
const upsertChartPoint = db.prepare(`
|
|
||||||
INSERT OR REPLACE INTO market_chart_points
|
|
||||||
(id, series, label, value, meta, sort_order, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(@id, @series, @label, @value, @meta, @sort_order, datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
const priceHistory = [
|
|
||||||
{ label: 'خرداد ۱۴۰۳', value: 15200 }, { label: 'تیر ۱۴۰۳', value: 15600 },
|
|
||||||
{ label: 'مرداد ۱۴۰۳', value: 16100 }, { label: 'شهریور ۱۴۰۳', value: 15800 },
|
|
||||||
{ label: 'مهر ۱۴۰۳', value: 16400 }, { label: 'آبان ۱۴۰۳', value: 17000 },
|
|
||||||
{ label: 'آذر ۱۴۰۳', value: 17500 }, { label: 'دی ۱۴۰۳', value: 17200 },
|
|
||||||
{ label: 'بهمن ۱۴۰۳', value: 17800 }, { label: 'اسفند ۱۴۰۳', value: 18100 },
|
|
||||||
{ label: 'فروردین ۱۴۰۴', value: 18600 }, { label: 'اردیبهشت ۱۴۰۴', value: 18400 },
|
|
||||||
];
|
|
||||||
priceHistory.forEach((p, i) => upsertChartPoint.run({ id: `history-${i + 1}`, series: 'history', label: p.label, value: p.value, meta: null, sort_order: i + 1 }));
|
|
||||||
const monthlyExport = [
|
|
||||||
{ label: 'خرداد ۱۴۰۳', value: 285000 }, { label: 'تیر ۱۴۰۳', value: 310000 },
|
|
||||||
{ label: 'مرداد ۱۴۰۳', value: 295000 }, { label: 'شهریور ۱۴۰۳', value: 320000 },
|
|
||||||
{ label: 'مهر ۱۴۰۳', value: 350000 }, { label: 'آبان ۱۴۰۳', value: 375000 },
|
|
||||||
{ label: 'آذر ۱۴۰۳', value: 390000 }, { label: 'دی ۱۴۰۳', value: 360000 },
|
|
||||||
{ label: 'بهمن ۱۴۰۳', value: 415000 }, { label: 'اسفند ۱۴۰۳', value: 420000 },
|
|
||||||
{ label: 'فروردین ۱۴۰۴', value: 400000 }, { label: 'اردیبهشت ۱۴۰۴', value: 410000 },
|
|
||||||
];
|
|
||||||
monthlyExport.forEach((p, i) => upsertChartPoint.run({ id: `export-${i + 1}`, series: 'export', label: p.label, value: p.value, meta: null, sort_order: i + 1 }));
|
|
||||||
const globalComparison = [
|
|
||||||
{ label: 'ایران', value: 18400, meta: { flag: '🇮🇷', currency: 'IRR', unit: 'تومان/کیلو' } },
|
|
||||||
{ label: 'ترکیه', value: 530, meta: { flag: '🇹🇷', currency: 'USD', unit: 'دلار/تن' } },
|
|
||||||
{ label: 'هند', value: 510, meta: { flag: '🇮🇳', currency: 'USD', unit: 'دلار/تن' } },
|
|
||||||
{ label: 'چین', value: 460, meta: { flag: '🇨🇳', currency: 'USD', unit: 'دلار/تن' } },
|
|
||||||
{ label: 'روسیه', value: 485, meta: { flag: '🇷🇺', currency: 'USD', unit: 'دلار/تن' } },
|
|
||||||
];
|
|
||||||
globalComparison.forEach((p, i) => upsertChartPoint.run({ id: `comparison-${i + 1}`, series: 'comparison', label: p.label, value: p.value, meta: JSON.stringify(p.meta), sort_order: i + 1 }));
|
|
||||||
console.log(` ✓ ${priceHistory.length + monthlyExport.length + globalComparison.length} نقطه نمودار وارد شد`);
|
|
||||||
|
|
||||||
/* ─── VISION ITEMS ──────────────────────────────────────────── */
|
|
||||||
const upsertVision = db.prepare(`
|
|
||||||
INSERT OR REPLACE INTO vision_items
|
|
||||||
(id, icon, title_fa, title_en, desc_fa, desc_en, sort_order, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(@id, @icon, @title_fa, @title_en, @desc_fa, @desc_en, @sort_order, datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
const visionItems = [
|
|
||||||
{ id:'research-leadership', icon:'FlaskConical', title_fa:'پیشرو در پژوهش', title_en:'Research Leadership', desc_fa:'تولید تحلیلهای راهبردی و گزارشهای تخصصی در حوزه صنعت فولاد', desc_en:'Producing strategic analyses and specialized reports in the steel industry', sort_order:0 },
|
|
||||||
{ id:'expert-networking', icon:'Network', title_fa:'شبکهسازی خبرگان', title_en:'Expert Networking', desc_fa:'ایجاد پیوند میان متخصصان، سیاستگذاران و فعالان صنعتی', desc_en:'Building connections between specialists, policymakers, and industry practitioners', sort_order:1 },
|
|
||||||
{ id:'green-steel', icon:'Leaf', title_fa:'فولاد سبز', title_en:'Green Steel', desc_fa:'ترویج رویکردهای پایدار و کاهش اثرات زیستمحیطی در زنجیره فولاد', desc_en:'Promoting sustainable approaches and reducing environmental impacts across the steel chain', sort_order:2 },
|
|
||||||
{ id:'futures-research', icon:'Telescope', title_fa:'آیندهپژوهی', title_en:'Futures Research', desc_fa:'رصد هوشمند تحولات جهانی و ارائه نقشه راه برای صنعت ایران', desc_en:"Smart monitoring of global developments and providing roadmaps for Iran's industry", sort_order:3 },
|
|
||||||
];
|
|
||||||
for (const v of visionItems) { upsertVision.run(v); }
|
|
||||||
console.log(` ✓ ${visionItems.length} آیتم چشمانداز وارد شد`);
|
|
||||||
|
|
||||||
/* ─── ADVISORY BOARD ────────────────────────────────────────── */
|
|
||||||
const upsertAdvisory = db.prepare(`
|
|
||||||
INSERT OR REPLACE INTO advisory_board
|
|
||||||
(id, name_fa, name_en, role_fa, role_en, sort_order, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(@id, @name_fa, @name_en, @role_fa, @role_en, @sort_order, datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
const advisoryBoard = [
|
|
||||||
{ id:'alireza-akbarian', name_fa:'دکتر علیرضا اکبریان', name_en:'Dr. Alireza Akbarian', role_fa:'مدیر اندیشکده', role_en:'Institute Director', sort_order:0 },
|
|
||||||
{ id:'sara-mohammadi', name_fa:'مهندس سارا محمدی', name_en:'Eng. Sara Mohammadi', role_fa:'پژوهشگر ارشد فولاد سبز', role_en:'Senior Green Steel Researcher', sort_order:1 },
|
|
||||||
{ id:'mohammad-jalali', name_fa:'دکتر محمد جلالی', name_en:'Dr. Mohammad Jalali', role_fa:'تحلیلگر بازارهای جهانی', role_en:'Global Markets Analyst', sort_order:2 },
|
|
||||||
{ id:'fatemeh-rezaei', name_fa:'دکتر فاطمه رضایی', name_en:'Dr. Fatemeh Rezaei', role_fa:'متخصص ژئوپلیتیک صنعتی', role_en:'Industrial Geopolitics Specialist', sort_order:3 },
|
|
||||||
{ id:'reza-karimi', name_fa:'مهندس رضا کریمی', name_en:'Eng. Reza Karimi', role_fa:'کارشناس فناوری و نوآوری', role_en:'Technology & Innovation Expert', sort_order:4 },
|
|
||||||
{ id:'niloofar-hosseini', name_fa:'دکتر نیلوفر حسینی', name_en:'Dr. Niloofar Hosseini', role_fa:'پژوهشگر آیندهپژوهی', role_en:'Futures Research Specialist', sort_order:5 },
|
|
||||||
];
|
|
||||||
for (const m of advisoryBoard) { upsertAdvisory.run(m); }
|
|
||||||
console.log(` ✓ ${advisoryBoard.length} عضو شورای مشورتی وارد شد`);
|
|
||||||
|
|
||||||
/* ─── VIDEOCAST / PODCAST (reuse articles) ──────────────────── */
|
|
||||||
const upsertMediaArticle = db.prepare(`
|
|
||||||
INSERT OR REPLACE INTO articles
|
|
||||||
(id, title, category, type, author, publish_date, cover_image, summary, is_free, tags, pages, price, featured, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
(@id, @title, @category, @type, @author, @publish_date, @cover_image, @summary, 1, '[]', 0, 0, 0, datetime('now'), datetime('now'))
|
|
||||||
`);
|
|
||||||
const mediaArticles = [
|
|
||||||
{ id:'video-1', type:'video', category:'اخبار صنعت', title:'نگاهی به آن سوی مرزهای تولید', author:'گفتگو با مهندس سیامک فجری', publish_date:'۲۵ خرداد ۱۴۰۵', cover_image:'/news/photo-1504307651254-35680f356dfd.jpg', summary:'در ویدیوکست «Steel Horizon»، فراتر از دادههای خام قیمت و تناژ، به سراغ مهندسی تغییر و معماری آینده صنعت میرویم.' },
|
|
||||||
{ id:'video-2', type:'video', category:'بازار جهانی', title:'تحلیل بازار جهانی فلزات پایه', author:'گفتگو با کارشناسان بازار', publish_date:'۱۸ خرداد ۱۴۰۵', cover_image:'/news/photo-1558618666-fcd25c85cd64.jpg', summary:'نوسانات قیمت مس، آلومینیوم و روی و تأثیر آن بر زنجیره فولاد را با تحلیلگران بازار بررسی میکنیم.' },
|
|
||||||
{ id:'video-3', type:'video', category:'صادرات', title:'ژئوپلیتیک و صادرات فولاد', author:'گفتگو با تحلیلگران ژئواکونومی', publish_date:'۱۱ خرداد ۱۴۰۵', cover_image:'/news/photo-1504328345606-18bbc8c9d7d1.jpg', summary:'تأثیر تحولات منطقهای بر بازارهای صادراتی فولاد ایران و فرصتهای پیش رو.' },
|
|
||||||
{ id:'video-4', type:'video', category:'تولید داخلی', title:'سرمایهگذاری در صنعت فولاد', author:'گفتگو با مدیران سرمایهگذاری', publish_date:'۴ خرداد ۱۴۰۵', cover_image:'/news/photo-1611273426858-450d8e3c9fce.jpg', summary:'فرصتها و چالشهای سرمایهگذاری در بخشهای مختلف زنجیره ارزش فولاد.' },
|
|
||||||
{ id:'podcast-1', type:'podcast', category:'اخبار صنعت', title:'کالبدشکافی عقلانی پیچیدگیهای صنعتی', author:'مهندس سیامک شجاعی', publish_date:'۲۵ / خرداد / ۱۴۰۵', cover_image:'/news/photo-1540575467063-178a50c2df87.jpg', summary:'صنعت فولاد در عصر حاضر در پیچیدهترین چرخه حیات خود قرار گرفته است؛ جایی که همگرایی هوش مصنوعی، الزامات زیستمحیطی و تنشهای ژئواکونومیک قواعد کلاسیک تولید و تجارت را بازنویسی کردهاند.' },
|
|
||||||
{ id:'podcast-2', type:'podcast', category:'اخبار صنعت', title:'آزمایشگاه کوره', author:'کارشناسان بازار', publish_date:'۱۸ / خرداد / ۱۴۰۵', cover_image:'/news/photo-1566873535350-6586b0b7a1f2.jpg', summary:'«The Foundry Lab» محلی برای کالبدشکافی دقیق تحولات فناورانه و مدلهای نوین فولادسازی است.' },
|
|
||||||
{ id:'podcast-3', type:'podcast', category:'اخبار صنعت', title:'معمار فولاد', author:'رهبران صنعت', publish_date:'۱۱ خرداد ۱۴۰۵', cover_image:'/news/photo-1578662996442-48f60103fc96.jpg', summary:'در «Steel Architect» به سراغ داستان ساختن فولاد فردا میرویم؛ روایت رهبرانی که مدلهای سنتی کسبوکار را به چالش کشیدهاند.' },
|
|
||||||
];
|
|
||||||
for (const a of mediaArticles) { upsertMediaArticle.run(a); }
|
|
||||||
console.log(` ✓ ${mediaArticles.length} ویدیوکست/پادکست وارد شد`);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n⏳ در حال وارد کردن محتوا به پایگاه داده...\n');
|
|
||||||
seed();
|
|
||||||
console.log('\n✅ همه محتوا با موفقیت در CMS وارد شد.\n');
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { db } from './db.js';
|
import { db, pool } from './db.js';
|
||||||
|
|
||||||
const username = process.env.ADMIN_USERNAME || 'admin';
|
const username = process.env.ADMIN_USERNAME || 'admin';
|
||||||
const password = process.env.ADMIN_PASSWORD;
|
const password = process.env.ADMIN_PASSWORD;
|
||||||
|
|
@ -9,16 +9,19 @@ if (!password || password.length < 12) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
const existing = await db.prepare('SELECT id FROM users WHERE username = ?').get(username);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.log(`User "${username}" already exists (id=${existing.id}). Skipping.`);
|
console.log(`User "${username}" already exists (id=${existing.id}). Skipping.`);
|
||||||
|
await pool.end();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = bcrypt.hashSync(password, 10);
|
const hash = bcrypt.hashSync(password, 10);
|
||||||
const info = db
|
const info = await db
|
||||||
.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
|
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'owner') RETURNING id")
|
||||||
.run(username, hash);
|
.run(username, hash);
|
||||||
|
|
||||||
console.log(`Created admin user "${username}" (id=${info.lastInsertRowid}).`);
|
console.log(`Created admin user "${username}" (id=${info.lastInsertRowid}).`);
|
||||||
console.log(`Login with the password from your .env (ADMIN_PASSWORD).`);
|
console.log(`Login with the password from your .env (ADMIN_PASSWORD).`);
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
|
|
||||||
704
panel/server.js
|
|
@ -1,9 +1,4 @@
|
||||||
import Database from 'better-sqlite3';
|
import { db, pool } from './db.js';
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const db = new Database(path.join(__dirname, 'data.db'));
|
|
||||||
|
|
||||||
// id → image (served from the frontend /public/news folder at /news/...)
|
// id → image (served from the frontend /public/news folder at /news/...)
|
||||||
const radarImages = {
|
const radarImages = {
|
||||||
|
|
@ -24,7 +19,7 @@ const radarImages = {
|
||||||
const stmt = db.prepare('UPDATE radar_items SET img = ? WHERE id = ?');
|
const stmt = db.prepare('UPDATE radar_items SET img = ? WHERE id = ?');
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
for (const [id, img] of Object.entries(radarImages)) {
|
for (const [id, img] of Object.entries(radarImages)) {
|
||||||
updated += stmt.run(img, id).changes;
|
updated += (await stmt.run(img, id)).changes;
|
||||||
}
|
}
|
||||||
console.log(`✓ radar images updated: ${updated}/${Object.keys(radarImages).length}`);
|
console.log(`✓ radar images updated: ${updated}/${Object.keys(radarImages).length}`);
|
||||||
|
|
||||||
|
|
@ -38,6 +33,8 @@ const articleImages = {
|
||||||
const aStmt = db.prepare('UPDATE articles SET cover_image = ? WHERE id = ?');
|
const aStmt = db.prepare('UPDATE articles SET cover_image = ? WHERE id = ?');
|
||||||
let aUpdated = 0;
|
let aUpdated = 0;
|
||||||
for (const [id, img] of Object.entries(articleImages)) {
|
for (const [id, img] of Object.entries(articleImages)) {
|
||||||
aUpdated += aStmt.run(img, id).changes;
|
aUpdated += (await aStmt.run(img, id)).changes;
|
||||||
}
|
}
|
||||||
console.log(`✓ article covers updated: ${aUpdated}/${Object.keys(articleImages).length}`);
|
console.log(`✓ article covers updated: ${aUpdated}/${Object.keys(articleImages).length}`);
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
|
|
||||||
BIN
public/hero-bg.mp4 (Stored with Git LFS)
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="109" height="15" viewBox="0 0 109 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M108.078 0C108.082 1.89096 107.796 3.76431 107.236 5.51278C106.675 7.26126 105.853 8.85046 104.814 10.1894C103.776 11.5283 102.542 12.5906 101.183 13.3154C99.8249 14.0402 98.3689 14.4133 96.8982 14.4133H11.1782C8.21307 14.4133 5.36932 12.8988 3.27267 10.203C1.17601 7.50724 -0.00199127 3.85098 -0.00199127 0.038571H108.078V0Z" fill="#CD9E53"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 458 B |
|
After Width: | Height: | Size: 23 KiB |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="2" height="23" viewBox="0 0 2 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 1L0.999999 22" stroke="#FA541C" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 187 B |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="2" height="23" viewBox="0 0 2 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 1L0.999999 22" stroke="#FA541C" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 187 B |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="2" height="23" viewBox="0 0 2 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 1L0.999999 22" stroke="#FA541C" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 187 B |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="2" height="23" viewBox="0 0 2 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 1L0.999999 22" stroke="#FA541C" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 187 B |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="2" height="23" viewBox="0 0 2 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M1 1L0.999999 22" stroke="#FA541C" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 187 B |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 6L14.2929 11.2929C14.6262 11.6262 14.7929 11.7929 14.7929 12C14.7929 12.2071 14.6262 12.3738 14.2929 12.7071L9 18" stroke="#CD9E53" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 315 B |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="36" height="27" viewBox="0 0 36 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8.88715 15.1816L16.7285 9.44187C17.307 9.01837 18.2451 9.01837 18.8236 9.44187L26.6649 15.1817" stroke="#556157" stroke-opacity="0.5" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 289 B |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="36" height="26" viewBox="0 0 36 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8.88715 14.957L16.7285 9.30259C17.307 8.88539 18.2451 8.88539 18.8236 9.30259L26.6649 14.957" stroke="#556157" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 266 B |
|
After Width: | Height: | Size: 23 KiB |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M40 20.0006C40 30.1016 32.5118 38.4522 22.7848 39.8077C21.8749 39.9344 20.9444 40 20 40C18.9093 40 17.8395 39.9125 16.796 39.7443C7.27399 38.2115 0 29.9542 0 19.9994C0 8.95403 8.95427 0 20 0C31.0457 0 40 8.95403 40 19.9994V20.0006Z" fill="#CD9E53"/>
|
||||||
|
<path d="M7.08909 19.0991C7.14552 19.0703 7.20195 19.0438 7.25838 19.0184C8.22003 18.5728 9.19435 18.1559 10.1675 17.739C10.2205 17.739 10.3092 17.6791 10.3575 17.6572C10.4324 17.625 10.5072 17.5927 10.5833 17.5605C10.7272 17.4983 10.8723 17.4372 11.0163 17.375C11.3054 17.2518 11.5933 17.1274 11.8823 17.0042C12.4593 16.7578 13.0375 16.5102 13.6145 16.2626C14.7696 15.7685 15.9247 15.2744 17.0799 14.7804C18.235 14.2863 19.3901 13.7923 20.5452 13.2982C21.7004 12.8042 22.8555 12.3101 24.0107 11.8161C25.1658 11.322 26.3209 10.828 27.4761 10.3339C27.7329 10.2245 28.0116 10.0587 28.2857 10.0103C28.5171 9.96999 28.7429 9.88937 28.9744 9.84561C29.4154 9.76154 29.9026 9.72699 30.3253 9.9101C30.4716 9.97345 30.6051 10.0633 30.7192 10.175C31.2547 10.7059 31.1798 11.5765 31.067 12.3228C30.2769 17.5236 29.4869 22.7233 28.6957 27.9241C28.5874 28.637 28.4412 29.4201 27.8768 29.8704C27.4 30.2516 26.7217 30.2953 26.1343 30.1329C25.5458 29.9706 25.0264 29.6285 24.5174 29.2911C22.4052 27.893 20.293 26.4938 18.1809 25.0957C17.6787 24.7628 17.1202 24.3298 17.1259 23.7275C17.1294 23.3647 17.3459 23.0411 17.567 22.7532C19.4017 20.3601 22.0517 18.7144 24.021 16.4307C24.2986 16.1094 24.5174 15.5267 24.1362 15.3412C23.9093 15.2307 23.6479 15.3804 23.4417 15.5244C20.8378 17.3336 18.2338 19.1428 15.6299 20.9509C14.78 21.5405 13.8897 22.1486 12.8659 22.2937C11.9503 22.4238 11.0278 22.1693 10.141 21.9079C9.3982 21.6891 8.65651 21.4657 7.91714 21.2342C7.52442 21.1121 7.11902 20.9808 6.81498 20.7033C6.51093 20.4269 6.33702 19.9616 6.52014 19.5931C6.63416 19.3616 6.85761 19.2154 7.0914 19.0968L7.08909 19.0991Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M40 20.0006C40 30.1016 32.5118 38.4522 22.7848 39.8077C21.8749 39.9344 20.9444 40 20 40C18.9094 40 17.8395 39.9125 16.796 39.7443C7.27399 38.2115 0 29.9542 0 19.9994C0 8.95402 8.95429 0 20 0C31.0457 0 40 8.95402 40 19.9994V20.0006Z" fill="#CD9E53"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 362 B |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12.0391 15.0524H4.95573C2.37031 15.0524 0.882812 13.5649 0.882812 10.9795V6.02116C0.882812 3.43574 2.37031 1.94824 4.95573 1.94824H12.0391C14.6245 1.94824 16.112 3.43574 16.112 6.02116V10.9795C16.112 13.5649 14.6245 15.0524 12.0391 15.0524ZM4.95573 3.01074C2.9299 3.01074 1.94531 3.99533 1.94531 6.02116V10.9795C1.94531 13.0053 2.9299 13.9899 4.95573 13.9899H12.0391C14.0649 13.9899 15.0495 13.0053 15.0495 10.9795V6.02116C15.0495 3.99533 14.0649 3.01074 12.0391 3.01074H4.95573Z" fill="#EDEDED"/>
|
||||||
|
<path d="M8.32483 8.23463C7.72983 8.23463 7.12775 8.05047 6.66734 7.67505L4.45025 5.90422C4.22358 5.72005 4.18109 5.38714 4.36525 5.16047C4.54942 4.9338 4.88234 4.89131 5.10901 5.07547L7.32608 6.84631C7.86442 7.27839 8.77816 7.27839 9.3165 6.84631L11.5336 5.07547C11.7603 4.89131 12.1003 4.92672 12.2773 5.16047C12.4615 5.38714 12.4261 5.72714 12.1923 5.90422L9.97525 7.67505C9.52192 8.05047 8.91983 8.23463 8.32483 8.23463Z" fill="#EDEDED"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14.9425 6.71289L10.0525 11.6029C9.475 12.1804 8.53 12.1804 7.9525 11.6029L3.0625 6.71289" stroke="#E14C19" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 311 B |
|
|
@ -0,0 +1,11 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 2V5" stroke="#CD9E53" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 2V5" stroke="#CD9E53" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3.5 9.08984H20.5" stroke="#CD9E53" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 19C22 19.75 21.79 20.46 21.42 21.06C20.73 22.22 19.46 23 18 23C16.99 23 16.07 22.63 15.37 22C15.06 21.74 14.79 21.42 14.58 21.06C14.21 20.46 14 19.75 14 19C14 16.79 15.79 15 18 15C19.2 15 20.27 15.53 21 16.36C21.62 17.07 22 17.99 22 19Z" stroke="#CD9E53" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16.4375 18.9995L17.4275 19.9895L19.5575 18.0195" stroke="#CD9E53" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M21 8.5V16.36C20.27 15.53 19.2 15 18 15C15.79 15 14 16.79 14 19C14 19.75 14.21 20.46 14.58 21.06C14.79 21.42 15.06 21.74 15.37 22H8C4.5 22 3 20 3 17V8.5C3 5.5 4.5 3.5 8 3.5H16C19.5 3.5 21 5.5 21 8.5Z" stroke="#CD9E53" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M11.9945 13.7002H12.0035" stroke="#CD9E53" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8.29138 13.7002H8.30036" stroke="#CD9E53" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8.29138 16.7002H8.30036" stroke="#CD9E53" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
|
@ -0,0 +1,12 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 2V5" stroke="#0084E6" stroke-width="1.11213" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 2V5" stroke="#0084E6" stroke-width="1.11213" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3.5 9.08984H20.5" stroke="#0084E6" stroke-width="1.11213" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M21 8.5V17C21 20 19.5 22 16 22H8C4.5 22 3 20 3 17V8.5C3 5.5 4.5 3.5 8 3.5H16C19.5 3.5 21 5.5 21 8.5Z" stroke="#0084E6" stroke-width="1.11213" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M15.6976 13.7002H15.7066" stroke="#0084E6" stroke-width="1.48284" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M15.6976 16.7002H15.7066" stroke="#0084E6" stroke-width="1.48284" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M11.9945 13.7002H12.0035" stroke="#0084E6" stroke-width="1.48284" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M11.9945 16.7002H12.0035" stroke="#0084E6" stroke-width="1.48284" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8.29138 13.7002H8.30036" stroke="#0084E6" stroke-width="1.48284" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8.29138 16.7002H8.30036" stroke="#0084E6" stroke-width="1.48284" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10.8359 9.16634L17.6693 2.33301" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M18.3359 5.66699V1.66699H14.3359" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9.16406 1.66699H7.4974C3.33073 1.66699 1.66406 3.33366 1.66406 7.50033V12.5003C1.66406 16.667 3.33073 18.3337 7.4974 18.3337H12.4974C16.6641 18.3337 18.3307 16.667 18.3307 12.5003V10.8337" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 637 B |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12.0028 13.4304C13.7259 13.4304 15.1228 12.0336 15.1228 10.3104C15.1228 8.5873 13.7259 7.19043 12.0028 7.19043C10.2797 7.19043 8.88281 8.5873 8.88281 10.3104C8.88281 12.0336 10.2797 13.4304 12.0028 13.4304Z" stroke="#CD9E53" stroke-width="1.5"/>
|
||||||
|
<path d="M3.61776 8.49C5.58776 -0.169998 18.4178 -0.159997 20.3778 8.5C21.5278 13.58 18.3678 17.88 15.5978 20.54C13.5878 22.48 10.4078 22.48 8.38776 20.54C5.62776 17.88 2.46776 13.57 3.61776 8.49Z" stroke="#CD9E53" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 595 B |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M13 22.75H5C2.58 22.75 1.25 21.42 1.25 19V11C1.25 8.58 2.58 7.25 5 7.25H10C10.41 7.25 10.75 7.59 10.75 8V19C10.75 20.58 11.42 21.25 13 21.25C13.41 21.25 13.75 21.59 13.75 22C13.75 22.41 13.41 22.75 13 22.75ZM5 8.75C3.42 8.75 2.75 9.42 2.75 11V19C2.75 20.58 3.42 21.25 5 21.25H9.79999C9.43999 20.66 9.25 19.91 9.25 19V8.75H5Z" fill="#032340"/>
|
||||||
|
<path d="M10 8.75H5C4.59 8.75 4.25 8.41 4.25 8V6C4.25 4.48 5.48 3.25 7 3.25H10.11C10.34 3.25 10.56 3.35998 10.7 3.53998C10.84 3.72998 10.89 3.97 10.83 4.19C10.77 4.41 10.75 4.66 10.75 5V8C10.75 8.41 10.41 8.75 10 8.75ZM5.75 7.25H9.25V5C9.25 4.91 9.25 4.83 9.25 4.75H7C6.31 4.75 5.75 5.31 5.75 6V7.25Z" fill="#032340"/>
|
||||||
|
<path d="M14 13.75C13.59 13.75 13.25 13.41 13.25 13V8C13.25 7.59 13.59 7.25 14 7.25C14.41 7.25 14.75 7.59 14.75 8V13C14.75 13.41 14.41 13.75 14 13.75Z" fill="#032340"/>
|
||||||
|
<path d="M18 13.75C17.59 13.75 17.25 13.41 17.25 13V8C17.25 7.59 17.59 7.25 18 7.25C18.41 7.25 18.75 7.59 18.75 8V13C18.75 13.41 18.41 13.75 18 13.75Z" fill="#032340"/>
|
||||||
|
<path d="M18 22.75H14C13.59 22.75 13.25 22.41 13.25 22V18C13.25 17.04 14.04 16.25 15 16.25H17C17.96 16.25 18.75 17.04 18.75 18V22C18.75 22.41 18.41 22.75 18 22.75ZM14.75 21.25H17.25V18C17.25 17.86 17.14 17.75 17 17.75H15C14.86 17.75 14.75 17.86 14.75 18V21.25Z" fill="#032340"/>
|
||||||
|
<path d="M6 17.75C5.59 17.75 5.25 17.41 5.25 17V13C5.25 12.59 5.59 12.25 6 12.25C6.41 12.25 6.75 12.59 6.75 13V17C6.75 17.41 6.41 17.75 6 17.75Z" fill="#032340"/>
|
||||||
|
<path d="M19 22.75H13C10.58 22.75 9.25 21.42 9.25 19V5C9.25 2.58 10.58 1.25 13 1.25H19C21.42 1.25 22.75 2.58 22.75 5V19C22.75 21.42 21.42 22.75 19 22.75ZM13 2.75C11.42 2.75 10.75 3.42 10.75 5V19C10.75 20.58 11.42 21.25 13 21.25H19C20.58 21.25 21.25 20.58 21.25 19V5C21.25 3.42 20.58 2.75 19 2.75H13Z" fill="#032340"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11.5 21.75C5.85 21.75 1.25 17.15 1.25 11.5C1.25 5.85 5.85 1.25 11.5 1.25C17.15 1.25 21.75 5.85 21.75 11.5C21.75 17.15 17.15 21.75 11.5 21.75ZM11.5 2.75C6.67 2.75 2.75 6.68 2.75 11.5C2.75 16.32 6.67 20.25 11.5 20.25C16.33 20.25 20.25 16.32 20.25 11.5C20.25 6.68 16.33 2.75 11.5 2.75Z" fill="white"/>
|
||||||
|
<path d="M21.9975 22.7504C21.8075 22.7504 21.6175 22.6804 21.4675 22.5304L19.4675 20.5304C19.1775 20.2404 19.1775 19.7604 19.4675 19.4704C19.7575 19.1804 20.2375 19.1804 20.5275 19.4704L22.5275 21.4704C22.8175 21.7604 22.8175 22.2404 22.5275 22.5304C22.3775 22.6804 22.1875 22.7504 21.9975 22.7504Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 727 B |
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12.0038 22.7498C11.7638 22.7498 11.5238 22.7198 11.2938 22.6498C6.10376 21.2198 2.34375 16.3698 2.34375 11.1098V6.71978C2.34375 5.59978 3.15376 4.38983 4.19376 3.95983L9.76376 1.6798C11.2138 1.0898 12.8037 1.0898 14.2437 1.6798L19.8138 3.95983C20.8538 4.38983 21.6638 5.59978 21.6638 6.71978V11.1098C21.6638 16.3598 17.8937 21.2098 12.7137 22.6498C12.4837 22.7198 12.2438 22.7498 12.0038 22.7498ZM12.0038 2.74981C11.4338 2.74981 10.8737 2.85983 10.3337 3.07983L4.76376 5.3598C4.28376 5.5598 3.84375 6.20979 3.84375 6.72979V11.1198C3.84375 15.7098 7.14376 19.9498 11.6938 21.2098C11.8938 21.2698 12.1138 21.2698 12.3138 21.2098C16.8638 19.9498 20.1638 15.7098 20.1638 11.1198V6.72979C20.1638 6.20979 19.7237 5.5598 19.2437 5.3598L13.6738 3.07983C13.1338 2.85983 12.5738 2.74981 12.0038 2.74981Z" fill="#032340"/>
|
||||||
|
<path d="M12 13.25C10.48 13.25 9.25 12.02 9.25 10.5C9.25 8.98 10.48 7.75 12 7.75C13.52 7.75 14.75 8.98 14.75 10.5C14.75 12.02 13.52 13.25 12 13.25ZM12 9.25C11.31 9.25 10.75 9.81 10.75 10.5C10.75 11.19 11.31 11.75 12 11.75C12.69 11.75 13.25 11.19 13.25 10.5C13.25 9.81 12.69 9.25 12 9.25Z" fill="#032340"/>
|
||||||
|
<path d="M12 16.25C11.59 16.25 11.25 15.91 11.25 15.5V12.5C11.25 12.09 11.59 11.75 12 11.75C12.41 11.75 12.75 12.09 12.75 12.5V15.5C12.75 15.91 12.41 16.25 12 16.25Z" fill="#032340"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
|
@ -8,9 +8,8 @@ function scrollToTop() {
|
||||||
/* curved top edge with a centered scroll-to-top button (matches the Figma) */
|
/* curved top edge with a centered scroll-to-top button (matches the Figma) */
|
||||||
function FooterCurve() {
|
function FooterCurve() {
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', height: 64, lineHeight: 0, background: 'var(--paper)' }}>
|
<div style={{ position: 'relative', height: 64, lineHeight: 0, background: 'var(--paper, #fff)' }}>
|
||||||
<svg viewBox="0 0 1440 64" preserveAspectRatio="none" width="100%" height="64" style={{ display: 'block' }}>
|
<svg viewBox="0 0 1440 64" preserveAspectRatio="none" width="100%" height="64" style={{ display: 'block' }}>
|
||||||
{/* white (page) above, navy below, with a smooth dip on the LEFT side */}
|
|
||||||
<path
|
<path
|
||||||
d="M0,0 L132,0 C192,0 206,52 240,52 C274,52 288,0 348,0 L1440,0 L1440,64 L0,64 Z"
|
d="M0,0 L132,0 C192,0 206,52 240,52 C274,52 288,0 348,0 L1440,0 L1440,64 L0,64 Z"
|
||||||
fill="#032340"
|
fill="#032340"
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ const BaseMap = memo(function BaseMap({ dotColor }: { dotColor: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
|
/* SAFE: `svg` is generated by DottedMap from static geometry + a hardcoded
|
||||||
|
dotColor. Never feed user/CMS-controlled values into this — it's an HTML sink. */
|
||||||
/* eslint-disable-next-line react/no-danger */
|
/* eslint-disable-next-line react/no-danger */
|
||||||
dangerouslySetInnerHTML={{ __html: svg }}
|
dangerouslySetInnerHTML={{ __html: svg }}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -15,52 +15,27 @@ export type FactoryReport = {
|
||||||
en: { tag: string; title: string; date: string; excerpt: string }
|
en: { tag: string; title: string; date: string; excerpt: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TEMP placeholder dataset — swap for panel/API data later. */
|
|
||||||
export const FACTORY_REPORTS: FactoryReport[] = [
|
export const FACTORY_REPORTS: FactoryReport[] = [
|
||||||
{
|
{
|
||||||
id: 'fr1', img: '/news/photo-1504307651254-35680f356dfd.jpg', dir: 'left',
|
id: 'msc-55',
|
||||||
fa: { tag: 'تولید و صادرات', title: 'تحلیل عملکرد تولید و صادرات', date: 'بهمن ۱۴۰۳', excerpt: 'بررسی آمار تولید ماهانه، مقایسه با بودجه سالانه و روند صادرات فولاد تخت در بازارهای منطقهای.' },
|
img: 'https://www.msc.ir/_DouranPortal/Album/Articles/%DB%B5%DB%B5_14050314_083241.jpg',
|
||||||
en: { tag: 'Output & Exports', title: 'Production & Export Performance', date: 'Feb 2025', excerpt: 'Monthly output vs. annual budget and the flat-steel export trend across regional markets.' },
|
dir: 'left',
|
||||||
|
fa: { tag: 'ماهنامه تحلیلی کارخانه', title: 'ماهنامه تحلیلی کارخانه — شماره ۵۵', date: 'خرداد ۱۴۰۵', excerpt: 'راهبرد یکپارچه چگونه انسجام راهبردی و مزیت رقابتی پایدار فولاد مبارکه را تضمین میکند؟ همافزایی شش محور استراتژیک، تبدیل ثروت معدنی به قدرت صنعتی، و دوام مزیتهای نسبی فولاد ایران زیر فشار ناترازی انرژی.' },
|
||||||
|
en: { tag: 'Factory Analysis', title: 'Factory Analysis Monthly — No. 55', date: 'May 2026', excerpt: 'Integrated strategy, synergy of six strategic axes, turning mineral wealth into industrial power, and the resilience of Iran\'s steel advantages under energy imbalance.' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'fr2', img: '/news/photo-1581092160607-ee22731c9b4e.jpg', dir: 'left',
|
id: 'msc-54',
|
||||||
fa: { tag: 'بازار و قیمت', title: 'وضعیت بازار و قیمتگذاری', date: 'دی ۱۴۰۳', excerpt: 'تحلیل نوسانات قیمت فولاد تخت در بازار داخلی و اثر نرخ ارز بر چشمانداز زمستان.' },
|
img: 'https://www.msc.ir/_DouranPortal/Album/Articles/;hvohki%2054_14050323_095959.jpg',
|
||||||
en: { tag: 'Market & Price', title: 'Market & Pricing Outlook', date: 'Jan 2025', excerpt: 'Flat-steel price swings in the domestic market and the FX effect on the winter outlook.' },
|
dir: 'right',
|
||||||
|
fa: { tag: 'ماهنامه تحلیلی کارخانه', title: 'ماهنامه تحلیلی کارخانه — شماره ۵۴', date: 'مهر ۱۴۰۴', excerpt: 'شماره ۵۴ ماهنامه تحلیلی راهبردی کارخانه فولاد مبارکه؛ بررسی عملکرد، نوآوری و چشمانداز بازار فولاد.' },
|
||||||
|
en: { tag: 'Factory Analysis', title: 'Factory Analysis Monthly — No. 54', date: 'Oct 2025', excerpt: 'Issue No. 54 of Mobarakeh Steel\'s strategic factory analysis monthly.' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'fr3', img: '/news/photo-1518770660439-4636190af475.jpg', dir: 'left',
|
id: 'msc-53',
|
||||||
fa: { tag: 'فناوری', title: 'دیجیتالسازی خطوط تولید', date: 'آذر ۱۴۰۳', excerpt: 'گزارشی از پیادهسازی سامانههای کنترل هوشمند و اثر آن بر بهرهوری خطوط نورد.' },
|
img: 'https://www.msc.ir/_DouranPortal/Album/Articles/01_20250921_103454.jpg',
|
||||||
en: { tag: 'Technology', title: 'Production Line Digitalization', date: 'Dec 2024', excerpt: 'Rolling-out smart control systems and their impact on rolling-line productivity.' },
|
dir: 'up',
|
||||||
},
|
fa: { tag: 'ماهنامه تحلیلی کارخانه', title: 'ماهنامه تحلیلی کارخانه — شماره ۵۳', date: 'شهریور ۱۴۰۴', excerpt: 'عبور از ملاحظات؛ آیا زمان جراحی نهادی برای تحقق توسعه پایدار صنایع معدنی فرا رسیده است؟' },
|
||||||
{
|
en: { tag: 'Factory Analysis', title: 'Factory Analysis Monthly — No. 53', date: 'Sep 2025', excerpt: 'Beyond considerations: has the time come for institutional surgery to achieve sustainable development of the mining industries?' },
|
||||||
id: 'fr4', img: '/news/photo-1567789884554-0b844b597180.jpg', dir: 'up',
|
|
||||||
fa: { tag: 'نیروی انسانی', title: 'تحلیل نیروی انسانی و بهرهوری', date: 'آبان ۱۴۰۳', excerpt: 'شاخصهای بهرهوری نیروی کار، آموزشهای ماهانه و آمار ایمنی شغلی.' },
|
|
||||||
en: { tag: 'Workforce', title: 'Workforce & Productivity', date: 'Nov 2024', excerpt: 'Labor productivity indicators, monthly training and occupational-safety statistics.' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'fr5', img: '/news/photo-1466611653911-95081537e5b7.jpg', dir: 'up',
|
|
||||||
fa: { tag: 'پایداری', title: 'گزارش پایداری و محیط زیست', date: 'آذر ۱۴۰۳', excerpt: 'شاخصهای زیستمحیطی ماهانه؛ مصرف انرژی، کاهش انتشار کربن و بازچرخانی پساب صنعتی.' },
|
|
||||||
en: { tag: 'Sustainability', title: 'Sustainability & Environment', date: 'Dec 2024', excerpt: 'Monthly environmental metrics: energy use, carbon reduction and industrial water recycling.' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'fr6', img: '/news/photo-1570168007204-dfb528c6958f.jpg', dir: 'up',
|
|
||||||
fa: { tag: 'زنجیره تأمین', title: 'تأمین مواد اولیه و لجستیک', date: 'مهر ۱۴۰۳', excerpt: 'وضعیت تأمین سنگآهن و قراضه، موجودی انبار و تحلیل ریسک زنجیره تأمین.' },
|
|
||||||
en: { tag: 'Supply Chain', title: 'Raw Materials & Logistics', date: 'Oct 2024', excerpt: 'Iron-ore and scrap supply, inventory levels and supply-chain risk analysis.' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'fr7', img: '/news/photo-1611273426858-450d8e3c9fce.jpg', dir: 'right',
|
|
||||||
fa: { tag: 'سرمایهگذاری', title: 'طرحهای توسعه و سرمایهگذاری', date: 'شهریور ۱۴۰۳', excerpt: 'پیشرفت طرحهای توسعه ظرفیت و تحلیل بازده سرمایهگذاری پروژههای جاری.' },
|
|
||||||
en: { tag: 'Investment', title: 'Expansion & Investment Plans', date: 'Sep 2024', excerpt: 'Capacity-expansion progress and ROI analysis of ongoing projects.' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'fr8', img: '/news/photo-1485827404703-89b55fcc595e.jpg', dir: 'right',
|
|
||||||
fa: { tag: 'کیفیت', title: 'کنترل کیفیت با هوش مصنوعی', date: 'مرداد ۱۴۰۳', excerpt: 'کاربرد بینایی ماشین در تشخیص عیوب سطحی و کاهش نرخ ضایعات تولید.' },
|
|
||||||
en: { tag: 'Quality', title: 'AI-Driven Quality Control', date: 'Aug 2024', excerpt: 'Machine vision for surface-defect detection and lower production scrap rates.' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'fr9', img: '/news/photo-1613665813446-82a78c468a1d.jpg', dir: 'right',
|
|
||||||
fa: { tag: 'انرژی', title: 'تحلیل مصرف انرژی و بهینهسازی', date: 'تیر ۱۴۰۳', excerpt: 'الگوی مصرف برق و گاز، فرصتهای بهینهسازی و اثر آن بر بهای تمامشده.' },
|
|
||||||
en: { tag: 'Energy', title: 'Energy Use & Optimization', date: 'Jul 2024', excerpt: 'Electricity and gas usage patterns, optimization opportunities and cost impact.' },
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,3 +116,44 @@ export const POSTS: Post[] = [
|
||||||
export function getPost(id: string): Post | undefined {
|
export function getPost(id: string): Post | undefined {
|
||||||
return POSTS.find(p => p.id === id) || POSTS[0]
|
return POSTS.find(p => p.id === id) || POSTS[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the static POSTS with articles from the panel.
|
||||||
|
* The DB article model is monolingual with a single `body` text blob, so we
|
||||||
|
* split it into paragraph blocks; charts/figures/comments exist only in the
|
||||||
|
* static mock and are dropped when DB content is present. Falls back to the
|
||||||
|
* static list when the panel is offline or has no articles.
|
||||||
|
*/
|
||||||
|
export async function bootstrapPosts(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${PANEL_API}/api/articles`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const fetched = await res.json()
|
||||||
|
if (!Array.isArray(fetched) || fetched.length === 0) return
|
||||||
|
POSTS.length = 0
|
||||||
|
POSTS.push(...fetched.map((a: Record<string, unknown>) => {
|
||||||
|
// drop the KEY_DATA appendix some importers add to the body
|
||||||
|
const bodyText = String(a.body ?? '').split('---KEY_DATA---')[0]
|
||||||
|
const body: PostBlock[] = bodyText
|
||||||
|
.split(/\n{2,}/).map(s => s.trim()).filter(Boolean)
|
||||||
|
.map(p => ({ type: 'p' as const, fa: p, en: '' }))
|
||||||
|
const title = String(a.title ?? '')
|
||||||
|
const author = String(a.author ?? '')
|
||||||
|
const date = String(a.publishDate ?? '')
|
||||||
|
const lead = String(a.summary ?? '')
|
||||||
|
const cat = String(a.category ?? '')
|
||||||
|
return {
|
||||||
|
id: String(a.id),
|
||||||
|
cover: String(a.coverImage ?? '') || IMG('photo-1581092160607-ee22731c9b4e.jpg'),
|
||||||
|
category: { fa: cat, en: cat },
|
||||||
|
fa: { title, author, date, lead },
|
||||||
|
en: { title, author, date, lead },
|
||||||
|
body,
|
||||||
|
comments: [],
|
||||||
|
related: RELATED,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
} catch { /* panel offline → keep static fallback */ }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ export interface Member {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
token: string | null
|
|
||||||
member: Member | null
|
member: Member | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,13 +23,21 @@ interface AuthContextValue extends AuthState {
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
// Only ever cache these display fields — never spread an unknown server object into
|
||||||
|
// localStorage (a future API change must not silently leak extra fields to the client store).
|
||||||
|
function toMember(u: Partial<Member> & { id: number; name: string; email: string }): Member {
|
||||||
|
return { id: u.id, name: u.name, email: u.email, phone: u.phone, avatar: u.avatar }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session itself lives in an HttpOnly cookie (set by the panel) and is never
|
||||||
|
// readable from JS. We only cache the member's display profile in localStorage so
|
||||||
|
// the UI can render immediately on reload; it carries no credential.
|
||||||
function load(): AuthState {
|
function load(): AuthState {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('member_token')
|
|
||||||
const raw = localStorage.getItem('member_info')
|
const raw = localStorage.getItem('member_info')
|
||||||
if (token && raw) return { token, member: JSON.parse(raw) }
|
if (raw) return { member: JSON.parse(raw) }
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return { token: null, member: null }
|
return { member: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
|
@ -39,33 +46,35 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const login = useCallback(async (email: string, password: string) => {
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
const res = await fetch(`${BASE}/api/members/login`, {
|
const res = await fetch(`${BASE}/api/members/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || 'خطا در ورود')
|
if (!res.ok) throw new Error(data.error || 'خطا در ورود')
|
||||||
localStorage.setItem('member_token', data.token)
|
const member = toMember(data.user)
|
||||||
localStorage.setItem('member_info', JSON.stringify(data.user))
|
localStorage.setItem('member_info', JSON.stringify(member))
|
||||||
setState({ token: data.token, member: data.user })
|
setState({ member })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const register = useCallback(async (name: string, email: string, password: string, phone?: string, org?: string) => {
|
const register = useCallback(async (name: string, email: string, password: string, phone?: string, org?: string) => {
|
||||||
const res = await fetch(`${BASE}/api/members/register`, {
|
const res = await fetch(`${BASE}/api/members/register`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, email, password, phone, org }),
|
body: JSON.stringify({ name, email, password, phone, org }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || 'خطا در ثبتنام')
|
if (!res.ok) throw new Error(data.error || 'خطا در ثبتنام')
|
||||||
localStorage.setItem('member_token', data.token)
|
const member = toMember(data.user)
|
||||||
localStorage.setItem('member_info', JSON.stringify(data.user))
|
localStorage.setItem('member_info', JSON.stringify(member))
|
||||||
setState({ token: data.token, member: data.user })
|
setState({ member })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
localStorage.removeItem('member_token')
|
fetch(`${BASE}/api/members/logout`, { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||||
localStorage.removeItem('member_info')
|
localStorage.removeItem('member_info')
|
||||||
setState({ token: null, member: null })
|
setState({ member: null })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const updateMember = useCallback((data: Partial<Member>) => {
|
const updateMember = useCallback((data: Partial<Member>) => {
|
||||||
|
|
@ -90,13 +99,16 @@ export function useAuth() {
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authFetch(token: string, path: string, opts: RequestInit = {}) {
|
// Authenticated calls send the HttpOnly session cookie automatically — no token argument.
|
||||||
|
export function authFetch(path: string, opts: RequestInit = {}) {
|
||||||
return fetch(`${BASE}${path}`, {
|
return fetch(`${BASE}${path}`, {
|
||||||
...opts,
|
...opts,
|
||||||
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
...(opts.headers as Record<string, string> ?? {}),
|
...(opts.headers as Record<string, string> ?? {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { BASE }
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export interface VideoItem {
|
||||||
duration: string;
|
duration: string;
|
||||||
date: string;
|
date: string;
|
||||||
thumb: string;
|
thumb: string;
|
||||||
|
mediaUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PodcastItem {
|
export interface PodcastItem {
|
||||||
|
|
@ -30,6 +31,7 @@ export interface PodcastItem {
|
||||||
cover: string;
|
cover: string;
|
||||||
fa: MediaLangText & { ep: string };
|
fa: MediaLangText & { ep: string };
|
||||||
en: MediaLangText & { ep: string };
|
en: MediaLangText & { ep: string };
|
||||||
|
mediaUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const videos: VideoItem[] = [
|
export const videos: VideoItem[] = [
|
||||||
|
|
@ -149,6 +151,7 @@ function articleToVideo(a: Record<string, unknown>, i: number): VideoItem {
|
||||||
duration: '',
|
duration: '',
|
||||||
date: String(a.publishDate ?? ''),
|
date: String(a.publishDate ?? ''),
|
||||||
thumb: String(a.coverImage ?? '') || '/news/photo-1504307651254-35680f356dfd.jpg',
|
thumb: String(a.coverImage ?? '') || '/news/photo-1504307651254-35680f356dfd.jpg',
|
||||||
|
mediaUrl: String(a.body ?? ''), // panel stores the media URL in the article body field
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,6 +169,7 @@ function articleToPodcast(a: Record<string, unknown>, i: number): PodcastItem {
|
||||||
cover: String(a.coverImage ?? '') || '/news/photo-1540575467063-178a50c2df87.jpg',
|
cover: String(a.coverImage ?? '') || '/news/photo-1540575467063-178a50c2df87.jpg',
|
||||||
fa: { title, guest, body, ep },
|
fa: { title, guest, body, ep },
|
||||||
en: { title, guest, body, ep },
|
en: { title, guest, body, ep },
|
||||||
|
mediaUrl: String(a.body ?? ''), // panel stores the media URL in the article body field
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,12 @@ import { bootstrapFactoryReports } from './content/factoryReports'
|
||||||
import { bootstrapIntegrations } from './content/integrations'
|
import { bootstrapIntegrations } from './content/integrations'
|
||||||
import { bootstrapAbout } from './data/about'
|
import { bootstrapAbout } from './data/about'
|
||||||
import { bootstrapMedia } from './data/media'
|
import { bootstrapMedia } from './data/media'
|
||||||
|
import { bootstrapPosts } from './content/posts'
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
bootstrapReports(), bootstrapEvents(), bootstrapTeam(), bootstrapRadar(), bootstrapRadarPages(), bootstrapBanners(),
|
bootstrapReports(), bootstrapEvents(), bootstrapTeam(), bootstrapRadar(), bootstrapRadarPages(), bootstrapBanners(),
|
||||||
bootstrapPlans(), bootstrapRisks(), bootstrapMarket(),
|
bootstrapPlans(), bootstrapRisks(), bootstrapMarket(),
|
||||||
bootstrapFactoryReports(), bootstrapIntegrations(), bootstrapAbout(), bootstrapMedia(),
|
bootstrapFactoryReports(), bootstrapIntegrations(), bootstrapAbout(), bootstrapMedia(), bootstrapPosts(),
|
||||||
])
|
])
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
|
|
||||||
|
|
@ -181,12 +181,12 @@ export default function About() {
|
||||||
<section style={{
|
<section style={{
|
||||||
padding: '64px 40px',
|
padding: '64px 40px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
background: `linear-gradient(135deg, ${NAVY} 0%, #0a4a7a 100%)`,
|
background: '#fff',
|
||||||
}}>
|
}}>
|
||||||
<h2 style={{ fontSize: 'clamp(22px,2.5vw,34px)', fontWeight: 900, color: '#fff', margin: '0 0 16px' }}>
|
<h2 style={{ fontSize: 'clamp(22px,2.5vw,34px)', fontWeight: 900, color: NAVY, margin: '0 0 16px' }}>
|
||||||
{t.contactTitle}
|
{t.contactTitle}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ fontSize: 15, color: 'rgba(255,255,255,0.7)', marginBottom: 32, maxWidth: 500, margin: '0 auto 32px' }}>
|
<p style={{ fontSize: 15, color: 'rgba(3,35,64,0.6)', marginBottom: 32, maxWidth: 500, margin: '0 auto 32px' }}>
|
||||||
{t.contactDesc}
|
{t.contactDesc}
|
||||||
</p>
|
</p>
|
||||||
<a href="/contact" style={{
|
<a href="/contact" style={{
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { Zap, X, AlertTriangle, Globe, Calendar } from 'lucide-react'
|
import { Zap, X, AlertTriangle, Globe, Calendar } from 'lucide-react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
|
|
@ -232,7 +233,8 @@ const TICKERS = [
|
||||||
function CardModal({ card, lang, onClose }: { card: Card; lang: 'fa' | 'en'; onClose: () => void }) {
|
function CardModal({ card, lang, onClose }: { card: Card; lang: 'fa' | 'en'; onClose: () => void }) {
|
||||||
const isFa = lang === 'fa'
|
const isFa = lang === 'fa'
|
||||||
const dir = isFa ? 'rtl' : 'ltr'
|
const dir = isFa ? 'rtl' : 'ltr'
|
||||||
const isLoggedIn = (() => { try { return localStorage.getItem('sf_logged_in') === '1' } catch { return false } })()
|
const { member } = useAuth()
|
||||||
|
const isLoggedIn = member !== null
|
||||||
const body = isFa ? card.bodyFa : card.bodyEn
|
const body = isFa ? card.bodyFa : card.bodyEn
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,485 @@
|
||||||
import { MapPin, Calendar } from 'lucide-react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
import { events } from '@/data/events'
|
import { events } from '@/data/events'
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
|
|
||||||
const TYPE_LABEL: Record<string, { fa: string; color: string }> = {
|
const TYPE_LABEL: Record<string, { fa: string; en: string; color: string; bg: string }> = {
|
||||||
conference: { fa: 'کنفرانس', color: '#2471a3' },
|
conference: { fa: 'کنفرانس', en: 'Conference', color: '#2471a3', bg: '#EAF2FB' },
|
||||||
exhibition: { fa: 'نمایشگاه', color: '#1e8449' },
|
exhibition: { fa: 'نمایشگاه', en: 'Exhibition', color: '#1e8449', bg: '#EAFAF1' },
|
||||||
seminar: { fa: 'نشست تخصصی', color: '#7d3c98' },
|
seminar: { fa: 'نشست تخصصی', en: 'Seminar', color: '#7d3c98', bg: '#F5EEF8' },
|
||||||
international: { fa: 'بینالملل', color: '#c0392b' },
|
international: { fa: 'بینالملل', en: 'International', color: '#c0392b', bg: '#FDEDEC' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EVENT_IMGS: Record<string, string> = {
|
||||||
|
'iran-steel-congress-1403': '/news/photo-1611273426858-450d8e3c9fce.jpg',
|
||||||
|
'middle-east-steel-metals-2024': '/news/photo-1570168007204-dfb528c6958f.jpg',
|
||||||
|
'steel-policy-seminar-1404': '/news/photo-1567789884554-0b844b597180.jpg',
|
||||||
|
'world-steel-forum-2025': '/news/photo-1504307651254-35680f356dfd.jpg',
|
||||||
|
'iran-metallurgy-expo-1404': '/news/photo-1518770660439-4636190af475.jpg',
|
||||||
|
'oecd-steel-summit-2025': '/news/photo-1581092160607-ee22731c9b4e.jpg',
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Countdown ─────────────────────────────────────────── */
|
||||||
|
const HERO_TARGET = new Date('2026-10-16T09:00:00')
|
||||||
|
function useCountdown(target: Date) {
|
||||||
|
const calc = () => {
|
||||||
|
const diff = Math.max(0, target.getTime() - Date.now())
|
||||||
|
return {
|
||||||
|
days: Math.floor(diff / 86400000),
|
||||||
|
hours: Math.floor((diff % 86400000) / 3600000),
|
||||||
|
mins: Math.floor((diff % 3600000) / 60000),
|
||||||
|
secs: Math.floor((diff % 60000) / 1000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [t, setT] = useState(calc)
|
||||||
|
useEffect(() => { const id = setInterval(() => setT(calc()), 1000); return () => clearInterval(id) }, [])
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Jalali calendar helpers ────────────────────────────── */
|
||||||
|
const WEEKDAYS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']
|
||||||
|
const J_MONTHS = ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند']
|
||||||
|
const faNum = (n: number) => n.toLocaleString('fa-IR', { useGrouping: false })
|
||||||
|
|
||||||
|
function jParts(d: Date) {
|
||||||
|
const p = new Intl.DateTimeFormat('en-US-u-ca-persian', { day:'numeric', month:'numeric', year:'numeric' }).formatToParts(d)
|
||||||
|
const get = (t: string) => Number(p.find(x => x.type === t)?.value)
|
||||||
|
return { day: get('day'), month: get('month'), year: get('year') }
|
||||||
|
}
|
||||||
|
function buildMonth(anchor: Date) {
|
||||||
|
const { month, year } = jParts(anchor)
|
||||||
|
const first = new Date(anchor)
|
||||||
|
while (jParts(first).day !== 1) first.setDate(first.getDate() - 1)
|
||||||
|
const days: Date[] = []
|
||||||
|
const cur = new Date(first)
|
||||||
|
while (jParts(cur).month === month && jParts(cur).year === year) {
|
||||||
|
days.push(new Date(cur)); cur.setDate(cur.getDate() + 1)
|
||||||
|
}
|
||||||
|
// Persian week starts Saturday → JS getDay(): Sat=6, Sun=0, Mon=1 … Fri=5
|
||||||
|
const dowMap = [1, 2, 3, 4, 5, 6, 0] // Sun→1, Mon→2 … Sat→0
|
||||||
|
const firstDow = dowMap[first.getDay()]
|
||||||
|
return { days, firstDow, month, year }
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniCalendar({ anchor, onNav, eventDates, onSelect }: {
|
||||||
|
anchor: Date; onNav: (d: 1|-1) => void
|
||||||
|
eventDates: Date[]; onSelect: (d: Date) => void
|
||||||
|
}) {
|
||||||
|
const { days, firstDow, month, year } = buildMonth(anchor)
|
||||||
|
const today = new Date(); const todayJ = jParts(today)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 12,
|
||||||
|
background: '#fff', border: '1px solid #E7E7E7', borderRadius: 8,
|
||||||
|
padding: 12, height: '100%', boxSizing: 'border-box',
|
||||||
|
}}>
|
||||||
|
{/* تقویم label row */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', padding: '0 8px' }}>
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 500, color: '#262626', textAlign: 'right' }}>تقویم</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* month nav */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 8px', direction: 'rtl' }}>
|
||||||
|
<button onClick={() => onNav(1)} style={{
|
||||||
|
width: 24, height: 24, borderRadius: 24, border: 'none',
|
||||||
|
background: '#FBEED9', cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<ChevronRight size={12} color={GOLD} />
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 500, color: '#262626', textAlign: 'center', flex: 1 }}>
|
||||||
|
{J_MONTHS[month - 1]}ماه {faNum(year)}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => onNav(-1)} style={{
|
||||||
|
width: 24, height: 24, borderRadius: 24, border: 'none',
|
||||||
|
background: '#FBEED9', cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<ChevronLeft size={12} color={GOLD} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* weekday headers — pill bg #F7F7F7 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', direction: 'rtl',
|
||||||
|
background: '#F7F7F7', borderRadius: 25, padding: '6px 4px', gap: 3,
|
||||||
|
}}>
|
||||||
|
{WEEKDAYS.map(d => (
|
||||||
|
<div key={d} style={{ textAlign: 'center', fontSize: 10, fontWeight: 500, color: '#5D5D5D', lineHeight: '16px' }}>{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* day cells — bg #F7F7F7 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', direction: 'rtl',
|
||||||
|
background: '#F7F7F7', borderRadius: 16, padding: 4, gap: 3, flex: 1,
|
||||||
|
}}>
|
||||||
|
{Array.from({ length: firstDow }, (_, i) => <div key={`e${i}`} />)}
|
||||||
|
{days.map(d => {
|
||||||
|
const jp = jParts(d)
|
||||||
|
const isToday = jp.day === todayJ.day && jp.month === todayJ.month && jp.year === todayJ.year
|
||||||
|
const hasEv = eventDates.some(ed => {
|
||||||
|
const ej = jParts(ed); return ej.day === jp.day && ej.month === jp.month && ej.year === jp.year
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<button key={jp.day} onClick={() => onSelect(d)} style={{
|
||||||
|
aspectRatio: '1', border: hasEv && !isToday ? `1px solid ${GOLD}` : 'none',
|
||||||
|
borderRadius: isToday ? 99 : hasEv ? 60 : '50%',
|
||||||
|
fontSize: isToday ? 14 : 12, fontWeight: 500,
|
||||||
|
background: isToday ? GOLD : '#fff',
|
||||||
|
color: isToday ? '#fff' : '#888888',
|
||||||
|
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'background .15s',
|
||||||
|
}}>
|
||||||
|
{faNum(jp.day)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Countdown box ─────────────────────────────────────── */
|
||||||
|
function CBox({ v, label }: { v: number; label: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 80, height: 80, borderRadius: 14,
|
||||||
|
background: 'rgba(255,255,255,0.09)', border: '1px solid rgba(255,255,255,0.14)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 32, fontWeight: 900, color: '#fff', fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}>{faNum(v)}</div>
|
||||||
|
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 600 }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main component ────────────────────────────────────── */
|
||||||
export default function Events() {
|
export default function Events() {
|
||||||
const { lang } = useLang()
|
const { lang } = useLang()
|
||||||
const isFa = lang === 'fa'
|
const isFa = lang === 'fa'
|
||||||
|
const cd = useCountdown(HERO_TARGET)
|
||||||
|
|
||||||
|
const [calAnchor, setCalAnchor] = useState(new Date())
|
||||||
|
const [selEvt, setSelEvt] = useState(events[0])
|
||||||
|
const [showAll, setShowAll] = useState(false)
|
||||||
|
|
||||||
|
const handleCalNav = (dir: 1 | -1) => {
|
||||||
|
const n = new Date(calAnchor); n.setDate(n.getDate() + dir * 30); setCalAnchor(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// approximate Gregorian marker dates for each event
|
||||||
|
const MONTH_OFF: Record<string, number> = {
|
||||||
|
فروردین:3, اردیبهشت:4, خرداد:5, تیر:6, مرداد:7, شهریور:8,
|
||||||
|
مهر:9, آبان:10, آذر:11, دی:12, بهمن:1, اسفند:2,
|
||||||
|
}
|
||||||
|
const eventDates = events.map(ev => {
|
||||||
|
const m = MONTH_OFF[ev.month] ?? 3
|
||||||
|
const yr = m < 3 ? 2026 : 2025
|
||||||
|
return new Date(yr, m - 1, ev.date)
|
||||||
|
})
|
||||||
|
|
||||||
|
const featured = events[0]
|
||||||
|
const others = events.slice(1)
|
||||||
|
const visibleOthers = showAll ? others : others.slice(0, 4)
|
||||||
|
|
||||||
|
const selType = TYPE_LABEL[selEvt.type] ?? { fa: selEvt.type, en: selEvt.type, color: GOLD, bg: '#f5f5f5' }
|
||||||
|
const featType = TYPE_LABEL[featured.type] ?? { fa: featured.type, en: featured.type, color: GOLD, bg: '#f5f5f5' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div dir={isFa ? 'rtl' : 'ltr'} style={{ background: '#faf7f4', minHeight: '80vh' }}>
|
<div dir={isFa ? 'rtl' : 'ltr'} style={{ background: '#F5F5F5', minHeight: '100vh' }}>
|
||||||
<div style={{ background: NAVY, padding: '52px 0 40px' }}>
|
|
||||||
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
|
{/* ═══════════════════════════════════════════════
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '3px', color: GOLD, marginBottom: 10, textTransform: 'uppercase' }}>
|
HERO BANNER
|
||||||
{isFa ? 'رسانه و رویداد' : 'Media & Events'}
|
═══════════════════════════════════════════════ */}
|
||||||
|
<section style={{ position: 'relative', background: NAVY, overflow: 'hidden' }}>
|
||||||
|
{/* world map background */}
|
||||||
|
<img src="/world-map.webp" alt="" style={{ width: '100%', display: 'block' }} />
|
||||||
|
{/* overlay + content absolutely on top of the image */}
|
||||||
|
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to left, rgba(3,35,64,0.92) 30%, rgba(3,35,64,0.55) 65%, rgba(3,35,64,0.35) 100%)' }} />
|
||||||
|
|
||||||
|
{/* text + countdown — right side, vertically centered */}
|
||||||
|
<div style={{ position: 'absolute', top: 0, right: 0, bottom: 0, width: '52%', zIndex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '48px 5vw 48px 24px', direction: 'rtl' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 14 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: GOLD }}>استکهلم، سوئد</span>
|
||||||
|
<img src="/icons/vuesax/linear/location.svg" alt="" style={{ width: 16, height: 16, filter: 'invert(73%) sepia(50%) saturate(500%) hue-rotate(5deg)' }} />
|
||||||
</div>
|
</div>
|
||||||
<h1 style={{ fontSize: 'clamp(26px,3vw,40px)', fontWeight: 900, color: '#fff', margin: '0 0 10px', letterSpacing: '-0.5px' }}>
|
<h1 style={{ fontSize: 'clamp(22px,3vw,42px)', fontWeight: 900, color: GOLD, margin: '0 0 14px', lineHeight: 1.2 }}>
|
||||||
{isFa ? 'رویدادها' : 'Events'}
|
اجلاس جهانی فولاد سبز
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ fontSize: 14, color: 'rgba(255,255,255,0.6)', margin: 0 }}>
|
<p style={{ fontSize: 14, lineHeight: 2, color: 'rgba(255,255,255,0.72)', margin: '0 0 24px' }}>
|
||||||
{isFa ? 'همه رویدادها، کنفرانسها و نمایشگاههای صنعت فولاد' : 'All events, conferences and exhibitions in the steel industry'}
|
مرجع نهایی برای تعیین استانداردهای کربنزدایی در زنجیره ارزش فولاد با حضور وزرای انرژی اتحادیه اروپا و مدیران ارشد ۵۰ شرکت برتر.
|
||||||
</p>
|
</p>
|
||||||
|
{/* countdown — inline below description */}
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginBottom: 24, direction: 'ltr', justifyContent: 'flex-end' }}>
|
||||||
|
<CBox v={cd.days} label={isFa ? 'روز' : 'Days'} />
|
||||||
|
<CBox v={cd.hours} label={isFa ? 'ساعت' : 'Hrs'} />
|
||||||
|
<CBox v={cd.mins} label={isFa ? 'دقیقه' : 'Min'} />
|
||||||
|
<CBox v={cd.secs} label={isFa ? 'ثانیه' : 'Sec'} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="#" style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 8, direction: 'ltr',
|
||||||
|
background: GOLD, color: NAVY, borderRadius: 10, padding: '12px 28px',
|
||||||
|
fontSize: 14, fontWeight: 800, textDecoration: 'none',
|
||||||
|
}}>
|
||||||
|
<ExternalLink size={14} strokeWidth={2.5} />
|
||||||
|
<span style={{ direction: 'rtl' }}>{isFa ? 'اطلاعات بیشتر' : 'Learn More'}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ═══════════════════════════════════════════════
|
||||||
|
CALENDAR | SELECTED EVENT DETAIL
|
||||||
|
LTR grid so physical column order: calendar=LEFT, event=RIGHT
|
||||||
|
═══════════════════════════════════════════════ */}
|
||||||
|
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 48, paddingBottom: 0 }}>
|
||||||
|
{/* Figma: Calendar 409px | gap 37 | Card rest — LTR so calendar LEFT */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '409px 1fr', gap: 37, direction: 'ltr', alignItems: 'stretch' }} className="max-lg:!grid-cols-1">
|
||||||
|
|
||||||
|
{/* LEFT — calendar */}
|
||||||
|
<div dir={isFa ? 'rtl' : 'ltr'}>
|
||||||
|
<MiniCalendar anchor={calAnchor} onNav={handleCalNav} eventDates={eventDates}
|
||||||
|
onSelect={d => {
|
||||||
|
const jp = jParts(d)
|
||||||
|
const hit = events.find(ev => {
|
||||||
|
const m = MONTH_OFF[ev.month] ?? 3
|
||||||
|
const yr = m < 3 ? 2026 : 2025
|
||||||
|
const ed = new Date(yr, m - 1, ev.date)
|
||||||
|
return jParts(ed).day === jp.day && jParts(ed).month === jp.month
|
||||||
|
})
|
||||||
|
if (hit) setSelEvt(hit)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT — event card (Figma: white, border #E7E7E7, radius 16, padding 20, gap 20) */}
|
||||||
|
<div style={{
|
||||||
|
background: '#fff', border: '1px solid #E7E7E7', borderRadius: 16,
|
||||||
|
padding: 20, display: 'flex', flexDirection: 'column', gap: 20,
|
||||||
|
}}>
|
||||||
|
{/* hint text — styled box (Figma: bg #F8F9FA, border gold-10%, radius 16, center) */}
|
||||||
|
<div style={{
|
||||||
|
background: '#F8F9FA', border: '1px solid rgba(115,92,0,0.2)',
|
||||||
|
borderRadius: 16, padding: '8px 24px', textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 500, color: '#4D4635', direction: 'rtl' }}>
|
||||||
|
"برای مشاهده جزئیات هر رویداد، روی تاریخ مورد نظر در تقویم کلیک کنید."
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* content row: text LEFT (flex 1) | image RIGHT (251px) — forced LTR */}
|
||||||
|
<div style={{ display: 'flex', gap: 18, direction: 'ltr', flex: 1, minHeight: 0 }}>
|
||||||
|
{/* text section */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 9, direction: isFa ? 'rtl' : 'ltr', minWidth: 0 }}>
|
||||||
|
{/* date + category row */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'flex-end', direction: 'ltr' }}>
|
||||||
|
{/* category tag — GOLD bg white text */}
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 400, padding: '2px 10px', borderRadius: 4, background: GOLD, color: '#fff' }}>
|
||||||
|
{isFa ? selType.fa : selEvt.type}
|
||||||
|
</span>
|
||||||
|
{/* date */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, color: '#262626', direction: 'rtl' }}>{selEvt.date} {selEvt.month} {selEvt.year}</span>
|
||||||
|
<img src="/icons/vuesax/linear/calendar-tick.svg" alt="" style={{ width: 22, height: 22, filter: 'invert(40%) sepia(90%) saturate(400%) hue-rotate(190deg)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* title */}
|
||||||
|
<h3 style={{ fontSize: 18, fontWeight: 400, color: '#183354', margin: 0, lineHeight: 1.7, textAlign: 'right' }}>{selEvt.title}</h3>
|
||||||
|
{/* description lines */}
|
||||||
|
<p style={{ fontSize: 14, lineHeight: 1.7, color: '#4D4635', margin: 0, textAlign: 'right' }}>{selEvt.description}</p>
|
||||||
|
{/* CTA — full width */}
|
||||||
|
<a href="#" style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
background: GOLD, color: '#fff', borderRadius: 8, padding: '8px 16px',
|
||||||
|
fontSize: 16, fontWeight: 500, textDecoration: 'none', marginTop: 'auto',
|
||||||
|
direction: 'ltr',
|
||||||
|
}}>
|
||||||
|
<img src="/icons/vuesax/linear/export.svg" alt="" style={{ width: 18, height: 18, filter: 'brightness(0) invert(1)' }} />
|
||||||
|
<span style={{ direction: 'rtl' }}>{isFa ? 'اطلاعات بیشتر' : 'Learn More'}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/* image RIGHT — 251px, full height */}
|
||||||
|
<div style={{ flexShrink: 0, width: 251, borderRadius: 8, overflow: 'hidden' }}>
|
||||||
|
<img src={EVENT_IMGS[selEvt.id] ?? '/news/photo-1567789884554-0b844b597180.jpg'} alt=""
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 48, paddingBottom: 72 }}>
|
{/* ═══════════════════════════════════════════════
|
||||||
<div className="grid grid-cols-2 max-lg:grid-cols-1" style={{ gap: 20 }}>
|
FEATURED UPCOMING EVENT
|
||||||
{events.map((ev) => {
|
═══════════════════════════════════════════════ */}
|
||||||
const typeInfo = TYPE_LABEL[ev.type] ?? { fa: ev.type, color: GOLD }
|
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 56 }}>
|
||||||
|
{/* heading — gold right border (Figma: border-right 5px solid #CD9E53) */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
|
||||||
|
borderRight: `5px solid ${GOLD}`, paddingRight: 10, marginBottom: 24,
|
||||||
|
direction: 'rtl',
|
||||||
|
}}>
|
||||||
|
<h2 style={{ fontSize: 24, fontWeight: 500, color: NAVY, margin: 0, lineHeight: '41px' }}>
|
||||||
|
{isFa ? 'رویداد برجسته پیش رو' : 'Featured Upcoming Event'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* card: image LEFT (1fr) | navy RIGHT (2fr) */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '354fr 714fr', borderRadius: 16, overflow: 'hidden', boxShadow: '0 4px 40px rgba(3,35,64,0.13)', direction: 'ltr', minHeight: 287 }} className="max-lg:!grid-cols-1">
|
||||||
|
|
||||||
|
{/* IMAGE — gradient overlay + CTA at bottom */}
|
||||||
|
<div style={{ position: 'relative', minHeight: 287, overflow: 'hidden' }}>
|
||||||
|
<img src={EVENT_IMGS[featured.id] ?? '/news/photo-1611273426858-450d8e3c9fce.jpg'} alt=""
|
||||||
|
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
|
{/* bottom gradient overlay */}
|
||||||
|
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(0deg, #000000 29%, rgba(0,0,0,0) 100%)' }} />
|
||||||
|
{/* bottom: date badge + CTA */}
|
||||||
|
<div style={{ position: 'absolute', bottom: 19, left: 17, right: 17, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 40, height: 40, borderRadius: 8,
|
||||||
|
background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(4px)',
|
||||||
|
}}>
|
||||||
|
<img src="/icons/vuesax/linear/calendar-tick.svg" alt="" style={{ width: 22, height: 22, filter: 'invert(73%) sepia(50%) saturate(500%) hue-rotate(5deg)' }} />
|
||||||
|
</div>
|
||||||
|
<a href="#" style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 8, direction: 'ltr',
|
||||||
|
background: GOLD, color: '#fff', borderRadius: 8, padding: '8px 16px',
|
||||||
|
fontSize: 14, fontWeight: 500, textDecoration: 'none', flex: 1, justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<img src="/icons/vuesax/linear/export.svg" alt="" style={{ width: 18, height: 18, filter: 'brightness(0) invert(1)' }} />
|
||||||
|
<span style={{ direction: 'rtl' }}>{isFa ? 'اطلاعات بیشتر' : 'Learn More'}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* NAVY PANEL */}
|
||||||
|
<div style={{ background: NAVY, padding: '0 36px', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'center', gap: 10, direction: isFa ? 'rtl' : 'ltr' }}>
|
||||||
|
{/* gold decorative bar */}
|
||||||
|
<div style={{ width: 108, height: 14, background: GOLD, borderRadius: 7, alignSelf: 'flex-end' }} />
|
||||||
|
|
||||||
|
{/* content */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, width: '100%' }}>
|
||||||
|
{/* ── top block: title + meta + tag | gold bottom border ── */}
|
||||||
|
<div style={{ borderBottom: `1px solid ${GOLD}`, paddingBottom: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{/* title */}
|
||||||
|
<h3 style={{ fontSize: 18, fontWeight: 500, color: GOLD, margin: 0, lineHeight: 1.5, textAlign: 'right' }}>{featured.title}</h3>
|
||||||
|
{/* meta row: location | date | category tag — all right-aligned */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, justifyContent: 'flex-end', direction: 'ltr', flexWrap: 'wrap' }}>
|
||||||
|
{/* location */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<img src="/icons/vuesax/linear/location.svg" alt="" style={{ width: 22, height: 22, filter: 'invert(73%) sepia(50%) saturate(500%) hue-rotate(5deg)' }} />
|
||||||
|
<span style={{ fontSize: 15, color: '#ADADAD', direction: 'rtl' }}>{featured.city}</span>
|
||||||
|
</div>
|
||||||
|
{/* date */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<img src="/icons/vuesax/linear/calendar-tick.svg" alt="" style={{ width: 22, height: 22, filter: 'invert(73%) sepia(50%) saturate(500%) hue-rotate(5deg)' }} />
|
||||||
|
<span style={{ fontSize: 15, color: '#ADADAD', direction: 'rtl' }}>{featured.date} {featured.month} {featured.year}</span>
|
||||||
|
</div>
|
||||||
|
{/* category tag */}
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 400, padding: '4px 14px', borderRadius: 8, background: '#E53935', color: '#fff' }}>
|
||||||
|
{isFa ? featType.fa : featured.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── bottom block: why + description ── */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '0 16px' }}>
|
||||||
|
<p style={{ fontSize: 18, fontWeight: 500, color: GOLD, margin: 0, textAlign: 'right' }}>
|
||||||
|
{isFa ? 'چرا این رویداد اهمیت دارد؟' : 'Why this event matters?'}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 16, lineHeight: 1.75, color: '#F9F9F9', margin: 0, textAlign: 'right' }}>{featured.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══════════════════════════════════════════════
|
||||||
|
OTHER EVENTS GRID
|
||||||
|
═══════════════════════════════════════════════ */}
|
||||||
|
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 56, paddingBottom: 80 }}>
|
||||||
|
{/* heading — gold right border */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', borderRight: `5px solid ${GOLD}`, paddingRight: 10, marginBottom: 24, direction: 'rtl' }}>
|
||||||
|
<h2 style={{ fontSize: 24, fontWeight: 500, color: NAVY, margin: 0, lineHeight: '41px' }}>
|
||||||
|
{isFa ? 'سایر رویدادها' : 'Other Events'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', columnGap: 18, rowGap: 32 }} className="max-lg:!grid-cols-1">
|
||||||
|
{visibleOthers.map(ev => {
|
||||||
|
const t = TYPE_LABEL[ev.type] ?? { fa: ev.type, en: ev.type, color: GOLD, bg: '#f5f5f5' }
|
||||||
return (
|
return (
|
||||||
<div key={ev.id} style={{
|
<div key={ev.id} style={{
|
||||||
background: '#fff', borderRadius: 14,
|
background: '#F9F9F9', borderBottom: `3px solid ${GOLD}`, borderRadius: 21,
|
||||||
boxShadow: '0 2px 16px rgba(7,29,73,0.07)',
|
padding: '11px 12px', display: 'flex', direction: 'ltr', gap: 10, minHeight: 207,
|
||||||
border: '1px solid rgba(7,29,73,0.07)',
|
transition: 'box-shadow .2s, transform .2s',
|
||||||
padding: '24px 28px',
|
|
||||||
display: 'flex', flexDirection: 'column', gap: 14,
|
|
||||||
borderTop: `3px solid ${typeInfo.color}`,
|
|
||||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { const d = e.currentTarget as HTMLElement; d.style.transform = 'translateY(-3px)'; d.style.boxShadow = '0 12px 32px rgba(7,29,73,0.13)' }}
|
onMouseEnter={e => { const el = e.currentTarget as HTMLElement; el.style.boxShadow='0 8px 28px rgba(3,35,64,0.13)'; el.style.transform='translateY(-2px)' }}
|
||||||
onMouseLeave={e => { const d = e.currentTarget as HTMLElement; d.style.transform = ''; d.style.boxShadow = '0 2px 16px rgba(7,29,73,0.07)' }}
|
onMouseLeave={e => { const el = e.currentTarget as HTMLElement; el.style.boxShadow=''; el.style.transform='' }}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
{/* IMAGE — left side */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ flexShrink: 0, width: 138, height: 175, borderRadius: 16, overflow: 'hidden', alignSelf: 'center' }} className="max-md:hidden">
|
||||||
<div style={{ background: NAVY, color: '#fff', borderRadius: 8, padding: '4px 12px', fontSize: 13, fontWeight: 800, display: 'flex', alignItems: 'center', gap: 6 }}>
|
<img src={EVENT_IMGS[ev.id] ?? '/news/photo-1567789884554-0b844b597180.jpg'} alt=""
|
||||||
<Calendar size={13} />
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
<span>{ev.date} {ev.month} {ev.year}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: 11, fontWeight: 700, padding: '3px 10px', borderRadius: 20, background: `${typeInfo.color}18`, color: typeInfo.color }}>
|
|
||||||
{isFa ? typeInfo.fa : ev.type}
|
{/* TEXT — right side */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', gap: 8, direction: isFa ? 'rtl' : 'ltr', minWidth: 0 }}>
|
||||||
|
{/* meta: location + date + category */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 13, justifyContent: 'flex-end', direction: 'ltr', flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<img src="/icons/vuesax/linear/location.svg" alt="" style={{ width: 24, height: 24, filter: 'invert(73%) sepia(50%) saturate(500%) hue-rotate(5deg)' }} />
|
||||||
|
<span style={{ fontSize: 16, color: '#ADADAD', direction: 'rtl' }}>{ev.city}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<img src="/icons/vuesax/linear/calendar-tick.svg" alt="" style={{ width: 24, height: 24, filter: 'invert(73%) sepia(50%) saturate(500%) hue-rotate(5deg)' }} />
|
||||||
|
<span style={{ fontSize: 16, color: '#ADADAD', direction: 'rtl' }}>{ev.date} {ev.month} {ev.year}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 400, padding: '4px 14px', borderRadius: 8, background: '#E53935', color: '#fff', flexShrink: 0 }}>
|
||||||
|
{isFa ? t.fa : ev.type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{ev.registrationOpen && (
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#1e8449', background: '#1e844918', padding: '3px 10px', borderRadius: 20 }}>
|
{/* content: title + desc + cta */}
|
||||||
{isFa ? 'ثبتنام باز' : 'Open'}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
</span>
|
<h3 style={{ fontSize: 18, fontWeight: 400, color: '#111415', margin: 0, lineHeight: 1.5, textAlign: 'right' }}>{ev.title}</h3>
|
||||||
)}
|
<p style={{ fontSize: 14, lineHeight: 1.7, color: '#4D4635', margin: 0, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden', textAlign: 'right' }}>{ev.description}</p>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<a href="#" style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8, direction: 'ltr',
|
||||||
|
background: GOLD, color: '#fff', borderRadius: 8, width: 169, height: 40,
|
||||||
|
fontSize: 16, fontWeight: 500, textDecoration: 'none', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<img src="/icons/vuesax/linear/export.svg" alt="" style={{ width: 18, height: 18, filter: 'brightness(0) invert(1)' }} />
|
||||||
|
<span style={{ direction: 'rtl' }}>{isFa ? 'اطلاعات بیشتر' : 'Learn More'}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 style={{ fontSize: 'clamp(15px,1.4vw,18px)', fontWeight: 900, color: NAVY, margin: 0, lineHeight: 1.5 }}>{ev.title}</h2>
|
|
||||||
<p style={{ fontSize: 13, lineHeight: 1.9, color: 'rgba(7,29,73,0.65)', margin: 0 }}>{ev.description}</p>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'rgba(7,29,73,0.5)', marginTop: 'auto' }}>
|
|
||||||
<MapPin size={13} color={GOLD} />
|
|
||||||
<span>{ev.location} — {ev.city}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* show all / less */}
|
||||||
|
{others.length > 4 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 36 }}>
|
||||||
|
<button onClick={() => setShowAll(v => !v)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, fontWeight: 700, color: NAVY,
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', direction: 'rtl',
|
||||||
|
}}>
|
||||||
|
<img src="/icons/vuesax/linear/arrow-down.svg" alt="" style={{ width: 18, height: 18, opacity: 0.6, transform: showAll ? 'rotate(180deg)' : 'none', transition: 'transform .2s' }} />
|
||||||
|
<span>{showAll ? (isFa ? 'نمایش کمتر' : 'Show less') : (isFa ? 'مشاهده همه' : 'Show all')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -196,10 +196,14 @@ export default function Experts() {
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [isPaused])
|
}, [isPaused])
|
||||||
|
|
||||||
|
// only allow http(s) destinations — blocks javascript:/data: hrefs once these
|
||||||
|
// URLs come from the CMS instead of the static seed data
|
||||||
|
const safeHref = (url?: string) => (url && /^https?:\/\//i.test(url) ? url : undefined)
|
||||||
|
|
||||||
const renderSocialIcon = (expert: Expert) => (
|
const renderSocialIcon = (expert: Expert) => (
|
||||||
<div style={{ display: 'flex', gap: 12, marginTop: 16, justifyContent: 'center' }}>
|
<div style={{ display: 'flex', gap: 12, marginTop: 16, justifyContent: 'center' }}>
|
||||||
{expert.telegram && (
|
{expert.telegram && (
|
||||||
<a href={expert.telegram} target="_blank" rel="noreferrer"
|
<a href={safeHref(expert.telegram)} target="_blank" rel="noreferrer"
|
||||||
style={{
|
style={{
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
|
|
@ -217,7 +221,7 @@ export default function Experts() {
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{expert.linkedin && (
|
{expert.linkedin && (
|
||||||
<a href={expert.linkedin} target="_blank" rel="noreferrer"
|
<a href={safeHref(expert.linkedin)} target="_blank" rel="noreferrer"
|
||||||
style={{
|
style={{
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
|
|
@ -235,7 +239,7 @@ export default function Experts() {
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{expert.twitter && (
|
{expert.twitter && (
|
||||||
<a href={expert.twitter} target="_blank" rel="noreferrer"
|
<a href={safeHref(expert.twitter)} target="_blank" rel="noreferrer"
|
||||||
style={{
|
style={{
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
|
|
@ -253,7 +257,7 @@ export default function Experts() {
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{expert.website && (
|
{expert.website && (
|
||||||
<a href={expert.website} target="_blank" rel="noreferrer"
|
<a href={safeHref(expert.website)} target="_blank" rel="noreferrer"
|
||||||
style={{
|
style={{
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useRef, useState, useEffect, useLayoutEffect } from 'react'
|
import { useRef, useEffect, useLayoutEffect, useState } from 'react'
|
||||||
import { gsap } from 'gsap'
|
import { gsap } from 'gsap'
|
||||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
import { ArrowRight } from 'lucide-react'
|
import { ArrowRight } from 'lucide-react'
|
||||||
|
|
@ -6,50 +6,24 @@ import { Link } from 'react-router-dom'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger)
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
// v2
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
const PAPER = '#faf7f4' // cream background revealed under the de-zoom
|
const PAPER = '#faf7f4'
|
||||||
|
|
||||||
const SLIDES = {
|
const CONTENT = {
|
||||||
fa: [
|
fa: {
|
||||||
{
|
|
||||||
title: 'بینشی نو برای توسعهی صنعتی',
|
title: 'بینشی نو برای توسعهی صنعتی',
|
||||||
desc: 'آشنایی با چشمانداز ما',
|
|
||||||
cta1: 'درباره ما', cta1href: '/about',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'داده، تحلیل، آیندهنگاری',
|
|
||||||
desc: 'تصمیمسازی هوشمند در جهانی در حال تحول\n+۱۰۰ منبع داده جهانی، رصد ۲۴/۷ بازارها',
|
desc: 'تصمیمسازی هوشمند در جهانی در حال تحول\n+۱۰۰ منبع داده جهانی، رصد ۲۴/۷ بازارها',
|
||||||
cta1: 'نبض صنعت', cta1href: '/scanner',
|
cta: 'درباره ما', ctaHref: '/about',
|
||||||
},
|
},
|
||||||
{
|
en: {
|
||||||
title: 'آزمون استراتژی در آیندهها',
|
|
||||||
desc: 'آزمایشگاه آینده',
|
|
||||||
cta1: 'آیندهپژوهی و رصد هوشمند', cta1href: '/radar',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
en: [
|
|
||||||
{
|
|
||||||
title: 'A New Vision for Industrial Development',
|
title: 'A New Vision for Industrial Development',
|
||||||
desc: 'Discover our perspective',
|
|
||||||
cta1: 'About Us', cta1href: '/about',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Data, Analysis, Foresight',
|
|
||||||
desc: 'Smart decision-making in a world in transformation\n100+ global data sources, 24/7 market monitoring',
|
desc: 'Smart decision-making in a world in transformation\n100+ global data sources, 24/7 market monitoring',
|
||||||
cta1: 'Industry Pulse', cta1href: '/scanner',
|
cta: 'About Us', ctaHref: '/about',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Testing Strategy in Futures',
|
|
||||||
desc: 'The Futures Laboratory',
|
|
||||||
cta1: 'Foresight & Smart Monitoring', cta1href: '/radar',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CENTER_IMG = '/hero%201.webp'
|
|
||||||
|
|
||||||
type Sat = { img: string; x: number; y: number; w: number; depth: number; big?: boolean }
|
type Sat = { img: string; x: number; y: number; w: number; depth: number; big?: boolean }
|
||||||
const SATELLITES: Sat[] = [
|
const SATELLITES: Sat[] = [
|
||||||
{ img: '/news/photo-1567789884554-0b844b597180.jpg', x: -38, y: -26, w: 200, depth: 1.0, big: true },
|
{ img: '/news/photo-1567789884554-0b844b597180.jpg', x: -38, y: -26, w: 200, depth: 1.0, big: true },
|
||||||
|
|
@ -62,18 +36,10 @@ const SATELLITES: Sat[] = [
|
||||||
{ img: '/news/photo-1581092160607-ee22731c9b4e.jpg', x: 31, y: 37, w: 110, depth: 1.9 },
|
{ img: '/news/photo-1581092160607-ee22731c9b4e.jpg', x: 31, y: 37, w: 110, depth: 1.9 },
|
||||||
]
|
]
|
||||||
|
|
||||||
/* ─── MAIN ─── */
|
|
||||||
export default function HeroSection() {
|
export default function HeroSection() {
|
||||||
const { lang } = useLang()
|
const { lang } = useLang()
|
||||||
const [slideIdx, setSlideIdx] = useState(0)
|
const t = CONTENT[lang]
|
||||||
const slides = SLIDES[lang]
|
|
||||||
const t = slides[slideIdx]
|
|
||||||
|
|
||||||
// auto-advance every 5s (only before scroll starts)
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setInterval(() => setSlideIdx(i => (i + 1) % slides.length), 5000)
|
|
||||||
return () => clearInterval(id)
|
|
||||||
}, [slides.length])
|
|
||||||
const sectionRef = useRef<HTMLDivElement>(null)
|
const sectionRef = useRef<HTMLDivElement>(null)
|
||||||
const pinRef = useRef<HTMLDivElement>(null)
|
const pinRef = useRef<HTMLDivElement>(null)
|
||||||
const centerRef = useRef<HTMLDivElement>(null)
|
const centerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
@ -91,14 +57,11 @@ export default function HeroSection() {
|
||||||
return () => mq.removeEventListener('change', update)
|
return () => mq.removeEventListener('change', update)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// GSAP ScrollTrigger scrub timeline — drives the whole de-zoom + scatter off scroll progress.
|
|
||||||
// Synced to Lenis via the GSAP ticker set up in RootLayout (joinspread.app recipe).
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const section = sectionRef.current
|
const section = sectionRef.current
|
||||||
if (!section) return
|
if (!section) return
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
const ctx = gsap.context(() => {
|
||||||
// single scrubbed timeline pinned for the section's scroll length
|
|
||||||
const tl = gsap.timeline({
|
const tl = gsap.timeline({
|
||||||
scrollTrigger: {
|
scrollTrigger: {
|
||||||
trigger: section,
|
trigger: section,
|
||||||
|
|
@ -108,26 +71,20 @@ export default function HeroSection() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// background morph navy → cream
|
|
||||||
tl.fromTo(pinRef.current, { backgroundColor: NAVY }, { backgroundColor: PAPER, ease: 'none' }, 0)
|
tl.fromTo(pinRef.current, { backgroundColor: NAVY }, { backgroundColor: PAPER, ease: 'none' }, 0)
|
||||||
|
|
||||||
// center image: full-bleed cover (scale 2.55) → centered rounded card
|
|
||||||
tl.fromTo(centerRef.current,
|
tl.fromTo(centerRef.current,
|
||||||
{ scale: 2.55, borderRadius: 0 },
|
{ scale: 2.55, borderRadius: 0 },
|
||||||
{ scale: 1, borderRadius: 18, ease: 'none' }, 0)
|
{ scale: 1, borderRadius: 18, ease: 'none' }, 0)
|
||||||
|
|
||||||
// legibility overlay fades out as the image shrinks
|
|
||||||
tl.fromTo(overlayRef.current, { opacity: 0.42 }, { opacity: 0, ease: 'none', duration: 0.4 }, 0)
|
tl.fromTo(overlayRef.current, { opacity: 0.42 }, { opacity: 0, ease: 'none', duration: 0.4 }, 0)
|
||||||
|
|
||||||
// headline lifts + blurs + fades out as the de-zoom begins
|
|
||||||
tl.fromTo(headlineRef.current,
|
tl.fromTo(headlineRef.current,
|
||||||
{ yPercent: 0, opacity: 1, filter: 'blur(0px)' },
|
{ yPercent: 0, opacity: 1, filter: 'blur(0px)' },
|
||||||
{ yPercent: -10, opacity: 0, filter: 'blur(8px)', ease: 'none', duration: 0.35 }, 0)
|
{ yPercent: -10, opacity: 0, filter: 'blur(8px)', ease: 'none', duration: 0.35 }, 0)
|
||||||
|
|
||||||
// scroll hint fades immediately
|
|
||||||
tl.fromTo(hintRef.current, { opacity: 1 }, { opacity: 0, ease: 'none', duration: 0.08 }, 0)
|
tl.fromTo(hintRef.current, { opacity: 1 }, { opacity: 0, ease: 'none', duration: 0.08 }, 0)
|
||||||
|
|
||||||
// satellites: clustered behind center → fan out to scattered positions with parallax
|
|
||||||
satRefs.current.forEach((el, i) => {
|
satRefs.current.forEach((el, i) => {
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const s = SATELLITES[i]
|
const s = SATELLITES[i]
|
||||||
|
|
@ -167,7 +124,7 @@ export default function HeroSection() {
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* center image — de-zooms from full-bleed cover to a centered card */}
|
{/* center video — de-zooms from full-bleed cover to a centered card */}
|
||||||
<div
|
<div
|
||||||
ref={centerRef}
|
ref={centerRef}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -180,10 +137,13 @@ export default function HeroSection() {
|
||||||
overflow: 'hidden', zIndex: 5, willChange: 'transform, border-radius',
|
overflow: 'hidden', zIndex: 5, willChange: 'transform, border-radius',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<video
|
||||||
src={CENTER_IMG}
|
src="/hero-bg.mp4"
|
||||||
alt=""
|
autoPlay
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 38%', display: 'block' }}
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center', display: 'block' }}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
|
|
@ -207,40 +167,28 @@ export default function HeroSection() {
|
||||||
<div style={{ pointerEvents: 'auto', maxWidth: 900 }}>
|
<div style={{ pointerEvents: 'auto', maxWidth: 900 }}>
|
||||||
<h1 style={{
|
<h1 style={{
|
||||||
fontSize: 'clamp(26px, 3.4vw, 50px)', fontWeight: 900, color: '#fff',
|
fontSize: 'clamp(26px, 3.4vw, 50px)', fontWeight: 900, color: '#fff',
|
||||||
lineHeight: 1.15, letterSpacing: '-1px', margin: 0, whiteSpace: 'nowrap',
|
lineHeight: 1.15, letterSpacing: '-1px', margin: 0,
|
||||||
textShadow: '0 2px 30px rgba(0,0,0,0.35)',
|
textShadow: '0 2px 30px rgba(0,0,0,0.35)',
|
||||||
}} className="max-md:!whitespace-normal max-md:!text-3xl">
|
}} className="max-md:!text-3xl">
|
||||||
{t.title}
|
{t.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{
|
<p style={{
|
||||||
fontSize: 'clamp(14px, 1.4vw, 19px)', lineHeight: 1.7, color: 'rgba(255,255,255,0.78)',
|
fontSize: 'clamp(14px, 1.4vw, 19px)', lineHeight: 1.7, color: 'rgba(255,255,255,0.78)',
|
||||||
maxWidth: 480, margin: '20px 0 28px', textShadow: '0 1px 12px rgba(0,0,0,0.3)',
|
maxWidth: 480, margin: '20px 0 28px', textShadow: '0 1px 12px rgba(0,0,0,0.3)',
|
||||||
|
whiteSpace: 'pre-line',
|
||||||
}}>
|
}}>
|
||||||
{t.desc}
|
{t.desc}
|
||||||
</p>
|
</p>
|
||||||
{/* CTA buttons */}
|
<div style={{ display: 'flex', gap: 12, direction: 'ltr', flexWrap: 'wrap' }}>
|
||||||
<div style={{ display: 'flex', gap: 12, direction: 'ltr', flexWrap: 'wrap', marginBottom: 28 }}>
|
<Link to={t.ctaHref} style={{
|
||||||
{t.cta1 && (
|
|
||||||
<Link to={t.cta1href} style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 8, direction: 'ltr',
|
display: 'inline-flex', alignItems: 'center', gap: 8, direction: 'ltr',
|
||||||
background: GOLD, color: NAVY, borderRadius: 10,
|
background: GOLD, color: NAVY, borderRadius: 10,
|
||||||
padding: '12px 24px', fontSize: 14, fontWeight: 800, textDecoration: 'none',
|
padding: '12px 24px', fontSize: 14, fontWeight: 800, textDecoration: 'none',
|
||||||
boxShadow: '0 8px 24px -8px rgba(205,158,83,0.6)',
|
boxShadow: '0 8px 24px -8px rgba(205,158,83,0.6)',
|
||||||
}}>
|
}}>
|
||||||
<ArrowRight size={16} strokeWidth={2.5} />
|
<ArrowRight size={16} strokeWidth={2.5} />
|
||||||
<span style={{ direction: 'rtl' }}>{t.cta1}</span>
|
<span style={{ direction: 'rtl' }}>{t.cta}</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* slide dots */}
|
|
||||||
<div style={{ display: 'flex', gap: 8, direction: 'ltr' }}>
|
|
||||||
{slides.map((_, i) => (
|
|
||||||
<button key={i} onClick={() => setSlideIdx(i)} style={{
|
|
||||||
width: i === slideIdx ? 24 : 8, height: 8, borderRadius: 4, border: 'none', cursor: 'pointer',
|
|
||||||
background: i === slideIdx ? GOLD : 'rgba(255,255,255,0.35)',
|
|
||||||
transition: 'width 300ms, background 300ms', padding: 0,
|
|
||||||
}} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,223 +1,14 @@
|
||||||
import { useRef, useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { motion, AnimatePresence, useScroll, useMotionValueEvent } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { useLang } from '@/context/LangContext'
|
|
||||||
import { ArrowLeft } from 'lucide-react'
|
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
|
|
||||||
const NAVY = '#032340'
|
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
|
const NAVY = '#032340'
|
||||||
const EASE = [0.22, 1, 0.36, 1] as const
|
const EASE = [0.22, 1, 0.36, 1] as const
|
||||||
|
|
||||||
type Feat = {
|
|
||||||
img: string
|
|
||||||
imgSide: 'left' | 'right'
|
|
||||||
imgPos?: string
|
|
||||||
fa: { kicker: string; title: string; desc: string; stat: string; statTail: string }
|
|
||||||
en: { kicker: string; title: string; desc: string; stat: string; statTail: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Your 3 hero images. As you scroll, they replace each other in place with a fade. */
|
|
||||||
const FEATURES: Feat[] = [
|
|
||||||
{
|
|
||||||
img: '/hero%201.webp', imgSide: 'right', imgPos: 'right 35%',
|
|
||||||
fa: {
|
|
||||||
kicker: 'اندیشکده فولاد آینده',
|
|
||||||
title: 'بینشی نو برای توسعهی صنعتی',
|
|
||||||
desc: '',
|
|
||||||
stat: '۶ بازار',
|
|
||||||
statTail: 'رصد ۲۴ ساعته. هشدار در کمتر از ۱۲ ثانیه.',
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
kicker: 'Future Steel Institute',
|
|
||||||
title: 'A New Vision for Industrial Development',
|
|
||||||
desc: '',
|
|
||||||
stat: '6 markets',
|
|
||||||
statTail: 'Scanned 24/7. Alerts in under 12 seconds.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
img: '/hero2.webp', imgSide: 'left', imgPos: '100% center',
|
|
||||||
fa: {
|
|
||||||
kicker: 'فناوری صنعتی',
|
|
||||||
title: 'داده، تحلیل، آیندهنگاری',
|
|
||||||
desc: 'تصمیمسازی هوشمند در جهانی در حال تحول\n+۱۰۰ منبع داده جهانی، رصد ۲۴/۷ بازارها',
|
|
||||||
stat: '٪۳۸',
|
|
||||||
statTail: 'میانگین حاشیه سود در هر معامله.',
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
kicker: 'Industrial Technology',
|
|
||||||
title: 'Data, Analysis, Foresight',
|
|
||||||
desc: 'Smart decision-making in a world in transformation\n100+ global data sources, 24/7 market monitoring',
|
|
||||||
stat: '38%',
|
|
||||||
statTail: 'Average margin per deal.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
img: '/hero3.webp', imgSide: 'right', imgPos: '100% center',
|
|
||||||
fa: {
|
|
||||||
kicker: 'ژئواکونومی',
|
|
||||||
title: 'آزمون استراتژی در آیندهها',
|
|
||||||
desc: '',
|
|
||||||
stat: '۲۰ ساعت',
|
|
||||||
statTail: 'صرفهجویی هفتگی نسبت به روش دستی.',
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
kicker: 'Geo-Economics',
|
|
||||||
title: 'Testing Strategy in Futures',
|
|
||||||
desc: '',
|
|
||||||
stat: '20 hrs',
|
|
||||||
statTail: 'Saved per week vs. manual.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
/* fade-up + blur wrapper — fires fast on mount (re-keyed per active slide) */
|
|
||||||
function FadeUp({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ y: 22, opacity: 0, filter: 'blur(10px)' }}
|
|
||||||
animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
|
|
||||||
exit={{ y: -10, opacity: 0, filter: 'blur(6px)' }}
|
|
||||||
transition={{ duration: 0.42, ease: EASE, delay }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const DomeIcon = () => (
|
|
||||||
<svg viewBox="0 0 64 64" width="48" height="48" fill="none" stroke={GOLD} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginBottom: 24 }}>
|
|
||||||
<path d="M42 16 A 12 12 0 1 0 46 28 A 15 15 0 1 1 42 16 Z" fill="rgba(205,158,83,0.25)" stroke="none" />
|
|
||||||
<line x1="8" y1="48" x2="56" y2="48" />
|
|
||||||
<path d="M16 48 A 16 16 0 0 1 48 48 Z" />
|
|
||||||
<path d="M28 32 L36 32 L36 48 L28 48 Z" />
|
|
||||||
<rect x="30" y="42" width="4" height="6" rx="1" fill={GOLD} />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
const GearIcon = () => (
|
|
||||||
<svg viewBox="0 0 64 64" width="48" height="48" fill="none" stroke={NAVY} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginBottom: 24 }}>
|
|
||||||
<circle cx="28" cy="36" r="10" />
|
|
||||||
<path d="M28 20 L28 26 M28 46 L28 52 M12 36 L18 36 M38 36 L44 36 M16 24 L21 29 M35 43 L40 48 M16 48 L21 43 M35 29 L40 24" />
|
|
||||||
<line x1="28" y1="36" x2="48" y2="18" />
|
|
||||||
<circle cx="48" cy="18" r="5" fill={NAVY} />
|
|
||||||
<line x1="28" y1="36" x2="16" y2="16" />
|
|
||||||
<circle cx="16" cy="16" r="4" fill={NAVY} />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
const ChartIcon = () => (
|
|
||||||
<svg viewBox="0 0 64 64" width="48" height="48" fill="none" stroke={GOLD} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginBottom: 24 }}>
|
|
||||||
<circle cx="42" cy="22" r="10" fill="rgba(205,158,83,0.25)" stroke="none" />
|
|
||||||
<rect x="18" y="36" width="6" height="12" rx="1" fill={GOLD} stroke="none" />
|
|
||||||
<rect x="29" y="26" width="6" height="22" rx="1" fill={GOLD} stroke="none" />
|
|
||||||
<rect x="40" y="18" width="6" height="30" rx="1" fill={GOLD} stroke="none" />
|
|
||||||
<path d="M12 44 L24 32 L36 28 L48 14" stroke={GOLD} strokeWidth="3" />
|
|
||||||
<path d="M38 14 L48 14 L48 24" stroke={GOLD} strokeWidth="3" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
/* the text column for the active slide */
|
|
||||||
function TextPanel({ index, feat, lang }: { index: number; feat: Feat; lang: 'fa' | 'en' }) {
|
|
||||||
const t = feat[lang]
|
|
||||||
const isSlide2 = index === 1
|
|
||||||
const textColor = isSlide2 ? NAVY : GOLD
|
|
||||||
const descColor = isSlide2 ? 'rgba(3, 35, 64, 0.72)' : 'rgba(255, 255, 255, 0.78)'
|
|
||||||
|
|
||||||
const renderIcon = () => {
|
|
||||||
if (index === 0) return <DomeIcon />
|
|
||||||
if (index === 1) return <GearIcon />
|
|
||||||
return <ChartIcon />
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttonText = lang === 'fa'
|
|
||||||
? (index === 0 ? 'آشنایی با چشمانداز ما' : index === 1 ? 'نبض صنعت' : 'آزمایشگاه آینده')
|
|
||||||
: (index === 0 ? 'Discover our perspective' : index === 1 ? 'Industry Pulse' : 'The Futures Laboratory')
|
|
||||||
const buttonHref = index === 0 ? '/about' : index === 1 ? '/pulse' : '/radar'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
direction: lang === 'fa' ? 'rtl' : 'ltr',
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: 480,
|
|
||||||
textAlign: lang === 'fa' ? 'right' : 'left',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
}}>
|
|
||||||
<FadeUp>
|
|
||||||
<div style={{ display: 'flex', justifyContent: lang === 'fa' ? 'flex-start' : 'flex-end', width: '100%' }}>
|
|
||||||
{renderIcon()}
|
|
||||||
</div>
|
|
||||||
</FadeUp>
|
|
||||||
<FadeUp delay={0.05}>
|
|
||||||
<h2 style={{
|
|
||||||
fontSize: 'clamp(18px, 3.4vw, 50px)',
|
|
||||||
fontWeight: 900,
|
|
||||||
color: textColor,
|
|
||||||
lineHeight: 1.15,
|
|
||||||
margin: '0 0 20px',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}} className="max-md:!whitespace-normal max-md:!text-[18px]">
|
|
||||||
{t.title}
|
|
||||||
</h2>
|
|
||||||
</FadeUp>
|
|
||||||
<FadeUp delay={0.1}>
|
|
||||||
<p style={{
|
|
||||||
fontSize: 'clamp(14px, 1.2vw, 17px)',
|
|
||||||
lineHeight: 1.95,
|
|
||||||
color: descColor,
|
|
||||||
margin: '0 0 32px',
|
|
||||||
textAlign: 'justify'
|
|
||||||
}}>
|
|
||||||
{t.desc}
|
|
||||||
</p>
|
|
||||||
</FadeUp>
|
|
||||||
<FadeUp delay={0.15}>
|
|
||||||
<Link to={buttonHref} style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
padding: '14px 28px',
|
|
||||||
borderRadius: 30,
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 700,
|
|
||||||
direction: 'ltr',
|
|
||||||
background: isSlide2 ? NAVY : GOLD,
|
|
||||||
color: isSlide2 ? '#fff' : NAVY,
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
}} className="hover:!scale-105 hover:!opacity-95">
|
|
||||||
<ArrowLeft size={16} strokeWidth={2.5} className="no-rtl-flip" style={{ flexShrink: 0 }} />
|
|
||||||
<span>{buttonText}</span>
|
|
||||||
</Link>
|
|
||||||
</FadeUp>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* progress rail */
|
|
||||||
function Rail({ active, total }: { active: number; total: number }) {
|
|
||||||
const inactiveColor = active === 1 ? 'rgba(3, 35, 64, 0.22)' : 'rgba(255, 255, 255, 0.3)'
|
|
||||||
return (
|
|
||||||
<div style={{ position: 'absolute', right: 28, top: '50%', transform: 'translateY(-50%)', display: 'flex', flexDirection: 'column', gap: 12, zIndex: 20 }} className="max-md:hidden">
|
|
||||||
{Array.from({ length: total }).map((_, i) => (
|
|
||||||
<div key={i} style={{
|
|
||||||
width: 3, height: i === active ? 34 : 14, borderRadius: 2,
|
|
||||||
background: i === active ? GOLD : inactiveColor,
|
|
||||||
transition: 'all 400ms cubic-bezier(0.22,1,0.36,1)',
|
|
||||||
}} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SpreadFeatures() {
|
export default function SpreadFeatures() {
|
||||||
const { lang } = useLang()
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [active, setActive] = useState(0)
|
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
|
const [videoSrc, setVideoSrc] = useState<string | null>(null)
|
||||||
|
const blobUrl = useRef<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia('(max-width: 767px)')
|
const mq = window.matchMedia('(max-width: 767px)')
|
||||||
|
|
@ -227,123 +18,72 @@ export default function SpreadFeatures() {
|
||||||
return () => mq.removeEventListener('change', update)
|
return () => mq.removeEventListener('change', update)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const { scrollYProgress } = useScroll({ target: containerRef, offset: ['start start', 'end end'] })
|
// Fetch video as blob to bypass range-request issues on tunnels/proxies
|
||||||
useMotionValueEvent(scrollYProgress, 'change', (v) => {
|
useEffect(() => {
|
||||||
const next = v < 0.34 ? 0 : v < 0.67 ? 1 : 2
|
let cancelled = false
|
||||||
if (next !== active) setActive(next)
|
fetch('/hero-bg.mp4')
|
||||||
|
.then(r => { if (!r.ok) throw new Error('fetch failed'); return r.blob() })
|
||||||
|
.then(blob => {
|
||||||
|
if (cancelled) return
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
blobUrl.current = url
|
||||||
|
setVideoSrc(url)
|
||||||
})
|
})
|
||||||
|
.catch(() => { /* keep navy background */ })
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (blobUrl.current) URL.revokeObjectURL(blobUrl.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} style={{ height: '300vh', position: 'relative', background: '#faf7f4', marginTop: isMobile ? -60 : -68 }}>
|
<div style={{ height: '100vh', position: 'relative', marginTop: isMobile ? -60 : -68 }}>
|
||||||
<div style={{ position: 'sticky', top: 0, height: '100vh', overflow: 'hidden' }}>
|
{/* navy fallback — visible until video blob is ready */}
|
||||||
|
<div style={{ position: 'sticky', top: 0, height: '100vh', overflow: 'hidden', background: NAVY }}>
|
||||||
|
|
||||||
{FEATURES.map((f, i) => {
|
{/* full-bleed video — src is a local blob URL, no range requests */}
|
||||||
const isActive = i === active
|
{videoSrc && (
|
||||||
const isTextOnLeft = i === 1
|
<video
|
||||||
const leftBg = isTextOnLeft ? GOLD : '#efe9e1'
|
src={videoSrc}
|
||||||
const rightBg = isTextOnLeft ? '#efe9e1' : NAVY
|
autoPlay muted loop playsInline
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={false}
|
|
||||||
animate={{ opacity: isActive ? 1 : 0 }}
|
|
||||||
transition={{ duration: 0.55, ease: EASE }}
|
|
||||||
style={{
|
style={{
|
||||||
|
position: 'absolute', inset: 0,
|
||||||
|
width: '100%', height: '100%',
|
||||||
|
objectFit: 'cover', objectPosition: 'center',
|
||||||
|
display: 'block', zIndex: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* full-width gradient: solid navy on right → transparent on left */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, zIndex: 1, pointerEvents: 'none',
|
||||||
|
background: 'linear-gradient(to left, rgba(3,35,64,0.96) 0%, rgba(3,35,64,0.85) 25%, rgba(3,35,64,0.4) 55%, transparent 80%)',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* title — far right */}
|
||||||
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
top: 0, right: 0,
|
||||||
display: 'flex',
|
width: isMobile ? '100%' : '48%',
|
||||||
flexDirection: isMobile ? 'column' : 'row',
|
|
||||||
direction: 'ltr',
|
|
||||||
alignItems: 'stretch',
|
|
||||||
justifyContent: 'stretch',
|
|
||||||
pointerEvents: isActive ? 'auto' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* LEFT PANEL */}
|
|
||||||
<div style={{
|
|
||||||
width: isMobile ? '100%' : '50%',
|
|
||||||
height: isMobile ? '50vh' : '100%',
|
|
||||||
background: leftBg,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
|
||||||
{isTextOnLeft ? (
|
|
||||||
isActive && (
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<div key={active} style={{ width: '100%', display: 'flex', justifyContent: 'center', padding: isMobile ? '24px 20px' : '0 8vw' }}>
|
|
||||||
<TextPanel index={i} feat={f} lang={lang} />
|
|
||||||
</div>
|
|
||||||
</AnimatePresence>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<motion.div
|
|
||||||
animate={{ scale: isActive ? 1 : 1.04 }}
|
|
||||||
transition={{ duration: 0.6, ease: EASE }}
|
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={f.img}
|
|
||||||
alt=""
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
height: '100%',
|
||||||
objectFit: 'cover',
|
display: 'flex', alignItems: 'center', justifyContent: 'flex-end',
|
||||||
objectPosition: f.imgPos || 'center center',
|
padding: isMobile ? '0 20px' : '0 5vw 0 0',
|
||||||
display: 'block'
|
zIndex: 2,
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RIGHT PANEL */}
|
|
||||||
<div style={{
|
|
||||||
width: isMobile ? '100%' : '50%',
|
|
||||||
height: isMobile ? '50vh' : '100%',
|
|
||||||
background: rightBg,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
}}>
|
||||||
{isTextOnLeft ? (
|
<motion.h2
|
||||||
<motion.div
|
initial={{ y: 24, opacity: 0, filter: 'blur(10px)' }}
|
||||||
animate={{ scale: isActive ? 1 : 1.04 }}
|
animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
|
||||||
transition={{ duration: 0.6, ease: EASE }}
|
transition={{ duration: 0.6, ease: EASE }}
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={f.img}
|
|
||||||
alt=""
|
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
direction: 'rtl', textAlign: 'right',
|
||||||
height: '100%',
|
fontSize: 'clamp(28px, 4vw, 62px)', fontWeight: 900,
|
||||||
objectFit: 'cover',
|
color: GOLD, lineHeight: 1.2, margin: 0,
|
||||||
objectPosition: f.imgPos || 'center center',
|
|
||||||
display: 'block'
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</motion.div>
|
بینشی نو برای توسعهی صنعتی
|
||||||
) : (
|
</motion.h2>
|
||||||
isActive && (
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<div key={active} style={{ width: '100%', display: 'flex', justifyContent: 'center', padding: isMobile ? '24px 20px' : '0 8vw' }}>
|
|
||||||
<TextPanel index={i} feat={f} lang={lang} />
|
|
||||||
</div>
|
</div>
|
||||||
</AnimatePresence>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<Rail active={active} total={FEATURES.length} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -95,16 +95,47 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* description only — playlist removed */}
|
{/* active video description */}
|
||||||
<div style={{ direction: lang === 'fa' ? 'rtl' : 'ltr' }}>
|
<div style={{ direction: lang === 'fa' ? 'rtl' : 'ltr', marginBottom: 36 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, justifyContent: 'flex-start' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
<span style={{ fontSize: 16 }}>🎙</span>
|
<span style={{ fontSize: 16 }}>🎙</span>
|
||||||
<h3 style={{ fontSize: 'clamp(16px,1.6vw,22px)', fontWeight: 900, color: NAVY, margin: 0, lineHeight: 1.4 }}>{VIDEOS[activeVideo][lang].title}</h3>
|
<h3 style={{ fontSize: 'clamp(16px,1.6vw,22px)', fontWeight: 900, color: NAVY, margin: 0, lineHeight: 1.4 }}>{VIDEOS[activeVideo][lang].title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 13.5, lineHeight: 2.1, color: 'rgba(3,35,64,0.72)', margin: '0 0 18px', whiteSpace: 'pre-line', textAlign: 'justify' }}>
|
<p style={{ fontSize: 13.5, lineHeight: 2.1, color: 'rgba(3,35,64,0.72)', margin: 0, whiteSpace: 'pre-line', textAlign: 'justify' }}>
|
||||||
{VIDEOS[activeVideo][lang].body}
|
{VIDEOS[activeVideo][lang].body}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* video playlist — 3 thumbnail cards */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 16, direction: lang === 'fa' ? 'rtl' : 'ltr' }} className="max-md:!grid-cols-1">
|
||||||
|
{VIDEOS.slice(0, 3).map((v, i) => (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
onClick={() => {/* setActiveVideo(i) — wired if needed */}}
|
||||||
|
style={{
|
||||||
|
textAlign: 'inherit', background: i === activeVideo ? '#efe9e1' : '#fff',
|
||||||
|
border: `1px solid ${i === activeVideo ? GOLD : 'rgba(3,35,64,0.08)'}`,
|
||||||
|
borderRadius: 12, overflow: 'hidden', cursor: 'pointer',
|
||||||
|
display: 'flex', flexDirection: 'column', transition: 'box-shadow .2s',
|
||||||
|
}}
|
||||||
|
className="hover:!shadow-md"
|
||||||
|
>
|
||||||
|
<div style={{ position: 'relative', aspectRatio: '16/9', overflow: 'hidden' }}>
|
||||||
|
<img src={v.thumb} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
|
<div style={{ position: 'absolute', inset: 0, background: 'rgba(3,35,64,0.28)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div style={{ width: 36, height: 36, borderRadius: '50%', background: 'rgba(255,255,255,0.85)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div style={{ width: 0, height: 0, borderTop: '7px solid transparent', borderBottom: '7px solid transparent', borderLeft: `12px solid ${NAVY}`, marginLeft: 3 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ position: 'absolute', bottom: 8, left: 8, fontSize: 10, fontWeight: 800, color: '#fff', background: 'rgba(0,0,0,0.55)', padding: '2px 7px', borderRadius: 4, direction: 'ltr' }}>{v.duration}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '12px 14px', direction: lang === 'fa' ? 'rtl' : 'ltr', textAlign: 'right' }}>
|
||||||
|
<div style={{ fontSize: 11, color: GOLD, fontWeight: 800, marginBottom: 4 }}>{v.ep}</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 800, color: NAVY, lineHeight: 1.4 }}>{v[lang].title}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -131,208 +162,77 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── featured player card (light) ── */}
|
{/* ── featured player card ── */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: lang === 'fa' ? 'row' : 'row-reverse',
|
||||||
gap: 24,
|
|
||||||
alignItems: 'stretch',
|
alignItems: 'stretch',
|
||||||
background: '#efe9e1',
|
background: '#efe9e1',
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
padding: 24,
|
|
||||||
marginBottom: 32,
|
marginBottom: 32,
|
||||||
direction: lang === 'fa' ? 'rtl' : 'ltr',
|
overflow: 'hidden',
|
||||||
|
minHeight: 200,
|
||||||
}} className="max-md:!flex-col">
|
}} className="max-md:!flex-col">
|
||||||
{/* cover + rating container */}
|
|
||||||
|
{/* cover — full height, flush to right edge */}
|
||||||
|
<div style={{ flexShrink: 0, width: 200, position: 'relative' }} className="max-md:!w-full max-md:!h-48">
|
||||||
|
<img src={pod.cover} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
|
{/* rating badge overlaid at bottom of cover */}
|
||||||
<div style={{
|
<div style={{
|
||||||
flexShrink: 0,
|
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||||
width: 170,
|
background: 'rgba(3,35,64,0.75)', backdropFilter: 'blur(4px)',
|
||||||
background: '#032340',
|
padding: '6px 10px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||||
borderRadius: 16,
|
}}>
|
||||||
padding: 12,
|
<span style={{ color: GOLD, fontSize: 12, letterSpacing: 1 }}>★★★★★</span>
|
||||||
display: 'flex',
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#fff' }}>{pod.rating}</span>
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
}} className="max-md:!w-full max-md:!max-w-[200px] max-md:!self-center">
|
|
||||||
<img src={pod.cover} alt="" style={{ width: '100%', aspectRatio: '1', objectFit: 'cover', borderRadius: 10, display: 'block', marginBottom: 12 }} />
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, width: '100%' }}>
|
|
||||||
<span style={{ color: GOLD, fontSize: 13, letterSpacing: 1 }}>★★★★★</span>
|
|
||||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#fff' }}>{pod.rating}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* player body */}
|
{/* player body — directly beside cover, no gap */}
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1,
|
flex: 1, minWidth: 0, padding: 24,
|
||||||
minWidth: 0,
|
display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||||
display: 'flex',
|
direction: lang === 'fa' ? 'rtl' : 'ltr',
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '4px 0'
|
|
||||||
}}>
|
}}>
|
||||||
{/* Top Row: Buttons on the far left, Episode number on the right */}
|
{/* buttons row + episode number */}
|
||||||
<div style={{
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, direction: 'ltr' }}>
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
width: '100%',
|
|
||||||
marginBottom: 6,
|
|
||||||
direction: 'ltr'
|
|
||||||
}}>
|
|
||||||
{/* Download + Bookmark outline circular buttons */}
|
|
||||||
<div style={{ display: 'flex', gap: 10 }}>
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
<button style={{
|
<button style={{ width: 36, height: 36, borderRadius: '50%', border: '1px solid rgba(3,35,64,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', color: NAVY, cursor: 'pointer', transition: 'all 0.2s' }} className="hover:!bg-[#fff] hover:!border-transparent">
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: '50%',
|
|
||||||
border: '1px solid rgba(3, 35, 64, 0.18)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'transparent',
|
|
||||||
color: NAVY,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s'
|
|
||||||
}} className="hover:!bg-[#fff] hover:!border-transparent">
|
|
||||||
<Download size={16} strokeWidth={2} />
|
<Download size={16} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
<button style={{
|
<button style={{ width: 36, height: 36, borderRadius: '50%', border: '1px solid rgba(3,35,64,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', color: NAVY, cursor: 'pointer', transition: 'all 0.2s' }} className="hover:!bg-[#fff] hover:!border-transparent">
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: '50%',
|
|
||||||
border: '1px solid rgba(3, 35, 64, 0.18)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'transparent',
|
|
||||||
color: NAVY,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s'
|
|
||||||
}} className="hover:!bg-[#fff] hover:!border-transparent">
|
|
||||||
<BookOpen size={16} strokeWidth={2} />
|
<BookOpen size={16} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 800, color: GOLD, direction: lang === 'fa' ? 'rtl' : 'ltr' }}>{pod.epNum}</div>
|
||||||
{/* Episode number */}
|
|
||||||
<div style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: GOLD,
|
|
||||||
textAlign: lang === 'fa' ? 'right' : 'left',
|
|
||||||
direction: lang === 'fa' ? 'rtl' : 'ltr'
|
|
||||||
}}>
|
|
||||||
{pod.epNum}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title Row */}
|
{/* title */}
|
||||||
<div style={{ textAlign: lang === 'fa' ? 'right' : 'left', marginBottom: 6 }}>
|
<h3 style={{ fontSize: 'clamp(16px,1.8vw,22px)', fontWeight: 900, color: NAVY, margin: '0 0 8px', lineHeight: 1.4, textAlign: lang === 'fa' ? 'right' : 'left' }}>
|
||||||
<h3 style={{
|
|
||||||
fontSize: 'clamp(16px, 1.8vw, 22px)',
|
|
||||||
fontWeight: 900,
|
|
||||||
color: NAVY,
|
|
||||||
margin: 0,
|
|
||||||
lineHeight: 1.4
|
|
||||||
}}>
|
|
||||||
{pod[lang].title}
|
{pod[lang].title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date Row (directly under Title) */}
|
{/* date */}
|
||||||
<div style={{
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'rgba(3,35,64,0.65)', fontWeight: 600, marginBottom: 16, flexDirection: lang === 'fa' ? 'row' : 'row-reverse' }}>
|
||||||
display: 'flex',
|
|
||||||
justifyContent: lang === 'fa' ? 'flex-start' : 'flex-end',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 12
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'rgba(3, 35, 64, 0.65)',
|
|
||||||
fontWeight: 600,
|
|
||||||
flexDirection: lang === 'fa' ? 'row' : 'row-reverse'
|
|
||||||
}}>
|
|
||||||
<Calendar size={14} color={GOLD} />
|
<Calendar size={14} color={GOLD} />
|
||||||
<span>{pod.date}</span>
|
<span>{pod.date}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Separator line */}
|
<div style={{ height: 1, background: 'rgba(3,35,64,0.1)', marginBottom: 16 }} />
|
||||||
<div style={{
|
|
||||||
height: 1,
|
|
||||||
background: 'rgba(3, 35, 64, 0.1)',
|
|
||||||
marginBottom: 16
|
|
||||||
}} />
|
|
||||||
|
|
||||||
{/* Play button + Waveform row */}
|
{/* play + waveform */}
|
||||||
<div style={{
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexDirection: lang === 'fa' ? 'row-reverse' : 'row', minWidth: 0 }}>
|
||||||
display: 'flex',
|
<button aria-label="play" style={{ flexShrink: 0, width: 44, height: 44, borderRadius: '50%', border: 'none', cursor: 'pointer', background: GOLD, display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'transform 0.2s, background-color 0.2s' }} className="hover:!scale-105 hover:!bg-[#b88d46]">
|
||||||
alignItems: 'center',
|
|
||||||
gap: 16,
|
|
||||||
flexDirection: lang === 'fa' ? 'row-reverse' : 'row',
|
|
||||||
minWidth: 0,
|
|
||||||
}}>
|
|
||||||
{/* Play button */}
|
|
||||||
<button
|
|
||||||
aria-label="play"
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: '50%',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: GOLD,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
transition: 'transform 0.2s, background-color 0.2s'
|
|
||||||
}}
|
|
||||||
className="hover:!scale-105 hover:!bg-[#b88d46]"
|
|
||||||
>
|
|
||||||
<Play size={18} fill="#fff" color="#fff" style={{ marginLeft: 2 }} />
|
<Play size={18} fill="#fff" color="#fff" style={{ marginLeft: 2 }} />
|
||||||
</button>
|
</button>
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 10, direction: 'ltr' }}>
|
||||||
{/* Waveform container */}
|
<span style={{ fontSize: 11, color: 'rgba(3,35,64,0.6)', fontWeight: 700, flexShrink: 0 }}>۰۲:۳۱</span>
|
||||||
<div style={{
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 3, height: 26, overflow: 'hidden' }}>
|
||||||
flex: 1,
|
{[4,6,8,10,7,5,8,12,15,18,14,10,8,12,16,20,22,17,12,9,12,15,18,22,24,20,15,11,8,12,15,17,20,22,18,14,10,8,12,15,19,21,16,12,9,11,14,18,20,16,11,8,6,9,12,15,17,13,9,7,10,13,16,18,14,10,8,11,14,16,13,9,7,10,13,16,18,14,10,7].map((h, i) => (
|
||||||
minWidth: 0,
|
<span key={i} style={{ flex: 1, height: h, borderRadius: 1.5, background: i < 20 ? GOLD : 'rgba(3,35,64,0.18)', minWidth: 2, transition: 'background-color 0.2s' }} />
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 10,
|
|
||||||
direction: 'ltr',
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: 11, color: 'rgba(3, 35, 64, 0.6)', fontWeight: 700, flexShrink: 0 }}>۰۲:۳۱</span>
|
|
||||||
<div style={{
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 3,
|
|
||||||
height: 26,
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
|
||||||
{[
|
|
||||||
4, 6, 8, 10, 7, 5, 8, 12, 15, 18, 14, 10, 8, 12, 16, 20, 22, 17, 12, 9,
|
|
||||||
12, 15, 18, 22, 24, 20, 15, 11, 8, 12, 15, 17, 20, 22, 18, 14, 10, 8, 12, 15,
|
|
||||||
19, 21, 16, 12, 9, 11, 14, 18, 20, 16, 11, 8, 6, 9, 12, 15, 17, 13, 9, 7,
|
|
||||||
10, 13, 16, 18, 14, 10, 8, 11, 14, 16, 13, 9, 7, 10, 13, 16, 18, 14, 10, 7
|
|
||||||
].map((h, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: h,
|
|
||||||
borderRadius: 1.5,
|
|
||||||
background: i < 20 ? GOLD : 'rgba(3, 35, 64, 0.18)',
|
|
||||||
minWidth: 2,
|
|
||||||
transition: 'background-color 0.2s'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: 11, color: 'rgba(3, 35, 64, 0.6)', fontWeight: 700, flexShrink: 0 }}>{pod.duration}</span>
|
<span style={{ fontSize: 11, color: 'rgba(3,35,64,0.6)', fontWeight: 700, flexShrink: 0 }}>{pod.duration}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -353,7 +253,7 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 16
|
gap: 16
|
||||||
}}>
|
}}>
|
||||||
{PODCASTS.filter((_, idx) => idx !== activePod).map((p) => {
|
{PODCASTS.filter((_, idx) => idx !== activePod).slice(0, 2).map((p) => {
|
||||||
const originalIndex = PODCASTS.findIndex(item => item.id === p.id);
|
const originalIndex = PODCASTS.findIndex(item => item.id === p.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useState } from 'react'
|
import { useParams, Link, useNavigate } from 'react-router-dom'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Calendar, Tag, MessageCircle, ArrowLeft, MoreHorizontal, Heart, Reply, Plus, UserPlus, LogIn, Send, Share2, Globe, Mail } from 'lucide-react'
|
import { Calendar, Tag, MessageCircle, ArrowLeft, MoreHorizontal, Heart, Reply, Plus, UserPlus, LogIn, Send, Share2, Globe, Mail } from 'lucide-react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { getPost, type Post } from '@/content/posts'
|
import { getPost, type Post } from '@/content/posts'
|
||||||
import { MagicTextGroup } from '@/components/ui/magic-text'
|
import { MagicTextGroup } from '@/components/ui/magic-text'
|
||||||
|
|
||||||
|
|
@ -247,13 +247,9 @@ export default function PostDetail() {
|
||||||
const post = getPost(id || 'p1')!
|
const post = getPost(id || 'p1')!
|
||||||
const t = post[lang]
|
const t = post[lang]
|
||||||
const isFa = lang === 'fa'
|
const isFa = lang === 'fa'
|
||||||
const [loggedIn, setLoggedIn] = useState(() => {
|
const { member } = useAuth()
|
||||||
try { return localStorage.getItem('sf_logged_in') === '1' } catch { return false }
|
const navigate = useNavigate()
|
||||||
})
|
const loggedIn = member !== null
|
||||||
const login = () => {
|
|
||||||
try { localStorage.setItem('sf_logged_in', '1') } catch { /* ignore */ }
|
|
||||||
setLoggedIn(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div dir={isFa ? 'rtl' : 'ltr'} style={{ background: '#fff', minHeight: '80vh' }}>
|
<div dir={isFa ? 'rtl' : 'ltr'} style={{ background: '#fff', minHeight: '80vh' }}>
|
||||||
|
|
@ -382,7 +378,7 @@ export default function PostDetail() {
|
||||||
</div>
|
</div>
|
||||||
{/* gate overlay */}
|
{/* gate overlay */}
|
||||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: 36 }}>
|
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: 36 }}>
|
||||||
<MembershipGate lang={lang} onPreviewLogin={login} />
|
<MembershipGate lang={lang} onPreviewLogin={() => navigate('/login')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth, authFetch } from '@/context/AuthContext'
|
import { useAuth, authFetch, BASE } from '@/context/AuthContext'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
|
|
||||||
const GOLD = '#CD9E53'
|
const GOLD = '#CD9E53'
|
||||||
|
|
@ -19,15 +19,15 @@ interface Purchase {
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { lang } = useLang()
|
const { lang } = useLang()
|
||||||
const { member, token, logout } = useAuth()
|
const { member, logout } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [tab, setTab] = useState<Tab>('info')
|
const [tab, setTab] = useState<Tab>('info')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) navigate('/login')
|
if (!member) navigate('/login')
|
||||||
}, [token, navigate])
|
}, [member, navigate])
|
||||||
|
|
||||||
if (!member || !token) return null
|
if (!member) return null
|
||||||
|
|
||||||
const initials = member.name.split(' ').map(w => w[0]).join('').slice(0, 2)
|
const initials = member.name.split(' ').map(w => w[0]).join('').slice(0, 2)
|
||||||
|
|
||||||
|
|
@ -136,9 +136,9 @@ export default function ProfilePage() {
|
||||||
|
|
||||||
{/* ── Tab Content ── */}
|
{/* ── Tab Content ── */}
|
||||||
<div style={{ maxWidth: 860, margin: '0 auto', padding: '1.5rem 1.5rem 4rem' }}>
|
<div style={{ maxWidth: 860, margin: '0 auto', padding: '1.5rem 1.5rem 4rem' }}>
|
||||||
{tab === 'info' && <InfoTab member={member} token={token} lang={lang} />}
|
{tab === 'info' && <InfoTab member={member} lang={lang} />}
|
||||||
{tab === 'password' && <PasswordTab token={token} lang={lang} />}
|
{tab === 'password' && <PasswordTab lang={lang} />}
|
||||||
{tab === 'content' && <ContentTab token={token} lang={lang} />}
|
{tab === 'content' && <ContentTab lang={lang} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -147,9 +147,8 @@ export default function ProfilePage() {
|
||||||
/* ═══════════════════════════════════════
|
/* ═══════════════════════════════════════
|
||||||
Info Tab
|
Info Tab
|
||||||
═══════════════════════════════════════ */
|
═══════════════════════════════════════ */
|
||||||
function InfoTab({ member, token, lang, }: {
|
function InfoTab({ member, lang, }: {
|
||||||
member: { name: string; phone?: string; email: string }
|
member: { name: string; phone?: string; email: string }
|
||||||
token: string
|
|
||||||
lang: 'fa' | 'en'
|
lang: 'fa' | 'en'
|
||||||
}) {
|
}) {
|
||||||
const { updateMember } = useAuth()
|
const { updateMember } = useAuth()
|
||||||
|
|
@ -161,7 +160,7 @@ function InfoTab({ member, token, lang, }: {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setStatus('saving')
|
setStatus('saving')
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(token, '/api/members/me', { method: 'PUT', body: JSON.stringify({ name, phone }) })
|
const res = await authFetch('/api/members/me', { method: 'PUT', body: JSON.stringify({ name, phone }) })
|
||||||
if (!res.ok) throw new Error()
|
if (!res.ok) throw new Error()
|
||||||
updateMember({ name, phone })
|
updateMember({ name, phone })
|
||||||
setStatus('saved')
|
setStatus('saved')
|
||||||
|
|
@ -235,7 +234,7 @@ function InfoTab({ member, token, lang, }: {
|
||||||
/* ═══════════════════════════════════════
|
/* ═══════════════════════════════════════
|
||||||
Password Tab
|
Password Tab
|
||||||
═══════════════════════════════════════ */
|
═══════════════════════════════════════ */
|
||||||
function PasswordTab({ token, lang }: { token: string; lang: 'fa' | 'en' }) {
|
function PasswordTab({ lang }: { lang: 'fa' | 'en' }) {
|
||||||
const [current, setCurrent] = useState('')
|
const [current, setCurrent] = useState('')
|
||||||
const [next, setNext] = useState('')
|
const [next, setNext] = useState('')
|
||||||
const [confirm, setConfirm] = useState('')
|
const [confirm, setConfirm] = useState('')
|
||||||
|
|
@ -247,7 +246,7 @@ function PasswordTab({ token, lang }: { token: string; lang: 'fa' | 'en' }) {
|
||||||
if (next !== confirm) { setErrMsg(lang === 'fa' ? 'رمزها یکسان نیستند' : 'Passwords do not match'); return }
|
if (next !== confirm) { setErrMsg(lang === 'fa' ? 'رمزها یکسان نیستند' : 'Passwords do not match'); return }
|
||||||
setStatus('saving'); setErrMsg('')
|
setStatus('saving'); setErrMsg('')
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(token, '/api/members/me/password', { method: 'PUT', body: JSON.stringify({ currentPassword: current, newPassword: next }) })
|
const res = await authFetch('/api/members/me/password', { method: 'PUT', body: JSON.stringify({ currentPassword: current, newPassword: next }) })
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error)
|
if (!res.ok) throw new Error(data.error)
|
||||||
setStatus('saved'); setCurrent(''); setNext(''); setConfirm('')
|
setStatus('saved'); setCurrent(''); setNext(''); setConfirm('')
|
||||||
|
|
@ -304,19 +303,17 @@ function PasswordTab({ token, lang }: { token: string; lang: 'fa' | 'en' }) {
|
||||||
/* ═══════════════════════════════════════
|
/* ═══════════════════════════════════════
|
||||||
Content Tab
|
Content Tab
|
||||||
═══════════════════════════════════════ */
|
═══════════════════════════════════════ */
|
||||||
function ContentTab({ token, lang }: { token: string; lang: 'fa' | 'en' }) {
|
function ContentTab({ lang }: { lang: 'fa' | 'en' }) {
|
||||||
const [items, setItems] = useState<Purchase[]>([])
|
const [items, setItems] = useState<Purchase[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authFetch(token, '/api/members/me/purchases')
|
authFetch('/api/members/me/purchases')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => { setItems(Array.isArray(data) ? data : []); setLoading(false) })
|
.then(data => { setItems(Array.isArray(data) ? data : []); setLoading(false) })
|
||||||
.catch(() => { setErr(lang === 'fa' ? 'خطا در بارگذاری' : 'Load error'); setLoading(false) })
|
.catch(() => { setErr(lang === 'fa' ? 'خطا در بارگذاری' : 'Load error'); setLoading(false) })
|
||||||
}, [token, lang])
|
}, [lang])
|
||||||
|
|
||||||
const BASE = import.meta.env.VITE_PANEL_API ?? ''
|
|
||||||
|
|
||||||
const categoryColors: Record<string, string> = {
|
const categoryColors: Record<string, string> = {
|
||||||
'گزارش': '#3b82f6', 'تحلیل': '#8b5cf6', 'رادار': '#10b981',
|
'گزارش': '#3b82f6', 'تحلیل': '#8b5cf6', 'رادار': '#10b981',
|
||||||
|
|
@ -419,7 +416,7 @@ function ContentTab({ token, lang }: { token: string; lang: 'fa' | 'en' }) {
|
||||||
{/* download */}
|
{/* download */}
|
||||||
{item.file_url && (
|
{item.file_url && (
|
||||||
<a
|
<a
|
||||||
href={`${BASE}/api/members/me/purchases/${item.id}/download?token=${token}`}
|
href={`${BASE}/api/members/me/purchases/${item.id}/download`}
|
||||||
target="_blank" rel="noreferrer"
|
target="_blank" rel="noreferrer"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5,
|
display: 'flex', alignItems: 'center', gap: 5,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { BarChart3, Cpu, Leaf, Globe, Zap, ChevronLeft, ChevronRight, X, LogIn, UserPlus, AlertTriangle, Calendar, User } from 'lucide-react'
|
import { BarChart3, Cpu, Leaf, Globe, Zap, ChevronLeft, ChevronRight, X, LogIn, UserPlus, AlertTriangle, Calendar, User } from 'lucide-react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { RADAR_ITEMS, type RadarItem, type RadarCategory } from '@/content/radar'
|
import { RADAR_ITEMS, type RadarItem, type RadarCategory } from '@/content/radar'
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
|
|
@ -96,7 +97,8 @@ function MembershipGate({ lang }: { lang: 'fa' | 'en' }) {
|
||||||
export function RadarItemModal({ item, lang, onClose }: { item: RadarItem; lang: 'fa' | 'en'; onClose: () => void }) {
|
export function RadarItemModal({ item, lang, onClose }: { item: RadarItem; lang: 'fa' | 'en'; onClose: () => void }) {
|
||||||
const t = T[lang]
|
const t = T[lang]
|
||||||
const content = item[lang]
|
const content = item[lang]
|
||||||
const isLoggedIn = (() => { try { return localStorage.getItem('sf_logged_in') === '1' } catch { return false } })()
|
const { member } = useAuth()
|
||||||
|
const isLoggedIn = member !== null
|
||||||
const Icon = CATEGORY_ICON[item.category]
|
const Icon = CATEGORY_ICON[item.category]
|
||||||
const catLabel = CATEGORY_LABEL[item.category][lang]
|
const catLabel = CATEGORY_LABEL[item.category][lang]
|
||||||
const dir = lang === 'fa' ? 'rtl' : 'ltr'
|
const dir = lang === 'fa' ? 'rtl' : 'ltr'
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import AuthPage from '@/pages/Auth/AuthPage'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Calendar, Tag, UserPlus, LogIn, Send, Share2, Globe, Mail, ArrowLeft } from 'lucide-react'
|
import { Calendar, Tag, UserPlus, LogIn, Send, Share2, Globe, Mail, ArrowLeft } from 'lucide-react'
|
||||||
import { useLang } from '@/context/LangContext'
|
import { useLang } from '@/context/LangContext'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { RADAR_ITEMS, type RadarItem, type RadarCategory } from '@/content/radar'
|
import { RADAR_ITEMS, type RadarItem, type RadarCategory } from '@/content/radar'
|
||||||
|
|
||||||
const NAVY = '#032340'
|
const NAVY = '#032340'
|
||||||
|
|
@ -114,15 +115,12 @@ export default function RadarPost() {
|
||||||
const content = item[lang]
|
const content = item[lang]
|
||||||
const catLabel = CATEGORY_LABEL[item.category][lang]
|
const catLabel = CATEGORY_LABEL[item.category][lang]
|
||||||
|
|
||||||
const [loggedIn, setLoggedIn] = useState(() => {
|
const { member } = useAuth()
|
||||||
try { return localStorage.getItem('sf_logged_in') === '1' } catch { return false }
|
const loggedIn = member !== null
|
||||||
})
|
|
||||||
const [showAuth, setShowAuth] = useState(false)
|
const [showAuth, setShowAuth] = useState(false)
|
||||||
const handleAuthSuccess = () => {
|
// AuthPage performs the real login/register via AuthContext; once `member` is set
|
||||||
try { localStorage.setItem('sf_logged_in', '1') } catch { /* ignore */ }
|
// `loggedIn` flips automatically — we just close the modal.
|
||||||
setLoggedIn(true)
|
const handleAuthSuccess = () => setShowAuth(false)
|
||||||
setShowAuth(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = content.body ?? []
|
const body = content.body ?? []
|
||||||
const firstPara = body[0]
|
const firstPara = body[0]
|
||||||
|
|
|
||||||