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:
parent
3ba233ee0b
commit
07239bd811
|
|
@ -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
139
CLAUDE.md
|
|
@ -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)
|
||||
|
||||
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 -->
|
||||
488
panel/db.js
488
panel/db.js
|
|
@ -54,6 +54,232 @@ db.exec(`
|
|||
|
||||
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 (
|
||||
symbol TEXT PRIMARY KEY,
|
||||
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) {
|
||||
if (!row) return null;
|
||||
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) {
|
||||
if (!row) return null;
|
||||
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) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -20,12 +20,523 @@
|
|||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"nodemailer": "^8.0.10"
|
||||
"nodemailer": "^8.0.10",
|
||||
"sharp": "^0.35.1"
|
||||
},
|
||||
"engines": {
|
||||
"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": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
|
|
@ -1613,6 +2124,50 @@
|
|||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
|
|
@ -1822,6 +2377,13 @@
|
|||
"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": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"nodemailer": "^8.0.10"
|
||||
"nodemailer": "^8.0.10",
|
||||
"sharp": "^0.35.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
|
|
|||
1353
panel/public/app.js
1353
panel/public/app.js
File diff suppressed because it is too large
Load Diff
|
|
@ -188,7 +188,15 @@ textarea { resize: vertical; min-height: 80px; }
|
|||
color: var(--ink-3);
|
||||
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 {
|
||||
display: grid;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
827
panel/server.js
827
panel/server.js
|
|
@ -5,13 +5,14 @@ import cookieParser from 'cookie-parser';
|
|||
import rateLimit from 'express-rate-limit';
|
||||
import helmet from 'helmet';
|
||||
import multer from 'multer';
|
||||
import sharp from 'sharp';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
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';
|
||||
|
||||
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');
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
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}`);
|
||||
},
|
||||
});
|
||||
// buffer the upload in memory so we can transcode it to WebP before writing to disk
|
||||
const upload = multer({
|
||||
storage,
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
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)'));
|
||||
},
|
||||
});
|
||||
|
|
@ -136,9 +131,16 @@ const PUBLIC_FIELDS = `
|
|||
|
||||
app.get('/api/articles', (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
||||
const rows = db
|
||||
.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles ORDER BY created_at DESC LIMIT ?`)
|
||||
.all(limit);
|
||||
const { category, type } = req.query;
|
||||
let query = `SELECT ${PUBLIC_FIELDS} FROM articles`;
|
||||
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));
|
||||
});
|
||||
|
||||
|
|
@ -148,10 +150,45 @@ app.get('/api/articles/:id', (req, res) => {
|
|||
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' });
|
||||
const relative = `/uploads/${req.file.filename}`;
|
||||
res.json({ url: relative, absoluteUrl: `${PUBLIC_ORIGIN}${relative}` });
|
||||
try {
|
||||
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) {
|
||||
|
|
@ -218,6 +255,702 @@ app.delete('/api/articles/:id', authRequired, (req, res) => {
|
|||
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 ----------
|
||||
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 }));
|
||||
|
||||
/* ── Contact form → email ─────────────────────────────── */
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 276 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react'
|
||||
import { Send, Share2, AtSign, MessageCircle, MapPin, Phone, Mail, ChevronsUp } from 'lucide-react'
|
||||
import { useState, type FC } from 'react'
|
||||
import { MapPin, Phone, Mail, ChevronsUp } from 'lucide-react'
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
|
|
@ -39,15 +39,59 @@ function FooterCurve() {
|
|||
const NAVY = '#032340'
|
||||
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) */
|
||||
const SOCIALS = [
|
||||
{ Icon: Send, name: 'تلگرام', href: '#' },
|
||||
{ Icon: Share2, name: 'لینکدین', href: '#' },
|
||||
{ Icon: AtSign, name: 'توییتر', href: '#' },
|
||||
{ Icon: MessageCircle, name: 'آپارات', href: '#' },
|
||||
const SOCIALS: { Icon: FC<IconProps>; name: string; href: string }[] = [
|
||||
{ Icon: AparatIcon, name: 'آپارات', href: '#' },
|
||||
{ Icon: XIcon, name: 'X', href: '#' },
|
||||
{ Icon: LinkedinIcon, 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)
|
||||
return (
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<Icon size={19} strokeWidth={2} />
|
||||
<Icon />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
|
@ -69,7 +113,7 @@ function SocialIcon({ Icon, name, href }: { Icon: typeof Send; name: string; hre
|
|||
const NAV_LINKS = [
|
||||
{ label: 'رادار آینده', href: '/radar' },
|
||||
{ label: 'آمار و داده', href: '#' },
|
||||
{ label: 'چندرسانهای', href: '#' },
|
||||
{ label: 'رسانه و رویداد', href: '#' },
|
||||
{ label: 'در یک نگاه', href: '/at-a-glance' },
|
||||
{ label: 'شبکه خبرگان', href: '#' },
|
||||
{ label: 'درباره ما', href: '#' },
|
||||
|
|
@ -96,7 +140,7 @@ function NavItem({ href, label }: { href: string; label: string }) {
|
|||
const CONTACT = [
|
||||
{ Icon: MapPin, label: 'نشانی:', value: 'اصفهان، خیابان سعادتآباد' },
|
||||
{ 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() {
|
||||
|
|
@ -201,8 +245,8 @@ export function Footer() {
|
|||
<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 }}
|
||||
className="max-md:!px-6 max-md:!justify-center max-md:!text-center">
|
||||
<span>تمامی حقوق مادی و معنوی این وبسایت برای شرکت فولاد مبارکه اصفهان محفوظ میباشد.</span>
|
||||
<span style={{ opacity: 0.8 }}>طراحی شده توسط شرکت دانشبنیان نواندیشان آتی نگار فرتاک</span>
|
||||
<span>تمامی حقوق مادی و معنوی این وبسایت متعلق به شرکت فولاد مبارکه میباشد.</span>
|
||||
<span style={{ opacity: 0.8 }}>طراح و پشتیبان؛ شرکت دانشبنیان نواندیشان آتی نگار فرتاک</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
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 { useLang } from '@/context/LangContext'
|
||||
|
||||
|
|
@ -21,11 +21,11 @@ type Section = { fa: string; en: string; to: string; Icon: LucideIcon }
|
|||
|
||||
/* ─── dropdown sections under رادار آینده ─── */
|
||||
const SECTIONS: Section[] = [
|
||||
{ fa: 'آیندهپژوهی و رصد هوشمند', en: 'Foresight & Intelligence', to: '/radar', Icon: Radar },
|
||||
{ fa: 'پایداری و فولاد سبز', en: 'Sustainability & Green Steel', to: '/sustainability', Icon: Leaf },
|
||||
{ fa: 'ژئوپلیتیک و اقتصاد جهانی', en: 'Geopolitics & Global Economy', to: '/geopolitics', Icon: Globe },
|
||||
{ fa: 'فناوری و نوآوری', en: 'Technology & Innovation', to: '/technology', Icon: Cpu },
|
||||
{ fa: 'بازار و زنجیره فولاد', en: 'Market & Steel Chain', to: '/pulse', Icon: BarChart3 },
|
||||
{ fa: 'آیندهپژوهی و رصد هوشمند', en: 'Foresight & Intelligence', to: '/radar/foresight', Icon: Radar },
|
||||
{ fa: 'پایداری و فولاد سبز', en: 'Sustainability & Green Steel', to: '/radar/sustainability', Icon: Leaf },
|
||||
{ fa: 'ژئوپلیتیک و اقتصاد جهانی', en: 'Geopolitics & Global Economy', to: '/radar/geopolitics', Icon: Globe },
|
||||
{ fa: 'فناوری و نوآوری', en: 'Technology & Innovation', to: '/radar/technology', Icon: Cpu },
|
||||
{ fa: 'بازار و زنجیره فولاد', en: 'Market & Steel Chain', to: '/radar/market', Icon: BarChart3 },
|
||||
]
|
||||
|
||||
/* ─── dropdown sections under رسانه و رویداد ─── */
|
||||
|
|
@ -35,15 +35,22 @@ const MEDIA: Section[] = [
|
|||
{ 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 ─── */
|
||||
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[] = [
|
||||
{ fa: 'رادار آینده', en: 'Radar', dropdown: 'radar' },
|
||||
{ fa: 'نبض صنعت', en: 'Industry Pulse', to: '/pulse' },
|
||||
{ fa: 'رسانه و رویداد', en: 'Media & Events', dropdown: 'media' },
|
||||
{ fa: 'آمار و داده', en: 'Stats & Data', to: '/pulse' },
|
||||
{ fa: 'در یک نگاه', en: 'At a Glance', to: '/at-a-glance' },
|
||||
{ fa: 'رسانه و رویداد', en: 'Media & Events', dropdown: 'media' },
|
||||
{ fa: 'شبکه خبرگان', en: 'Expert Network', to: '/experts' },
|
||||
{ fa: 'درباره ما', en: 'About', to: '#' },
|
||||
{ fa: 'درباره ما', en: 'About', dropdown: 'about' },
|
||||
]
|
||||
|
||||
const META_LEFT: { fa: string; en: string }[] = [
|
||||
|
|
@ -107,19 +114,19 @@ function NavDropdown({ lang, label, sections }: { lang: 'fa' | 'en'; label: stri
|
|||
key={i}
|
||||
to={item.to}
|
||||
onClick={() => setOpen(false)}
|
||||
style={({ isActive }: { isActive: boolean }): CSSProperties => ({
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
style={(): CSSProperties => ({
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '15px 20px', textDecoration: 'none',
|
||||
color: isActive ? T.red : '#3d3d3d',
|
||||
background: isActive ? 'rgba(155,28,28,0.04)' : 'transparent',
|
||||
color: '#3d3d3d',
|
||||
background: 'transparent',
|
||||
borderBottom: i < sections.length - 1 ? '1px solid rgba(7,29,73,0.07)' : 'none',
|
||||
transition: 'background 100ms', fontSize: 14, fontWeight: 600,
|
||||
})}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = 'rgba(7,29,73,0.03)' }}
|
||||
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>
|
||||
<item.Icon size={28} color="#CD9E53" strokeWidth={1.5} style={{ flexShrink: 0 }} />
|
||||
</NavLink>
|
||||
))}
|
||||
</motion.div>
|
||||
|
|
@ -250,7 +257,8 @@ export default function Header() {
|
|||
>
|
||||
{TOP_NAV.map((item) =>
|
||||
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!} />
|
||||
)}
|
||||
</nav>
|
||||
|
|
@ -279,16 +287,19 @@ export default function Header() {
|
|||
</OutlineButton>
|
||||
<button
|
||||
onClick={toggleLang}
|
||||
aria-label={lang === 'fa' ? 'Switch to English' : 'تغییر به فارسی'}
|
||||
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}`,
|
||||
color: T.ink, cursor: 'pointer', fontFamily: 'inherit',
|
||||
letterSpacing: '0.5px', transition: 'border-color 120ms',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'border-color 120ms',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = T.ink }}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import Special from '@/pages/Special/Special'
|
|||
import Risks from '@/pages/Risks/Risks'
|
||||
import Scanner from '@/pages/Scanner/Scanner'
|
||||
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 Pulse from '@/pages/Pulse/Pulse'
|
||||
import Membership from '@/pages/Membership/Membership'
|
||||
|
|
@ -19,6 +21,8 @@ import Sustainability from '@/pages/Sustainability/Sustainability'
|
|||
import Geopolitics from '@/pages/Geopolitics/Geopolitics'
|
||||
import AtAGlance from '@/pages/AtAGlance/AtAGlance'
|
||||
import Experts from '@/pages/Experts/Experts'
|
||||
import Contact from '@/pages/Contact/Contact'
|
||||
import About from '@/pages/About/About'
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
|
|
@ -37,6 +41,8 @@ export const router = createBrowserRouter([
|
|||
{ path: 'risks', element: <Risks /> },
|
||||
{ path: 'scanner', element: <Scanner /> },
|
||||
{ path: 'radar', element: <Radar /> },
|
||||
{ path: 'radar/:category', element: <RadarCategory /> },
|
||||
{ path: 'posts/:id', element: <PostDetail /> },
|
||||
{ path: 'technology', element: <Technology /> },
|
||||
{ path: 'pulse', element: <Pulse /> },
|
||||
{ path: 'sustainability', element: <Sustainability /> },
|
||||
|
|
@ -44,6 +50,8 @@ export const router = createBrowserRouter([
|
|||
{ path: 'membership', element: <Membership /> },
|
||||
{ path: 'at-a-glance', element: <AtAGlance /> },
|
||||
{ path: 'experts', element: <Experts /> },
|
||||
{ path: 'contact', element: <Contact /> },
|
||||
{ path: 'about', element: <About /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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 GOLD = '#CD9E53'
|
||||
|
|
@ -30,11 +31,22 @@ export default function StrategicEvents() {
|
|||
return (
|
||||
<div style={{ direction: 'rtl' }}>
|
||||
{/* heading */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 28, justifyContent: 'flex-start' }}>
|
||||
<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>
|
||||
<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 }} />
|
||||
<h2 style={{ fontSize: 'clamp(20px,2.2vw,28px)', fontWeight: 900, color: NAVY, letterSpacing: '-0.5px', margin: 0 }}>
|
||||
دیدهبان رویدادها
|
||||
</h2>
|
||||
</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 */}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export interface BentoGridItemProps {
|
|||
title: string
|
||||
description?: string
|
||||
header?: ReactNode /* visual block above content */
|
||||
footer?: ReactNode /* slot below description */
|
||||
icon?: ReactNode
|
||||
tag?: string
|
||||
tagColor?: string /* CSS color value */
|
||||
|
|
@ -45,6 +46,7 @@ export function BentoGridItem({
|
|||
title,
|
||||
description,
|
||||
header,
|
||||
footer,
|
||||
icon,
|
||||
tag,
|
||||
tagColor = 'var(--red)',
|
||||
|
|
@ -125,6 +127,8 @@ export function BentoGridItem({
|
|||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{footer && <div style={{ marginTop: 'auto', paddingTop: 16 }}>{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -65,6 +65,45 @@ export const FACTORY_REPORTS: FactoryReport[] = [
|
|||
]
|
||||
|
||||
export const FACTORY_REPORTS_META = {
|
||||
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' },
|
||||
fa: { overline: 'MOBARAKEH STEEL', heading: 'ماهنامه تحلیلی کارخانه', sub: 'گزارشهای تحلیلی ماهانه عملکرد، بازار، فناوری و پایداری صنعت فولاد.', cta: 'دریافت گزارش' },
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,14 +38,48 @@ export const INTEGRATIONS: Integration[] = [
|
|||
export const INTEGRATIONS_META = {
|
||||
fa: {
|
||||
overline: 'DATA SOURCES',
|
||||
heading: 'یکپارچگی با منابع جهانی',
|
||||
sub: 'اتصال به بیش از ۱۰۰ منبع داده، بورس و پایگاه جهانی فولاد — رصد لحظهای قیمتها، صادرات و روندهای بازار در یک جا.',
|
||||
heading: 'نبض صنعت',
|
||||
sub: 'پنجرهای واحد به جهان دادههای فولاد؛ دسترسی به شاخصها، قیمتها، تجارت جهانی و دادههای کلیدی از معدن تا محصول نهایی.',
|
||||
cta: 'مشاهده منابع داده',
|
||||
},
|
||||
en: {
|
||||
overline: 'DATA SOURCES',
|
||||
heading: 'Seamless Global Integration',
|
||||
sub: 'Connected to 100+ data sources, exchanges and global steel databases — real-time prices, exports and market trends in one place.',
|
||||
heading: 'Industry Pulse',
|
||||
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',
|
||||
},
|
||||
}
|
||||
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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.' },
|
||||
},
|
||||
]
|
||||
|
||||
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 */ }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */ }
|
||||
}
|
||||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
|
@ -93,3 +93,21 @@ export const events: Event[] = [
|
|||
'نشست سالانه کمیته فولاد 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 */ }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,3 +177,102 @@ export const stats: InstituteStats = {
|
|||
memberOrgs: 60,
|
||||
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 */ }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
|
|
@ -72,3 +72,37 @@ export const plans: Plan[] = [
|
|||
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 */
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
|
@ -107,3 +107,21 @@ export const team: TeamMember[] = [
|
|||
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 */ }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -330,6 +330,27 @@ body {
|
|||
.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
|
||||
═══════════════════════════════════════════════ */
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}
|
||||
18
src/main.tsx
18
src/main.tsx
|
|
@ -4,8 +4,24 @@ import { RouterProvider } from 'react-router-dom'
|
|||
import './index.css'
|
||||
import { router } from './app/router'
|
||||
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(
|
||||
<StrictMode>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
|
|
@ -1,147 +1,81 @@
|
|||
import { useState } from 'react'
|
||||
import { events } from '@/data/events'
|
||||
import type { EventType } from '@/data/events'
|
||||
import { MapPin, Calendar } from 'lucide-react'
|
||||
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> = {
|
||||
conference: 'var(--ink)',
|
||||
exhibition: '#6b4a00',
|
||||
seminar: '#1a4a2a',
|
||||
international: 'var(--red)',
|
||||
}
|
||||
|
||||
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: '1403–1404', 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}، {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>
|
||||
)
|
||||
const TYPE_LABEL: Record<string, { fa: string; color: string }> = {
|
||||
conference: { fa: 'کنفرانس', color: '#2471a3' },
|
||||
exhibition: { fa: 'نمایشگاه', color: '#1e8449' },
|
||||
seminar: { fa: 'نشست تخصصی', color: '#7d3c98' },
|
||||
international: { fa: 'بینالملل', color: '#c0392b' },
|
||||
}
|
||||
|
||||
export default function Events() {
|
||||
const { lang } = useLang()
|
||||
const t = T[lang]
|
||||
const filters = FILTERS[lang]
|
||||
const [activeFilter, setActiveFilter] = useState<FilterValue>('all')
|
||||
|
||||
const filtered = activeFilter === 'all' ? events : events.filter(e => e.type === activeFilter)
|
||||
const isFa = lang === 'fa'
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1280, margin: '0 auto', padding: 'clamp(24px,5vw,64px) clamp(16px,4vw,48px)', direction: lang === 'fa' ? 'rtl' : 'ltr' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', borderBottom: '3px solid var(--ink)', marginBottom: 36 }}>
|
||||
<div style={{ background: 'var(--ink)', padding: '14px 28px', display: 'flex', alignItems: 'center' }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 900, color: 'var(--paper)', letterSpacing: '-0.5px', whiteSpace: 'nowrap' }}>{t.heading}</h1>
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', padding: '14px 0 14px 4px' }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--ink-4)' }}>{t.year}</span>
|
||||
<div dir={isFa ? 'rtl' : 'ltr'} style={{ background: '#faf7f4', minHeight: '80vh' }}>
|
||||
<div style={{ background: NAVY, padding: '52px 0 40px' }}>
|
||||
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
|
||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '3px', color: GOLD, marginBottom: 10, textTransform: 'uppercase' }}>
|
||||
{isFa ? 'رسانه و رویداد' : 'Media & Events'}
|
||||
</div>
|
||||
<h1 style={{ fontSize: 'clamp(26px,3vw,40px)', fontWeight: 900, color: '#fff', margin: '0 0 10px', letterSpacing: '-0.5px' }}>
|
||||
{isFa ? 'رویدادها' : 'Events'}
|
||||
</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 style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--rule-thin)', marginBottom: 40, overflowX: 'auto', scrollbarWidth: 'none' }}>
|
||||
{filters.map(f => {
|
||||
const isActive = activeFilter === f.value
|
||||
return (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setActiveFilter(f.value)}
|
||||
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' }}
|
||||
>
|
||||
{f.label}
|
||||
{f.value !== 'all' && (
|
||||
<span style={{ marginLeft: 6, fontSize: 10, color: isActive ? 'rgba(244,239,231,0.6)' : 'var(--ink-6)' }}>
|
||||
({events.filter(e => e.type === f.value).length})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ padding: '80px 0', textAlign: 'center', color: 'var(--ink-5)' }}>
|
||||
<p style={{ fontSize: 15, fontWeight: 300 }}>{t.empty}</p>
|
||||
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 48, paddingBottom: 72 }}>
|
||||
<div className="grid grid-cols-2 max-lg:grid-cols-1" style={{ gap: 20 }}>
|
||||
{events.map((ev) => {
|
||||
const typeInfo = TYPE_LABEL[ev.type] ?? { fa: ev.type, color: GOLD }
|
||||
return (
|
||||
<div key={ev.id} style={{
|
||||
background: '#fff', borderRadius: 14,
|
||||
boxShadow: '0 2px 16px rgba(7,29,73,0.07)',
|
||||
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)' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ background: NAVY, color: '#fff', borderRadius: 8, padding: '4px 12px', fontSize: 13, fontWeight: 800, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
<style>{`@media (max-width: 767px) { .events-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -481,7 +481,7 @@ export default function Experts() {
|
|||
|
||||
{/* Room 1: Sustainable Development */}
|
||||
<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" />
|
||||
<h2 style={{ fontSize: 19, fontWeight: 900, color: '#032340', margin: 0 }}>{t.roomSustainability}</h2>
|
||||
</div>
|
||||
|
|
@ -494,7 +494,7 @@ export default function Experts() {
|
|||
|
||||
{/* Room 2: Steel Industry */}
|
||||
<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" />
|
||||
<h2 style={{ fontSize: 19, fontWeight: 900, color: '#032340', margin: 0 }}>{t.roomSteel}</h2>
|
||||
</div>
|
||||
|
|
@ -507,7 +507,7 @@ export default function Experts() {
|
|||
|
||||
{/* Room 3: Economics */}
|
||||
<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" />
|
||||
<h2 style={{ fontSize: 19, fontWeight: 900, color: '#032340', margin: 0 }}>{t.roomEconomics}</h2>
|
||||
</div>
|
||||
|
|
@ -520,7 +520,7 @@ export default function Experts() {
|
|||
|
||||
{/* Room 4: Technology & Innovation */}
|
||||
<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" />
|
||||
<h2 style={{ fontSize: 19, fontWeight: 900, color: '#032340', margin: 0 }}>{t.roomTechnology}</h2>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import AtAGlanceSection from './sections/AtAGlanceSection'
|
|||
import FeaturedReportBanner from './sections/FeaturedReportBanner'
|
||||
import MapEventsSection from './sections/MapEventsSection'
|
||||
import FactoryReportsScroll from './sections/FactoryReportsScroll'
|
||||
import NewsletterMarquee from './sections/NewsletterMarquee'
|
||||
import VideocastPodcastSection from './sections/VideocastPodcastSection'
|
||||
// import RiskSection from './sections/RiskSection' // «نقشه ریسک صنعت» — disabled, restore anytime
|
||||
import { useLang } from '@/context/LangContext'
|
||||
|
|
@ -24,7 +23,6 @@ export default function Home() {
|
|||
<FeaturedReportBanner />
|
||||
<IntegrationsSection />
|
||||
<MapEventsSection />
|
||||
<NewsletterMarquee />
|
||||
<FactoryReportsScroll />
|
||||
<VideocastPodcastSection only="video" />
|
||||
<VideocastPodcastSection only="pod" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useLang } from '@/context/LangContext'
|
||||
|
||||
const NAVY = '#032340'
|
||||
|
|
@ -46,108 +48,117 @@ const ITEMS: Glance[] = [
|
|||
export default function AtAGlanceSection({ embedded = false }: { embedded?: boolean }) {
|
||||
const { lang } = useLang()
|
||||
const isFa = lang === 'fa'
|
||||
const [i, setI] = useState(0)
|
||||
const [idx, setIdx] = useState(0)
|
||||
const [dir, setDir] = useState<1 | -1>(1)
|
||||
const it = ITEMS[i]
|
||||
const it = ITEMS[idx]
|
||||
|
||||
const go = (d: 1 | -1) => {
|
||||
setDir(d)
|
||||
setI((prev) => (prev + d + ITEMS.length) % ITEMS.length)
|
||||
}
|
||||
|
||||
const T = {
|
||||
heading: isFa ? 'در یک نگاه' : 'At a Glance',
|
||||
setIdx((prev) => (prev + d + ITEMS.length) % ITEMS.length)
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
|
||||
{/* ── heading row ── */}
|
||||
{!embedded && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, justifyContent: 'flex-start', marginBottom: 24 }}>
|
||||
<div style={{ width: 4, height: 28, background: GOLD, borderRadius: 2 }} />
|
||||
<h2 style={{ fontSize: 'clamp(22px, 2.2vw, 30px)', fontWeight: 900, color: NAVY, letterSpacing: '-0.5px', margin: 0 }}>
|
||||
{T.heading}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 32 }}>
|
||||
{/* title — bilingual stacked */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<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>
|
||||
</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).
|
||||
Container is LTR; image is the first DOM child, so plain `row` lands it on the left. */}
|
||||
<div
|
||||
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 side (with stacked-card shadow) ── */}
|
||||
<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 */}
|
||||
{/* ── card row: image left, text right ── */}
|
||||
{/* image is taller; text card is centered vertically and overlaps image by ~48px */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 0, direction: 'ltr', position: 'relative' }} 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">
|
||||
{/* back shadow card */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
transform: 'translate(-28px, -22px)',
|
||||
background: '#ece7e1', borderRadius: 18,
|
||||
boxShadow: '0 30px 70px -30px rgba(7,29,73,0.25)',
|
||||
transform: 'translate(-20px, -16px)',
|
||||
background: '#e8e2db', borderRadius: 18,
|
||||
}} />
|
||||
{/* front image — portrait on desktop, shorter landscape on mobile */}
|
||||
<div
|
||||
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]"
|
||||
>
|
||||
{/* image fills full height of its column */}
|
||||
<div style={{ position: 'relative', borderRadius: 18, overflow: 'hidden', height: '100%', minHeight: 420 }} className="max-lg:!min-h-[260px]">
|
||||
<AnimatePresence mode="wait" custom={dir}>
|
||||
<motion.img
|
||||
key={i}
|
||||
key={idx}
|
||||
src={it.img}
|
||||
alt=""
|
||||
initial={{ opacity: 0, scale: 1.06 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 1.02 }}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── TEXT card side ── */}
|
||||
<div style={{ flex: 1, position: 'relative', zIndex: 2 }} className="max-lg:!w-full">
|
||||
{/* TEXT — shorter, overlaps image by 48px on the left */}
|
||||
<div style={{ flex: 1, position: 'relative', marginLeft: -48, zIndex: 2 }} className="max-lg:!ms-0 max-lg:!me-0">
|
||||
<div style={{
|
||||
background: '#fff', borderRadius: 6,
|
||||
boxShadow: '0 30px 80px -40px rgba(7,29,73,0.30)',
|
||||
padding: '40px 44px 32px',
|
||||
// pull the card toward the image to overlap it (image is on the left in RTL)
|
||||
marginLeft: isFa ? '-8%' : 0,
|
||||
marginRight: isFa ? 0 : '-8%',
|
||||
background: '#fff',
|
||||
borderRadius: 6,
|
||||
boxShadow: '0 20px 60px -30px rgba(7,29,73,0.25)',
|
||||
padding: '32px 40px 48px',
|
||||
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">
|
||||
<motion.div
|
||||
key={i}
|
||||
key={idx}
|
||||
initial={{ opacity: 0, x: dir * 24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: dir * -16 }}
|
||||
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}
|
||||
</h3>
|
||||
|
||||
{/* date (leading side) + source (trailing side) */}
|
||||
<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 />
|
||||
{isFa ? it.date : it.dateEn}
|
||||
</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: GOLD, letterSpacing: '0.3px' }}>{it.source}</span>
|
||||
</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}
|
||||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* prev / next controls */}
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 28 }}>
|
||||
{/* nav buttons — half inside / half outside bottom of text card */}
|
||||
<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="next">›</NavBtn>
|
||||
</div>
|
||||
|
|
@ -166,13 +177,15 @@ function NavBtn({ children, onClick, aria }: { children: React.ReactNode; onClic
|
|||
aria-label={aria}
|
||||
style={{
|
||||
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,
|
||||
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' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = '#fff'; (e.currentTarget as HTMLElement).style.color = GOLD }}
|
||||
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 = GOLD; (e.currentTarget as HTMLElement).style.transform = 'translateY(0)' }}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
|
@ -181,7 +194,7 @@ function NavBtn({ children, onClick, aria }: { children: React.ReactNode; onClic
|
|||
|
||||
function CalendarIcon() {
|
||||
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" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { Download } from 'lucide-react'
|
||||
import { BentoGrid, BentoGridItem } from '@/components/ui/bento-grid'
|
||||
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 = {
|
||||
publisher: string
|
||||
publisherEn: string
|
||||
|
|
@ -88,8 +91,25 @@ const REPORTS: FactoryReport[] = [
|
|||
]
|
||||
|
||||
const T = {
|
||||
fa: { overline: 'MOBARAKEH STEEL', heading: 'ماهنامه تحلیلی کارخانه', all: 'مشاهده همه ←' },
|
||||
en: { overline: 'MOBARAKEH STEEL', heading: 'Factory Monthly Analysis', all: 'View All →' },
|
||||
fa: { overline: 'MOBARAKEH STEEL', heading: 'ماهنامه تحلیلی کارخانه', all: 'مشاهده همه', download: 'دریافت گزارش' },
|
||||
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' }) {
|
||||
|
|
@ -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>
|
||||
<h2 style={{ fontSize: 'clamp(22px,2.5vw,32px)', fontWeight: 900, color: 'var(--ink)', letterSpacing: '-0.5px' }}>{t.heading}</h2>
|
||||
</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>
|
||||
|
||||
<BentoGrid>
|
||||
|
|
@ -147,6 +167,7 @@ export default function FactoryBentoSection() {
|
|||
tagColor={report.accentColor}
|
||||
title={lang === 'fa' ? report.title : report.titleEn}
|
||||
description={lang === 'fa' ? report.description : report.descriptionEn}
|
||||
footer={<DownloadCTA label={t.download} />}
|
||||
accentLine
|
||||
className={i === 3 || i === 6 ? 'md:col-span-2' : ''}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { motion } from 'framer-motion'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { Download } from 'lucide-react'
|
||||
import { useLang } from '@/context/LangContext'
|
||||
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 GOLD = '#CD9E53'
|
||||
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>
|
||||
<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>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, fontWeight: 800, color: GOLD }}>
|
||||
{FACTORY_REPORTS_META[lang].cta}
|
||||
<ArrowLeft size={14} style={{ transform: lang === 'fa' ? 'none' : 'rotate(180deg)' }} />
|
||||
</span>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<a
|
||||
href={MSC_URL}
|
||||
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>
|
||||
</article>
|
||||
)
|
||||
|
|
@ -62,7 +72,6 @@ function ReportCard({ r, lang }: { r: FactoryReport; lang: 'fa' | 'en' }) {
|
|||
|
||||
export default function FactoryReportsScroll() {
|
||||
const { lang } = useLang()
|
||||
const m = FACTORY_REPORTS_META[lang]
|
||||
const reports = FACTORY_REPORTS.slice(0, 4) // compact: just the latest 4
|
||||
|
||||
return (
|
||||
|
|
@ -72,12 +81,11 @@ export default function FactoryReportsScroll() {
|
|||
{/* 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>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '3px', color: GOLD, marginBottom: 6 }}>{m.overline}</div>
|
||||
<h2 style={{ fontSize: 'clamp(20px, 2vw, 28px)', fontWeight: 900, color: NAVY, letterSpacing: '-0.5px', margin: 0 }}>{m.heading}</h2>
|
||||
<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 }}>ماهنامه تحلیلی کارخانه</h2>
|
||||
</div>
|
||||
<a href="#" 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 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' }}>
|
||||
مشاهده همه
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,36 +1,29 @@
|
|||
import { Download } from 'lucide-react'
|
||||
import { useLang } from '@/context/LangContext'
|
||||
import { bannerFor } from '@/data/banners'
|
||||
|
||||
const NAVY = '#032340'
|
||||
const GOLD = '#CD9E53'
|
||||
const 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',
|
||||
},
|
||||
}
|
||||
const FALLBACK_BOOK = '/Horizontal_Book_Mockup_6%201.webp'
|
||||
|
||||
export default function FeaturedReportBanner() {
|
||||
const { lang } = useLang()
|
||||
const isFa = lang === 'fa'
|
||||
const t = T[lang]
|
||||
|
||||
// no PDF yet — button present but downloads nothing
|
||||
const onDownload = () => { /* TODO: wire real PDF when available */ }
|
||||
// CMS-managed banner (bootstrapped at startup); render nothing if none active
|
||||
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 (
|
||||
<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 */}
|
||||
<div style={{ position: 'relative', overflow: 'visible' }}>
|
||||
|
||||
|
|
@ -46,48 +39,58 @@ export default function FeaturedReportBanner() {
|
|||
}} className="max-md:hidden" />
|
||||
</div>
|
||||
|
||||
{/* content: text on the LEFT, book on the RIGHT (text is first DOM child, plain row) */}
|
||||
<div style={{ position: 'relative', display: 'flex', flexDirection: 'row', alignItems: 'center', minHeight: 240, direction: 'ltr' }}
|
||||
{/* DESKTOP: row. MOBILE: column */}
|
||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', minHeight: 240, direction: 'ltr' }}
|
||||
className="max-md:!flex-col">
|
||||
|
||||
{/* TEXT — left */}
|
||||
{/* TEXT */}
|
||||
<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">
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'rgba(255,255,255,0.55)', marginBottom: 14 }}>{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' }}>
|
||||
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: 10 }}>{t.overline}</div>
|
||||
<h2 style={{ fontSize: 'clamp(18px, 2.3vw, 30px)', fontWeight: 900, color: '#fff', lineHeight: 1.4, letterSpacing: '-0.5px', margin: '0 0 8px' }}>
|
||||
{t.title}
|
||||
</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}
|
||||
</div>
|
||||
{/* button BELOW the text */}
|
||||
<button
|
||||
onClick={onDownload}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 10,
|
||||
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} />
|
||||
{t.cta}
|
||||
</button>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={onDownload}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 10,
|
||||
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} />
|
||||
{t.cta}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOOK — right, spilling out of the frame */}
|
||||
<div style={{ width: '48%', position: 'relative', alignSelf: 'stretch' }} className="max-md:!w-full">
|
||||
{/* BOOK */}
|
||||
<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}
|
||||
src={book}
|
||||
alt={t.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:!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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
{/* ── Text header ────────────────────────────────── */}
|
||||
<div
|
||||
style={{ textAlign: 'center', paddingTop: 72, paddingBottom: 48 }}
|
||||
style={{ textAlign: 'center', paddingTop: 40, paddingBottom: 24 }}
|
||||
>
|
||||
{/* Headline */}
|
||||
<h2 style={{
|
||||
|
|
|
|||
|
|
@ -1,22 +1,51 @@
|
|||
import { useRef, useState, useEffect, useLayoutEffect } from 'react'
|
||||
import { gsap } from 'gsap'
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useLang } from '@/context/LangContext'
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
|
||||
const NAVY = '#032340'
|
||||
const GOLD = '#CD9E53'
|
||||
const PAPER = '#faf7f4' // cream background revealed under the de-zoom
|
||||
|
||||
const HERO = {
|
||||
fa: {
|
||||
title: 'آینده فولاد را امروز رصد کن.',
|
||||
desc: 'هوشمندی که هر بازار را رصد میکند و راهبرد برنده را در یک نگاه به تو میدهد.',
|
||||
},
|
||||
en: {
|
||||
title: 'See the future of steel today.',
|
||||
desc: 'Intelligence that scans every market and hands you the winning strategy at a glance.',
|
||||
},
|
||||
const SLIDES = {
|
||||
fa: [
|
||||
{
|
||||
title: 'بینشی نو برای توسعهی صنعتی',
|
||||
desc: 'آشنایی با چشمانداز ما',
|
||||
cta1: 'درباره ما', cta1href: '/about',
|
||||
},
|
||||
{
|
||||
title: 'داده، تحلیل، آیندهنگاری',
|
||||
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'
|
||||
|
|
@ -36,7 +65,15 @@ const SATELLITES: Sat[] = [
|
|||
/* ─── MAIN ─── */
|
||||
export default function HeroSection() {
|
||||
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 pinRef = useRef<HTMLDivElement>(null)
|
||||
const centerRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -177,10 +214,34 @@ export default function HeroSection() {
|
|||
</h1>
|
||||
<p style={{
|
||||
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}
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useMemo } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
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,
|
||||
}
|
||||
|
||||
const CELL = new Map(INTEGRATIONS.map((it) => [`${it.col}-${it.row}`, it]))
|
||||
|
||||
export default function IntegrationsSection() {
|
||||
const { lang } = useLang()
|
||||
const isFa = lang === 'fa'
|
||||
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 (
|
||||
<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 */}
|
||||
<motion.div
|
||||
animate={{ x: [0, 26, 0], y: [0, -18, 0], opacity: [0.8, 1, 0.8] }}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ export default function MapEventsSection() {
|
|||
return (
|
||||
<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 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 ── */}
|
||||
<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 />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,42 +1,43 @@
|
|||
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 { RadarCard } from '@/pages/Radar/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 }
|
||||
const RESEARCH_AREAS: Record<'fa' | 'en', Area[]> = {
|
||||
fa: [
|
||||
{ Icon: Telescope, color: '#CD9E53', title: 'آیندهنگاری علمی', desc: 'بهجای پیشبینی تکنقطهای، با سناریوپردازی و تحلیل روند، چند آیندهٔ محتمل صنعت فولاد را ترسیم میکنیم.' },
|
||||
{ Icon: Database, color: '#2471a3', title: 'تحلیل دادهمحور', desc: 'هر بینش بر پایهٔ دادههای معتبر جهانی و داخلی است، نه گمانهزنی؛ تصمیمسازی با شواهد قابلاتکا.' },
|
||||
{ Icon: Scale, color: '#1e8449', title: 'بیطرفی و استقلال', desc: 'تحلیلهای ما مستقل از منافع کوتاهمدت است؛ شفاف و بدون جانبداری از یک بازیگر خاص.' },
|
||||
{ Icon: Users, color: '#7d3c98', title: 'هماندیشی خبرگان', desc: 'شبکهای از مدیران، پژوهشگران و فعالان صنعت که دانش پراکنده را به بینش جمعی تبدیل میکند.' },
|
||||
{ Icon: Lightbulb, color: '#c0392b', title: 'از تحلیل تا اقدام', desc: 'یافتهها را به راهبردهای عملی و قابلاجرا برای رهبران صنعت ترجمه میکنیم، نه گزارشهای روی طاقچه.' },
|
||||
{ Icon: Database, color: '#2471a3', title: 'تحلیل دادهمحور', desc: 'استفاده از دادههای بازار، اقتصاد، فناوری و زنجیره ارزش فولاد برای استخراج بینشهای قابل اتکا.' },
|
||||
{ Icon: Network, color: '#7d3c98', title: 'نگاه نظاممند', desc: 'تحلیل صنعت فولاد در تعامل با اقتصاد، انرژی، فناوری، محیط زیست و سیاستهای جهانی.' },
|
||||
{ Icon: Users, color: '#16a085', title: 'هماندیشی خبرگان', desc: 'بهرهگیری مستمر از شبکهای از خبرگان صنعت، دانشگاه و فناوری.' },
|
||||
{ Icon: Telescope, color: '#CD9E53', title: 'آیندهنگاری علمی', desc: 'تحلیل روندها، شناسایی عدمقطعیتها و طراحی سناریوها.' },
|
||||
{ Icon: Lightbulb, color: '#c0392b', title: 'از تحلیل تا اقدام', desc: 'تبدیل تحلیلها به توصیهها و تجویزهای استراتژیک.' },
|
||||
{ Icon: Scale, color: '#1e8449', title: 'بیطرفی و استقلال', desc: 'اندیشههای مستقل و متکی بر شواهد، دادهها و نگاه بلندمدت.' },
|
||||
],
|
||||
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: 'Every insight rests on credible global and domestic data — evidence, not guesswork.' },
|
||||
{ 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: '#7d3c98', title: 'Expert Collaboration', desc: 'A network of executives, researchers and practitioners turning scattered knowledge into collective insight.' },
|
||||
{ Icon: Lightbulb, color: '#c0392b', title: 'From Analysis to Action', desc: 'We translate findings into practical strategies for industry leaders — not shelf-ware reports.' },
|
||||
{ Icon: Database, color: '#2471a3', title: 'Data-Driven Analysis', desc: 'Using market, economic, technology and value-chain data to extract reliable insights.' },
|
||||
{ Icon: Network, color: '#7d3c98', title: 'Systemic Thinking', desc: 'Analysing the steel industry in its interaction with economy, energy, technology, environment and global policy.' },
|
||||
{ Icon: Users, color: '#16a085', title: 'Expert Collaboration', desc: 'Continuous engagement with a network of industry, academic and technology experts.' },
|
||||
{ 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: '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 = {
|
||||
fa: {
|
||||
radarOverline: 'FUTURE RADAR', radarHeading: 'رادار آینده',
|
||||
radarSub: 'سیگنالهای جهانی با اثر مستقیم بر صنعت فولاد ایران',
|
||||
radarSub: 'تحلیل چندوجهی آینده صنعت فولاد',
|
||||
radarAll: 'همه سیگنالها ←',
|
||||
areasOverline: 'OUR APPROACH', areasHeading: 'رویکرد ما',
|
||||
areasSub: 'اصول و روش کار اندیشکده فولاد آینده در تحلیل و آیندهنگاری صنعت',
|
||||
areasOverline: 'OUR FRAMEWORK', areasHeading: 'چارچوب اندیشه ما',
|
||||
areasSub: 'ترکیب داده، تحلیل، آیندهنگاری و خرد جمعی برای فهم تحولات صنعت فولاد.',
|
||||
},
|
||||
en: {
|
||||
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 →',
|
||||
areasOverline: 'OUR APPROACH', areasHeading: 'Our Approach',
|
||||
areasSub: 'How the Steel Futures Institute works — its principles for analysis and foresight',
|
||||
areasOverline: 'OUR FRAMEWORK', areasHeading: 'Our Thinking Framework',
|
||||
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
|
||||
key={i}
|
||||
style={{
|
||||
background: '#fff', borderRadius: 16, padding: '26px 24px',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.06)', display: 'flex', flexDirection: 'column', gap: 14,
|
||||
border: '1px solid var(--rule-thin)', transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
background: '#fff', borderRadius: 16, padding: '20px 18px',
|
||||
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', 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)' }}
|
||||
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={{
|
||||
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',
|
||||
}}>
|
||||
<Icon size={26} color={a.color} strokeWidth={2} />
|
||||
<Icon size={22} color={a.color} strokeWidth={2} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 900, color: 'var(--ink)', lineHeight: 1.4, margin: '0 0 8px' }}>{a.title}</h3>
|
||||
<p style={{ fontSize: 12.5, color: 'var(--ink-4)', lineHeight: 1.85, margin: 0 }}>{a.desc}</p>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 900, color: 'var(--ink)', lineHeight: 1.4, margin: '0 0 6px' }}>{a.title}</h3>
|
||||
<p style={{ fontSize: 12, color: 'var(--ink-4)', lineHeight: 1.7, margin: 0 }}>{a.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useRef, useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence, useScroll, useMotionValueEvent } from 'framer-motion'
|
||||
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 GOLD = '#CD9E53'
|
||||
|
|
@ -21,15 +22,15 @@ const FEATURES: Feat[] = [
|
|||
img: '/hero%201.webp', imgSide: 'right', imgPos: 'right 35%',
|
||||
fa: {
|
||||
kicker: 'اندیشکده فولاد آینده',
|
||||
title: 'بینش راهبردی',
|
||||
desc: 'عصرِ رقابت بر سر تناژ به پایان رسیده است. در جهان پرشتاب فردا، هژمونی صنعتی از «عمق تحلیل»، «تسلط بر شبکههای داده» و «جسارت در طراحی آینده» خلق میشود.',
|
||||
title: 'بینشی نو برای توسعهی صنعتی',
|
||||
desc: '',
|
||||
stat: '۶ بازار',
|
||||
statTail: 'رصد ۲۴ ساعته. هشدار در کمتر از ۱۲ ثانیه.',
|
||||
},
|
||||
en: {
|
||||
kicker: 'Future Steel Institute',
|
||||
title: 'Strategic Vision',
|
||||
desc: 'The era of tonnage competition is over. Industrial leadership is built on analytical depth, data mastery, and the courage to design the future.',
|
||||
title: 'A New Vision for Industrial Development',
|
||||
desc: '',
|
||||
stat: '6 markets',
|
||||
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',
|
||||
fa: {
|
||||
kicker: 'فناوری صنعتی',
|
||||
title: 'رادار فناوری',
|
||||
desc: 'انقلاب هوش مصنوعی در حال بازنویسی DNA زنجیره ارزش است. ما سیگنالهای ضعیف فناوری را شکار کرده و آنها را به استراتژیهای برنده برای رهبران صنعتی تبدیل میکنیم.',
|
||||
title: 'داده، تحلیل، آیندهنگاری',
|
||||
desc: 'تصمیمسازی هوشمند در جهانی در حال تحول\n+۱۰۰ منبع داده جهانی، رصد ۲۴/۷ بازارها',
|
||||
stat: '٪۳۸',
|
||||
statTail: 'میانگین حاشیه سود در هر معامله.',
|
||||
},
|
||||
en: {
|
||||
kicker: 'Industrial Technology',
|
||||
title: 'Technology Radar',
|
||||
desc: 'AI is rewriting the DNA of the value chain. We hunt weak technology signals and transform them into winning strategies for industry leaders.',
|
||||
title: 'Data, Analysis, Foresight',
|
||||
desc: 'Smart decision-making in a world in transformation\n100+ global data sources, 24/7 market monitoring',
|
||||
stat: '38%',
|
||||
statTail: 'Average margin per deal.',
|
||||
},
|
||||
|
|
@ -55,15 +56,15 @@ const FEATURES: Feat[] = [
|
|||
img: '/hero%203.webp', imgSide: 'right', imgPos: 'center center',
|
||||
fa: {
|
||||
kicker: 'ژئواکونومی',
|
||||
title: 'کلانروندها',
|
||||
desc: 'هندسه قدرت در صنعت در حال تغییر است. رمزگشایی از تقاطع پیچیده ژئواکونومی، مقررات کربنی و گذار انرژی؛ قطبنمای شما برای عبور ایمن از طوفانهای آینده.',
|
||||
title: 'آزمون استراتژی در آیندهها',
|
||||
desc: '',
|
||||
stat: '۲۰ ساعت',
|
||||
statTail: 'صرفهجویی هفتگی نسبت به روش دستی.',
|
||||
},
|
||||
en: {
|
||||
kicker: 'Geo-Economics',
|
||||
title: 'Macro Trends',
|
||||
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.',
|
||||
title: 'Testing Strategy in Futures',
|
||||
desc: '',
|
||||
stat: '20 hrs',
|
||||
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'
|
||||
? (index === 0 ? 'آشنایی با مأموریت ما' : index === 1 ? 'تحلیلهای فناوری' : 'داشبوردهای پایداری')
|
||||
: (index === 0 ? 'Our Mission' : index === 1 ? 'Technology Analysis' : 'Sustainability Dashboards')
|
||||
? (index === 0 ? 'آشنایی با چشمانداز ما' : index === 1 ? 'نبض صنعت' : 'آزمایشگاه آینده')
|
||||
: (index === 0 ? 'Discover our perspective' : index === 1 ? 'Industry Pulse' : 'The Futures Laboratory')
|
||||
const buttonHref = index === 0 ? '/about' : index === 1 ? '/pulse' : '/radar'
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
|
|
@ -141,7 +143,7 @@ function TextPanel({ index, feat, lang }: { index: number; feat: Feat; lang: 'fa
|
|||
textAlign: lang === 'fa' ? 'right' : 'left',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: lang === 'fa' ? 'flex-start' : 'flex-end',
|
||||
alignItems: 'flex-end',
|
||||
}}>
|
||||
<FadeUp>
|
||||
<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 delay={0.05}>
|
||||
<h2 style={{
|
||||
fontSize: 'clamp(28px, 3.4vw, 50px)',
|
||||
fontSize: 'clamp(18px, 3.4vw, 50px)',
|
||||
fontWeight: 900,
|
||||
color: textColor,
|
||||
lineHeight: 1.15,
|
||||
margin: '0 0 20px',
|
||||
}}>
|
||||
whiteSpace: 'nowrap',
|
||||
}} className="max-md:!whitespace-normal max-md:!text-[18px]">
|
||||
{t.title}
|
||||
</h2>
|
||||
</FadeUp>
|
||||
|
|
@ -171,24 +174,24 @@ function TextPanel({ index, feat, lang }: { index: number; feat: Feat; lang: 'fa
|
|||
</p>
|
||||
</FadeUp>
|
||||
<FadeUp delay={0.15}>
|
||||
<button style={{
|
||||
<Link to={buttonHref} style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '14px 28px',
|
||||
borderRadius: 30,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
direction: 'ltr',
|
||||
background: isSlide2 ? NAVY : GOLD,
|
||||
color: isSlide2 ? '#fff' : NAVY,
|
||||
transition: 'all 0.2s',
|
||||
}} className="hover:!scale-105 hover:!opacity-95">
|
||||
<ArrowLeft size={16} strokeWidth={2.5} className="no-rtl-flip" style={{ flexShrink: 0 }} />
|
||||
<span>{buttonText}</span>
|
||||
{lang === 'fa' ? <ArrowLeft size={16} strokeWidth={2.5} /> : <ArrowRight size={16} strokeWidth={2.5} />}
|
||||
</button>
|
||||
</Link>
|
||||
</FadeUp>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,93 +1,30 @@
|
|||
import { useState } from 'react'
|
||||
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 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 = {
|
||||
fa: {
|
||||
videoLabel: 'ویدیوکست', videoSub: 'آخرین گفتگوهای تصویری اندیشکده',
|
||||
podLabel: 'پادکستها', podSub: 'شنیدنیهای اندیشکده فولاد آینده',
|
||||
watchAll: 'همه ویدیوها ←', listenAll: 'مشاهده همه',
|
||||
watchAll: 'مشاهده همه', listenAll: 'مشاهده همه',
|
||||
},
|
||||
en: {
|
||||
videoLabel: 'Videocast', videoSub: 'Latest video conversations from the 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' }) {
|
||||
const { lang } = useLang()
|
||||
const t = T[lang]
|
||||
const [activeVideo, setActiveVideo] = useState(0)
|
||||
const [activeVideo] = useState(0)
|
||||
const [activePod, setActivePod] = useState(0)
|
||||
const pod = PODCASTS[activePod]
|
||||
|
||||
|
|
@ -106,9 +43,21 @@ 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">
|
||||
|
||||
{/* heading */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24, justifyContent: 'flex-start' }}>
|
||||
<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>
|
||||
<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 }} />
|
||||
<h2 style={{ fontSize: 'clamp(20px,2.2vw,28px)', fontWeight: 900, letterSpacing: '-0.5px', color: NAVY, margin: 0 }}>{t.videoLabel}</h2>
|
||||
</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 */}
|
||||
|
|
@ -146,57 +95,15 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* playlist (right) + description (left) */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.2fr', gap: 40 }} className="max-lg:!grid-cols-1 max-lg:!gap-8">
|
||||
|
||||
{/* 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' }}>
|
||||
<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>
|
||||
</div>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 2.1, color: 'rgba(3,35,64,0.72)', margin: '0 0 18px', whiteSpace: 'pre-line', textAlign: 'justify' }}>
|
||||
{VIDEOS[activeVideo][lang].body}
|
||||
</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>
|
||||
{/* description only — playlist removed */}
|
||||
<div style={{ direction: lang === 'fa' ? 'rtl' : 'ltr' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, justifyContent: 'flex-start' }}>
|
||||
<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>
|
||||
</div>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 2.1, color: 'rgba(3,35,64,0.72)', margin: '0 0 18px', whiteSpace: 'pre-line', textAlign: 'justify' }}>
|
||||
{VIDEOS[activeVideo][lang].body}
|
||||
</p>
|
||||
</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>
|
||||
</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'
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
@ -603,19 +493,6 @@ export default function VideocastPodcastSection({ only }: { only?: 'video' | 'po
|
|||
}}>
|
||||
{lang === 'fa' ? 'مهمان برنامه: ' : 'Guest: '}{pod[lang].guest}
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ export default function Membership() {
|
|||
<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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
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 { RADAR_ITEMS, type RadarItem, type RadarCategory } from '@/content/radar'
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ const PER_PAGE = 8
|
|||
const CATEGORY_ICON: Record<RadarCategory, typeof BarChart3> = {
|
||||
market: BarChart3,
|
||||
tech: Cpu,
|
||||
commodity: Mountain,
|
||||
commodity: Leaf,
|
||||
geo: Globe,
|
||||
energy: Zap,
|
||||
}
|
||||
|
|
@ -96,20 +96,9 @@ export function RadarCard({ item, lang }: { item: RadarItem; lang: 'fa' | 'en' }
|
|||
direction: lang === 'fa' ? 'rtl' : 'ltr',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
{/* author avatar replaces the category icon */}
|
||||
<motion.img
|
||||
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 style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, color: '#fff', lineHeight: 1.4, marginBottom: 4 }}>{t.title}</div>
|
||||
<div style={{ fontSize: 10, color: GOLD, fontWeight: 600 }}>{t.date}</div>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, lineHeight: 1.8, color: 'rgba(255,255,255,0.78)', margin: 0 }}>
|
||||
{t.excerpt}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,23 +1,6 @@
|
|||
import { marketPrices, globalComparison } from '@/data/market';
|
||||
import type { MarketPrice } from '@/data/market';
|
||||
|
||||
const riskAlerts = [
|
||||
{
|
||||
title: 'فشار صادرات چین',
|
||||
desc: 'افزایش ۱۸٪ صادرات ارزان چین فشار قیمتی روی بازار آسیا',
|
||||
level: 'high',
|
||||
},
|
||||
{
|
||||
title: 'محدودیت انرژی داخلی',
|
||||
desc: 'کاهش تولید زمستانی به دلیل محدودیت گاز صنعتی',
|
||||
level: 'medium',
|
||||
},
|
||||
{
|
||||
title: 'فرصت بازار عراق',
|
||||
desc: 'کاهش واردات ترکیه، فرصت افزایش سهم بازار برای ایران',
|
||||
level: 'low',
|
||||
},
|
||||
] as const;
|
||||
import { riskAlerts } from '@/data/risks';
|
||||
|
||||
type AlertLevel = 'high' | 'medium' | 'low';
|
||||
|
||||
|
|
@ -157,7 +140,7 @@ export default function Scanner() {
|
|||
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
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
|
|
@ -215,7 +198,7 @@ export default function Scanner() {
|
|||
</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 */}
|
||||
<div style={{ marginBottom: '56px' }}>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -191,11 +191,11 @@ function MemberCard({ member }: { member: (typeof team)[0] }) {
|
|||
export default function Team() {
|
||||
return (
|
||||
<div
|
||||
className="max-w-7xl mx-auto px-12 max-md:px-5"
|
||||
style={{
|
||||
maxWidth: 1280,
|
||||
margin: '0 auto',
|
||||
padding: '64px 48px',
|
||||
direction: 'rtl',
|
||||
paddingTop: 64,
|
||||
paddingBottom: 64,
|
||||
}}
|
||||
>
|
||||
{/* ─── Section header ────────────────────────────────── */}
|
||||
|
|
|
|||
Loading…
Reference in New Issue