feat: radar category pages, post detail page, panel CMS wiring + webp uploads

- Add /radar/:category dynamic detail pages (hero, special banner, grids, pagination)
- Add /posts/:id article page: cover mask, magic-text scroll reveal, charts,
  comments, floating related sidebar, logged-out membership gate
- Panel: radar_pages table/routes + admin UI, grouped right sidebar, webp
  image conversion on upload (sharp)
- Mobile fixes: overflow-x guards, left-side accordion menu, globe/podcast/hero
- Header: flag language toggle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
alireza 2026-06-14 17:01:28 +03:30
parent 3ba233ee0b
commit 07239bd811
57 changed files with 7289 additions and 583 deletions

13
.rtk/filters.toml Normal file
View File

@ -0,0 +1,13 @@
# Project-local RTK filters — commit this file with your repo.
# Filters here override user-global and built-in filters.
# Docs: https://github.com/rtk-ai/rtk#custom-filters
schema_version = 1
# Example: suppress build noise from a custom tool
# [filters.my-tool]
# description = "Compact my-tool output"
# match_command = "^my-tool\\s+build"
# strip_ansi = true
# strip_lines_matching = ["^\\s*$", "^Downloading", "^Installing"]
# max_lines = 30
# on_empty = "my-tool: ok"

139
CLAUDE.md
View File

@ -59,3 +59,142 @@ npm rebuild better-sqlite3 # REQUIRED if Node's ABI changed — native addon, f
## Wiring the panel to the site (not done yet) ## Wiring the panel to the site (not done yet)
When asked to make published content appear on the site: add a frontend fetch client (e.g. `src/lib/api.ts` reading `import.meta.env.VITE_API_URL`), replace a section's `src/content/*` import with a hook that calls `GET /api/articles?category=<section>`, and keep the static file as the loading fallback. Do it one section at a time. The `articles` table's `category` column is the per-section discriminator. Recommended schema additions before going live: a `status` (draft/published) column, and an `events` resource for the calendar. When asked to make published content appear on the site: add a frontend fetch client (e.g. `src/lib/api.ts` reading `import.meta.env.VITE_API_URL`), replace a section's `src/content/*` import with a hook that calls `GET /api/articles?category=<section>`, and keep the static file as the loading fallback. Do it one section at a time. The `articles` table's `category` column is the per-section discriminator. Recommended schema additions before going live: a `status` (draft/published) column, and an `events` resource for the calendar.
<!-- rtk-instructions v2 -->
# RTK (Rust Token Killer) - Token-Optimized Commands
## Golden Rule
**Always prefix commands with `rtk`**. If RTK has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always safe to use.
**Important**: Even in command chains with `&&`, use `rtk`:
```bash
# ❌ Wrong
git add . && git commit -m "msg" && git push
# ✅ Correct
rtk git add . && rtk git commit -m "msg" && rtk git push
```
## RTK Commands by Workflow
### Build & Compile (80-90% savings)
```bash
rtk cargo build # Cargo build output
rtk cargo check # Cargo check output
rtk cargo clippy # Clippy warnings grouped by file (80%)
rtk tsc # TypeScript errors grouped by file/code (83%)
rtk lint # ESLint/Biome violations grouped (84%)
rtk prettier --check # Files needing format only (70%)
rtk next build # Next.js build with route metrics (87%)
```
### Test (60-99% savings)
```bash
rtk cargo test # Cargo test failures only (90%)
rtk go test # Go test failures only (90%)
rtk jest # Jest failures only (99.5%)
rtk vitest # Vitest failures only (99.5%)
rtk playwright test # Playwright failures only (94%)
rtk pytest # Python test failures only (90%)
rtk rake test # Ruby test failures only (90%)
rtk rspec # RSpec test failures only (60%)
rtk test <cmd> # Generic test wrapper - failures only
```
### Git (59-80% savings)
```bash
rtk git status # Compact status
rtk git log # Compact log (works with all git flags)
rtk git diff # Compact diff (80%)
rtk git show # Compact show (80%)
rtk git add # Ultra-compact confirmations (59%)
rtk git commit # Ultra-compact confirmations (59%)
rtk git push # Ultra-compact confirmations
rtk git pull # Ultra-compact confirmations
rtk git branch # Compact branch list
rtk git fetch # Compact fetch
rtk git stash # Compact stash
rtk git worktree # Compact worktree
```
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
### GitHub (26-87% savings)
```bash
rtk gh pr view <num> # Compact PR view (87%)
rtk gh pr checks # Compact PR checks (79%)
rtk gh run list # Compact workflow runs (82%)
rtk gh issue list # Compact issue list (80%)
rtk gh api # Compact API responses (26%)
```
### JavaScript/TypeScript Tooling (70-90% savings)
```bash
rtk pnpm list # Compact dependency tree (70%)
rtk pnpm outdated # Compact outdated packages (80%)
rtk pnpm install # Compact install output (90%)
rtk npm run <script> # Compact npm script output
rtk npx <cmd> # Compact npx command output
rtk prisma # Prisma without ASCII art (88%)
```
### Files & Search (60-75% savings)
```bash
rtk ls <path> # Tree format, compact (65%)
rtk read <file> # Code reading with filtering (60%)
rtk grep <pattern> # Search grouped by file (75%). Format flags (-c, -l, -L, -o, -Z) run raw.
rtk find <pattern> # Find grouped by directory (70%)
```
### Analysis & Debug (70-90% savings)
```bash
rtk err <cmd> # Filter errors only from any command
rtk log <file> # Deduplicated logs with counts
rtk json <file> # JSON structure without values
rtk deps # Dependency overview
rtk env # Environment variables compact
rtk summary <cmd> # Smart summary of command output
rtk diff # Ultra-compact diffs
```
### Infrastructure (85% savings)
```bash
rtk docker ps # Compact container list
rtk docker images # Compact image list
rtk docker logs <c> # Deduplicated logs
rtk kubectl get # Compact resource list
rtk kubectl logs # Deduplicated pod logs
```
### Network (65-70% savings)
```bash
rtk curl <url> # Compact HTTP responses (70%)
rtk wget <url> # Compact download output (65%)
```
### Meta Commands
```bash
rtk gain # View token savings statistics
rtk gain --history # View command history with savings
rtk discover # Analyze Claude Code sessions for missed RTK usage
rtk proxy <cmd> # Run command without filtering (for debugging)
rtk init # Add RTK instructions to CLAUDE.md
rtk init --global # Add RTK to ~/.claude/CLAUDE.md
```
## Token Savings Overview
| Category | Commands | Typical Savings |
|----------|----------|-----------------|
| Tests | vitest, playwright, cargo test | 90-99% |
| Build | next, tsc, lint, prettier | 70-87% |
| Git | status, log, diff, add, commit | 59-80% |
| GitHub | gh pr, gh run, gh issue | 26-87% |
| Package Managers | pnpm, npm, npx | 70-90% |
| Files | ls, read, grep, find | 60-75% |
| Infrastructure | docker, kubectl | 85% |
| Network | curl, wget | 65-70% |
Overall average: **60-90% token reduction** on common development operations.
<!-- /rtk-instructions -->

View File

@ -54,6 +54,232 @@ db.exec(`
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 (
id TEXT PRIMARY KEY,
title_fa TEXT NOT NULL,
title_en TEXT,
date_fa TEXT,
date_en TEXT,
month_fa TEXT,
month_en TEXT,
year TEXT,
type TEXT CHECK(type IN ('conference','exhibition','seminar','international')),
location_fa TEXT,
location_en TEXT,
city_fa TEXT,
city_en TEXT,
description_fa TEXT,
description_en TEXT,
registration_open INTEGER DEFAULT 1,
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_events_sort ON events(sort_order, created_at DESC);
CREATE TABLE IF NOT EXISTS vision_items (
id TEXT PRIMARY KEY,
icon TEXT,
title_fa TEXT NOT NULL,
title_en TEXT,
desc_fa TEXT,
desc_en TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_vision_sort ON vision_items(sort_order, created_at DESC);
CREATE TABLE IF NOT EXISTS advisory_board (
id TEXT PRIMARY KEY,
name_fa TEXT NOT NULL,
name_en TEXT,
role_fa TEXT,
role_en TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_advisory_sort ON advisory_board(sort_order, created_at DESC);
CREATE TABLE IF NOT EXISTS team_members (
id TEXT PRIMARY KEY,
name_fa TEXT NOT NULL,
name_en TEXT,
role_fa TEXT,
role_en TEXT,
bio_fa TEXT,
bio_en TEXT,
expertise TEXT,
email TEXT,
initial TEXT,
photo TEXT,
report_count INTEGER DEFAULT 0,
is_expert INTEGER DEFAULT 0,
expert_room_fa TEXT,
expert_room_en TEXT,
telegram TEXT,
linkedin TEXT,
twitter TEXT,
website TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_team_sort ON team_members(sort_order, created_at DESC);
CREATE TABLE IF NOT EXISTS plans (
id TEXT PRIMARY KEY,
name_fa TEXT NOT NULL,
name_en TEXT,
price INTEGER DEFAULT 0,
period_fa TEXT,
period_en TEXT,
features TEXT,
badge_fa TEXT,
badge_en TEXT,
is_featured INTEGER DEFAULT 0,
cta_fa TEXT,
cta_en TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_plans_sort ON plans(sort_order);
CREATE TABLE IF NOT EXISTS radar_items (
id TEXT PRIMARY KEY,
category TEXT CHECK(category IN ('market','tech','commodity','geo','energy')),
img TEXT,
author_name TEXT,
author_avatar TEXT,
title_fa TEXT,
date_fa TEXT,
excerpt_fa TEXT,
title_en TEXT,
date_en TEXT,
excerpt_en TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_radar_sort ON radar_items(sort_order, created_at DESC);
CREATE TABLE IF NOT EXISTS radar_pages (
slug TEXT PRIMARY KEY,
source_categories TEXT,
label_fa TEXT,
label_en TEXT,
latest_heading_fa TEXT,
latest_heading_en TEXT,
featured_title_fa TEXT,
featured_title_en TEXT,
featured_date_fa TEXT,
featured_date_en TEXT,
featured_img TEXT,
banner_kicker_fa TEXT,
banner_kicker_en TEXT,
banner_title_fa TEXT,
banner_title_en TEXT,
banner_desc_fa TEXT,
banner_desc_en TEXT,
banner_book_img TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_radar_pages_sort ON radar_pages(sort_order);
CREATE TABLE IF NOT EXISTS banners (
id TEXT PRIMARY KEY,
overline_fa TEXT,
overline_en TEXT,
title_fa TEXT NOT NULL,
title_en TEXT,
subtitle_fa TEXT,
subtitle_en TEXT,
cta_label_fa TEXT,
cta_label_en TEXT,
cta_url TEXT,
image TEXT,
position TEXT DEFAULT 'hero',
active INTEGER DEFAULT 1,
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_banners_active ON banners(active, sort_order);
CREATE TABLE IF NOT EXISTS factory_reports (
id TEXT PRIMARY KEY,
img TEXT,
tag_fa TEXT,
tag_en TEXT,
title_fa TEXT NOT NULL,
title_en TEXT,
date_fa TEXT,
date_en TEXT,
excerpt_fa TEXT,
excerpt_en TEXT,
scroll_dir TEXT CHECK(scroll_dir IN ('left','right','up')) DEFAULT 'up',
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_factory_reports_sort ON factory_reports(sort_order, created_at DESC);
CREATE TABLE IF NOT EXISTS integrations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
logo TEXT,
accent TEXT,
grid_col INTEGER DEFAULT 1,
grid_row INTEGER DEFAULT 1,
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_integrations_sort ON integrations(sort_order, created_at DESC);
CREATE TABLE IF NOT EXISTS institute_stats (
id TEXT PRIMARY KEY,
label_fa TEXT NOT NULL,
label_en TEXT,
value INTEGER DEFAULT 0,
suffix_fa TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_institute_stats_sort ON institute_stats(sort_order);
CREATE TABLE IF NOT EXISTS market_prices (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
value REAL DEFAULT 0,
unit TEXT,
change REAL DEFAULT 0,
change_percent REAL DEFAULT 0,
trend TEXT CHECK(trend IN ('up','down','flat')) DEFAULT 'flat',
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_market_prices_sort ON market_prices(sort_order);
CREATE TABLE IF NOT EXISTS market_chart_points (
id TEXT PRIMARY KEY,
series TEXT NOT NULL CHECK(series IN ('history','export','comparison')),
label TEXT NOT NULL,
value REAL DEFAULT 0,
meta TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_chart_points_series ON market_chart_points(series, sort_order);
CREATE TABLE IF NOT EXISTS prices ( CREATE TABLE IF NOT EXISTS prices (
symbol TEXT PRIMARY KEY, symbol TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@ -66,6 +292,16 @@ db.exec(`
); );
`); `);
/* idempotent column migrations for already-created tables */
function ensureColumn(table, col, def) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
if (!cols.some((c) => c.name === col)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`);
}
}
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;
return { return {
@ -103,6 +339,80 @@ export function upsertPrice(p) {
}); });
} }
export function rowToEvent(row) {
if (!row) return null;
return {
id: row.id,
titleFa: row.title_fa,
titleEn: row.title_en,
dateFa: row.date_fa,
dateEn: row.date_en,
monthFa: row.month_fa,
monthEn: row.month_en,
year: row.year,
type: row.type,
locationFa: row.location_fa,
locationEn: row.location_en,
cityFa: row.city_fa,
cityEn: row.city_en,
descriptionFa: row.description_fa,
descriptionEn: row.description_en,
registrationOpen: !!row.registration_open,
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function rowToTeamMember(row) {
if (!row) return null;
return {
id: row.id,
nameFa: row.name_fa,
nameEn: row.name_en,
roleFa: row.role_fa,
roleEn: row.role_en,
bioFa: row.bio_fa,
bioEn: row.bio_en,
expertise: row.expertise ? JSON.parse(row.expertise) : [],
email: row.email,
initial: row.initial,
photo: row.photo,
reportCount: row.report_count,
isExpert: !!row.is_expert,
expertRoomFa: row.expert_room_fa,
expertRoomEn: row.expert_room_en,
telegram: row.telegram,
linkedin: row.linkedin,
twitter: row.twitter,
website: row.website,
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function rowToPlan(row) {
if (!row) return null;
return {
id: row.id,
nameFa: row.name_fa,
nameEn: row.name_en,
price: row.price,
periodFa: row.period_fa,
periodEn: row.period_en,
features: row.features ? JSON.parse(row.features) : [],
badgeFa: row.badge_fa,
badgeEn: row.badge_en,
isFeatured: !!row.is_featured,
ctaFa: row.cta_fa,
ctaEn: row.cta_en,
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function rowToRiskSignal(row) { export function rowToRiskSignal(row) {
if (!row) return null; if (!row) return null;
return { return {
@ -117,6 +427,184 @@ export function rowToRiskSignal(row) {
}; };
} }
export function rowToFactoryReport(row) {
if (!row) return null;
return {
id: row.id,
img: row.img,
tagFa: row.tag_fa,
tagEn: row.tag_en,
titleFa: row.title_fa,
titleEn: row.title_en,
dateFa: row.date_fa,
dateEn: row.date_en,
excerptFa: row.excerpt_fa,
excerptEn: row.excerpt_en,
scrollDir: row.scroll_dir,
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function rowToIntegration(row) {
if (!row) return null;
return {
id: row.id,
name: row.name,
icon: row.icon,
logo: row.logo,
accent: row.accent,
gridCol: row.grid_col,
gridRow: row.grid_row,
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function rowToInstituteStat(row) {
if (!row) return null;
return {
id: row.id,
labelFa: row.label_fa,
labelEn: row.label_en,
value: row.value,
suffixFa: row.suffix_fa,
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function rowToMarketPrice(row) {
if (!row) return null;
return {
id: row.id,
name: row.name,
value: row.value,
unit: row.unit,
change: row.change,
changePercent: row.change_percent,
trend: row.trend,
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function rowToMarketChartPoint(row) {
if (!row) return null;
return {
id: row.id,
series: row.series,
label: row.label,
value: row.value,
meta: row.meta,
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function rowToVisionItem(row) {
if (!row) return null;
return {
id: row.id,
icon: row.icon,
titleFa: row.title_fa,
titleEn: row.title_en,
descFa: row.desc_fa,
descEn: row.desc_en,
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function rowToAdvisoryMember(row) {
if (!row) return null;
return {
id: row.id,
nameFa: row.name_fa,
nameEn: row.name_en,
roleFa: row.role_fa,
roleEn: row.role_en,
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function rowToRadarItem(row) {
if (!row) return null;
return {
id: row.id,
category: row.category,
img: row.img,
authorName: row.author_name,
authorAvatar: row.author_avatar,
titleFa: row.title_fa,
dateFa: row.date_fa,
excerptFa: row.excerpt_fa,
titleEn: row.title_en,
dateEn: row.date_en,
excerptEn: row.excerpt_en,
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function rowToRadarPage(row) {
if (!row) return null;
return {
slug: row.slug,
sourceCategories: row.source_categories ? JSON.parse(row.source_categories) : [],
labelFa: row.label_fa,
labelEn: row.label_en,
latestHeadingFa: row.latest_heading_fa,
latestHeadingEn: row.latest_heading_en,
featuredTitleFa: row.featured_title_fa,
featuredTitleEn: row.featured_title_en,
featuredDateFa: row.featured_date_fa,
featuredDateEn: row.featured_date_en,
featuredImg: row.featured_img,
bannerKickerFa: row.banner_kicker_fa,
bannerKickerEn: row.banner_kicker_en,
bannerTitleFa: row.banner_title_fa,
bannerTitleEn: row.banner_title_en,
bannerDescFa: row.banner_desc_fa,
bannerDescEn: row.banner_desc_en,
bannerBookImg: row.banner_book_img,
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function rowToBanner(row) {
if (!row) return null;
return {
id: row.id,
overlineFa: row.overline_fa,
overlineEn: row.overline_en,
titleFa: row.title_fa,
titleEn: row.title_en,
subtitleFa: row.subtitle_fa,
subtitleEn: row.subtitle_en,
ctaLabelFa: row.cta_label_fa,
ctaLabelEn: row.cta_label_en,
ctaUrl: row.cta_url,
image: row.image,
position: row.position,
active: !!row.active,
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function rowToArticle(row) { export function rowToArticle(row) {
if (!row) return null; if (!row) return null;
return { return {

564
panel/package-lock.json generated
View File

@ -20,12 +20,523 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"nodemailer": "^8.0.10" "nodemailer": "^8.0.10",
"sharp": "^0.35.1"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.1.tgz",
"integrity": "sha512-T15JRWOubQ3f5+GxnWeIvo47u5qV0M9HBgJhT+f2gE1e9e6OhR6K73Re52Hm80qWcu1DNb3GweKmpr/MnuP2Ow==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.0"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.1.tgz",
"integrity": "sha512-t1CPD0cr7XCHjwUj6tQ5MC0pCi866I+gUW6zbUX4aFPnKd1DFBtk0M+gWcjX8VeEzgfCNiSiNTVFZ6b7kvdbnQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.0"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.1.tgz",
"integrity": "sha512-MBSQXqNPThW9EcZ905H6N4sEdX5EwZEYzGx5EBq9ncDCGJALMiY1xPFJxNdzuB1iBjLOpIfxajM6YxdvwmQSLA==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.0.tgz",
"integrity": "sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.0.tgz",
"integrity": "sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.0.tgz",
"integrity": "sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.0.tgz",
"integrity": "sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.0.tgz",
"integrity": "sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.0.tgz",
"integrity": "sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.0.tgz",
"integrity": "sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.0.tgz",
"integrity": "sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.0.tgz",
"integrity": "sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.0.tgz",
"integrity": "sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.1.tgz",
"integrity": "sha512-jygmR02PpCYypt7xB7nst1vqjZp/BpRA/Kf9nK7qRponJ/KrLPaZWEG4G15z1d2FZ6XqI+T0350ha3RSnKx24A==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.0"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.1.tgz",
"integrity": "sha512-ErCRyGU7LeoaFBZ0xW8hhLlXzhAg80sc4vxePB86qvtEvW1jEhhmbiNBP4oEzZfPMnu6HwHXfzD2W2kBU+RnCw==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.0"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.1.tgz",
"integrity": "sha512-LUWZ2+r2UoLCd8j0RLCwQ4gL6w47+Y7igxtVnPIDXOOEjV86LpBkAHq5VpJeg+GHbw0KN/JWlPJOdZjyZnFqFQ==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.0"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.1.tgz",
"integrity": "sha512-i7x6J3mwF4JgT0sM4V4WlAWdJ0bucPtA9rzO1bTji1n5qgBq/W5nn87RvOQPleuuxahNoLdTngByD8/vDDLArw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.0"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.1.tgz",
"integrity": "sha512-0zSaTUjTF0kIWTSYxD4EG/nvCU4jez53+3RdURtoY3HvbXtIQ98W90JnrGz/oLRFuEnfIy9+7xeq883euc0ZWw==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.0"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.1.tgz",
"integrity": "sha512-NbJD4mWdeyrNQKluO/tR/wBDOelcowSVGNBWxI0e3ZtlXc6F/UOVKDj1MLD4zl3oHTuvKW3s+MA9N54YTldAYw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.0"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.1.tgz",
"integrity": "sha512-VoW2sQCWI+0YIKQEmWJ8vzaQjTg9wIyfkFpvEfAS2h43X6iHu7GTk1hhOgB4IpSzCHe8UwQZIcx7b81VTaOrJA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.0"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.1.tgz",
"integrity": "sha512-LjBoSd/c5JU0/K5MwzDMlgsSRP2bPn98JQGFFQAOLQ0bU/1z4ekxUdSKY9BmlwSh/cA+OrvpgsWqfZyYfVHBRw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.0"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.1.tgz",
"integrity": "sha512-PCQUoQdZyE8tp3HpbevuihfUmgSP4qWI0FGEPWoeXqaS+cUrFfemabHQiebUmUmlUhCuNnQMxGrQ+CPqK4hnxg==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.0"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.1.tgz",
"integrity": "sha512-xU2ml2bU2OPxYVvW2A6ae4M1g5QKyhKG06P4FAt+YEaFQQO0919Qx+XxIZEUuWTMoDViLpMws2/dQwoe/VcA6A==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.1.tgz",
"integrity": "sha512-IkmHwuFhYpd3bTsN5SAahjwhiAcyXPooBt8vEUgxY3T0IP70sSJ0nU1xiPzZY8AH/OB1XpV3j8aZSVSOSfTbdA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.1.tgz",
"integrity": "sha512-wQahqCi9MD8Yxzg4gVM4fNrZxh+r6vD55PyIg+WJPaM5ZRUyF35iQpwJCuma3r6viU9/8Pxlc+XHV+woVa6nCQ==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.1.tgz",
"integrity": "sha512-WzBtkYtZHATLPe8XRharxZXxQ9cdLrQWHiwxt+BJ5rBsisQrKeeV86ErxPSVhcG6xCEuNhs0SqLpWr7XDa2k6w==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -1613,6 +2124,50 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.1.tgz",
"integrity": "sha512-lW979AMi+ESidzMv/Lnv+F9bknzLyxLqFI05Sm433vOeRcltgxQmXpnfOOFIAlKtwXU/ksupm2srQoFCkR214g==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.4"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.1",
"@img/sharp-darwin-x64": "0.35.1",
"@img/sharp-freebsd-wasm32": "0.35.1",
"@img/sharp-libvips-darwin-arm64": "1.3.0",
"@img/sharp-libvips-darwin-x64": "1.3.0",
"@img/sharp-libvips-linux-arm": "1.3.0",
"@img/sharp-libvips-linux-arm64": "1.3.0",
"@img/sharp-libvips-linux-ppc64": "1.3.0",
"@img/sharp-libvips-linux-riscv64": "1.3.0",
"@img/sharp-libvips-linux-s390x": "1.3.0",
"@img/sharp-libvips-linux-x64": "1.3.0",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.0",
"@img/sharp-libvips-linuxmusl-x64": "1.3.0",
"@img/sharp-linux-arm": "0.35.1",
"@img/sharp-linux-arm64": "0.35.1",
"@img/sharp-linux-ppc64": "0.35.1",
"@img/sharp-linux-riscv64": "0.35.1",
"@img/sharp-linux-s390x": "0.35.1",
"@img/sharp-linux-x64": "0.35.1",
"@img/sharp-linuxmusl-arm64": "0.35.1",
"@img/sharp-linuxmusl-x64": "0.35.1",
"@img/sharp-webcontainers-wasm32": "0.35.1",
"@img/sharp-win32-arm64": "0.35.1",
"@img/sharp-win32-ia32": "0.35.1",
"@img/sharp-win32-x64": "0.35.1"
}
},
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
@ -1822,6 +2377,13 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/tunnel-agent": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",

View File

@ -23,7 +23,8 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"nodemailer": "^8.0.10" "nodemailer": "^8.0.10",
"sharp": "^0.35.1"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"

File diff suppressed because it is too large Load Diff

View File

@ -189,6 +189,14 @@ textarea { resize: vertical; min-height: 80px; }
letter-spacing: .5px; letter-spacing: .5px;
} }
.pill.red { background: var(--red); color: white; } .pill.red { background: var(--red); color: white; }
.pill.green { background: var(--green); color: white; }
.row-avatar {
width: 44px; height: 44px; border-radius: 50%;
background: #032340; color: #CD9E53;
display: flex; align-items: center; justify-content: center;
font-size: 16px; font-weight: 800; flex-shrink: 0;
}
.editor .grid { .editor .grid {
display: grid; display: grid;

702
panel/seed-content.js Normal file
View File

@ -0,0 +1,702 @@
/**
* One-shot content seeder run once:
* node panel/seed-content.js
*
* 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(() => {
/* ─── REPORTS → articles table ─────────────────────────────── */
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 = [
{
id: 'iran-steel-global-trade-scenarios-1404',
title: 'ایران در نقشه جدید تجارت فولاد جهان: چهار سناریو برای ۱۴۰۴',
category: 'بازار جهانی', type: 'special',
author: 'دکتر علی محمدی', author_role: 'مدیر ارشد پژوهش', author_initial: 'م',
publish_date: 'بهمن ۱۴۰۳', pages: 84, price: 850000, is_free: 0, featured: 1,
summary: 'این گزارش ویژه با تحلیل روندهای کلان اقتصاد جهانی، چهار سناریوی محتمل برای جایگاه صادرات فولاد ایران در سال ۱۴۰۴ را بررسی می‌کند. مدل‌سازی کمی بر اساس داده‌های تجارت ۱۵ کشور رقیب انجام شده و توصیه‌های سیاست‌گذاری مشخص ارائه می‌دهد.',
tags: JSON.stringify(['تجارت جهانی', 'سناریوسازی', 'صادرات', 'سیاست‌گذاری', '۱۴۰۴']),
},
{
id: 'steel-energy-consumption-efficiency-1403',
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) {
upsertArticle.run(r);
}
console.log(`${reports.length} گزارش وارد شد`);
/* ─── EVENTS ────────────────────────────────────────────────── */
const upsertEvent = db.prepare(`
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} رویداد وارد شد`);
/* ─── TEAM MEMBERS ──────────────────────────────────────────── */
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 = [
{
id: 'ali-mohammadi',
name_fa: 'دکتر علی محمدی', role_fa: 'مدیر ارشد پژوهش', initial: 'م',
bio_fa: 'دکتری اقتصاد صنعتی از دانشگاه تهران با بیش از ۱۸ سال تجربه در تحلیل بازارهای فولاد و فلزات. پیش از پیوستن به اندیشکده، مشاور ارشد سازمان توسعه و نوسازی معادن و صنایع معدنی ایران (ایمیدرو) بوده است. بیش از ۵۰ گزارش تحلیلی و ۱۲ مقاله علمی در حوزه اقتصاد فولاد منتشر کرده است.',
expertise: JSON.stringify(['اقتصاد صنعتی', 'تحلیل بازار فولاد', 'سیاست‌گذاری صنعتی', 'مدل‌سازی اقتصادی', 'تجارت بین‌الملل']),
email: 'mohammadi@andishkade-foolad.ir',
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) {
upsertTeam.run(m);
}
console.log(`${team.length} عضو تیم وارد شد`);
/* ─── 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 upsertRisk = db.prepare(`
INSERT OR REPLACE INTO risk_signals
(id, quote, name, date, level, sort_order, created_at, updated_at)
VALUES
(@id, @quote, @name, @date, @level, @sort_order,
datetime('now'), datetime('now'))
`);
const risks = [
{
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 ───────────────────────────────────────────── */
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 = [
{ 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');

View File

@ -5,13 +5,14 @@ import cookieParser from 'cookie-parser';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import helmet from 'helmet'; import helmet from 'helmet';
import multer from 'multer'; import multer from 'multer';
import sharp from 'sharp';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { db, rowToArticle, rowToRiskSignal, rowToPrice } from './db.js'; import { db, rowToArticle, rowToRiskSignal, rowToPrice, rowToEvent, rowToTeamMember, rowToPlan, rowToBanner, rowToRadarItem, rowToRadarPage, rowToFactoryReport, rowToIntegration, rowToInstituteStat, rowToMarketPrice, rowToMarketChartPoint, rowToVisionItem, rowToAdvisoryMember } from './db.js';
import { startScraperLoop, scrapeOnce } from './scraper.js'; import { startScraperLoop, scrapeOnce } from './scraper.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -26,18 +27,12 @@ const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || `http://localhost:${PORT}`;
const uploadsDir = path.join(__dirname, 'uploads'); const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
const storage = multer.diskStorage({ // buffer the upload in memory so we can transcode it to WebP before writing to disk
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase().slice(0, 8) || '.bin';
cb(null, `${nanoid(12)}${ext}`);
},
});
const upload = multer({ const upload = multer({
storage, storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 }, limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
if (/^image\/(jpe?g|png|webp|gif)$/.test(file.mimetype)) cb(null, true); if (/^image\/(jpe?g|png|webp|gif|avif|tiff?)$/.test(file.mimetype)) cb(null, true);
else cb(new Error('Only image uploads are allowed (jpeg, png, webp, gif)')); else cb(new Error('Only image uploads are allowed (jpeg, png, webp, gif)'));
}, },
}); });
@ -136,9 +131,16 @@ const PUBLIC_FIELDS = `
app.get('/api/articles', (req, res) => { app.get('/api/articles', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 200); const limit = Math.min(Number(req.query.limit) || 50, 200);
const rows = db const { category, type } = req.query;
.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles ORDER BY created_at DESC LIMIT ?`) let query = `SELECT ${PUBLIC_FIELDS} FROM articles`;
.all(limit); const params = [];
const conditions = [];
if (category) { conditions.push('category = ?'); params.push(category); }
if (type) { conditions.push('type = ?'); params.push(type); }
if (conditions.length) query += ' WHERE ' + conditions.join(' AND ');
query += ' ORDER BY created_at DESC LIMIT ?';
params.push(limit);
const rows = db.prepare(query).all(...params);
res.json(rows.map(rowToArticle)); res.json(rows.map(rowToArticle));
}); });
@ -148,10 +150,45 @@ app.get('/api/articles/:id', (req, res) => {
res.json(rowToArticle(row)); res.json(rowToArticle(row));
}); });
app.post('/api/uploads', authRequired, upload.single('file'), (req, res) => { app.post('/api/uploads', authRequired, upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'no_file' }); if (!req.file) return res.status(400).json({ error: 'no_file' });
const relative = `/uploads/${req.file.filename}`; try {
res.json({ url: relative, absoluteUrl: `${PUBLIC_ORIGIN}${relative}` }); const original = req.file.buffer;
const originalSize = original.length;
const isAnimated = req.file.mimetype === 'image/gif';
// cap very large images, then transcode to WebP. Step quality down until the
// output is ≤ ~25% of the original (or we hit the quality floor).
const target = Math.round(originalSize * 0.25);
let out = null;
for (const quality of [80, 70, 60, 50, 40, 32]) {
const buf = await sharp(original, { animated: isAnimated })
.rotate() // respect EXIF orientation
.resize({ width: 2000, height: 2000, fit: 'inside', withoutEnlargement: true })
.webp({ quality, effort: 4 })
.toBuffer();
out = buf;
if (buf.length <= target) break;
}
// never write a file larger than the original
const finalBuf = out.length < originalSize ? out : await sharp(original, { animated: isAnimated })
.webp({ quality: 80 }).toBuffer();
const filename = `${nanoid(12)}.webp`;
fs.writeFileSync(path.join(uploadsDir, filename), finalBuf);
const relative = `/uploads/${filename}`;
res.json({
url: relative,
absoluteUrl: `${PUBLIC_ORIGIN}${relative}`,
originalSize,
size: finalBuf.length,
reduction: Math.round((1 - finalBuf.length / originalSize) * 100),
});
} catch (err) {
console.error('[upload] webp conversion failed:', err.message);
res.status(500).json({ error: 'conversion_failed' });
}
}); });
function normalizeArticleBody(b) { function normalizeArticleBody(b) {
@ -218,6 +255,702 @@ app.delete('/api/articles/:id', authRequired, (req, res) => {
res.status(204).end(); res.status(204).end();
}); });
// ---------- events ----------
const EVENT_FIELDS = `id, title_fa, title_en, date_fa, date_en, month_fa, month_en, year, type,
location_fa, location_en, city_fa, city_en, description_fa, description_en,
registration_open, sort_order, created_at, updated_at`;
app.get('/api/events', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 100, 500);
const rows = db.prepare(`SELECT ${EVENT_FIELDS} FROM events ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
res.json(rows.map(rowToEvent));
});
app.get('/api/events/:id', (req, res) => {
const row = db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToEvent(row));
});
function normalizeEventBody(b) {
return {
title_fa: String(b.titleFa || '').trim(),
title_en: b.titleEn || null,
date_fa: b.dateFa || null, date_en: b.dateEn || null,
month_fa: b.monthFa || null, month_en: b.monthEn || null,
year: b.year || null,
type: ['conference','exhibition','seminar','international'].includes(b.type) ? b.type : null,
location_fa: b.locationFa || null, location_en: b.locationEn || null,
city_fa: b.cityFa || null, city_en: b.cityEn || null,
description_fa: b.descriptionFa || null, description_en: b.descriptionEn || null,
registration_open: b.registrationOpen ? 1 : 0,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/events', authRequired, (req, res) => {
const e = normalizeEventBody(req.body || {});
if (!e.title_fa) return res.status(400).json({ error: 'title_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO events (id,title_fa,title_en,date_fa,date_en,month_fa,month_en,year,type,
location_fa,location_en,city_fa,city_en,description_fa,description_en,registration_open,sort_order)
VALUES (@id,@title_fa,@title_en,@date_fa,@date_en,@month_fa,@month_en,@year,@type,
@location_fa,@location_en,@city_fa,@city_en,@description_fa,@description_en,@registration_open,@sort_order)
`).run({ id, ...e });
res.status(201).json(rowToEvent(db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(id)));
});
app.put('/api/events/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM events WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const e = normalizeEventBody(req.body || {});
if (!e.title_fa) return res.status(400).json({ error: 'title_fa_required' });
db.prepare(`UPDATE events SET title_fa=@title_fa,title_en=@title_en,date_fa=@date_fa,date_en=@date_en,
month_fa=@month_fa,month_en=@month_en,year=@year,type=@type,location_fa=@location_fa,location_en=@location_en,
city_fa=@city_fa,city_en=@city_en,description_fa=@description_fa,description_en=@description_en,
registration_open=@registration_open,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...e });
res.json(rowToEvent(db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/events/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM events WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- radar ----------
const RADAR_FIELDS = `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`;
app.get('/api/radar', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 500);
const rows = db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
res.json(rows.map(rowToRadarItem));
});
app.get('/api/radar/:id', (req, res) => {
const row = db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToRadarItem(row));
});
function normalizeRadarBody(b) {
return {
category: ['market','tech','commodity','geo','energy'].includes(b.category) ? b.category : null,
img: b.img || null,
author_name: b.authorName || null,
author_avatar: b.authorAvatar || null,
title_fa: String(b.titleFa || '').trim(),
date_fa: b.dateFa || null,
excerpt_fa: b.excerptFa || null,
title_en: b.titleEn || null,
date_en: b.dateEn || null,
excerpt_en: b.excerptEn || null,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/radar', authRequired, (req, res) => {
const r = normalizeRadarBody(req.body || {});
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO radar_items (id,category,img,author_name,author_avatar,
title_fa,date_fa,excerpt_fa,title_en,date_en,excerpt_en,sort_order)
VALUES (@id,@category,@img,@author_name,@author_avatar,
@title_fa,@date_fa,@excerpt_fa,@title_en,@date_en,@excerpt_en,@sort_order)
`).run({ id, ...r });
res.status(201).json(rowToRadarItem(db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(id)));
});
app.put('/api/radar/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM radar_items WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const r = normalizeRadarBody(req.body || {});
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
db.prepare(`UPDATE radar_items SET category=@category,img=@img,author_name=@author_name,
author_avatar=@author_avatar,title_fa=@title_fa,date_fa=@date_fa,excerpt_fa=@excerpt_fa,
title_en=@title_en,date_en=@date_en,excerpt_en=@excerpt_en,
sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...r });
res.json(rowToRadarItem(db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/radar/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM radar_items WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- radar pages (per-category hero + banner) ----------
const RADAR_PAGE_FIELDS = `slug, source_categories, label_fa, label_en,
latest_heading_fa, latest_heading_en, featured_title_fa, featured_title_en,
featured_date_fa, featured_date_en, featured_img,
banner_kicker_fa, banner_kicker_en, banner_title_fa, banner_title_en,
banner_desc_fa, banner_desc_en, banner_book_img,
sort_order, created_at, updated_at`;
app.get('/api/radar-pages', (req, res) => {
const rows = db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages ORDER BY sort_order ASC`).all();
res.json(rows.map(rowToRadarPage));
});
app.get('/api/radar-pages/:slug', (req, res) => {
const row = db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages WHERE slug = ?`).get(req.params.slug);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToRadarPage(row));
});
function normalizeRadarPageBody(b) {
return {
source_categories: JSON.stringify(Array.isArray(b.sourceCategories) ? b.sourceCategories : []),
label_fa: String(b.labelFa || '').trim(),
label_en: b.labelEn || null,
latest_heading_fa: b.latestHeadingFa || null,
latest_heading_en: b.latestHeadingEn || null,
featured_title_fa: b.featuredTitleFa || null,
featured_title_en: b.featuredTitleEn || null,
featured_date_fa: b.featuredDateFa || null,
featured_date_en: b.featuredDateEn || null,
featured_img: b.featuredImg || null,
banner_kicker_fa: b.bannerKickerFa || null,
banner_kicker_en: b.bannerKickerEn || null,
banner_title_fa: b.bannerTitleFa || null,
banner_title_en: b.bannerTitleEn || null,
banner_desc_fa: b.bannerDescFa || null,
banner_desc_en: b.bannerDescEn || null,
banner_book_img: b.bannerBookImg || null,
sort_order: Number(b.sortOrder) || 0,
};
}
// upsert by slug (slug is the stable key for the 5 fixed pages)
app.put('/api/radar-pages/:slug', authRequired, (req, res) => {
const slug = req.params.slug;
const r = normalizeRadarPageBody(req.body || {});
if (!r.label_fa) return res.status(400).json({ error: 'label_fa_required' });
const exists = db.prepare('SELECT slug FROM radar_pages WHERE slug = ?').get(slug);
if (exists) {
db.prepare(`UPDATE radar_pages SET source_categories=@source_categories,label_fa=@label_fa,label_en=@label_en,
latest_heading_fa=@latest_heading_fa,latest_heading_en=@latest_heading_en,
featured_title_fa=@featured_title_fa,featured_title_en=@featured_title_en,
featured_date_fa=@featured_date_fa,featured_date_en=@featured_date_en,featured_img=@featured_img,
banner_kicker_fa=@banner_kicker_fa,banner_kicker_en=@banner_kicker_en,
banner_title_fa=@banner_title_fa,banner_title_en=@banner_title_en,
banner_desc_fa=@banner_desc_fa,banner_desc_en=@banner_desc_en,banner_book_img=@banner_book_img,
sort_order=@sort_order,updated_at=datetime('now') WHERE slug=@slug
`).run({ slug, ...r });
} else {
db.prepare(`INSERT INTO radar_pages (slug,source_categories,label_fa,label_en,
latest_heading_fa,latest_heading_en,featured_title_fa,featured_title_en,
featured_date_fa,featured_date_en,featured_img,banner_kicker_fa,banner_kicker_en,
banner_title_fa,banner_title_en,banner_desc_fa,banner_desc_en,banner_book_img,sort_order)
VALUES (@slug,@source_categories,@label_fa,@label_en,@latest_heading_fa,@latest_heading_en,
@featured_title_fa,@featured_title_en,@featured_date_fa,@featured_date_en,@featured_img,
@banner_kicker_fa,@banner_kicker_en,@banner_title_fa,@banner_title_en,
@banner_desc_fa,@banner_desc_en,@banner_book_img,@sort_order)
`).run({ slug, ...r });
}
res.json(rowToRadarPage(db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages WHERE slug = ?`).get(slug)));
});
// ---------- factory reports ----------
const FACTORY_REPORT_FIELDS = `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`;
app.get('/api/factory-reports', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 200);
const rows = db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
res.json(rows.map(rowToFactoryReport));
});
app.get('/api/factory-reports/:id', (req, res) => {
const row = db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToFactoryReport(row));
});
function normalizeFactoryReportBody(b) {
return {
img: b.img || null,
tag_fa: b.tagFa || null, tag_en: b.tagEn || null,
title_fa: String(b.titleFa || '').trim(),
title_en: b.titleEn || null,
date_fa: b.dateFa || null, date_en: b.dateEn || null,
excerpt_fa: b.excerptFa || null, excerpt_en: b.excerptEn || null,
scroll_dir: ['left','right','up'].includes(b.scrollDir) ? b.scrollDir : 'up',
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/factory-reports', authRequired, (req, res) => {
const r = normalizeFactoryReportBody(req.body || {});
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT 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)
VALUES (@id,@img,@tag_fa,@tag_en,@title_fa,@title_en,@date_fa,@date_en,
@excerpt_fa,@excerpt_en,@scroll_dir,@sort_order)
`).run({ id, ...r });
res.status(201).json(rowToFactoryReport(db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(id)));
});
app.put('/api/factory-reports/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM factory_reports WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const r = normalizeFactoryReportBody(req.body || {});
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
db.prepare(`UPDATE factory_reports SET img=@img,tag_fa=@tag_fa,tag_en=@tag_en,title_fa=@title_fa,title_en=@title_en,
date_fa=@date_fa,date_en=@date_en,excerpt_fa=@excerpt_fa,excerpt_en=@excerpt_en,
scroll_dir=@scroll_dir,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...r });
res.json(rowToFactoryReport(db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/factory-reports/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM factory_reports WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- integrations ----------
const INTEGRATION_FIELDS = `id, name, icon, logo, accent, grid_col, grid_row, sort_order, created_at, updated_at`;
const INTEGRATION_ICONS = ['exchange','globe','chart','activity','database','newspaper','building','trending','coins'];
app.get('/api/integrations', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 200);
const rows = db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
res.json(rows.map(rowToIntegration));
});
app.get('/api/integrations/:id', (req, res) => {
const row = db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToIntegration(row));
});
function normalizeIntegrationBody(b) {
return {
name: String(b.name || '').trim(),
icon: INTEGRATION_ICONS.includes(b.icon) ? b.icon : 'globe',
logo: b.logo || null,
accent: b.accent || null,
grid_col: Number(b.gridCol) || 1,
grid_row: Number(b.gridRow) || 1,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/integrations', authRequired, (req, res) => {
const it = normalizeIntegrationBody(req.body || {});
if (!it.name) return res.status(400).json({ error: 'name_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO integrations (id,name,icon,logo,accent,grid_col,grid_row,sort_order)
VALUES (@id,@name,@icon,@logo,@accent,@grid_col,@grid_row,@sort_order)
`).run({ id, ...it });
res.status(201).json(rowToIntegration(db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(id)));
});
app.put('/api/integrations/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM integrations WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const it = normalizeIntegrationBody(req.body || {});
if (!it.name) return res.status(400).json({ error: 'name_required' });
db.prepare(`UPDATE integrations SET name=@name,icon=@icon,logo=@logo,accent=@accent,
grid_col=@grid_col,grid_row=@grid_row,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...it });
res.json(rowToIntegration(db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/integrations/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM integrations WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- institute stats ----------
const INSTITUTE_STATS_FIELDS = `id,label_fa,label_en,value,suffix_fa,sort_order,created_at,updated_at`;
app.get('/api/institute-stats', (_req, res) => {
res.json(db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats ORDER BY sort_order ASC`).all().map(rowToInstituteStat));
});
app.get('/api/institute-stats/:id', (req, res) => {
const row = db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToInstituteStat(row));
});
function normalizeInstituteStatBody(b) {
return {
label_fa: String(b.labelFa || '').trim(),
label_en: b.labelEn || null,
value: Number(b.value) || 0,
suffix_fa: b.suffixFa || null,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/institute-stats', authRequired, (req, res) => {
const s = normalizeInstituteStatBody(req.body || {});
if (!s.label_fa) return res.status(400).json({ error: 'label_fa_required' });
const id = (req.body && req.body.id ? String(req.body.id).trim() : '') || nanoid(14);
db.prepare(`INSERT INTO institute_stats (id,label_fa,label_en,value,suffix_fa,sort_order)
VALUES (@id,@label_fa,@label_en,@value,@suffix_fa,@sort_order)
`).run({ id, ...s });
res.status(201).json(rowToInstituteStat(db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(id)));
});
app.put('/api/institute-stats/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM institute_stats WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const s = normalizeInstituteStatBody(req.body || {});
if (!s.label_fa) return res.status(400).json({ error: 'label_fa_required' });
db.prepare(`UPDATE institute_stats SET label_fa=@label_fa,label_en=@label_en,value=@value,
suffix_fa=@suffix_fa,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...s });
res.json(rowToInstituteStat(db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/institute-stats/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM institute_stats WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- market prices ----------
const MARKET_PRICE_FIELDS = `id,name,value,unit,change,change_percent,trend,sort_order,created_at,updated_at`;
app.get('/api/market-prices', (_req, res) => {
res.json(db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices ORDER BY sort_order ASC`).all().map(rowToMarketPrice));
});
app.get('/api/market-prices/:id', (req, res) => {
const row = db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToMarketPrice(row));
});
function normalizeMarketPriceBody(b) {
return {
name: String(b.name || '').trim(),
value: Number(b.value) || 0,
unit: b.unit || null,
change: Number(b.change) || 0,
change_percent: Number(b.changePercent) || 0,
trend: ['up','down','flat'].includes(b.trend) ? b.trend : 'flat',
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/market-prices', authRequired, (req, res) => {
const p = normalizeMarketPriceBody(req.body || {});
if (!p.name) return res.status(400).json({ error: 'name_required' });
const id = (req.body && req.body.id ? String(req.body.id).trim() : '') || nanoid(14);
db.prepare(`INSERT INTO market_prices (id,name,value,unit,change,change_percent,trend,sort_order)
VALUES (@id,@name,@value,@unit,@change,@change_percent,@trend,@sort_order)
`).run({ id, ...p });
res.status(201).json(rowToMarketPrice(db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(id)));
});
app.put('/api/market-prices/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM market_prices WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const p = normalizeMarketPriceBody(req.body || {});
if (!p.name) return res.status(400).json({ error: 'name_required' });
db.prepare(`UPDATE market_prices SET name=@name,value=@value,unit=@unit,change=@change,
change_percent=@change_percent,trend=@trend,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...p });
res.json(rowToMarketPrice(db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/market-prices/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM market_prices WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- market chart points (history | export | comparison) ----------
const CHART_POINT_FIELDS = `id,series,label,value,meta,sort_order,created_at,updated_at`;
const ALLOWED_SERIES = new Set(['history','export','comparison']);
app.get('/api/market-chart-points', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 200, 1000);
const series = ALLOWED_SERIES.has(req.query.series) ? req.query.series : null;
let q = `SELECT ${CHART_POINT_FIELDS} FROM market_chart_points`;
const params = [];
if (series) { q += ' WHERE series = ?'; params.push(series); }
q += ' ORDER BY series ASC, sort_order ASC LIMIT ?';
params.push(limit);
res.json(db.prepare(q).all(...params).map(rowToMarketChartPoint));
});
app.get('/api/market-chart-points/:id', (req, res) => {
const row = db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToMarketChartPoint(row));
});
function normalizeChartPointBody(b) {
let meta = null;
if (b.meta != null && b.meta !== '') {
meta = typeof b.meta === 'string' ? b.meta : JSON.stringify(b.meta);
}
return {
series: ALLOWED_SERIES.has(b.series) ? b.series : null,
label: String(b.label || '').trim(),
value: Number(b.value) || 0,
meta,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/market-chart-points', authRequired, (req, res) => {
const c = normalizeChartPointBody(req.body || {});
if (!c.series) return res.status(400).json({ error: 'series_required' });
if (!c.label) return res.status(400).json({ error: 'label_required' });
const id = (req.body && req.body.id ? String(req.body.id).trim() : '') || nanoid(14);
db.prepare(`INSERT INTO market_chart_points (id,series,label,value,meta,sort_order)
VALUES (@id,@series,@label,@value,@meta,@sort_order)
`).run({ id, ...c });
res.status(201).json(rowToMarketChartPoint(db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(id)));
});
app.put('/api/market-chart-points/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM market_chart_points WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const c = normalizeChartPointBody(req.body || {});
if (!c.series) return res.status(400).json({ error: 'series_required' });
if (!c.label) return res.status(400).json({ error: 'label_required' });
db.prepare(`UPDATE market_chart_points SET series=@series,label=@label,value=@value,meta=@meta,
sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...c });
res.json(rowToMarketChartPoint(db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/market-chart-points/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM market_chart_points WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- vision items (About page) ----------
const VISION_FIELDS = `id, icon, title_fa, title_en, desc_fa, desc_en, sort_order, created_at, updated_at`;
app.get('/api/vision-items', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 100, 500);
const rows = db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
res.json(rows.map(rowToVisionItem));
});
app.get('/api/vision-items/:id', (req, res) => {
const row = db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToVisionItem(row));
});
function normalizeVisionBody(b) {
return {
icon: b.icon || null,
title_fa: String(b.titleFa || '').trim(),
title_en: b.titleEn || null,
desc_fa: b.descFa || null,
desc_en: b.descEn || null,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/vision-items', authRequired, (req, res) => {
const v = normalizeVisionBody(req.body || {});
if (!v.title_fa) return res.status(400).json({ error: 'title_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO vision_items (id,icon,title_fa,title_en,desc_fa,desc_en,sort_order)
VALUES (@id,@icon,@title_fa,@title_en,@desc_fa,@desc_en,@sort_order)
`).run({ id, ...v });
res.status(201).json(rowToVisionItem(db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(id)));
});
app.put('/api/vision-items/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM vision_items WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const v = normalizeVisionBody(req.body || {});
if (!v.title_fa) return res.status(400).json({ error: 'title_fa_required' });
db.prepare(`UPDATE vision_items SET icon=@icon,title_fa=@title_fa,title_en=@title_en,
desc_fa=@desc_fa,desc_en=@desc_en,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...v });
res.json(rowToVisionItem(db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/vision-items/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM vision_items WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- advisory board (About page) ----------
const ADVISORY_FIELDS = `id, name_fa, name_en, role_fa, role_en, sort_order, created_at, updated_at`;
app.get('/api/advisory-board', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 100, 500);
const rows = db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
res.json(rows.map(rowToAdvisoryMember));
});
app.get('/api/advisory-board/:id', (req, res) => {
const row = db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToAdvisoryMember(row));
});
function normalizeAdvisoryBody(b) {
return {
name_fa: String(b.nameFa || '').trim(),
name_en: b.nameEn || null,
role_fa: b.roleFa || null,
role_en: b.roleEn || null,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/advisory-board', authRequired, (req, res) => {
const m = normalizeAdvisoryBody(req.body || {});
if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO advisory_board (id,name_fa,name_en,role_fa,role_en,sort_order)
VALUES (@id,@name_fa,@name_en,@role_fa,@role_en,@sort_order)
`).run({ id, ...m });
res.status(201).json(rowToAdvisoryMember(db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(id)));
});
app.put('/api/advisory-board/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM advisory_board WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const m = normalizeAdvisoryBody(req.body || {});
if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' });
db.prepare(`UPDATE advisory_board SET name_fa=@name_fa,name_en=@name_en,role_fa=@role_fa,
role_en=@role_en,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...m });
res.json(rowToAdvisoryMember(db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/advisory-board/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM advisory_board WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- team members & experts ----------
const TEAM_FIELDS = `id,name_fa,name_en,role_fa,role_en,bio_fa,bio_en,expertise,email,initial,photo,
report_count,is_expert,expert_room_fa,expert_room_en,telegram,linkedin,twitter,website,sort_order,created_at,updated_at`;
app.get('/api/team', (req, res) => {
const isExpert = req.query.expert === '1' ? 1 : req.query.expert === '0' ? 0 : null;
let query = `SELECT ${TEAM_FIELDS} FROM team_members`;
if (isExpert !== null) query += ` WHERE is_expert = ${isExpert}`;
query += ' ORDER BY sort_order ASC, created_at DESC';
res.json(db.prepare(query).all().map(rowToTeamMember));
});
app.get('/api/team/:id', (req, res) => {
const row = db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToTeamMember(row));
});
function normalizeTeamBody(b) {
return {
name_fa: String(b.nameFa || '').trim(),
name_en: b.nameEn || null,
role_fa: b.roleFa || null, role_en: b.roleEn || null,
bio_fa: b.bioFa || null, bio_en: b.bioEn || null,
expertise: JSON.stringify(Array.isArray(b.expertise) ? b.expertise : []),
email: b.email || null, initial: b.initial || null, photo: b.photo || null,
report_count: Number(b.reportCount) || 0,
is_expert: b.isExpert ? 1 : 0,
expert_room_fa: b.expertRoomFa || null, expert_room_en: b.expertRoomEn || null,
telegram: b.telegram || null, linkedin: b.linkedin || null,
twitter: b.twitter || null, website: b.website || null,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/team', authRequired, (req, res) => {
const m = normalizeTeamBody(req.body || {});
if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO team_members (id,name_fa,name_en,role_fa,role_en,bio_fa,bio_en,expertise,email,initial,photo,
report_count,is_expert,expert_room_fa,expert_room_en,telegram,linkedin,twitter,website,sort_order)
VALUES (@id,@name_fa,@name_en,@role_fa,@role_en,@bio_fa,@bio_en,@expertise,@email,@initial,@photo,
@report_count,@is_expert,@expert_room_fa,@expert_room_en,@telegram,@linkedin,@twitter,@website,@sort_order)
`).run({ id, ...m });
res.status(201).json(rowToTeamMember(db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(id)));
});
app.put('/api/team/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM team_members WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const m = normalizeTeamBody(req.body || {});
if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' });
db.prepare(`UPDATE team_members SET name_fa=@name_fa,name_en=@name_en,role_fa=@role_fa,role_en=@role_en,
bio_fa=@bio_fa,bio_en=@bio_en,expertise=@expertise,email=@email,initial=@initial,photo=@photo,
report_count=@report_count,is_expert=@is_expert,expert_room_fa=@expert_room_fa,expert_room_en=@expert_room_en,
telegram=@telegram,linkedin=@linkedin,twitter=@twitter,website=@website,
sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...m });
res.json(rowToTeamMember(db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/team/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM team_members WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- plans ----------
const PLAN_FIELDS = `id,name_fa,name_en,price,period_fa,period_en,features,badge_fa,badge_en,is_featured,cta_fa,cta_en,sort_order,created_at,updated_at`;
app.get('/api/plans', (req, res) => {
res.json(db.prepare(`SELECT ${PLAN_FIELDS} FROM plans ORDER BY sort_order ASC`).all().map(rowToPlan));
});
app.get('/api/plans/:id', (req, res) => {
const row = db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToPlan(row));
});
function normalizePlanBody(b) {
return {
name_fa: String(b.nameFa || '').trim(),
name_en: b.nameEn || null,
price: Number(b.price) || 0,
period_fa: b.periodFa || null, period_en: b.periodEn || null,
features: JSON.stringify(Array.isArray(b.features) ? b.features : []),
badge_fa: b.badgeFa || null, badge_en: b.badgeEn || null,
is_featured: b.isFeatured ? 1 : 0,
cta_fa: b.ctaFa || null, cta_en: b.ctaEn || null,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/plans', authRequired, (req, res) => {
const p = normalizePlanBody(req.body || {});
if (!p.name_fa) return res.status(400).json({ error: 'name_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO plans (id,name_fa,name_en,price,period_fa,period_en,features,badge_fa,badge_en,is_featured,cta_fa,cta_en,sort_order)
VALUES (@id,@name_fa,@name_en,@price,@period_fa,@period_en,@features,@badge_fa,@badge_en,@is_featured,@cta_fa,@cta_en,@sort_order)
`).run({ id, ...p });
res.status(201).json(rowToPlan(db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(id)));
});
app.put('/api/plans/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM plans WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const p = normalizePlanBody(req.body || {});
if (!p.name_fa) return res.status(400).json({ error: 'name_fa_required' });
db.prepare(`UPDATE plans SET name_fa=@name_fa,name_en=@name_en,price=@price,period_fa=@period_fa,period_en=@period_en,
features=@features,badge_fa=@badge_fa,badge_en=@badge_en,is_featured=@is_featured,cta_fa=@cta_fa,cta_en=@cta_en,
sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...p });
res.json(rowToPlan(db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/plans/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM plans WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- risk signals ---------- // ---------- risk signals ----------
const RISK_FIELDS = `id, quote, name, date, level, sort_order, created_at, updated_at`; const RISK_FIELDS = `id, quote, name, date, level, sort_order, created_at, updated_at`;
@ -349,6 +1082,68 @@ app.post('/api/prices/refresh', authRequired, async (_req, res) => {
} }
}); });
// ---------- banners ----------
const BANNER_FIELDS = `id,overline_fa,overline_en,title_fa,title_en,subtitle_fa,subtitle_en,cta_label_fa,cta_label_en,cta_url,image,position,active,sort_order,created_at,updated_at`;
app.get('/api/banners', (req, res) => {
const onlyActive = req.query.active === '1';
let q = `SELECT ${BANNER_FIELDS} FROM banners`;
if (onlyActive) q += ' WHERE active = 1';
q += ' ORDER BY sort_order ASC, created_at DESC';
res.json(db.prepare(q).all().map(rowToBanner));
});
app.get('/api/banners/:id', (req, res) => {
const row = db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToBanner(row));
});
function normalizeBannerBody(b) {
return {
overline_fa: b.overlineFa || null,
overline_en: b.overlineEn || null,
title_fa: String(b.titleFa || '').trim(),
title_en: b.titleEn || null,
subtitle_fa: b.subtitleFa || null,
subtitle_en: b.subtitleEn || null,
cta_label_fa: b.ctaLabelFa || null,
cta_label_en: b.ctaLabelEn || null,
cta_url: b.ctaUrl || null,
image: b.image || null,
position: b.position || 'hero',
active: b.active !== false ? 1 : 0,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/banners', authRequired, (req, res) => {
const bn = normalizeBannerBody(req.body || {});
if (!bn.title_fa) return res.status(400).json({ error: 'title_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO banners (id,overline_fa,overline_en,title_fa,title_en,subtitle_fa,subtitle_en,cta_label_fa,cta_label_en,cta_url,image,position,active,sort_order)
VALUES (@id,@overline_fa,@overline_en,@title_fa,@title_en,@subtitle_fa,@subtitle_en,@cta_label_fa,@cta_label_en,@cta_url,@image,@position,@active,@sort_order)
`).run({ id, ...bn });
res.status(201).json(rowToBanner(db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(id)));
});
app.put('/api/banners/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM banners WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const bn = normalizeBannerBody(req.body || {});
if (!bn.title_fa) return res.status(400).json({ error: 'title_fa_required' });
db.prepare(`UPDATE banners SET overline_fa=@overline_fa,overline_en=@overline_en,title_fa=@title_fa,title_en=@title_en,subtitle_fa=@subtitle_fa,subtitle_en=@subtitle_en,
cta_label_fa=@cta_label_fa,cta_label_en=@cta_label_en,cta_url=@cta_url,image=@image,
position=@position,active=@active,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...bn });
res.json(rowToBanner(db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/banners/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM banners WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
app.get('/api/health', (_req, res) => res.json({ ok: true })); app.get('/api/health', (_req, res) => res.json({ ok: true }));
/* ── Contact form → email ─────────────────────────────── */ /* ── Contact form → email ─────────────────────────────── */

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

BIN
public/hero2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
public/hero3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@ -1,5 +1,5 @@
import { useState } from 'react' import { useState, type FC } from 'react'
import { Send, Share2, AtSign, MessageCircle, MapPin, Phone, Mail, ChevronsUp } from 'lucide-react' import { MapPin, Phone, Mail, ChevronsUp } from 'lucide-react'
function scrollToTop() { function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
@ -39,15 +39,59 @@ function FooterCurve() {
const NAVY = '#032340' const NAVY = '#032340'
const GOLD = '#CD9E53' const GOLD = '#CD9E53'
/* brand glyphs — lucide has no brand icons, so inline SVGs; fill follows currentColor (= NAVY) */
type IconProps = { size?: number }
const TelegramIcon: FC<IconProps> = ({ size = 18 }) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" aria-hidden="true">
<path d="M9.78 18.65l.28-4.23 7.68-6.92c.34-.31-.07-.46-.52-.19L7.74 13.3 3.64 12c-.88-.25-.89-.86.2-1.3l15.97-6.16c.73-.33 1.43.18 1.15 1.3l-2.72 12.81c-.19.91-.74 1.13-1.5.71L12.6 16.3l-1.99 1.93c-.23.23-.42.42-.83.42z" />
</svg>
)
const XIcon: FC<IconProps> = ({ size = 16 }) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" aria-hidden="true">
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z" />
</svg>
)
const LinkedinIcon: FC<IconProps> = ({ size = 18 }) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" aria-hidden="true">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
)
const AparatIcon: FC<IconProps> = ({ size = 19 }) => (
<svg viewBox="0 0 100 100" width={size} height={size} fill="currentColor" aria-hidden="true">
<defs>
<mask id="aparat-mask">
{/* show everything white by default */}
<rect width="100" height="100" fill="white" />
{/* cut gap between outer petals and inner disc */}
<circle cx="50" cy="50" r="44" fill="black" />
{/* restore the disc */}
<circle cx="50" cy="50" r="41" fill="white" />
{/* punch 4 holes + center in disc */}
<circle cx="34" cy="34" r="10" fill="black" />
<circle cx="66" cy="34" r="10" fill="black" />
<circle cx="34" cy="66" r="10" fill="black" />
<circle cx="66" cy="66" r="10" fill="black" />
<circle cx="50" cy="50" r="5" fill="black" />
</mask>
</defs>
{/* full square → mask reveals: 4 corner petals + reel with holes */}
<rect width="100" height="100" rx="22" mask="url(#aparat-mask)" />
</svg>
)
/* round social buttons (gold filled, like the screenshot) */ /* round social buttons (gold filled, like the screenshot) */
const SOCIALS = [ const SOCIALS: { Icon: FC<IconProps>; name: string; href: string }[] = [
{ Icon: Send, name: 'تلگرام', href: '#' }, { Icon: AparatIcon, name: 'آپارات', href: '#' },
{ Icon: Share2, name: 'لینکدین', href: '#' }, { Icon: XIcon, name: 'X', href: '#' },
{ Icon: AtSign, name: 'توییتر', href: '#' }, { Icon: LinkedinIcon, name: 'لینکداین', href: '#' },
{ Icon: MessageCircle, name: 'آپارات', href: '#' }, { Icon: TelegramIcon, name: 'تلگرام', href: '#' },
] ]
function SocialIcon({ Icon, name, href }: { Icon: typeof Send; name: string; href: string }) { function SocialIcon({ Icon, name, href }: { Icon: FC<IconProps>; name: string; href: string }) {
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
return ( return (
<a <a
@ -60,7 +104,7 @@ function SocialIcon({ Icon, name, href }: { Icon: typeof Send; name: string; hre
transform: hovered ? 'translateY(-3px)' : 'none', opacity: hovered ? 0.9 : 1, transform: hovered ? 'translateY(-3px)' : 'none', opacity: hovered ? 0.9 : 1,
}} }}
> >
<Icon size={19} strokeWidth={2} /> <Icon />
</a> </a>
) )
} }
@ -69,7 +113,7 @@ function SocialIcon({ Icon, name, href }: { Icon: typeof Send; name: string; hre
const NAV_LINKS = [ const NAV_LINKS = [
{ label: 'رادار آینده', href: '/radar' }, { label: 'رادار آینده', href: '/radar' },
{ label: 'آمار و داده', href: '#' }, { label: 'آمار و داده', href: '#' },
{ label: 'چندرسانه‌ای', href: '#' }, { label: 'رسانه و رویداد', href: '#' },
{ label: 'در یک نگاه', href: '/at-a-glance' }, { label: 'در یک نگاه', href: '/at-a-glance' },
{ label: 'شبکه خبرگان', href: '#' }, { label: 'شبکه خبرگان', href: '#' },
{ label: 'درباره ما', href: '#' }, { label: 'درباره ما', href: '#' },
@ -96,7 +140,7 @@ function NavItem({ href, label }: { href: string; label: string }) {
const CONTACT = [ const CONTACT = [
{ Icon: MapPin, label: 'نشانی:', value: 'اصفهان، خیابان سعادت‌آباد' }, { Icon: MapPin, label: 'نشانی:', value: 'اصفهان، خیابان سعادت‌آباد' },
{ Icon: Phone, label: 'شماره تماس:', value: '۰۳۱ - ۳۸۸۸۴۵۳۹', ltr: true }, { Icon: Phone, label: 'شماره تماس:', value: '۰۳۱ - ۳۸۸۸۴۵۳۹', ltr: true },
{ Icon: Mail, label: 'پست الکترونیک:', value: 'info@steelfutures.com', ltr: true, accent: true }, { Icon: Mail, label: 'پست الکترونیک:', value: 'info@steelforesight.com', ltr: true, accent: true },
] ]
function NewsletterBox() { function NewsletterBox() {
@ -201,8 +245,8 @@ export function Footer() {
<div style={{ background: GOLD, color: NAVY }}> <div style={{ background: GOLD, color: NAVY }}>
<div style={{ maxWidth: 1280, margin: '0 auto', padding: '14px 48px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', fontSize: 12, fontWeight: 700 }} <div style={{ maxWidth: 1280, margin: '0 auto', padding: '14px 48px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', fontSize: 12, fontWeight: 700 }}
className="max-md:!px-6 max-md:!justify-center max-md:!text-center"> className="max-md:!px-6 max-md:!justify-center max-md:!text-center">
<span>تمامی حقوق مادی و معنوی این وبسایت برای شرکت فولاد مبارکه اصفهان محفوظ میباشد.</span> <span>تمامی حقوق مادی و معنوی این وبسایت متعلق به شرکت فولاد مبارکه میباشد.</span>
<span style={{ opacity: 0.8 }}>طراحی شده توسط شرکت دانشبنیان نواندیشان آتی نگار فرتاک</span> <span style={{ opacity: 0.8 }}>طراح و پشتیبان؛ شرکت دانشبنیان نواندیشان آتی نگار فرتاک</span>
</div> </div>
</div> </div>
</footer> </footer>

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import type { CSSProperties } from 'react' import type { CSSProperties } from 'react'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { Search, Menu, X, ChevronDown, Radar, Leaf, Globe, Cpu, BarChart3, Video, Mic, CalendarDays, type LucideIcon } from 'lucide-react' import { Search, Menu, X, ChevronDown, Radar, Leaf, Globe, Cpu, BarChart3, Video, Mic, CalendarDays, Users, Eye, Phone, type LucideIcon } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { useLang } from '@/context/LangContext' import { useLang } from '@/context/LangContext'
@ -21,11 +21,11 @@ type Section = { fa: string; en: string; to: string; Icon: LucideIcon }
/* ─── dropdown sections under رادار آینده ─── */ /* ─── dropdown sections under رادار آینده ─── */
const SECTIONS: Section[] = [ const SECTIONS: Section[] = [
{ fa: 'آینده‌پژوهی و رصد هوشمند', en: 'Foresight & Intelligence', to: '/radar', Icon: Radar }, { fa: 'آینده‌پژوهی و رصد هوشمند', en: 'Foresight & Intelligence', to: '/radar/foresight', Icon: Radar },
{ fa: 'پایداری و فولاد سبز', en: 'Sustainability & Green Steel', to: '/sustainability', Icon: Leaf }, { fa: 'پایداری و فولاد سبز', en: 'Sustainability & Green Steel', to: '/radar/sustainability', Icon: Leaf },
{ fa: 'ژئوپلیتیک و اقتصاد جهانی', en: 'Geopolitics & Global Economy', to: '/geopolitics', Icon: Globe }, { fa: 'ژئوپلیتیک و اقتصاد جهانی', en: 'Geopolitics & Global Economy', to: '/radar/geopolitics', Icon: Globe },
{ fa: 'فناوری و نوآوری', en: 'Technology & Innovation', to: '/technology', Icon: Cpu }, { fa: 'فناوری و نوآوری', en: 'Technology & Innovation', to: '/radar/technology', Icon: Cpu },
{ fa: 'بازار و زنجیره فولاد', en: 'Market & Steel Chain', to: '/pulse', Icon: BarChart3 }, { fa: 'بازار و زنجیره فولاد', en: 'Market & Steel Chain', to: '/radar/market', Icon: BarChart3 },
] ]
/* ─── dropdown sections under رسانه و رویداد ─── */ /* ─── dropdown sections under رسانه و رویداد ─── */
@ -35,15 +35,22 @@ const MEDIA: Section[] = [
{ fa: 'رویدادها', en: 'Events', to: '#', Icon: CalendarDays }, { fa: 'رویدادها', en: 'Events', to: '#', Icon: CalendarDays },
] ]
/* ─── dropdown sections under درباره ما ─── */
const ABOUT: Section[] = [
{ fa: 'تیم ما', en: 'Our Team', to: '/about#team', Icon: Users },
{ fa: 'چشم‌انداز ما', en: 'Our Vision', to: '/about#vision', Icon: Eye },
{ fa: 'تماس با ما', en: 'Contact Us', to: '/contact', Icon: Phone },
]
/* ─── top-level nav items ─── */ /* ─── top-level nav items ─── */
type TopNav = { fa: string; en: string; to?: string; dropdown?: 'radar' | 'media' } type TopNav = { fa: string; en: string; to?: string; dropdown?: 'radar' | 'media' | 'about' }
const TOP_NAV: TopNav[] = [ const TOP_NAV: TopNav[] = [
{ fa: 'رادار آینده', en: 'Radar', dropdown: 'radar' }, { fa: 'رادار آینده', en: 'Radar', dropdown: 'radar' },
{ fa: 'نبض صنعت', en: 'Industry Pulse', to: '/pulse' }, { fa: 'آمار و داده', en: 'Stats & Data', to: '/pulse' },
{ fa: 'رسانه و رویداد', en: 'Media & Events', dropdown: 'media' },
{ fa: 'در یک نگاه', en: 'At a Glance', to: '/at-a-glance' }, { fa: 'در یک نگاه', en: 'At a Glance', to: '/at-a-glance' },
{ fa: 'رسانه و رویداد', en: 'Media & Events', dropdown: 'media' },
{ fa: 'شبکه خبرگان', en: 'Expert Network', to: '/experts' }, { fa: 'شبکه خبرگان', en: 'Expert Network', to: '/experts' },
{ fa: 'درباره ما', en: 'About', to: '#' }, { fa: 'درباره ما', en: 'About', dropdown: 'about' },
] ]
const META_LEFT: { fa: string; en: string }[] = [ const META_LEFT: { fa: string; en: string }[] = [
@ -107,19 +114,19 @@ function NavDropdown({ lang, label, sections }: { lang: 'fa' | 'en'; label: stri
key={i} key={i}
to={item.to} to={item.to}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
style={({ isActive }: { isActive: boolean }): CSSProperties => ({ style={(): CSSProperties => ({
display: 'flex', alignItems: 'center', justifyContent: 'space-between', display: 'flex', alignItems: 'center', gap: 12,
padding: '15px 20px', textDecoration: 'none', padding: '15px 20px', textDecoration: 'none',
color: isActive ? T.red : '#3d3d3d', color: '#3d3d3d',
background: isActive ? 'rgba(155,28,28,0.04)' : 'transparent', background: 'transparent',
borderBottom: i < sections.length - 1 ? '1px solid rgba(7,29,73,0.07)' : 'none', borderBottom: i < sections.length - 1 ? '1px solid rgba(7,29,73,0.07)' : 'none',
transition: 'background 100ms', fontSize: 14, fontWeight: 600, transition: 'background 100ms', fontSize: 14, fontWeight: 600,
})} })}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = 'rgba(7,29,73,0.03)' }} onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = 'rgba(7,29,73,0.03)' }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = 'transparent' }} onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = 'transparent' }}
> >
<item.Icon size={20} color="#CD9E53" strokeWidth={1.5} style={{ flexShrink: 0 }} />
<span>{lang === 'fa' ? item.fa : item.en}</span> <span>{lang === 'fa' ? item.fa : item.en}</span>
<item.Icon size={28} color="#CD9E53" strokeWidth={1.5} style={{ flexShrink: 0 }} />
</NavLink> </NavLink>
))} ))}
</motion.div> </motion.div>
@ -250,7 +257,8 @@ export default function Header() {
> >
{TOP_NAV.map((item) => {TOP_NAV.map((item) =>
item.dropdown item.dropdown
? <NavDropdown key={item.fa} lang={lang} label={lang === 'fa' ? item.fa : item.en} sections={item.dropdown === 'radar' ? SECTIONS : MEDIA} /> ? <NavDropdown key={item.fa} lang={lang} label={lang === 'fa' ? item.fa : item.en}
sections={item.dropdown === 'radar' ? SECTIONS : item.dropdown === 'about' ? ABOUT : MEDIA} />
: <PlainNavItem key={item.fa} label={lang === 'fa' ? item.fa : item.en} to={item.to!} /> : <PlainNavItem key={item.fa} label={lang === 'fa' ? item.fa : item.en} to={item.to!} />
)} )}
</nav> </nav>
@ -279,16 +287,19 @@ export default function Header() {
</OutlineButton> </OutlineButton>
<button <button
onClick={toggleLang} onClick={toggleLang}
aria-label={lang === 'fa' ? 'Switch to English' : 'تغییر به فارسی'}
style={{ style={{
height: 34, padding: '0 12px', fontSize: 12, fontWeight: 700, height: 34, width: 40, padding: 0, fontSize: 20, lineHeight: 1,
background: 'transparent', border: `1px solid ${T.ruleThin}`, background: 'transparent', border: `1px solid ${T.ruleThin}`,
color: T.ink, cursor: 'pointer', fontFamily: 'inherit', cursor: 'pointer', fontFamily: 'inherit',
letterSpacing: '0.5px', transition: 'border-color 120ms', display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'border-color 120ms',
}} }}
onMouseEnter={e => { e.currentTarget.style.borderColor = T.ink }} onMouseEnter={e => { e.currentTarget.style.borderColor = T.ink }}
onMouseLeave={e => { e.currentTarget.style.borderColor = T.ruleThin }} onMouseLeave={e => { e.currentTarget.style.borderColor = T.ruleThin }}
> >
{lang === 'fa' ? 'EN' : 'FA'} {/* show the flag of the language you'll switch TO */}
{lang === 'fa' ? '🇺🇸' : '🇮🇷'}
</button> </button>
</> </>
)} )}

View File

@ -12,6 +12,8 @@ import Special from '@/pages/Special/Special'
import Risks from '@/pages/Risks/Risks' import Risks from '@/pages/Risks/Risks'
import Scanner from '@/pages/Scanner/Scanner' import Scanner from '@/pages/Scanner/Scanner'
import Radar from '@/pages/Radar/Radar' import Radar from '@/pages/Radar/Radar'
import RadarCategory from '@/pages/RadarCategory/RadarCategory'
import PostDetail from '@/pages/PostDetail/PostDetail'
import Technology from '@/pages/Technology/Technology' import Technology from '@/pages/Technology/Technology'
import Pulse from '@/pages/Pulse/Pulse' import Pulse from '@/pages/Pulse/Pulse'
import Membership from '@/pages/Membership/Membership' import Membership from '@/pages/Membership/Membership'
@ -19,6 +21,8 @@ import Sustainability from '@/pages/Sustainability/Sustainability'
import Geopolitics from '@/pages/Geopolitics/Geopolitics' import Geopolitics from '@/pages/Geopolitics/Geopolitics'
import AtAGlance from '@/pages/AtAGlance/AtAGlance' import AtAGlance from '@/pages/AtAGlance/AtAGlance'
import Experts from '@/pages/Experts/Experts' import Experts from '@/pages/Experts/Experts'
import Contact from '@/pages/Contact/Contact'
import About from '@/pages/About/About'
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
@ -37,6 +41,8 @@ export const router = createBrowserRouter([
{ path: 'risks', element: <Risks /> }, { path: 'risks', element: <Risks /> },
{ path: 'scanner', element: <Scanner /> }, { path: 'scanner', element: <Scanner /> },
{ path: 'radar', element: <Radar /> }, { path: 'radar', element: <Radar /> },
{ path: 'radar/:category', element: <RadarCategory /> },
{ path: 'posts/:id', element: <PostDetail /> },
{ path: 'technology', element: <Technology /> }, { path: 'technology', element: <Technology /> },
{ path: 'pulse', element: <Pulse /> }, { path: 'pulse', element: <Pulse /> },
{ path: 'sustainability', element: <Sustainability /> }, { path: 'sustainability', element: <Sustainability /> },
@ -44,6 +50,8 @@ export const router = createBrowserRouter([
{ path: 'membership', element: <Membership /> }, { path: 'membership', element: <Membership /> },
{ path: 'at-a-glance', element: <AtAGlance /> }, { path: 'at-a-glance', element: <AtAGlance /> },
{ path: 'experts', element: <Experts /> }, { path: 'experts', element: <Experts /> },
{ path: 'contact', element: <Contact /> },
{ path: 'about', element: <About /> },
], ],
}, },
]) ])

View File

@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { ChevronDown } from 'lucide-react' import { ChevronDown, ChevronRight } from 'lucide-react'
import { Link } from 'react-router-dom'
const NAVY = '#032340' const NAVY = '#032340'
const GOLD = '#CD9E53' const GOLD = '#CD9E53'
@ -30,12 +31,23 @@ export default function StrategicEvents() {
return ( return (
<div style={{ direction: 'rtl' }}> <div style={{ direction: 'rtl' }}>
{/* heading */} {/* heading */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 28, justifyContent: 'flex-start' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 28 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 4, height: 30, background: GOLD, borderRadius: 2 }} /> <div style={{ width: 4, height: 30, background: GOLD, borderRadius: 2 }} />
<h2 style={{ fontSize: 'clamp(20px,2.2vw,28px)', fontWeight: 900, color: NAVY, letterSpacing: '-0.5px', margin: 0 }}> <h2 style={{ fontSize: 'clamp(20px,2.2vw,28px)', fontWeight: 900, color: NAVY, letterSpacing: '-0.5px', margin: 0 }}>
دیدهبان رویدادهای راهبردی دیدهبان رویدادها
</h2> </h2>
</div> </div>
<Link to="/events" style={{
display: 'inline-flex', alignItems: 'center', gap: 6, direction: 'ltr',
fontSize: 13, fontWeight: 700, color: GOLD, textDecoration: 'none', flexShrink: 0,
}}>
<div style={{ width: 24, height: 24, borderRadius: '50%', border: `1px solid ${GOLD}`, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ChevronRight size={14} strokeWidth={2.5} />
</div>
<span>مشاهده همه</span>
</Link>
</div>
{/* accordion list */} {/* accordion list */}
<div> <div>

View File

@ -32,6 +32,7 @@ export interface BentoGridItemProps {
title: string title: string
description?: string description?: string
header?: ReactNode /* visual block above content */ header?: ReactNode /* visual block above content */
footer?: ReactNode /* slot below description */
icon?: ReactNode icon?: ReactNode
tag?: string tag?: string
tagColor?: string /* CSS color value */ tagColor?: string /* CSS color value */
@ -45,6 +46,7 @@ export function BentoGridItem({
title, title,
description, description,
header, header,
footer,
icon, icon,
tag, tag,
tagColor = 'var(--red)', tagColor = 'var(--red)',
@ -125,6 +127,8 @@ export function BentoGridItem({
{description} {description}
</p> </p>
)} )}
{footer && <div style={{ marginTop: 'auto', paddingTop: 16 }}>{footer}</div>}
</div> </div>
</div> </div>
) )

View File

@ -0,0 +1,111 @@
import * as React from 'react'
import { motion, useScroll, useTransform, type MotionValue } from 'framer-motion'
import { useRef } from 'react'
export interface MagicTextProps {
text: string
/** dir of the text — affects word flow */
dir?: 'rtl' | 'ltr'
/** inline style applied to the paragraph (font-size, color, line-height…) */
style?: React.CSSProperties
className?: string
}
interface WordProps {
children: string
progress: MotionValue<number>
range: [number, number]
}
const Word: React.FC<WordProps> = ({ children, progress, range }) => {
const opacity = useTransform(progress, range, [0, 1])
return (
<span style={{ position: 'relative', marginInlineEnd: '0.28em' }}>
<span style={{ position: 'absolute', inset: 0, opacity: 0.18 }}>{children}</span>
<motion.span style={{ opacity }}>{children}</motion.span>
</span>
)
}
/**
* Scroll-driven per-word reveal. Each word fades from faint solid as the
* paragraph scrolls through the viewport. Adapted from the HextaUI MagicText
* recipe to framer-motion + RTL body text.
*/
export const MagicText: React.FC<MagicTextProps> = ({ text, dir = 'rtl', style, className }) => {
const container = useRef<HTMLParagraphElement>(null)
const { scrollYProgress } = useScroll({
target: container,
offset: ['start 0.9', 'start 0.35'],
})
const words = text.split(' ')
return (
<p
ref={container}
dir={dir}
className={className}
style={{ display: 'flex', flexWrap: 'wrap', textAlign: 'justify', ...style }}
>
{words.map((word, i) => {
const start = i / words.length
const end = start + 1 / words.length
return (
<Word key={i} progress={scrollYProgress} range={[start, end]}>
{word}
</Word>
)
})}
</p>
)
}
export interface MagicTextGroupProps {
/** ordered list of paragraphs — revealed sequentially, one finishing before the next starts */
paragraphs: string[]
dir?: 'rtl' | 'ltr'
style?: React.CSSProperties
className?: string
}
/**
* Reveals several paragraphs with a SINGLE shared scroll progress. Every word
* across all paragraphs gets a global range, so paragraph 1 fully reveals
* before paragraph 2 begins (instead of all paragraphs animating at once).
*/
export const MagicTextGroup: React.FC<MagicTextGroupProps> = ({ paragraphs, dir = 'rtl', style, className }) => {
const container = useRef<HTMLDivElement>(null)
const { scrollYProgress } = useScroll({
target: container,
offset: ['start 0.9', 'end 0.4'],
})
// tokenize once and assign a continuous global index to every word
const wordsPerPara = paragraphs.map((p) => p.split(' '))
const totalWords = wordsPerPara.reduce((n, w) => n + w.length, 0)
let globalIndex = 0
return (
<div ref={container}>
{wordsPerPara.map((words, pi) => (
<p
key={pi}
dir={dir}
className={className}
style={{ display: 'flex', flexWrap: 'wrap', textAlign: 'justify', ...style }}
>
{words.map((word, wi) => {
const start = globalIndex / totalWords
const end = (globalIndex + 1) / totalWords
globalIndex += 1
return (
<Word key={wi} progress={scrollYProgress} range={[start, end]}>
{word}
</Word>
)
})}
</p>
))}
</div>
)
}

View File

@ -65,6 +65,45 @@ export const FACTORY_REPORTS: FactoryReport[] = [
] ]
export const FACTORY_REPORTS_META = { export const FACTORY_REPORTS_META = {
fa: { overline: 'MOBARAKEH STEEL', heading: 'ماهنامه تحلیل کارخانه', sub: 'گزارش‌های تحلیلی ماهانه عملکرد، بازار، فناوری و پایداری صنعت فولاد.', cta: 'دریافت گزارش' }, fa: { overline: 'MOBARAKEH STEEL', heading: 'ماهنامه تحلیلی کارخانه', sub: 'گزارش‌های تحلیلی ماهانه عملکرد، بازار، فناوری و پایداری صنعت فولاد.', cta: 'دریافت گزارش' },
en: { overline: 'MOBARAKEH STEEL', heading: 'Factory Analysis Monthly', sub: 'Monthly analytical reports on performance, market, technology and sustainability.', cta: 'Get report' }, en: { overline: 'MOBARAKEH STEEL', heading: 'Monthly Industry Report', sub: 'Monthly analytical reports on performance, market, technology and sustainability.', cta: 'Get report' },
}
const PANEL_API =
(import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API ||
'http://localhost:3001'
const SCROLL_DIRS: ScrollDir[] = ['left', 'right', 'up']
const asDir = (v: unknown): ScrollDir =>
SCROLL_DIRS.includes(v as ScrollDir) ? (v as ScrollDir) : 'up'
export async function bootstrapFactoryReports(): Promise<void> {
try {
const res = await fetch(`${PANEL_API}/api/factory-reports?limit=50`)
if (!res.ok) return
const fetched = await res.json()
if (!Array.isArray(fetched) || fetched.length === 0) return
FACTORY_REPORTS.length = 0
FACTORY_REPORTS.push(
...fetched.map((r: Record<string, unknown>) => ({
id: String(r.id),
img: String(r.img ?? ''),
dir: asDir(r.scrollDir),
fa: {
tag: String(r.tagFa ?? ''),
title: String(r.titleFa ?? ''),
date: String(r.dateFa ?? ''),
excerpt: String(r.excerptFa ?? ''),
},
en: {
tag: String(r.tagEn ?? ''),
title: String(r.titleEn ?? ''),
date: String(r.dateEn ?? ''),
excerpt: String(r.excerptEn ?? ''),
},
})),
)
} catch {
/* panel offline → keep static fallback */
}
} }

View File

@ -38,14 +38,48 @@ export const INTEGRATIONS: Integration[] = [
export const INTEGRATIONS_META = { export const INTEGRATIONS_META = {
fa: { fa: {
overline: 'DATA SOURCES', overline: 'DATA SOURCES',
heading: 'یکپارچگی با منابع جهانی', heading: 'نبض صنعت',
sub: 'اتصال به بیش از ۱۰۰ منبع داده، بورس و پایگاه جهانی فولاد — رصد لحظه‌ای قیمت‌ها، صادرات و روندهای بازار در یک جا.', sub: 'پنجره‌ای واحد به جهان داده‌های فولاد؛ دسترسی به شاخص‌ها، قیمت‌ها، تجارت جهانی و داده‌های کلیدی از معدن تا محصول نهایی.',
cta: 'مشاهده منابع داده', cta: 'مشاهده منابع داده',
}, },
en: { en: {
overline: 'DATA SOURCES', overline: 'DATA SOURCES',
heading: 'Seamless Global Integration', heading: 'Industry Pulse',
sub: 'Connected to 100+ data sources, exchanges and global steel databases — real-time prices, exports and market trends in one place.', sub: 'A single window into the world of steel data — access to indices, prices, global trade and key data from mine to finished product.',
cta: 'View data sources', cta: 'View data sources',
}, },
} }
const PANEL_API =
(import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API ||
'http://localhost:3001'
const ICON_TYPES: IntegrationIcon[] = [
'exchange', 'globe', 'chart', 'activity', 'database',
'newspaper', 'building', 'trending', 'coins',
]
const asIcon = (v: unknown): IntegrationIcon =>
ICON_TYPES.includes(v as IntegrationIcon) ? (v as IntegrationIcon) : 'globe'
export async function bootstrapIntegrations(): Promise<void> {
try {
const res = await fetch(`${PANEL_API}/api/integrations?limit=50`)
if (!res.ok) return
const fetched = await res.json()
if (!Array.isArray(fetched) || fetched.length === 0) return
INTEGRATIONS.length = 0
INTEGRATIONS.push(
...fetched.map((it: Record<string, unknown>) => ({
id: String(it.id),
name: String(it.name ?? ''),
icon: asIcon(it.icon),
logo: it.logo ? String(it.logo) : undefined,
accent: it.accent ? String(it.accent) : undefined,
col: Number(it.gridCol) || 1,
row: Number(it.gridRow) || 1,
})),
)
} catch {
/* panel offline → keep static fallback */
}
}

118
src/content/posts.ts Normal file
View File

@ -0,0 +1,118 @@
/*
Posts / articles content layer (نوشتهها).
Each post opens a detail page at /posts/:id with cover, rich body
(paragraphs + charts/figures), comments, and a floating "related"
sidebar. Mock data swap for panel/API (GET /api/articles) later.
*/
export type PostBlock =
| { type: 'p'; fa: string; en: string }
| { type: 'h'; fa: string; en: string }
| { type: 'chart'; chart: 'pareto' | 'bars' }
| { type: 'figure'; img: string; fa: string; en: string }
export type CommentTag = { fa: string; en: string }
export type PostComment = {
id: string
name: string
roleFa: string
roleEn: string
avatar?: string
date: string
tags: CommentTag[]
fa: string
en: string
}
export type RelatedPost = {
id: string
img: string
fa: { title: string; excerpt: string; date: string }
en: { title: string; excerpt: string; date: string }
}
export type Post = {
id: string
cover: string
category: { fa: string; en: string }
fa: { title: string; author: string; date: string; lead: string }
en: { title: string; author: string; date: string; lead: string }
body: PostBlock[]
comments: PostComment[]
related: RelatedPost[]
}
const IMG = (n: string) => `/news/${n}`
const REL_EXCERPT_FA = 'گذار به سوخت‌های پاک و کاهش وابستگی به…'
const REL_EXCERPT_EN = 'The shift to clean fuels and reduced dependency…'
const RELATED: RelatedPost[] = [
{ id: 'p2', img: IMG('photo-1504307651254-35680f356dfd.jpg'),
fa: { title: 'آینده‌ی زیرساخت‌های سوختی در …', excerpt: REL_EXCERPT_FA, date: 'اردیبهشت ۱۴۰۵' },
en: { title: 'The future of fuel infrastructure …', excerpt: REL_EXCERPT_EN, date: 'May 2026' } },
{ id: 'p3', img: IMG('photo-1581092160607-ee22731c9b4e.jpg'),
fa: { title: 'آینده‌ی زیرساخت‌های سوختی در …', excerpt: REL_EXCERPT_FA, date: 'اردیبهشت ۱۴۰۵' },
en: { title: 'The future of fuel infrastructure …', excerpt: REL_EXCERPT_EN, date: 'May 2026' } },
{ id: 'p4', img: IMG('photo-1518770660439-4636190af475.jpg'),
fa: { title: 'آینده‌ی زیرساخت‌های سوختی در …', excerpt: REL_EXCERPT_FA, date: 'اردیبهشت ۱۴۰۵' },
en: { title: 'The future of fuel infrastructure …', excerpt: REL_EXCERPT_EN, date: 'May 2026' } },
{ id: 'p5', img: IMG('photo-1611273426858-450d8e3c9fce.jpg'),
fa: { title: 'آینده‌ی زیرساخت‌های سوختی در …', excerpt: REL_EXCERPT_FA, date: 'اردیبهشت ۱۴۰۵' },
en: { title: 'The future of fuel infrastructure …', excerpt: REL_EXCERPT_EN, date: 'May 2026' } },
{ id: 'p6', img: IMG('photo-1567789884554-0b844b597180.jpg'),
fa: { title: 'آینده‌ی زیرساخت‌های سوختی در …', excerpt: REL_EXCERPT_FA, date: 'اردیبهشت ۱۴۰۵' },
en: { title: 'The future of fuel infrastructure …', excerpt: REL_EXCERPT_EN, date: 'May 2026' } },
{ id: 'p7', img: IMG('photo-1611273426858-450d8e3c9fce.jpg'),
fa: { title: 'آینده‌ی زیرساخت‌های سوختی در …', excerpt: REL_EXCERPT_FA, date: 'اردیبهشت ۱۴۰۵' },
en: { title: 'The future of fuel infrastructure …', excerpt: REL_EXCERPT_EN, date: 'May 2026' } },
]
export const POSTS: Post[] = [
{
id: 'p1',
cover: IMG('photo-1581092160607-ee22731c9b4e.jpg'),
category: { fa: 'بازار و زنجیره فولاد', en: 'Market & Steel Chain' },
fa: {
title: 'پایش ماهواره‌ای کربن در صنعت فولاد',
author: 'دکتر سارا احمدی',
date: 'اردیبهشت ۱۴۰۵',
lead: '',
},
en: {
title: 'Satellite Carbon Monitoring in the Steel Industry',
author: 'Dr. Sara Ahmadi',
date: 'May 2026',
lead: '',
},
body: [
{ type: 'p',
fa: 'ابزارهای پایش فضایی، نحوه‌ی ارزیابی زیست‌محیطی صنایع سنگین را به‌صورت بنیادین دگرگون ساخته‌اند. پیش‌تر، سنجش میزان آلایندگی مجتمع‌های متالورژیکی عمدتاً بر تخمین‌های درون‌سازمانی، میانگین‌های ملی و محاسبات تئوریک استوار بود. اکنون، آژانس فضایی ناسا با توسعه و پرتاب ابزارهای پیشرفته تشخیص گازهای گلخانه‌ای، امکان نقشه‌برداری دقیق و نقطه‌ای انتشار متان و دی‌اکسیدکربن را فراهم می‌سازد. این تجهیزات مدرن در مدار زمین، مستر و با اندازه‌گیری دقیق میزان جذب نور خورشید در اتمسفر، اضافه‌ای طبق گازهای مختلف را شناسایی می‌کنند. داده‌های این پایشگرها، وضعیتی کاملاً عینی و مستقل از آمارهای محلی ارائه می‌دهند. این لایه شفافیت اطلاعاتی، نهادهای نظارتی را قادر می‌سازد تا حجم گازهای خروجی از دودکش کارخانجات را بدون اتکا به گزارش‌های زمینی بسنجند.',
en: 'Space-based monitoring tools have fundamentally transformed environmental assessment of heavy industry.' },
{ type: 'p',
fa: 'کانون اصلی این پایش‌های ماهواره‌ای، مناطقی با تراکم بالای مجتمع‌های متالورژیکی و قوانین اقلیمی سخت‌گیرانه است. نشریات علمی کوپرنیک در بررسی‌های خود نشان می‌دهند که قاره اروپا هدفی کلیدی برای این ارزیابی‌های اتمسفری محسوب می‌شود. ماهواره‌های نسل جدید با بهره‌گیری از طیف‌سنجی‌های مادون قرمز، توده‌های گاز ناشی از فرآیندهای صنعتی را در مقیاس‌های بسیار کوچک تفکیک می‌کنند. نقشه‌برداری دقیق از موقعیت کارخانه‌های فولاد، بستر لازم برای ایجاد یک پایگاه داده یکپارچه از منابع انتشار را فراهم می‌آورد. این ساختار یکپارچه به ناظران اجازه می‌دهد تا تغییرات غلظت گازها را مستقیماً به دارایی‌های فیزیکی مشخص متصل کنند. موقعیت مکانی این واحدهای صنعتی، چارچوب اصلی پایش فضایی را به روشنی مشخص می‌سازد. در همین راستا، شکل ۱ موقعیت جغرافیایی مجتمع‌های آهن و فولاد مورد بررسی را در کشورهای مختلف اروپایی به تصویر می‌کشد.',
en: 'The focus of these satellite scans is regions with dense metallurgical clusters and strict climate regulations.' },
{ type: 'chart', chart: 'pareto' },
{ type: 'p',
fa: 'پیش از توسعه این مأموریت‌های فضایی، مجتمع‌های صنعتی، داده‌های آلایندگی خود را با روش‌های سنتی و ناهمگون به سامانه ثبت انتشار آلاینده‌های اروپا (EPRTR-European Pollutant Release and Transfer Register) ارسال می‌کردند. مستندات علمی نشان می‌دهند که کارخانجات از الزامات فنی یکسانی برای خوداظهاری تبعیت نمی‌کنند. این از واحدها میزان خروجی را مستقیماً اندازه‌گیری می‌کنند و گروهی دیگر بر محاسبات و تخمین‌های مهندسی متکی هستند. این تفاوت‌های روش‌شناختی سبب بروز عدم قطعیت در پایگاه‌های داده اقلیمی کلان شده و امکان مقایسه دقیق عملکرد کارخانه‌ها را سلب می‌نماید. بنابراین دقیق داده‌های تاریخی نیازمند درک قاعده‌مند است. شناسایی این الگوهای ناپایدار در گزارش‌های رسمی، ضرورت عبور از روش‌های سنتی حساب‌رسی را اثبات می‌کند. برای واکاوی این ناهمگونی، شکل ۲ روش‌های مختلف گزارش‌دهی انتشار مونوکسید کربن را در این تأسیسات از سال ۲۰۱۷ تا ۲۰۳۱ میلادی نشان می‌دهد.',
en: 'Before these space missions, industrial complexes reported their pollution data through inconsistent legacy methods.' },
{ type: 'chart', chart: 'bars' },
],
comments: [
{ id: 'c1', name: 'دکتر احسان محبی', roleFa: 'پژوهشگر ارشد اقتصاد محیط زیست', roleEn: 'Senior environmental economist', date: '۵ روز پیش',
tags: [{ fa: 'خلأ قانونی', en: 'Legal gap' }, { fa: 'شفافیت داده‌ها', en: 'Data transparency' }],
fa: 'تحلیل بسیار جامع و عمیقی بود. با ورود پایش‌های ماهواره‌ای ناسا، بالاخره دوران آمارسازی و دستکاری گزارش‌های محیط‌زیستی کارخانه‌ها تمام می‌شود و شرکت‌هایی که واقعاً روی تکنولوژی سبز سرمایه‌گذاری کرده‌اند، برنده می‌گردند.',
en: 'A very comprehensive analysis. With NASA satellite monitoring, the era of fudged environmental reports is over.' },
{ id: 'c2', name: 'سارا نیک‌نام', roleFa: 'مدیر توسعه پایدار', roleEn: 'Sustainability lead', date: '۵ روز پیش',
tags: [{ fa: 'چالش دوران گذار', en: 'Transition challenge' }, { fa: 'اقتصاد سبز', en: 'Green economy' }],
fa: 'مطلب دقیقی بود، اما یک نگرانی بزرگ: الان تو صنعت وجود داره؛ اگه اختلاف آمار دقیق ماهواره با خوداظهاری‌های سال‌های قبل ما خیلی زیاد باشد، آیا قرار بابت گذشته هم جریمه بشیم؟ این چالش حقوقی باید زودتر شفاف بشه.',
en: 'A precise piece, but one big concern: if satellite data differs sharply from past self-reports, will we be fined retroactively?' },
],
related: RELATED,
},
]
export function getPost(id: string): Post | undefined {
return POSTS.find(p => p.id === id) || POSTS[0]
}

View File

@ -85,3 +85,25 @@ export const RADAR_ITEMS: RadarItem[] = [
en: { title: 'Global Steel Market Analysis', date: 'Apr 26, 2026', excerpt: 'China\'s steel export flood and price pressure on Iran\'s target markets.' }, en: { title: 'Global Steel Market Analysis', date: 'Apr 26, 2026', excerpt: 'China\'s steel export flood and price pressure on Iran\'s target markets.' },
}, },
] ]
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || 'http://localhost:3001'
const RADAR_CATEGORIES: RadarCategory[] = ['market', 'tech', 'commodity', 'geo', 'energy']
export async function bootstrapRadar(): Promise<void> {
try {
const res = await fetch(`${PANEL_API}/api/radar?limit=50`)
if (!res.ok) return
const fetched = await res.json()
if (!Array.isArray(fetched) || fetched.length === 0) return
RADAR_ITEMS.length = 0
RADAR_ITEMS.push(...fetched.map((r: Record<string, unknown>): RadarItem => ({
id: String(r.id),
category: RADAR_CATEGORIES.includes(r.category as RadarCategory) ? (r.category as RadarCategory) : 'market',
img: String(r.img ?? ''),
author: { name: String(r.authorName ?? ''), avatar: String(r.authorAvatar ?? '') },
fa: { title: String(r.titleFa ?? ''), date: String(r.dateFa ?? ''), excerpt: String(r.excerptFa ?? '') },
en: { title: String(r.titleEn ?? ''), date: String(r.dateEn ?? ''), excerpt: String(r.excerptEn ?? '') },
})))
} catch { /* panel offline → keep static fallback */ }
}

View File

@ -0,0 +1,276 @@
/*
Radar category-detail pages content layer.
Each of the 5 "رادار آینده" categories gets its own detail page
at /radar/:slug with the SAME layout, only the content differs.
Kept separate from the component so it can be served from the
panel/API later (replace these objects with a fetch).
*/
import type { RadarCategory } from './radar'
export type RadarSlug =
| 'foresight' // آینده‌پژوهی و رصد هوشمند
| 'market' // بازار و زنجیره فولاد
| 'sustainability' // پایداری و فولاد سبز
| 'geopolitics' // ژئوپلیتیک و اقتصاد جهانی
| 'technology' // فناوری و نوآوری
export type RadarCard = {
id: string
img: string
fa: { title: string; excerpt: string; date: string }
en: { title: string; excerpt: string; date: string }
}
export type RadarCategoryPage = {
slug: RadarSlug
/** which RadarCategory(ies) from radar.ts this page draws its card pool from */
sourceCategories: RadarCategory[]
fa: {
label: string // breadcrumb + headings
latestHeading: string // "تازه‌ترین‌های …"
featured: { title: string; date: string; img: string }
banner: { kicker: string; title: string; desc: string }
}
en: {
label: string
latestHeading: string
featured: { title: string; date: string; img: string }
banner: { kicker: string; title: string; desc: string }
}
}
const BOOK_IMG = '/Horizontal_Book_Mockup_6 1.webp'
/* ── the 5 pages ─────────────────────────────────────────────── */
export const RADAR_CATEGORY_PAGES: Record<RadarSlug, RadarCategoryPage> = {
foresight: {
slug: 'foresight',
sourceCategories: ['market', 'tech', 'commodity', 'geo', 'energy'],
fa: {
label: 'آینده‌پژوهی و رصد هوشمند',
latestHeading: 'تازه‌ترین‌های آینده‌پژوهی',
featured: {
title: 'طلوع هوشمندی: چگونه هوش مصنوعی، زنجیره ارزش فولاد را بازتعریف می‌کند؟',
date: 'اردیبهشت ۱۴۰۵',
img: '/news/photo-1611974789855-9c2a0a7236a3.jpg',
},
banner: {
kicker: 'تحلیل ویژه',
title: 'نقشه راه آینده‌پژوهی صنعت فولاد',
desc: 'جامع‌ترین گزارش تحلیلی سال پیرامون سناریوهای محتمل برای آینده صنعت فولاد، روندهای نوظهور فناورانه و سیگنال‌های راهبردی برای تصمیم‌سازان.',
},
},
en: {
label: 'Foresight & Smart Monitoring',
latestHeading: 'Latest in Foresight',
featured: {
title: 'The Dawn of Intelligence: How AI Is Redefining the Steel Value Chain',
date: 'May 2026',
img: '/news/photo-1611974789855-9c2a0a7236a3.jpg',
},
banner: {
kicker: 'Special Analysis',
title: 'A Foresight Roadmap for the Steel Industry',
desc: 'The most comprehensive analytical report of the year on plausible scenarios for the future of steel, emerging tech trends and strategic signals for decision-makers.',
},
},
},
market: {
slug: 'market',
sourceCategories: ['market', 'commodity'],
fa: {
label: 'بازار و زنجیره فولاد',
latestHeading: 'تازه‌ترین‌های بازار و زنجیره فولاد',
featured: {
title: 'طلوع هوشمندی: چگونه هوش مصنوعی، زنجیره ارزش فولاد را بازتعریف می‌کند؟',
date: 'اردیبهشت ۱۴۰۵',
img: '/news/photo-1611974789855-9c2a0a7236a3.jpg',
},
banner: {
kicker: 'تحلیل ویژه',
title: 'معماری نوین زنجیره تأمین',
desc: 'جامع‌ترین گزارش تحلیلی سال پیرامون مسیر تجارت جهانی سنگ‌آهن، تأثیرات قطعی استانداردهای کربنی اروپا (CBAM) و سناریوهای تاب‌آوری برای فولادسازان خاورمیانه.',
},
},
en: {
label: 'Market & Steel Chain',
latestHeading: 'Latest in Market & Chain',
featured: {
title: 'The Dawn of Intelligence: How AI Is Redefining the Steel Value Chain',
date: 'May 2026',
img: '/news/photo-1611974789855-9c2a0a7236a3.jpg',
},
banner: {
kicker: 'Special Analysis',
title: 'A New Supply-Chain Architecture',
desc: "The most comprehensive analytical report of the year on global iron-ore trade routes, the decisive impact of Europe's carbon standards (CBAM) and resilience scenarios for Middle-Eastern steelmakers.",
},
},
},
sustainability: {
slug: 'sustainability',
sourceCategories: ['energy', 'commodity'],
fa: {
label: 'پایداری و فولاد سبز',
latestHeading: 'تازه‌ترین‌های پایداری و فولاد سبز',
featured: {
title: 'هیدروژن سبز؛ سوخت کربن‌زدایی صنعت فولاد در افق ۲۰۵۰',
date: 'اردیبهشت ۱۴۰۵',
img: '/news/photo-1466611653911-95081537e5b7.jpg',
},
banner: {
kicker: 'تحلیل ویژه',
title: 'مسیر کربن‌زدایی صنعت فولاد',
desc: 'جامع‌ترین گزارش تحلیلی سال پیرامون فناوری‌های احیای مستقیم بر پایه هیدروژن، برق تجدیدپذیر و سناریوهای دستیابی به فولاد بدون کربن تا افق ۲۰۵۰.',
},
},
en: {
label: 'Sustainability & Green Steel',
latestHeading: 'Latest in Sustainability',
featured: {
title: 'Green Hydrogen: The Decarbonization Fuel of Steel Toward 2050',
date: 'May 2026',
img: '/news/photo-1466611653911-95081537e5b7.jpg',
},
banner: {
kicker: 'Special Analysis',
title: 'The Steel Decarbonization Pathway',
desc: 'The most comprehensive analytical report of the year on hydrogen-based direct reduction, renewable power and scenarios for reaching carbon-free steel by 2050.',
},
},
},
geopolitics: {
slug: 'geopolitics',
sourceCategories: ['geo', 'market'],
fa: {
label: 'ژئوپلیتیک و اقتصاد جهانی',
latestHeading: 'تازه‌ترین‌های ژئوپلیتیک و اقتصاد جهانی',
featured: {
title: 'هندسه نوین قدرت؛ ژئوپلیتیک فولاد در جهانی چندقطبی',
date: 'اردیبهشت ۱۴۰۵',
img: '/news/photo-1518770660439-4636190af475.jpg',
},
banner: {
kicker: 'تحلیل ویژه',
title: 'ژئوپلیتیک تجارت فولاد',
desc: 'جامع‌ترین گزارش تحلیلی سال پیرامون تنش‌های تجاری، تعرفه‌ها و بازآرایی مسیرهای صادراتی فولاد و پیامد آن بر رقابت‌پذیری ایران در بازارهای منطقه‌ای.',
},
},
en: {
label: 'Geopolitics & Global Economy',
latestHeading: 'Latest in Geopolitics',
featured: {
title: 'A New Power Geometry: Steel Geopolitics in a Multipolar World',
date: 'May 2026',
img: '/news/photo-1518770660439-4636190af475.jpg',
},
banner: {
kicker: 'Special Analysis',
title: 'The Geopolitics of Steel Trade',
desc: "The most comprehensive analytical report of the year on trade tensions, tariffs and the realignment of steel export routes, and their fallout for Iran's regional competitiveness.",
},
},
},
technology: {
slug: 'technology',
sourceCategories: ['tech'],
fa: {
label: 'فناوری و نوآوری',
latestHeading: 'تازه‌ترین‌های فناوری و نوآوری',
featured: {
title: 'کارخانه هوشمند؛ تحول دیجیتال در خطوط تولید فولاد',
date: 'اردیبهشت ۱۴۰۵',
img: '/news/photo-1581092160607-ee22731c9b4e.jpg',
},
banner: {
kicker: 'تحلیل ویژه',
title: 'انقلاب دیجیتال در صنعت فولاد',
desc: 'جامع‌ترین گزارش تحلیلی سال پیرامون هوش مصنوعی، دوقلوی دیجیتال، اینترنت اشیا صنعتی و اثر اتوماسیون هوشمند بر بهره‌وری و کیفیت تولید فولاد.',
},
},
en: {
label: 'Technology & Innovation',
latestHeading: 'Latest in Technology',
featured: {
title: 'The Smart Factory: Digital Transformation in Steel Production Lines',
date: 'May 2026',
img: '/news/photo-1581092160607-ee22731c9b4e.jpg',
},
banner: {
kicker: 'Special Analysis',
title: 'The Digital Revolution in Steel',
desc: 'The most comprehensive analytical report of the year on AI, digital twins, industrial IoT and the impact of smart automation on steel productivity and quality.',
},
},
},
}
export const RADAR_SLUGS = Object.keys(RADAR_CATEGORY_PAGES) as RadarSlug[]
export { BOOK_IMG }
/* CMS wiring
Overwrites RADAR_CATEGORY_PAGES from the panel at startup.
Falls back to the static objects above when the panel is offline.
*/
const PANEL_API =
(import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API ||
'http://localhost:3001'
const VALID_SLUGS = new Set(RADAR_SLUGS)
const VALID_CATS: RadarCategory[] = ['market', 'tech', 'commodity', 'geo', 'energy']
const asCats = (v: unknown): RadarCategory[] =>
Array.isArray(v) ? v.filter((c): c is RadarCategory => VALID_CATS.includes(c as RadarCategory)) : []
export async function bootstrapRadarPages(): Promise<void> {
try {
const res = await fetch(`${PANEL_API}/api/radar-pages`)
if (!res.ok) return
const rows = await res.json()
if (!Array.isArray(rows) || rows.length === 0) return
for (const r of rows as Record<string, unknown>[]) {
const slug = String(r.slug) as RadarSlug
if (!VALID_SLUGS.has(slug)) continue
const cats = asCats(r.sourceCategories)
RADAR_CATEGORY_PAGES[slug] = {
slug,
sourceCategories: cats.length ? cats : RADAR_CATEGORY_PAGES[slug].sourceCategories,
fa: {
label: String(r.labelFa ?? RADAR_CATEGORY_PAGES[slug].fa.label),
latestHeading: String(r.latestHeadingFa ?? RADAR_CATEGORY_PAGES[slug].fa.latestHeading),
featured: {
title: String(r.featuredTitleFa ?? RADAR_CATEGORY_PAGES[slug].fa.featured.title),
date: String(r.featuredDateFa ?? RADAR_CATEGORY_PAGES[slug].fa.featured.date),
img: String(r.featuredImg ?? RADAR_CATEGORY_PAGES[slug].fa.featured.img),
},
banner: {
kicker: String(r.bannerKickerFa ?? RADAR_CATEGORY_PAGES[slug].fa.banner.kicker),
title: String(r.bannerTitleFa ?? RADAR_CATEGORY_PAGES[slug].fa.banner.title),
desc: String(r.bannerDescFa ?? RADAR_CATEGORY_PAGES[slug].fa.banner.desc),
},
},
en: {
label: String(r.labelEn ?? RADAR_CATEGORY_PAGES[slug].en.label),
latestHeading: String(r.latestHeadingEn ?? RADAR_CATEGORY_PAGES[slug].en.latestHeading),
featured: {
title: String(r.featuredTitleEn ?? RADAR_CATEGORY_PAGES[slug].en.featured.title),
date: String(r.featuredDateEn ?? RADAR_CATEGORY_PAGES[slug].en.featured.date),
img: String(r.featuredImg ?? RADAR_CATEGORY_PAGES[slug].en.featured.img),
},
banner: {
kicker: String(r.bannerKickerEn ?? RADAR_CATEGORY_PAGES[slug].en.banner.kicker),
title: String(r.bannerTitleEn ?? RADAR_CATEGORY_PAGES[slug].en.banner.title),
desc: String(r.bannerDescEn ?? RADAR_CATEGORY_PAGES[slug].en.banner.desc),
},
},
}
}
} catch {
/* panel offline → keep static fallback */
}
}

144
src/data/about.ts Normal file
View File

@ -0,0 +1,144 @@
// ─────────────────────────────────────────────────────────────
// About page content layer (vision pillars + advisory board)
// Mock/seed data. Replaced at runtime by bootstrapAbout() which
// fetches /api/vision-items and /api/advisory-board from the panel.
// Keep this shape so the swap stays mechanical.
// ─────────────────────────────────────────────────────────────
export interface VisionItem {
id: string;
/** lucide icon name (string key) — mapped to a component in About.tsx */
icon: string;
titleFa: string;
titleEn: string;
descFa: string;
descEn: string;
}
export interface AdvisoryMember {
id: string;
nameFa: string;
nameEn: string;
roleFa: string;
roleEn: string;
}
export const visionItems: VisionItem[] = [
{
id: 'research-leadership',
icon: 'FlaskConical',
titleFa: 'پیشرو در پژوهش',
titleEn: 'Research Leadership',
descFa: 'تولید تحلیل‌های راهبردی و گزارش‌های تخصصی در حوزه صنعت فولاد',
descEn: 'Producing strategic analyses and specialized reports in the steel industry',
},
{
id: 'expert-networking',
icon: 'Network',
titleFa: 'شبکه‌سازی خبرگان',
titleEn: 'Expert Networking',
descFa: 'ایجاد پیوند میان متخصصان، سیاست‌گذاران و فعالان صنعتی',
descEn: 'Building connections between specialists, policymakers, and industry practitioners',
},
{
id: 'green-steel',
icon: 'Leaf',
titleFa: 'فولاد سبز',
titleEn: 'Green Steel',
descFa: 'ترویج رویکردهای پایدار و کاهش اثرات زیست‌محیطی در زنجیره فولاد',
descEn: 'Promoting sustainable approaches and reducing environmental impacts across the steel chain',
},
{
id: 'futures-research',
icon: 'Telescope',
titleFa: 'آینده‌پژوهی',
titleEn: 'Futures Research',
descFa: 'رصد هوشمند تحولات جهانی و ارائه نقشه راه برای صنعت ایران',
descEn: "Smart monitoring of global developments and providing roadmaps for Iran's industry",
},
];
export const advisoryBoard: AdvisoryMember[] = [
{
id: 'alireza-akbarian',
nameFa: 'دکتر علیرضا اکبریان',
nameEn: 'Dr. Alireza Akbarian',
roleFa: 'مدیر اندیشکده',
roleEn: 'Institute Director',
},
{
id: 'sara-mohammadi',
nameFa: 'مهندس سارا محمدی',
nameEn: 'Eng. Sara Mohammadi',
roleFa: 'پژوهشگر ارشد فولاد سبز',
roleEn: 'Senior Green Steel Researcher',
},
{
id: 'mohammad-jalali',
nameFa: 'دکتر محمد جلالی',
nameEn: 'Dr. Mohammad Jalali',
roleFa: 'تحلیلگر بازارهای جهانی',
roleEn: 'Global Markets Analyst',
},
{
id: 'fatemeh-rezaei',
nameFa: 'دکتر فاطمه رضایی',
nameEn: 'Dr. Fatemeh Rezaei',
roleFa: 'متخصص ژئوپلیتیک صنعتی',
roleEn: 'Industrial Geopolitics Specialist',
},
{
id: 'reza-karimi',
nameFa: 'مهندس رضا کریمی',
nameEn: 'Eng. Reza Karimi',
roleFa: 'کارشناس فناوری و نوآوری',
roleEn: 'Technology & Innovation Expert',
},
{
id: 'niloofar-hosseini',
nameFa: 'دکتر نیلوفر حسینی',
nameEn: 'Dr. Niloofar Hosseini',
roleFa: 'پژوهشگر آینده‌پژوهی',
roleEn: 'Futures Research Specialist',
},
];
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || 'http://localhost:3001'
export async function bootstrapAbout(): Promise<void> {
try {
const [visionRes, boardRes] = await Promise.all([
fetch(`${PANEL_API}/api/vision-items?limit=50`),
fetch(`${PANEL_API}/api/advisory-board?limit=50`),
])
if (visionRes.ok) {
const fetched = await visionRes.json()
if (Array.isArray(fetched) && fetched.length > 0) {
visionItems.length = 0
visionItems.push(...fetched.map((v: Record<string, unknown>) => ({
id: String(v.id),
icon: String(v.icon ?? ''),
titleFa: String(v.titleFa ?? ''),
titleEn: String(v.titleEn ?? ''),
descFa: String(v.descFa ?? ''),
descEn: String(v.descEn ?? ''),
})))
}
}
if (boardRes.ok) {
const fetched = await boardRes.json()
if (Array.isArray(fetched) && fetched.length > 0) {
advisoryBoard.length = 0
advisoryBoard.push(...fetched.map((m: Record<string, unknown>) => ({
id: String(m.id),
nameFa: String(m.nameFa ?? ''),
nameEn: String(m.nameEn ?? ''),
roleFa: String(m.roleFa ?? ''),
roleEn: String(m.roleEn ?? ''),
})))
}
}
} catch { /* panel offline → keep static fallback */ }
}

82
src/data/banners.ts Normal file
View File

@ -0,0 +1,82 @@
/*
Banner content layer (featured-report promo banner on the home
page, plus any future hero/announcement/promo banners).
Static fallback below mirrors the original hardcoded banner; it
is replaced at startup by `bootstrapBanners()` when the panel is
reachable. Mutated in place so component imports stay live.
*/
export interface Banner {
id: string
position: string // 'featured' | 'hero' | 'announcement' | 'promo'
active: boolean
image: string
ctaUrl: string
fa: { overline: string; title: string; subtitle: string; cta: string }
en: { overline: string; title: string; subtitle: string; cta: string }
}
/* TEMP fallback — swap for panel/API data via bootstrapBanners(). */
export const banners: Banner[] = [
{
id: 'featured-report',
position: 'featured',
active: true,
image: '/Horizontal_Book_Mockup_6 1.webp',
ctaUrl: '',
fa: {
overline: 'ماهنامه شماره ۱۲ منتشر شد…',
title: 'دیپلماسی معدنی در عصر انسدادهای ژئوپلیتیک',
subtitle: 'Mineral Diplomacy in the Era of Geopolitical Blockades',
cta: 'دانلود گزارش',
},
en: {
overline: 'Monthly issue #12 is out…',
title: 'Mineral Diplomacy in the Era of Geopolitical Blockades',
subtitle: 'دیپلماسی معدنی در عصر انسدادهای ژئوپلیتیک',
cta: 'Download report',
},
},
]
/** First active banner for a given position (used by section components). */
export function bannerFor(position: string): Banner | undefined {
return banners.find((b) => b.active && b.position === position)
}
const PANEL_API =
(import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API ||
'http://localhost:3001'
export async function bootstrapBanners(): Promise<void> {
try {
const res = await fetch(`${PANEL_API}/api/banners?active=1`)
if (!res.ok) return
const fetched = await res.json()
if (!Array.isArray(fetched) || fetched.length === 0) return
banners.length = 0
banners.push(
...fetched.map((b: Record<string, unknown>) => ({
id: String(b.id),
position: String(b.position ?? 'featured'),
active: Boolean(b.active),
image: String(b.image ?? ''),
ctaUrl: String(b.ctaUrl ?? ''),
fa: {
overline: String(b.overlineFa ?? ''),
title: String(b.titleFa ?? ''),
subtitle: String(b.subtitleFa ?? ''),
cta: String(b.ctaLabelFa ?? ''),
},
en: {
overline: String(b.overlineEn ?? ''),
title: String(b.titleEn ?? ''),
subtitle: String(b.subtitleEn ?? ''),
cta: String(b.ctaLabelEn ?? ''),
},
})),
)
} catch {
/* panel offline → keep static fallback */
}
}

View File

@ -93,3 +93,21 @@ export const events: Event[] = [
'نشست سالانه کمیته فولاد OECD برای بررسی مازاد ظرفیت جهانی، سیاست‌های تجاری و استانداردهای پایداری در صنعت فولاد.', 'نشست سالانه کمیته فولاد OECD برای بررسی مازاد ظرفیت جهانی، سیاست‌های تجاری و استانداردهای پایداری در صنعت فولاد.',
}, },
]; ];
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || 'http://localhost:3001'
export async function bootstrapEvents(): Promise<void> {
try {
const res = await fetch(`${PANEL_API}/api/events?limit=200`)
if (!res.ok) return
const fetched = await res.json()
if (!Array.isArray(fetched) || fetched.length === 0) return
events.length = 0
events.push(...fetched.map((e: Record<string, unknown>) => ({
id: String(e.id), title: String(e.titleFa ?? ''), date: e.dateFa as number,
month: String(e.monthFa ?? ''), year: e.year as number, type: e.type as EventType,
location: String(e.locationFa ?? ''), city: String(e.cityFa ?? ''),
registrationOpen: Boolean(e.registrationOpen), description: String(e.descriptionFa ?? ''),
})))
} catch { /* panel offline → keep static fallback */ }
}

View File

@ -177,3 +177,102 @@ export const stats: InstituteStats = {
memberOrgs: 60, memberOrgs: 60,
subscribers: 2800, subscribers: 2800,
}; };
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || 'http://localhost:3001'
/** Map an institute_stats row id back onto the InstituteStats object key. */
const STAT_KEY_BY_ID: Record<string, keyof InstituteStats> = {
'total-reports': 'totalReports',
'years': 'years',
'experts': 'experts',
'member-orgs': 'memberOrgs',
'subscribers': 'subscribers',
}
/**
* Fetch CMS-managed market data and mutate the exported arrays/object in place.
* Mirrors bootstrapEvents(): silent try/catch, keeps static fallback when the
* panel is offline or returns nothing. Endpoints fetched in parallel.
*/
export async function bootstrapMarket(): Promise<void> {
try {
const [statsRes, pricesRes, pointsRes] = await Promise.all([
fetch(`${PANEL_API}/api/institute-stats`),
fetch(`${PANEL_API}/api/market-prices`),
fetch(`${PANEL_API}/api/market-chart-points?limit=500`),
])
/* ── institute stats → stats object ── */
if (statsRes.ok) {
const rows = await statsRes.json()
if (Array.isArray(rows) && rows.length) {
for (const row of rows as Array<Record<string, unknown>>) {
const key = STAT_KEY_BY_ID[String(row.id)]
if (key) stats[key] = Number(row.value) || 0
}
}
}
/* ── market prices → marketPrices[] ── */
if (pricesRes.ok) {
const rows = await pricesRes.json()
if (Array.isArray(rows) && rows.length) {
marketPrices.length = 0
marketPrices.push(...(rows as Array<Record<string, unknown>>).map((r) => ({
id: String(r.id),
name: String(r.name ?? ''),
value: Number(r.value) || 0,
unit: String(r.unit ?? ''),
change: Number(r.change) || 0,
changePercent: Number(r.changePercent) || 0,
trend: (['up', 'down', 'flat'].includes(String(r.trend)) ? r.trend : 'flat') as PriceTrend,
})))
}
}
/* ── chart points → priceHistory / monthlyExport / globalComparison ── */
if (pointsRes.ok) {
const rows = await pointsRes.json()
if (Array.isArray(rows) && rows.length) {
const points = rows as Array<Record<string, unknown>>
const history = points.filter((p) => p.series === 'history')
const exports = points.filter((p) => p.series === 'export')
const comparison = points.filter((p) => p.series === 'comparison')
if (history.length) {
priceHistory.length = 0
priceHistory.push(...history.map((p) => ({
month: String(p.label ?? ''),
price: Number(p.value) || 0,
})))
}
if (exports.length) {
monthlyExport.length = 0
monthlyExport.push(...exports.map((p) => ({
month: String(p.label ?? ''),
tonnage: Number(p.value) || 0,
})))
}
if (comparison.length) {
globalComparison.length = 0
globalComparison.push(...comparison.map((p) => {
// `meta` carries flag + currency + unit packed by the panel.
let meta: { flag?: string; currency?: string; unit?: string } = {}
if (p.meta) {
try { meta = typeof p.meta === 'string' ? JSON.parse(p.meta) : (p.meta as Record<string, string>) } catch { /* keep {} */ }
}
return {
country: String(p.label ?? ''),
flag: String(meta.flag ?? ''),
price: Number(p.value) || 0,
unit: String(meta.unit ?? ''),
currency: String(meta.currency ?? ''),
}
}))
}
}
}
} catch { /* panel offline → keep static fallback */ }
}

157
src/data/media.ts Normal file
View File

@ -0,0 +1,157 @@
// ─────────────────────────────────────────────────────────────
// Home media content layer (videocast + podcast items)
// Mock/seed data. Replaced at runtime by bootstrapMedia() which
// reuses the EXISTING articles API (type='video' / type='podcast').
// No dedicated table — see articleToVideo / articleToPodcast below.
// ─────────────────────────────────────────────────────────────
export interface MediaLangText {
title: string;
guest: string;
body: string;
}
export interface VideoItem {
id: number | string;
fa: MediaLangText;
en: MediaLangText;
ep: string;
duration: string;
date: string;
thumb: string;
}
export interface PodcastItem {
id: number | string;
epNum: string;
date: string;
rating: string;
duration: string;
cover: string;
fa: MediaLangText & { ep: string };
en: MediaLangText & { ep: string };
}
export const videos: VideoItem[] = [
{
id: 1,
fa: { title: 'نگاهی به آن سوی مرزهای تولید', guest: 'گفتگو با مهندس سیامک فجری',
body: 'در ویدیوکست «Steel Horizon»، ما فراتر از داده‌های خام قیمت و تناژ، به سراغ مهندسی تغییر و معماری آینده صنعت می‌رویم. اینجا محل تلاقی استراتژی‌های کلان، تکنولوژی‌های برهم‌زننده و رهبرانی است که فولاد فردا را نه با کوره‌ها، که با «بینش» می‌سازند. ما طوفان‌های بازار را تحلیل می‌کنیم تا شما با اطمینان، مسیر آینده را ترسیم کنید.\nصنعت فولاد در حال ورود به پیچیده‌ترین چرخه حیات خود است؛ جایی که هوش مصنوعی، مقررات کربنی و ژئواکونومی، قواعد بازی را بازنویسی کرده‌اند. در «Steel Logic» با تحلیل‌گران ارشد و استراتژیست‌های تراز اول، به گفتگو می‌نشینیم تا لایه‌های پنهان این پیچیدگی‌ها را آشکار کنیم. اینجا خبری از هیجان‌زدگی نیست؛ فقط تحلیل‌های داده‌محور و تصمیم‌ساز.' },
en: { title: 'Beyond the Frontiers of Production', guest: 'A talk with Eng. Siamak Fajri',
body: 'In the "Steel Horizon" videocast we go beyond raw price and tonnage data to the engineering of change and the architecture of the industry\'s future.' },
ep: 'EP.12', duration: '۴۲ دقیقه', date: '۲۵ خرداد ۱۴۰۵', thumb: '/news/photo-1504307651254-35680f356dfd.jpg',
},
{
id: 2,
fa: { title: 'تحلیل بازار جهانی فلزات پایه', guest: 'گفتگو با کارشناسان بازار',
body: 'نوسانات قیمت مس، آلومینیوم و روی و تأثیر آن بر زنجیره فولاد را با تحلیل‌گران بازار بررسی می‌کنیم.' },
en: { title: 'Global Base Metals Analysis', guest: 'With market analysts',
body: 'Price volatility in copper, aluminum and zinc and its steel-chain impact.' },
ep: 'EP.11', duration: '۳۸ دقیقه', date: '۱۸ خرداد ۱۴۰۵', thumb: '/news/photo-1558618666-fcd25c85cd64.jpg',
},
{
id: 3,
fa: { title: 'ژئوپلیتیک و صادرات فولاد', guest: 'گفتگو با تحلیل‌گران ژئواکونومی',
body: 'تأثیر تحولات منطقه‌ای بر بازارهای صادراتی فولاد ایران و فرصت‌های پیش رو.' },
en: { title: 'Geopolitics & Steel Exports', guest: 'With geo-economics analysts',
body: 'How regional developments shape Iran\'s steel export markets.' },
ep: 'EP.10', duration: '۵۱ دقیقه', date: '۱۱ خرداد ۱۴۰۵', thumb: '/news/photo-1504328345606-18bbc8c9d7d1.jpg',
},
{
id: 4,
fa: { title: 'سرمایه‌گذاری در صنعت فولاد', guest: 'گفتگو با مدیران سرمایه‌گذاری',
body: 'فرصت‌ها و چالش‌های سرمایه‌گذاری در بخش‌های مختلف زنجیره ارزش فولاد.' },
en: { title: 'Steel Industry Investment', guest: 'With investment managers',
body: 'Opportunities and challenges across the steel value chain.' },
ep: 'EP.09', duration: '۴۵ دقیقه', date: '۴ خرداد ۱۴۰۵', thumb: '/news/photo-1611273426858-450d8e3c9fce.jpg',
},
]
export const podcasts: PodcastItem[] = [
{
id: 1,
epNum: 'اپیزود ۷۷', date: '۲۵ / خرداد / ۱۴۰۵', rating: '۴/۵', duration: '۱۰:۵۶', cover: '/news/photo-1540575467063-178a50c2df87.jpg',
fa: { title: 'کالبدشکافی عقلانی پیچیدگی‌های صنعتی', ep: 'قسمت ۱۲', guest: 'مهندس سیامک شجاعی',
body: '«صنعت فولاد در عصر حاضر، در پیچیده‌ترین چرخه حیات خود قرار گرفته است؛ جایی که همگرایی هوش مصنوعی، الزامات زیست‌محیطی و تنش‌های ژئواکونومیک، قواعد کلاسیک تولید و تجارت را به‌کلی بازنویسی کرده‌اند. در پادکست «نام انتخابی»، ما با عبور از هیاهوی رسانه‌ای و تحلیل‌های سطحی، به کالبدشکافی عقلانی این پیچیدگی‌ها می‌پردازیم. ما با دعوت از استراتژیست‌های تراز اول و خبرگان این حوزه، لایه‌های پنهان چالش‌های صنعتی را می‌شکافیم تا بینشی شفاف، داده‌محور و تصمیم‌ساز را در اختیار رهبری قرار دهیم که برای عبور از طوفان‌های تغییر، به قطب‌نمای دقیق‌تر از عرف‌های بازار نیاز دارند.»' },
en: { title: 'A Rational Dissection of Industrial Complexity', ep: 'Episode 12', guest: 'Eng. Siamak Shojaei',
body: 'Steel today is in its most complex cycle yet — where AI, environmental mandates and geo-economic tension rewrite the classic rules of production and trade.' },
},
{
id: 2,
epNum: 'اپیزود ۷۶', date: '۱۸ / خرداد / ۱۴۰۵', rating: '۴/۵', duration: '۱:۴۰:۱۸', cover: '/news/photo-1566873535350-6586b0b7a1f2.jpg',
fa: { title: 'آزمایشگاه کوره', ep: 'قسمت ۸', guest: 'کارشناسان بازار',
body: '«The Foundry Lab» محلی برای کالبدشکافی دقیق تحولات فناورانه و مدل‌های نوین فولادسازی است. ما در این پادکست با نگاهی موشکافانه، چالش‌های فنی را بررسی می‌کنیم.' },
en: { title: 'The Foundry Lab', ep: 'Episode 8', guest: 'Market experts',
body: 'The Foundry Lab dissects technological change and new steelmaking models with a meticulous engineering lens.' },
},
{
id: 3,
epNum: 'اپیزود ۷۵', date: '۱۱ / خرداد / ۱۴۰۵', rating: '۴/۵', duration: '۱:۴۰:۱۸', cover: '/news/photo-1578662996442-48f60103fc96.jpg',
fa: { title: 'معمار فولاد', ep: 'قسمت ۵', guest: 'رهبران صنعت',
body: 'در «Steel Architect»، ما به سراغ داستان ساختن فولاد فردا می‌رویم؛ روایتی که رهبرانی که از مدل‌های سنتی کسب‌وکار به چالش کشیده‌اند را به تصویر می‌کشد.' },
en: { title: 'Steel Architect', ep: 'Episode 5', guest: 'Industry leaders',
body: 'Steel Architect tells the story of building tomorrow\'s steel — leaders challenging traditional business models.' },
},
]
// ── article → media item mappers ───────────────────────────────
// The articles API has no per-language fields, so we put the same
// title/summary in both fa/en and let the component pick by lang.
function articleToVideo(a: Record<string, unknown>, i: number): VideoItem {
const title = String(a.title ?? '')
const guest = String(a.author ?? '')
const body = String(a.summary ?? '')
return {
id: String(a.id ?? i),
fa: { title, guest, body },
en: { title, guest, body },
ep: `EP.${i + 1}`,
duration: '',
date: String(a.publishDate ?? ''),
thumb: String(a.coverImage ?? '') || '/news/photo-1504307651254-35680f356dfd.jpg',
}
}
function articleToPodcast(a: Record<string, unknown>, i: number): PodcastItem {
const title = String(a.title ?? '')
const guest = String(a.author ?? '')
const body = String(a.summary ?? '')
const ep = `قسمت ${i + 1}`
return {
id: String(a.id ?? i),
epNum: `اپیزود ${i + 1}`,
date: String(a.publishDate ?? ''),
rating: '۵/۵',
duration: '',
cover: String(a.coverImage ?? '') || '/news/photo-1540575467063-178a50c2df87.jpg',
fa: { title, guest, body, ep },
en: { title, guest, body, ep },
}
}
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || 'http://localhost:3001'
export async function bootstrapMedia(): Promise<void> {
try {
const [videoRes, podRes] = await Promise.all([
fetch(`${PANEL_API}/api/articles?type=video&limit=50`),
fetch(`${PANEL_API}/api/articles?type=podcast&limit=50`),
])
if (videoRes.ok) {
const fetched = await videoRes.json()
if (Array.isArray(fetched) && fetched.length > 0) {
videos.length = 0
videos.push(...fetched.map((a: Record<string, unknown>, i: number) => articleToVideo(a, i)))
}
}
if (podRes.ok) {
const fetched = await podRes.json()
if (Array.isArray(fetched) && fetched.length > 0) {
podcasts.length = 0
podcasts.push(...fetched.map((a: Record<string, unknown>, i: number) => articleToPodcast(a, i)))
}
}
} catch { /* panel offline → keep static fallback */ }
}

View File

@ -72,3 +72,37 @@ export const plans: Plan[] = [
ctaVariant: 'paper', ctaVariant: 'paper',
}, },
]; ];
const PANEL_API =
(import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API ||
'http://localhost:3001';
export async function bootstrapPlans(): Promise<void> {
try {
const res = await fetch(`${PANEL_API}/api/plans`);
if (!res.ok) return;
const fetched = await res.json();
if (!Array.isArray(fetched) || fetched.length === 0) return;
plans.length = 0;
plans.push(
...fetched.map((p: Record<string, unknown>) => {
const price = Number(p.price) || 0;
const featured = Boolean(p.isFeatured);
return {
id: String(p.id),
name: String(p.nameFa ?? ''),
price,
priceLabel: price > 0 ? `${price.toLocaleString('fa-IR')} تومان` : 'تماس بگیرید',
period: String(p.periodFa ?? ''),
features: Array.isArray(p.features) ? (p.features as string[]) : [],
badge: (p.badgeFa as string) || undefined,
featured,
ctaLabel: String(p.ctaFa ?? 'شروع اشتراک'),
ctaVariant: (featured ? 'filled' : price === 0 ? 'paper' : 'outline') as PlanCtaVariant,
};
}),
);
} catch {
/* panel offline → keep static fallback */
}
}

69
src/data/risks.ts Normal file
View File

@ -0,0 +1,69 @@
/*
Market risk alerts (Scanner page). Static fallback mirrors the
original hardcoded list; replaced at startup by bootstrapRisks()
when the panel is reachable. Mutated in place so importers stay live.
*/
export type RiskLevel = 'high' | 'medium' | 'low';
export interface RiskAlert {
title: string;
desc: string;
level: RiskLevel;
}
export const riskAlerts: RiskAlert[] = [
{
title: 'فشار صادرات چین',
desc: 'افزایش ۱۸٪ صادرات ارزان چین فشار قیمتی روی بازار آسیا',
level: 'high',
},
{
title: 'محدودیت انرژی داخلی',
desc: 'کاهش تولید زمستانی به دلیل محدودیت گاز صنعتی',
level: 'medium',
},
{
title: 'فرصت بازار عراق',
desc: 'کاهش واردات ترکیه، فرصت افزایش سهم بازار برای ایران',
level: 'low',
},
];
/* panel risk levels (critical/high/medium/low/opportunity) → Scanner's 3 levels */
function mapLevel(level: unknown): RiskLevel {
switch (level) {
case 'critical':
case 'high':
return 'high';
case 'medium':
return 'medium';
case 'low':
case 'opportunity':
default:
return 'low';
}
}
const PANEL_API =
(import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API ||
'http://localhost:3001';
export async function bootstrapRisks(): Promise<void> {
try {
const res = await fetch(`${PANEL_API}/api/risks?limit=100`);
if (!res.ok) return;
const fetched = await res.json();
if (!Array.isArray(fetched) || fetched.length === 0) return;
riskAlerts.length = 0;
riskAlerts.push(
...fetched.map((r: Record<string, unknown>) => ({
title: String(r.name ?? ''),
desc: String(r.quote ?? ''),
level: mapLevel(r.level),
})),
);
} catch {
/* panel offline → keep static fallback */
}
}

View File

@ -107,3 +107,21 @@ export const team: TeamMember[] = [
email: 'hosseini@andishkade-foolad.ir', email: 'hosseini@andishkade-foolad.ir',
}, },
]; ];
const PANEL_API = (import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_PANEL_API || 'http://localhost:3001'
export async function bootstrapTeam(): Promise<void> {
try {
const res = await fetch(`${PANEL_API}/api/team?expert=0`)
if (!res.ok) return
const fetched = await res.json()
if (!Array.isArray(fetched) || fetched.length === 0) return
team.length = 0
team.push(...fetched.map((m: Record<string, unknown>) => ({
id: String(m.id), name: String(m.nameFa ?? ''), role: String(m.roleFa ?? ''),
initial: String(m.initial ?? ''), bio: String(m.bioFa ?? ''),
expertise: Array.isArray(m.expertise) ? m.expertise as string[] : [],
reportCount: Number(m.reportCount) || 0, email: m.email as string | undefined,
})))
} catch { /* panel offline → keep static fallback */ }
}

View File

@ -330,6 +330,27 @@ body {
.animate-marquee-reverse { animation: none; } .animate-marquee-reverse { animation: none; }
} }
/*
RTL ICON MIRROR FIX
Lucide SVGs that represent directional concepts
(arrows, chevrons, send, play) should flip in RTL
so they point the correct way. Non-directional icons
(phone, mail, pin, star) must NOT flip.
*/
[dir="rtl"] svg.lucide-arrow-right,
[dir="rtl"] svg.lucide-arrow-left,
[dir="rtl"] svg.lucide-chevron-right,
[dir="rtl"] svg.lucide-chevron-left,
[dir="rtl"] svg.lucide-send,
[dir="rtl"] svg.lucide-external-link,
[dir="rtl"] svg.lucide-log-in,
[dir="rtl"] svg.lucide-log-out {
transform: scaleX(-1);
}
/* opt-out: keep an icon pointing exactly as authored, ignoring the RTL mirror */
[dir="rtl"] svg.lucide.no-rtl-flip { transform: none !important; }
/* /*
PRINT PRINT
*/ */

19
src/lib/api.ts Normal file
View File

@ -0,0 +1,19 @@
const BASE = import.meta.env.VITE_PANEL_API ?? 'http://localhost:3001'
async function get<T>(path: string, params?: Record<string, string>): Promise<T> {
const url = new URL(`${BASE}${path}`)
if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v))
const res = await fetch(url.toString(), { credentials: 'include' })
if (!res.ok) throw new Error(`API ${path}${res.status}`)
return res.json() as Promise<T>
}
export const api = {
articles: (params?: Record<string, string>) => get<unknown[]>('/api/articles', params),
article: (id: string) => get<unknown>(`/api/articles/${id}`),
risks: () => get<unknown[]>('/api/risks'),
prices: () => get<unknown[]>('/api/prices'),
events: () => get<unknown[]>('/api/events'),
team: (expert?: boolean) => get<unknown[]>('/api/team', expert !== undefined ? { expert: expert ? '1' : '0' } : undefined),
plans: () => get<unknown[]>('/api/plans'),
}

View File

@ -4,8 +4,24 @@ import { RouterProvider } from 'react-router-dom'
import './index.css' import './index.css'
import { router } from './app/router' import { router } from './app/router'
import { bootstrapReports } from './data/reports' import { bootstrapReports } from './data/reports'
import { bootstrapEvents } from './data/events'
import { bootstrapTeam } from './data/team'
import { bootstrapRadar } from './content/radar'
import { bootstrapRadarPages } from './content/radarCategories'
import { bootstrapBanners } from './data/banners'
import { bootstrapPlans } from './data/plans'
import { bootstrapRisks } from './data/risks'
import { bootstrapMarket } from './data/market'
import { bootstrapFactoryReports } from './content/factoryReports'
import { bootstrapIntegrations } from './content/integrations'
import { bootstrapAbout } from './data/about'
import { bootstrapMedia } from './data/media'
await bootstrapReports() await Promise.all([
bootstrapReports(), bootstrapEvents(), bootstrapTeam(), bootstrapRadar(), bootstrapRadarPages(), bootstrapBanners(),
bootstrapPlans(), bootstrapRisks(), bootstrapMarket(),
bootstrapFactoryReports(), bootstrapIntegrations(), bootstrapAbout(), bootstrapMedia(),
])
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

209
src/pages/About/About.tsx Normal file
View File

@ -0,0 +1,209 @@
import { useLang } from '@/context/LangContext'
import { FlaskConical, Network, Leaf, Telescope, Sparkles, type LucideIcon } from 'lucide-react'
import { visionItems, advisoryBoard } from '@/data/about'
const NAVY = '#032340'
const GOLD = '#CD9E53'
// map the icon string key (stored in the CMS) → lucide component
const ICONS: Record<string, LucideIcon> = {
FlaskConical, Network, Leaf, Telescope, Sparkles,
}
const iconFor = (name: string): LucideIcon => ICONS[name] ?? Sparkles
const tr = {
fa: {
pageTitle: 'درباره ما',
teamTitle: 'تیم ما',
teamDesc: 'اندیشکده فولاد آینده از متخصصان برجسته صنعت، پژوهشگران و تحلیلگران باتجربه تشکیل شده است که با هم‌افزایی دانش و تجربه، مسیر آینده صنعت فولاد را روشن می‌کنند.',
visionTitle: 'چشم‌انداز ما',
visionDesc: 'ما در اندیشکده فولاد آینده، چشم‌اندازی روشن از صنعت فولادی پایدار، نوآور و رقابت‌پذیر در افق ۱۴۱۰ داریم. هدف ما ایجاد بستری برای تبادل دانش، تحلیل راهبردی و همافزایی میان بازیگران کلیدی این صنعت است.',
contactTitle: 'تماس با ما',
contactDesc: 'برای همکاری، مشاوره یا دریافت اطلاعات بیشتر با ما در تماس باشید.',
contactBtn: 'صفحه تماس با ما',
},
en: {
pageTitle: 'About Us',
teamTitle: 'Our Team',
teamDesc: 'The Steel Futures Institute is composed of prominent industry specialists, researchers, and experienced analysts who illuminate the path of the steel industry through shared expertise.',
visionTitle: 'Our Vision',
visionDesc: 'At the Steel Futures Institute, we hold a clear vision of a sustainable, innovative, and competitive steel industry by 2031. Our goal is to create a platform for knowledge exchange, strategic analysis, and synergy among key players in the industry.',
contactTitle: 'Contact Us',
contactDesc: 'Get in touch for collaboration, consulting, or more information.',
contactBtn: 'Go to Contact Page',
},
}
function Avatar({ name }: { name: string }) {
const initials = name.split(' ').slice(-2).map(w => w[0]).join('')
return (
<div style={{
width: 80, height: 80, borderRadius: '50%',
background: `linear-gradient(135deg, ${NAVY} 0%, #0a4a7a 100%)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, fontWeight: 800, color: GOLD, flexShrink: 0,
margin: '0 auto 16px',
boxShadow: '0 4px 16px rgba(3,35,64,0.18)',
}}>
{initials}
</div>
)
}
export default function About() {
const { lang } = useLang()
const t = tr[lang]
const dir = lang === 'fa' ? 'rtl' : 'ltr'
return (
<div dir={dir} style={{ background: 'var(--paper)', minHeight: '100vh' }}>
{/* ── breadcrumb ── */}
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{
borderBottom: '1px solid rgba(3,35,64,0.08)',
padding: '14px 0',
display: 'flex', alignItems: 'center', gap: 8,
fontSize: 13, color: 'rgba(3,35,64,0.5)',
}}>
<span style={{ color: NAVY, fontWeight: 700, fontSize: 16 }}>|</span>
<span style={{ color: NAVY, fontWeight: 700, fontSize: 15 }}>
{lang === 'fa' ? 'خانه' : 'Home'}
</span>
<span></span>
<span style={{ color: GOLD, fontWeight: 700 }}>{t.pageTitle}</span>
</div>
{/* ── TEAM section ── */}
<section id="team" className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 64, paddingBottom: 64 }}>
<div style={{ marginBottom: 48, textAlign: 'center' }}>
<span style={{ fontSize: 12, fontWeight: 700, color: GOLD, letterSpacing: 2, textTransform: 'uppercase' }}>
{lang === 'fa' ? 'اندیشکده فولاد آینده' : 'Steel Futures Institute'}
</span>
<h2 style={{ fontSize: 'clamp(26px,3vw,40px)', fontWeight: 900, color: NAVY, margin: '10px 0 16px' }}>
{t.teamTitle}
</h2>
<p style={{ fontSize: 15, color: 'rgba(3,35,64,0.65)', maxWidth: 600, margin: '0 auto', lineHeight: 1.9 }}>
{t.teamDesc}
</p>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 28,
}} className="max-lg:!grid-cols-2 max-md:!grid-cols-1">
{advisoryBoard.map((m) => {
const name = lang === 'fa' ? m.nameFa : m.nameEn
const role = lang === 'fa' ? m.roleFa : m.roleEn
return (
<div key={m.id} style={{
background: '#fff',
borderRadius: 16,
padding: '32px 24px',
textAlign: 'center',
boxShadow: '0 2px 16px rgba(3,35,64,0.07)',
border: '1px solid rgba(3,35,64,0.07)',
transition: 'transform 150ms, box-shadow 150ms',
}}
onMouseEnter={e => {
(e.currentTarget as HTMLElement).style.transform = 'translateY(-4px)'
;(e.currentTarget as HTMLElement).style.boxShadow = '0 8px 32px rgba(3,35,64,0.13)'
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.transform = 'none'
;(e.currentTarget as HTMLElement).style.boxShadow = '0 2px 16px rgba(3,35,64,0.07)'
}}
>
<Avatar name={name} />
<h3 style={{ fontSize: 15, fontWeight: 800, color: NAVY, margin: '0 0 8px' }}>{name}</h3>
<p style={{ fontSize: 13, color: 'rgba(3,35,64,0.55)', margin: 0 }}>{role}</p>
</div>
)
})}
</div>
</section>
{/* ── divider ── */}
<div style={{ height: 1, background: 'rgba(3,35,64,0.08)' }} />
{/* ── VISION section ── */}
<section id="vision" className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 64, paddingBottom: 64 }}>
<div style={{ marginBottom: 48, textAlign: 'center' }}>
<span style={{ fontSize: 12, fontWeight: 700, color: GOLD, letterSpacing: 2, textTransform: 'uppercase' }}>
{lang === 'fa' ? 'افق ۱۴۱۰' : 'Horizon 2031'}
</span>
<h2 style={{ fontSize: 'clamp(26px,3vw,40px)', fontWeight: 900, color: NAVY, margin: '10px 0 16px' }}>
{t.visionTitle}
</h2>
<p style={{ fontSize: 15, color: 'rgba(3,35,64,0.65)', maxWidth: 700, margin: '0 auto', lineHeight: 1.9 }}>
{t.visionDesc}
</p>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 24,
}} className="max-md:!grid-cols-1">
{visionItems.map((item, i) => {
const Icon = iconFor(item.icon)
const title = lang === 'fa' ? item.titleFa : item.titleEn
const desc = lang === 'fa' ? item.descFa : item.descEn
return (
<div key={item.id} style={{
background: i % 2 === 0 ? NAVY : '#fff',
borderRadius: 16,
padding: '36px 32px',
border: `1px solid ${i % 2 === 0 ? NAVY : 'rgba(3,35,64,0.1)'}`,
}}>
<Icon size={28} color={GOLD} strokeWidth={1.8} style={{ marginBottom: 16 }} />
<div style={{
width: 40, height: 4, background: GOLD,
borderRadius: 2, marginBottom: 20,
...(dir === 'rtl' ? { marginRight: 0 } : { marginLeft: 0 }),
}} />
<h3 style={{ fontSize: 18, fontWeight: 800, color: i % 2 === 0 ? '#fff' : NAVY, margin: '0 0 12px' }}>
{title}
</h3>
<p style={{ fontSize: 14, color: i % 2 === 0 ? 'rgba(255,255,255,0.7)' : 'rgba(3,35,64,0.6)', margin: 0, lineHeight: 1.8 }}>
{desc}
</p>
</div>
)
})}
</div>
</section>
{/* ── divider ── */}
<div style={{ height: 1, background: 'rgba(3,35,64,0.08)' }} />
{/* ── CONTACT teaser ── */}
<section style={{
padding: '64px 40px',
textAlign: 'center',
background: `linear-gradient(135deg, ${NAVY} 0%, #0a4a7a 100%)`,
}}>
<h2 style={{ fontSize: 'clamp(22px,2.5vw,34px)', fontWeight: 900, color: '#fff', margin: '0 0 16px' }}>
{t.contactTitle}
</h2>
<p style={{ fontSize: 15, color: 'rgba(255,255,255,0.7)', marginBottom: 32, maxWidth: 500, margin: '0 auto 32px' }}>
{t.contactDesc}
</p>
<a href="/contact" style={{
display: 'inline-block',
background: GOLD, color: NAVY,
borderRadius: 10, padding: '14px 40px',
fontSize: 15, fontWeight: 800,
textDecoration: 'none',
transition: 'transform 150ms',
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)' }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.transform = 'none' }}
>
{t.contactBtn}
</a>
</section>
</div>
)
}

View File

@ -0,0 +1,449 @@
import { useState, useRef } from 'react'
import { useLang } from '@/context/LangContext'
import { MapPin, Phone, Mail, ChevronDown, Paperclip, Send, X } from 'lucide-react'
const NAVY = '#032340'
const GOLD = '#CD9E53'
const tr = {
fa: {
breadcrumbHome: 'خانه',
breadcrumbContact: 'تماس با ما',
title: 'تماس با ما',
address: 'اصفهان، خیابان سعادت‌آباد',
phone: '۳۸۸۸۴۵۳۹ - ۰۳۱',
email: 'info@steelforesight.com',
addressLabel: 'آدرس',
phoneLabel: 'تلفن',
emailLabel: 'ایمیل',
description:
'اندیشکده فولاد آینده، بستری برای تبادل دانش و همافزایی است. ما با نگاهی تحلیلی به دیدگاه‌ها و پرسش‌های شما می‌نگریم و مشتاقانه آماده‌ایم تا در مسیر خلق ارزش‌های راهبردی، همراه و پاسخگوی شما باشیم.',
nameLabel: 'نام و نام خانوادگی',
namePlaceholder: 'علیرضا اکبریان',
subjectLabel: 'موضوع',
subjectPlaceholder: 'موضوع خود را انتخاب کنید',
subjects: ['همکاری پژوهشی', 'درخواست مشاوره', 'عضویت', 'گزارش فنی', 'سایر'],
emailLabel2: 'ایمیل',
emailPlaceholder: 'M.jalali@gamil.com',
phoneLabel2: 'شماره تماس',
phonePlaceholder: '۰۹۱۲۳۴۵۶۷۸۹',
messageLabel: 'متن پیام',
messagePlaceholder: 'پیام خود را وارد کنید...',
fileHint: 'حداکثر ۵ تصویر حداکثر یک مگابایت، یک ویدیو MP4 حداکثر ۵۰ مگابایت',
addFile: 'افزودن فایل',
submit: 'ثبت و ارسال',
required: 'این فیلد الزامی است',
subjectRequired: 'لطفاً توضیحات خود را وارد کنید.',
},
en: {
breadcrumbHome: 'Home',
breadcrumbContact: 'Contact Us',
title: 'Contact Us',
address: 'Isfahan, Saadatabad Street',
phone: '031-38884539',
email: 'info@steelforesight.com',
addressLabel: 'Address',
phoneLabel: 'Phone',
emailLabel: 'Email',
description:
'The Steel Futures Institute is a platform for knowledge exchange and synergy. We approach your ideas and questions with an analytical lens, and are eager to accompany you in creating strategic value.',
nameLabel: 'Full Name',
namePlaceholder: 'Alireza Akbarian',
subjectLabel: 'Subject',
subjectPlaceholder: 'Select a subject',
subjects: ['Research Collaboration', 'Consulting Request', 'Membership', 'Technical Report', 'Other'],
emailLabel2: 'Email',
emailPlaceholder: 'M.jalali@email.com',
phoneLabel2: 'Phone Number',
phonePlaceholder: '+98 912 345 6789',
messageLabel: 'Message',
messagePlaceholder: 'Write your message here...',
fileHint: 'Max 5 images up to 1MB each, one MP4 video up to 50MB',
addFile: 'Add File',
submit: 'Submit',
required: 'This field is required',
subjectRequired: 'Please enter your details.',
},
}
type FormState = {
name: string
subject: string
email: string
phone: string
message: string
files: File[]
}
type Errors = Partial<Record<keyof FormState, string>>
export default function Contact() {
const { lang } = useLang()
const t = tr[lang]
const isRtl = lang === 'fa'
const dir = isRtl ? 'rtl' : 'ltr'
const [form, setForm] = useState<FormState>({ name: '', subject: '', email: '', phone: '', message: '', files: [] })
const [errors, setErrors] = useState<Errors>({})
const [subjectOpen, setSubjectOpen] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const set = (field: keyof FormState, value: string) => {
setForm(f => ({ ...f, [field]: value }))
if (errors[field]) setErrors(e => ({ ...e, [field]: undefined }))
}
const validate = () => {
const e: Errors = {}
if (!form.name.trim()) e.name = t.required
if (!form.subject) e.subject = t.subjectRequired
if (!form.email.trim()) e.email = t.required
if (!form.phone.trim()) e.phone = t.required
setErrors(e)
return Object.keys(e).length === 0
}
const handleSubmit = (ev: React.FormEvent) => {
ev.preventDefault()
if (validate()) alert(isRtl ? 'پیام شما ارسال شد' : 'Your message has been sent')
}
const handleFiles = (ev: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(ev.target.files ?? [])
setForm(f => ({ ...f, files: [...f.files, ...picked].slice(0, 6) }))
ev.target.value = ''
}
const removeFile = (i: number) =>
setForm(f => ({ ...f, files: f.files.filter((_, idx) => idx !== i) }))
return (
<div dir={dir} style={{ background: 'var(--paper)', minHeight: '100vh' }}>
{/* ── breadcrumb ── */}
<div style={{
borderBottom: '1px solid rgba(3,35,64,0.08)',
padding: '14px 40px',
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
color: 'rgba(3,35,64,0.5)',
}}>
<span style={{ color: NAVY, fontWeight: 700, fontSize: 16 }}>|</span>
<span style={{ color: NAVY, fontWeight: 700, fontSize: 15 }}>{t.breadcrumbHome}</span>
<span>{isRtl ? '' : ''}</span>
<span style={{ color: GOLD, fontWeight: 700 }}>{t.breadcrumbContact}</span>
</div>
{/* ── breathing pin keyframes ── */}
<style>{`
@keyframes pin-pulse {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
50% { transform: translate(-50%, -50%) scale(1.18); opacity: 0.75; }
}
@keyframes ring-expand {
0% { transform: translate(-50%, -50%) scale(0.8); opacity: 0.6; }
100% { transform: translate(-50%, -50%) scale(2.6); opacity: 0; }
}
.map-pin-wrap { position: absolute; top: 50%; left: 50%; pointer-events: none; z-index: 10; }
.map-pin-dot { position: absolute; top: 50%; left: 50%;
width: 20px; height: 20px; background: #e53e3e; border: 3px solid #fff;
border-radius: 50%; box-shadow: 0 2px 10px rgba(0,0,0,0.35);
animation: pin-pulse 2s ease-in-out infinite; }
.map-pin-ring { position: absolute; top: 50%; left: 50%; width: 20px; height: 20px;
border: 2px solid rgba(229,62,62,0.55); border-radius: 50%;
animation: ring-expand 2s ease-out infinite; }
.map-pin-ring2 { animation-delay: 1s; }
`}</style>
{/* ── map + contact card ── */}
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 32 }}>
{/* wrapper: relative so card can overflow downward */}
<div style={{ position: 'relative', paddingBottom: 80 }}>
{/* map */}
<div style={{ borderRadius: 16, overflow: 'hidden', boxShadow: '0 4px 32px rgba(3,35,64,0.12)', height: 380 }} className="max-md:!h-[260px]">
<iframe
title="map"
src="https://www.google.com/maps/embed?pb=!1m14!1m12!1m3!1d13421.2!2d51.67!3d32.66!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!5e0!3m2!1sfa!2sir!4v1700000000000!5m2!1sfa!2sir"
style={{ width: '100%', height: '100%', border: 'none', display: 'block', filter: 'grayscale(10%)' }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
{/* breathing pin — centred on iframe */}
<div className="map-pin-wrap" style={{ position: 'absolute', top: '190px', left: '50%' }}>
<div className="map-pin-ring" />
<div className="map-pin-ring map-pin-ring2" />
<div className="map-pin-dot" />
</div>
</div>
{/* contact card — half inside map, half below */}
<div style={{
position: 'absolute',
bottom: 0,
right: 32,
background: '#fff',
borderRadius: 16,
padding: '28px 32px',
boxShadow: '0 8px 40px rgba(3,35,64,0.14)',
minWidth: 300,
direction: dir,
zIndex: 10,
}} className="max-md:!position-static max-md:!right-0 max-md:!min-w-0">
<h2 style={{ fontSize: 17, fontWeight: 900, color: NAVY, margin: '0 0 16px' }}>{t.title}</h2>
{[
{ icon: <MapPin size={15} color={GOLD} />, label: t.addressLabel, value: t.address },
{ icon: <Phone size={15} color={GOLD} />, label: t.phoneLabel, value: t.phone },
{ icon: <Mail size={15} color={GOLD} />, label: t.emailLabel, value: t.email },
].map(({ icon, label, value }) => (
<div key={label} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 12 }}>
<span style={{ marginTop: 2, flexShrink: 0 }}>{icon}</span>
<div style={{ fontSize: 13, color: NAVY, lineHeight: 1.6 }}>
<span style={{ color: 'rgba(3,35,64,0.45)', marginInlineEnd: 4 }}>{label}:</span>
{value}
</div>
</div>
))}
</div>
</div>
</div>
{/* ── form section ── */}
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 48, paddingBottom: 80 }}>
<p style={{ fontSize: 15, fontWeight: 700, color: NAVY, lineHeight: 2, marginBottom: 40 }}>
{t.description}
</p>
<form onSubmit={handleSubmit} noValidate>
{/* row 1: name + subject */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 20 }} className="max-md:!grid-cols-1">
<Field label={t.nameLabel} required error={errors.name} rtl={isRtl}>
<input
type="text"
placeholder={t.namePlaceholder}
value={form.name}
onChange={e => set('name', e.target.value)}
style={inputStyle(!!errors.name)}
/>
</Field>
<Field label={t.subjectLabel} required error={errors.subject} rtl={isRtl} noErrorMsg>
<div style={{ position: 'relative' }}>
<button
type="button"
onClick={() => setSubjectOpen(o => !o)}
style={{
...inputStyle(!!errors.subject),
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
background: '#fff',
width: '100%',
color: form.subject ? NAVY : 'rgba(3,35,64,0.35)',
textAlign: isRtl ? 'right' : 'left',
}}
>
<span style={{ flex: 1, textAlign: isRtl ? 'right' : 'left' }}>
{form.subject || t.subjectPlaceholder}
</span>
<ChevronDown size={16} style={{ flexShrink: 0 }} />
</button>
{subjectOpen && (
<ul style={{
position: 'absolute',
top: '100%',
left: 0, right: 0,
background: '#fff',
border: '1px solid rgba(3,35,64,0.15)',
borderRadius: 10,
marginTop: 4,
zIndex: 50,
overflow: 'hidden',
boxShadow: '0 8px 24px rgba(3,35,64,0.10)',
listStyle: 'none',
padding: 0,
margin: '4px 0 0',
}}>
{t.subjects.map(s => (
<li key={s}>
<button
type="button"
onClick={() => { set('subject', s); setSubjectOpen(false) }}
style={{
width: '100%',
padding: '10px 16px',
textAlign: isRtl ? 'right' : 'left',
background: form.subject === s ? 'rgba(205,158,83,0.08)' : 'transparent',
color: form.subject === s ? GOLD : NAVY,
fontSize: 14,
cursor: 'pointer',
border: 'none',
display: 'block',
fontWeight: form.subject === s ? 700 : 400,
fontFamily: 'inherit',
}}
>
{s}
</button>
</li>
))}
</ul>
)}
</div>
{errors.subject && (
<p style={{ color: '#e53e3e', fontSize: 12, marginTop: 4 }}>{errors.subject}</p>
)}
</Field>
</div>
{/* row 2: email + phone */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 20 }} className="max-md:!grid-cols-1">
<Field label={t.emailLabel2} required error={errors.email} rtl={isRtl}>
<input
type="email"
placeholder={t.emailPlaceholder}
value={form.email}
onChange={e => set('email', e.target.value)}
style={{ ...inputStyle(!!errors.email), direction: 'ltr', textAlign: isRtl ? 'right' : 'left' }}
/>
</Field>
<Field label={t.phoneLabel2} required error={errors.phone} rtl={isRtl}>
<input
type="tel"
placeholder={t.phonePlaceholder}
value={form.phone}
onChange={e => set('phone', e.target.value)}
style={{ ...inputStyle(!!errors.phone), direction: 'ltr', textAlign: isRtl ? 'right' : 'left' }}
/>
</Field>
</div>
{/* message */}
<div style={{ marginBottom: 20 }}>
<Field label={t.messageLabel} rtl={isRtl}>
<textarea
rows={5}
placeholder={t.messagePlaceholder}
value={form.message}
onChange={e => set('message', e.target.value)}
style={{ ...inputStyle(false), resize: 'vertical', minHeight: 120 }}
/>
</Field>
</div>
{/* file upload */}
<div style={{
border: '1.5px dashed rgba(3,35,64,0.2)',
borderRadius: 12,
padding: '24px 20px',
marginBottom: 32,
textAlign: 'center',
background: 'rgba(3,35,64,0.015)',
}}>
<p style={{ fontSize: 13, color: 'rgba(3,35,64,0.5)', marginBottom: 14 }}>{t.fileHint}</p>
{form.files.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center', marginBottom: 14 }}>
{form.files.map((f, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'rgba(3,35,64,0.07)', borderRadius: 8,
padding: '4px 10px', fontSize: 12, color: NAVY, maxWidth: 180,
}}>
<Paperclip size={12} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
<button type="button" onClick={() => removeFile(i)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(3,35,64,0.4)', padding: 0, lineHeight: 1 }}>
<X size={12} />
</button>
</div>
))}
</div>
)}
<input ref={fileRef} type="file" multiple accept="image/*,video/mp4" onChange={handleFiles} style={{ display: 'none' }} />
<button
type="button"
onClick={() => fileRef.current?.click()}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
border: `1.5px solid ${GOLD}`, borderRadius: 8,
padding: '8px 20px', background: 'transparent',
color: GOLD, fontSize: 14, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
}}
>
<Paperclip size={15} />
{t.addFile}
</button>
</div>
{/* submit — always left-aligned, icon on the left */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="submit"
style={{
display: 'inline-flex', alignItems: 'center', flexDirection: 'row-reverse', gap: 10,
background: '#2d6a2d', color: '#fff', border: 'none',
borderRadius: 10, padding: '13px 36px',
fontSize: 15, fontWeight: 800, cursor: 'pointer',
letterSpacing: 0.5, fontFamily: 'inherit',
}}
>
<Send size={16} style={{ transform: 'scaleX(-1)' }} />
{t.submit}
</button>
</div>
</form>
</div>
</div>
)
}
function Field({
label, required, error, children, noErrorMsg, style: extraStyle,
}: {
label: string
required?: boolean
error?: string
rtl?: boolean
children: React.ReactNode
noErrorMsg?: boolean
style?: React.CSSProperties
}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, ...extraStyle }}>
<label style={{
fontSize: 13, fontWeight: 700, color: NAVY,
display: 'flex', alignItems: 'center', gap: 4,
}}>
{required && <span style={{ color: '#e53e3e' }}>*</span>}
{label}
</label>
{children}
{!noErrorMsg && error && (
<p style={{ color: '#e53e3e', fontSize: 12, margin: 0 }}>{error}</p>
)}
</div>
)
}
function inputStyle(hasError: boolean): React.CSSProperties {
return {
width: '100%',
padding: '11px 14px',
border: `1.5px solid ${hasError ? '#e53e3e' : 'rgba(3,35,64,0.15)'}`,
borderRadius: 10,
fontSize: 14,
color: NAVY,
background: '#fff',
outline: 'none',
fontFamily: 'inherit',
boxSizing: 'border-box',
transition: 'border-color 150ms',
}
}

View File

@ -1,147 +1,81 @@
import { useState } from 'react' import { MapPin, Calendar } from 'lucide-react'
import { events } from '@/data/events'
import type { EventType } from '@/data/events'
import { useLang } from '@/context/LangContext' import { useLang } from '@/context/LangContext'
import { events } from '@/data/events'
type FilterValue = 'all' | EventType const NAVY = '#032340'
const GOLD = '#CD9E53'
const TYPE_BORDER_COLORS: Record<EventType, string> = { const TYPE_LABEL: Record<string, { fa: string; color: string }> = {
conference: 'var(--ink)', conference: { fa: 'کنفرانس', color: '#2471a3' },
exhibition: '#6b4a00', exhibition: { fa: 'نمایشگاه', color: '#1e8449' },
seminar: '#1a4a2a', seminar: { fa: 'نشست تخصصی', color: '#7d3c98' },
international: 'var(--red)', international: { fa: 'بین‌الملل', color: '#c0392b' },
}
const TYPE_LABELS = {
fa: { conference: 'کنگره', exhibition: 'نمایشگاه', seminar: 'سمینار', international: 'جهانی' },
en: { conference: 'Conference', exhibition: 'Exhibition', seminar: 'Seminar', international: 'International' },
}
const FILTERS = {
fa: [
{ label: 'همه', value: 'all' as FilterValue },
{ label: 'کنگره', value: 'conference' as FilterValue },
{ label: 'نمایشگاه', value: 'exhibition' as FilterValue },
{ label: 'سمینار', value: 'seminar' as FilterValue },
{ label: 'جهانی', value: 'international' as FilterValue },
],
en: [
{ label: 'All', value: 'all' as FilterValue },
{ label: 'Conference', value: 'conference' as FilterValue },
{ label: 'Exhibition', value: 'exhibition' as FilterValue },
{ label: 'Seminar', value: 'seminar' as FilterValue },
{ label: 'International', value: 'international' as FilterValue },
],
}
const T = {
fa: { heading: 'رویدادهای پیش‌رو', year: '۱۴۰۳–۱۴۰۴', empty: 'رویدادی در این دسته‌بندی یافت نشد' },
en: { heading: 'Upcoming Events', year: '14031404', empty: 'No events found in this category' },
}
function EventCard({ event, lang }: { event: (typeof events)[0]; lang: 'fa' | 'en' }) {
const [hovered, setHovered] = useState(false)
const borderColor = TYPE_BORDER_COLORS[event.type]
const typeLabels = TYPE_LABELS[lang]
return (
<article
style={{ border: '1px solid var(--rule-thin)', background: hovered ? 'var(--paper-2)' : 'var(--paper)', transition: 'background 140ms', display: 'flex', flexDirection: 'column' }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div style={{ display: 'flex', borderBottom: '2px solid var(--ink)' }}>
<div style={{ padding: '18px 24px', borderLeft: '1px solid var(--rule-thin)', minWidth: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: 'var(--paper)', flexShrink: 0 }}>
<span style={{ fontSize: 52, fontWeight: 900, lineHeight: 1, color: 'var(--ink)', letterSpacing: '-2px', display: 'block' }}>
{event.date.toLocaleString(lang === 'fa' ? 'fa-IR' : 'en-US')}
</span>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--ink-4)', marginTop: 4, display: 'block', whiteSpace: 'nowrap' }}>
{event.month} {event.year.toLocaleString(lang === 'fa' ? 'fa-IR' : 'en-US')}
</span>
</div>
<div style={{ flex: 1, padding: '18px 20px', display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 8 }}>
<span style={{ display: 'inline-block', alignSelf: 'flex-start', fontSize: 10, fontWeight: 700, letterSpacing: '1.2px', color: borderColor, border: `1px solid ${borderColor}`, padding: '2px 8px' }}>
{typeLabels[event.type]}
</span>
<h3 style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.45, color: 'var(--ink)', letterSpacing: '-0.3px' }}>{event.title}</h3>
</div>
</div>
<div style={{ padding: '16px 20px', flex: 1, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 4, height: 4, background: 'var(--ink-4)', flexShrink: 0, display: 'inline-block' }} />
<span style={{ fontSize: 12, color: 'var(--ink-4)', fontWeight: 400 }}>{event.location}،&nbsp;{event.city}</span>
</div>
<p style={{ fontSize: 13, fontWeight: 300, color: 'var(--ink-3)', lineHeight: 1.8 }}>{event.description}</p>
</div>
<div style={{ borderTop: '1px solid var(--rule-thin)', padding: '12px 20px', display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
{event.registrationOpen ? (
<a href="#" style={{ fontSize: 13, fontWeight: 700, color: 'var(--red)', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}>
{lang === 'fa' ? 'ثبت‌نام' : 'Register'}<span style={{ fontSize: 15 }}></span>
</a>
) : (
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--ink-5)' }}>
{lang === 'fa' ? 'به‌زودی' : 'Coming Soon'}
</span>
)}
</div>
</article>
)
} }
export default function Events() { export default function Events() {
const { lang } = useLang() const { lang } = useLang()
const t = T[lang] const isFa = lang === 'fa'
const filters = FILTERS[lang]
const [activeFilter, setActiveFilter] = useState<FilterValue>('all')
const filtered = activeFilter === 'all' ? events : events.filter(e => e.type === activeFilter)
return ( return (
<div style={{ maxWidth: 1280, margin: '0 auto', padding: 'clamp(24px,5vw,64px) clamp(16px,4vw,48px)', direction: lang === 'fa' ? 'rtl' : 'ltr' }}> <div dir={isFa ? 'rtl' : 'ltr'} style={{ background: '#faf7f4', minHeight: '80vh' }}>
<div style={{ display: 'flex', alignItems: 'stretch', borderBottom: '3px solid var(--ink)', marginBottom: 36 }}> <div style={{ background: NAVY, padding: '52px 0 40px' }}>
<div style={{ background: 'var(--ink)', padding: '14px 28px', display: 'flex', alignItems: 'center' }}> <div className="max-w-7xl mx-auto px-12 max-md:px-5">
<h1 style={{ fontSize: 24, fontWeight: 900, color: 'var(--paper)', letterSpacing: '-0.5px', whiteSpace: 'nowrap' }}>{t.heading}</h1> <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '3px', color: GOLD, marginBottom: 10, textTransform: 'uppercase' }}>
{isFa ? 'رسانه و رویداد' : 'Media & Events'}
</div> </div>
<div style={{ flex: 1 }} /> <h1 style={{ fontSize: 'clamp(26px,3vw,40px)', fontWeight: 900, color: '#fff', margin: '0 0 10px', letterSpacing: '-0.5px' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '14px 0 14px 4px' }}> {isFa ? 'رویداد‌ها' : 'Events'}
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--ink-4)' }}>{t.year}</span> </h1>
<p style={{ fontSize: 14, color: 'rgba(255,255,255,0.6)', margin: 0 }}>
{isFa ? 'همه رویدادها، کنفرانس‌ها و نمایشگاه‌های صنعت فولاد' : 'All events, conferences and exhibitions in the steel industry'}
</p>
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--rule-thin)', marginBottom: 40, overflowX: 'auto', scrollbarWidth: 'none' }}> <div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 48, paddingBottom: 72 }}>
{filters.map(f => { <div className="grid grid-cols-2 max-lg:grid-cols-1" style={{ gap: 20 }}>
const isActive = activeFilter === f.value {events.map((ev) => {
const typeInfo = TYPE_LABEL[ev.type] ?? { fa: ev.type, color: GOLD }
return ( return (
<button <div key={ev.id} style={{
key={f.value} background: '#fff', borderRadius: 14,
onClick={() => setActiveFilter(f.value)} boxShadow: '0 2px 16px rgba(7,29,73,0.07)',
style={{ padding: '10px 24px', fontSize: 13, fontWeight: isActive ? 700 : 500, border: 'none', background: isActive ? 'var(--ink)' : 'transparent', color: isActive ? 'var(--paper)' : 'var(--ink-4)', cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap', flexShrink: 0, transition: 'all 120ms', marginBottom: -1, outline: 'none' }} border: '1px solid rgba(7,29,73,0.07)',
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)' }}
onMouseLeave={e => { const d = e.currentTarget as HTMLElement; d.style.transform = ''; d.style.boxShadow = '0 2px 16px rgba(7,29,73,0.07)' }}
> >
{f.label} <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
{f.value !== 'all' && ( <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ marginLeft: 6, fontSize: 10, color: isActive ? 'rgba(244,239,231,0.6)' : 'var(--ink-6)' }}> <div style={{ background: NAVY, color: '#fff', borderRadius: 8, padding: '4px 12px', fontSize: 13, fontWeight: 800, display: 'flex', alignItems: 'center', gap: 6 }}>
({events.filter(e => e.type === f.value).length}) <Calendar size={13} />
<span>{ev.date} {ev.month} {ev.year}</span>
</div>
<span style={{ fontSize: 11, fontWeight: 700, padding: '3px 10px', borderRadius: 20, background: `${typeInfo.color}18`, color: typeInfo.color }}>
{isFa ? typeInfo.fa : ev.type}
</span>
</div>
{ev.registrationOpen && (
<span style={{ fontSize: 11, fontWeight: 700, color: '#1e8449', background: '#1e844918', padding: '3px 10px', borderRadius: 20 }}>
{isFa ? 'ثبت‌نام باز' : 'Open'}
</span> </span>
)} )}
</button> </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 className="events-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1, background: 'var(--rule-thin)' }}>
{filtered.map(event => <EventCard key={event.id} event={event} lang={lang} />)}
{filtered.length % 2 !== 0 && <div style={{ background: 'var(--paper)' }} />}
</div> </div>
{filtered.length === 0 && (
<div style={{ padding: '80px 0', textAlign: 'center', color: 'var(--ink-5)' }}>
<p style={{ fontSize: 15, fontWeight: 300 }}>{t.empty}</p>
</div>
)}
<style>{`@media (max-width: 767px) { .events-grid { grid-template-columns: 1fr !important; } }`}</style>
</div> </div>
) )
} }

View File

@ -481,7 +481,7 @@ export default function Experts() {
{/* Room 1: Sustainable Development */} {/* Room 1: Sustainable Development */}
<section style={{ marginBottom: 64 }}> <section style={{ marginBottom: 64 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, borderBottom: '2px solid #032340', pb: 10, marginBottom: 24, paddingBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, borderBottom: '2px solid #032340', marginBottom: 24, paddingBottom: 12 }}>
<Leaf size={20} color="#CD9E53" /> <Leaf size={20} color="#CD9E53" />
<h2 style={{ fontSize: 19, fontWeight: 900, color: '#032340', margin: 0 }}>{t.roomSustainability}</h2> <h2 style={{ fontSize: 19, fontWeight: 900, color: '#032340', margin: 0 }}>{t.roomSustainability}</h2>
</div> </div>
@ -494,7 +494,7 @@ export default function Experts() {
{/* Room 2: Steel Industry */} {/* Room 2: Steel Industry */}
<section style={{ marginBottom: 64 }}> <section style={{ marginBottom: 64 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, borderBottom: '2px solid #032340', pb: 10, marginBottom: 24, paddingBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, borderBottom: '2px solid #032340', marginBottom: 24, paddingBottom: 12 }}>
<Globe size={20} color="#CD9E53" /> <Globe size={20} color="#CD9E53" />
<h2 style={{ fontSize: 19, fontWeight: 900, color: '#032340', margin: 0 }}>{t.roomSteel}</h2> <h2 style={{ fontSize: 19, fontWeight: 900, color: '#032340', margin: 0 }}>{t.roomSteel}</h2>
</div> </div>
@ -507,7 +507,7 @@ export default function Experts() {
{/* Room 3: Economics */} {/* Room 3: Economics */}
<section style={{ marginBottom: 64 }}> <section style={{ marginBottom: 64 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, borderBottom: '2px solid #032340', pb: 10, marginBottom: 24, paddingBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, borderBottom: '2px solid #032340', marginBottom: 24, paddingBottom: 12 }}>
<BarChart3 size={20} color="#CD9E53" /> <BarChart3 size={20} color="#CD9E53" />
<h2 style={{ fontSize: 19, fontWeight: 900, color: '#032340', margin: 0 }}>{t.roomEconomics}</h2> <h2 style={{ fontSize: 19, fontWeight: 900, color: '#032340', margin: 0 }}>{t.roomEconomics}</h2>
</div> </div>
@ -520,7 +520,7 @@ export default function Experts() {
{/* Room 4: Technology & Innovation */} {/* Room 4: Technology & Innovation */}
<section> <section>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, borderBottom: '2px solid #032340', pb: 10, marginBottom: 24, paddingBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, borderBottom: '2px solid #032340', marginBottom: 24, paddingBottom: 12 }}>
<Cpu size={20} color="#CD9E53" /> <Cpu size={20} color="#CD9E53" />
<h2 style={{ fontSize: 19, fontWeight: 900, color: '#032340', margin: 0 }}>{t.roomTechnology}</h2> <h2 style={{ fontSize: 19, fontWeight: 900, color: '#032340', margin: 0 }}>{t.roomTechnology}</h2>
</div> </div>

View File

@ -6,7 +6,6 @@ import AtAGlanceSection from './sections/AtAGlanceSection'
import FeaturedReportBanner from './sections/FeaturedReportBanner' import FeaturedReportBanner from './sections/FeaturedReportBanner'
import MapEventsSection from './sections/MapEventsSection' import MapEventsSection from './sections/MapEventsSection'
import FactoryReportsScroll from './sections/FactoryReportsScroll' import FactoryReportsScroll from './sections/FactoryReportsScroll'
import NewsletterMarquee from './sections/NewsletterMarquee'
import VideocastPodcastSection from './sections/VideocastPodcastSection' import VideocastPodcastSection from './sections/VideocastPodcastSection'
// import RiskSection from './sections/RiskSection' // «نقشه ریسک صنعت» — disabled, restore anytime // import RiskSection from './sections/RiskSection' // «نقشه ریسک صنعت» — disabled, restore anytime
import { useLang } from '@/context/LangContext' import { useLang } from '@/context/LangContext'
@ -24,7 +23,6 @@ export default function Home() {
<FeaturedReportBanner /> <FeaturedReportBanner />
<IntegrationsSection /> <IntegrationsSection />
<MapEventsSection /> <MapEventsSection />
<NewsletterMarquee />
<FactoryReportsScroll /> <FactoryReportsScroll />
<VideocastPodcastSection only="video" /> <VideocastPodcastSection only="video" />
<VideocastPodcastSection only="pod" /> <VideocastPodcastSection only="pod" />

View File

@ -1,5 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { ChevronRight } from 'lucide-react'
import { Link } from 'react-router-dom'
import { useLang } from '@/context/LangContext' import { useLang } from '@/context/LangContext'
const NAVY = '#032340' const NAVY = '#032340'
@ -46,108 +48,117 @@ const ITEMS: Glance[] = [
export default function AtAGlanceSection({ embedded = false }: { embedded?: boolean }) { export default function AtAGlanceSection({ embedded = false }: { embedded?: boolean }) {
const { lang } = useLang() const { lang } = useLang()
const isFa = lang === 'fa' const isFa = lang === 'fa'
const [i, setI] = useState(0) const [idx, setIdx] = useState(0)
const [dir, setDir] = useState<1 | -1>(1) const [dir, setDir] = useState<1 | -1>(1)
const it = ITEMS[i] const it = ITEMS[idx]
const go = (d: 1 | -1) => { const go = (d: 1 | -1) => {
setDir(d) setDir(d)
setI((prev) => (prev + d + ITEMS.length) % ITEMS.length) setIdx((prev) => (prev + d + ITEMS.length) % ITEMS.length)
}
const T = {
heading: isFa ? 'در یک نگاه' : 'At a Glance',
} }
return ( return (
<section id="at-a-glance" style={{ background: '#faf7f4', padding: '52px 0', overflow: 'hidden', scrollMarginTop: '90px' }} dir={isFa ? 'rtl' : 'ltr'}> <section id="at-a-glance" style={{ background: '#faf7f4', padding: '52px 0', overflow: 'hidden', scrollMarginTop: '90px' }} dir={isFa ? 'rtl' : 'ltr'}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5"> <div className="max-w-7xl mx-auto px-12 max-md:px-5">
{/* ── heading row ── */}
{!embedded && ( {!embedded && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, justifyContent: 'flex-start', marginBottom: 24 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 32 }}>
<div style={{ width: 4, height: 28, background: GOLD, borderRadius: 2 }} /> {/* title — bilingual stacked */}
<h2 style={{ fontSize: 'clamp(22px, 2.2vw, 30px)', fontWeight: 900, color: NAVY, letterSpacing: '-0.5px', margin: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{T.heading} <div style={{ width: 4, height: 34, background: GOLD, borderRadius: 2, flexShrink: 0 }} />
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'rgba(7,29,73,0.4)', letterSpacing: '1.5px', textTransform: 'uppercase', marginBottom: 3 }}>
At a Glance
</div>
<h2 style={{ fontSize: 'clamp(20px, 2vw, 28px)', fontWeight: 900, color: NAVY, letterSpacing: '-0.5px', margin: 0, lineHeight: 1.2 }}>
{isFa ? 'در یک نگاه' : 'At a Glance'}
</h2> </h2>
</div> </div>
</div>
{/* مشاهده همه */}
<Link to="/at-a-glance" style={{
display: 'inline-flex', alignItems: 'center', gap: 6, direction: 'ltr',
fontSize: 13, fontWeight: 700, color: GOLD, textDecoration: 'none',
}}>
<div style={{ width: 24, height: 24, borderRadius: '50%', border: `1px solid ${GOLD}`, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ChevronRight size={14} strokeWidth={2.5} />
</div>
<span style={{ direction: isFa ? 'rtl' : 'ltr' }}>{isFa ? 'مشاهده همه' : 'View All'}</span>
</Link>
</div>
)} )}
{/* image on the LEFT, text on the RIGHT (matches the reference). {/* ── card row: image left, text right ── */}
Container is LTR; image is the first DOM child, so plain `row` lands it on the left. */} {/* image is taller; text card is centered vertically and overlaps image by ~48px */}
<div <div style={{ display: 'flex', alignItems: 'center', gap: 0, direction: 'ltr', position: 'relative' }} className="max-lg:!flex-col max-lg:!gap-8">
style={{ display: 'flex', flexDirection: isFa ? 'row' : 'row-reverse', alignItems: 'center', gap: 0, minHeight: 380, direction: 'ltr' }}
className="max-lg:!flex-col max-lg:!gap-8" {/* IMAGE — taller than the text card */}
> <div style={{ width: '40%', position: 'relative', flexShrink: 0, alignSelf: 'stretch' }} className="max-lg:!w-full">
{/* ── IMAGE side (with stacked-card shadow) ── */} {/* back shadow card */}
<div style={{ width: '35%', position: 'relative', flexShrink: 0 }} className="max-lg:!w-full max-lg:!max-w-[400px]">
{/* back card (offset, behind) — offset away from the text card */}
<div style={{ <div style={{
position: 'absolute', inset: 0, position: 'absolute', inset: 0,
transform: 'translate(-28px, -22px)', transform: 'translate(-20px, -16px)',
background: '#ece7e1', borderRadius: 18, background: '#e8e2db', borderRadius: 18,
boxShadow: '0 30px 70px -30px rgba(7,29,73,0.25)',
}} /> }} />
{/* front image — portrait on desktop, shorter landscape on mobile */} {/* image fills full height of its column */}
<div <div style={{ position: 'relative', borderRadius: 18, overflow: 'hidden', height: '100%', minHeight: 420 }} className="max-lg:!min-h-[260px]">
style={{ position: 'relative', borderRadius: 18, overflow: 'hidden', boxShadow: '0 40px 90px -30px rgba(7,29,73,0.45)' }}
className="aspect-[3/3.2] max-lg:!aspect-[16/10]"
>
<AnimatePresence mode="wait" custom={dir}> <AnimatePresence mode="wait" custom={dir}>
<motion.img <motion.img
key={i} key={idx}
src={it.img} src={it.img}
alt="" alt=""
initial={{ opacity: 0, scale: 1.06 }} initial={{ opacity: 0, scale: 1.06 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.02 }} exit={{ opacity: 0, scale: 1.02 }}
transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }} transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/> />
</AnimatePresence> </AnimatePresence>
</div> </div>
</div> </div>
{/* ── TEXT card side ── */} {/* TEXT — shorter, overlaps image by 48px on the left */}
<div style={{ flex: 1, position: 'relative', zIndex: 2 }} className="max-lg:!w-full"> <div style={{ flex: 1, position: 'relative', marginLeft: -48, zIndex: 2 }} className="max-lg:!ms-0 max-lg:!me-0">
<div style={{ <div style={{
background: '#fff', borderRadius: 6, background: '#fff',
boxShadow: '0 30px 80px -40px rgba(7,29,73,0.30)', borderRadius: 6,
padding: '40px 44px 32px', boxShadow: '0 20px 60px -30px rgba(7,29,73,0.25)',
// pull the card toward the image to overlap it (image is on the left in RTL) padding: '32px 40px 48px',
marginLeft: isFa ? '-8%' : 0,
marginRight: isFa ? 0 : '-8%',
direction: isFa ? 'rtl' : 'ltr', direction: isFa ? 'rtl' : 'ltr',
}} className="max-lg:!ms-0 max-lg:!me-0 max-md:!p-6"> borderLeft: `4px solid ${GOLD}`,
position: 'relative',
}} className="max-md:!p-6">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
key={i} key={idx}
initial={{ opacity: 0, x: dir * 24 }} initial={{ opacity: 0, x: dir * 24 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: dir * -16 }} exit={{ opacity: 0, x: dir * -16 }}
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }} transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
> >
<h3 style={{ fontSize: 'clamp(20px, 1.9vw, 27px)', fontWeight: 900, color: NAVY, lineHeight: 1.4, letterSpacing: '-0.3px', margin: '0 0 18px' }}> <h3 style={{ fontSize: 'clamp(18px, 1.7vw, 25px)', fontWeight: 900, color: NAVY, lineHeight: 1.5, letterSpacing: '-0.3px', margin: '0 0 18px' }}>
{isFa ? it.title : it.titleEn} {isFa ? it.title : it.titleEn}
</h3> </h3>
{/* date (leading side) + source (trailing side) */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20, flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20, flexWrap: 'wrap', gap: 8 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 13, color: 'rgba(7,29,73,0.55)' }}> <span style={{ fontSize: 14, fontWeight: 700, color: GOLD }}>{it.source}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'rgba(7,29,73,0.55)' }}>
<CalendarIcon /> <CalendarIcon />
{isFa ? it.date : it.dateEn} {isFa ? it.date : it.dateEn}
</span> </span>
<span style={{ fontSize: 14, fontWeight: 700, color: GOLD, letterSpacing: '0.3px' }}>{it.source}</span>
</div> </div>
<p style={{ fontSize: 'clamp(13px, 1.05vw, 15px)', lineHeight: 2.1, color: 'rgba(7,29,73,0.72)', margin: 0, textAlign: 'justify' }}> <p style={{ fontSize: 'clamp(13px, 1vw, 15px)', lineHeight: 2.1, color: 'rgba(7,29,73,0.72)', margin: 0, textAlign: 'justify' }}>
{isFa ? it.desc : it.descEn} {isFa ? it.desc : it.descEn}
</p> </p>
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
{/* prev / next controls */} {/* nav buttons — half inside / half outside bottom of text card */}
<div style={{ display: 'flex', gap: 12, marginTop: 28 }}> <div style={{ position: 'absolute', bottom: -19, left: 40, display: 'flex', gap: 8, zIndex: 4 }}>
<NavBtn onClick={() => go(-1)} aria="prev"></NavBtn> <NavBtn onClick={() => go(-1)} aria="prev"></NavBtn>
<NavBtn onClick={() => go(1)} aria="next"></NavBtn> <NavBtn onClick={() => go(1)} aria="next"></NavBtn>
</div> </div>
@ -166,13 +177,15 @@ function NavBtn({ children, onClick, aria }: { children: React.ReactNode; onClic
aria-label={aria} aria-label={aria}
style={{ style={{
width: 38, height: 38, borderRadius: '50%', width: 38, height: 38, borderRadius: '50%',
background: '#fff', color: GOLD, border: `1.5px solid ${GOLD}`, cursor: 'pointer', background: GOLD, color: '#fff',
border: 'none', cursor: 'pointer',
fontSize: 20, fontWeight: 700, lineHeight: 1, fontSize: 20, fontWeight: 700, lineHeight: 1,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'inherit', transition: 'background 180ms, color 180ms', fontFamily: 'inherit', transition: 'background 180ms, transform 180ms',
boxShadow: '0 4px 14px rgba(205,158,83,0.45)',
}} }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = GOLD; (e.currentTarget as HTMLElement).style.color = '#fff' }} onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = '#b88d46'; (e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = '#fff'; (e.currentTarget as HTMLElement).style.color = GOLD }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = GOLD; (e.currentTarget as HTMLElement).style.transform = 'translateY(0)' }}
> >
{children} {children}
</button> </button>
@ -181,7 +194,7 @@ function NavBtn({ children, onClick, aria }: { children: React.ReactNode; onClic
function CalendarIcon() { function CalendarIcon() {
return ( return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke={GOLD} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={GOLD} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" /> <rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" /> <line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" /> <line x1="8" y1="2" x2="8" y2="6" />

View File

@ -1,6 +1,9 @@
import { Download } from 'lucide-react'
import { BentoGrid, BentoGridItem } from '@/components/ui/bento-grid' import { BentoGrid, BentoGridItem } from '@/components/ui/bento-grid'
import { useLang } from '@/context/LangContext' import { useLang } from '@/context/LangContext'
const MSC_URL = 'https://www.msc.ir/fa-IR/Portal/8029/page/%D9%85%D8%A7%D9%87%D9%86%D8%A7%D9%85%D9%87-%D8%AA%D8%AD%D9%84%DB%8C%D9%84%DB%8C-%D8%B1%D8%A7%D9%87%D8%A8%D8%B1%D8%AF%DB%8C-%DA%A9%D8%A7%D8%B1%D8%AE%D8%A7%D9%86%D9%87'
type FactoryReport = { type FactoryReport = {
publisher: string publisher: string
publisherEn: string publisherEn: string
@ -88,8 +91,25 @@ const REPORTS: FactoryReport[] = [
] ]
const T = { const T = {
fa: { overline: 'MOBARAKEH STEEL', heading: 'ماهنامه تحلیلی کارخانه', all: 'مشاهده همه ←' }, fa: { overline: 'MOBARAKEH STEEL', heading: 'ماهنامه تحلیلی کارخانه', all: 'مشاهده همه', download: 'دریافت گزارش' },
en: { overline: 'MOBARAKEH STEEL', heading: 'Factory Monthly Analysis', all: 'View All →' }, en: { overline: 'MOBARAKEH STEEL', heading: 'Monthly Industry Briefing', all: 'View All', download: 'Download Report' },
}
function DownloadCTA({ label }: { label: string }) {
return (
<a
href={MSC_URL}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, direction: 'ltr',
fontSize: 12, fontWeight: 700, color: 'var(--red)', textDecoration: 'none',
}}
>
<Download size={14} strokeWidth={2.5} />
<span>{label}</span>
</a>
)
} }
function ReportCover({ report, lang }: { report: FactoryReport; lang: 'fa' | 'en' }) { function ReportCover({ report, lang }: { report: FactoryReport; lang: 'fa' | 'en' }) {
@ -135,7 +155,7 @@ export default function FactoryBentoSection() {
<div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)', marginBottom: 6 }}>{t.overline}</div> <div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)', marginBottom: 6 }}>{t.overline}</div>
<h2 style={{ fontSize: 'clamp(22px,2.5vw,32px)', fontWeight: 900, color: 'var(--ink)', letterSpacing: '-0.5px' }}>{t.heading}</h2> <h2 style={{ fontSize: 'clamp(22px,2.5vw,32px)', fontWeight: 900, color: 'var(--ink)', letterSpacing: '-0.5px' }}>{t.heading}</h2>
</div> </div>
<a href="#" style={{ fontSize: 12, fontWeight: 700, color: 'var(--red)', textDecoration: 'none' }}>{t.all}</a> <a href={MSC_URL} target="_blank" rel="noopener noreferrer" style={{ fontSize: 12, fontWeight: 700, color: 'var(--red)', textDecoration: 'none' }}>{t.all}</a>
</div> </div>
<BentoGrid> <BentoGrid>
@ -147,6 +167,7 @@ export default function FactoryBentoSection() {
tagColor={report.accentColor} tagColor={report.accentColor}
title={lang === 'fa' ? report.title : report.titleEn} title={lang === 'fa' ? report.title : report.titleEn}
description={lang === 'fa' ? report.description : report.descriptionEn} description={lang === 'fa' ? report.description : report.descriptionEn}
footer={<DownloadCTA label={t.download} />}
accentLine accentLine
className={i === 3 || i === 6 ? 'md:col-span-2' : ''} className={i === 3 || i === 6 ? 'md:col-span-2' : ''}
/> />

View File

@ -1,8 +1,10 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { ArrowLeft } from 'lucide-react' import { Download } from 'lucide-react'
import { useLang } from '@/context/LangContext' import { useLang } from '@/context/LangContext'
import { FACTORY_REPORTS, FACTORY_REPORTS_META, type FactoryReport, type ScrollDir } from '@/content/factoryReports' import { FACTORY_REPORTS, FACTORY_REPORTS_META, type FactoryReport, type ScrollDir } from '@/content/factoryReports'
const MSC_URL = 'https://www.msc.ir/fa-IR/Portal/8029/page/%D9%85%D8%A7%D9%87%D9%86%D8%A7%D9%85%D9%87-%D8%AA%D8%AD%D9%84%DB%8C%D9%84%DB%8C-%D8%B1%D8%A7%D9%87%D8%A8%D8%B1%D8%AF%DB%8C-%DA%A9%D8%A7%D8%B1%D8%AE%D8%A7%D9%86%D9%87'
const NAVY = '#032340' const NAVY = '#032340'
const GOLD = '#CD9E53' const GOLD = '#CD9E53'
const EASE = [0.22, 1, 0.36, 1] as const const EASE = [0.22, 1, 0.36, 1] as const
@ -51,10 +53,18 @@ function ReportCard({ r, lang }: { r: FactoryReport; lang: 'fa' | 'en' }) {
<div style={{ fontSize: 11, color: 'var(--ink-5)', marginBottom: 8 }}>{t.date}</div> <div style={{ fontSize: 11, color: 'var(--ink-5)', marginBottom: 8 }}>{t.date}</div>
<h3 style={{ fontSize: 16, fontWeight: 900, color: NAVY, lineHeight: 1.45, margin: '0 0 10px' }}>{t.title}</h3> <h3 style={{ fontSize: 16, fontWeight: 900, color: NAVY, lineHeight: 1.45, margin: '0 0 10px' }}>{t.title}</h3>
<p style={{ fontSize: 12.5, lineHeight: 1.9, color: 'rgba(7,29,73,0.62)', margin: '0 0 14px' }}>{t.excerpt}</p> <p style={{ fontSize: 12.5, lineHeight: 1.9, color: 'rgba(7,29,73,0.62)', margin: '0 0 14px' }}>{t.excerpt}</p>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, fontWeight: 800, color: GOLD }}> <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
{FACTORY_REPORTS_META[lang].cta} <a
<ArrowLeft size={14} style={{ transform: lang === 'fa' ? 'none' : 'rotate(180deg)' }} /> href={MSC_URL}
</span> target="_blank"
rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, direction: 'ltr', fontSize: 12, fontWeight: 800, color: GOLD, textDecoration: 'none' }}
onClick={e => e.stopPropagation()}
>
<Download size={14} strokeWidth={2.5} />
<span>{FACTORY_REPORTS_META[lang].cta}</span>
</a>
</div>
</div> </div>
</article> </article>
) )
@ -62,7 +72,6 @@ function ReportCard({ r, lang }: { r: FactoryReport; lang: 'fa' | 'en' }) {
export default function FactoryReportsScroll() { export default function FactoryReportsScroll() {
const { lang } = useLang() const { lang } = useLang()
const m = FACTORY_REPORTS_META[lang]
const reports = FACTORY_REPORTS.slice(0, 4) // compact: just the latest 4 const reports = FACTORY_REPORTS.slice(0, 4) // compact: just the latest 4
return ( return (
@ -72,12 +81,11 @@ export default function FactoryReportsScroll() {
{/* compact heading row — title on the start side, "view all" on the end */} {/* compact heading row — title on the start side, "view all" on the end */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 12, marginBottom: 24, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 12, marginBottom: 24, flexWrap: 'wrap' }}>
<div> <div>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '3px', color: GOLD, marginBottom: 6 }}>{m.overline}</div> <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '3px', color: GOLD, marginBottom: 6 }}>Monthly Industry Report</div>
<h2 style={{ fontSize: 'clamp(20px, 2vw, 28px)', fontWeight: 900, color: NAVY, letterSpacing: '-0.5px', margin: 0 }}>{m.heading}</h2> <h2 style={{ fontSize: 'clamp(20px, 2vw, 28px)', fontWeight: 900, color: NAVY, letterSpacing: '-0.5px', margin: 0 }}>ماهنامه تحلیلی کارخانه</h2>
</div> </div>
<a href="#" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, fontWeight: 800, color: GOLD, textDecoration: 'none', whiteSpace: 'nowrap' }}> <a href={MSC_URL} target="_blank" rel="noopener noreferrer" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, fontWeight: 800, color: GOLD, textDecoration: 'none', whiteSpace: 'nowrap' }}>
{m.cta} مشاهده همه
<ArrowLeft size={14} style={{ transform: lang === 'fa' ? 'none' : 'rotate(180deg)' }} />
</a> </a>
</div> </div>

View File

@ -1,36 +1,29 @@
import { Download } from 'lucide-react' import { Download } from 'lucide-react'
import { useLang } from '@/context/LangContext' import { useLang } from '@/context/LangContext'
import { bannerFor } from '@/data/banners'
const NAVY = '#032340' const NAVY = '#032340'
const GOLD = '#CD9E53' const GOLD = '#CD9E53'
const BOOK = '/Horizontal_Book_Mockup_6%201.webp' const FALLBACK_BOOK = '/Horizontal_Book_Mockup_6%201.webp'
const T = {
fa: {
overline: 'ماهنامه شماره ۱۲ منتشر شد…',
title: 'دیپلماسی معدنی در عصر انسدادهای ژئوپلیتیک',
subtitle: 'Mineral Diplomacy in the Era of Geopolitical Blockades',
cta: 'دانلود گزارش',
},
en: {
overline: 'Monthly issue #12 is out…',
title: 'Mineral Diplomacy in the Era of Geopolitical Blockades',
subtitle: 'دیپلماسی معدنی در عصر انسدادهای ژئوپلیتیک',
cta: 'Download report',
},
}
export default function FeaturedReportBanner() { export default function FeaturedReportBanner() {
const { lang } = useLang() const { lang } = useLang()
const isFa = lang === 'fa' const isFa = lang === 'fa'
const t = T[lang]
// no PDF yet — button present but downloads nothing // CMS-managed banner (bootstrapped at startup); render nothing if none active
const onDownload = () => { /* TODO: wire real PDF when available */ } const banner = bannerFor('featured')
if (!banner) return null
const t = banner[lang]
const book = banner.image || FALLBACK_BOOK
// ctaUrl from CMS (PDF link or route); no-op when empty
const onDownload = () => {
if (banner.ctaUrl) window.open(banner.ctaUrl, '_blank', 'noopener')
}
return ( return (
<section style={{ background: 'var(--paper)', padding: '48px 0 72px' }} dir={isFa ? 'rtl' : 'ltr'}> <section style={{ background: 'var(--paper)', padding: '48px 0 72px' }} dir={isFa ? 'rtl' : 'ltr'}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5"> <div className="max-w-7xl mx-auto px-12 max-md:px-4">
{/* wrapper allows the book to spill outside the frame */} {/* wrapper allows the book to spill outside the frame */}
<div style={{ position: 'relative', overflow: 'visible' }}> <div style={{ position: 'relative', overflow: 'visible' }}>
@ -46,21 +39,21 @@ export default function FeaturedReportBanner() {
}} className="max-md:hidden" /> }} className="max-md:hidden" />
</div> </div>
{/* content: text on the LEFT, book on the RIGHT (text is first DOM child, plain row) */} {/* DESKTOP: row. MOBILE: column */}
<div style={{ position: 'relative', display: 'flex', flexDirection: 'row', alignItems: 'center', minHeight: 240, direction: 'ltr' }} <div style={{ position: 'relative', display: 'flex', alignItems: 'center', minHeight: 240, direction: 'ltr' }}
className="max-md:!flex-col"> className="max-md:!flex-col">
{/* TEXT — left */} {/* TEXT */}
<div style={{ width: '52%', padding: '40px 48px', direction: isFa ? 'rtl' : 'ltr' }} <div style={{ width: '52%', padding: '40px 48px', direction: isFa ? 'rtl' : 'ltr' }}
className="max-md:!w-full max-md:!px-6 max-md:!pt-8 max-md:!pb-6 max-md:!text-center"> className="max-md:!w-full max-md:!px-6 max-md:!pb-7 max-md:!pt-5 max-md:!order-2">
<div style={{ fontSize: 13, fontWeight: 700, color: 'rgba(255,255,255,0.55)', marginBottom: 14 }}>{t.overline}</div> <div style={{ fontSize: 13, fontWeight: 700, color: 'rgba(255,255,255,0.55)', marginBottom: 10 }}>{t.overline}</div>
<h2 style={{ fontSize: 'clamp(20px, 2.3vw, 30px)', fontWeight: 900, color: '#fff', lineHeight: 1.4, letterSpacing: '-0.5px', margin: '0 0 8px' }}> <h2 style={{ fontSize: 'clamp(18px, 2.3vw, 30px)', fontWeight: 900, color: '#fff', lineHeight: 1.4, letterSpacing: '-0.5px', margin: '0 0 8px' }}>
{t.title} {t.title}
</h2> </h2>
<div style={{ fontSize: 'clamp(12px, 1.1vw, 15px)', color: 'rgba(255,255,255,0.58)', marginBottom: 24, direction: 'ltr', textAlign: isFa ? 'right' : 'left' }} className="max-md:!text-center"> <div style={{ fontSize: 'clamp(12px, 1.1vw, 15px)', color: 'rgba(255,255,255,0.58)', marginBottom: 20, direction: 'ltr', textAlign: isFa ? 'right' : 'left' }}>
{t.subtitle} {t.subtitle}
</div> </div>
{/* button BELOW the text */} <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button <button
onClick={onDownload} onClick={onDownload}
style={{ style={{
@ -76,18 +69,28 @@ export default function FeaturedReportBanner() {
{t.cta} {t.cta}
</button> </button>
</div> </div>
</div>
{/* BOOK — right, spilling out of the frame */} {/* BOOK */}
<div style={{ width: '48%', position: 'relative', alignSelf: 'stretch' }} className="max-md:!w-full"> <div style={{ width: '48%', position: 'relative', alignSelf: 'stretch' }}
className="max-md:!w-full max-md:!order-1 max-md:!flex max-md:!justify-center max-md:!pt-6">
{/* desktop */}
<img <img
src={BOOK} src={book}
alt={t.title} alt={t.title}
style={{ style={{
position: 'absolute', insetInlineEnd: '-2%', top: '50%', transform: 'translateY(-50%)', position: 'absolute', insetInlineEnd: '-2%', top: '50%', transform: 'translateY(-50%)',
width: '116%', maxWidth: 'none', height: 'auto', width: '116%', maxWidth: 'none', height: 'auto',
filter: 'drop-shadow(0 30px 46px rgba(0,0,0,0.5))', filter: 'drop-shadow(0 30px 46px rgba(0,0,0,0.5))',
}} }}
className="max-md:!relative max-md:!w-full max-md:!translate-y-0 max-md:!top-0 max-md:!end-0 max-md:!p-4" className="max-md:!hidden"
/>
{/* mobile — natural flow, no absolute */}
<img
src={book}
alt=""
style={{ filter: 'drop-shadow(0 16px 30px rgba(0,0,0,0.45))' }}
className="hidden max-md:!block max-md:!w-[85%] max-md:!max-w-[300px] max-md:!h-auto"
/> />
</div> </div>

View File

@ -84,7 +84,7 @@ export default function GlobalScannerSection() {
<div className="max-w-7xl mx-auto w-full px-12 max-md:px-5 pb-12"> <div className="max-w-7xl mx-auto w-full px-12 max-md:px-5 pb-12">
{/* ── Text header ────────────────────────────────── */} {/* ── Text header ────────────────────────────────── */}
<div <div
style={{ textAlign: 'center', paddingTop: 72, paddingBottom: 48 }} style={{ textAlign: 'center', paddingTop: 40, paddingBottom: 24 }}
> >
{/* Headline */} {/* Headline */}
<h2 style={{ <h2 style={{

View File

@ -1,22 +1,51 @@
import { useRef, useState, useEffect, useLayoutEffect } from 'react' import { useRef, useState, useEffect, useLayoutEffect } 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 { Link } from 'react-router-dom'
import { useLang } from '@/context/LangContext' import { useLang } from '@/context/LangContext'
gsap.registerPlugin(ScrollTrigger) gsap.registerPlugin(ScrollTrigger)
const NAVY = '#032340' const NAVY = '#032340'
const GOLD = '#CD9E53'
const PAPER = '#faf7f4' // cream background revealed under the de-zoom const PAPER = '#faf7f4' // cream background revealed under the de-zoom
const HERO = { const SLIDES = {
fa: { fa: [
title: 'آینده فولاد را امروز رصد کن.', {
desc: 'هوشمندی که هر بازار را رصد می‌کند و راهبرد برنده را در یک نگاه به تو می‌دهد.', title: 'بینشی نو برای توسعه‌ی صنعتی',
desc: 'آشنایی با چشم‌انداز ما',
cta1: 'درباره ما', cta1href: '/about',
}, },
en: { {
title: 'See the future of steel today.', title: 'داده، تحلیل، آینده‌نگاری',
desc: 'Intelligence that scans every market and hands you the winning strategy at a glance.', desc: 'تصمیم‌سازی هوشمند در جهانی در حال تحول\n+۱۰۰ منبع داده جهانی، رصد ۲۴/۷ بازارها',
cta1: 'نبض صنعت', cta1href: '/scanner',
}, },
{
title: 'آزمون استراتژی در آینده‌ها',
desc: 'آزمایشگاه آینده',
cta1: 'آینده‌پژوهی و رصد هوشمند', cta1href: '/radar',
},
],
en: [
{
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',
cta1: 'Industry Pulse', cta1href: '/scanner',
},
{
title: 'Testing Strategy in Futures',
desc: 'The Futures Laboratory',
cta1: 'Foresight & Smart Monitoring', cta1href: '/radar',
},
],
} }
const CENTER_IMG = '/hero%201.webp' const CENTER_IMG = '/hero%201.webp'
@ -36,7 +65,15 @@ const SATELLITES: Sat[] = [
/* ─── MAIN ─── */ /* ─── MAIN ─── */
export default function HeroSection() { export default function HeroSection() {
const { lang } = useLang() const { lang } = useLang()
const t = HERO[lang] const [slideIdx, setSlideIdx] = useState(0)
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)
@ -177,10 +214,34 @@ export default function HeroSection() {
</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 0', 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)',
}}> }}>
{t.desc} {t.desc}
</p> </p>
{/* CTA buttons */}
<div style={{ display: 'flex', gap: 12, direction: 'ltr', flexWrap: 'wrap', marginBottom: 28 }}>
{t.cta1 && (
<Link to={t.cta1href} style={{
display: 'inline-flex', alignItems: 'center', gap: 8, direction: 'ltr',
background: GOLD, color: NAVY, borderRadius: 10,
padding: '12px 24px', fontSize: 14, fontWeight: 800, textDecoration: 'none',
boxShadow: '0 8px 24px -8px rgba(205,158,83,0.6)',
}}>
<ArrowRight size={16} strokeWidth={2.5} />
<span style={{ direction: 'rtl' }}>{t.cta1}</span>
</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>

View File

@ -1,3 +1,4 @@
import { useMemo } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { import {
ArrowLeft, Landmark, Globe, BarChart3, Activity, Database, ArrowLeft, Landmark, Globe, BarChart3, Activity, Database,
@ -18,15 +19,15 @@ const ICONS: Record<IntegrationIcon, LucideIcon> = {
database: Database, newspaper: Newspaper, building: Building2, trending: TrendingUp, coins: Coins, database: Database, newspaper: Newspaper, building: Building2, trending: TrendingUp, coins: Coins,
} }
const CELL = new Map(INTEGRATIONS.map((it) => [`${it.col}-${it.row}`, it]))
export default function IntegrationsSection() { export default function IntegrationsSection() {
const { lang } = useLang() const { lang } = useLang()
const isFa = lang === 'fa' const isFa = lang === 'fa'
const m = INTEGRATIONS_META[lang] const m = INTEGRATIONS_META[lang]
// Built at render time so it reflects the post-bootstrap INTEGRATIONS array.
const CELL = useMemo(() => new Map(INTEGRATIONS.map((it) => [`${it.col}-${it.row}`, it])), [])
return ( return (
<section style={{ background: NAVY, padding: '96px 0', overflow: 'hidden', position: 'relative' }} dir={isFa ? 'rtl' : 'ltr'}> <section style={{ background: NAVY, padding: '64px 0', overflow: 'hidden', position: 'relative' }} dir={isFa ? 'rtl' : 'ltr'}>
{/* drifting gold glow */} {/* drifting gold glow */}
<motion.div <motion.div
animate={{ x: [0, 26, 0], y: [0, -18, 0], opacity: [0.8, 1, 0.8] }} animate={{ x: [0, 26, 0], y: [0, -18, 0], opacity: [0.8, 1, 0.8] }}

View File

@ -8,10 +8,10 @@ export default function MapEventsSection() {
return ( return (
<section dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper)', borderBottom: '1px solid var(--rule-thin)', overflow: 'hidden' }}> <section dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper)', borderBottom: '1px solid var(--rule-thin)', overflow: 'hidden' }}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5"> <div className="max-w-7xl mx-auto px-12 max-md:px-5">
<div style={{ display: 'flex', alignItems: 'stretch', minHeight: 520 }} className="max-md:flex-col"> <div style={{ display: 'flex', alignItems: 'stretch', minHeight: 360 }} className="max-md:flex-col">
{/* ── Strategic events — RIGHT in RTL ── */} {/* ── Strategic events — RIGHT in RTL ── */}
<div style={{ flex: '4 4 0', minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '48px 24px' }} className="max-md:!px-5"> <div style={{ flex: '4 4 0', minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '24px 24px' }} className="max-md:!px-5">
<StrategicEvents /> <StrategicEvents />
</div> </div>

View File

@ -1,42 +1,43 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Telescope, Scale, Database, Users, Lightbulb, type LucideIcon } from 'lucide-react' import { Telescope, Scale, Database, Users, Lightbulb, Network, type LucideIcon } from 'lucide-react'
import { useLang } from '@/context/LangContext' import { useLang } from '@/context/LangContext'
import { RadarCard } from '@/pages/Radar/Radar' import { RadarCard } from '@/pages/Radar/Radar'
import { RADAR_ITEMS } from '@/content/radar' import { RADAR_ITEMS } from '@/content/radar'
/* ─── رویکرد ما — the institute's approach (new content, not duplicated in the navbar) ─── */
type Area = { Icon: LucideIcon; color: string; title: string; desc: string } type Area = { Icon: LucideIcon; color: string; title: string; desc: string }
const RESEARCH_AREAS: Record<'fa' | 'en', Area[]> = { const RESEARCH_AREAS: Record<'fa' | 'en', Area[]> = {
fa: [ fa: [
{ Icon: Telescope, color: '#CD9E53', title: 'آینده‌نگاری علمی', desc: 'به‌جای پیش‌بینی تک‌نقطه‌ای، با سناریوپردازی و تحلیل روند، چند آیندهٔ محتمل صنعت فولاد را ترسیم می‌کنیم.' }, { Icon: Database, color: '#2471a3', title: 'تحلیل داده‌محور', desc: 'استفاده از داده‌های بازار، اقتصاد، فناوری و زنجیره ارزش فولاد برای استخراج بینش‌های قابل اتکا.' },
{ Icon: Database, color: '#2471a3', title: 'تحلیل داده‌محور', desc: 'هر بینش بر پایهٔ داده‌های معتبر جهانی و داخلی است، نه گمانه‌زنی؛ تصمیم‌سازی با شواهد قابل‌اتکا.' }, { Icon: Network, color: '#7d3c98', title: 'نگاه نظام‌مند', desc: 'تحلیل صنعت فولاد در تعامل با اقتصاد، انرژی، فناوری، محیط زیست و سیاست‌های جهانی.' },
{ Icon: Scale, color: '#1e8449', title: 'بی‌طرفی و استقلال', desc: 'تحلیل‌های ما مستقل از منافع کوتاه‌مدت است؛ شفاف و بدون جانب‌داری از یک بازیگر خاص.' }, { Icon: Users, color: '#16a085', title: 'هم‌اندیشی خبرگان', desc: 'بهره‌گیری مستمر از شبکه‌ای از خبرگان صنعت، دانشگاه و فناوری.' },
{ Icon: Users, color: '#7d3c98', title: 'هم‌اندیشی خبرگان', desc: 'شبکه‌ای از مدیران، پژوهشگران و فعالان صنعت که دانش پراکنده را به بینش جمعی تبدیل می‌کند.' }, { Icon: Telescope, color: '#CD9E53', title: 'آینده‌نگاری علمی', desc: 'تحلیل روندها، شناسایی عدم‌قطعیت‌ها و طراحی سناریوها.' },
{ Icon: Lightbulb, color: '#c0392b', title: 'از تحلیل تا اقدام', desc: 'یافته‌ها را به راهبردهای عملی و قابل‌اجرا برای رهبران صنعت ترجمه می‌کنیم، نه گزارش‌های روی طاقچه.' }, { Icon: Lightbulb, color: '#c0392b', title: 'از تحلیل تا اقدام', desc: 'تبدیل تحلیل‌ها به توصیه‌ها و تجویزهای استراتژیک.' },
{ Icon: Scale, color: '#1e8449', title: 'بی‌طرفی و استقلال', desc: 'اندیشه‌های مستقل و متکی بر شواهد، داده‌ها و نگاه بلندمدت.' },
], ],
en: [ en: [
{ Icon: Telescope, color: '#CD9E53', title: 'Scientific Foresight', desc: 'Instead of single-point prediction, we map several plausible futures of the steel industry through scenario planning.' }, { Icon: Database, color: '#2471a3', title: 'Data-Driven Analysis', desc: 'Using market, economic, technology and value-chain data to extract reliable insights.' },
{ Icon: Database, color: '#2471a3', title: 'Data-Driven Analysis', desc: 'Every insight rests on credible global and domestic data — evidence, not guesswork.' }, { Icon: Network, color: '#7d3c98', title: 'Systemic Thinking', desc: 'Analysing the steel industry in its interaction with economy, energy, technology, environment and global policy.' },
{ Icon: Scale, color: '#1e8449', title: 'Independence & Neutrality', desc: 'Our analysis is independent of short-term interests — transparent and unbiased toward any single actor.' }, { Icon: Users, color: '#16a085', title: 'Expert Collaboration', desc: 'Continuous engagement with a network of industry, academic and technology experts.' },
{ Icon: Users, color: '#7d3c98', title: 'Expert Collaboration', desc: 'A network of executives, researchers and practitioners turning scattered knowledge into collective insight.' }, { Icon: Telescope, color: '#CD9E53', title: 'Scientific Foresight', desc: 'Trend analysis, uncertainty identification and scenario design.' },
{ Icon: Lightbulb, color: '#c0392b', title: 'From Analysis to Action', desc: 'We translate findings into practical strategies for industry leaders — not shelf-ware reports.' }, { Icon: Lightbulb, color: '#c0392b', title: 'From Analysis to Action',desc: 'Turning analyses into actionable strategic recommendations.' },
{ Icon: Scale, color: '#1e8449', title: 'Independence & Neutrality', desc: 'Evidence-based, data-driven thinking with a long-term perspective.' },
], ],
} }
const T = { const T = {
fa: { fa: {
radarOverline: 'FUTURE RADAR', radarHeading: 'رادار آینده', radarOverline: 'FUTURE RADAR', radarHeading: 'رادار آینده',
radarSub: 'سیگنال‌های جهانی با اثر مستقیم بر صنعت فولاد ایران', radarSub: 'تحلیل چندوجهی آینده صنعت فولاد',
radarAll: 'همه سیگنال‌ها ←', radarAll: 'همه سیگنال‌ها ←',
areasOverline: 'OUR APPROACH', areasHeading: 'رویکرد ما', areasOverline: 'OUR FRAMEWORK', areasHeading: 'چارچوب اندیشه ما',
areasSub: 'اصول و روش کار اندیشکده فولاد آینده در تحلیل و آینده‌نگاری صنعت', areasSub: 'ترکیب داده، تحلیل، آینده‌نگاری و خرد جمعی برای فهم تحولات صنعت فولاد.',
}, },
en: { en: {
radarOverline: 'FUTURE RADAR', radarHeading: 'Future Radar', radarOverline: 'FUTURE RADAR', radarHeading: 'Future Radar',
radarSub: 'Global signals with direct impact on Iran\'s steel industry', radarSub: 'Multi-dimensional analysis of the steel industry\'s future',
radarAll: 'All Signals →', radarAll: 'All Signals →',
areasOverline: 'OUR APPROACH', areasHeading: 'Our Approach', areasOverline: 'OUR FRAMEWORK', areasHeading: 'Our Thinking Framework',
areasSub: 'How the Steel Futures Institute works — its principles for analysis and foresight', areasSub: 'Combining data, analysis, foresight and collective wisdom to understand steel industry transformations.',
}, },
} }
@ -91,22 +92,22 @@ export default function RadarTechSection({ only }: { only?: 'radar' | 'approach'
<div <div
key={i} key={i}
style={{ style={{
background: '#fff', borderRadius: 16, padding: '26px 24px', background: '#fff', borderRadius: 16, padding: '20px 18px',
boxShadow: '0 2px 12px rgba(0,0,0,0.06)', display: 'flex', flexDirection: 'column', gap: 14, boxShadow: '0 2px 12px rgba(0,0,0,0.06)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10,
border: '1px solid var(--rule-thin)', transition: 'transform 0.2s, box-shadow 0.2s', border: '1px solid var(--rule-thin)', transition: 'transform 0.2s, box-shadow 0.2s', textAlign: 'center',
}} }}
onMouseEnter={e => { const d = e.currentTarget as HTMLElement; d.style.transform = 'translateY(-4px)'; d.style.boxShadow = '0 14px 34px -18px rgba(7,29,73,0.25)' }} onMouseEnter={e => { const d = e.currentTarget as HTMLElement; d.style.transform = 'translateY(-4px)'; d.style.boxShadow = '0 14px 34px -18px rgba(7,29,73,0.25)' }}
onMouseLeave={e => { const d = e.currentTarget as HTMLElement; d.style.transform = 'translateY(0)'; d.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)' }} onMouseLeave={e => { const d = e.currentTarget as HTMLElement; d.style.transform = 'translateY(0)'; d.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)' }}
> >
<div style={{ <div style={{
width: 52, height: 52, borderRadius: 14, flexShrink: 0, width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: `${a.color}15`, display: 'flex', alignItems: 'center', justifyContent: 'center', background: `${a.color}15`, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}> }}>
<Icon size={26} color={a.color} strokeWidth={2} /> <Icon size={22} color={a.color} strokeWidth={2} />
</div> </div>
<div> <div>
<h3 style={{ fontSize: 16, fontWeight: 900, color: 'var(--ink)', lineHeight: 1.4, margin: '0 0 8px' }}>{a.title}</h3> <h3 style={{ fontSize: 14, fontWeight: 900, color: 'var(--ink)', lineHeight: 1.4, margin: '0 0 6px' }}>{a.title}</h3>
<p style={{ fontSize: 12.5, color: 'var(--ink-4)', lineHeight: 1.85, margin: 0 }}>{a.desc}</p> <p style={{ fontSize: 12, color: 'var(--ink-4)', lineHeight: 1.7, margin: 0 }}>{a.desc}</p>
</div> </div>
</div> </div>
) )

View File

@ -1,7 +1,8 @@
import { useRef, useState, useEffect } from 'react' import { useRef, useState, useEffect } from 'react'
import { motion, AnimatePresence, useScroll, useMotionValueEvent } from 'framer-motion' import { motion, AnimatePresence, useScroll, useMotionValueEvent } from 'framer-motion'
import { useLang } from '@/context/LangContext' import { useLang } from '@/context/LangContext'
import { ArrowLeft, ArrowRight } from 'lucide-react' import { ArrowLeft } from 'lucide-react'
import { Link } from 'react-router-dom'
const NAVY = '#032340' const NAVY = '#032340'
const GOLD = '#CD9E53' const GOLD = '#CD9E53'
@ -21,15 +22,15 @@ const FEATURES: Feat[] = [
img: '/hero%201.webp', imgSide: 'right', imgPos: 'right 35%', img: '/hero%201.webp', imgSide: 'right', imgPos: 'right 35%',
fa: { fa: {
kicker: 'اندیشکده فولاد آینده', kicker: 'اندیشکده فولاد آینده',
title: 'بینش راهبردی', title: 'بینشی نو برای توسعه‌ی صنعتی',
desc: 'عصرِ رقابت بر سر تناژ به پایان رسیده است. در جهان پرشتاب فردا، هژمونی صنعتی از «عمق تحلیل»، «تسلط بر شبکه‌های داده» و «جسارت در طراحی آینده» خلق می‌شود.', desc: '',
stat: '۶ بازار', stat: '۶ بازار',
statTail: 'رصد ۲۴ ساعته. هشدار در کمتر از ۱۲ ثانیه.', statTail: 'رصد ۲۴ ساعته. هشدار در کمتر از ۱۲ ثانیه.',
}, },
en: { en: {
kicker: 'Future Steel Institute', kicker: 'Future Steel Institute',
title: 'Strategic Vision', title: 'A New Vision for Industrial Development',
desc: 'The era of tonnage competition is over. Industrial leadership is built on analytical depth, data mastery, and the courage to design the future.', desc: '',
stat: '6 markets', stat: '6 markets',
statTail: 'Scanned 24/7. Alerts in under 12 seconds.', statTail: 'Scanned 24/7. Alerts in under 12 seconds.',
}, },
@ -38,15 +39,15 @@ const FEATURES: Feat[] = [
img: '/hero%202.webp', imgSide: 'left', imgPos: 'center center', img: '/hero%202.webp', imgSide: 'left', imgPos: 'center center',
fa: { fa: {
kicker: 'فناوری صنعتی', kicker: 'فناوری صنعتی',
title: 'رادار فناوری', title: 'داده، تحلیل، آینده‌نگاری',
desc: 'انقلاب هوش مصنوعی در حال بازنویسی DNA زنجیره ارزش است. ما سیگنال‌های ضعیف فناوری را شکار کرده و آن‌ها را به استراتژی‌های برنده برای رهبران صنعتی تبدیل می‌کنیم.', desc: 'تصمیم‌سازی هوشمند در جهانی در حال تحول\n+۱۰۰ منبع داده جهانی، رصد ۲۴/۷ بازارها',
stat: '٪۳۸', stat: '٪۳۸',
statTail: 'میانگین حاشیه سود در هر معامله.', statTail: 'میانگین حاشیه سود در هر معامله.',
}, },
en: { en: {
kicker: 'Industrial Technology', kicker: 'Industrial Technology',
title: 'Technology Radar', title: 'Data, Analysis, Foresight',
desc: 'AI is rewriting the DNA of the value chain. We hunt weak technology signals and transform them into winning strategies for industry leaders.', desc: 'Smart decision-making in a world in transformation\n100+ global data sources, 24/7 market monitoring',
stat: '38%', stat: '38%',
statTail: 'Average margin per deal.', statTail: 'Average margin per deal.',
}, },
@ -55,15 +56,15 @@ const FEATURES: Feat[] = [
img: '/hero%203.webp', imgSide: 'right', imgPos: 'center center', img: '/hero%203.webp', imgSide: 'right', imgPos: 'center center',
fa: { fa: {
kicker: 'ژئواکونومی', kicker: 'ژئواکونومی',
title: 'کلان‌روندها', title: 'آزمون استراتژی در آینده‌ها',
desc: 'هندسه قدرت در صنعت در حال تغییر است. رمزگشایی از تقاطع پیچیده ژئواکونومی، مقررات کربنی و گذار انرژی؛ قطب‌نمای شما برای عبور ایمن از طوفان‌های آینده.', desc: '',
stat: '۲۰ ساعت', stat: '۲۰ ساعت',
statTail: 'صرفه‌جویی هفتگی نسبت به روش دستی.', statTail: 'صرفه‌جویی هفتگی نسبت به روش دستی.',
}, },
en: { en: {
kicker: 'Geo-Economics', kicker: 'Geo-Economics',
title: 'Macro Trends', title: 'Testing Strategy in Futures',
desc: 'The geometry of power is shifting. Decoding the complex intersection of geo-economics, carbon regulations and energy transition — your compass for navigating the storms ahead.', desc: '',
stat: '20 hrs', stat: '20 hrs',
statTail: 'Saved per week vs. manual.', statTail: 'Saved per week vs. manual.',
}, },
@ -130,8 +131,9 @@ function TextPanel({ index, feat, lang }: { index: number; feat: Feat; lang: 'fa
} }
const buttonText = lang === 'fa' const buttonText = lang === 'fa'
? (index === 0 ? 'آشنایی با مأموریت ما' : index === 1 ? 'تحلیل‌های فناوری' : 'داشبوردهای پایداری') ? (index === 0 ? 'آشنایی با چشم‌انداز ما' : index === 1 ? 'نبض صنعت' : 'آزمایشگاه آینده')
: (index === 0 ? 'Our Mission' : index === 1 ? 'Technology Analysis' : 'Sustainability Dashboards') : (index === 0 ? 'Discover our perspective' : index === 1 ? 'Industry Pulse' : 'The Futures Laboratory')
const buttonHref = index === 0 ? '/about' : index === 1 ? '/pulse' : '/radar'
return ( return (
<div style={{ <div style={{
@ -141,7 +143,7 @@ function TextPanel({ index, feat, lang }: { index: number; feat: Feat; lang: 'fa
textAlign: lang === 'fa' ? 'right' : 'left', textAlign: lang === 'fa' ? 'right' : 'left',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: lang === 'fa' ? 'flex-start' : 'flex-end', alignItems: 'flex-end',
}}> }}>
<FadeUp> <FadeUp>
<div style={{ display: 'flex', justifyContent: lang === 'fa' ? 'flex-start' : 'flex-end', width: '100%' }}> <div style={{ display: 'flex', justifyContent: lang === 'fa' ? 'flex-start' : 'flex-end', width: '100%' }}>
@ -150,12 +152,13 @@ function TextPanel({ index, feat, lang }: { index: number; feat: Feat; lang: 'fa
</FadeUp> </FadeUp>
<FadeUp delay={0.05}> <FadeUp delay={0.05}>
<h2 style={{ <h2 style={{
fontSize: 'clamp(28px, 3.4vw, 50px)', fontSize: 'clamp(18px, 3.4vw, 50px)',
fontWeight: 900, fontWeight: 900,
color: textColor, color: textColor,
lineHeight: 1.15, lineHeight: 1.15,
margin: '0 0 20px', margin: '0 0 20px',
}}> whiteSpace: 'nowrap',
}} className="max-md:!whitespace-normal max-md:!text-[18px]">
{t.title} {t.title}
</h2> </h2>
</FadeUp> </FadeUp>
@ -171,24 +174,24 @@ function TextPanel({ index, feat, lang }: { index: number; feat: Feat; lang: 'fa
</p> </p>
</FadeUp> </FadeUp>
<FadeUp delay={0.15}> <FadeUp delay={0.15}>
<button style={{ <Link to={buttonHref} style={{
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
gap: 12, gap: 12,
padding: '14px 28px', padding: '14px 28px',
borderRadius: 30, borderRadius: 30,
border: 'none', textDecoration: 'none',
cursor: 'pointer',
fontFamily: 'inherit', fontFamily: 'inherit',
fontSize: 14, fontSize: 14,
fontWeight: 700, fontWeight: 700,
direction: 'ltr',
background: isSlide2 ? NAVY : GOLD, background: isSlide2 ? NAVY : GOLD,
color: isSlide2 ? '#fff' : NAVY, color: isSlide2 ? '#fff' : NAVY,
transition: 'all 0.2s', transition: 'all 0.2s',
}} className="hover:!scale-105 hover:!opacity-95"> }} className="hover:!scale-105 hover:!opacity-95">
<ArrowLeft size={16} strokeWidth={2.5} className="no-rtl-flip" style={{ flexShrink: 0 }} />
<span>{buttonText}</span> <span>{buttonText}</span>
{lang === 'fa' ? <ArrowLeft size={16} strokeWidth={2.5} /> : <ArrowRight size={16} strokeWidth={2.5} />} </Link>
</button>
</FadeUp> </FadeUp>
</div> </div>
) )

View File

@ -1,93 +1,30 @@
import { useState } from 'react' import { useState } from 'react'
import { useLang } from '@/context/LangContext' import { useLang } from '@/context/LangContext'
import { Download, BookOpen, Podcast, Clock, Calendar, Play, ChevronLeft, ChevronRight } from 'lucide-react' import { Download, BookOpen, Podcast, Clock, Calendar, Play, ChevronRight } from 'lucide-react'
import { videos as VIDEOS, podcasts as PODCASTS } from '@/data/media'
const NAVY = '#032340' const NAVY = '#032340'
const GOLD = '#CD9E53' const GOLD = '#CD9E53'
const HASHTAGS = ['#فولاد _ آینده', '#فولاد _ آینده', '#فولاد _ آینده']
const VIDEOS = [
{
id: 1,
fa: { title: 'نگاهی به آن سوی مرزهای تولید', guest: 'گفتگو با مهندس سیامک فجری',
body: 'در ویدیوکست «Steel Horizon»، ما فراتر از داده‌های خام قیمت و تناژ، به سراغ مهندسی تغییر و معماری آینده صنعت می‌رویم. اینجا محل تلاقی استراتژی‌های کلان، تکنولوژی‌های برهم‌زننده و رهبرانی است که فولاد فردا را نه با کوره‌ها، که با «بینش» می‌سازند. ما طوفان‌های بازار را تحلیل می‌کنیم تا شما با اطمینان، مسیر آینده را ترسیم کنید.\nصنعت فولاد در حال ورود به پیچیده‌ترین چرخه حیات خود است؛ جایی که هوش مصنوعی، مقررات کربنی و ژئواکونومی، قواعد بازی را بازنویسی کرده‌اند. در «Steel Logic» با تحلیل‌گران ارشد و استراتژیست‌های تراز اول، به گفتگو می‌نشینیم تا لایه‌های پنهان این پیچیدگی‌ها را آشکار کنیم. اینجا خبری از هیجان‌زدگی نیست؛ فقط تحلیل‌های داده‌محور و تصمیم‌ساز.' },
en: { title: 'Beyond the Frontiers of Production', guest: 'A talk with Eng. Siamak Fajri',
body: 'In the "Steel Horizon" videocast we go beyond raw price and tonnage data to the engineering of change and the architecture of the industry\'s future.' },
ep: 'EP.12', duration: '۴۲ دقیقه', date: '۲۵ خرداد ۱۴۰۵', thumb: '/news/photo-1504307651254-35680f356dfd.jpg',
},
{
id: 2,
fa: { title: 'تحلیل بازار جهانی فلزات پایه', guest: 'گفتگو با کارشناسان بازار',
body: 'نوسانات قیمت مس، آلومینیوم و روی و تأثیر آن بر زنجیره فولاد را با تحلیل‌گران بازار بررسی می‌کنیم.' },
en: { title: 'Global Base Metals Analysis', guest: 'With market analysts',
body: 'Price volatility in copper, aluminum and zinc and its steel-chain impact.' },
ep: 'EP.11', duration: '۳۸ دقیقه', date: '۱۸ خرداد ۱۴۰۵', thumb: '/news/photo-1558618666-fcd25c85cd64.jpg',
},
{
id: 3,
fa: { title: 'ژئوپلیتیک و صادرات فولاد', guest: 'گفتگو با تحلیل‌گران ژئواکونومی',
body: 'تأثیر تحولات منطقه‌ای بر بازارهای صادراتی فولاد ایران و فرصت‌های پیش رو.' },
en: { title: 'Geopolitics & Steel Exports', guest: 'With geo-economics analysts',
body: 'How regional developments shape Iran\'s steel export markets.' },
ep: 'EP.10', duration: '۵۱ دقیقه', date: '۱۱ خرداد ۱۴۰۵', thumb: '/news/photo-1504328345606-18bbc8c9d7d1.jpg',
},
{
id: 4,
fa: { title: 'سرمایه‌گذاری در صنعت فولاد', guest: 'گفتگو با مدیران سرمایه‌گذاری',
body: 'فرصت‌ها و چالش‌های سرمایه‌گذاری در بخش‌های مختلف زنجیره ارزش فولاد.' },
en: { title: 'Steel Industry Investment', guest: 'With investment managers',
body: 'Opportunities and challenges across the steel value chain.' },
ep: 'EP.09', duration: '۴۵ دقیقه', date: '۴ خرداد ۱۴۰۵', thumb: '/news/photo-1611273426858-450d8e3c9fce.jpg',
},
]
const POD_HASHTAGS = ['#هوش_مصنوعی', '#عدم_قطعیت']
const PODCASTS = [
{
id: 1,
epNum: 'اپیزود ۷۷', date: '۲۵ / خرداد / ۱۴۰۵', rating: '۴/۵', duration: '۱۰:۵۶', cover: '/news/photo-1540575467063-178a50c2df87.jpg',
fa: { title: 'کالبدشکافی عقلانی پیچیدگی‌های صنعتی', ep: 'قسمت ۱۲', guest: 'مهندس سیامک شجاعی',
body: '«صنعت فولاد در عصر حاضر، در پیچیده‌ترین چرخه حیات خود قرار گرفته است؛ جایی که همگرایی هوش مصنوعی، الزامات زیست‌محیطی و تنش‌های ژئواکونومیک، قواعد کلاسیک تولید و تجارت را به‌کلی بازنویسی کرده‌اند. در پادکست «نام انتخابی»، ما با عبور از هیاهوی رسانه‌ای و تحلیل‌های سطحی، به کالبدشکافی عقلانی این پیچیدگی‌ها می‌پردازیم. ما با دعوت از استراتژیست‌های تراز اول و خبرگان این حوزه، لایه‌های پنهان چالش‌های صنعتی را می‌شکافیم تا بینشی شفاف، داده‌محور و تصمیم‌ساز را در اختیار رهبری قرار دهیم که برای عبور از طوفان‌های تغییر، به قطب‌نمای دقیق‌تر از عرف‌های بازار نیاز دارند.»' },
en: { title: 'A Rational Dissection of Industrial Complexity', ep: 'Episode 12', guest: 'Eng. Siamak Shojaei',
body: 'Steel today is in its most complex cycle yet — where AI, environmental mandates and geo-economic tension rewrite the classic rules of production and trade.' },
},
{
id: 2,
epNum: 'اپیزود ۷۶', date: '۱۸ / خرداد / ۱۴۰۵', rating: '۴/۵', duration: '۱:۴۰:۱۸', cover: '/news/photo-1566873535350-6586b0b7a1f2.jpg',
fa: { title: 'آزمایشگاه کوره', ep: 'قسمت ۸', guest: 'کارشناسان بازار',
body: '«The Foundry Lab» محلی برای کالبدشکافی دقیق تحولات فناورانه و مدل‌های نوین فولادسازی است. ما در این پادکست با نگاهی موشکافانه، چالش‌های فنی را بررسی می‌کنیم.' },
en: { title: 'The Foundry Lab', ep: 'Episode 8', guest: 'Market experts',
body: 'The Foundry Lab dissects technological change and new steelmaking models with a meticulous engineering lens.' },
},
{
id: 3,
epNum: 'اپیزود ۷۵', date: '۱۱ / خرداد / ۱۴۰۵', rating: '۴/۵', duration: '۱:۴۰:۱۸', cover: '/news/photo-1578662996442-48f60103fc96.jpg',
fa: { title: 'معمار فولاد', ep: 'قسمت ۵', guest: 'رهبران صنعت',
body: 'در «Steel Architect»، ما به سراغ داستان ساختن فولاد فردا می‌رویم؛ روایتی که رهبرانی که از مدل‌های سنتی کسب‌وکار به چالش کشیده‌اند را به تصویر می‌کشد.' },
en: { title: 'Steel Architect', ep: 'Episode 5', guest: 'Industry leaders',
body: 'Steel Architect tells the story of building tomorrow\'s steel — leaders challenging traditional business models.' },
},
]
const T = { const T = {
fa: { fa: {
videoLabel: 'ویدیوکست', videoSub: 'آخرین گفتگوهای تصویری اندیشکده', videoLabel: 'ویدیوکست', videoSub: 'آخرین گفتگوهای تصویری اندیشکده',
podLabel: 'پادکست‌ها', podSub: 'شنیدنی‌های اندیشکده فولاد آینده', podLabel: 'پادکست‌ها', podSub: 'شنیدنی‌های اندیشکده فولاد آینده',
watchAll: 'همه ویدیوها ←', listenAll: 'مشاهده همه', watchAll: 'مشاهده همه', listenAll: 'مشاهده همه',
}, },
en: { en: {
videoLabel: 'Videocast', videoSub: 'Latest video conversations from the Institute', videoLabel: 'Videocast', videoSub: 'Latest video conversations from the Institute',
podLabel: 'Podcasts', podSub: 'Audio episodes from Future Steel Institute', podLabel: 'Podcasts', podSub: 'Audio episodes from Future Steel Institute',
watchAll: 'All videos →', listenAll: 'View All', watchAll: 'View All', listenAll: 'View All',
}, },
} }
export default function VideocastPodcastSection({ only }: { only?: 'video' | 'pod' }) { export default function VideocastPodcastSection({ only }: { only?: 'video' | 'pod' }) {
const { lang } = useLang() const { lang } = useLang()
const t = T[lang] const t = T[lang]
const [activeVideo, setActiveVideo] = useState(0) const [activeVideo] = useState(0)
const [activePod, setActivePod] = useState(0) const [activePod, setActivePod] = useState(0)
const pod = PODCASTS[activePod] const pod = PODCASTS[activePod]
@ -106,10 +43,22 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
<div style={{ padding: '56px 0 72px', display: 'flex', flexDirection: 'column', gridColumn: '1 / -1' }} className="max-md:!p-5"> <div style={{ padding: '56px 0 72px', display: 'flex', flexDirection: 'column', gridColumn: '1 / -1' }} className="max-md:!p-5">
{/* heading */} {/* heading */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24, justifyContent: 'flex-start' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 4, height: 28, background: GOLD, borderRadius: 2 }} /> <div style={{ width: 4, height: 28, background: GOLD, borderRadius: 2 }} />
<h2 style={{ fontSize: 'clamp(20px,2.2vw,28px)', fontWeight: 900, letterSpacing: '-0.5px', color: NAVY, margin: 0 }}>{t.videoLabel}</h2> <h2 style={{ fontSize: 'clamp(20px,2.2vw,28px)', fontWeight: 900, letterSpacing: '-0.5px', color: NAVY, margin: 0 }}>{t.videoLabel}</h2>
</div> </div>
<button style={{
display: 'inline-flex', alignItems: 'center', gap: 8, direction: 'ltr',
fontSize: 13, fontWeight: 700, color: GOLD,
background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
}}>
<div style={{ width: 24, height: 24, borderRadius: '50%', border: `1px solid ${GOLD}`, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ChevronRight size={14} strokeWidth={2.5} />
</div>
<span style={{ direction: 'rtl' }}>{t.watchAll}</span>
</button>
</div>
{/* featured player */} {/* featured player */}
<div style={{ position: 'relative', overflow: 'hidden', background: '#000', borderRadius: 16, aspectRatio: '16/9', marginBottom: 36, boxShadow: '0 30px 70px -34px rgba(3,35,64,0.5)' }} className="max-md:!aspect-[4/3]"> <div style={{ position: 'relative', overflow: 'hidden', background: '#000', borderRadius: 16, aspectRatio: '16/9', marginBottom: 36, boxShadow: '0 30px 70px -34px rgba(3,35,64,0.5)' }} className="max-md:!aspect-[4/3]">
@ -146,44 +95,8 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
</div> </div>
</div> </div>
{/* playlist (right) + description (left) */} {/* description only — playlist removed */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.2fr', gap: 40 }} className="max-lg:!grid-cols-1 max-lg:!gap-8"> <div style={{ direction: lang === 'fa' ? 'rtl' : 'ltr' }}>
{/* playlist — first DOM child → right in RTL */}
<div style={{ order: lang === 'fa' ? 2 : 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, justifyContent: 'flex-end' }}>
<span style={{ fontSize: 14, fontWeight: 900, color: NAVY }}>پلی لیست</span>
<span style={{ fontSize: 16 }}>🎬</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{VIDEOS.map((v, i) => (
<button key={v.id} onClick={() => setActiveVideo(i)} style={{
display: 'flex', alignItems: 'center', gap: 14, width: '100%', cursor: 'pointer',
background: i === activeVideo ? '#fff' : 'transparent', border: 'none', borderRadius: 12, padding: 8,
boxShadow: i === activeVideo ? '0 12px 30px -18px rgba(3,35,64,0.3)' : 'none',
textAlign: lang === 'fa' ? 'right' : 'left', flexDirection: lang === 'fa' ? 'row' : 'row-reverse',
}}>
<div style={{ position: 'relative', flexShrink: 0, width: 96, height: 64, borderRadius: 10, overflow: 'hidden' }}>
<img src={v.thumb} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
<span style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ width: 26, height: 26, borderRadius: '50%', background: 'rgba(255,255,255,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ width: 0, height: 0, borderTop: '5px solid transparent', borderBottom: '5px solid transparent', borderLeft: `8px solid ${GOLD}`, marginLeft: 2 }} />
</span>
</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 700, color: GOLD, lineHeight: 1.5, marginBottom: 5, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{v[lang].title}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-5)' }}>{i === activeVideo ? v[lang].guest : v.date}</div>
</div>
</button>
))}
</div>
</div>
{/* description — second DOM child → left in RTL */}
<div style={{ order: lang === 'fa' ? 1 : 2, direction: lang === 'fa' ? 'rtl' : 'ltr' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, justifyContent: 'flex-start' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, justifyContent: 'flex-start' }}>
<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>
@ -191,12 +104,6 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
<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 0 18px', whiteSpace: 'pre-line', textAlign: 'justify' }}>
{VIDEOS[activeVideo][lang].body} {VIDEOS[activeVideo][lang].body}
</p> </p>
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
{HASHTAGS.map((h, i) => (
<span key={i} style={{ fontSize: 12, fontWeight: 700, color: GOLD, direction: 'rtl' }}>{h}</span>
))}
</div>
</div>
</div> </div>
</div> </div>
)} )}
@ -213,31 +120,14 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
<h2 style={{ fontSize: 'clamp(20px,2.2vw,28px)', fontWeight: 900, letterSpacing: '-0.5px', color: NAVY, margin: 0 }}>{t.podLabel}</h2> <h2 style={{ fontSize: 'clamp(20px,2.2vw,28px)', fontWeight: 900, letterSpacing: '-0.5px', color: NAVY, margin: 0 }}>{t.podLabel}</h2>
</div> </div>
<button style={{ <button style={{
display: 'inline-flex', display: 'inline-flex', alignItems: 'center', gap: 8,
alignItems: 'center', fontSize: 13, fontWeight: 700, color: GOLD,
gap: 8, background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
direction: 'ltr',
fontSize: 13,
fontWeight: 700,
color: GOLD,
background: 'none',
border: 'none',
cursor: 'pointer',
fontFamily: 'inherit'
}}> }}>
<div style={{
width: 24,
height: 24,
borderRadius: '50%',
border: `1px solid ${GOLD}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: GOLD
}}>
{lang === 'fa' ? <ChevronLeft size={14} strokeWidth={2.5} /> : <ChevronRight size={14} strokeWidth={2.5} />}
</div>
<span>{t.listenAll}</span> <span>{t.listenAll}</span>
<div style={{ width: 24, height: 24, borderRadius: '50%', border: `1px solid ${GOLD}`, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ChevronRight size={14} strokeWidth={2.5} />
</div>
</button> </button>
</div> </div>
@ -603,19 +493,6 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
}}> }}>
{lang === 'fa' ? 'مهمان برنامه: ' : 'Guest: '}{pod[lang].guest} {lang === 'fa' ? 'مهمان برنامه: ' : 'Guest: '}{pod[lang].guest}
</div> </div>
<div style={{
display: 'flex',
gap: 16,
flexWrap: 'wrap',
direction: lang === 'fa' ? 'rtl' : 'ltr',
justifyContent: lang === 'fa' ? 'flex-start' : 'flex-end'
}}>
{POD_HASHTAGS.map((h, i) => (
<span key={i} style={{ fontSize: 12, fontWeight: 700, color: GOLD }}>
{h}
</span>
))}
</div>
</div> </div>
</div> </div>

View File

@ -135,7 +135,7 @@ export default function Membership() {
<div style={{ position: 'absolute', top: 0, right: 0, left: 0, height: 4, background: 'var(--red)' }} /> <div style={{ position: 'absolute', top: 0, right: 0, left: 0, height: 4, background: 'var(--red)' }} />
<h3 style={{ fontSize: 16, fontWeight: 800, color: 'var(--ink)', marginBottom: 10 }}>{t.contactHeading}</h3> <h3 style={{ fontSize: 16, fontWeight: 800, color: 'var(--ink)', marginBottom: 10 }}>{t.contactHeading}</h3>
<p style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.75, marginBottom: 20 }}>{t.contactSub}</p> <p style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.75, marginBottom: 20 }}>{t.contactSub}</p>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--ink)' }}>info@iransteel.ir</div> <div style={{ fontSize: 13, fontWeight: 700, color: 'var(--ink)' }}>info@steelforesight.com</div>
</motion.div> </motion.div>
)} )}
</div> </div>

View File

@ -0,0 +1,400 @@
import { useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Calendar, Tag, MessageCircle, ArrowLeft, MoreHorizontal, Heart, Reply, Plus, UserPlus, LogIn, Send, Share2, Globe, Mail } from 'lucide-react'
import { useLang } from '@/context/LangContext'
import { getPost, type Post } from '@/content/posts'
import { MagicTextGroup } from '@/components/ui/magic-text'
const NAVY = '#032340'
const GOLD = '#CD9E53'
const BLUE = '#1f8fff'
const PAPER = '#faf7f4'
const EASE = [0.22, 1, 0.36, 1] as const
/* ════════ Pareto chart (bars desc + cumulative line + 80% rule) ════════ */
function ParetoChart() {
const bars = [46, 30, 22, 14, 10, 7, 5, 3]
const total = bars.reduce((a, b) => a + b, 0)
let acc = 0
const cum = bars.map((b) => { acc += b; return (acc / total) * 100 })
const W = 560, H = 240, padL = 36, padR = 40, padB = 44, padT = 14
const cw = W - padL - padR, ch = H - padB - padT
const maxBar = 60
const bw = cw / bars.length
const x = (i: number) => padL + i * bw + bw / 2
const yBar = (v: number) => padT + ch - (v / maxBar) * ch
const yCum = (v: number) => padT + ch - (v / 100) * ch
const labels = ['Defect Type A','Defect Type B','Defect Type C','Defect Type D','Defect Type E','Defect Type F','Defect Type G','Defect Type H']
const linePts = cum.map((v, i) => `${x(i)},${yCum(v)}`).join(' ')
return (
<div style={{ background: '#fff', borderRadius: 14, border: '1px solid rgba(7,29,73,0.08)', padding: 18, boxShadow: '0 10px 30px -24px rgba(7,29,73,0.3)' }}>
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block', direction: 'ltr' }}>
{/* y grid + left axis labels */}
{[0, 15, 30, 45, 60].map((v) => (
<g key={v}>
<line x1={padL} y1={yBar(v)} x2={W - padR} y2={yBar(v)} stroke="rgba(7,29,73,0.08)" strokeWidth="1" />
<text x={padL - 6} y={yBar(v) + 3} fontSize="9" fill="rgba(7,29,73,0.5)" textAnchor="end">{v}</text>
</g>
))}
{/* right axis (cumulative %) */}
{[0, 25, 50, 75, 100].map((v) => (
<text key={v} x={W - padR + 6} y={yCum(v) + 3} fontSize="9" fill="rgba(7,29,73,0.5)" textAnchor="start">{v}</text>
))}
{/* 80% dashed rule */}
<line x1={padL} y1={yCum(80)} x2={W - padR} y2={yCum(80)} stroke="rgba(7,29,73,0.35)" strokeWidth="1" strokeDasharray="4 4" />
{/* bars */}
{bars.map((v, i) => (
<rect key={i} x={x(i) - bw * 0.32} y={yBar(v)} width={bw * 0.64} height={padT + ch - yBar(v)} rx="2" fill={GOLD} opacity={0.92} />
))}
{/* cumulative line + dots */}
<polyline points={linePts} fill="none" stroke={BLUE} strokeWidth="2" />
{cum.map((v, i) => <circle key={i} cx={x(i)} cy={yCum(v)} r="3" fill={BLUE} />)}
{/* x labels */}
{labels.map((l, i) => (
<text key={i} x={x(i)} y={H - padB + 14} fontSize="7.5" fill="rgba(7,29,73,0.55)" textAnchor="end" transform={`rotate(-35 ${x(i)} ${H - padB + 14})`}>{l}</text>
))}
</svg>
{/* legend */}
<div style={{ display: 'flex', justifyContent: 'center', gap: 20, margin: '4px 0 14px', direction: 'ltr' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'rgba(7,29,73,0.7)' }}><span style={{ width: 14, height: 3, background: 'rgba(7,29,73,0.4)', display: 'inline-block' }} />80% Line</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'rgba(7,29,73,0.7)' }}><span style={{ width: 12, height: 8, background: BLUE, display: 'inline-block', borderRadius: 2 }} />Cumulative %</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'rgba(7,29,73,0.7)' }}><span style={{ width: 12, height: 8, background: GOLD, display: 'inline-block', borderRadius: 2 }} />Frequency</span>
</div>
{/* caption box */}
<div style={{ background: PAPER, borderRadius: 10, padding: '12px 16px', display: 'flex', gap: 40, direction: 'ltr', flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: NAVY }}>Pareto Analysis (80/20 Rule)</div>
<div><div style={{ fontSize: 10, color: 'rgba(7,29,73,0.5)' }}>Top 20% categories:</div><div style={{ fontSize: 13, fontWeight: 800, color: NAVY }}>2 of 8</div></div>
<div><div style={{ fontSize: 10, color: 'rgba(7,29,73,0.5)' }}>Contribute:</div><div style={{ fontSize: 13, fontWeight: 800, color: GOLD }}>68.8% of total</div></div>
</div>
</div>
)
}
/* ════════ Grouped monthly bar chart (expenses / profit / revenue) ════════ */
function BarsChart() {
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
const data = [
[3000, 2000, 4500], [3200, 1800, 5200], [2800, 2400, 4800], [3500, 2600, 6000],
[3100, 2200, 5500], [3400, 2800, 6200], [3000, 2500, 5800], [3600, 3000, 6800],
[3300, 2700, 6400], [3800, 3200, 7600], [3500, 2900, 8200], [3200, 3400, 9000],
]
const W = 560, H = 250, padL = 40, padR = 12, padB = 36, padT = 14
const cw = W - padL - padR, ch = H - padB - padT
const maxV = 10000
const gw = cw / months.length
const colors = [NAVY, GOLD, BLUE]
const y = (v: number) => padT + ch - (v / maxV) * ch
return (
<div style={{ background: '#fff', borderRadius: 14, border: '1px solid rgba(7,29,73,0.08)', padding: 18, boxShadow: '0 10px 30px -24px rgba(7,29,73,0.3)' }}>
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block', direction: 'ltr' }}>
{[0, 2500, 5000, 7500, 10000].map((v) => (
<g key={v}>
<line x1={padL} y1={y(v)} x2={W - padR} y2={y(v)} stroke="rgba(7,29,73,0.08)" strokeWidth="1" />
<text x={padL - 6} y={y(v) + 3} fontSize="8.5" fill="rgba(7,29,73,0.5)" textAnchor="end">{v}</text>
</g>
))}
{data.map((grp, i) => {
const x0 = padL + i * gw
const innerBw = (gw * 0.7) / 3
return grp.map((v, j) => (
<rect key={j} x={x0 + gw * 0.15 + j * innerBw} y={y(v)} width={innerBw - 1} height={padT + ch - y(v)} fill={colors[j]} rx="1" />
))
})}
{months.map((m, i) => (
<text key={m} x={padL + i * gw + gw / 2} y={H - padB + 14} fontSize="8.5" fill="rgba(7,29,73,0.55)" textAnchor="middle">{m}</text>
))}
</svg>
<div style={{ display: 'flex', justifyContent: 'center', gap: 22, marginTop: 8, direction: 'ltr' }}>
{[['expenses', NAVY], ['profit', GOLD], ['revenue', BLUE]].map(([l, c]) => (
<span key={l} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'rgba(7,29,73,0.7)' }}>
<span style={{ width: 12, height: 9, background: c as string, display: 'inline-block', borderRadius: 2 }} />{l}
</span>
))}
</div>
</div>
)
}
/* ════════ floating related sidebar (sticky → follows scroll) ════════ */
function RelatedSidebar({ related, lang }: { related: Post['related']; lang: 'fa' | 'en' }) {
return (
<aside style={{ position: 'sticky', top: 88, alignSelf: 'flex-start' }} className="max-lg:!static">
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{related.map((r) => (
<Link key={r.id} to={`/posts/${r.id}`} style={{ textDecoration: 'none' }}>
<article
style={{
background: '#fff', borderRadius: 14, overflow: 'hidden', cursor: 'pointer',
border: '1px solid rgba(205,158,83,0.3)', borderBottom: `3px solid ${GOLD}`,
display: 'flex', flexDirection: 'column', transition: 'transform 180ms, box-shadow 180ms',
}}
onMouseEnter={(e) => { const d = e.currentTarget; d.style.transform = 'translateY(-3px)'; d.style.boxShadow = '0 16px 34px -22px rgba(7,29,73,0.42)' }}
onMouseLeave={(e) => { const d = e.currentTarget; d.style.transform = 'translateY(0)'; d.style.boxShadow = 'none' }}
>
<img src={r.img} alt="" style={{ width: '100%', aspectRatio: '16 / 10', objectFit: 'cover', display: 'block' }} />
<div style={{ padding: '12px 14px', textAlign: lang === 'fa' ? 'right' : 'left' }}>
<h4 style={{ fontSize: 13, fontWeight: 800, color: NAVY, lineHeight: 1.6, margin: '0 0 6px' }}>{r[lang].title}</h4>
<p style={{ fontSize: 11.5, color: 'rgba(7,29,73,0.55)', lineHeight: 1.7, margin: '0 0 12px' }}>{r[lang].excerpt}</p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ width: 28, height: 28, borderRadius: '50%', background: 'rgba(205,158,83,0.15)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
<ArrowLeft size={14} color={GOLD} strokeWidth={2.5} className="no-rtl-flip" />
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 10.5, color: 'rgba(7,29,73,0.5)', fontWeight: 600 }}>
<Calendar size={11} color={GOLD} />{r[lang].date}
</span>
</div>
</div>
</article>
</Link>
))}
</div>
</aside>
)
}
/* ════════ a single comment ════════ */
function CommentCard({ c, lang }: { c: Post['comments'][number]; lang: 'fa' | 'en' }) {
const isFa = lang === 'fa'
return (
<div style={{ background: '#fff', borderRadius: 14, padding: 18, border: '1px solid rgba(7,29,73,0.08)' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
{/* identity */}
<div style={{ display: 'flex', gap: 12, minWidth: 0 }}>
<div style={{ flexShrink: 0, width: 46, height: 46, borderRadius: '50%', background: NAVY, color: GOLD, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 800, fontSize: 17 }}>
{c.name.trim().charAt(0)}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 800, color: NAVY }}>{c.name}</div>
<div style={{ fontSize: 11.5, color: 'rgba(7,29,73,0.55)' }}>{isFa ? c.roleFa : c.roleEn}</div>
</div>
</div>
{/* actions */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, direction: 'ltr', flexShrink: 0 }}>
<MoreHorizontal size={16} color="rgba(7,29,73,0.4)" />
<Reply size={15} color="rgba(7,29,73,0.4)" />
<Heart size={15} color="rgba(7,29,73,0.4)" />
<span style={{ fontSize: 11, color: 'rgba(7,29,73,0.45)', whiteSpace: 'nowrap' }}>{c.date}</span>
</div>
</div>
{/* tag pills */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, margin: '12px 0' }}>
{c.tags.map((tg, i) => (
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 700, color: NAVY, background: 'rgba(205,158,83,0.15)', borderRadius: 999, padding: '4px 12px' }}>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: GOLD }} />
{isFa ? tg.fa : tg.en}
</span>
))}
</div>
<p style={{ fontSize: 13.5, lineHeight: 2, color: 'rgba(7,29,73,0.75)', margin: 0, textAlign: 'justify' }}>{c[lang]}</p>
</div>
)
}
/* ════════ membership gate (shown to logged-out readers) ════════ */
function MembershipGate({ lang, onPreviewLogin }: { lang: 'fa' | 'en'; onPreviewLogin: () => void }) {
const isFa = lang === 'fa'
const copy = isFa
? 'ما در اندیشکده فولاد آینده، خروجی‌های راهبردی خود را برای اعضای شبکه تخصصی‌مان تدوین می‌کنیم. با عضویت در این اندیشکده، می‌توانید به متن کامل گزارش‌ها، سناریوهای آینده‌پژوهی و تحلیل‌های سطح‌بالا دسترسی داشته باشید. پیوستن به این شبکه، دریچه‌ای است برای مشارکت در گفت‌وگوهای تخصصی و هم‌افزایی با سایر پیشگامان صنعت.'
: 'At the Future Steel Institute we craft our strategic outputs for members of our expert network. Membership unlocks full reports, foresight scenarios and high-level analysis — and a seat in the conversation with industry pioneers.'
return (
<div
style={{
position: 'relative', background: '#fff', borderRadius: 18, padding: 'clamp(20px, 3vw, 34px)',
boxShadow: '0 30px 70px -30px rgba(7,29,73,0.35)', border: '1px solid rgba(7,29,73,0.06)',
maxWidth: 720, margin: '0 auto', direction: isFa ? 'rtl' : 'ltr',
}}
>
<p style={{ fontSize: 14.5, lineHeight: 2.1, color: 'rgba(7,29,73,0.78)', margin: '0 0 22px', textAlign: 'justify' }}>{copy}</p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
{/* socials */}
<div style={{ display: 'flex', gap: 10, direction: 'ltr' }}>
{[Send, Share2, Globe, Mail].map((Icon, i) => (
<a key={i} href="#" aria-label="social" style={{ width: 36, height: 36, borderRadius: '50%', background: NAVY, color: GOLD, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
<Icon size={16} />
</a>
))}
</div>
{/* actions */}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<button onClick={onPreviewLogin} style={{
display: 'inline-flex', alignItems: 'center', gap: 8, direction: 'ltr',
background: 'transparent', color: NAVY, border: `1.5px solid ${NAVY}`, borderRadius: 10,
padding: '12px 26px', fontFamily: 'inherit', fontSize: 14, fontWeight: 800, cursor: 'pointer',
}}>
<LogIn size={16} strokeWidth={2.5} />
<span>{isFa ? 'ورود' : 'Log in'}</span>
</button>
<Link to="/membership" style={{
display: 'inline-flex', alignItems: 'center', gap: 8, direction: 'ltr',
background: GOLD, color: NAVY, borderRadius: 10, padding: '12px 26px',
fontSize: 14, fontWeight: 800, textDecoration: 'none',
}}>
<UserPlus size={16} strokeWidth={2.5} />
<span>{isFa ? 'ثبت نام' : 'Sign up'}</span>
</Link>
</div>
</div>
</div>
)
}
export default function PostDetail() {
const { lang } = useLang()
const { id } = useParams<{ id: string }>()
const post = getPost(id || 'p1')!
const t = post[lang]
const isFa = lang === 'fa'
const [loggedIn, setLoggedIn] = useState(() => {
try { return localStorage.getItem('sf_logged_in') === '1' } catch { return false }
})
const login = () => {
try { localStorage.setItem('sf_logged_in', '1') } catch { /* ignore */ }
setLoggedIn(true)
}
return (
<div dir={isFa ? 'rtl' : 'ltr'} style={{ background: '#fff', minHeight: '80vh' }}>
{/* ── COVER — full-screen, full-bleed (rounded mask corners) ── */}
<div
style={{
position: 'relative',
width: '100vw',
marginInline: 'calc(50% - 50vw)',
height: 'clamp(420px, 92vh, 1000px)',
borderRadius: 40,
overflow: 'hidden',
}}
className="max-md:!rounded-3xl"
>
<img
src={post.cover}
alt={t.title}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</div>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 28, paddingBottom: 72 }}>
{/* ── BODY GRID: article (main) + floating related ── */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 40 }} className="max-lg:!grid-cols-1 max-lg:!gap-8">
{/* MAIN */}
<article style={{ minWidth: 0 }}>
{/* date badge */}
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'rgba(7,29,73,0.55)', fontWeight: 600, marginBottom: 12 }}>
<Calendar size={14} color={GOLD} /> {t.date}
</div>
{/* title */}
<h1 style={{ fontSize: 'clamp(22px, 2.6vw, 34px)', fontWeight: 900, color: NAVY, lineHeight: 1.5, letterSpacing: '-0.5px', margin: '0 0 12px' }}>
{t.title}
</h1>
{/* category */}
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 13, fontWeight: 700, color: GOLD, marginBottom: 26 }}>
<Tag size={15} /> {post.category[lang]}
</div>
{/* body — full when logged in, gated preview (blurred + gate) when logged out */}
{loggedIn ? (
<>
{/* body blocks — consecutive paragraphs grouped so they reveal sequentially */}
{(() => {
const out: React.ReactNode[] = []
let paraRun: string[] = []
const flush = (key: string) => {
if (!paraRun.length) return
out.push(
<MagicTextGroup
key={key}
paragraphs={paraRun}
dir={isFa ? 'rtl' : 'ltr'}
style={{ fontSize: 15.5, lineHeight: 2.2, color: NAVY, margin: '0 0 18px', fontWeight: 500 }}
/>,
)
paraRun = []
}
post.body.forEach((b, i) => {
if (b.type === 'p') { paraRun.push(b[lang]); return }
flush(`g${i}`)
if (b.type === 'h') {
out.push(<h2 key={i} style={{ fontSize: 'clamp(17px,1.9vw,22px)', fontWeight: 900, color: NAVY, margin: '30px 0 14px', lineHeight: 1.5 }}>{b[lang]}</h2>)
} else if (b.type === 'chart') {
out.push(
<motion.div key={i} initial={{ opacity: 0, y: 18 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, amount: 0.15 }} transition={{ duration: 0.5, ease: EASE }} style={{ margin: '26px 0' }}>
{b.chart === 'pareto' ? <ParetoChart /> : <BarsChart />}
</motion.div>,
)
} else if (b.type === 'figure') {
out.push(
<figure key={i} style={{ margin: '26px 0' }}>
<div style={{ borderRadius: 14, overflow: 'hidden', border: '1px solid rgba(205,158,83,0.3)' }}><img src={b.img} alt={b[lang]} style={{ width: '100%', display: 'block' }} /></div>
<figcaption style={{ fontSize: 12.5, color: 'rgba(7,29,73,0.6)', marginTop: 10, textAlign: 'center', fontWeight: 600 }}>{b[lang]}</figcaption>
</figure>,
)
}
})
flush('g-end')
return out
})()}
{/* ── COMMENTS (دیدگاه) ── */}
<section style={{ marginTop: 46 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24, gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<h2 style={{ fontSize: 20, fontWeight: 900, color: NAVY, margin: 0 }}>{isFa ? 'دیدگاه' : 'Comments'}</h2>
<MessageCircle size={20} color={GOLD} />
</div>
<button style={{ display: 'inline-flex', alignItems: 'center', gap: 7, background: 'transparent', color: NAVY, border: `1.5px solid ${GOLD}`, borderRadius: 999, padding: '8px 18px', fontFamily: 'inherit', fontSize: 13, fontWeight: 800, cursor: 'pointer' }}>
<Plus size={15} strokeWidth={2.5} color={GOLD} />
{isFa ? 'ثبت دیدگاه' : 'Add comment'}
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{post.comments.map((c) => <CommentCard key={c.id} c={c} lang={lang} />)}
</div>
</section>
</>
) : (
(() => {
const paraTexts: string[] = []
post.body.forEach((b) => { if (b.type === 'p') paraTexts.push(b[lang]) })
const first = paraTexts[0]
const rest = paraTexts.slice(1)
return (
<>
{first && (
<p style={{ fontSize: 15.5, lineHeight: 2.2, color: NAVY, margin: '0 0 18px', textAlign: 'justify', fontWeight: 500 }}>{first}</p>
)}
<div style={{ position: 'relative' }}>
{/* blurred teaser of the locked content */}
<div aria-hidden style={{
filter: 'blur(7px)', opacity: 0.55, maxHeight: 340, overflow: 'hidden',
pointerEvents: 'none', userSelect: 'none',
WebkitMaskImage: 'linear-gradient(to bottom, #000 0%, #000 28%, transparent 92%)',
maskImage: 'linear-gradient(to bottom, #000 0%, #000 28%, transparent 92%)',
}}>
{rest.map((p, i) => (
<p key={i} style={{ fontSize: 15.5, lineHeight: 2.2, color: NAVY, margin: '0 0 18px', textAlign: 'justify', fontWeight: 500 }}>{p}</p>
))}
</div>
{/* gate overlay */}
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: 36 }}>
<MembershipGate lang={lang} onPreviewLogin={login} />
</div>
</div>
</>
)
})()
)}
</article>
{/* FLOATING RELATED */}
<RelatedSidebar related={post.related} lang={lang} />
</div>
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { BarChart3, Cpu, Mountain, Globe, Zap, ChevronLeft, ChevronRight } from 'lucide-react' import { BarChart3, Cpu, Leaf, Globe, Zap, ChevronLeft, ChevronRight } from 'lucide-react'
import { useLang } from '@/context/LangContext' import { useLang } from '@/context/LangContext'
import { RADAR_ITEMS, type RadarItem, type RadarCategory } from '@/content/radar' import { RADAR_ITEMS, type RadarItem, type RadarCategory } from '@/content/radar'
@ -11,7 +11,7 @@ const PER_PAGE = 8
const CATEGORY_ICON: Record<RadarCategory, typeof BarChart3> = { const CATEGORY_ICON: Record<RadarCategory, typeof BarChart3> = {
market: BarChart3, market: BarChart3,
tech: Cpu, tech: Cpu,
commodity: Mountain, commodity: Leaf,
geo: Globe, geo: Globe,
energy: Zap, energy: Zap,
} }
@ -96,20 +96,9 @@ export function RadarCard({ item, lang }: { item: RadarItem; lang: 'fa' | 'en' }
direction: lang === 'fa' ? 'rtl' : 'ltr', direction: lang === 'fa' ? 'rtl' : 'ltr',
}} }}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
{/* author avatar replaces the category icon */} <div style={{ fontSize: 13, fontWeight: 800, color: '#fff', lineHeight: 1.4, marginBottom: 4 }}>{t.title}</div>
<motion.img <div style={{ fontSize: 10, color: GOLD, fontWeight: 600 }}>{t.date}</div>
src={item.author.avatar}
alt={item.author.name}
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1], delay: 0.08 }}
style={{ width: 40, height: 40, borderRadius: '50%', objectFit: 'cover', border: `2px solid ${GOLD}`, flexShrink: 0 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 800, color: '#fff', lineHeight: 1.4, marginBottom: 2 }}>{t.title}</div>
<div style={{ fontSize: 10, color: GOLD, fontWeight: 600 }}>{item.author.name} · {t.date}</div>
</div>
</div> </div>
<p style={{ fontSize: 12, lineHeight: 1.8, color: 'rgba(255,255,255,0.78)', margin: 0 }}> <p style={{ fontSize: 12, lineHeight: 1.8, color: 'rgba(255,255,255,0.78)', margin: 0 }}>
{t.excerpt} {t.excerpt}

View File

@ -0,0 +1,351 @@
import { useState } from 'react'
import { Link, useParams, Navigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ArrowLeft, Download, Calendar, ChevronLeft, ChevronRight } from 'lucide-react'
import { useLang } from '@/context/LangContext'
import { RADAR_ITEMS } from '@/content/radar'
import {
RADAR_CATEGORY_PAGES,
BOOK_IMG,
type RadarSlug,
} from '@/content/radarCategories'
const NAVY = '#032340'
const GOLD = '#CD9E53'
const PAPER = '#faf7f4'
const PER_PAGE = 12
const EASE = [0.22, 1, 0.36, 1] as const
/* ─── small reveal wrapper (animates in once on mount, never hides content) ─── */
function Reveal({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
return (
<motion.div
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: EASE, delay }}
>
{children}
</motion.div>
)
}
/* ─── breadcrumb ─── */
function Breadcrumb({ label, lang }: { label: string; lang: 'fa' | 'en' }) {
const home = lang === 'fa' ? 'خانه' : 'Home'
const radar = lang === 'fa' ? 'رادار آینده' : 'Future Radar'
const Sep = () => (
<ChevronLeft size={15} color="rgba(7,29,73,0.4)" style={{ transform: lang === 'fa' ? 'none' : 'rotate(180deg)' }} className="no-rtl-flip" />
)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 24, direction: lang === 'fa' ? 'rtl' : 'ltr', flexWrap: 'wrap' }}>
{/* gold tick */}
<span style={{ width: 4, height: 18, background: GOLD, borderRadius: 2, display: 'inline-block', marginInlineEnd: 6 }} />
<Link to="/" style={{ fontSize: 14, fontWeight: 700, color: NAVY, textDecoration: 'none' }}>{home}</Link>
<Sep />
<Link to="/radar" style={{ fontSize: 14, fontWeight: 700, color: NAVY, textDecoration: 'none' }}>{radar}</Link>
<Sep />
<span style={{ fontSize: 14, fontWeight: 800, color: GOLD }}>{label}</span>
</div>
)
}
/* ─── a "latest" mini-card (image on the trailing side, text + CTA) ─── */
function MiniCard({ img, title, date, lang }: { img: string; title: string; date: string; lang: 'fa' | 'en' }) {
const cta = lang === 'fa' ? 'مشاهده تحلیل' : 'View analysis'
return (
<article style={{ display: 'flex', gap: 16, direction: lang === 'fa' ? 'rtl' : 'ltr', alignItems: 'stretch' }}>
{/* image (first in RTL → right side) */}
<div style={{ flexShrink: 0, width: 180, minHeight: 150, borderRadius: 12, overflow: 'hidden' }} className="max-sm:!w-[130px]">
<img src={img} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
{/* text */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', textAlign: lang === 'fa' ? 'right' : 'left' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'rgba(7,29,73,0.5)', fontWeight: 600, marginBottom: 8, justifyContent: lang === 'fa' ? 'flex-start' : 'flex-start' }}>
<Calendar size={13} color={GOLD} />
<span>{date}</span>
</div>
<h3 style={{ fontSize: 15, fontWeight: 800, color: NAVY, lineHeight: 1.55, margin: '0 0 8px' }}>{title}</h3>
<p style={{ fontSize: 12, lineHeight: 1.9, color: 'rgba(7,29,73,0.55)', margin: '0 0 14px', overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{lang === 'fa'
? 'لیتیوم فراتر از یک کانی استراتژیک، اکنون به اهرم اصلی قدرت در تغییر نظم اقتصادی چ ...'
: 'Beyond a strategic mineral, lithium is now the main lever of power in reshaping the economic order ...'}
</p>
<div style={{ marginTop: 'auto', display: 'flex', justifyContent: lang === 'fa' ? 'flex-start' : 'flex-start' }}>
<button style={{
display: 'inline-flex', alignItems: 'center', gap: 8, direction: 'ltr',
background: GOLD, color: NAVY, border: 'none', borderRadius: 8,
padding: '8px 16px', fontFamily: 'inherit', fontSize: 12, fontWeight: 700, cursor: 'pointer',
}}>
<ArrowLeft size={14} strokeWidth={2.5} className="no-rtl-flip" />
<span>{cta}</span>
</button>
</div>
</div>
</article>
)
}
/* ─── big featured card (right column of the hero) ─── */
function FeaturedCard({ img, title, date, lang }: { img: string; title: string; date: string; lang: 'fa' | 'en' }) {
return (
<article style={{ position: 'relative', borderRadius: 16, overflow: 'hidden', height: '100%', minHeight: 360 }} className="max-md:!min-h-[260px]">
<img src={img} alt="" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(3,35,64,0.92) 0%, rgba(3,35,64,0.45) 45%, transparent 75%)' }} />
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: 28, direction: lang === 'fa' ? 'rtl' : 'ltr', textAlign: lang === 'fa' ? 'right' : 'left' }} className="max-md:!p-5">
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: GOLD, color: NAVY, borderRadius: 8, padding: '5px 12px', fontSize: 11, fontWeight: 700, marginBottom: 14 }}>
<Calendar size={13} />
<span>{date}</span>
</div>
<h2 style={{ fontSize: 'clamp(18px, 1.8vw, 24px)', fontWeight: 900, color: '#fff', lineHeight: 1.5, margin: 0 }}>{title}</h2>
</div>
</article>
)
}
/* ─── grid card (gold framed) ─── */
function GridCard({ img, title, excerpt, date, lang }: { img: string; title: string; excerpt: string; date: string; lang: 'fa' | 'en' }) {
const [hover, setHover] = useState(false)
return (
<motion.article
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
animate={{ y: hover ? -4 : 0 }}
transition={{ duration: 0.3, ease: EASE }}
style={{
background: '#fff', borderRadius: 14, overflow: 'hidden', cursor: 'pointer',
borderTop: `1px solid rgba(205,158,83,0.35)`,
borderInlineStart: `1px solid rgba(205,158,83,0.35)`,
borderInlineEnd: `1px solid rgba(205,158,83,0.35)`,
borderBottom: `3px solid ${GOLD}`,
boxShadow: hover ? '0 22px 50px -26px rgba(7,29,73,0.40)' : '0 10px 30px -22px rgba(7,29,73,0.28)',
display: 'flex', flexDirection: 'column',
direction: lang === 'fa' ? 'rtl' : 'ltr',
}}
>
<div style={{ overflow: 'hidden' }}>
<motion.img
src={img} alt=""
animate={{ scale: hover ? 1.06 : 1 }}
transition={{ duration: 0.6, ease: EASE }}
style={{ width: '100%', aspectRatio: '16 / 10', objectFit: 'cover', display: 'block' }}
/>
</div>
<div style={{ padding: '14px 16px 12px', display: 'flex', flexDirection: 'column', flex: 1, textAlign: lang === 'fa' ? 'right' : 'left' }}>
<h3 style={{ fontSize: 14, fontWeight: 800, color: NAVY, lineHeight: 1.55, margin: '0 0 6px', overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical' }}>{title}</h3>
<p style={{ fontSize: 12, lineHeight: 1.8, color: 'rgba(7,29,73,0.55)', margin: '0 0 12px', overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical' }}>{excerpt}</p>
{/* footer: date (trailing/right) + arrow (physical left) */}
<div style={{ marginTop: 'auto', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 11, color: 'rgba(7,29,73,0.5)', fontWeight: 600 }}>
<Calendar size={12} color={GOLD} />
{date}
</span>
<span style={{ width: 30, height: 30, borderRadius: '50%', background: 'rgba(205,158,83,0.15)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
<ArrowLeft size={15} color={GOLD} strokeWidth={2.5} className="no-rtl-flip" />
</span>
</div>
</div>
</motion.article>
)
}
/* special-analysis banner reuses the home FeaturedReportBanner design
(book mockup spills outside the frame; navy gradient + angled gold band) */
function SpecialBanner({ kicker, title, desc, lang }: { kicker: string; title: string; desc: string; lang: 'fa' | 'en' }) {
const isFa = lang === 'fa'
const dl = isFa ? 'دانلود گزارش' : 'Download report'
return (
<div style={{ margin: '52px 0 60px' }} className="max-md:!my-8">
{/* wrapper allows the book to spill outside the frame */}
<div style={{ position: 'relative', overflow: 'visible' }} className="max-md:!overflow-hidden max-md:!rounded-2xl">
{/* clipped background (navy gradient + angled gold band) */}
<div style={{
position: 'absolute', inset: 0, borderRadius: 20, overflow: 'hidden',
background: `linear-gradient(110deg, ${NAVY} 0%, #06335c 55%, ${NAVY} 100%)`,
boxShadow: '0 30px 70px -34px rgba(3,35,64,0.5)',
}}>
<div style={{
position: 'absolute', insetInlineStart: 0, insetInlineEnd: 0, bottom: -6, height: 88,
background: GOLD, transform: 'skewY(-2.4deg)', transformOrigin: isFa ? 'right' : 'left',
}} className="max-md:hidden" />
</div>
{/* DESKTOP: row. MOBILE: column */}
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', minHeight: 240, direction: 'ltr' }}
className="max-md:!flex-col">
{/* TEXT */}
<div style={{ width: '52%', padding: '40px 48px', direction: isFa ? 'rtl' : 'ltr' }}
className="max-md:!w-full max-md:!px-6 max-md:!pb-7 max-md:!pt-5 max-md:!order-2">
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
<span style={{ background: GOLD, color: NAVY, borderRadius: 6, padding: '4px 12px', fontSize: 12, fontWeight: 800 }}>{kicker}</span>
</div>
<h2 style={{ fontSize: 'clamp(18px, 2.3vw, 30px)', fontWeight: 900, color: '#fff', lineHeight: 1.4, letterSpacing: '-0.5px', margin: '0 0 12px' }}>
{title}
</h2>
<div style={{ fontSize: 'clamp(12px, 1.1vw, 15px)', lineHeight: 1.9, color: 'rgba(255,255,255,0.6)', marginBottom: 22, textAlign: isFa ? 'right' : 'left' }}>
{desc}
</div>
<div style={{ display: 'flex', justifyContent: isFa ? 'flex-start' : 'flex-end' }}>
<button
style={{
display: 'inline-flex', alignItems: 'center', gap: 10, direction: 'ltr',
background: '#f4ede0', color: NAVY, border: 'none', borderRadius: 10,
padding: '13px 28px', fontSize: 14, fontWeight: 800, cursor: 'pointer', fontFamily: 'inherit',
transition: 'transform 180ms, box-shadow 180ms',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'; (e.currentTarget as HTMLElement).style.boxShadow = '0 12px 26px -10px rgba(0,0,0,0.4)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.transform = 'translateY(0)'; (e.currentTarget as HTMLElement).style.boxShadow = 'none' }}
>
<Download size={17} strokeWidth={2.4} />
{dl}
</button>
</div>
</div>
{/* BOOK — spills outside the frame */}
<div style={{ width: '48%', position: 'relative', alignSelf: 'stretch' }}
className="max-md:!w-full max-md:!order-1 max-md:!flex max-md:!justify-center max-md:!pt-6">
{/* desktop */}
<img
src={BOOK_IMG}
alt={title}
style={{
position: 'absolute', insetInlineEnd: '-2%', top: '50%', transform: 'translateY(-50%)',
width: '116%', maxWidth: 'none', height: 'auto',
filter: 'drop-shadow(0 30px 46px rgba(0,0,0,0.5))',
}}
className="max-md:!hidden"
/>
{/* mobile — natural flow */}
<img
src={BOOK_IMG}
alt=""
style={{ filter: 'drop-shadow(0 16px 30px rgba(0,0,0,0.45))' }}
className="hidden max-md:!block max-md:!w-[85%] max-md:!max-w-[300px] max-md:!h-auto"
/>
</div>
</div>
</div>
</div>
)
}
/* ─── pagination ─── */
function Pagination({ page, pages, onGo, lang }: { page: number; pages: number; onGo: (p: number) => void; lang: 'fa' | 'en' }) {
if (pages <= 1) return null
const fa = (n: number) => lang === 'fa' ? n.toLocaleString('fa-IR') : String(n)
const nums: (number | '…')[] = []
for (let i = 1; i <= pages; i++) {
if (i === 1 || i === pages || Math.abs(i - page) <= 1) nums.push(i)
else if (nums[nums.length - 1] !== '…') nums.push('…')
}
const Btn = ({ children, active, onClick, disabled }: { children: React.ReactNode; active?: boolean; onClick?: () => void; disabled?: boolean }) => (
<button onClick={onClick} disabled={disabled} style={{
minWidth: 40, height: 40, borderRadius: 10, border: 'none', padding: '0 10px',
fontFamily: 'inherit', fontSize: 14, fontWeight: 700, cursor: disabled ? 'default' : 'pointer',
background: active ? GOLD : '#eee9e3', color: active ? NAVY : NAVY,
opacity: disabled ? 0.4 : 1, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 160ms',
}}>{children}</button>
)
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, marginTop: 44, direction: lang === 'fa' ? 'rtl' : 'ltr' }}>
<Btn onClick={() => onGo(page - 1)} disabled={page === 1}>{lang === 'fa' ? <ChevronLeft size={18} /> : <ChevronRight size={18} />}</Btn>
{nums.map((n, i) =>
n === '…'
? <span key={`e${i}`} style={{ color: NAVY, opacity: 0.5, padding: '0 4px' }}></span>
: <Btn key={n} active={n === page} onClick={() => onGo(n)}>{fa(n)}</Btn>
)}
<Btn onClick={() => onGo(page + 1)} disabled={page === pages}>{lang === 'fa' ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}</Btn>
</div>
)
}
/* ════════════════════════════════════════════════════════════ */
export default function RadarCategory() {
const { lang } = useLang()
const { category } = useParams<{ category: string }>()
const [page, setPage] = useState(1)
const cfg = category ? RADAR_CATEGORY_PAGES[category as RadarSlug] : undefined
if (!cfg) return <Navigate to="/radar" replace />
const c = cfg[lang]
// build the card pool from radar items in this page's source categories
const base = RADAR_ITEMS.filter((r) => cfg.sourceCategories.includes(r.category))
const seed = base.length ? base : RADAR_ITEMS
// expand the mock pool to ~28 cards so the grid fills and pagination shows
const cards = Array.from({ length: 28 }, (_, i) => {
const s = seed[i % seed.length]
return { ...s, id: `${s.id}-${i}` }
})
// latest 2 mini-cards
const latest = cards.slice(0, 2)
const pages = Math.max(1, Math.ceil(cards.length / PER_PAGE))
const start = (page - 1) * PER_PAGE
const visible = cards.slice(start, start + PER_PAGE)
const go = (p: number) => {
if (p < 1 || p > pages) return
setPage(p)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<div dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: PAPER, minHeight: '80vh', overflowX: 'hidden' }}>
<div className="max-w-7xl mx-auto px-12 max-md:!px-4" style={{ paddingTop: 40, paddingBottom: 72 }}>
{/* breadcrumb */}
<Breadcrumb label={c.label} lang={lang} />
{/* ── HERO ROW ── */}
<Reveal>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginBottom: 40 }} className="max-lg:!grid-cols-1 max-md:!gap-4 max-md:!mb-7">
{/* RIGHT (first in RTL): big featured */}
<FeaturedCard img={c.featured.img} title={c.featured.title} date={c.featured.date} lang={lang} />
{/* LEFT (second in RTL): latest box */}
<div style={{ background: '#efe9e1', borderRadius: 16, padding: 24, display: 'flex', flexDirection: 'column' }} className="max-md:!p-4">
<h2 style={{ fontSize: 16, fontWeight: 900, color: GOLD, margin: '0 0 18px', textAlign: lang === 'fa' ? 'right' : 'left' }}>{c.latestHeading}</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, flex: 1, justifyContent: 'space-around' }}>
{latest.map((item) => (
<MiniCard key={item.id} img={item.img} title={item[lang].title} date={item[lang].date} lang={lang} />
))}
</div>
</div>
</div>
</Reveal>
{/* ── FIRST GRID (4) ── */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 18 }} className="max-lg:!grid-cols-2 max-sm:!grid-cols-1">
{visible.slice(0, 4).map((item) => (
<Reveal key={item.id}>
<GridCard img={item.img} title={item[lang].title} excerpt={item[lang].excerpt} date={item[lang].date} lang={lang} />
</Reveal>
))}
</div>
{/* ── SPECIAL BANNER ── */}
<Reveal>
<SpecialBanner kicker={c.banner.kicker} title={c.banner.title} desc={c.banner.desc} lang={lang} />
</Reveal>
{/* ── SECOND GRID (rest) ── */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 18 }} className="max-lg:!grid-cols-2 max-sm:!grid-cols-1">
{visible.slice(4).map((item) => (
<Reveal key={item.id}>
<GridCard img={item.img} title={item[lang].title} excerpt={item[lang].excerpt} date={item[lang].date} lang={lang} />
</Reveal>
))}
</div>
<Pagination page={page} pages={pages} onGo={go} lang={lang} />
</div>
</div>
)
}

View File

@ -1,23 +1,6 @@
import { marketPrices, globalComparison } from '@/data/market'; import { marketPrices, globalComparison } from '@/data/market';
import type { MarketPrice } from '@/data/market'; import type { MarketPrice } from '@/data/market';
import { riskAlerts } from '@/data/risks';
const riskAlerts = [
{
title: 'فشار صادرات چین',
desc: 'افزایش ۱۸٪ صادرات ارزان چین فشار قیمتی روی بازار آسیا',
level: 'high',
},
{
title: 'محدودیت انرژی داخلی',
desc: 'کاهش تولید زمستانی به دلیل محدودیت گاز صنعتی',
level: 'medium',
},
{
title: 'فرصت بازار عراق',
desc: 'کاهش واردات ترکیه، فرصت افزایش سهم بازار برای ایران',
level: 'low',
},
] as const;
type AlertLevel = 'high' | 'medium' | 'low'; type AlertLevel = 'high' | 'medium' | 'low';
@ -157,7 +140,7 @@ export default function Scanner() {
padding: '40px 48px 32px', padding: '40px 48px 32px',
}} }}
> >
<div style={{ maxWidth: '1200px', margin: '0 auto' }}> <div className="max-w-7xl mx-auto px-12 max-md:px-5">
<div <div
style={{ style={{
fontSize: '11px', fontSize: '11px',
@ -215,7 +198,7 @@ export default function Scanner() {
</div> </div>
</div> </div>
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '40px 48px 64px' }}> <div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 40, paddingBottom: 64 }}>
{/* Price Ticker Grid */} {/* Price Ticker Grid */}
<div style={{ marginBottom: '56px' }}> <div style={{ marginBottom: '56px' }}>
<div <div

View File

@ -191,11 +191,11 @@ function MemberCard({ member }: { member: (typeof team)[0] }) {
export default function Team() { export default function Team() {
return ( return (
<div <div
className="max-w-7xl mx-auto px-12 max-md:px-5"
style={{ style={{
maxWidth: 1280,
margin: '0 auto',
padding: '64px 48px',
direction: 'rtl', direction: 'rtl',
paddingTop: 64,
paddingBottom: 64,
}} }}
> >
{/* ─── Section header ────────────────────────────────── */} {/* ─── Section header ────────────────────────────────── */}